Files
llmgw/internal/control/adminapi/judges.go
T
Ilker ManapandClaude Opus 5.5 b0273ffb5f Qwen3Guard is the judge, and judges on a GPU and on CPUs are asked in turn
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>
2026-10-08 10:22:47 +02:00

195 lines
6.1 KiB
Go

package adminapi
import (
"encoding/json"
"fmt"
"net/http"
"slices"
"sort"
"strings"
"time"
"gitea.mynodes.xyz/ilker/llmgw/internal/control/reconciler"
"gitea.mynodes.xyz/ilker/llmgw/internal/guard"
)
// JudgeView is one judge model the guardrail policies ask, and whether it
// can answer them.
type JudgeView struct {
Model string `json:"model"`
// Policies are those that ask it; Closed those that refuse what it
// cannot read (fail: closed).
Policies []string `json:"policies"`
Closed []string `json:"closed,omitempty"`
// Then are the judges its policies ask after it, when it gives no
// verdict: a CPU judge behind a GPU one.
Then []string `json:"then,omitempty"`
// InCatalog is whether a model of that name is in the catalog, and
// Judge whether it is there as a judge (role: guard).
InCatalog bool `json:"in_catalog"`
Judge bool `json:"judge"`
Ready int `json:"ready_replicas"`
Wanted int `json:"wanted_replicas"`
// LastError is its last failure to give a verdict, if any.
LastError string `json:"last_error,omitempty"`
LastErrorAt *time.Time `json:"last_error_at,omitempty"`
// Problem says, in words, what is wrong and what to do; empty when
// nothing is.
Problem string `json:"problem,omitempty"`
}
// judgeErrorFresh is how recent a judge's failure must be to count as a
// problem now.
const judgeErrorFresh = 5 * time.Minute
// Judges are the judge models the policies in force ask, each with what is
// wrong with it, if anything.
func (a *API) Judges() []JudgeView {
if a.guard == nil || a.guard.Library == nil {
return nil
}
lib := a.guard.Library()
if !lib.On() {
// Off: no judge is asked, so none is missing.
return nil
}
byModel := map[string]*JudgeView{}
for name, p := range lib.Policies {
judges := p.Judge.Judges()
for i, model := range judges {
v := byModel[model]
if v == nil {
v = &JudgeView{Model: model}
byModel[model] = v
}
if !slices.Contains(v.Policies, name) {
v.Policies = append(v.Policies, name)
}
if p.Fail == "closed" && p.Mode != "shadow" && !slices.Contains(v.Closed, name) {
v.Closed = append(v.Closed, name)
}
for _, after := range judges[i+1:] {
if !slices.Contains(v.Then, after) {
v.Then = append(v.Then, after)
}
}
}
}
ready := map[string]int{}
for _, rep := range a.ctl.Reconciler().Replicas() {
if rep.State == reconciler.Ready {
ready[rep.Model]++
}
}
var errs map[string]guard.JudgeError
if a.guard.JudgeErrors != nil {
errs = a.guard.JudgeErrors()
}
out := make([]JudgeView, 0, len(byModel))
for _, v := range byModel {
sort.Strings(v.Policies)
sort.Strings(v.Closed)
if m, ok := a.ctl.Catalog().Resolve(v.Model); ok && m.Name == v.Model {
v.InCatalog, v.Judge = true, m.IsGuard()
v.Wanted, v.Ready = m.Scaling.Min, ready[m.Name]
}
if e, ok := errs[v.Model]; ok {
at := e.At
v.LastError, v.LastErrorAt = e.Err, &at
}
v.Problem = judgeProblem(*v)
out = append(out, *v)
}
slices.SortFunc(out, func(x, y JudgeView) int {
switch {
case x.Model < y.Model:
return -1
case x.Model > y.Model:
return 1
}
return 0
})
return out
}
func judgeProblem(v JudgeView) string {
consequence := "requests its policies judge are served unjudged"
switch {
case len(v.Then) > 0:
consequence = "its policies ask " + strings.Join(v.Then, ", else ") + " instead"
case len(v.Closed) > 0:
consequence = "requests its policies judge are refused (guard_unavailable)"
}
switch {
case !v.InCatalog:
return fmt.Sprintf("not in the catalog, so %s: llmgw ctl guard judge enable", consequence)
case !v.Judge:
return fmt.Sprintf("in the catalog as a model clients use, not as a judge (role: guard), so %s: "+
"llmgw ctl guard judge enable --replace", consequence)
case v.Ready == 0:
return fmt.Sprintf("no replica ready, so %s: llmgw ctl replicas --model %s", consequence, v.Model)
case v.LastErrorAt != nil && time.Since(*v.LastErrorAt) < judgeErrorFresh:
return fmt.Sprintf("gave no verdict %s ago (%s)", time.Since(*v.LastErrorAt).Round(time.Second), v.LastError)
}
return ""
}
func (a *API) listJudges(w http.ResponseWriter, _ *http.Request) {
if a.guard == nil {
writeError(w, http.StatusNotImplemented, "no guard policies: define them under guard in the cluster's settings (llmgw ctl config edit)")
return
}
writeJSON(w, http.StatusOK, a.Judges())
}
// JudgeAskRequest is a text to have a judge read.
type JudgeAskRequest struct {
// Model is the judge; empty is the one the policies ask, when they ask one.
Model string `json:"model,omitempty"`
Text string `json:"text"`
// Answer, when given, is read as the assistant's reply to Text.
Answer string `json:"answer,omitempty"`
}
// JudgeAskView is what the judge answered.
type JudgeAskView struct {
Model string `json:"model"`
// Verdict is safe or unsafe, as the gateway reads the answer.
Verdict string `json:"verdict"`
Categories []string `json:"categories,omitempty"`
// Raw is the judge's answer as it gave it.
Raw string `json:"raw"`
TookMs int64 `json:"took_ms"`
}
func (a *API) askJudge(w http.ResponseWriter, r *http.Request) {
if a.guard == nil || a.guard.Ask == nil {
writeError(w, http.StatusNotImplemented, "no guard policies: define them under guard in the cluster's settings (llmgw ctl config edit)")
return
}
var req JudgeAskRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil || strings.TrimSpace(req.Text) == "" {
writeError(w, http.StatusBadRequest, "give the text for the judge to read")
return
}
if req.Model == "" {
judges := a.Judges()
if len(judges) == 0 {
writeError(w, http.StatusBadRequest, "no policy asks a judge: give the model")
return
}
req.Model = judges[0].Model
}
ans, err := a.guard.Ask(r.Context(), req.Model, req.Text, req.Answer)
v := JudgeAskView{Model: req.Model, Raw: ans.Raw, Categories: ans.Categories, TookMs: ans.Took.Milliseconds()}
if err != nil {
writeError(w, http.StatusBadGateway, fmt.Sprintf("the judge %s gave no verdict: %v (it answered %q)", req.Model, err, ans.Raw))
return
}
v.Verdict = "safe"
if ans.Unsafe {
v.Verdict = "unsafe"
}
writeJSON(w, http.StatusOK, v)
}