Compare commits

...
Author SHA1 Message Date
Ilker ManapandClaude Opus 5.5 e0adcc5c4e The presets view says the ComfyUI the cluster installs
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-13 19:23:56 +02:00
Ilker ManapandClaude Opus 5.5 a1974c2b45 The leader adds the picture and 3D presets a node fits, unless told not to
A customer's portal had no Images or 3D pages: nobody had written a
definition for either, on a cluster whose V100 runs both. The leading
control plane now adds flux-schnell and triposr, on demand, once a node
has a card that fits one, the cluster installs ComfyUI, and the catalog
makes nothing of that kind. presets.auto (a live setting, on by default)
turns it off; a preset somebody removes is recorded as declined and not
added back until added again by hand. Each addition is in the audit log.

GET /api/v1/presets says how each preset stands and why one is not added,
POST /api/v1/presets/{name} adds one, PUT /api/v1/presets/auto switches it.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-13 16:31:08 +02:00
Ilker ManapandClaude Opus 5.5 92b588452e wip: the presets API's shape, answering nothing yet
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-13 16:14:55 +02:00
18 changed files with 937 additions and 5 deletions

No files matched your search

+12
View File
@@ -26,6 +26,18 @@ APIs change between minors without a deprecation period.
## Unreleased ## 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 ## v0.14.4 — 2026-10-13
Any ComfyUI workflow as a job, with typed inputs; picture and 3D models Any ComfyUI workflow as a job, with typed inputs; picture and 3D models
+192
View File
@@ -86,6 +86,9 @@
{ {
"name": "plan" "name": "plan"
}, },
{
"name": "presets"
},
{ {
"name": "projects" "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": { "/api/v1/projects": {
"post": { "post": {
"operationId": "postProjects", "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": { "ProjectView": {
"type": "object", "type": "object",
"description": "ProjectView is a project, with how many people and keys are in it.", "description": "ProjectView is a project, with how many people and keys are in it.",
+24
View File
@@ -244,6 +244,7 @@ rather than repeated.
| `match_ratio` | `float64` | [Prefix](#prefix) | | `match_ratio` | `float64` | [Prefix](#prefix) |
| `balance_abs` | `float64` | [Prefix](#prefix) | | `balance_abs` | `float64` | [Prefix](#prefix) |
| `balance_rel` | `float64` | [Prefix](#prefix) | | `balance_rel` | `float64` | [Prefix](#prefix) |
| `auto` | `bool` | [PresetSettings](#presetsettings) |
| `source` | `string` | [PreviousOrigin](#previousorigin) | | `source` | `string` | [PreviousOrigin](#previousorigin) |
| `revision` | `string` | [PreviousOrigin](#previousorigin) | | `revision` | `string` | [PreviousOrigin](#previousorigin) |
| `files` | `[]OriginFile` | [PreviousOrigin](#previousorigin) | | `files` | `[]OriginFile` | [PreviousOrigin](#previousorigin) |
@@ -344,6 +345,7 @@ rather than repeated.
| `mail` | `mail.Config` | [Server](#server) | | `mail` | `mail.Config` | [Server](#server) |
| `serving` | `ServingSettings` | [Server](#server) | | `serving` | `ServingSettings` | [Server](#server) |
| `firewall` | `FirewallSettings` | [Server](#server) | | `firewall` | `FirewallSettings` | [Server](#server) |
| `presets` | `PresetSettings` | [Server](#server) |
| `agents` | `AgentSettings` | [Server](#server) | | `agents` | `AgentSettings` | [Server](#server) |
| `firewall_networks` | `[]string` | [Server](#server) | | `firewall_networks` | `[]string` | [Server](#server) |
| `backup_recipients` | `[]string` | [Server](#server) | | `backup_recipients` | `[]string` | [Server](#server) |
@@ -2158,6 +2160,20 @@ before affinity is abandoned.
`float64` `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 ## PreviousOrigin
### `source` ### `source`
@@ -2960,6 +2976,14 @@ Firewall turns llmgw's firewall on every machine on or off.
Takes a [FirewallSettings](#firewallsettings) block. 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` ### `agents`
`AgentSettings` `AgentSettings`
+2
View File
@@ -243,6 +243,8 @@ var liveSettings = []string{
"firewall", "firewall_networks", "firewall", "firewall_networks",
// Sent to the nodes' agents as they change. // Sent to the nodes' agents as they change.
"agents", "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. // Read by server init, node add and backups, not by the server.
"backup_recipients", "backup_copy_to", "backup_recipients", "backup_copy_to",
} }
+37
View File
@@ -465,6 +465,14 @@ func newServerCmd() *cobra.Command {
} }
api.SetModels(svc) api.SetModels(svc)
catalogSvc = 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 // And the mirror page, so a model that has just been
// fetched can be served without writing YAML by hand. // fetched can be served without writing YAML by hand.
rt.SetCatalogAdmin(svc) 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 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)
}
}
}
+4
View File
@@ -158,6 +158,9 @@ type Server struct {
// Firewall turns llmgw's firewall on every machine on or off. // Firewall turns llmgw's firewall on every machine on or off.
Firewall FirewallSettings `koanf:"firewall"` 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 // Agents are the nodes' agent settings: agent.yaml's keys but those an
// agent needs to start, for every node (default) and for one // agent needs to start, for every node (default) and for one
// (nodes.<id>). Sent to the agents as they change; an agent whose // (nodes.<id>). Sent to the agents as they change; an agent whose
@@ -795,6 +798,7 @@ func Default() Server {
MaxConnections: 10_000, MaxConnections: 10_000,
Mirror: Mirror{UpdateCheck: 6 * time.Hour}, Mirror: Mirror{UpdateCheck: 6 * time.Hour},
Firewall: FirewallSettings{Enabled: true}, Firewall: FirewallSettings{Enabled: true},
Presets: PresetSettings{Auto: true},
Control: Control{ Control: Control{
Interval: 5 * time.Second, Interval: 5 * time.Second,
BaseBackoff: 10 * time.Second, BaseBackoff: 10 * time.Second,
+13
View File
@@ -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"`
}
+12 -5
View File
@@ -81,11 +81,15 @@ type API struct {
// revoke records a removed node, so its certificate is refused. // revoke records a removed node, so its certificate is refused.
revoke func(ctx context.Context, nodeID string, at time.Time) error revoke func(ctx context.Context, nodeID string, at time.Time) error
models Models models Models
capacity Capacity // presets keeps what became of each preset, presetCfg reads the
usage Usage // settings in force; nil without a store.
quotas Quotas presets PresetStore
ctl *control.Controller presetCfg func() PresetConfig
capacity Capacity
usage Usage
quotas Quotas
ctl *control.Controller
} }
// New builds the API over a controller. // New builds the API over a controller.
@@ -166,6 +170,7 @@ func (a *API) Register(mux Mux) {
} }
writeJSON(w, http.StatusOK, t) writeJSON(w, http.StatusOK, t)
}) })
a.registerPresets(mux)
mux.HandleFunc("GET /api/v1/models", a.listModels) mux.HandleFunc("GET /api/v1/models", a.listModels)
mux.HandleFunc("GET /api/v1/replicas", a.listReplicas) mux.HandleFunc("GET /api/v1/replicas", a.listReplicas)
// Listing is a read and deleting is a separate verb, so a mistyped // 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()) writeError(w, http.StatusBadRequest, err.Error())
return 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}) writeJSON(w, http.StatusOK, Removed{Removed: name})
} }
+353
View File
@@ -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
}
}
+141
View File
@@ -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)
}
}
+7
View File
@@ -65,6 +65,7 @@ type Param struct {
// before them and their name. // before them and their name.
var PathParams = map[string]string{ var PathParams = map[string]string{
"models/{name}": "The model's name in the catalog.", "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.", "nodes/{id}": "The node's ID, as `llmgw ctl nodes` lists it.",
"jobs/{id}": "The job's ID, as starting it answered.", "jobs/{id}": "The job's ID, as starting it answered.",
"runs/{id}": "The quality run'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{}}, Request: CollectRequest{}, OptionalBody: true, Response: []CollectResult{}},
// The catalog. // 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", {Method: "GET", Path: "/api/v1/models", Summary: "Every model, with its replicas and state",
Response: []ModelView{}}, Response: []ModelView{}},
{Method: "POST", Path: "/api/v1/models", Summary: "Add a model to the catalog", {Method: "POST", Path: "/api/v1/models", Summary: "Add a model to the catalog",
+4
View File
@@ -313,6 +313,9 @@ var Reference = []Block{
{Key: "balance_abs", Type: "float64", Doc: "", Nested: ""}, {Key: "balance_abs", Type: "float64", Doc: "", Nested: ""},
{Key: "balance_rel", 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{ {Package: "catalog", Name: "PreviousOrigin", Title: "PreviousOrigin", Doc: "", Fields: []Field{
{Key: "source", Type: "string", Doc: "", Nested: ""}, {Key: "source", Type: "string", Doc: "", Nested: ""},
{Key: "revision", 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: "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: "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: "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.<id>). Sent to the agents as they change; an agent whose\nsettings changed starts again on them, its engines serving\nthroughout.", Nested: "config.AgentSettings"}, {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.<id>). 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: "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: ""}, {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: ""},
+1
View File
@@ -35,6 +35,7 @@ var Sources = []string{
"internal/config/oidc.go", "internal/config/oidc.go",
"internal/config/firewall.go", "internal/config/firewall.go",
"internal/config/serving.go", "internal/config/serving.go",
"internal/config/presets.go",
"internal/config/agents.go", "internal/config/agents.go",
"internal/mail/mail.go", "internal/mail/mail.go",
"internal/notify/notify.go", "internal/notify/notify.go",
+1
View File
@@ -27,6 +27,7 @@ var copyOrder = []string{
"memberships", "memberships",
// After users, which it points at. // After users, which it points at.
"user_excluded_licences", "user_excluded_licences",
"preset_state",
"api_keys", "api_keys",
"key_claims", "key_claims",
"sessions", "sessions",
@@ -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
);
@@ -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
);
+72
View File
@@ -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)
}
+39
View File
@@ -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)
}
}