Files
llmgw/internal/cli/judgekeeper_test.go
T
Ilker ManapandClaude Opus 5.5 314cc5e6a3
CI / test (push) Successful in 5m27s
The judge init installs is no longer replaced by the leader's own
Found on a fresh HA install with a judge machine: the leader saw the
policy's judge missing while init was still fetching it, served one
itself, and its entry replaced init's, so a second judge ran off the
judge machine. The keeper now waits ten minutes after it starts, and a
serve job it starts gives way to a model added meanwhile (AddNew).

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-19 20:37:10 +02:00

238 lines
8.3 KiB
Go

package cli
import (
"context"
"errors"
"log/slog"
"slices"
"testing"
"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"
)
type fakeServe struct {
started []modelserve.Request
running []modelserve.Job
err error
}
func (f *fakeServe) Start(r modelserve.Request) (modelserve.Job, error) {
if f.err != nil {
return modelserve.Job{}, f.err
}
f.started = append(f.started, r)
return modelserve.Job{ID: "j", Model: r.Name, Request: r, State: modelserve.Running}, nil
}
func (f *fakeServe) Jobs() []modelserve.Job { return f.running }
func TestAJudgeAPolicyNamesIsFetchedWhenTheCatalogLacksIt(t *testing.T) {
lib := guard.Config{Policies: map[string]guard.Policy{
"baseline": {Judge: &guard.JudgePolicy{Models: []string{"qwen3guard-4b", "qwen3guard-0.6b"}, Input: "block"}},
"custom": {Judge: &guard.JudgePolicy{Model: "our-own-judge", Input: "block"}},
}}
inCatalog := []catalog.Model{{Name: "qwen3guard-0.6b"}}
now := time.Date(2026, 10, 8, 12, 0, 0, 0, time.UTC)
fs := &fakeServe{}
leader := true
k := &judgeKeeper{
library: func() guard.Config { return lib },
models: func() []catalog.Model { return inCatalog },
serve: fs,
leader: func() bool { return leader },
gpus: func() []adminapi.ClassUse { return []adminapi.ClassUse{{Class: "6000-24g", Total: 1, Used: 1}} },
log: slog.New(slog.DiscardHandler),
now: func() time.Time { return now },
}
k.check()
// The GPU judge alone: the CPU one is there, and one llmgw does not know
// is not guessed at.
if len(fs.started) != 1 || fs.started[0].Name != "qwen3guard-4b" || fs.started[0].GPUClass != "6000-24g" ||
fs.started[0].Class != catalog.ClassStandard || fs.started[0].GuardFormat != "qwen3guard" || fs.started[0].CPU ||
!fs.started[0].IfAbsent {
t.Fatalf("started %+v", fs.started)
}
// Being fetched: not asked for again.
fs.running = []modelserve.Job{{Model: "qwen3guard-4b", State: modelserve.Running}}
k.check()
if len(fs.started) != 1 {
t.Errorf("asked for again while it is being fetched: %d", len(fs.started))
}
// Failed: asked for again only after a while.
fs.running = nil
now = now.Add(time.Minute)
k.check()
if len(fs.started) != 1 {
t.Error("asked for again at once after it failed")
}
now = now.Add(judgeRetry)
k.check()
if len(fs.started) != 2 {
t.Error("never asked for again")
}
// Only the leader fetches, and nothing while the guardrails are off.
leader = false
now = now.Add(judgeRetry)
k.check()
leader = true
off := false
lib.Enabled = &off
k.check()
if len(fs.started) != 2 {
t.Errorf("a standby or the guardrails off fetched: %d", len(fs.started))
}
// A CPU judge on a cluster without GPUs is served on CPUs, two of it.
lib = guard.Config{Policies: map[string]guard.Policy{"b": {Judge: &guard.JudgePolicy{Model: "qwen3guard-0.6b", Input: "block"}}}}
inCatalog = nil
k.gpus = func() []adminapi.ClassUse { return nil }
k.nodeCPUs = func() []int { return []int{8, 4} }
k.check()
// Sized as init sizes its two judges: 2 GB, and two cores each on a
// cluster whose smallest node has four.
if last := fs.started[len(fs.started)-1]; last.Name != "qwen3guard-0.6b" || !last.CPU || last.Replicas != 2 ||
last.MemoryGB != judgeMemoryGB || last.CPUCores != refitCores(2, []int{8, 4}) || last.CPUCores == 0 {
t.Errorf("CPU judge: %+v", last)
}
fs.err = errors.New("the mirror is down")
lib = guard.Config{Policies: map[string]guard.Policy{"b": {Judge: &guard.JudgePolicy{Model: "qwen3guard-8b", Input: "block"}}}}
k.check() // no GPU, so not started, and no panic on the error path either
}
// A policy moved from Llama Guard's codes to Qwen3Guard would count nothing:
// its categories become the judge's own.
func TestCategoriesFollowTheJudges(t *testing.T) {
lg := []string{"S1", "S2", "S10"}
if got := categoriesFor([]string{"qwen3guard-0.6b"}, lg); !slices.Equal(got, baselineCategories["qwen3guard"]) {
t.Errorf("Llama Guard's codes for Qwen3Guard: %v", got)
}
if got := categoriesFor([]string{"qwen3guard-4b", "qwen3guard-0.6b"}, []string{"Violent", "Jailbreak"}); got != nil {
t.Errorf("its own names were replaced: %v", got)
}
if got := categoriesFor([]string{"llama-guard-3-1b"}, lg); got != nil {
t.Errorf("Llama Guard's own codes were replaced: %v", got)
}
// Both kinds asked in turn: each must have something to count.
got := categoriesFor([]string{"qwen3guard-4b", "llama-guard-3-1b"}, []string{"Violent"})
if !slices.Contains(got, "Violent") || !slices.Contains(got, "S1") {
t.Errorf("mixed: %v", got)
}
if got := categoriesFor([]string{"our-own-judge"}, lg); got != nil {
t.Errorf("a judge llmgw does not know: %v", got)
}
if got := categoriesFor([]string{"qwen3guard-0.6b"}, nil); got != nil {
t.Errorf("no categories (every one counts) became %v", got)
}
}
type fakeProfiles struct {
set map[string][]catalog.Profile
}
func (f *fakeProfiles) SetProfiles(_ context.Context, name string, ps []catalog.Profile, _ string) (catalog.Model, error) {
if f.set == nil {
f.set = map[string][]catalog.Profile{}
}
f.set[name] = ps
return catalog.Model{Name: name, Profiles: ps}, nil
}
// A judge that v0.14.6 or earlier's init installed is moved to today's
// figures, once, and its replicas restarted onto them; one somebody changed
// is left alone.
func TestAnEarlierInstallsJudgeIsRefittedOnce(t *testing.T) {
lib := guard.Config{Policies: map[string]guard.Policy{
"baseline": {Judge: &guard.JudgePolicy{Model: "qwen3guard-0.6b", Input: "block"}},
}}
earlier := guardModel("http://10.0.0.5:8081/qwen3guard-0.6b/guard/g.gguf", 2, 1, nil)
earlier.Profiles[0].MinMemoryGB, earlier.Profiles[0].Args = 4, []string{"--ctx-size", "8192"}
inCatalog := []catalog.Model{earlier}
fp := &fakeProfiles{}
var restarted []string
k := &judgeKeeper{
library: func() guard.Config { return lib },
models: func() []catalog.Model { return inCatalog },
serve: &fakeServe{},
log: slog.New(slog.DiscardHandler),
profiles: fp,
restart: func(ids ...string) error { restarted = append(restarted, ids...); return nil },
replicas: func(string) []string { return []string{"j-1", "j-2"} },
nodeCPUs: func() []int { return []int{4} },
}
k.check()
got := fp.set["qwen3guard-0.6b"]
if len(got) != 1 || got[0].MinMemoryGB != 2 || got[0].CPUCores != 2 || !slices.Contains(got[0].Args, "--cache-ram") {
t.Fatalf("refitted to %+v", got)
}
if !slices.Equal(restarted, []string{"j-1", "j-2"}) {
t.Errorf("restarted %v", restarted)
}
k.check()
if len(restarted) != 2 {
t.Error("refitted twice")
}
// Changed by somebody: an extra arg. Left as it is.
mine := guardModel("http://x/g.gguf", 2, 3, nil)
mine.Profiles[0].Args = []string{"--ctx-size", "16384"}
inCatalog = []catalog.Model{mine}
fp.set, k.looked = nil, nil
k.check()
if fp.set != nil {
t.Errorf("a judge set by hand was changed: %+v", fp.set)
}
}
func TestAJudgeIsRefittedToTheCoresInitWouldGive(t *testing.T) {
for _, c := range []struct {
replicas int
cpus []int
want int
}{
{1, []int{4}, 4},
{1, []int{2}, 2},
{1, []int{16}, 4},
{2, []int{4}, 2},
{2, []int{2}, 1},
{2, []int{4, 32}, 2},
{2, nil, 2},
} {
if got := refitCores(c.replicas, c.cpus); got != c.want {
t.Errorf("%d judges on %v: %d cores, want %d", c.replicas, c.cpus, got, c.want)
}
}
}
// Just after the control plane starts, the judge init is adding is left to
// init: the keeper serves one itself only after its grace.
func TestTheKeeperLeavesAJustStartedInstallsJudgeToInit(t *testing.T) {
lib := guard.Config{Policies: map[string]guard.Policy{"b": {Judge: &guard.JudgePolicy{Model: "qwen3guard-0.6b", Input: "block"}}}}
now := time.Date(2026, 10, 19, 12, 0, 0, 0, time.UTC)
fs := &fakeServe{}
k := &judgeKeeper{
library: func() guard.Config { return lib },
models: func() []catalog.Model { return nil },
serve: fs, grace: judgeGrace,
gpus: func() []adminapi.ClassUse { return nil },
log: slog.New(slog.DiscardHandler),
now: func() time.Time { return now },
}
k.check()
now = now.Add(judgeGrace / 2)
k.check()
if len(fs.started) != 0 {
t.Fatalf("served within its grace: %+v", fs.started)
}
now = now.Add(judgeGrace)
k.check()
if len(fs.started) != 1 {
t.Errorf("never served after its grace: %d", len(fs.started))
}
}