diff --git a/CHANGELOG.md b/CHANGELOG.md index 8630e05..1132941 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -26,6 +26,18 @@ APIs change between minors without a deprecation period. ## Unreleased +### Added + +- **Picture and 3D models by themselves**: once a node has a card that fits + flux-schnell or triposr and the catalog makes nothing of that kind, the + leading control plane adds the preset on demand — so a new cluster's + portal has its Images and 3D pages without a definition written — where + the cluster installs ComfyUI. `presets.auto` turns it off; a preset + somebody removes is not added back. `GET /api/v1/presets` says how each + stands and why one is not added; `POST /api/v1/presets/{name}` adds one + (and sets `engines.comfyui` if the cluster has none); `PUT + /api/v1/presets/auto`. Each automatic addition is in the audit log. + ## v0.14.4 — 2026-10-13 Any ComfyUI workflow as a job, with typed inputs; picture and 3D models diff --git a/docs/admin-api.openapi.json b/docs/admin-api.openapi.json index 746e0f5..46efe7e 100644 --- a/docs/admin-api.openapi.json +++ b/docs/admin-api.openapi.json @@ -86,6 +86,9 @@ { "name": "plan" }, + { + "name": "presets" + }, { "name": "projects" }, @@ -3651,6 +3654,116 @@ } } }, + "/api/v1/presets": { + "get": { + "operationId": "getPresets", + "summary": "The models that come with llmgw: whether each fits this cluster, is in the catalog, was declined", + "tags": [ + "presets" + ], + "responses": { + "200": { + "description": "OK", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/PresetsView" + } + } + } + }, + "401": { + "$ref": "#/components/responses/Unauthorized" + }, + "default": { + "$ref": "#/components/responses/Error" + } + } + } + }, + "/api/v1/presets/auto": { + "put": { + "operationId": "putPresetsAuto", + "summary": "Turn on or off the adding of presets when a node fits them", + "tags": [ + "presets" + ], + "parameters": [ + { + "$ref": "#/components/parameters/Actor" + } + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/PresetsAutoRequest" + } + } + } + }, + "responses": { + "200": { + "description": "OK", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/PresetsView" + } + } + } + }, + "401": { + "$ref": "#/components/responses/Unauthorized" + }, + "default": { + "$ref": "#/components/responses/Error" + } + } + } + }, + "/api/v1/presets/{name}": { + "post": { + "operationId": "postPresetsName", + "summary": "Add a preset to the catalog, and clear its decline", + "tags": [ + "presets" + ], + "parameters": [ + { + "name": "name", + "in": "path", + "required": true, + "description": "The preset's name, as `llmgw ctl model presets` lists it.", + "schema": { + "type": "string" + } + }, + { + "$ref": "#/components/parameters/Actor" + } + ], + "responses": { + "200": { + "description": "OK", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ModelAdded" + } + } + } + }, + "401": { + "$ref": "#/components/responses/Unauthorized" + }, + "default": { + "$ref": "#/components/responses/Error" + } + } + } + }, "/api/v1/projects": { "post": { "operationId": "postProjects", @@ -7481,6 +7594,85 @@ } } }, + "PresetView": { + "type": "object", + "description": "PresetView is one preset as it stands in this cluster.", + "properties": { + "name": { + "type": "string" + }, + "makes": { + "type": "string" + }, + "description": { + "type": "string" + }, + "card": { + "type": "string" + }, + "licence": { + "$ref": "#/components/schemas/catalog.License" + }, + "fits": { + "type": "boolean", + "description": "Fits is whether some node has a card that meets the preset's need; FitsClasses names those classes." + }, + "fits_classes": { + "type": "array", + "items": { + "type": "string" + } + }, + "in_catalog": { + "type": "boolean" + }, + "added_by": { + "type": "string", + "description": "AddedBy is \"auto\" when the leader added it, who did otherwise, \"\" when it was not added as a preset; AddedAt is when." + }, + "added_at": { + "type": "string", + "format": "date-time" + }, + "declined": { + "type": "boolean", + "description": "Declined is whether somebody removed it after it was added, so it is not added again by itself; adding it again clears that." + }, + "blocked": { + "type": "string", + "description": "Blocked is why the leader does not add it by itself — presets.auto off, no ComfyUI, no card that fits, declined, another model already making the same — empty when it would or has." + } + } + }, + "PresetsAutoRequest": { + "type": "object", + "description": "PresetsAutoRequest turns the automatic adding of presets on or off.", + "properties": { + "on": { + "type": "boolean" + } + } + }, + "PresetsView": { + "type": "object", + "description": "PresetsView is the models that come with llmgw, and whether the leader adds them by itself.", + "properties": { + "auto": { + "type": "boolean", + "description": "Auto is whether a preset is added when a node fits it and the catalog has no model that makes what it makes (presets.auto)." + }, + "comfyui": { + "type": "string", + "description": "ComfyUI is engines.comfyui as it stands: the version the nodes install, \"\" for none — in which case adding a preset sets it, and the control planes start again on that." + }, + "presets": { + "type": "array", + "items": { + "$ref": "#/components/schemas/PresetView" + } + } + } + }, "ProjectView": { "type": "object", "description": "ProjectView is a project, with how many people and keys are in it.", diff --git a/docs/reference.md b/docs/reference.md index d63f5a8..921e9e6 100644 --- a/docs/reference.md +++ b/docs/reference.md @@ -244,6 +244,7 @@ rather than repeated. | `match_ratio` | `float64` | [Prefix](#prefix) | | `balance_abs` | `float64` | [Prefix](#prefix) | | `balance_rel` | `float64` | [Prefix](#prefix) | +| `auto` | `bool` | [PresetSettings](#presetsettings) | | `source` | `string` | [PreviousOrigin](#previousorigin) | | `revision` | `string` | [PreviousOrigin](#previousorigin) | | `files` | `[]OriginFile` | [PreviousOrigin](#previousorigin) | @@ -344,6 +345,7 @@ rather than repeated. | `mail` | `mail.Config` | [Server](#server) | | `serving` | `ServingSettings` | [Server](#server) | | `firewall` | `FirewallSettings` | [Server](#server) | +| `presets` | `PresetSettings` | [Server](#server) | | `agents` | `AgentSettings` | [Server](#server) | | `firewall_networks` | `[]string` | [Server](#server) | | `backup_recipients` | `[]string` | [Server](#server) | @@ -2158,6 +2160,20 @@ before affinity is abandoned. `float64` +## PresetSettings + +### `auto` + +`bool` + +Auto has the leading control plane add a picture model (flux-schnell) +and a 3D model (triposr) by itself, on demand, once a node has a card +that fits one and the catalog has no model that makes the same kind of +thing — so a new cluster's portal has its pictures and 3D pages +without anybody writing a definition. Only where the cluster already +installs ComfyUI (engines.comfyui). A preset somebody removed is not +added again by itself. In force on the leader within a minute. + ## PreviousOrigin ### `source` @@ -2960,6 +2976,14 @@ Firewall turns llmgw's firewall on every machine on or off. Takes a [FirewallSettings](#firewallsettings) block. +### `presets` + +`PresetSettings` + +Presets is how the models that come with llmgw reach the catalog. + +Takes a [PresetSettings](#presetsettings) block. + ### `agents` `AgentSettings` diff --git a/internal/cli/clustersettings.go b/internal/cli/clustersettings.go index 99c77bd..99d1f98 100644 --- a/internal/cli/clustersettings.go +++ b/internal/cli/clustersettings.go @@ -243,6 +243,8 @@ var liveSettings = []string{ "firewall", "firewall_networks", // Sent to the nodes' agents as they change. "agents", + // Read by the leader's presets pass each time it runs. + "presets", // Read by server init, node add and backups, not by the server. "backup_recipients", "backup_copy_to", } diff --git a/internal/cli/server.go b/internal/cli/server.go index 9b27618..e8d3f4e 100644 --- a/internal/cli/server.go +++ b/internal/cli/server.go @@ -465,6 +465,14 @@ func newServerCmd() *cobra.Command { } api.SetModels(svc) catalogSvc = svc + // The models that come with llmgw: added by the leader + // when a node fits one and the catalog makes nothing of + // its kind, unless somebody removed it or turned it off. + api.SetPresets(st, func() adminapi.PresetConfig { + c := current.Load() + return adminapi.PresetConfig{Auto: c.Presets.Auto, ComfyUI: c.Engines[catalog.EngineComfyUI]} + }) + go runPresetsPass(ctx, api, admin.Leadership(), log) // And the mirror page, so a model that has just been // fetched can be served without writing YAML by hand. rt.SetCatalogAdmin(svc) @@ -1281,3 +1289,32 @@ func embedder(replay func(ctx context.Context, path, model string, body []byte) return res.Data[0].Embedding, nil } } + +// presetsEvery is how often the leader looks for presets to add; the first +// look waits as long, for the nodes to have registered their cards. +const presetsEvery = time.Minute + +// runPresetsPass has the leader add the presets a node fits and the catalog +// lacks, now and then. +func runPresetsPass(ctx context.Context, api *adminapi.API, lead *control.Leadership, log *slog.Logger) { + t := time.NewTicker(presetsEvery) + defer t.Stop() + for { + select { + case <-ctx.Done(): + return + case <-t.C: + } + if lead != nil && !lead.IsLeader() { + continue + } + added, err := api.PresetsPass(ctx) + for _, name := range added { + log.Info("added a preset by itself: a node fits it and the catalog made nothing of its kind", + "model", name, "off_switch", "presets.auto") + } + if err != nil { + log.Warn("could not add a preset", "err", err) + } + } +} diff --git a/internal/config/config.go b/internal/config/config.go index de9b0fc..fd2412d 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -158,6 +158,9 @@ type Server struct { // Firewall turns llmgw's firewall on every machine on or off. Firewall FirewallSettings `koanf:"firewall"` + // Presets is how the models that come with llmgw reach the catalog. + Presets PresetSettings `koanf:"presets"` + // Agents are the nodes' agent settings: agent.yaml's keys but those an // agent needs to start, for every node (default) and for one // (nodes.). Sent to the agents as they change; an agent whose @@ -795,6 +798,7 @@ func Default() Server { MaxConnections: 10_000, Mirror: Mirror{UpdateCheck: 6 * time.Hour}, Firewall: FirewallSettings{Enabled: true}, + Presets: PresetSettings{Auto: true}, Control: Control{ Interval: 5 * time.Second, BaseBackoff: 10 * time.Second, diff --git a/internal/config/presets.go b/internal/config/presets.go new file mode 100644 index 0000000..702cb54 --- /dev/null +++ b/internal/config/presets.go @@ -0,0 +1,13 @@ +package config + +// PresetSettings is how the models that come with llmgw reach the catalog. +type PresetSettings struct { + // Auto has the leading control plane add a picture model (flux-schnell) + // and a 3D model (triposr) by itself, on demand, once a node has a card + // that fits one and the catalog has no model that makes the same kind of + // thing — so a new cluster's portal has its pictures and 3D pages + // without anybody writing a definition. Only where the cluster already + // installs ComfyUI (engines.comfyui). A preset somebody removed is not + // added again by itself. In force on the leader within a minute. + Auto bool `koanf:"auto"` +} diff --git a/internal/control/adminapi/api.go b/internal/control/adminapi/api.go index 371b93f..bba005d 100644 --- a/internal/control/adminapi/api.go +++ b/internal/control/adminapi/api.go @@ -81,11 +81,15 @@ type API struct { // revoke records a removed node, so its certificate is refused. revoke func(ctx context.Context, nodeID string, at time.Time) error - models Models - capacity Capacity - usage Usage - quotas Quotas - ctl *control.Controller + models Models + // presets keeps what became of each preset, presetCfg reads the + // settings in force; nil without a store. + presets PresetStore + presetCfg func() PresetConfig + capacity Capacity + usage Usage + quotas Quotas + ctl *control.Controller } // New builds the API over a controller. @@ -166,6 +170,7 @@ func (a *API) Register(mux Mux) { } writeJSON(w, http.StatusOK, t) }) + a.registerPresets(mux) mux.HandleFunc("GET /api/v1/models", a.listModels) mux.HandleFunc("GET /api/v1/replicas", a.listReplicas) // Listing is a read and deleting is a separate verb, so a mistyped @@ -1229,6 +1234,8 @@ func (a *API) removeModel(w http.ResponseWriter, r *http.Request) { writeError(w, http.StatusBadRequest, err.Error()) return } + // A preset removed is one the leader must not put back by itself. + a.presetDeclined(r.Context(), name, actorOf(r)) writeJSON(w, http.StatusOK, Removed{Removed: name}) } diff --git a/internal/control/adminapi/presets.go b/internal/control/adminapi/presets.go new file mode 100644 index 0000000..17454cb --- /dev/null +++ b/internal/control/adminapi/presets.go @@ -0,0 +1,353 @@ +package adminapi + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "net/http" + "strconv" + "strings" + "time" + + "gitea.mynodes.xyz/ilker/llmgw/internal/agent/discovery" + "gitea.mynodes.xyz/ilker/llmgw/internal/agent/provision" + "gitea.mynodes.xyz/ilker/llmgw/internal/control/catalog" + "gitea.mynodes.xyz/ilker/llmgw/internal/settings" + "gitea.mynodes.xyz/ilker/llmgw/internal/store" +) + +// PresetsView is the models that come with llmgw, and whether the leader adds +// them by itself. +type PresetsView struct { + // Auto is whether a preset is added when a node fits it and the catalog + // has no model that makes what it makes (presets.auto). + Auto bool `json:"auto"` + // ComfyUI is engines.comfyui as it stands: the version the nodes + // install, "" for none — in which case adding a preset sets it, and the + // control planes start again on that. + ComfyUI string `json:"comfyui"` + Presets []PresetView `json:"presets"` +} + +// PresetView is one preset as it stands in this cluster. +type PresetView struct { + Name string `json:"name"` + Makes string `json:"makes"` + Description string `json:"description"` + Card string `json:"card"` + Licence catalog.License `json:"licence"` + // Fits is whether some node has a card that meets the preset's need; + // FitsClasses names those classes. + Fits bool `json:"fits"` + FitsClasses []string `json:"fits_classes,omitempty"` + InCatalog bool `json:"in_catalog"` + // AddedBy is "auto" when the leader added it, who did otherwise, "" + // when it was not added as a preset; AddedAt is when. + AddedBy string `json:"added_by,omitempty"` + AddedAt time.Time `json:"added_at,omitzero"` + // Declined is whether somebody removed it after it was added, so it is + // not added again by itself; adding it again clears that. + Declined bool `json:"declined"` + // Blocked is why the leader does not add it by itself — presets.auto + // off, no ComfyUI, no card that fits, declined, another model already + // making the same — empty when it would or has. + Blocked string `json:"blocked,omitempty"` +} + +// PresetsAutoRequest turns the automatic adding of presets on or off. +type PresetsAutoRequest struct { + On bool `json:"on"` +} + +// errPresetsUnavailable is a control plane without the store presets are +// kept in. +var errPresetsUnavailable = errors.New("this control plane has no store, so presets cannot be added here") + +// PresetStore keeps what became of each preset, and the audit log the +// leader writes its own additions to. +type PresetStore interface { + PresetStates(ctx context.Context) (map[string]store.PresetState, error) + PresetAdded(ctx context.Context, name, by string, at time.Time) error + PresetDeclined(ctx context.Context, name, by string, at time.Time) error + WriteAudit(ctx context.Context, e store.AuditEvent) error +} + +// PresetConfig is what the presets read of the settings in force. +type PresetConfig struct { + // Auto is presets.auto. + Auto bool + // ComfyUI is engines.comfyui: the version the nodes install, empty for + // none. + ComfyUI string +} + +// SetPresets enables the presets: where what became of them is kept, and +// the settings in force. +func (a *API) SetPresets(st PresetStore, cfg func() PresetConfig) { + a.presets, a.presetCfg = st, cfg +} + +// Presets is the presets as they stand in this cluster. +func (a *API) Presets(ctx context.Context) (PresetsView, error) { + if a.presets == nil || a.presetCfg == nil { + return PresetsView{}, errPresetsUnavailable + } + states, err := a.presets.PresetStates(ctx) + if err != nil { + return PresetsView{}, err + } + cfg := a.presetCfg() + cat := a.ctl.Catalog() + kinds := a.gpuKinds() + out := PresetsView{Auto: cfg.Auto, ComfyUI: cfg.ComfyUI} + for _, p := range catalog.Presets() { + v := PresetView{Name: p.Model.Name, Makes: p.Makes, Description: p.Model.Description, + Card: presetCard(p.Model), FitsClasses: presetFits(p.Model, kinds)} + if p.Model.License != nil { + v.Licence = *p.Model.License + } + v.Fits = len(v.FitsClasses) > 0 + _, v.InCatalog = cat.Resolve(p.Model.Name) + if st, ok := states[p.Model.Name]; ok { + v.AddedBy, v.AddedAt, v.Declined = st.AddedBy, st.AddedAt, st.Declined + } + v.Blocked = presetBlocked(p, v, cfg, cat) + out.Presets = append(out.Presets, v) + } + return out, nil +} + +// AddPreset adds the preset of that name, and clears its decline. A cluster +// that installs no ComfyUI yet is set to (engines.comfyui), as `llmgw ctl +// model add` does; that change starts the control planes again. +func (a *API) AddPreset(ctx context.Context, name, by string) error { + if a.presets == nil || a.presetCfg == nil || a.models == nil { + return errPresetsUnavailable + } + p, ok := catalog.PresetNamed(name) + if !ok { + return fmt.Errorf("%w: %q (there are %s)", errNoSuchPreset, name, strings.Join(catalog.PresetNames(), ", ")) + } + if _, in := a.ctl.Catalog().Resolve(name); in { + return fmt.Errorf("%s is already in the catalog", name) + } + if a.presetCfg().ComfyUI == "" { + if err := a.setComfyUI(ctx, by); err != nil { + return err + } + } + if err := a.models.Add(ctx, p.Model, by); err != nil { + return err + } + return a.presets.PresetAdded(ctx, name, by, time.Now()) +} + +// SetAutoPresets turns presets.auto on or off. +func (a *API) SetAutoPresets(ctx context.Context, on bool, by string) error { + if a.presets == nil { + return errPresetsUnavailable + } + note := "presets added by themselves: " + map[bool]string{true: "on", false: "off"}[on] + return a.editSettings(ctx, []string{"presets", "auto"}, strconv.FormatBool(on), by, note) +} + +// PresetsPass adds, as "auto", each preset a node fits whose kind of model +// the catalog has none of: run by the leader, now and then. It adds nothing +// with presets.auto off, where the cluster installs no ComfyUI, or a preset +// somebody declined. It says what it added. +func (a *API) PresetsPass(ctx context.Context) ([]string, error) { + v, err := a.Presets(ctx) + if err != nil || !v.Auto { + return nil, err + } + var added []string + for _, pv := range v.Presets { + if pv.Blocked != "" || pv.InCatalog { + continue + } + p, _ := catalog.PresetNamed(pv.Name) + if err := a.models.Add(ctx, p.Model, store.PresetAdder); err != nil { + return added, fmt.Errorf("adding %s: %w", pv.Name, err) + } + now := time.Now() + if err := a.presets.PresetAdded(ctx, pv.Name, store.PresetAdder, now); err != nil { + return added, err + } + _ = a.presets.WriteAudit(ctx, store.AuditEvent{At: now, Actor: store.PresetAdder, Source: "presets", + Action: "add preset", Target: pv.Name, Status: http.StatusOK, + Detail: fmt.Sprintf("%s, on demand: a node has %s (%s) and the catalog made no %s", + pv.Name, strings.Join(pv.FitsClasses, ", "), pv.Card, pv.Makes)}) + added = append(added, pv.Name) + } + return added, nil +} + +// presetDeclined records a preset's name removed from the catalog, so the +// leader does not put it back. +func (a *API) presetDeclined(ctx context.Context, name, by string) { + if a.presets == nil { + return + } + if _, ok := catalog.PresetNamed(name); ok { + _ = a.presets.PresetDeclined(ctx, name, by, time.Now()) + } +} + +// presetBlocked is why the leader would not add a preset by itself, empty +// when it would (or has). +func presetBlocked(p catalog.Preset, v PresetView, cfg PresetConfig, cat *catalog.Catalog) string { + switch { + case v.InCatalog: + return "" + case v.Declined: + return "removed from the catalog after it was added; add it again to undo" + case !cfg.Auto: + return "presets.auto is off" + case cfg.ComfyUI == "": + return "the cluster installs no ComfyUI (engines.comfyui); adding it by hand sets that" + case !v.Fits: + return "no node has a card it needs (" + v.Card + ")" + } + for _, m := range cat.Models() { + if p.Makes == catalog.OutputPictures && m.MakesImages() || p.Makes == catalog.OutputMeshes && m.MakesMeshes() { + return "the catalog has " + m.Name + ", which makes " + p.Makes + " already" + } + } + return "" +} + +// presetCard is what a preset needs of a card, as people read it. +func presetCard(m catalog.Model) string { + var needs []string + for _, pr := range m.Profiles { + switch { + case pr.GPU != nil: + needs = append(needs, pr.GPU.String()) + case pr.GPUClass != "": + needs = append(needs, pr.GPUClass) + } + } + return strings.Join(needs, " or ") +} + +// presetFits are the classes of card in the cluster one of the preset's +// profiles can run on. +func presetFits(m catalog.Model, kinds []catalog.GPUKind) []string { + var out []string + for _, k := range kinds { + for _, pr := range m.Profiles { + if pr.GPU != nil && pr.GPU.Fits(k) || pr.GPU == nil && pr.GPUClass == k.Class { + out = append(out, k.Class) + break + } + } + } + return out +} + +// gpuKinds are the kinds of card the cluster's nodes have. +func (a *API) gpuKinds() []catalog.GPUKind { + var gpus []discovery.GPU + for _, n := range a.ctl.Nodes() { + gpus = append(gpus, n.GPUs...) + } + return catalog.GPUKinds(gpus) +} + +// setComfyUI has the nodes install the ComfyUI this release knows. +func (a *API) setComfyUI(ctx context.Context, by string) error { + if a.settings != nil { + cur, err := a.settings.Store.ClusterSettings(ctx) + if err != nil { + return err + } + if doc, err := settings.FromBytes("settings", []byte(cur.Body)); err == nil { + if n := doc.Node([]string{"engines", catalog.EngineComfyUI}); n != nil && n.Value == "" { + return errors.New("engines.comfyui is set to none, so the nodes install no ComfyUI: " + + "set a version (llmgw ctl config set engines.comfyui " + provision.ComfyUIVersion() + ")") + } + } + } + return a.editSettings(ctx, []string{"engines", catalog.EngineComfyUI}, provision.ComfyUIVersion(), by, + "ComfyUI for the presets") +} + +// editSettings sets one key of the cluster's settings, as a new version. +func (a *API) editSettings(ctx context.Context, path []string, value, by, note string) error { + if a.settings == nil { + return errors.New("this control plane keeps no cluster settings") + } + cur, err := a.settings.Store.ClusterSettings(ctx) + if err != nil { + return err + } + doc, err := settings.FromBytes("settings", []byte(cur.Body)) + if err != nil { + return err + } + if n := doc.Node(path); n != nil && n.Value == value { + return nil + } + if err := doc.SetYAML(path, value); err != nil { + return err + } + b, err := doc.Bytes() + if err != nil { + return err + } + if err := a.settings.Check(b); err != nil { + return err + } + _, err = a.settings.Store.PutClusterSettings(ctx, cur.Version, string(b), by, note) + return err +} + +func (a *API) registerPresets(mux Mux) { + mux.HandleFunc("GET /api/v1/presets", func(w http.ResponseWriter, r *http.Request) { + v, err := a.Presets(r.Context()) + if err != nil { + writeError(w, presetStatus(err), err.Error()) + return + } + writeJSON(w, http.StatusOK, v) + }) + mux.HandleFunc("POST /api/v1/presets/{name}", func(w http.ResponseWriter, r *http.Request) { + if err := a.AddPreset(r.Context(), r.PathValue("name"), actorOf(r)); err != nil { + writeError(w, presetStatus(err), err.Error()) + return + } + writeJSON(w, http.StatusOK, ModelAdded{Added: r.PathValue("name")}) + }) + mux.HandleFunc("PUT /api/v1/presets/auto", func(w http.ResponseWriter, r *http.Request) { + var req PresetsAutoRequest + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + writeError(w, http.StatusBadRequest, err.Error()) + return + } + if err := a.SetAutoPresets(r.Context(), req.On, actorOf(r)); err != nil { + writeError(w, presetStatus(err), err.Error()) + return + } + v, err := a.Presets(r.Context()) + if err != nil { + writeError(w, presetStatus(err), err.Error()) + return + } + writeJSON(w, http.StatusOK, v) + }) +} + +// errNoSuchPreset is a name no preset has. +var errNoSuchPreset = errors.New("no preset of that name") + +func presetStatus(err error) int { + switch { + case errors.Is(err, errPresetsUnavailable): + return http.StatusNotImplemented + case errors.Is(err, errNoSuchPreset): + return http.StatusNotFound + default: + return http.StatusBadRequest + } +} diff --git a/internal/control/adminapi/presets_test.go b/internal/control/adminapi/presets_test.go new file mode 100644 index 0000000..f784fc0 --- /dev/null +++ b/internal/control/adminapi/presets_test.go @@ -0,0 +1,141 @@ +package adminapi + +import ( + "net/http" + "path/filepath" + "slices" + "strings" + "testing" + + "gitea.mynodes.xyz/ilker/llmgw/internal/agent/discovery" + "gitea.mynodes.xyz/ilker/llmgw/internal/control/catalog" + "gitea.mynodes.xyz/ilker/llmgw/internal/control/inventory" + "gitea.mynodes.xyz/ilker/llmgw/internal/store" +) + +func v100Node(id string) inventory.Node { + return inventory.Node{ID: id, State: inventory.Ready, AgentVersion: "v1", GPUs: []discovery.GPU{{ + Index: 0, Name: "Tesla V100-SXM2-32GB", MemoryTotalMB: 32768, ComputeCapability: "7.0"}}} +} + +func presetsAPI(t *testing.T, models []catalog.Model, cfg PresetConfig, nodes ...inventory.Node) (*API, *fakeModels, *store.DB) { + t.Helper() + a, _ := setup(t, models, nodes...) + st, err := store.OpenSQLite(t.Context(), filepath.Join(t.TempDir(), "p.db")) + if err != nil { + t.Fatal(err) + } + t.Cleanup(func() { _ = st.Close() }) + fm := &fakeModels{} + a.SetModels(fm) + a.SetPresets(st, func() PresetConfig { return cfg }) + a.SetSettings(&Settings{Store: st, Check: func([]byte) error { return nil }}) + return a, fm, st +} + +func TestTheLeaderAddsThePresetsANodeFitsAndNoOtherWhere(t *testing.T) { + // A customer's portal had no pictures or 3D pages: nobody had written a + // definition for either, on a cluster with a V100 that runs both. + a, fm, st := presetsAPI(t, testModels(), PresetConfig{Auto: true, ComfyUI: "0.39.2"}, v100Node("p-g3")) + added, err := a.PresetsPass(t.Context()) + if err != nil || !slices.Equal(added, []string{"flux-schnell", "triposr"}) { + t.Fatalf("added %v %v", added, err) + } + if !slices.Equal(fm.actors, []string{store.PresetAdder, store.PresetAdder}) { + t.Errorf("added by %v", fm.actors) + } + states, _ := st.PresetStates(t.Context()) + if states["triposr"].AddedBy != store.PresetAdder || states["triposr"].AddedAt.IsZero() { + t.Errorf("state %+v", states["triposr"]) + } + events, _ := st.AuditEvents(t.Context(), store.AuditFilter{Limit: 10}) + if len(events) != 2 || events[0].Actor != store.PresetAdder || !strings.Contains(events[0].Detail, "v100-32g") { + t.Errorf("audit %+v", events) + } +} + +func TestAPresetIsNotAddedByItselfWhereItShouldNotBe(t *testing.T) { + picture := catalog.Model{Name: "sdxl", Source: "/w/sdxl.safetensors", ComfyUI: &catalog.ComfyUI{Workflow: "flux-schnell"}, + Profiles: []catalog.Profile{{Name: "p", Engine: catalog.EngineComfyUI, GPUClass: "v100-32g", TP: 1}}} + cases := []struct { + name string + models []catalog.Model + cfg PresetConfig + nodes []inventory.Node + want map[string]string // preset -> part of why it is not added + }{ + {"auto off", testModels(), PresetConfig{Auto: false, ComfyUI: "0.39.2"}, []inventory.Node{v100Node("g")}, + map[string]string{"flux-schnell": "presets.auto is off", "triposr": "presets.auto is off"}}, + {"no ComfyUI", testModels(), PresetConfig{Auto: true}, []inventory.Node{v100Node("g")}, + map[string]string{"triposr": "engines.comfyui"}}, + {"no card", testModels(), PresetConfig{Auto: true, ComfyUI: "0.39.2"}, nil, + map[string]string{"triposr": "no node has a card"}}, + {"a picture model already", append(testModels(), picture), PresetConfig{Auto: true, ComfyUI: "0.39.2"}, + []inventory.Node{v100Node("g")}, map[string]string{"flux-schnell": "sdxl"}}, + } + for _, c := range cases { + t.Run(c.name, func(t *testing.T) { + a, fm, _ := presetsAPI(t, c.models, c.cfg, c.nodes...) + v, err := a.Presets(t.Context()) + if err != nil { + t.Fatal(err) + } + if v.ComfyUI != c.cfg.ComfyUI { + t.Errorf("comfyui %q", v.ComfyUI) + } + for _, p := range v.Presets { + if why, ok := c.want[p.Name]; ok && !strings.Contains(p.Blocked, why) { + t.Errorf("%s blocked %q, want it to say %q", p.Name, p.Blocked, why) + } + } + _, _ = a.PresetsPass(t.Context()) + for _, m := range fm.added { + if _, blocked := c.want[m.Name]; blocked { + t.Errorf("%s was added", m.Name) + } + } + }) + } +} + +func TestAPresetRemovedStaysOutUntilAddedAgain(t *testing.T) { + a, fm, _ := presetsAPI(t, testModels(), PresetConfig{Auto: true, ComfyUI: "0.39.2"}, v100Node("g")) + if code := call(t, a, http.MethodDelete, "/api/v1/models/triposr", "", nil); code != http.StatusOK { + t.Fatalf("remove %d", code) + } + var v PresetsView + call(t, a, http.MethodGet, "/api/v1/presets", "", &v) + i := slices.IndexFunc(v.Presets, func(p PresetView) bool { return p.Name == "triposr" }) + if i < 0 || !v.Presets[i].Declined || !strings.Contains(v.Presets[i].Blocked, "removed") { + t.Fatalf("after removal %+v", v.Presets) + } + added, _ := a.PresetsPass(t.Context()) + if slices.Contains(added, "triposr") { + t.Error("a removed preset was added back by itself") + } + if code := call(t, a, http.MethodPost, "/api/v1/presets/triposr", "", nil); code != http.StatusOK { + t.Fatalf("add again %d", code) + } + if fm.added[len(fm.added)-1].Name != "triposr" { + t.Error("adding it again did not add it") + } + call(t, a, http.MethodGet, "/api/v1/presets", "", &v) + i = slices.IndexFunc(v.Presets, func(p PresetView) bool { return p.Name == "triposr" }) + if v.Presets[i].Declined { + t.Error("adding it again left it declined") + } + if code := call(t, a, http.MethodPost, "/api/v1/presets/nope", "", nil); code != http.StatusNotFound { + t.Errorf("unknown preset %d", code) + } +} + +func TestPresetsAutoIsTheClusterSetting(t *testing.T) { + a, _, st := presetsAPI(t, testModels(), PresetConfig{Auto: true, ComfyUI: "0.39.2"}) + if code := call(t, a, http.MethodPut, "/api/v1/presets/auto", `{"on":false}`, nil); code != http.StatusOK { + t.Fatalf("put %d", code) + } + cur, _ := st.ClusterSettings(t.Context()) + if !strings.Contains(cur.Body, "auto: false") { + t.Errorf("settings %q", cur.Body) + } +} diff --git a/internal/control/adminapi/routes.go b/internal/control/adminapi/routes.go index dd3045a..03a977a 100644 --- a/internal/control/adminapi/routes.go +++ b/internal/control/adminapi/routes.go @@ -65,6 +65,7 @@ type Param struct { // before them and their name. var PathParams = map[string]string{ "models/{name}": "The model's name in the catalog.", + "presets/{name}": "The preset's name, as `llmgw ctl model presets` lists it.", "nodes/{id}": "The node's ID, as `llmgw ctl nodes` lists it.", "jobs/{id}": "The job's ID, as starting it answered.", "runs/{id}": "The quality run's ID, as starting it answered.", @@ -136,6 +137,12 @@ var Routes = []Route{ Request: CollectRequest{}, OptionalBody: true, Response: []CollectResult{}}, // The catalog. + {Method: "GET", Path: "/api/v1/presets", Summary: "The models that come with llmgw: whether each fits this cluster, is in the catalog, was declined", + Response: PresetsView{}}, + {Method: "POST", Path: "/api/v1/presets/{name}", Summary: "Add a preset to the catalog, and clear its decline", + Response: ModelAdded{}}, + {Method: "PUT", Path: "/api/v1/presets/auto", Summary: "Turn on or off the adding of presets when a node fits them", + Request: PresetsAutoRequest{}, Response: PresetsView{}}, {Method: "GET", Path: "/api/v1/models", Summary: "Every model, with its replicas and state", Response: []ModelView{}}, {Method: "POST", Path: "/api/v1/models", Summary: "Add a model to the catalog", diff --git a/internal/docgen/reference_gen.go b/internal/docgen/reference_gen.go index 18aca25..bfa2a4c 100644 --- a/internal/docgen/reference_gen.go +++ b/internal/docgen/reference_gen.go @@ -313,6 +313,9 @@ var Reference = []Block{ {Key: "balance_abs", Type: "float64", Doc: "", Nested: ""}, {Key: "balance_rel", Type: "float64", Doc: "", Nested: ""}, }}, + {Package: "config", Name: "PresetSettings", Title: "PresetSettings", Doc: "", Fields: []Field{ + {Key: "auto", Type: "bool", Doc: "Auto has the leading control plane add a picture model (flux-schnell)\nand a 3D model (triposr) by itself, on demand, once a node has a card\nthat fits one and the catalog has no model that makes the same kind of\nthing — so a new cluster's portal has its pictures and 3D pages\nwithout anybody writing a definition. Only where the cluster already\ninstalls ComfyUI (engines.comfyui). A preset somebody removed is not\nadded again by itself. In force on the leader within a minute.", Nested: ""}, + }}, {Package: "catalog", Name: "PreviousOrigin", Title: "PreviousOrigin", Doc: "", Fields: []Field{ {Key: "source", Type: "string", Doc: "", Nested: ""}, {Key: "revision", Type: "string", Doc: "", Nested: ""}, @@ -438,6 +441,7 @@ var Reference = []Block{ {Key: "mail", Type: "mail.Config", Doc: "Mail is where the gateway's few emails go out from: a link to set a\nforgotten password. Unset, none is sent and people ask an operator.", Nested: "mail.Config"}, {Key: "serving", Type: "ServingSettings", Doc: "Serving stops and starts the whole cluster's service (llmgw stop, llmgw\nstart).", Nested: "config.ServingSettings"}, {Key: "firewall", Type: "FirewallSettings", Doc: "Firewall turns llmgw's firewall on every machine on or off.", Nested: "config.FirewallSettings"}, + {Key: "presets", Type: "PresetSettings", Doc: "Presets is how the models that come with llmgw reach the catalog.", Nested: "config.PresetSettings"}, {Key: "agents", Type: "AgentSettings", Doc: "Agents are the nodes' agent settings: agent.yaml's keys but those an\nagent needs to start, for every node (default) and for one\n(nodes.). Sent to the agents as they change; an agent whose\nsettings changed starts again on them, its engines serving\nthroughout.", Nested: "config.AgentSettings"}, {Key: "firewall_networks", Type: "[]string", Doc: "FirewallNetworks are networks, besides those each machine reaches the\ncluster on, that llmgw's internal ports answer: a monitoring host on\nanother subnet, nodes behind a router. Written into every machine's\nfirewall by `server init`, `node add` and `server add`.", Nested: ""}, {Key: "backup_recipients", Type: "[]string", Doc: "BackupRecipients encrypt every archive `llmgw server backup` writes, to\nwhoever holds one of their keys: age recipients (age1…) or SSH public\nkeys (ssh-ed25519 …, ssh-rsa …), as `age` takes them. An archive holds\nthe certificate authority's key and every secret in the store, and is\ncopied off the machine; encrypted, the copy is safe wherever it lands.\nEmpty writes archives unencrypted, readable by root only.", Nested: ""}, diff --git a/internal/docgen/render.go b/internal/docgen/render.go index e9c3974..106adeb 100644 --- a/internal/docgen/render.go +++ b/internal/docgen/render.go @@ -35,6 +35,7 @@ var Sources = []string{ "internal/config/oidc.go", "internal/config/firewall.go", "internal/config/serving.go", + "internal/config/presets.go", "internal/config/agents.go", "internal/mail/mail.go", "internal/notify/notify.go", diff --git a/internal/store/copy.go b/internal/store/copy.go index 3c1530e..93e8829 100644 --- a/internal/store/copy.go +++ b/internal/store/copy.go @@ -27,6 +27,7 @@ var copyOrder = []string{ "memberships", // After users, which it points at. "user_excluded_licences", + "preset_state", "api_keys", "key_claims", "sessions", diff --git a/internal/store/migrations/postgres/0047_preset_state.sql b/internal/store/migrations/postgres/0047_preset_state.sql new file mode 100644 index 0000000..dd9a53c --- /dev/null +++ b/internal/store/migrations/postgres/0047_preset_state.sql @@ -0,0 +1,12 @@ +-- Ported from the SQLite migration of the same number. +-- The models that come with llmgw, as they stand in this cluster: who added +-- one and when ("auto" for the leader), and whether somebody removed it +-- since, so that the leader does not add it back by itself. +CREATE TABLE preset_state ( + name TEXT PRIMARY KEY, + added_by TEXT NOT NULL DEFAULT '', + added_at BIGINT NOT NULL DEFAULT 0, + declined BIGINT NOT NULL DEFAULT 0, + declined_by TEXT NOT NULL DEFAULT '', + declined_at BIGINT NOT NULL DEFAULT 0 +); diff --git a/internal/store/migrations/sqlite/0047_preset_state.sql b/internal/store/migrations/sqlite/0047_preset_state.sql new file mode 100644 index 0000000..491e1cb --- /dev/null +++ b/internal/store/migrations/sqlite/0047_preset_state.sql @@ -0,0 +1,11 @@ +-- The models that come with llmgw, as they stand in this cluster: who added +-- one and when ("auto" for the leader), and whether somebody removed it +-- since, so that the leader does not add it back by itself. +CREATE TABLE preset_state ( + name TEXT PRIMARY KEY, + added_by TEXT NOT NULL DEFAULT '', + added_at INTEGER NOT NULL DEFAULT 0, + declined INTEGER NOT NULL DEFAULT 0, + declined_by TEXT NOT NULL DEFAULT '', + declined_at INTEGER NOT NULL DEFAULT 0 +); diff --git a/internal/store/presets.go b/internal/store/presets.go new file mode 100644 index 0000000..bedf0ed --- /dev/null +++ b/internal/store/presets.go @@ -0,0 +1,72 @@ +package store + +import ( + "context" + "time" +) + +// PresetState is one preset as it stands in this cluster. +type PresetState struct { + Name string + // AddedBy is "auto" when the leader added it, who did otherwise. + AddedBy string + AddedAt time.Time + // Declined is somebody removing it after it was added: the leader does + // not add it back by itself. + Declined bool + DeclinedBy string + DeclinedAt time.Time +} + +// PresetAdder is the AddedBy of a preset the leader added by itself. +const PresetAdder = "auto" + +// PresetStates is every preset that was ever added or declined, by name. +func (s *DB) PresetStates(ctx context.Context) (map[string]PresetState, error) { + rows, err := s.db.QueryContext(ctx, + `SELECT name, added_by, added_at, declined, declined_by, declined_at FROM preset_state`) + if err != nil { + return nil, err + } + defer func() { _ = rows.Close() }() + out := map[string]PresetState{} + for rows.Next() { + var p PresetState + var added, declinedAt int64 + var declined int + if err := rows.Scan(&p.Name, &p.AddedBy, &added, &declined, &p.DeclinedBy, &declinedAt); err != nil { + return nil, err + } + p.AddedAt, p.Declined, p.DeclinedAt = presetTime(added), declined != 0, presetTime(declinedAt) + out[p.Name] = p + } + return out, rows.Err() +} + +// PresetAdded records a preset added, by whom, and clears its decline. +func (s *DB) PresetAdded(ctx context.Context, name, by string, at time.Time) error { + _, err := s.db.ExecContext(ctx, + `INSERT INTO preset_state (name, added_by, added_at, declined, declined_by, declined_at) VALUES (?, ?, ?, 0, '', 0) + ON CONFLICT (name) DO UPDATE SET added_by = excluded.added_by, added_at = excluded.added_at, + declined = 0, declined_by = '', declined_at = 0`, + name, by, at.UnixMilli()) + return err +} + +// PresetDeclined records a preset removed after it was added, so the leader +// does not add it back. +func (s *DB) PresetDeclined(ctx context.Context, name, by string, at time.Time) error { + _, err := s.db.ExecContext(ctx, + `INSERT INTO preset_state (name, declined, declined_by, declined_at) VALUES (?, 1, ?, ?) + ON CONFLICT (name) DO UPDATE SET declined = 1, declined_by = excluded.declined_by, declined_at = excluded.declined_at`, + name, by, at.UnixMilli()) + return err +} + +// presetTime is a stored instant, zero for none. +func presetTime(ms int64) time.Time { + if ms == 0 { + return time.Time{} + } + return fromMillis(ms) +} diff --git a/internal/store/presets_test.go b/internal/store/presets_test.go new file mode 100644 index 0000000..a791ef6 --- /dev/null +++ b/internal/store/presets_test.go @@ -0,0 +1,39 @@ +package store + +import ( + "testing" + "time" +) + +func TestAPresetDeclinedStaysSoUntilAddedAgain(t *testing.T) { + db := openDB(t) + ctx := t.Context() + at := time.Date(2026, 10, 13, 12, 0, 0, 0, time.UTC) + if err := db.PresetAdded(ctx, "triposr", PresetAdder, at); err != nil { + t.Fatal(err) + } + if err := db.PresetDeclined(ctx, "triposr", "ops@example.com", at.Add(time.Hour)); err != nil { + t.Fatal(err) + } + // Removed before it was ever added as a preset: declined all the same. + if err := db.PresetDeclined(ctx, "flux-schnell", "ops@example.com", at); err != nil { + t.Fatal(err) + } + got, err := db.PresetStates(ctx) + if err != nil { + t.Fatal(err) + } + if p := got["triposr"]; p.AddedBy != PresetAdder || !p.AddedAt.Equal(at) || !p.Declined || p.DeclinedBy != "ops@example.com" { + t.Errorf("triposr %+v", p) + } + if p := got["flux-schnell"]; !p.Declined || p.AddedBy != "" || !p.AddedAt.IsZero() { + t.Errorf("flux %+v", p) + } + if err := db.PresetAdded(ctx, "triposr", "ops@example.com", at.Add(2*time.Hour)); err != nil { + t.Fatal(err) + } + got, _ = db.PresetStates(ctx) + if p := got["triposr"]; p.Declined || p.AddedBy != "ops@example.com" || !p.DeclinedAt.IsZero() { + t.Errorf("added again, %+v", p) + } +}