Compare commits

..
4 Commits
Author SHA1 Message Date
Ilker ManapandClaude Opus 5.5 c22c3a309b Describe v0.9.2
CI / test (push) Successful in 2m14s
Release / build (push) Successful in 3m1s
Release / notes (push) Successful in 3s
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-28 16:50:12 +02:00
Ilker ManapandClaude Opus 5.5 42fa602247 Make a new control plane serve a model by itself
server init now also:
- runs a model mirror beside the control plane (/srv/models, bytes on
  :8081 at an address nodes reach, management API on loopback so it
  needs no certificate) and adds it to server.yaml;
- makes the machine a node of its own control plane: issues its agent
  certificate, writes agent.yaml labelled as the control plane, starts
  llmgw-agent, and waits for it to register and install llama.cpp;
- checks for NVIDIA GPUs with nvidia-smi, and reports a card on the PCI
  bus whose driver does not answer rather than ignoring it;
- fetches Qwen2.5 0.5B (Q4_K_M) into the mirror and adds it to the
  catalog pinned to this node: a llama.cpp profile per GPU class found,
  with a CPU profile as the fallback, then waits for it to serve.

--no-mirror, --no-node and --no-model leave these out. Every step looks
before it acts, so a rerun finishes what an interrupted one started.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-28 16:49:55 +02:00
Ilker ManapandClaude Opus 5.5 5501f77805 Describe v0.9.1
CI / test (push) Successful in 2m22s
Release / build (push) Successful in 3m1s
Release / notes (push) Successful in 2s
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-28 16:37:43 +02:00
Ilker ManapandClaude Opus 5.5 75d69a6b9c Show the mirror one line per model version
Each held file took two table rows — seven columns and its address
beneath — so one sharded model filled the screen. A version is now one
line: model and version, the file or "N files", total size, and the
catalog models that use it. The address is behind a "copy address"
button (a set's is its directory, trailing slash included) and the
checksum is in the file's tooltip.

The model service reports which models name each source, so a file in
use shows who uses it and is not offered for serving a second time;
removing one warns that the catalog uses it. Downloads and failures
stay one line per file, since each has its own progress or retry. The
header counts versions, not files, and the nav calls /admin/users
"people" everywhere.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-28 16:36:36 +02:00
15 changed files with 816 additions and 41 deletions

No files matched your search

+26
View File
@@ -19,6 +19,32 @@ is only released once its **exit criterion** is met, not when its code lands.
Until v1.0.0 the minor version is the breaking one: configuration formats and Go
APIs change between minors without a deprecation period.
## v0.9.2 — 2026-09-28
### A new control plane serves a model by itself
`llmgw server init` now also runs a model mirror on the same machine, makes the
machine a node of its own control plane, and fetches Qwen2.5 0.5B into the
mirror and serves it there — on the machine's NVIDIA GPU when `nvidia-smi` finds
one (the node installs the CUDA build of llama.cpp), on CPU otherwise, with a
CPU profile as the fallback. An NVIDIA card on the PCI bus with no working
driver is reported. `--no-mirror`, `--no-node` and `--no-model` leave these out,
and every step is skipped on a rerun when it is already done.
Not yet run end to end on a real machine.
## v0.9.1 — 2026-09-28
### The mirror page is one line per model
What the mirror holds used to take two table rows per file — seven columns and
the address under them — so a sharded model filled the screen. Now each version
is one line: model and version, the file (or "4 files" for a set), total size,
and which catalog models use it. The address moved into a "copy address" button;
the checksum is in the file's tooltip. A version already in use is not offered
for serving again, and removing one warns that the catalog uses it. The header
counts versions rather than files.
## v0.9.0 — 2026-09-28
### Managing people and the tenancy tree
+11
View File
@@ -104,6 +104,17 @@ That is the whole step. In order, it:
and promoted to administrator — the password is generated and **printed
once**
6. installs the systemd unit, starts it, and waits until `/health` answers
7. runs a model mirror beside it (`/srv/models`, bytes on `:8081`, its
management API on loopback) and points the control plane at it
8. makes this machine a node of its own control plane — on its GPUs when
`nvidia-smi` finds any, on CPU otherwise — and waits for it to install its
engine
9. fetches Qwen2.5 0.5B into the mirror and serves it on this node, so the
first thing anyone opens `/chat` to find is a model that answers
`--no-mirror`, `--no-node` and `--no-model` leave the last three out. An NVIDIA
card with no working driver is reported rather than silently ignored; install
the driver and run init again to serve on it.
Add `--postgres` to keep accounts and keys in PostgreSQL rather than SQLite:
init installs it and sets it up, and on a machine already running on SQLite it
+36
View File
@@ -69,6 +69,10 @@ type serverInit struct {
// postgres keeps the store in a PostgreSQL on this machine, installed if
// need be, moving whatever SQLite already holds.
postgres bool
// noMirror, noNode and noModel turn off the parts of a first install that
// make it serve something: a mirror, this machine as a node, and a small
// model on it.
noMirror, noNode, noModel bool
// pgSetup prepares PostgreSQL and returns the store address; a seam for
// tests. Nil does it for real.
pgSetup func(context.Context) (string, error)
@@ -95,6 +99,11 @@ func newServerInitCmd() *cobra.Command {
"control plane's certificate for every address this machine has, a\n" +
"configuration, an administrator account, and the systemd unit — then\n" +
"starts it and waits until it answers.\n\n" +
"Then it makes the machine serve something: a model mirror beside the\n" +
"control plane, this machine as a node of it (on its GPUs when nvidia-smi\n" +
"finds any, on CPU otherwise), and " + starterName + " fetched into the\n" +
"mirror and served on this node. --no-mirror, --no-node and --no-model\n" +
"leave those out.\n\n" +
"With --postgres the store is a PostgreSQL database on this machine:\n" +
"installed if there is none, with its own role and a generated password\n" +
"kept in /etc/llmgw/secrets. An existing SQLite store is copied into it,\n" +
@@ -121,6 +130,9 @@ func newServerInitCmd() *cobra.Command {
"an address agents dial besides the ones found on this machine; repeatable")
f.StringVar(&in.admin, "admin", "", "email of the first administrator; a password is generated and printed once")
f.StringVar(&in.pkiDir, "pki", in.pkiDir, "directory holding the certificate authority")
f.BoolVar(&in.noMirror, "no-mirror", false, "do not run a model mirror on this machine")
f.BoolVar(&in.noNode, "no-node", false, "do not make this machine a node (and serve no model on it)")
f.BoolVar(&in.noModel, "no-model", false, "do not fetch and serve the starter model ("+starterName+")")
f.BoolVar(&in.postgres, "postgres", false,
"keep the store in PostgreSQL on this machine, installing it and moving an existing SQLite store into it")
return cmd
@@ -146,9 +158,33 @@ func (in *serverInit) run(ctx context.Context) error {
if in.unitPath == "" {
return nil
}
hasSystemd := true
if _, err := exec.LookPath("systemctl"); err != nil {
hasSystemd = false
}
// Before the service starts, because the mirror goes into server.yaml.
if hasSystemd && !in.noMirror {
if err := in.ensureMirror(ctx); err != nil {
return fmt.Errorf("mirror: %w", err)
}
}
if err := in.ensureService(ctx); err != nil {
return fmt.Errorf("service: %w", err)
}
if hasSystemd && !in.noNode {
if _, err := in.ensureNode(ctx); err != nil {
return fmt.Errorf("node: %w", err)
}
switch {
case in.noModel:
case in.noMirror:
fmt.Fprintln(in.out, " skip no starter model without the mirror")
default:
if err := in.ensureStarter(ctx); err != nil {
return fmt.Errorf("starter model: %w", err)
}
}
}
in.nextSteps()
return nil
}
+513
View File
@@ -0,0 +1,513 @@
package cli
import (
"context"
"encoding/json"
"errors"
"fmt"
"net"
"os"
"os/exec"
"path/filepath"
"slices"
"strings"
"time"
"gitea.mynodes.xyz/ilker/llmgw/internal/agent/discovery"
"gitea.mynodes.xyz/ilker/llmgw/internal/config"
"gitea.mynodes.xyz/ilker/llmgw/internal/control/adminapi"
"gitea.mynodes.xyz/ilker/llmgw/internal/control/catalog"
"gitea.mynodes.xyz/ilker/llmgw/internal/mirror"
"gitea.mynodes.xyz/ilker/llmgw/internal/sourcecheck"
"gitea.mynodes.xyz/ilker/llmgw/internal/tlsconf"
)
// A control plane on its own serves nothing: it needs a node, weights, and a
// model, and the install guide made each of those a separate afternoon. init
// makes the machine all three — a mirror, a node, and a small model served on
// it — so the first thing anyone sees is an answer. Each is skipped when it is
// already there and can be turned off.
const (
mirrorDir = "/srv/models"
mirrorAPI = "127.0.0.1:8082"
mirrorUnitPath = "/etc/systemd/system/llmgw-mirror.service"
agentUnitPath = "/etc/systemd/system/llmgw-agent.service"
// The starter: small enough to download in a minute and run on any CPU,
// real enough to answer a question. Q4_K_M is about 400 MB.
starterName = "qwen2.5-0.5b-instruct"
starterAlias = "qwen-0.5b"
starterSource = "hf:Qwen/Qwen2.5-0.5B-Instruct-GGUF/qwen2.5-0.5b-instruct-q4_k_m.gguf"
starterVersion = "starter"
// localLabel marks the control plane's own node, so the starter model is
// served where it was asked for and does not wander to the first GPU node
// that joins later.
localLabelKey = "llmgw.control-plane"
localLabelValue = "true"
)
// agentUnit is deploy/systemd/llmgw-agent.service; a test holds them together.
const agentUnit = `[Unit]
Description=llmgw node agent
After=network-online.target
Wants=network-online.target
[Service]
ExecStart=/usr/local/bin/llmgw agent --config /etc/llmgw/agent.yaml
# Root, because the agent launches engines and needs device access. Engines
# outlive a restart of this unit and are adopted again on the next start, so
# KillMode must not take the whole process group with it.
KillMode=process
Restart=on-failure
RestartSec=2s
StateDirectory=llmgw-agent
[Install]
WantedBy=multi-user.target
`
// reachableAddr is the address other machines use for this one: the first
// name given with --host, else the first address it has, else its hostname.
// It is in the server certificate either way.
func (in *serverInit) reachableAddr() string {
if len(in.hosts) > 0 {
return in.hosts[0]
}
if ips := localAddrs(); len(ips) > 0 {
return ips[0].String()
}
name, _ := os.Hostname()
return name
}
func (in *serverInit) systemctl(ctx context.Context, args ...string) error {
out, err := exec.CommandContext(ctx, "systemctl", args...).CombinedOutput()
if err != nil {
return fmt.Errorf("systemctl %s: %v: %s", strings.Join(args, " "), err, strings.TrimSpace(string(out)))
}
return nil
}
// writeUnit installs a unit when it differs from what is there, keeping
// somebody's edited copy beside it, and says whether it changed.
func (in *serverInit) writeUnit(path, body string) (bool, error) {
b, err := os.ReadFile(path)
if err == nil && string(b) == body {
return false, nil
}
if err == nil {
_ = os.WriteFile(path+".bak", b, 0o644)
}
return true, os.WriteFile(path, []byte(body), 0o644)
}
// --- the mirror ---
func (in *serverInit) mirrorUnit() string {
base := "http://" + net.JoinHostPort(in.reachableAddr(), "8081")
return `[Unit]
Description=llmgw model mirror
After=network-online.target
Wants=network-online.target
[Service]
# Written by 'llmgw server init'. Bytes on :8081 for every node; the
# management API on loopback only, for the control plane beside it.
ExecStart=/usr/local/bin/llmgw mirror serve --dir ` + mirrorDir + ` --listen :8081 --api-listen ` + mirrorAPI + ` --base ` + base + `
User=llmgw
Group=llmgw
Restart=on-failure
RestartSec=2s
NoNewPrivileges=true
ProtectSystem=strict
ProtectHome=true
PrivateTmp=true
ReadWritePaths=` + mirrorDir + `
[Install]
WantedBy=multi-user.target
`
}
// ensureMirror runs a mirror on this machine and points the control plane at
// it. Its management API is on loopback, which is why it needs no certificate:
// nothing but this machine can reach it.
func (in *serverInit) ensureMirror(ctx context.Context) error {
cfg, err := config.Load(in.configPath())
if err != nil {
return err
}
if cfg.Mirror.API != "" && cfg.Mirror.API != "http://"+mirrorAPI {
in.kept("mirror at %s, as server.yaml says", cfg.Mirror.API)
return nil
}
if err := os.MkdirAll(mirrorDir, 0o755); err != nil {
return err
}
if err := in.chown(mirrorDir, true); err != nil {
return err
}
changed, err := in.writeUnit(mirrorUnitPath, in.mirrorUnit())
if err != nil {
return err
}
if err := in.systemctl(ctx, "daemon-reload"); err != nil {
return err
}
if err := in.systemctl(ctx, "enable", "--now", "llmgw-mirror"); err != nil {
return err
}
if changed {
if err := in.systemctl(ctx, "restart", "llmgw-mirror"); err != nil {
return err
}
in.done("model mirror in %s, serving on :8081", mirrorDir)
} else {
in.kept("model mirror in %s", mirrorDir)
}
if cfg.Mirror.API == "" {
if err := appendMirrorBlock(in.configPath()); err != nil {
return err
}
in.done("server.yaml points at the mirror")
}
m, err := mirror.NewClient("http://"+mirrorAPI, tlsconf.Config{}, 5*time.Second)
if err != nil {
return err
}
return waitFor(ctx, 30*time.Second, "the mirror to answer", func() (bool, error) {
return m.Health(ctx) == nil, nil
})
}
// appendMirrorBlock adds the mirror to server.yaml without touching the rest,
// keeping the file as it was beside it.
func appendMirrorBlock(path string) error {
b, err := os.ReadFile(path)
if err != nil {
return err
}
backup := fmt.Sprintf("%s.bak-%s", path, time.Now().Format("20060102-150405"))
if err := os.WriteFile(backup, b, 0o640); err != nil {
return err
}
block := "\n# The model mirror on this machine, added by 'llmgw server init'. Its\n" +
"# management API is on loopback, so it needs no certificate.\n" +
"mirror:\n api: http://" + mirrorAPI + "\n"
info, err := os.Stat(path)
if err != nil {
return err
}
return os.WriteFile(path, append(b, []byte(block)...), info.Mode().Perm())
}
// --- this machine as a node ---
// gpuClasses asks nvidia-smi what cards this machine has. None is an answer,
// not an error: the node then serves on CPU.
func gpuClasses(ctx context.Context) ([]string, int) {
gpus, err := discovery.DiscoverGPUsSMI(ctx, "")
if err != nil || len(gpus) == 0 {
return nil, 0
}
return discovery.Inventory{GPUs: gpus}.Classes(), len(gpus)
}
// ensureNode makes this machine an agent of its own control plane.
func (in *serverInit) ensureNode(ctx context.Context) (string, error) {
nodeID, _ := os.Hostname()
if nodeID == "" {
return "", errors.New("this machine has no hostname to use as its node id")
}
if classes, n := gpuClasses(ctx); n > 0 {
in.kept("%d NVIDIA GPU(s) here: %s — the node will use them", n, strings.Join(classes, ", "))
} else if hasNVIDIADevice() {
fmt.Fprintf(in.out, " !! an NVIDIA card is on the PCI bus but nvidia-smi does not answer: "+
"install the driver and run init again to serve on it; until then this node is CPU only\n")
} else {
in.kept("no NVIDIA GPU here; the node serves on CPU")
}
certPath := filepath.Join(in.etcDir, "agent.pem")
if _, err := os.Stat(certPath); err == nil {
in.kept("agent certificate for %s", nodeID)
} else {
if _, err := os.Stat(filepath.Join(in.pkiDir, "ca-key.pem")); err != nil {
return "", fmt.Errorf("the authority is not on this machine, so init cannot give it a node " +
"identity; onboard it from where the authority is, or pass --no-node")
}
ca, err := loadCA(in.pkiDir)
if err != nil {
return "", err
}
leaf, err := ca.IssueClient(nodeID)
if err != nil {
return "", err
}
if err := leaf.Write(in.etcDir, "agent"); err != nil {
return "", err
}
in.done("agent certificate for %s", nodeID)
}
agentConf := filepath.Join(in.etcDir, "agent.yaml")
if _, err := os.Stat(agentConf); err == nil {
in.kept("%s (never overwritten)", agentConf)
} else {
server := "https://" + net.JoinHostPort(in.reachableAddr(), "7443")
body := fmt.Sprintf(`# This machine as a node of its own control plane, written by 'llmgw server init'.
server: %q
node_id: %q
tls:
ca: %s/ca.pem
cert: %s/agent.pem
key: %s/agent-key.pem
labels:
%s: %q
`, server, nodeID, in.etcDir, in.etcDir, in.etcDir, localLabelKey, localLabelValue)
if err := os.WriteFile(agentConf, []byte(body), 0o644); err != nil {
return "", err
}
in.done("%s", agentConf)
}
changed, err := in.writeUnit(agentUnitPath, agentUnit)
if err != nil {
return "", err
}
if err := in.systemctl(ctx, "daemon-reload"); err != nil {
return "", err
}
if err := in.systemctl(ctx, "enable", "--now", "llmgw-agent"); err != nil {
return "", err
}
if changed {
_ = in.systemctl(ctx, "restart", "llmgw-agent")
in.done("llmgw-agent started")
}
// Registered is quick. Having an engine is not: the node downloads and
// checks its own llama.cpp, the CUDA build when it has a GPU, and a new
// machine is often busy with its own package updates first.
client := in.adminAPI()
last := ""
err = waitFor(ctx, 20*time.Minute, "this node to be ready with an engine", func() (bool, error) {
var nodes []adminapi.NodeView
if err := client.get(ctx, "/api/v1/nodes", &nodes); err != nil {
return false, nil
}
for _, n := range nodes {
if n.ID != nodeID {
continue
}
if n.State == "ready" && slices.Contains(n.Engines, "llamacpp") {
return true, nil
}
for _, p := range n.Provision {
if line := fmt.Sprintf("%s %s: %s %s", p.Engine, p.Version, p.State, p.Detail); line != last {
fmt.Fprintf(in.out, " .. %s\n", strings.TrimSpace(line))
last = line
}
if p.State == "failed" || p.State == "unsupported" {
return false, fmt.Errorf("installing %s on this node: %s", p.Engine, p.Detail)
}
}
}
return false, nil
})
if err != nil {
return "", err
}
in.kept("node %s is ready", nodeID)
return nodeID, nil
}
// hasNVIDIADevice reports an NVIDIA card on the PCI bus, driver or not, so a
// machine whose GPU is invisible for want of a driver says so instead of
// quietly serving on CPU.
func hasNVIDIADevice() bool {
vendors, _ := filepath.Glob("/sys/bus/pci/devices/*/vendor")
for _, v := range vendors {
b, err := os.ReadFile(v)
if err == nil && strings.TrimSpace(string(b)) == "0x10de" {
class, _ := os.ReadFile(filepath.Join(filepath.Dir(v), "class"))
// 0x03xxxx is a display or 3D controller; NVIDIA also makes bridges.
if strings.HasPrefix(strings.TrimSpace(string(class)), "0x03") {
return true
}
}
}
return false
}
// --- the starter model ---
// starterModel is the catalog entry: a GPU profile first when there is a card,
// and a CPU profile always, both pinned to this machine.
func starterModel(source string, gpuClasses []string) catalog.Model {
pin := map[string]string{localLabelKey: localLabelValue}
var profiles []catalog.Profile
for _, class := range gpuClasses {
profiles = append(profiles, catalog.Profile{
Name: "gpu-" + class, Engine: "llamacpp", GPUClass: class,
Priority: 200, MinMemoryGB: 2, NodeSelector: pin,
Args: []string{"--ctx-size", "32768"},
})
}
profiles = append(profiles, catalog.Profile{
Name: "cpu", Engine: "llamacpp", Priority: 100, MinMemoryGB: 2, NodeSelector: pin,
Args: []string{"--ctx-size", "16384"},
})
return catalog.Model{
Name: starterName,
Aliases: []string{starterAlias},
Description: "A small model init put here so there is something to try; fast, not clever.",
Source: source,
RoutingProfile: "chat",
ContextLength: 4096,
MaxOutput: 1024,
Scaling: catalog.Scaling{Min: 1, Max: 1},
Profiles: profiles,
}
}
// ensureStarter fetches the starter model into the mirror and serves it here.
func (in *serverInit) ensureStarter(ctx context.Context) error {
client := in.adminAPI()
var models []adminapi.ModelView
if err := client.get(ctx, "/api/v1/models", &models); err != nil {
return fmt.Errorf("reading the catalog: %w", err)
}
for _, m := range models {
if m.Name == starterName {
in.kept("%s in the catalog", starterName)
return in.waitForStarter(ctx)
}
}
upstream, ok := sourcecheck.HTTPSource(starterSource)
if !ok {
return fmt.Errorf("cannot turn %s into a download", starterSource)
}
m, err := mirror.NewClient("http://"+mirrorAPI, tlsconf.Config{}, 15*time.Second)
if err != nil {
return err
}
st, err := m.Status(ctx, starterName, starterVersion)
var apiErr *mirror.APIError
switch {
case err == nil:
case errors.As(err, &apiErr) && apiErr.Status == 404:
if st, err = m.Fetch(ctx, mirror.FetchRequest{
Source: upstream, Model: starterName, Version: starterVersion,
}); err != nil {
return fmt.Errorf("asking the mirror for %s: %w", starterName, err)
}
fmt.Fprintf(in.out, " .. downloading %s into the mirror\n", starterName)
default:
return err
}
lastPct := -10
err = waitFor(ctx, 30*time.Minute, starterName+" to download", func() (bool, error) {
cur, err := m.Status(ctx, starterName, starterVersion)
if err != nil {
return false, nil
}
st = cur
switch cur.Status.State {
case mirror.Present:
return true, nil
case mirror.Failed:
return false, fmt.Errorf("the mirror could not download %s: %s", starterName, cur.Status.Err)
}
if pct, ok := cur.Status.Percent(); ok && pct >= lastPct+10 {
fmt.Fprintf(in.out, " .. %d%%\n", pct)
lastPct = pct
}
return false, nil
})
if err != nil {
return err
}
in.done("%s in the mirror", starterName)
classes, _ := gpuClasses(ctx)
body, err := json.Marshal(starterModel(st.URL, classes))
if err != nil {
return err
}
if err := client.post(ctx, "/api/v1/models", body, &map[string]string{}); err != nil {
return fmt.Errorf("adding %s to the catalog: %w", starterName, err)
}
where := "on CPU"
if len(classes) > 0 {
where = "on " + strings.Join(classes, ", ") + ", or CPU if the card is busy"
}
in.done("%s in the catalog, served here %s", starterName, where)
return in.waitForStarter(ctx)
}
func (in *serverInit) waitForStarter(ctx context.Context) error {
client := in.adminAPI()
last := ""
return waitFor(ctx, 10*time.Minute, starterName+" to start", func() (bool, error) {
var reps []adminapi.ReplicaView
if err := client.get(ctx, "/api/v1/replicas?model="+starterName, &reps); err != nil {
return false, nil
}
for _, r := range reps {
if r.State == "ready" {
in.kept("%s is serving (profile %s)", starterName, r.Profile)
return true, nil
}
}
for _, r := range reps {
line := r.State
if r.Err != "" {
line += ": " + r.Err
}
if line != last {
fmt.Fprintf(in.out, " .. %s %s\n", starterName, line)
last = line
}
}
return false, nil
})
}
func (in *serverInit) adminAPI() *ctlClient {
addr := "127.0.0.1:9090"
if cfg, err := config.Load(in.configPath()); err == nil && cfg.AdminListen != "" {
addr = cfg.AdminListen
}
return newCtlClient(addr)
}
// waitFor polls until done says so, fails, or the time runs out.
func waitFor(ctx context.Context, limit time.Duration, what string, done func() (bool, error)) error {
deadline := time.Now().Add(limit)
for {
ok, err := done()
if err != nil {
return err
}
if ok {
return nil
}
if time.Now().After(deadline) {
return fmt.Errorf("gave up waiting for %s after %s; run init again to keep waiting", what, limit)
}
select {
case <-ctx.Done():
return ctx.Err()
case <-time.After(2 * time.Second):
}
}
}
+89
View File
@@ -0,0 +1,89 @@
package cli
import (
"os"
"path/filepath"
"strings"
"testing"
"gitea.mynodes.xyz/ilker/llmgw/internal/config"
"gitea.mynodes.xyz/ilker/llmgw/internal/control/catalog"
)
func TestTheInitAgentUnitIsTheShippedUnit(t *testing.T) {
b, err := os.ReadFile("../../deploy/systemd/llmgw-agent.service")
if err != nil {
t.Fatal(err)
}
if string(b) != agentUnit {
t.Error("deploy/systemd/llmgw-agent.service and the unit 'server init' writes have drifted")
}
}
func TestTheStarterUsesTheGPUAndFallsBackToCPUOnThisMachine(t *testing.T) {
m := starterModel("http://10.0.0.5:8081/qwen2.5-0.5b-instruct/starter/q.gguf", []string{"4090-24g"})
c, err := catalog.New([]catalog.Model{m})
if err != nil {
t.Fatalf("the starter is not a valid catalog entry: %v", err)
}
got, _ := c.Resolve(starterName)
if len(got.Profiles) != 2 {
t.Fatalf("profiles = %+v", got.Profiles)
}
gpu, cpu := got.Profiles[0], got.Profiles[1]
if gpu.GPUClass != "4090-24g" || gpu.Priority <= cpu.Priority {
t.Errorf("the GPU profile should come first: %+v", gpu)
}
if cpu.GPUClass != "" || cpu.Engine != "llamacpp" {
t.Errorf("the fallback should be llama.cpp on CPU: %+v", cpu)
}
for _, p := range got.Profiles {
if p.NodeSelector[localLabelKey] != localLabelValue {
t.Errorf("profile %s is not pinned to this machine", p.Name)
}
}
if got.Scaling.Min != 1 {
t.Error("the starter should be running, not waiting to be asked for")
}
if m := starterModel("http://x/q.gguf", nil); len(m.Profiles) != 1 || m.Profiles[0].Name != "cpu" {
t.Errorf("without a GPU only the CPU profile: %+v", m.Profiles)
}
}
func TestTheMirrorBlockIsAddedAndReads(t *testing.T) {
in, out := newTestInit(t)
if err := in.run(t.Context()); err != nil {
t.Fatalf("%v\n%s", err, out)
}
if err := appendMirrorBlock(in.configPath()); err != nil {
t.Fatal(err)
}
cfg, err := config.Load(in.configPath())
if err != nil {
t.Fatal(err)
}
if cfg.Mirror.API != "http://"+mirrorAPI {
t.Errorf("mirror.api = %q", cfg.Mirror.API)
}
if err := cfg.Validate(); err != nil {
t.Error(err)
}
backups, _ := filepath.Glob(in.configPath() + ".bak-*")
if len(backups) != 1 {
t.Errorf("backups = %v", backups)
}
}
func TestTheMirrorUnitAdvertisesAnAddressNodesCanReach(t *testing.T) {
in := &serverInit{hosts: []string{"10.0.0.140"}}
u := in.mirrorUnit()
if !strings.Contains(u, "--base http://10.0.0.140:8081") {
t.Errorf("base: %s", u)
}
// The management port can delete models; nothing but this machine may
// reach it, which is what lets it go without a certificate.
if !strings.Contains(u, "--api-listen 127.0.0.1:8082") {
t.Errorf("the management API is not on loopback: %s", u)
}
}
+16
View File
@@ -70,6 +70,22 @@ func (s *Service) Declared(name string) bool {
return false
}
// Sources maps each weights source in the catalog to the models that name it,
// so a page listing files can say which of them are in use.
func (s *Service) Sources(ctx context.Context) (map[string][]string, error) {
c, err := s.Load(ctx)
if err != nil {
return nil, err
}
out := map[string][]string{}
for _, m := range c.Models() {
if m.Source != "" {
out[m.Source] = append(out[m.Source], m.Name)
}
}
return out, nil
}
// Add puts a model in the catalog and keeps it across restarts.
//
// Validated by building the whole catalog before anything is written. A model
+38 -9
View File
@@ -70,6 +70,14 @@ type mirrorRow struct {
// then not drawn at all: a progress bar that has to invent its own total is
// worse than a byte count that only goes up.
HasPercent bool
// Files is how many files a held version has. A version of several is one
// row — a fifteen-shard model is one thing to serve or remove, and fifteen
// rows of it pushed everything else off the page.
Files int
// UsedBy names the catalog models whose source is this row's address, so
// a file that is in use does not look like one waiting to be served.
UsedBy []string
}
type adminMirrorData struct {
@@ -276,25 +284,46 @@ func (p *AccountPages) renderMirror(w http.ResponseWriter, r *http.Request,
}
d.Base = got.Base
var sources map[string][]string
if p.catalog != nil {
if sources, err = p.catalog.Sources(r.Context()); err != nil {
p.log.Warn("could not read which models use the mirror", "err", err)
}
}
held := map[string]int{} // model/version -> index of its row
for _, st := range got.Models {
row := mirrorRow{
Status: st,
URL: mirror.Entry{Model: st.Model, Version: st.Version, File: st.File}.
URL(got.Base),
}
if pc, ok := st.Percent(); ok {
row.Percent, row.HasPercent = pc, true
}
switch st.State {
case mirror.Fetching:
d.Fetching++
case mirror.Failed:
d.Failed++
default:
d.Present++
key := st.Model + "/" + st.Version
if i, ok := held[key]; ok {
// Another file of a version already shown: counted into it.
d.Rows[i].Files++
d.Rows[i].BytesDone += st.BytesDone
d.Rows[i].URL = strings.TrimSuffix(
mirror.Entry{Model: st.Model, Version: st.Version}.URL(got.Base), "/") + "/"
continue
}
held[key] = len(d.Rows)
d.Present++ // versions, not files: what the rows below count
}
row := mirrorRow{
Status: st,
Files: 1,
URL: mirror.Entry{Model: st.Model, Version: st.Version, File: st.File}.
URL(got.Base),
}
if pc, ok := st.Percent(); ok {
row.Percent, row.HasPercent = pc, true
}
d.Rows = append(d.Rows, row)
}
for i := range d.Rows {
d.Rows[i].UsedBy = sources[d.Rows[i].URL]
}
// The one row whose serve form is open, and only when the catalog can be
// changed at all.
+33 -1
View File
@@ -331,7 +331,7 @@ func TestAFailedRowOffersRetryRatherThanRemove(t *testing.T) {
if strings.Contains(body, "/admin/mirror/m/v/remove") {
t.Error("a failed row should not offer a remove that can only 404")
}
if !strings.Contains(body, "Retry") {
if !strings.Contains(body, ">retry<") {
t.Error("a failed row should offer to start the download again")
}
if !strings.Contains(body, `value="http://upstream/model.gguf"`) {
@@ -535,3 +535,35 @@ func TestAlternativeQuantisationsStaySeparateRows(t *testing.T) {
t.Errorf("expected a form each plus the manual one, found %d", n)
}
}
func TestAHeldVersionIsOneLineAndSaysWhoUsesIt(t *testing.T) {
m := &fakeMirror{models: []mirror.Status{
{Model: "big", Version: "v1", File: "a-00001-of-00002.gguf", State: mirror.Present, BytesDone: 1 << 30},
{Model: "big", Version: "v1", File: "a-00002-of-00002.gguf", State: mirror.Present, BytesDone: 1 << 30},
{Model: "small", Version: "v1", File: "s.gguf", State: mirror.Present, BytesDone: 1 << 20},
}}
cat := &fakeCatalog{sources: map[string][]string{"http://mirror:8081/small/v1/s.gguf": {"small-chat"}}}
body := mirrorGet(t, mirrorPageDeps(t, MirrorDeps{Mirror: m, Catalog: cat}, true),
"/admin/mirror").Body.String()
if n := strings.Count(body, "/admin/mirror/big/v1/remove"); n != 1 {
t.Errorf("a two-file version should be one row, found %d", n)
}
if !strings.Contains(body, "2 files") || !strings.Contains(body, "2.0 GiB") {
t.Error("the row should count its files and add up their size")
}
if !strings.Contains(body, `data-url="http://mirror:8081/big/v1/"`) {
t.Error("a set's address is its directory, trailing slash included")
}
if !strings.Contains(body, ">small-chat<") {
t.Error("the model serving a file should be named on its row")
}
// Already served: offering to serve it again invites a second catalog
// entry for the same weights.
if strings.Contains(body, "serve=small:v1") {
t.Error("a file in use should not be offered for serving")
}
if !strings.Contains(body, "serve=big:v1") {
t.Error("a file nobody uses should be offered for serving")
}
}
+2
View File
@@ -15,6 +15,8 @@ import (
type CatalogAdmin interface {
Add(ctx context.Context, m catalog.Model, by string) error
Declared(name string) bool
// Sources maps a weights source to the catalog models that use it.
Sources(ctx context.Context) (map[string][]string, error)
}
// serveForm is the offer to put one mirrored version into service.
+4
View File
@@ -17,6 +17,7 @@ import (
type fakeCatalog struct {
added []catalog.Model
declared string
sources map[string][]string
err error
}
@@ -28,6 +29,9 @@ func (f *fakeCatalog) Add(_ context.Context, m catalog.Model, _ string) error {
return nil
}
func (f *fakeCatalog) Declared(name string) bool { return name == f.declared }
func (f *fakeCatalog) Sources(context.Context) (map[string][]string, error) {
return f.sources, nil
}
// fleet reports a cluster with a given number of cards and engines.
type fakeFleet struct {
+5
View File
@@ -357,3 +357,8 @@ input.search { width: 100%; max-width: 26rem; margin: 0 0 .7rem; padding: .45rem
font: inherit; border: 1px solid var(--line); border-radius: 6px;
background: var(--card); color: var(--fg); }
tr[hidden] { display: none; }
/* The mirror's list of what it holds: one line per version. */
table.held td.file { max-width: 22rem; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
table.held td.actions > * + * { margin-left: .7rem; }
table.held .tag { margin: 0 .3rem 0 0; text-transform: none; letter-spacing: 0; }
+39 -27
View File
@@ -12,7 +12,7 @@
{{if .Unreachable}}{{.Addr}} did not answer{{else}}{{.Present}} held ·
{{.Fetching}} downloading · {{.Failed}} failed · {{.Addr}}{{end}}
</p>
<nav><a href="/admin">cluster</a> <a href="/admin/users">accounts</a>
<nav><a href="/admin">cluster</a> <a href="/admin/users">people</a>
<a href="/profile">your keys</a></nav>
</header>
@@ -31,17 +31,15 @@ reachable from this machine.</p>
<h2>What the mirror holds</h2>
{{if .Rows}}
<table>
<tr><th>Model</th><th>Version</th><th>File</th><th>State</th><th>Size</th>
<th>Checksum</th><th></th></tr>
<table class="compact held">
<tr><th>Model</th><th>Files</th><th class="num">Size</th><th>In use</th><th></th></tr>
{{range .Rows}}<tr>
<td>{{.Model}}</td>
<td>{{.Version}}</td>
<td><code>{{.File}}</code></td>
<td class="state state-{{.State}}">{{.State}}</td>
<td><strong>{{.Model}}</strong> <span class="opt">{{.Version}}</span></td>
<td class="file" title="{{.File}}{{if .SHA256}} · sha256 {{.SHA256}}{{end}}">
{{if gt .Files 1}}{{.Files}} files{{else}}<code>{{.File}}</code>{{end}}</td>
<td class="num">
{{if eq (printf "%s" .State) "fetching"}}
{{if .HasPercent}}{{bytes .BytesDone}} / {{bytes .BytesTotal}} ({{.Percent}}%)
{{if .HasPercent}}{{.Percent}}% of {{bytes .BytesTotal}}
{{else}}{{/* No total came from upstream, so no percentage is invented —
a bar that guesses its own end and then stops moving is worse
than a number that only goes up. */}}
@@ -49,11 +47,14 @@ reachable from this machine.</p>
{{else if eq (printf "%s" .State) "failed"}}—
{{else}}{{bytes .BytesDone}}{{end}}
</td>
<td>{{if .SHA256}}<code title="{{.SHA256}}">{{slice .SHA256 0 12}}</code>{{else}}—{{end}}</td>
<td>
{{if eq (printf "%s" .State) "fetching"}}
<span class="note">downloading</span>
{{else if eq (printf "%s" .State) "failed"}}
{{if eq (printf "%s" .State) "fetching"}}<span class="state state-starting">downloading</span>
{{else if eq (printf "%s" .State) "failed"}}<span class="state state-failed">failed</span>
{{else if .UsedBy}}{{range .UsedBy}}<span class="tag">{{.}}</span>{{end}}
{{else}}<span class="opt">not served</span>{{end}}
</td>
<td class="actions">
{{if eq (printf "%s" .State) "failed"}}
{{/* Retry, not remove. A fetch that failed left nothing on disk, so the
remove would come back "the mirror holds nothing for this" — a button
whose only outcome is a confusing error. Starting the same download
@@ -65,25 +66,24 @@ reachable from this machine.</p>
<input type="hidden" name="model" value="{{.Model}}">
<input type="hidden" name="version" value="{{.Version}}">
<input type="hidden" name="file" value="{{.File}}">
<button type="submit" class="link">Retry</button>
<button type="submit" class="link">retry</button>
</form>
{{else}}
{{if $.HasCatalog}}{{if .Serve}}<a href="/admin/mirror">close</a>
{{else}}<a href="/admin/mirror?serve={{.Model}}:{{.Version}}">serve</a>{{end}}
{{end}}
{{else if eq (printf "%s" .State) "present"}}
<button type="button" class="link copy-url" data-url="{{.URL}}" title="{{.URL}}">copy address</button>
{{if and $.HasCatalog (not .UsedBy)}}{{if .Serve}}<a href="/admin/mirror">close</a>
{{else}}<a href="/admin/mirror?serve={{.Model}}:{{.Version}}">serve</a>{{end}}{{end}}
<form method="post" action="/admin/mirror/{{.Model}}/{{.Version}}/remove"
class="inline" onsubmit="return confirm('Remove {{.Model}}/{{.Version}} and every file in it?')">
class="inline" onsubmit="return confirm('Remove {{.Model}}/{{.Version}} and every file in it?{{if .UsedBy}} It is in use by the catalog.{{end}}')">
<input type="hidden" name="csrf" value="{{$.CSRF}}">
<button type="submit" class="link danger">Remove</button>
<button type="submit" class="link danger">remove</button>
</form>
{{end}}
</td>
</tr>
{{if .Err}}<tr><td colspan="7" class="error-row">{{.Err}}</td></tr>{{end}}
{{if not .Err}}<tr><td colspan="7" class="url-row"><code>{{.URL}}</code></td></tr>{{end}}
{{if .Err}}<tr><td colspan="5" class="error-row">{{.Err}}</td></tr>{{end}}
{{with .Serve}}
<tr class="editing"><td colspan="7">
<tr class="editing"><td colspan="5">
{{if .Refusal}}
{{/* Offered and then failing at placement is worse than not offered at all. */}}
<p class="error">Cannot be served as it stands: {{.Refusal}}.</p>
@@ -140,10 +140,22 @@ job by name if you would rather pin it.</p>
{{end}}
{{end}}
</table>
<p class="note">The address under each row is what a catalog entry should say.
It is rendered here rather than assembled by hand because the mirror and the
catalog have to spell the same file the same way — two spellings is what makes a
file that is in use look unreferenced.</p>
<p class="note">“copy address” gives what a catalog entry's <code>source</code>
should say — the file, or for a version of several files the directory, with its
trailing slash. It is rendered rather than assembled by hand because the mirror
and the catalog have to spell a file the same way, which is also how this page
knows which models use it.</p>
<script>
document.addEventListener('click', function (e) {
var b = e.target.closest('.copy-url');
if (!b) return;
var done = function (t) { var o = b.textContent; b.textContent = t; setTimeout(function () { b.textContent = o; }, 1500); };
if (navigator.clipboard) {
navigator.clipboard.writeText(b.dataset.url).then(function () { done('copied'); },
function () { window.prompt('Address', b.dataset.url); });
} else { window.prompt('Address', b.dataset.url); }
});
</script>
{{else}}<p class="empty">The mirror is reachable and holds nothing yet.</p>{{end}}
{{if .HasRegistry}}
+2 -2
View File
@@ -6,7 +6,7 @@
<header>
<h1>Quotas</h1>
<p class="sub">What each organization, team and project may use · spend over the last {{.Since}}</p>
<nav><a href="/admin">cluster</a> <a href="/admin/users">accounts</a>
<nav><a href="/admin">cluster</a> <a href="/admin/users">people</a>
<a href="/admin/usage">usage</a> <a href="/profile">your keys</a></nav>
</header>
@@ -89,7 +89,7 @@ unaffected — they count requests and tokens, not money. Set prices in the
</table>
{{else}}
<p class="empty">Nothing to give a quota to yet. Make an organization on the
<a href="/admin/users">accounts page</a> first.</p>
<a href="/admin/users">people page</a> first.</p>
{{end}}
<p class="note">Limits compose downward by taking the most restrictive value, so
+1 -1
View File
@@ -6,7 +6,7 @@
<header>
<h1>Usage over time</h1>
<p class="sub">Who is using the cluster, and when</p>
<nav><a href="/admin">cluster</a> <a href="/admin/users">accounts</a>
<nav><a href="/admin">cluster</a> <a href="/admin/users">people</a>
<a href="/admin/quotas">quotas</a> <a href="/profile">your keys</a></nav>
</header>
+1 -1
View File
@@ -13,7 +13,7 @@
<a href="/debug/pprof/">pprof</a>
<a href="/api/v1/nodes">api</a>
{{else}}
<a href="/admin/users">accounts</a>
<a href="/admin/users">people</a>
<a href="/admin/usage">usage</a>
{{if .Nav.Mirror}} <a href="/admin/mirror">mirror</a>
{{end}}