Files
llmgw/internal/cli/judgekeeper.go
T
Ilker ManapandClaude Opus 5.5 2545fca174
CI / test (push) Successful in 8m53s
An upgrade refits the judge an earlier server init installed
A v0.14.6 install on fewer than eight cores refused every request until
its judge was given cores by hand, and upgrading did not change that. The
judge keeper, on the leader, now finds a judge whose entry is exactly what
init before v0.14.7 wrote: one llama.cpp CPU profile, an 8192 context and
nothing else, 2 or 4 GB, none to two cores. It gives the judge 2 GB,
--cache-ram 0 and cores by init's rule through modelsvc.SetProfiles, then
restarts its ready replicas. The replica count is left as it is. A judge
whose entry differs is left alone, and the log says so once.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-16 22:06:56 +02:00

216 lines
6.5 KiB
Go

package cli
import (
"context"
"log/slog"
"slices"
"time"
"gitea.mynodes.xyz/ilker/llmgw/internal/control/adminapi"
"gitea.mynodes.xyz/ilker/llmgw/internal/control/catalog"
"gitea.mynodes.xyz/ilker/llmgw/internal/control/modelserve"
"gitea.mynodes.xyz/ilker/llmgw/internal/guard"
)
// judgeKeeper fetches and serves, on the leader, each judge llmgw knows that
// a policy names and the catalog does not have: naming qwen3guard-4b in a
// policy is all it takes to have one, and a judge removed by mistake comes
// back. A judge it does not know is left to llmgw ctl guard judge enable
// --repo, said in guard judge status.
type judgeKeeper struct {
library func() guard.Config
models func() []catalog.Model
serve interface {
Start(modelserve.Request) (modelserve.Job, error)
Jobs() []modelserve.Job
}
leader func() bool
gpus func() []adminapi.ClassUse
log *slog.Logger
now func() time.Time
// tried is when each judge was last asked for, so one that cannot be
// fetched is not asked for again on every look.
tried map[string]time.Time
// What refitting an earlier install's judge needs: the catalog to change
// its profile in, the replicas to restart, and the nodes' CPUs. Any nil
// leaves judges as they are.
profiles interface {
SetProfiles(ctx context.Context, name string, profiles []catalog.Profile, by string) (catalog.Model, error)
}
restart func(ids ...string) error
replicas func(model string) []string
nodeCPUs func() []int
// looked is each judge already refitted or found changed by hand, so
// each is said once.
looked map[string]bool
}
// judgeRetry is how long a judge whose serving failed waits before it is
// asked for again.
const judgeRetry = 10 * time.Minute
func (k *judgeKeeper) run(ctx context.Context, every time.Duration) {
t := time.NewTicker(every)
defer t.Stop()
for {
k.check()
select {
case <-ctx.Done():
return
case <-t.C:
}
}
}
func (k *judgeKeeper) check() {
if k.leader != nil && !k.leader() {
return
}
lib := k.library()
if !lib.On() {
return
}
if k.tried == nil {
k.tried = map[string]time.Time{}
}
now := time.Now
if k.now != nil {
now = k.now
}
have := map[string]bool{}
for _, m := range k.models() {
have[m.Name] = true
k.refit(m)
}
busy := map[string]bool{}
for _, j := range k.serve.Jobs() {
if j.State == modelserve.Running {
busy[j.Model] = true
busy[j.Request.Name] = true
}
}
var wanted []string
for _, p := range lib.Policies {
for _, j := range p.Judge.Judges() {
if !slices.Contains(wanted, j) {
wanted = append(wanted, j)
}
}
}
slices.Sort(wanted)
for _, name := range wanted {
kj, known := knownJudgeNamed(name)
if have[name] || !known || busy[name] {
continue
}
if at, ok := k.tried[name]; ok && now().Sub(at) < judgeRetry {
continue
}
k.tried[name] = now()
var opts judgeServeOptions
if kj.GPU {
if opts.GPUClass = roomiestGPUClass(k.gpus()); opts.GPUClass == "" {
k.log.Warn("a policy names a judge that runs on a GPU, and this cluster has none",
"judge", name, "instead", defaultJudge)
continue
}
}
req := kj.serveRequest(opts)
req.By = "llmgw (a guard policy names it)"
job, err := k.serve.Start(req)
if err != nil {
k.log.Error("could not start serving a judge a policy names", "judge", name, "err", err)
continue
}
k.log.Info("serving a judge a policy names, which the catalog did not have",
"judge", name, "repo", kj.Repo, "file", kj.File, "job", job.ID)
}
}
// refit moves a judge an earlier `server init` installed to what init
// installs now, and restarts its replicas onto it. Those judges reserved no
// cores or one, 2 or 4 GB, and ran llama.cpp's prompt cache: on fewer than
// eight cores every judgment missed its 5 s budget and fail: closed refused
// every request, until fixed by hand. A judge whose profile differs in any
// other way was set by somebody, and is left as it is, said once.
func (k *judgeKeeper) refit(m catalog.Model) {
if k.profiles == nil || k.restart == nil || k.replicas == nil || m.Name != defaultJudge || !m.IsGuard() {
return
}
if k.looked == nil {
k.looked = map[string]bool{}
}
if k.looked[m.Name] {
return
}
k.looked[m.Name] = true
if !earlierInitJudge(m) {
if len(m.Profiles) != 1 || !slices.Contains(m.Profiles[0].Args, "--cache-ram") {
k.log.Info("the judge's profile is not one init wrote; left as it is",
"judge", m.Name, "see", "llmgw ctl model resources "+m.Name)
}
return
}
var cpus []int
if k.nodeCPUs != nil {
cpus = k.nodeCPUs()
}
p := m.Profiles[0]
p.MinMemoryGB = judgeMemoryGB
p.CPUCores = refitCores(m.Scaling.Min, cpus)
p.Args = []string{"--ctx-size", "8192", "--cache-ram", "0"}
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
defer cancel()
if _, err := k.profiles.SetProfiles(ctx, m.Name, []catalog.Profile{p}, "llmgw (the judge an earlier init installed)"); err != nil {
k.log.Warn("could not refit the judge an earlier init installed", "judge", m.Name, "err", err)
k.looked[m.Name] = false
return
}
ids := k.replicas(m.Name)
if len(ids) > 0 {
if err := k.restart(ids...); err != nil {
k.log.Warn("refitted the judge, but could not restart it", "judge", m.Name, "err", err)
}
}
k.log.Info("refitted the judge an earlier init installed", "judge", m.Name,
"memory_gb", p.MinMemoryGB, "cpu_cores", p.CPUCores, "replicas_restarted", len(ids))
}
// earlierInitJudge is whether a judge's entry is exactly what `server init`
// before v0.14.7 wrote: one llama.cpp profile on CPUs, an 8192 context and
// no other args, 2 or 4 GB, none to two cores.
func earlierInitJudge(m catalog.Model) bool {
if len(m.Profiles) != 1 {
return false
}
p := m.Profiles[0]
return p.Name == "cpu" && p.Engine == "llamacpp" && p.GPUClass == "" && p.GPU == nil &&
len(p.NodeSelector) == 0 && slices.Equal(p.Args, []string{"--ctx-size", "8192"}) &&
(p.MinMemoryGB == 2 || p.MinMemoryGB == 4) && p.CPUCores >= 0 && p.CPUCores <= 2
}
// refitCores is a judge replica's cores by init's rule: up to four for the
// one judge of a cluster, two each where there are more, fewer where the
// smallest node cannot hold them all.
func refitCores(replicas int, cpus []int) int {
smallest := 0
for _, c := range cpus {
if c > 0 && (smallest == 0 || c < smallest) {
smallest = c
}
}
if replicas <= 1 {
if smallest == 0 {
return judgeCores
}
return min(smallest, judgeCoresAlone)
}
cores := judgeCores
if len(cpus) == 1 && smallest > 0 && replicas*cores > smallest {
cores = max(1, smallest/replicas)
}
return cores
}