Compare commits

..
5 Commits
Author SHA1 Message Date
Ilker ManapandClaude Opus 5.5 45666e72db v0.14.5: changelog and README
Release / build (push) Successful in 5m42s
CI / test (push) Successful in 11m27s
Release / notes (push) Successful in 4s
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-14 01:33:59 +02:00
Ilker ManapandClaude Opus 5.5 2eb2a6d8fe A mesh carries its whole licence, not only its name
CI / test (push) Failing after 6m25s
GLBs carried the licence's name, URL and notice in asset.extras.license.
A pipeline that sorts assets by licence needs what a machine matches on:
the id, whether commercial use is allowed, the regions it excludes, the
revenue cap and the attribution. The whole licence is written, as
/v1/models gives it.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-14 01:12:47 +02:00
Ilker ManapandClaude Opus 5.5 cfc636e328 A mesh's base colour can be brought to the picture it was made from
CI / test (push) Failing after 6m28s
Stable Fast 3D takes the light out of the picture it sees, and a dark
basalt (median 0.17) came back a light stone dimly lit (0.67). The save
node takes albedo_match, the picture and its mask, and scales the base
colour texture in linear light by the ratio of the object's medians, so
marks keep their contrast; texels nothing maps to are left alone. On the
game team's rock it is 0.18.

A workflow's {{name|false}} default now fills a BOOLEAN input with a
boolean rather than the word.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-14 00:51:06 +02:00
Ilker ManapandClaude Opus 5.5 848db08469 The cluster page and the console say which picture and 3D models are here, and add them
CI / test (push) Failing after 6m27s
A site whose catalog had no flux-schnell or triposr showed no Images or 3D
page, and nothing said they were one click away. The cluster page now has
a Pictures and 3D models card under the models: each preset, what it
makes, the classes of card here it fits, whether and by whom it was
added, and why the leader has not added it by itself; an Add button, which
says first when it would install ComfyUI on the GPU nodes and restart the
control planes; and the switch for presets.auto. The console's models
screen shows the same under its table: A adds one, asking first in the
same case, and P turns automatic adding on or off.

`ctl model add PRESET` and `ctl model presets` go through the presets API,
so the control plane keeps who added one, and `ctl model presets auto
on|off` flips the setting; against a control plane from before, add sends
the entry as v0.14.4 did.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-13 20:48:40 +02:00
Ilker ManapandClaude Opus 5.5 f42af27da5 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, the ComfyUI the cluster
installs, and why one is not added; POST /api/v1/presets/{name} adds one
(setting engines.comfyui where there is none); PUT /api/v1/presets/auto
switches it.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-13 20:48:40 +02:00
39 changed files with 1839 additions and 35 deletions

No files matched your search

+55
View File
@@ -26,6 +26,61 @@ APIs change between minors without a deprecation period.
## Unreleased
## v0.14.5 — 2026-10-14
Picture and 3D models that add themselves where a card fits them, with a
card on the portal and a screen in the console to see and add them; a
mesh's colour brought to the picture it was made from; and the whole
licence written into every mesh.
**Upgrading.**
- The store gains a table (migration 0047: preset_state); nothing to do.
- **`presets.auto` is on**: once upgraded, the leading control plane adds
flux-schnell and triposr, on demand, where a node's card fits one, the
cluster installs ComfyUI (`engines.comfyui`) and the catalog makes
nothing of that kind. Nothing is fetched until somebody asks for one.
Turn it off with `llmgw ctl model presets auto off`.
- **Nodes** need this release for `albedo_match` (it is in the save node
the gate puts in each replica); a ComfyUI replica takes it when it
starts again.
### Added
- **A mesh's base colour brought to its picture's**: `LLMGWSaveMesh` takes
`albedo_match` with the picture and its mask, and scales the texture in
linear light to the object's brightness in the picture. Stable Fast 3D
takes the light out of what it sees and read a dark basalt (0.17) as a
light stone dimly lit (0.67); matched, it is 0.17. On sf3d it is the
`albedo_match` input (off unless asked).
- **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.
- **Presets on the portal and in the console**: the cluster page's
**Pictures and 3D models** card lists each preset — what it makes, the
classes it fits here, whether and by whom it was added, why the leader
has not — with an **Add** button that says first when it would install
ComfyUI and restart the control planes, and the switch for `presets.auto`.
The console's models screen shows the same; **A** adds one, **P** flips
the switch. `ctl model add PRESET` and `ctl model presets` go through the
API (and `ctl model presets auto on|off`), falling back to the entry as
v0.14.4 sent it on an older control plane.
### Fixed
- A mesh carried only its licence's name, URL and notice; it carries the
whole licence as `/v1/models` gives it — id, commercial use, excluded
regions, revenue cap, attribution — for a pipeline that sorts assets by
licence.
- A workflow's `{{name|false}}` default filled a BOOLEAN input with the
word rather than a boolean.
## v0.14.4 — 2026-10-13
Any ComfyUI workflow as a job, with typed inputs; picture and 3D models
+8 -1
View File
@@ -40,7 +40,7 @@ Releases track the roadmap phases; see [CHANGELOG.md](CHANGELOG.md).
| v0.11.0 | — | High availability from setup, the setup and console screens, usage sums and per-tenant alerts | released |
| v0.12.0 | — | The cluster's settings in the store, in force when changed; the firewall switch; wipe | released |
| v0.13.0 | — | The whole system stopped and started from one command; Qwen3Guard judges, on a GPU and CPUs together | released |
| v0.14.0 | — | Knowledge bases: documents from people's own machines, kept apart per tenant, answered from | released (v0.14.1: adapters, the tree over the API, the firewall on every machine; v0.14.2: pictures from ComfyUI; v0.14.3: 3D models as jobs; v0.14.4: any ComfyUI workflow as a job, presets, licences) |
| v0.14.0 | — | Knowledge bases: documents from people's own machines, kept apart per tenant, answered from | released (v0.14.1: adapters, the tree over the API, the firewall on every machine; v0.14.2: pictures from ComfyUI; v0.14.3: 3D models as jobs; v0.14.4: any ComfyUI workflow as a job, presets, licences; v0.14.5: picture and 3D models that add themselves) |
| v1.0.0 | 5 | What phase 5 has left: precise KV routing proven on vLLM, the block cost picker, defragmentation across NVLink domains, prefill/decode disaggregation | planned |
As of v0.6.0 a cluster runs unattended: agents report their hardware, the
@@ -50,6 +50,13 @@ budgets and rate limits decide who may use any of it.
Since v0.11.0 (each release's detail is in [CHANGELOG.md](CHANGELOG.md)):
- **Picture and 3D models that add themselves** (v0.14.5). Where a node's
card fits flux-schnell or triposr and the catalog makes nothing of that
kind, the leader adds it on demand, so a new cluster's portal has its
Images and 3D pages; the cluster page and the console show each preset
and add it by hand, and `presets.auto` turns it off. Stable Fast 3D's
colours can be brought to the picture's (`albedo_match`), and every mesh
carries its whole licence.
- **Any ComfyUI workflow, and models by name** (v0.14.4). `POST
/v1/workflows/runs` runs a ComfyUI model's workflow as a job, its inputs
(text, numbers, choices, images, masks, audio, video, files) declared in
+192
View File
@@ -86,6 +86,9 @@
{
"name": "plan"
},
{
"name": "presets"
},
{
"name": "projects"
},
@@ -3651,6 +3654,116 @@
}
}
},
"/api/v1/presets": {
"get": {
"operationId": "getPresets",
"summary": "The models that come with llmgw: whether each fits this cluster, is in the catalog, was declined",
"tags": [
"presets"
],
"responses": {
"200": {
"description": "OK",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/PresetsView"
}
}
}
},
"401": {
"$ref": "#/components/responses/Unauthorized"
},
"default": {
"$ref": "#/components/responses/Error"
}
}
}
},
"/api/v1/presets/auto": {
"put": {
"operationId": "putPresetsAuto",
"summary": "Turn on or off the adding of presets when a node fits them",
"tags": [
"presets"
],
"parameters": [
{
"$ref": "#/components/parameters/Actor"
}
],
"requestBody": {
"required": true,
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/PresetsAutoRequest"
}
}
}
},
"responses": {
"200": {
"description": "OK",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/PresetsView"
}
}
}
},
"401": {
"$ref": "#/components/responses/Unauthorized"
},
"default": {
"$ref": "#/components/responses/Error"
}
}
}
},
"/api/v1/presets/{name}": {
"post": {
"operationId": "postPresetsName",
"summary": "Add a preset to the catalog, and clear its decline",
"tags": [
"presets"
],
"parameters": [
{
"name": "name",
"in": "path",
"required": true,
"description": "The preset's name, as `llmgw ctl model presets` lists it.",
"schema": {
"type": "string"
}
},
{
"$ref": "#/components/parameters/Actor"
}
],
"responses": {
"200": {
"description": "OK",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/ModelAdded"
}
}
}
},
"401": {
"$ref": "#/components/responses/Unauthorized"
},
"default": {
"$ref": "#/components/responses/Error"
}
}
}
},
"/api/v1/projects": {
"post": {
"operationId": "postProjects",
@@ -7481,6 +7594,85 @@
}
}
},
"PresetView": {
"type": "object",
"description": "PresetView is one preset as it stands in this cluster.",
"properties": {
"name": {
"type": "string"
},
"makes": {
"type": "string"
},
"description": {
"type": "string"
},
"card": {
"type": "string"
},
"licence": {
"$ref": "#/components/schemas/catalog.License"
},
"fits": {
"type": "boolean",
"description": "Fits is whether some node has a card that meets the preset's need; FitsClasses names those classes."
},
"fits_classes": {
"type": "array",
"items": {
"type": "string"
}
},
"in_catalog": {
"type": "boolean"
},
"added_by": {
"type": "string",
"description": "AddedBy is \"auto\" when the leader added it, who did otherwise, \"\" when it was not added as a preset; AddedAt is when."
},
"added_at": {
"type": "string",
"format": "date-time"
},
"declined": {
"type": "boolean",
"description": "Declined is whether somebody removed it after it was added, so it is not added again by itself; adding it again clears that."
},
"blocked": {
"type": "string",
"description": "Blocked is why the leader does not add it by itself — presets.auto off, no ComfyUI, no card that fits, declined, another model already making the same — empty when it would or has."
}
}
},
"PresetsAutoRequest": {
"type": "object",
"description": "PresetsAutoRequest turns the automatic adding of presets on or off.",
"properties": {
"on": {
"type": "boolean"
}
}
},
"PresetsView": {
"type": "object",
"description": "PresetsView is the models that come with llmgw, and whether the leader adds them by itself.",
"properties": {
"auto": {
"type": "boolean",
"description": "Auto is whether a preset is added when a node fits it and the catalog has no model that makes what it makes (presets.auto)."
},
"comfyui": {
"type": "string",
"description": "ComfyUI is engines.comfyui as it stands: the version the nodes install, \"\" for none — in which case adding a preset sets it, and the control planes start again on that."
},
"presets": {
"type": "array",
"items": {
"$ref": "#/components/schemas/PresetView"
}
}
}
},
"ProjectView": {
"type": "object",
"description": "ProjectView is a project, with how many people and keys are in it.",
+17 -2
View File
@@ -1313,8 +1313,23 @@ A preset's profile says the card it needs, not a class, so it places on
whatever cards the site has. When the cluster's settings name no ComfyUI,
adding one sets `engines.comfyui` to the version this release installs, and
every node with an NVIDIA GPU installs it; a cluster that names a version,
or `""` for none, keeps it. In the console it is **A** on the models screen.
The rest of this section is for a model of your own.
or `""` for none, keeps it; the control planes then start again on that
setting, standbys first.
They also come by themselves. Once a node has a card that fits one, and the
catalog has nothing that makes what it makes, the leader adds it, on demand —
where the cluster already installs ComfyUI; it never sets `engines.comfyui`
by itself. A preset somebody removes stays out until it is added again.
`llmgw ctl model presets auto off` (the setting `presets.auto`) stops it.
The cluster page on the portal has a **Pictures and 3D models** card under
the models: each preset, the classes of card here it fits, whether it is in
the catalog and who added it, why the leader has not, an **Add** button (one
that would also install ComfyUI says so first) and the switch for adding
them by themselves. In the console, the models screen shows the same: **A**
adds one, **P** turns adding them by themselves on or off.
`llmgw ctl model presets` prints it. The rest of this section is for a model
of your own.
The node must offer the engine. Ask for it in the cluster's settings, and every
node with an NVIDIA GPU installs it:
+24
View File
@@ -244,6 +244,7 @@ rather than repeated.
| `match_ratio` | `float64` | [Prefix](#prefix) |
| `balance_abs` | `float64` | [Prefix](#prefix) |
| `balance_rel` | `float64` | [Prefix](#prefix) |
| `auto` | `bool` | [PresetSettings](#presetsettings) |
| `source` | `string` | [PreviousOrigin](#previousorigin) |
| `revision` | `string` | [PreviousOrigin](#previousorigin) |
| `files` | `[]OriginFile` | [PreviousOrigin](#previousorigin) |
@@ -344,6 +345,7 @@ rather than repeated.
| `mail` | `mail.Config` | [Server](#server) |
| `serving` | `ServingSettings` | [Server](#server) |
| `firewall` | `FirewallSettings` | [Server](#server) |
| `presets` | `PresetSettings` | [Server](#server) |
| `agents` | `AgentSettings` | [Server](#server) |
| `firewall_networks` | `[]string` | [Server](#server) |
| `backup_recipients` | `[]string` | [Server](#server) |
@@ -2158,6 +2160,20 @@ before affinity is abandoned.
`float64`
## PresetSettings
### `auto`
`bool`
Auto has the leading control plane add a picture model (flux-schnell)
and a 3D model (triposr) by itself, on demand, once a node has a card
that fits one and the catalog has no model that makes the same kind of
thing — so a new cluster's portal has its pictures and 3D pages
without anybody writing a definition. Only where the cluster already
installs ComfyUI (engines.comfyui). A preset somebody removed is not
added again by itself. In force on the leader within a minute.
## PreviousOrigin
### `source`
@@ -2960,6 +2976,14 @@ Firewall turns llmgw's firewall on every machine on or off.
Takes a [FirewallSettings](#firewallsettings) block.
### `presets`
`PresetSettings`
Presets is how the models that come with llmgw reach the catalog.
Takes a [PresetSettings](#presetsettings) block.
### `agents`
`AgentSettings`
+2
View File
@@ -243,6 +243,8 @@ var liveSettings = []string{
"firewall", "firewall_networks",
// Sent to the nodes' agents as they change.
"agents",
// Read by the leader's presets pass each time it runs.
"presets",
// Read by server init, node add and backups, not by the server.
"backup_recipients", "backup_copy_to",
}
+16 -1
View File
@@ -3,6 +3,7 @@ package cli
import (
"encoding/json"
"fmt"
"net/url"
"os"
"os/user"
"slices"
@@ -32,7 +33,7 @@ func newModelCmd(client func() *ctlClient, asJSON *bool) *cobra.Command {
Use: "model",
Short: "Add or remove models while the cluster is running",
}
c.AddCommand(newModelAddCmd(client), newModelPresetsCmd(), newModelRemoveCmd(client), newModelPlanCmd(client),
c.AddCommand(newModelAddCmd(client), newModelPresetsCmd(client, asJSON), newModelRemoveCmd(client), newModelPlanCmd(client),
newResourcesCmd(client), newClassCmd(client), newOwnerCmd(client),
newTargetCmd(client, asJSON), newAdaptersCmd(client),
newModelServeCmd(client), newModelUpdatesCmd(client, asJSON), newModelUpdateCmd(client),
@@ -73,6 +74,20 @@ func newModelAddCmd(client func() *ctlClient) *cobra.Command {
return fmt.Errorf("no preset %q: there are %s; any other model is added from a file (-f)",
args[0], strings.Join(catalog.PresetNames(), ", "))
}
// The control plane adds it, and keeps that it was added as
// a preset, by whom; one from before it could is sent the
// entry, as it was then.
var res adminapi.ModelAdded
err := client().post(cmd.Context(), "/api/v1/presets/"+url.PathEscape(p.Model.Name), []byte("{}"), &res)
if err == nil {
fmt.Fprintf(cmd.OutOrStdout(),
"%s added, and ComfyUI with it where the cluster had none. It is placed on the next\n"+
"reconcile pass and loads when first asked for; watch it with:\n llmgw ctl models\n", p.Model.Name)
return nil
}
if !olderControlPlane(err) {
return err
}
m = p.Model
case path == "":
return fmt.Errorf("a preset (%s) or --file, the model definition as YAML",
+93 -10
View File
@@ -1,7 +1,9 @@
package cli
import (
"encoding/json"
"fmt"
"strings"
"github.com/spf13/cobra"
@@ -11,25 +13,106 @@ import (
"gitea.mynodes.xyz/ilker/llmgw/internal/settings"
)
func newModelPresetsCmd() *cobra.Command {
return &cobra.Command{
func newModelPresetsCmd(client func() *ctlClient, asJSON *bool) *cobra.Command {
c := &cobra.Command{
Use: "presets",
Short: "The models that come with llmgw, added by name",
Short: "The models that come with llmgw: whether each fits and is here",
Long: "Each is a catalog entry whose workflow, custom nodes, packages and licence\n" +
"were found and tried, and whose profile says the card it needs rather than\n" +
"a class, so it places on any site's cards. Add one with\n" +
"`llmgw ctl model add NAME`.",
"`llmgw ctl model add NAME`.\n\n" +
"While presets.auto is on, the leader adds one by itself when a card fits it\n" +
"and the catalog has nothing that makes what it makes; one somebody removed\n" +
"is not added again by itself.",
Args: cobra.NoArgs,
RunE: func(cmd *cobra.Command, _ []string) error {
w := tabTo(cmd.OutOrStdout())
fmt.Fprintln(w, "NAME\tMAKES\tCARD\tLICENCE\tWHAT IT IS FOR")
for _, p := range catalog.Presets() {
fmt.Fprintf(w, "%s\t%s\t%s\t%s\t%s\n", p.Model.Name, p.Makes, presetCard(p.Model),
presetLicence(p.Model), p.Model.Description)
var v adminapi.PresetsView
err := client().get(cmd.Context(), "/api/v1/presets", &v)
if err != nil && !olderControlPlane(err) {
return err
}
return w.Flush()
if *asJSON && err == nil {
return emitJSON(v)
}
w := tabTo(cmd.OutOrStdout())
if err != nil {
// A control plane from before it kept them: what this
// release has, with nothing about the cluster.
fmt.Fprintln(w, "NAME\tMAKES\tCARD\tLICENCE\tWHAT IT IS FOR")
for _, p := range catalog.Presets() {
fmt.Fprintf(w, "%s\t%s\t%s\t%s\t%s\n", p.Model.Name, p.Makes, presetCard(p.Model),
presetLicence(p.Model), p.Model.Description)
}
return w.Flush()
}
fmt.Fprintln(w, "NAME\tMAKES\tCARD\tLICENCE\tFITS\tHERE\tWHAT IT IS FOR")
for _, p := range v.Presets {
fmt.Fprintf(w, "%s\t%s\t%s\t%s\t%s\t%s\t%s\n", p.Name, p.Makes, p.Card, licenceText(p.Licence),
orDash(strings.Join(p.FitsClasses, ",")), presetState(p), p.Description)
}
if err := w.Flush(); err != nil {
return err
}
if v.Auto {
fmt.Fprintln(cmd.OutOrStdout(), "\nAdded by themselves where a card fits (presets.auto); "+
"llmgw ctl model presets auto off stops it.")
} else {
fmt.Fprintln(cmd.OutOrStdout(), "\nNot added by themselves; llmgw ctl model presets auto on starts it.")
}
return nil
},
}
c.AddCommand(&cobra.Command{
Use: "auto on|off",
Short: "Whether the leader adds a preset by itself where a card fits it",
Args: cobra.ExactArgs(1),
ValidArgs: []string{"on", "off"},
RunE: func(cmd *cobra.Command, args []string) error {
if args[0] != "on" && args[0] != "off" {
return fmt.Errorf("on or off, not %q", args[0])
}
body, _ := json.Marshal(adminapi.PresetsAutoRequest{On: args[0] == "on"})
var v adminapi.PresetsView
if err := client().put(cmd.Context(), "/api/v1/presets/auto", body, &v); err != nil {
return err
}
if v.Auto {
fmt.Fprintln(cmd.OutOrStdout(), "Presets are added by themselves where a card fits.")
} else {
fmt.Fprintln(cmd.OutOrStdout(), "Presets are no longer added by themselves.")
}
return nil
},
})
return c
}
// olderControlPlane is an error from a control plane that has no such path:
// one from before it was added.
func olderControlPlane(err error) bool {
s := err.Error()
return strings.Contains(s, "answered with a page") || strings.HasPrefix(s, "404 Not Found: 404 page not found")
}
func presetState(p adminapi.PresetView) string {
switch {
case p.InCatalog && p.AddedBy == "auto":
return "added by itself"
case p.InCatalog && p.AddedBy != "":
return "added by " + p.AddedBy
case p.InCatalog:
return "here"
case p.Declined:
return "removed; stays out"
}
return "—"
}
func licenceText(l catalog.License) string {
if l.Commercial != nil && !*l.Commercial {
return l.Name + ", not commercial"
}
return l.Name
}
func presetCard(m catalog.Model) string {
+21
View File
@@ -17,6 +17,10 @@ import (
// presetAdmin is an admin API holding settings, which records the models
// added and the settings put.
type presetAdmin struct {
// api is a control plane that adds presets itself; without it, one
// from before, which answers their paths with a 404.
api bool
byAPI []string
mu sync.Mutex
settings string
added []catalog.Model
@@ -30,6 +34,10 @@ func (a *presetAdmin) client(t *testing.T) func() *ctlClient {
body, _ := io.ReadAll(r.Body)
w.Header().Set("Content-Type", "application/json")
switch {
case a.api && r.Method == http.MethodPost && strings.HasPrefix(r.URL.Path, "/api/v1/presets/"):
name := strings.TrimPrefix(r.URL.Path, "/api/v1/presets/")
a.byAPI = append(a.byAPI, name)
_ = json.NewEncoder(w).Encode(adminapi.ModelAdded{Added: name})
case r.Method == http.MethodPost && r.URL.Path == "/api/v1/models":
var m catalog.Model
_ = json.Unmarshal(body, &m)
@@ -62,6 +70,19 @@ func runModelAdd(t *testing.T, client func() *ctlClient, args ...string) (string
return out.String(), err
}
func TestAPresetIsAddedByTheControlPlane(t *testing.T) {
a := &presetAdmin{api: true}
out, err := runModelAdd(t, a.client(t), "triposr")
if err != nil {
t.Fatalf("%v\n%s", err, out)
}
if len(a.byAPI) != 1 || a.byAPI[0] != "triposr" || len(a.added) != 0 || len(a.put) != 0 {
t.Errorf("by the API %v; sent the entry %v; settings %v", a.byAPI, a.added, a.put)
}
}
// A control plane from before presets were its own is sent the entry, and
// ComfyUI set beside it, as the release that brought presets did.
func TestAPresetIsAddedByNameAndHasTheNodesInstallComfyUI(t *testing.T) {
a := &presetAdmin{settings: "router:\n retries: 2\n"}
out, err := runModelAdd(t, a.client(t), "triposr")
+37
View File
@@ -465,6 +465,14 @@ func newServerCmd() *cobra.Command {
}
api.SetModels(svc)
catalogSvc = svc
// The models that come with llmgw: added by the leader
// when a node fits one and the catalog makes nothing of
// its kind, unless somebody removed it or turned it off.
api.SetPresets(st, func() adminapi.PresetConfig {
c := current.Load()
return adminapi.PresetConfig{Auto: c.Presets.Auto, ComfyUI: c.Engines[catalog.EngineComfyUI]}
})
go runPresetsPass(ctx, api, admin.Leadership(), log)
// And the mirror page, so a model that has just been
// fetched can be served without writing YAML by hand.
rt.SetCatalogAdmin(svc)
@@ -1281,3 +1289,32 @@ func embedder(replay func(ctx context.Context, path, model string, body []byte)
return res.Data[0].Embedding, nil
}
}
// presetsEvery is how often the leader looks for presets to add; the first
// look waits as long, for the nodes to have registered their cards.
const presetsEvery = time.Minute
// runPresetsPass has the leader add the presets a node fits and the catalog
// lacks, now and then.
func runPresetsPass(ctx context.Context, api *adminapi.API, lead *control.Leadership, log *slog.Logger) {
t := time.NewTicker(presetsEvery)
defer t.Stop()
for {
select {
case <-ctx.Done():
return
case <-t.C:
}
if lead != nil && !lead.IsLeader() {
continue
}
added, err := api.PresetsPass(ctx)
for _, name := range added {
log.Info("added a preset by itself: a node fits it and the catalog made nothing of its kind",
"model", name, "off_switch", "presets.auto")
}
if err != nil {
log.Warn("could not add a preset", "err", err)
}
}
}
+17 -2
View File
@@ -90,7 +90,10 @@ type consoleData struct {
// federation is the other llmgw clusters whose models are offered
// here; empty on a control plane without a store.
federation adminapi.FederationView
err error
// presets is the models that come with llmgw as they stand here; nil
// from a control plane that cannot say.
presets *adminapi.PresetsView
err error
}
type (
@@ -275,6 +278,10 @@ func (m consoleModel) fetch() tea.Cmd {
_ = c.get(ctx, "/api/v1/jobs", &d.jobs)
_ = c.get(ctx, "/api/v1/audit?since=7d&limit=300", &d.audit)
_ = c.get(ctx, "/api/v1/federation", &d.federation)
var presets adminapi.PresetsView
if c.get(ctx, "/api/v1/presets", &presets) == nil {
d.presets = &presets
}
return consoleDataMsg(d)
}
}
@@ -599,6 +606,10 @@ func (m consoleModel) key(k tea.KeyMsg) (tea.Model, tea.Cmd) {
m.hf.models, m.hf.err, m.row = nil, nil, 0
return m, m.fetchHF()
}
case "p":
if m.menu == cModels && m.inPanel && m.data.presets != nil {
m.autoPresetsConfirm()
}
case "a":
switch m.menu {
case cQuotas:
@@ -1238,8 +1249,12 @@ func (m consoleModel) panel() (title, body, keys string) {
}
if m.inPanel {
keys = "↑↓ row · Enter actions · A add a model · ← back · Q leave"
if m.data.presets != nil {
keys = "↑↓ row · Enter actions · A add a model · P pictures and 3D added by themselves · ← back · Q leave"
}
}
return "Models", "\n" + table([]string{"MODEL", "READY", "STATUS", "CLASS", "PROFILES", "UPDATE", ""}, rows, m.row, pw, m.inPanel), keys
return "Models", "\n" + table([]string{"MODEL", "READY", "STATUS", "CLASS", "PROFILES", "UPDATE", ""}, rows, m.row, pw, m.inPanel) +
presetsBlock(m.data.presets, pw), keys
case cReplicas:
var rows [][]string
for _, r := range m.data.replicas {
+7 -2
View File
@@ -269,15 +269,20 @@ func (m *consoleModel) addModelForm() {
}
note.WriteString("A ComfyUI one has the nodes install ComfyUI if the cluster has none.\n" +
"Or a definition file: the YAML a model takes in server.yaml's models.")
var choices []string
for _, n := range catalog.PresetNames() {
choices = append(choices, presetChoice(m.data.presets, n))
}
m.openForm(&formDialog{title: "Add a model",
note: note.String(),
fields: []formField{
choiceF("model", append(catalog.PresetNames(), fromFile), catalog.PresetNames()[0]),
choiceF("model", append(choices, fromFile), choices[0]),
textF("file", "", "for "+fromFile+": like /root/qwen.yaml"),
},
submit: func(m *consoleModel, v []string) (tea.Cmd, error) {
if v[0] != fromFile {
return m.runCLI("Add "+v[0], "ctl", server, "model", "add", v[0]), nil
// The name, without what the choice says beside it.
return m.addPreset(strings.Fields(v[0])[0]), nil
}
if v[1] == "" {
return nil, fmt.Errorf("a file, or a model that comes with llmgw")
+128
View File
@@ -0,0 +1,128 @@
package cli
import (
"encoding/json"
"net/http"
"net/url"
"strings"
tea "github.com/charmbracelet/bubbletea"
"gitea.mynodes.xyz/ilker/llmgw/internal/control/adminapi"
)
// presetsBlock is the models that come with llmgw, under the models table:
// whether each fits a card here and whether it is in the catalog, and
// whether the leader adds them by itself. Empty from a control plane that
// cannot say.
func presetsBlock(v *adminapi.PresetsView, width int) string {
if v == nil || len(v.Presets) == 0 {
return ""
}
var rows [][]string
var why []string
for _, p := range v.Presets {
rows = append(rows, []string{p.Name, p.Makes, orDash(strings.Join(p.FitsClasses, ",")), presetState(p)})
// Why the leader leaves it, under the table: a reason is longer than
// any column the panel has room for. Automatic adding being off is
// said once, below.
if p.Blocked != "" && !strings.HasPrefix(p.Blocked, "presets.auto") && !p.Declined {
why = append(why, " "+wrap(p.Name+": "+p.Blocked, width-4, " "))
}
}
auto := "Added by themselves where a card fits · P stops that"
if !v.Auto {
auto = "Not added by themselves · P starts that · A adds one"
}
out := "\n\n Pictures and 3D models that come with llmgw\n" +
table([]string{"PRESET", "MAKES", "FITS", "HERE"}, rows, -1, width, false)
if len(why) > 0 {
out += "\n" + sDim.Render(strings.Join(why, "\n"))
}
return out + "\n " + sDim.Render(auto)
}
// presetChoice is how a preset is offered in the add dialog: its name, and
// where it stands when that is worth knowing before choosing it.
func presetChoice(v *adminapi.PresetsView, name string) string {
if v == nil {
return name
}
for _, p := range v.Presets {
if p.Name != name {
continue
}
switch {
case p.InCatalog:
return name + " (already here)"
case presetAddsComfyUI(v, p):
return name + " (installs ComfyUI too)"
case !p.Fits:
return name + " (no card here fits it)"
case p.Declined:
return name + " (removed before)"
}
}
return name
}
// presetAddsComfyUI is whether adding a preset by hand also sets
// engines.comfyui, which has the GPU nodes install it and the control planes
// restart on the setting.
func presetAddsComfyUI(v *adminapi.PresetsView, p adminapi.PresetView) bool {
return v != nil && !p.InCatalog && v.ComfyUI == ""
}
// addPreset adds a preset through the control plane, or, from one from
// before it could, the way the release that brought presets did. One whose
// adding installs ComfyUI and restarts the control planes is asked about
// first.
func (m *consoleModel) addPreset(name string) tea.Cmd {
if m.data.presets != nil {
for _, p := range m.data.presets.Presets {
if p.Name == name && presetAddsComfyUI(m.data.presets, p) {
m.confirmD = &confirmDialog{title: "Add " + name + " and ComfyUI",
text: "The cluster installs no ComfyUI yet. Adding " + name + " sets engines.comfyui:\n" +
"every GPU node installs it, about 10 GB each, and the control planes restart\n" +
"on the setting, standbys first. Engines keep serving.",
yes: func(m *consoleModel) tea.Cmd { return m.postPreset(name) }}
return nil
}
}
}
return m.postPreset(name)
}
func (m *consoleModel) postPreset(name string) tea.Cmd {
if m.data.presets == nil {
return m.runCLI("Add "+name, "ctl", "--server="+m.client.base, "model", "add", name)
}
return m.call("Add "+name, http.MethodPost, "/api/v1/presets/"+url.PathEscape(name), map[string]string{},
func(_ json.RawMessage) string {
return name + " is added, and ComfyUI with it where the cluster had none.\n" +
"It is placed on the next pass, and loads when first asked for."
})
}
// autoPresetsConfirm turns the adding of presets by themselves on or off,
// after saying what that does.
func (m *consoleModel) autoPresetsConfirm() {
on := !m.data.presets.Auto
title, text := "Stop adding pictures and 3D models by themselves",
"Presets already added stay. A preset is added only when somebody adds it."
if on {
title, text = "Add pictures and 3D models by themselves",
"Where a card fits one and the catalog has nothing that makes what it makes, the leader adds it.\n"+
"That also has every GPU node install ComfyUI, about 10 GB each, and the mirror fetch its weights.\n"+
"A preset somebody removed is not added again."
}
m.confirmD = &confirmDialog{title: title, text: text, yes: func(m *consoleModel) tea.Cmd {
return m.call(title, http.MethodPut, "/api/v1/presets/auto", adminapi.PresetsAutoRequest{On: on},
func(_ json.RawMessage) string {
if on {
return "Presets are added by themselves where a card fits."
}
return "Presets are no longer added by themselves."
})
}}
}
+63
View File
@@ -0,0 +1,63 @@
package cli
import (
"strings"
"testing"
tea "github.com/charmbracelet/bubbletea"
"gitea.mynodes.xyz/ilker/llmgw/internal/control/adminapi"
)
func presetsHere() *adminapi.PresetsView {
return &adminapi.PresetsView{Auto: true, Presets: []adminapi.PresetView{
{Name: "flux-schnell", Makes: "pictures", Card: "cc 7.0+, 24 GB+", Fits: true, FitsClasses: []string{"v100-32g"},
Blocked: "the cluster installs no ComfyUI (engines.comfyui); adding it by hand sets that"},
{Name: "triposr", Makes: "meshes", Card: "cc 7.0+, 8 GB+", Fits: true, InCatalog: true, AddedBy: "auto"},
}}
}
func TestThePresetsAreOnTheModelsScreen(t *testing.T) {
m, _ := consoleWith(t, cModels)
m.data.presets = presetsHere()
v := m.View()
for _, want := range []string{"Pictures and 3D models that come with llmgw", "flux-schnell", "v100-32g",
"installs no ComfyUI", "added by itself", "P pictures and 3D"} {
if !strings.Contains(v, want) {
t.Errorf("%q not shown:\n%s", want, v)
}
}
// A control plane that cannot say shows none of it.
m.data.presets = nil
if v := m.View(); strings.Contains(v, "come with llmgw") || strings.Contains(v, "P pictures") {
t.Errorf("presets shown from nothing:\n%s", v)
}
}
func TestAPresetThatInstallsComfyUIIsAddedOnlyWhenConfirmed(t *testing.T) {
m, s := consoleWith(t, cModels)
m.data.presets = presetsHere()
var tm tea.Model = m
tm = press(tm, "a")
f := tm.(consoleModel).formD
if f == nil || !strings.Contains(f.fields[0].value(), "installs ComfyUI too") {
t.Fatalf("no add dialog saying what flux-schnell brings: %+v", f)
}
tm = press(tm, "enter", "enter", "enter")
if tm.(consoleModel).confirmD == nil || s.last() != "" {
t.Fatalf("added without asking: %q", s.last())
}
tm = pressRun(tm, "y")
if got := s.last(); got != "POST /api/v1/presets/flux-schnell {}" {
t.Fatalf("sent %q", got)
}
tm = press(tm, "esc", "p") // past what the add said
if c := tm.(consoleModel).confirmD; c == nil || !strings.HasPrefix(c.title, "Stop adding") {
t.Fatalf("P did not ask to stop: %+v", c)
}
_ = pressRun(tm, "y")
if got := s.last(); got != `PUT /api/v1/presets/auto {"on":false}` {
t.Fatalf("sent %q", got)
}
}
+4
View File
@@ -222,6 +222,10 @@ func fillString(s string, p Params, missing *[]string) any {
if f, err := strconv.ParseFloat(def, 64); err == nil {
return f, true
}
// A BOOLEAN input refuses the word: {{albedo_match|false}}.
if b, err := strconv.ParseBool(def); err == nil && (def == "true" || def == "false") {
return b, true
}
return def, true
}
*missing = append(*missing, name)
+16
View File
@@ -252,3 +252,19 @@ func TestAPictureLosesTheWorkflowComfyUIWroteIntoIt(t *testing.T) {
t.Error("a truncated PNG was changed")
}
}
func TestATrueOrFalseDefaultIsABoolean(t *testing.T) {
// A BOOLEAN input refuses the word "false": sf3d's albedo_match.
tpl, err := Load((`{"5":{"class_type":"LLMGWSaveMesh","inputs":{"albedo_match":"{{albedo_match|false}}","count":"{{count|2}}","s":"{{s|x}}"}}}`))
if err != nil {
t.Fatal(err)
}
g, err := tpl.Fill(Params{})
if err != nil {
t.Fatal(err)
}
in := g["5"].(map[string]any)["inputs"].(map[string]any)
if in["albedo_match"] != false || in["count"] != 2.0 || in["s"] != "x" {
t.Errorf("inputs %#v", in)
}
}
+54 -3
View File
@@ -94,22 +94,60 @@ class LLMGWPrepareForMesh:
return (both[..., :3].contiguous(), both[..., 3].contiguous())
def _to_linear(x):
return np.where(x <= 0.04045, x / 12.92, ((x + 0.055) / 1.055) ** 2.4)
def _to_srgb(x):
return np.where(x <= 0.0031308, x * 12.92, 1.055 * np.power(np.clip(x, 0, None), 1 / 2.4) - 0.055)
def _match_albedo(tex, reference, mask):
"""The texture's brightness brought to the picture's: Stable Fast 3D
takes the light out of what it sees, and a dark object it reads as a
light one dimly lit — a basalt of 0.17 came back 0.67. Scaled in linear
light by the ratio of the medians over the object, so its marks keep
their contrast; texels nothing maps to (black) are left as they are."""
t = np.asarray(tex.convert("RGB")).astype(np.float64) / 255.0
used = t.max(-1) > 2 / 255
ref = reference.cpu().numpy().astype(np.float64)
obj = mask.cpu().numpy() > 0.5 if mask is not None else np.ones(ref.shape[:2], bool)
if used.sum() < 16 or obj.sum() < 16:
return tex
lum = lambda a: a @ np.array([0.2126, 0.7152, 0.0722])
want = np.median(lum(_to_linear(ref[obj])))
have = np.median(lum(_to_linear(t[used])))
if have <= 0:
return tex
k = float(np.clip(want / have, 0.05, 20.0))
out = t.copy()
out[used] = np.clip(_to_srgb(_to_linear(t[used]) * k), 0, 1)
img = Image.fromarray((out * 255 + 0.5).astype(np.uint8))
img.format = tex.format
return img
class LLMGWSaveMesh:
"""Writes each mesh a 3D node made to output/ as a GLB and names the
files, which is how the gate finds a result. A model's own save node
may return the mesh as base64 (Stable Fast 3D's does) or as an OBJ
without its texture, and the gate reads neither."""
without its texture, and the gate reads neither.
With albedo_match and the picture the mesh was made from (and its
mask), the mesh's base colour is brought to the picture's brightness."""
@classmethod
def INPUT_TYPES(cls):
return {"required": {"mesh": ("MESH",), "filename_prefix": ("STRING", {"default": "mesh"})}}
return {"required": {"mesh": ("MESH",), "filename_prefix": ("STRING", {"default": "mesh"})},
"optional": {"albedo_match": ("BOOLEAN", {"default": False}),
"reference": ("IMAGE",), "reference_mask": ("MASK",)}}
RETURN_TYPES = ()
OUTPUT_NODE = True
FUNCTION = "save"
CATEGORY = "llmgw"
def save(self, mesh, filename_prefix="mesh"):
def save(self, mesh, filename_prefix="mesh", albedo_match=False, reference=None, reference_mask=None):
import os
import folder_paths
import trimesh
@@ -119,6 +157,9 @@ class LLMGWSaveMesh:
filename_prefix, folder_paths.get_output_directory())
saved = []
for i, m in enumerate(mesh if isinstance(mesh, (list, tuple)) else [mesh]):
if albedo_match and reference is not None:
_match_scene(m, reference[min(i, reference.shape[0] - 1)],
None if reference_mask is None else reference_mask[min(i, reference_mask.shape[0] - 1)])
file = f"{name.replace('%batch_num%', str(i))}_{counter + i:05}_.glb"
scene = m if isinstance(m, trimesh.Scene) else trimesh.Scene(m)
with open(os.path.join(folder, file), "wb") as f:
@@ -127,6 +168,16 @@ class LLMGWSaveMesh:
return {"ui": {"mesh": saved}}
def _match_scene(m, reference, mask):
"""_match_albedo on every base colour texture of a mesh or scene."""
geoms = m.geometry.values() if hasattr(m, "geometry") else [m]
for g in geoms:
mat = getattr(getattr(g, "visual", None), "material", None)
tex = getattr(mat, "baseColorTexture", None)
if tex is not None:
mat.baseColorTexture = _match_albedo(tex, reference, mask)
NODE_CLASS_MAPPINGS = {"LLMGWPrepareForMesh": LLMGWPrepareForMesh, "LLMGWSaveMesh": LLMGWSaveMesh}
NODE_DISPLAY_NAME_MAPPINGS = {"LLMGWPrepareForMesh": "Prepare a picture for a 3D model (llmgw)",
"LLMGWSaveMesh": "Save a mesh as GLB (llmgw)"}
+4
View File
@@ -158,6 +158,9 @@ type Server struct {
// Firewall turns llmgw's firewall on every machine on or off.
Firewall FirewallSettings `koanf:"firewall"`
// Presets is how the models that come with llmgw reach the catalog.
Presets PresetSettings `koanf:"presets"`
// Agents are the nodes' agent settings: agent.yaml's keys but those an
// agent needs to start, for every node (default) and for one
// (nodes.<id>). Sent to the agents as they change; an agent whose
@@ -795,6 +798,7 @@ func Default() Server {
MaxConnections: 10_000,
Mirror: Mirror{UpdateCheck: 6 * time.Hour},
Firewall: FirewallSettings{Enabled: true},
Presets: PresetSettings{Auto: true},
Control: Control{
Interval: 5 * time.Second,
BaseBackoff: 10 * time.Second,
+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 func(ctx context.Context, nodeID string, at time.Time) error
models Models
capacity Capacity
usage Usage
quotas Quotas
ctl *control.Controller
models Models
// presets keeps what became of each preset, presetCfg reads the
// settings in force; nil without a store.
presets PresetStore
presetCfg func() PresetConfig
capacity Capacity
usage Usage
quotas Quotas
ctl *control.Controller
}
// New builds the API over a controller.
@@ -166,6 +170,7 @@ func (a *API) Register(mux Mux) {
}
writeJSON(w, http.StatusOK, t)
})
a.registerPresets(mux)
mux.HandleFunc("GET /api/v1/models", a.listModels)
mux.HandleFunc("GET /api/v1/replicas", a.listReplicas)
// Listing is a read and deleting is a separate verb, so a mistyped
@@ -1229,6 +1234,8 @@ func (a *API) removeModel(w http.ResponseWriter, r *http.Request) {
writeError(w, http.StatusBadRequest, err.Error())
return
}
// A preset removed is one the leader must not put back by itself.
a.presetDeclined(r.Context(), name, actorOf(r))
writeJSON(w, http.StatusOK, Removed{Removed: name})
}
+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.
var PathParams = map[string]string{
"models/{name}": "The model's name in the catalog.",
"presets/{name}": "The preset's name, as `llmgw ctl model presets` lists it.",
"nodes/{id}": "The node's ID, as `llmgw ctl nodes` lists it.",
"jobs/{id}": "The job's ID, as starting it answered.",
"runs/{id}": "The quality run's ID, as starting it answered.",
@@ -136,6 +137,12 @@ var Routes = []Route{
Request: CollectRequest{}, OptionalBody: true, Response: []CollectResult{}},
// The catalog.
{Method: "GET", Path: "/api/v1/presets", Summary: "The models that come with llmgw: whether each fits this cluster, is in the catalog, was declined",
Response: PresetsView{}},
{Method: "POST", Path: "/api/v1/presets/{name}", Summary: "Add a preset to the catalog, and clear its decline",
Response: ModelAdded{}},
{Method: "PUT", Path: "/api/v1/presets/auto", Summary: "Turn on or off the adding of presets when a node fits them",
Request: PresetsAutoRequest{}, Response: PresetsView{}},
{Method: "GET", Path: "/api/v1/models", Summary: "Every model, with its replicas and state",
Response: []ModelView{}},
{Method: "POST", Path: "/api/v1/models", Summary: "Add a model to the catalog",
+4
View File
@@ -313,6 +313,9 @@ var Reference = []Block{
{Key: "balance_abs", Type: "float64", Doc: "", Nested: ""},
{Key: "balance_rel", Type: "float64", Doc: "", Nested: ""},
}},
{Package: "config", Name: "PresetSettings", Title: "PresetSettings", Doc: "", Fields: []Field{
{Key: "auto", Type: "bool", Doc: "Auto has the leading control plane add a picture model (flux-schnell)\nand a 3D model (triposr) by itself, on demand, once a node has a card\nthat fits one and the catalog has no model that makes the same kind of\nthing — so a new cluster's portal has its pictures and 3D pages\nwithout anybody writing a definition. Only where the cluster already\ninstalls ComfyUI (engines.comfyui). A preset somebody removed is not\nadded again by itself. In force on the leader within a minute.", Nested: ""},
}},
{Package: "catalog", Name: "PreviousOrigin", Title: "PreviousOrigin", Doc: "", Fields: []Field{
{Key: "source", Type: "string", Doc: "", Nested: ""},
{Key: "revision", Type: "string", Doc: "", Nested: ""},
@@ -438,6 +441,7 @@ var Reference = []Block{
{Key: "mail", Type: "mail.Config", Doc: "Mail is where the gateway's few emails go out from: a link to set a\nforgotten password. Unset, none is sent and people ask an operator.", Nested: "mail.Config"},
{Key: "serving", Type: "ServingSettings", Doc: "Serving stops and starts the whole cluster's service (llmgw stop, llmgw\nstart).", Nested: "config.ServingSettings"},
{Key: "firewall", Type: "FirewallSettings", Doc: "Firewall turns llmgw's firewall on every machine on or off.", Nested: "config.FirewallSettings"},
{Key: "presets", Type: "PresetSettings", Doc: "Presets is how the models that come with llmgw reach the catalog.", Nested: "config.PresetSettings"},
{Key: "agents", Type: "AgentSettings", Doc: "Agents are the nodes' agent settings: agent.yaml's keys but those an\nagent needs to start, for every node (default) and for one\n(nodes.<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: "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/firewall.go",
"internal/config/serving.go",
"internal/config/presets.go",
"internal/config/agents.go",
"internal/mail/mail.go",
"internal/notify/notify.go",
+4
View File
@@ -329,6 +329,10 @@ func (r *Router) Handler() http.Handler {
if e, ok := r.clusterView.(web.NodeEngineAdmin); ok {
pages.RegisterNodeEngines(mux, e)
}
// The models that come with llmgw, added from the same page.
if a, ok := r.clusterView.(web.PresetAdmin); ok {
pages.RegisterPresets(mux, a)
}
pages.RegisterAdminQuotas(mux, r.store, r.acct)
// And the report of what that tree actually used.
if r.history != nil {
+9 -5
View File
@@ -221,12 +221,16 @@ func glbWithLicense(glb []byte, lic catalog.License) ([]byte, error) {
if extras == nil {
extras = map[string]any{}
}
l := map[string]any{"name": lic.Name}
if lic.URL != "" {
l["url"] = lic.URL
// The whole licence, as /v1/models gives it: its id, whether it allows
// commercial use and where it grants nothing are what a pipeline that
// sorts assets by licence reads, and the name alone is for people.
var l map[string]any
b, err := json.Marshal(lic)
if err != nil {
return nil, err
}
if lic.Notice != "" {
l["notice"] = lic.Notice
if err := json.Unmarshal(b, &l); err != nil {
return nil, err
}
extras["license"] = l
asset["extras"] = extras
+31
View File
@@ -401,3 +401,34 @@ func TestAModelAsleepUntilAskedIsListedAndSaidAsleep(t *testing.T) {
t.Error("a model serving is not said loaded")
}
}
func TestAMeshCarriesItsWholeLicence(t *testing.T) {
// The name alone was written: a pipeline that sorts assets by licence
// reads the id, whether commercial use is allowed and where it is not.
glb, err := objToGLB([]byte("v 0 0 0\nv 1 0 0\nv 1 1 0\nf 1 2 3\n"))
if err != nil {
t.Fatal(err)
}
yes := true
out, err := glbWithLicense(glb, catalog.License{ID: "stability-community", Name: "Stability AI Community License",
Commercial: &yes, RevenueCapUSD: 1000000, Attribution: "Powered by Stability AI", ExcludedRegions: []string{"XX"}})
if err != nil {
t.Fatal(err)
}
n := binary.LittleEndian.Uint32(out[12:])
var doc struct {
Asset struct {
Extras struct {
License map[string]any `json:"license"`
} `json:"extras"`
} `json:"asset"`
}
if err := json.Unmarshal(out[20:20+n], &doc); err != nil {
t.Fatal(err)
}
l := doc.Asset.Extras.License
if l["id"] != "stability-community" || l["commercial"] != true || l["revenue_cap_usd"] != 1000000.0 ||
l["attribution"] != "Powered by Stability AI" || l["name"] == nil {
t.Errorf("licence %v", l)
}
}
+1
View File
@@ -27,6 +27,7 @@ var copyOrder = []string{
"memberships",
// After users, which it points at.
"user_excluded_licences",
"preset_state",
"api_keys",
"key_claims",
"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)
}
}
+147
View File
@@ -0,0 +1,147 @@
package web
import (
"context"
"net/http"
"net/url"
"strings"
"gitea.mynodes.xyz/ilker/llmgw/internal/control/adminapi"
"gitea.mynodes.xyz/ilker/llmgw/internal/store"
)
// PresetAdmin is the models that come with llmgw, as the cluster page shows
// and adds them: flux-schnell for pictures, triposr for 3D models. A site
// whose catalog had neither showed no Images or 3D page, and nothing said
// they were one click away.
type PresetAdmin interface {
Presets(ctx context.Context) (adminapi.PresetsView, error)
AddPreset(ctx context.Context, name, by string) error
SetAutoPresets(ctx context.Context, on bool, by string) error
}
// presetsCard is the presets as the cluster page shows them.
type presetsCard struct {
Auto bool
Rows []presetRow
}
// presetRow is one preset, its words already in the reader's language.
type presetRow struct {
Name, Description, Makes, Card, Licence string
// Fits says which of the cluster's classes can run it; empty when none.
Fits string
// State is where it stands: added, and by whom; removed, so not added
// again by itself; or neither.
State string
InCatalog bool
Declined bool
// Blocked is why the leader does not add it by itself, in the control
// plane's words (English); not said when automatic adding is off, which
// the line under the table says once.
Blocked string
// AddsComfyUI is an Add that also has the GPU nodes install ComfyUI and
// the control planes restart on that setting.
AddsComfyUI bool
}
// presetAddsComfyUI is whether adding a preset by hand sets engines.comfyui:
// the cluster installs none yet.
func presetAddsComfyUI(v adminapi.PresetsView, p adminapi.PresetView) bool {
return !p.InCatalog && v.ComfyUI == ""
}
func init() {
registerKeys("dash.presets.makes.pictures", "dash.presets.makes.meshes")
}
// presetsCardOf reads the presets for the page; nil, and the card left off,
// where the control plane cannot say.
func presetsCardOf(ctx context.Context, l *Lang, a PresetAdmin) *presetsCard {
v, err := a.Presets(ctx)
if err != nil {
return nil
}
c := &presetsCard{Auto: v.Auto}
for _, p := range v.Presets {
row := presetRow{
Name: p.Name, Description: p.Description, Card: p.Card,
Makes: l.T("dash.presets.makes." + p.Makes),
Licence: p.Licence.Name,
Fits: strings.Join(p.FitsClasses, ", "),
InCatalog: p.InCatalog, Declined: p.Declined,
AddsComfyUI: presetAddsComfyUI(v, p),
}
if !strings.HasPrefix(p.Blocked, "presets.auto") && !p.Declined {
row.Blocked = p.Blocked
}
if c := p.Licence.Commercial; c != nil && !*c {
row.Licence = l.T("dash.presets.not_commercial", p.Licence.Name)
}
at := ""
if !p.AddedAt.IsZero() {
at = p.AddedAt.UTC().Format("2006-01-02 15:04")
}
switch {
case p.InCatalog && p.AddedBy == "auto":
row.State = l.T("dash.presets.added_auto", at)
case p.InCatalog && p.AddedBy != "":
row.State = l.T("dash.presets.added_by", p.AddedBy, at)
case p.InCatalog:
row.State = l.T("dash.presets.in_catalog")
case p.Declined:
row.State = l.T("dash.presets.declined")
}
c.Rows = append(c.Rows, row)
}
return c
}
// RegisterPresets mounts adding a preset, and turning their automatic adding
// on and off, on the cluster page: for an administrator, with the session's
// CSRF token, answered by going back to the page with what happened.
func (p *AccountPages) RegisterPresets(mux *http.ServeMux, a PresetAdmin) {
back := func(w http.ResponseWriter, r *http.Request, q url.Values) {
http.Redirect(w, r, "/admin?"+q.Encode()+"#presets", http.StatusSeeOther)
}
checked := func(h func(w http.ResponseWriter, r *http.Request, admin store.User)) http.HandlerFunc {
return p.adminOnly(func(w http.ResponseWriter, r *http.Request, admin store.User) {
sess, _, _ := p.session(r)
if !p.accounts.CheckCSRF(sess, r.FormValue("csrf")) {
http.Error(w, "bad request", http.StatusBadRequest)
return
}
h(w, r, admin)
})
}
// Before the one by name, which would otherwise take "auto" for a preset.
mux.HandleFunc("POST /admin/presets/auto", checked(func(w http.ResponseWriter, r *http.Request, admin store.User) {
on := r.FormValue("on") == "true"
q := url.Values{}
if err := a.SetAutoPresets(r.Context(), on, admin.Email); err != nil {
p.log.Warn("presets.auto was not changed", "admin", admin.Email, "err", err)
q.Set("problem", err.Error())
} else {
p.log.Info("presets.auto changed", "admin", admin.Email, "on", on)
key := "dash.presets.notice.auto_off"
if on {
key = "dash.presets.notice.auto_on"
}
q.Set("notice", langOf(r).T(key))
}
back(w, r, q)
}))
mux.HandleFunc("POST /admin/presets/{name}", checked(func(w http.ResponseWriter, r *http.Request, admin store.User) {
name := r.PathValue("name")
q := url.Values{}
if err := a.AddPreset(r.Context(), name, admin.Email); err != nil {
p.log.Warn("a preset was not added", "admin", admin.Email, "preset", name, "err", err)
// The control plane's reason, as it gave it: in English.
q.Set("problem", name+": "+err.Error())
} else {
p.log.Info("a preset was added", "admin", admin.Email, "preset", name)
q.Set("notice", langOf(r).T("dash.presets.notice.added", name))
}
back(w, r, q)
}))
}
+134
View File
@@ -0,0 +1,134 @@
package web
import (
"context"
"errors"
"io"
"log/slog"
"net/http"
"net/url"
"strings"
"testing"
"time"
"gitea.mynodes.xyz/ilker/llmgw/internal/control/adminapi"
"gitea.mynodes.xyz/ilker/llmgw/internal/control/catalog"
"gitea.mynodes.xyz/ilker/llmgw/internal/store"
)
// presetFleet is a cluster whose control plane says its presets: triposr
// added by itself, flux-schnell removed by somebody.
type presetFleet struct {
fakeFleet
view adminapi.PresetsView
err error
added []string
auto []bool
}
func (f *presetFleet) Presets(context.Context) (adminapi.PresetsView, error) { return f.view, f.err }
func (f *presetFleet) AddPreset(_ context.Context, name, by string) error {
f.added = append(f.added, name+" by "+by)
return nil
}
func (f *presetFleet) SetAutoPresets(_ context.Context, on bool, _ string) error {
f.auto = append(f.auto, on)
return nil
}
func newPresetFleet() *presetFleet {
no := false
return &presetFleet{view: adminapi.PresetsView{Auto: true, Presets: []adminapi.PresetView{
{Name: "flux-schnell", Makes: "pictures", Card: "cc 7.0+, 24 GB+", Licence: catalog.License{Name: "Apache-2.0"},
FitsClasses: []string{"v100-32g"}, Fits: true, Declined: true,
Blocked: "removed from the catalog after it was added; add it again to undo"},
{Name: "sdxl", Makes: "pictures", Card: "cc 8.0+", Licence: catalog.License{Name: "x"},
Blocked: "the cluster installs no ComfyUI (engines.comfyui); adding it by hand sets that"},
{Name: "triposr", Makes: "meshes", Card: "cc 7.0+, 8 GB+", Licence: catalog.License{Name: "Made-up", Commercial: &no},
FitsClasses: []string{"t4-15g", "v100-32g"}, Fits: true, InCatalog: true, AddedBy: "auto",
AddedAt: time.Date(2026, 10, 13, 9, 30, 0, 0, time.UTC)},
}}}
}
func TestThePicturesAnd3DModelsAreAddedFromTheClusterPage(t *testing.T) {
a := fakeAccounts{user: store.User{ID: "u1", Email: "a@example.com", IsAdmin: true}}
p := NewAccountPages(a, nil, nil, "v0", slog.New(slog.NewTextHandler(io.Discard, nil)))
f := newPresetFleet()
mux := http.NewServeMux()
mux.HandleFunc("GET /admin", p.AdminPage(Dashboard(f, nil, "v1", time.Second, false, NavExtras{}, nil)))
p.RegisterPresets(mux, f)
body := mirrorGet(t, mux, "/admin").Body.String()
for _, want := range []string{
`id="presets"`, "Pictures and 3D models",
`action="/admin/presets/flux-schnell"`, "Add again", "removed, so not added again by itself",
"added automatically, 2026-10-13 09:30", "Made-up, not for commercial use", "t4-15g, v100-32g",
`action="/admin/presets/auto"`, `name="on" value="false"`, "Stop adding them",
"(the cluster installs no ComfyUI (engines.comfyui); adding it by hand sets that)",
"restarts the control planes",
} {
if !strings.Contains(body, want) {
t.Errorf("%q not on the page", want)
}
}
// The button's title and the line beside it, on each preset not here:
// the cluster installs no ComfyUI yet.
if n := strings.Count(body, "restarts the control planes"); n != 4 {
t.Errorf("the ComfyUI warning is said %d times, not on the two presets an Add would install it for", n)
}
if strings.Contains(body, "(removed from the catalog") {
t.Error("a decline is said twice")
}
if strings.Contains(body, `action="/admin/presets/triposr"`) {
t.Error("a preset in the catalog is offered to be added")
}
w := mirrorPost(t, mux, "/admin/presets/flux-schnell", url.Values{"csrf": {"tok"}})
if w.Code != http.StatusSeeOther || len(f.added) != 1 || f.added[0] != "flux-schnell by a@example.com" {
t.Fatalf("status %d, added %v", w.Code, f.added)
}
if loc := w.Header().Get("Location"); !strings.Contains(loc, "notice=") || !strings.HasSuffix(loc, "#presets") {
t.Errorf("redirected to %s", loc)
}
w = mirrorPost(t, mux, "/admin/presets/auto", url.Values{"csrf": {"tok"}, "on": {"false"}})
if w.Code != http.StatusSeeOther || len(f.auto) != 1 || f.auto[0] || len(f.added) != 1 {
t.Fatalf("status %d, auto %v, added %v: auto was taken for a preset's name", w.Code, f.auto, f.added)
}
if w := mirrorPost(t, mux, "/admin/presets/triposr", url.Values{"csrf": {"bad"}}); w.Code != http.StatusBadRequest || len(f.added) != 1 {
t.Errorf("a forged form was accepted: %d", w.Code)
}
}
func TestNoComfyUIWarningWhereTheClusterInstallsIt(t *testing.T) {
f := newPresetFleet()
f.view.ComfyUI = "0.39.2"
a := fakeAccounts{user: store.User{ID: "u1", Email: "a@example.com", IsAdmin: true}}
p := NewAccountPages(a, nil, nil, "v0", slog.New(slog.NewTextHandler(io.Discard, nil)))
mux := http.NewServeMux()
mux.HandleFunc("GET /admin", p.AdminPage(Dashboard(f, nil, "v1", time.Second, false, NavExtras{}, nil)))
body := mirrorGet(t, mux, "/admin").Body.String()
if !strings.Contains(body, `action="/admin/presets/flux-schnell"`) {
t.Fatal("no Add on the page")
}
if strings.Contains(body, "restarts the control planes") {
t.Error("warned of installing a ComfyUI the cluster already installs")
}
}
func TestThePresetsCardIsLeftOffWhereTheControlPlaneCannotSay(t *testing.T) {
f := newPresetFleet()
f.err = errors.New("501")
_, body := get(t, Dashboard(f, nil, "v1", time.Second, true, NavExtras{}, nil), "http://127.0.0.1:9090/", nil)
if strings.Contains(body, `id="presets"`) {
t.Error("an empty presets card was drawn")
}
// The read-only page shows them, with nothing to press.
f.err = nil
_, body = get(t, Dashboard(f, nil, "v1", time.Second, true, NavExtras{}, nil), "http://127.0.0.1:9090/", nil)
if !strings.Contains(body, `id="presets"`) || strings.Contains(body, `/admin/presets/`) {
t.Errorf("the admin listener's page: card %v, forms %v",
strings.Contains(body, `id="presets"`), strings.Contains(body, `/admin/presets/`))
}
}
+8 -2
View File
@@ -36,8 +36,11 @@ type DashboardData struct {
Models []adminapi.ModelView
Replicas []adminapi.ReplicaView
Unplaced []adminapi.ExplainView
Usage []store.KeyUsage
Summary adminapi.Summary
// Presets is the models that come with llmgw — pictures, 3D models —
// and whether each is here; nil where the control plane cannot say.
Presets *presetsCard
Usage []store.KeyUsage
Summary adminapi.Summary
// Fabrics is the physical fabrics and what disagrees about them, from a
// source that can say.
Fabrics *fabrics.Report
@@ -236,6 +239,9 @@ func Dashboard(src Source, usage UsageSource, version string, refresh time.Durat
}
}
d.Unplaced = unplaced(src, d.Models)
if pa, ok := src.(PresetAdmin); ok {
d.Presets = presetsCardOf(r.Context(), l, pa)
}
ids := make([]string, 0, len(d.Nodes))
for _, n := range d.Nodes {
ids = append(ids, n.ID)
+27 -1
View File
@@ -196,5 +196,31 @@
"dash.usage.none": "No requests recorded in the last 24 hours.",
"dash.usage.auth_off": "Authentication is off, so there is no identity to account against.",
"dash.admin_listener": "Metrics, pprof and the management API are on the admin listener, which is bound to loopback and not reachable from here. pprof can dump the process heap, where API keys sit in plaintext, which is why that port is not exposed:"
"dash.admin_listener": "Metrics, pprof and the management API are on the admin listener, which is bound to loopback and not reachable from here. pprof can dump the process heap, where API keys sit in plaintext, which is why that port is not exposed:",
"dash.presets.title": "Pictures and 3D models",
"dash.presets.about": "Models that come with llmgw, their workflow, nodes and licence already tried. Adding one also has the GPU nodes install ComfyUI, about 10 GB each, when the cluster has none.",
"dash.presets.makes": "Makes",
"dash.presets.makes.pictures": "pictures",
"dash.presets.makes.meshes": "3D models",
"dash.presets.card": "Needs",
"dash.presets.licence": "Licence",
"dash.presets.not_commercial": "%s, not for commercial use",
"dash.presets.fits": "Fits",
"dash.presets.fits_none": "no card here",
"dash.presets.state": "Here",
"dash.presets.added_auto": "added automatically, %s",
"dash.presets.added_by": "added by %s, %s",
"dash.presets.in_catalog": "in the catalog",
"dash.presets.declined": "removed, so not added again by itself",
"dash.presets.add": "Add",
"dash.presets.add_again": "Add again",
"dash.presets.auto_on": "Added by themselves: when a card fits one and the catalog has nothing that makes what it makes.",
"dash.presets.auto_off": "Not added by themselves: only when somebody adds one.",
"dash.presets.turn_off": "Stop adding them",
"dash.presets.turn_on": "Add them by themselves",
"dash.presets.notice.added": "%s added; it is placed on the next pass, and loads when first asked for",
"dash.presets.notice.auto_on": "Pictures and 3D models are added by themselves where a card fits",
"dash.presets.notice.auto_off": "Pictures and 3D models are no longer added by themselves",
"dash.presets.adds_comfyui": "Adding it installs ComfyUI on the GPU nodes, about 10 GB each, and restarts the control planes, standbys first"
}
+27 -1
View File
@@ -194,5 +194,31 @@
"dash.usage.none": "Son 24 saatte hiç istek kaydedilmedi.",
"dash.usage.auth_off": "Kimlik doğrulama kapalı; bu yüzden kullanımın yazılacağı bir kimlik yok.",
"dash.admin_listener": "Metrikler, pprof ve yönetim API'si yönetici dinleyicisindedir; bu dinleyici loopback'e bağlıdır ve buradan erişilemez. pprof, API anahtarlarının düz metin olarak durduğu süreç belleğinin dökümünü alabilir; o portun dışarı açılmamasının nedeni budur:"
"dash.admin_listener": "Metrikler, pprof ve yönetim API'si yönetici dinleyicisindedir; bu dinleyici loopback'e bağlıdır ve buradan erişilemez. pprof, API anahtarlarının düz metin olarak durduğu süreç belleğinin dökümünü alabilir; o portun dışarı açılmamasının nedeni budur:",
"dash.presets.title": "Resim ve 3D modelleri",
"dash.presets.about": "llmgw ile gelen modeller; iş akışları, düğümleri ve lisansları denenmiş. Birini eklemek, kümede ComfyUI yoksa GPU düğümlerine onu da kurdurur (her birine yaklaşık 10 GB).",
"dash.presets.makes": "Ürettiği",
"dash.presets.makes.pictures": "resim",
"dash.presets.makes.meshes": "3D model",
"dash.presets.card": "Gerekli kart",
"dash.presets.licence": "Lisans",
"dash.presets.not_commercial": "%s, ticari kullanım yok",
"dash.presets.fits": "Uyan sınıflar",
"dash.presets.fits_none": "burada uyan kart yok",
"dash.presets.state": "Durum",
"dash.presets.added_auto": "kendiliğinden eklendi, %s",
"dash.presets.added_by": "%s ekledi, %s",
"dash.presets.in_catalog": "katalogda",
"dash.presets.declined": "kaldırıldı; kendiliğinden yeniden eklenmez",
"dash.presets.add": "Ekle",
"dash.presets.add_again": "Yeniden ekle",
"dash.presets.auto_on": "Kendiliğinden ekleniyorlar: bir kart uyduğunda ve katalogda aynı işi yapan model olmadığında.",
"dash.presets.auto_off": "Kendiliğinden eklenmiyorlar: yalnızca biri eklediğinde.",
"dash.presets.turn_off": "Kendiliğinden eklemeyi durdur",
"dash.presets.turn_on": "Kendiliğinden ekle",
"dash.presets.notice.added": "%s eklendi; sonraki turda yerleştirilir, ilk istendiğinde yüklenir",
"dash.presets.notice.auto_on": "Resim ve 3D modelleri, kart uyan yerde kendiliğinden eklenecek",
"dash.presets.notice.auto_off": "Resim ve 3D modelleri artık kendiliğinden eklenmeyecek",
"dash.presets.adds_comfyui": "Eklemek GPU düğümlerine ComfyUI kurar (her birine yaklaşık 10 GB) ve kontrol düzlemlerini yeniden başlatır, önce yedekler"
}
+28
View File
@@ -163,6 +163,34 @@ document.querySelectorAll("form.build-choice select").forEach(function (s) {
{{end}}</table>
{{else}}<p class="empty">{{t "dash.models.empty"}}</p>{{end}}
{{with .Presets}}
<h3 id="presets">{{t "dash.presets.title"}}</h3>
<p class="note">{{t "dash.presets.about"}}</p>
<table class="rows"><tr><th>{{t "dash.col.model"}}</th><th>{{t "dash.presets.makes"}}</th><th>{{t "dash.presets.card"}}</th><th>{{t "dash.presets.licence"}}</th><th>{{t "dash.presets.fits"}}</th><th>{{t "dash.presets.state"}}</th><th></th></tr>
{{range .Rows}}<tr>
<td><code>{{.Name}}</code></td>
<td title="{{.Description}}">{{.Makes}}</td>
<td>{{.Card}}</td>
<td>{{.Licence}}</td>
<td>{{or .Fits (t "dash.presets.fits_none")}}</td>
{{/* Why the leader does not add it itself is the control plane's, in English. */}}
<td{{if .Declined}} class="note"{{end}}>{{or .State "—"}}{{with .Blocked}} <span class="note">({{.}})</span>{{end}}</td>
<td>{{if and $.Menu.CSRF (not .InCatalog)}}
<form method="post" action="/admin/presets/{{.Name}}" class="inline">
<input type="hidden" name="csrf" value="{{$.Menu.CSRF}}">
<button type="submit"{{if .AddsComfyUI}} title="{{t "dash.presets.adds_comfyui"}}"{{end}}>{{if .Declined}}{{t "dash.presets.add_again"}}{{else}}{{t "dash.presets.add"}}{{end}}</button>
</form>{{if .AddsComfyUI}} <span class="warn">{{t "dash.presets.adds_comfyui"}}</span>{{end}}{{end}}</td>
</tr>
{{end}}</table>
<p>{{if .Auto}}{{t "dash.presets.auto_on"}}{{else}}{{t "dash.presets.auto_off"}}{{end}}
{{if $.Menu.CSRF}}
<form method="post" action="/admin/presets/auto" class="inline">
<input type="hidden" name="csrf" value="{{$.Menu.CSRF}}">
<input type="hidden" name="on" value="{{if .Auto}}false{{else}}true{{end}}">
<button type="submit">{{if .Auto}}{{t "dash.presets.turn_off"}}{{else}}{{t "dash.presets.turn_on"}}{{end}}</button>
</form>{{end}}</p>
{{end}}
{{if .Unplaced}}
<h2>{{t "dash.unplaced"}}</h2>
{{/* The scheduler's reasons, as it gives them. */}}