Compare commits
3
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e0adcc5c4e | ||
|
|
a1974c2b45 | ||
|
|
92b588452e |
No files matched your search
@@ -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
|
||||||
|
|||||||
@@ -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.",
|
||||||
|
|||||||
@@ -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`
|
||||||
|
|||||||
@@ -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",
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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,
|
||||||
|
|||||||
@@ -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"`
|
||||||
|
}
|
||||||
@@ -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})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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",
|
||||||
|
|||||||
@@ -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: ""},
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -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
|
||||||
|
);
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in new issue
Block a user