Llama Guard 3 1B could not tell ordinary coding requests from harmful ones: on the PoC it scored "continue" 0.51, "continue the code above" 0.89 and "how do I kill all child processes in bash" 0.92 unsafe, against 0.97 to 1.00 for harmful requests, at Q4_K_M and Q8_0 alike, with its own template. A site had "continue" refused. Qwen3Guard-Gen 0.6B gave the ordinary ones 0.15 at the most and the harmful ones 1.00, in 0.8 GB on CPUs. - format qwen3guard: its prompt written out as its template writes it, for requests and answers, and its Safe / Unsafe / Controversial verdict read; controversial counts as the policy's judge.controversial says, safe by default. - judge.models: judges asked in turn, the first that answers deciding; a failure is recorded against the judge that failed. guard judge use sets the order, replacing categories none of the judges names. - known judges (Qwen3Guard 0.6B/4B/8B, Llama Guard 1B/8B): guard judge enable serves any of them, --gpu the 4B on the roomiest GPU class as a standard model; the leader fetches and serves one a policy names that the catalog lacks. - server init installs qwen3guard-0.6b with the baseline's categories. - a judge reads a long text by its start and end, which fit its context, rather than overflowing it and refusing the request under fail: closed. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
127 lines
4.6 KiB
Go
127 lines
4.6 KiB
Go
package cli
|
|
|
|
import (
|
|
"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 {
|
|
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.check()
|
|
if last := fs.started[len(fs.started)-1]; last.Name != "qwen3guard-0.6b" || !last.CPU || last.Replicas != 2 {
|
|
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)
|
|
}
|
|
}
|