A second judge replica on the same machine shares its cores and is lost with it, so it bought nothing but half the speed of each. On a 4-core, 8 GB single-machine install, one judge on four cores passed 20 of 20 requests at about 3.5 s each, judged in and out. Two judges on two cores each had passed 19 of 20. server init now serves one judge when this machine is the only node, and two, on two cores each and spread over the nodes, where there will be others (HA setups, or a control plane that is not a node). --judge-replicas and the setup's Copies still choose. install.md gives the measured times in place of "half a second". Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
242 lines
9.7 KiB
Go
242 lines
9.7 KiB
Go
package cli
|
|
|
|
import (
|
|
"context"
|
|
"fmt"
|
|
"os"
|
|
"path/filepath"
|
|
"slices"
|
|
"strings"
|
|
"testing"
|
|
"time"
|
|
|
|
"gitea.mynodes.xyz/ilker/llmgw/internal/config"
|
|
"gitea.mynodes.xyz/ilker/llmgw/internal/pgha"
|
|
)
|
|
|
|
func TestEveryChoiceIsInTheConfigurationWrittenAndItLoads(t *testing.T) {
|
|
in := &serverInit{
|
|
language: "tr", signupOpen: true, defaultRPM: 120, defaultTPM: 50000, maxKeyTTL: 30 * 24 * time.Hour,
|
|
media: true, evictAfterIdle: time.Minute, minLifetime: 90 * time.Second,
|
|
judge: judgeSpec{Fail: "open", Categories: []string{"Violent", "PII"}, Replicas: 3, Budget: 3 * time.Second},
|
|
}
|
|
j := in.judgeFor()
|
|
body := in.tuneConfig(fmt.Sprintf(serverConfigTemplate, "/etc/llmgw", "/etc/llmgw", "/etc/llmgw",
|
|
"/var/lib/llmgw/llmgw.db", "ops@example.com", guardSectionFor(&j)))
|
|
path := filepath.Join(t.TempDir(), "server.yaml")
|
|
if err := os.WriteFile(path, []byte(body), 0o600); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
cfg, err := config.Load(path)
|
|
if err != nil {
|
|
t.Fatalf("%v\n%s", err, body)
|
|
}
|
|
if err := cfg.Validate(); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
switch {
|
|
case cfg.Landing.Language != "tr" || !cfg.Auth.SignupOpen:
|
|
t.Errorf("language %q signup %v", cfg.Landing.Language, cfg.Auth.SignupOpen)
|
|
case cfg.Auth.DefaultRPM != 120 || cfg.Auth.DefaultTPM != 50000 || cfg.Auth.MaxKeyTTL != 30*24*time.Hour:
|
|
t.Errorf("limits %d %d %s", cfg.Auth.DefaultRPM, cfg.Auth.DefaultTPM, cfg.Auth.MaxKeyTTL)
|
|
case !cfg.Presets.Auto || cfg.Engines["comfyui"] == "":
|
|
t.Errorf("pictures and 3D: presets %v comfyui %q", cfg.Presets.Auto, cfg.Engines["comfyui"])
|
|
case cfg.OnDemand.EvictAfterIdle != time.Minute || cfg.OnDemand.MinLifetime != 90*time.Second:
|
|
t.Errorf("on demand %+v", cfg.OnDemand)
|
|
}
|
|
base := cfg.Guard.Policies["baseline"]
|
|
if base.Fail != "open" || base.Judge == nil || !slices.Equal(base.Judge.Categories, []string{"Violent", "PII"}) ||
|
|
base.Judge.LatencyBudget != 3*time.Second {
|
|
t.Errorf("the judge as written: fail %q %+v", base.Fail, base.Judge)
|
|
}
|
|
if !strings.Contains(body, "Three replicas run on CPUs") {
|
|
t.Error("the comment says another number of judges than run")
|
|
}
|
|
}
|
|
|
|
func TestNothingChosenIsTheTemplateAsItWas(t *testing.T) {
|
|
body := fmt.Sprintf(serverConfigTemplate, "/e", "/e", "/e", "/s.db", "a@b", guardSection(true))
|
|
if got := (&serverInit{}).tuneConfig(body); got != body {
|
|
t.Error("an install with nothing chosen writes another configuration than before")
|
|
}
|
|
// Each line tuneConfig changes is in the template, or a choice would be
|
|
// lost without a word.
|
|
for _, line := range []string{" signup_open: false", " default_rpm: 60", " default_tpm: 200000",
|
|
" max_key_ttl: 2160h # 90 days", " # language: tr"} {
|
|
if !strings.Contains(serverConfigTemplate, line) {
|
|
t.Errorf("the template has no %q", line)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestAStarterIsChosenByNameOrByItsFile(t *testing.T) {
|
|
s, err := starterFrom("qwen3-8b")
|
|
if err != nil || s.SizeGB != 5.0 || !strings.HasPrefix(s.Source, "hf:Qwen/Qwen3-8B-GGUF/") {
|
|
t.Errorf("by name: %+v %v", s, err)
|
|
}
|
|
s, err = starterFrom("hf:bartowski/Phi-4-GGUF/Phi-4-Q4_K_M.gguf")
|
|
if err != nil || s.Name != "phi-4-q4_k_m" {
|
|
t.Errorf("by file: %+v %v", s, err)
|
|
}
|
|
if _, err := starterFrom("gpt-5"); err == nil {
|
|
t.Error("a model nobody offers was taken")
|
|
}
|
|
m := starterModel(s, 8192, 0, "http://x/p.gguf", []string{"6000-24g"}, true)
|
|
if m.Name != "phi-4-q4_k_m" || m.ContextLength != 8192 || len(m.Aliases) != 0 ||
|
|
!slices.Equal(m.Profiles[0].Args, []string{"--ctx-size", "32768"}) {
|
|
t.Errorf("entry %+v", m)
|
|
}
|
|
big, _ := starterFrom("qwen3-14b")
|
|
if m := starterModel(big, 16384, 0, "http://x/q.gguf", nil, true); m.Profiles[0].MinMemoryGB != 11 ||
|
|
!slices.Equal(m.Profiles[0].Args, []string{"--ctx-size", "65536"}) {
|
|
t.Errorf("a 9 GB starter asks for %d GB, %v", m.Profiles[0].MinMemoryGB, m.Profiles[0].Args)
|
|
}
|
|
}
|
|
|
|
func TestTheChoicesAreTheirFlagsAgain(t *testing.T) {
|
|
in := &serverInit{admin: "ops@example.com", hosts: []string{"llm.example.com"}, postgres: true,
|
|
mirrorPath: "/data/models", noNode: true, language: "tr", media: true,
|
|
webhooks: []string{"https://hooks.slack.com/services/T/B/x, json:https://a.example.com/in"},
|
|
judge: judgeSpec{Categories: []string{"Violent", "Jailbreak"}}, maxKeyTTL: 7 * 24 * time.Hour}
|
|
in.starter, _ = starterFrom("qwen3-4b")
|
|
got := strings.Join(in.initFlags(), " ")
|
|
want := "--admin ops@example.com --host llm.example.com --language tr --max-key-ttl 168h --postgres " +
|
|
"--mirror-dir /data/models --no-node --starter qwen3-4b --judge-categories Violent,Jailbreak " +
|
|
"--webhook https://hooks.slack.com/services/T/B/x --webhook json:https://a.example.com/in --media"
|
|
if got != want {
|
|
t.Errorf("flags\n got %s\nwant %s", got, want)
|
|
}
|
|
if got := strings.Join((&serverInit{}).initFlags(), " "); got != "" {
|
|
t.Errorf("nothing chosen is %q", got)
|
|
}
|
|
if w := shellWord("Non-violent Illegal Acts,PII"); w != "'Non-violent Illegal Acts,PII'" {
|
|
t.Errorf("quoted %s", w)
|
|
}
|
|
}
|
|
|
|
func TestChoicesThatCannotBeInstalledAreRefused(t *testing.T) {
|
|
for _, in := range []serverInit{
|
|
{language: "de"},
|
|
{judge: judgeSpec{Fail: "maybe"}},
|
|
{judge: judgeSpec{Categories: []string{"Rudeness"}}},
|
|
{judge: judgeSpec{Replicas: 9}},
|
|
{starterCtx: 100},
|
|
{maxKeyTTL: time.Minute},
|
|
{vip: "10.0.0.160/24"},
|
|
{standbys: true, vip: "10.0.0.160"},
|
|
} {
|
|
if err := in.checkChoices(); err == nil {
|
|
t.Errorf("taken: %+v", in)
|
|
}
|
|
}
|
|
if err := (&serverInit{standbys: true, vip: "10.0.0.160/24"}).checkChoices(); err != nil {
|
|
t.Error(err)
|
|
}
|
|
}
|
|
|
|
func TestWithStandbysTheMirrorAnswersEveryControlPlane(t *testing.T) {
|
|
in := &serverInit{hosts: []string{"10.0.0.5"}, patroniNodes: []pgha.Node{
|
|
{Member: pgha.Member{Name: "db1", IP: "10.0.0.6"}}, {Member: pgha.Member{Name: "db2", IP: "10.0.0.7"}},
|
|
}}
|
|
if u := in.mirrorUnit(); !strings.Contains(u, "--api-listen 127.0.0.1:8082") || strings.Contains(u, "--api-allow") {
|
|
t.Errorf("one control plane: the API is for this machine only:\n%s", u)
|
|
}
|
|
in.standbys = true
|
|
u := in.mirrorUnit()
|
|
for _, want := range []string{"--api-listen :8082", "--api-allow 10.0.0.5", "--api-allow 10.0.0.6", "--api-allow 10.0.0.7"} {
|
|
if !strings.Contains(u, want) {
|
|
t.Errorf("the unit lacks %q:\n%s", want, u)
|
|
}
|
|
}
|
|
if got := in.mirrorAPIURL(); got != "http://10.0.0.5:8082" {
|
|
t.Errorf("the control planes reach the mirror at %s", got)
|
|
}
|
|
}
|
|
|
|
func TestTheFloatingAddressIsInTheCertificate(t *testing.T) {
|
|
in := &serverInit{vip: "10.0.0.160/24"}
|
|
if !slices.Contains(in.certHosts(), "10.0.0.160") {
|
|
t.Errorf("hosts %v", in.certHosts())
|
|
}
|
|
}
|
|
|
|
func TestStandbysWantTheStoresMachines(t *testing.T) {
|
|
in := &serverInit{standbys: true, out: &strings.Builder{}}
|
|
if err := in.ensureStandbys(context.Background()); err == nil || !strings.Contains(err.Error(), "--patroni") {
|
|
t.Errorf("standbys with nowhere to go: %v", err)
|
|
}
|
|
}
|
|
|
|
func TestTwoClustersOnOneNetworkKeepApartFloatingAddresses(t *testing.T) {
|
|
a, b := vrrpRouterID("10.0.0.161"), vrrpRouterID("10.0.0.164")
|
|
if a == b || a < 1 || a > 255 || b < 1 || b > 255 {
|
|
t.Errorf("router ids %d and %d", a, b)
|
|
}
|
|
conf := keepalivedConf("ens18", "10.0.0.164", 24, 100, "secret12")
|
|
if !strings.Contains(conf, fmt.Sprintf("virtual_router_id %d\n", b)) || !strings.Contains(conf, "interface ens18") {
|
|
t.Errorf("conf:\n%s", conf)
|
|
}
|
|
}
|
|
|
|
func TestTheJudgesAndTheStarterShareTheCPUsRatherThanEachTakingAll(t *testing.T) {
|
|
for _, c := range []struct {
|
|
in serverInit
|
|
judge, starter int
|
|
}{
|
|
// One machine: one judge, on up to four cores; the starter has the rest.
|
|
{serverInit{cpus: 4}, 4, 0},
|
|
{serverInit{cpus: 8}, 4, 4},
|
|
{serverInit{cpus: 32}, 4, 8},
|
|
{serverInit{cpus: 2}, 2, 0},
|
|
{serverInit{cpus: 1}, 0, 0},
|
|
{serverInit{cpus: 4, noGuardModel: true}, judgeCores, 4}, // no judge: all for the starter
|
|
// Two asked for on one machine: two cores each, one each where two
|
|
// would not fit both.
|
|
{serverInit{cpus: 8, judge: judgeSpec{Replicas: 2}}, 2, 4},
|
|
{serverInit{cpus: 2, judge: judgeSpec{Replicas: 2}}, 1, 0},
|
|
{serverInit{cpus: 8, judge: judgeSpec{Replicas: 3}}, 2, 2},
|
|
{serverInit{cpus: 4, noNode: true}, 2, 0}, // on nodes not known yet
|
|
} {
|
|
got := c.in.cpuPlan()
|
|
if got.judge != c.judge || got.starter != c.starter {
|
|
t.Errorf("%+v: judge %d starter %d, want %d %d", c.in, got.judge, got.starter, c.judge, c.starter)
|
|
}
|
|
}
|
|
m := guardModel("http://x/g.gguf", 2, 1)
|
|
if p := m.Profiles[0]; p.CPUCores != 1 || p.MinMemoryGB != 2 || !slices.Contains(p.Args, "--cache-ram") {
|
|
t.Errorf("judge profile: %d cores, %d GB, args %v", p.CPUCores, p.MinMemoryGB, p.Args)
|
|
}
|
|
s := starterModel(starterChoices[0], defaultContext, 2, "http://x/q.gguf", []string{"6000-24g"}, true)
|
|
if cpu := s.Profiles[len(s.Profiles)-1]; cpu.Name != "cpu" || cpu.CPUCores != 2 || s.Profiles[0].CPUCores != 0 {
|
|
t.Errorf("starter: GPU profile %d cores, CPU %+v", s.Profiles[0].CPUCores, cpu)
|
|
}
|
|
}
|
|
|
|
// A second judge on the same machine shares its cores and is lost with it:
|
|
// one machine has one, more machines two.
|
|
func TestOneMachineHasOneJudgeAndMoreHaveTwo(t *testing.T) {
|
|
for _, c := range []struct {
|
|
in serverInit
|
|
want int
|
|
}{
|
|
{serverInit{}, 1},
|
|
{serverInit{noNode: true}, 2},
|
|
{serverInit{patroni: []string{"a@db1", "b@db2"}}, 2},
|
|
{serverInit{standbys: true}, 2},
|
|
{serverInit{judge: judgeSpec{Replicas: 3}}, 3},
|
|
} {
|
|
if got := c.in.judgeFor().Replicas; got != c.want {
|
|
t.Errorf("%+v: %d judges, want %d", c.in, got, c.want)
|
|
}
|
|
}
|
|
// The defaults are left out of the command; a choice against them is in.
|
|
ha := serverInit{noNode: true, patroni: []string{"a@db1", "b@db2"}, judge: judgeSpec{Replicas: 2}}
|
|
if f := strings.Join(ha.initFlags(), " "); strings.Contains(f, "--judge-replicas") {
|
|
t.Errorf("two judges on three machines named: %s", f)
|
|
}
|
|
one := serverInit{judge: judgeSpec{Replicas: 2}}
|
|
if f := strings.Join(one.initFlags(), " "); !strings.Contains(f, "--judge-replicas 2") {
|
|
t.Errorf("two judges on one machine not named: %s", f)
|
|
}
|
|
}
|