diff --git a/CHANGELOG.md b/CHANGELOG.md
index ff6d00be..d9dc987d 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -22,6 +22,32 @@ is only released once its **exit criterion** is met, not when its code lands.
Until v1.0.0 the minor version is the breaking one: configuration formats and Go
APIs change between minors without a deprecation period.
+## Unreleased
+
+### Added
+
+- **`llmgw ctl guard judge status` and `enable`**: each judge model the
+ policies ask, whether it is in the catalog, as a judge, with replicas
+ ready and answering — and serving the missing one, Llama Guard 3 1B on
+ CPUs under the name the policies give it, with `--replace` for a model of
+ that name that is not a judge. No more `server init` to put a judge back.
+ Also **Do → Serve the guardrails' judge** in the console.
+- **A missing or broken judge is said where it is looked for**: `llmgw ctl
+ status` and the console's Overview list each judge with a problem — not in
+ the catalog, not a judge, no replica ready, or failing to answer — and
+ what that does to requests (refused or unjudged).
+
+### Fixed
+
+- **A Llama Guard file served from the mirror page is the judge**: it was
+ added as a model clients use, under whatever name the form had, so the
+ policies never found it and refused everything they judged
+ (`guard_unavailable`). It is now added as a judge, under the name the
+ policies ask.
+- **`ctl` says so when the API answers with a page**: a command a control
+ plane does not know — the leader on an older release, through a standby —
+ failed with "invalid character '<'"; it now says which and why.
+
## v0.12.0 — 2026-10-06
A minor version, because where the configuration lives changes: everything in
diff --git a/docs/admin-api.openapi.json b/docs/admin-api.openapi.json
index 2b38790c..4007b34d 100644
--- a/docs/admin-api.openapi.json
+++ b/docs/admin-api.openapi.json
@@ -894,6 +894,36 @@
}
}
},
+ "/api/v1/guard/judges": {
+ "get": {
+ "operationId": "getGuardJudges",
+ "summary": "The judge models the policies ask, and what is wrong with each",
+ "tags": [
+ "guard"
+ ],
+ "responses": {
+ "200": {
+ "description": "OK",
+ "content": {
+ "application/json": {
+ "schema": {
+ "type": "array",
+ "items": {
+ "$ref": "#/components/schemas/JudgeView"
+ }
+ }
+ }
+ }
+ },
+ "401": {
+ "$ref": "#/components/responses/Unauthorized"
+ },
+ "default": {
+ "$ref": "#/components/responses/Error"
+ }
+ }
+ }
+ },
"/api/v1/jobs": {
"get": {
"operationId": "getJobs",
@@ -4359,6 +4389,52 @@
}
}
},
+ "JudgeView": {
+ "type": "object",
+ "description": "JudgeView is one judge model the guardrail policies ask, and whether it can answer them.",
+ "properties": {
+ "model": {
+ "type": "string"
+ },
+ "policies": {
+ "type": "array",
+ "items": {
+ "type": "string"
+ },
+ "description": "Policies are those that ask it; Closed those that refuse what it cannot read (fail: closed)."
+ },
+ "closed": {
+ "type": "array",
+ "items": {
+ "type": "string"
+ }
+ },
+ "in_catalog": {
+ "type": "boolean",
+ "description": "InCatalog is whether a model of that name is in the catalog, and Judge whether it is there as a judge (role: guard)."
+ },
+ "judge": {
+ "type": "boolean"
+ },
+ "ready_replicas": {
+ "type": "integer"
+ },
+ "wanted_replicas": {
+ "type": "integer"
+ },
+ "last_error": {
+ "type": "string",
+ "description": "LastError is its last failure to give a verdict, if any."
+ },
+ "last_error_at": {
+ "type": "string"
+ },
+ "problem": {
+ "type": "string",
+ "description": "Problem says, in words, what is wrong and what to do; empty when nothing is."
+ }
+ }
+ },
"Live": {
"type": "object",
"description": "Live is one of the cluster's adapters as its node last reported it.",
@@ -5696,6 +5772,13 @@
"control_planes": {
"$ref": "#/components/schemas/ControlPlanes",
"description": "ControlPlanes is the cluster's control planes when there is more than one: which leads, which stand by, and the floating address in front."
+ },
+ "judges": {
+ "type": "array",
+ "items": {
+ "$ref": "#/components/schemas/JudgeView"
+ },
+ "description": "Judges are the guardrails' judge models with something wrong."
}
}
},
diff --git a/docs/guardrails.md b/docs/guardrails.md
index ad5838df..b05b7fc3 100644
--- a/docs/guardrails.md
+++ b/docs/guardrails.md
@@ -103,7 +103,23 @@ guard:
Patterns find what has a shape. For what does not — violence, self-harm,
abuse, whatever a moderation model was trained on — a policy can have a
-**judge model on the cluster** read the text:
+**judge model on the cluster** read the text.
+
+The judge must be in the catalog under the name the policy gives, as a
+judge (`role: guard`), with a replica ready. When it is not, what the
+policy judges is refused with `guard_unavailable` (`fail: closed`) or served
+unjudged (`fail: open`), and `llmgw ctl status` and the console's Overview
+say which judge and why. To serve it:
+
+```
+llmgw ctl guard judge status # each judge the policies ask, and what is wrong with it
+llmgw ctl guard judge enable # Llama Guard 3 1B on CPUs, under the name the policies ask
+llmgw ctl guard judge enable --replace # when a model of that name is there, but not as a judge
+```
+
+or **Do → Serve the guardrails' judge** in the console. A Llama Guard file
+served from the mirror page is served as a judge too, under the name the
+policies ask. By hand, the catalog entry is:
```yaml
# a catalog model (server.yaml models:, or ctl model add)
diff --git a/internal/cli/ctl.go b/internal/cli/ctl.go
index fe35a0c7..10c25643 100644
--- a/internal/cli/ctl.go
+++ b/internal/cli/ctl.go
@@ -116,6 +116,13 @@ func (c *ctlClient) do(ctx context.Context, method, path string, body, out any)
if out == nil {
return nil
}
+ // An older control plane answers a path it does not know with its
+ // dashboard: a page, which read as JSON said only "invalid character '<'".
+ if strings.HasPrefix(resp.Header.Get("Content-Type"), "text/html") {
+ return fmt.Errorf("%s %s answered with a page, not the API: the control plane answering "+
+ "(the leader, through a standby) does not know it — is it on an older release? "+
+ "llmgw ctl status says which it runs", method, path)
+ }
return json.NewDecoder(resp.Body).Decode(out)
}
diff --git a/internal/cli/ctl_config_test.go b/internal/cli/ctl_config_test.go
new file mode 100644
index 00000000..6f5afdb0
--- /dev/null
+++ b/internal/cli/ctl_config_test.go
@@ -0,0 +1,22 @@
+package cli
+
+import (
+ "net/http"
+ "net/http/httptest"
+ "strings"
+ "testing"
+)
+
+func TestAPageInsteadOfTheAPIIsSaidPlainly(t *testing.T) {
+ srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
+ w.Header().Set("Content-Type", "text/html; charset=utf-8")
+ _, _ = w.Write([]byte("
llmgw"))
+ }))
+ defer srv.Close()
+ c := newCtlClient(strings.TrimPrefix(srv.URL, "http://"), "")
+ var out map[string]any
+ err := c.get(t.Context(), "/api/v1/settings", &out)
+ if err == nil || !strings.Contains(err.Error(), "older release") || strings.Contains(err.Error(), "invalid character") {
+ t.Errorf("%v", err)
+ }
+}
diff --git a/internal/cli/guard.go b/internal/cli/guard.go
index d99ab6ca..55f16e40 100644
--- a/internal/cli/guard.go
+++ b/internal/cli/guard.go
@@ -24,6 +24,7 @@ func newGuardCmd(client func() *ctlClient, asJSON *bool) *cobra.Command {
"Targets are written org:Acme, team:Acme/ml, project:Acme/ml/training (or\n" +
"with IDs), key:KEY-ID or user:USER-ID. An attachment applies at once.",
}
+ c.AddCommand(newGuardJudgeCmd(client, asJSON))
c.AddCommand(&cobra.Command{
Use: "list",
Short: "The policies, and where each is attached",
diff --git a/internal/cli/guard_judge.go b/internal/cli/guard_judge.go
new file mode 100644
index 00000000..c59a9880
--- /dev/null
+++ b/internal/cli/guard_judge.go
@@ -0,0 +1,189 @@
+package cli
+
+import (
+ "encoding/json"
+ "errors"
+ "fmt"
+ "strings"
+ "time"
+
+ "github.com/spf13/cobra"
+
+ "gitea.mynodes.xyz/ilker/llmgw/internal/control/adminapi"
+ "gitea.mynodes.xyz/ilker/llmgw/internal/control/modelserve"
+)
+
+// The judge the guardrails are installed with: Meta's Llama Guard 3 1B,
+// from the repository server init fetches it from.
+const (
+ guardModelRepo = "QuantFactory/Llama-Guard-3-1B-GGUF"
+ guardModelFile = "Llama-Guard-3-1B.Q4_K_M.gguf"
+)
+
+func newGuardJudgeCmd(client func() *ctlClient, asJSON *bool) *cobra.Command {
+ c := &cobra.Command{
+ Use: "judge",
+ Short: "The judge models the policies ask: whether each can answer, and serving one",
+ Long: "A policy with a judge has a model read what patterns cannot. The judge must\n" +
+ "be in the catalog under the name the policy gives, as a judge (role:\n" +
+ "guard), with a replica ready; otherwise what the policy judges is refused\n" +
+ "(fail: closed, guard_unavailable) or served unjudged (fail: open).",
+ }
+ c.AddCommand(newGuardJudgeStatusCmd(client, asJSON), newGuardJudgeEnableCmd(client))
+ return c
+}
+
+func newGuardJudgeStatusCmd(client func() *ctlClient, asJSON *bool) *cobra.Command {
+ return &cobra.Command{
+ Use: "status",
+ Short: "Each judge the policies ask, and what is wrong with it",
+ Args: cobra.NoArgs,
+ RunE: func(cmd *cobra.Command, _ []string) error {
+ var js []adminapi.JudgeView
+ if err := client().get(cmd.Context(), "/api/v1/guard/judges", &js); err != nil {
+ return err
+ }
+ if *asJSON {
+ return emitJSON(js)
+ }
+ out := cmd.OutOrStdout()
+ if len(js) == 0 {
+ fmt.Fprintln(out, "no policy asks a judge")
+ return nil
+ }
+ w := tabTo(out)
+ fmt.Fprintln(w, "JUDGE\tPOLICIES\tIN CATALOG\tAS JUDGE\tREADY\tSTATE")
+ for _, j := range js {
+ state := "answering"
+ if j.Problem != "" {
+ state = j.Problem
+ }
+ fmt.Fprintf(w, "%s\t%s\t%s\t%s\t%d/%d\t%s\n", j.Model, strings.Join(j.Policies, ","),
+ yesNo(j.InCatalog), yesNo(j.Judge), j.Ready, j.Wanted, state)
+ }
+ if err := w.Flush(); err != nil {
+ return err
+ }
+ strays, _ := strayJudges(cmd, client, js)
+ for _, s := range strays {
+ fmt.Fprintf(out, "\n%s looks like a judge's weights but is served as a model clients use, and no "+
+ "policy asks it: llmgw ctl model remove %s\n", s, s)
+ }
+ return nil
+ },
+ }
+}
+
+// strayJudges are models whose weights are a judge's but which are in the
+// catalog as models clients use, under a name no policy asks: what serving
+// Llama Guard from the mirror page did before it knew better.
+func strayJudges(cmd *cobra.Command, client func() *ctlClient, js []adminapi.JudgeView) ([]string, error) {
+ var models []adminapi.ModelView
+ if err := client().get(cmd.Context(), "/api/v1/models", &models); err != nil {
+ return nil, err
+ }
+ asked := map[string]bool{}
+ for _, j := range js {
+ asked[j.Model] = true
+ }
+ var out []string
+ for _, m := range models {
+ if !asked[m.Name] && strings.Contains(strings.ToLower(m.Source), "llama-guard") {
+ out = append(out, m.Name)
+ }
+ }
+ return out, nil
+}
+
+func newGuardJudgeEnableCmd(client func() *ctlClient) *cobra.Command {
+ var (
+ model, repo, file string
+ replicas, cores int
+ replace, detach bool
+ )
+ c := &cobra.Command{
+ Use: "enable",
+ Short: "Serve the judge the policies ask, as a judge: Llama Guard 3 1B on CPUs",
+ Long: "Fetches Llama Guard 3 1B into the mirror (once), adds it to the catalog under\n" +
+ "the name the policies give its judge, as a judge (role: guard,\n" +
+ "format llamaguard3), on CPUs so it takes no card, and waits for a replica\n" +
+ "to answer. With no --model, the judge the policies ask that is missing.\n\n" +
+ "A model of that name already in the catalog as a model clients use is\n" +
+ "refused; --replace removes it first. An offline cluster gets its judge\n" +
+ "from its bundle with server init.",
+ Example: " llmgw ctl guard judge enable\n" +
+ " llmgw ctl guard judge enable --replace",
+ Args: cobra.NoArgs,
+ RunE: func(cmd *cobra.Command, _ []string) error {
+ ctx := cmd.Context()
+ out := cmd.OutOrStdout()
+ var js []adminapi.JudgeView
+ if err := client().get(ctx, "/api/v1/guard/judges", &js); err != nil {
+ return err
+ }
+ var target *adminapi.JudgeView
+ for i, j := range js {
+ if (model != "" && j.Model == model) || (model == "" && (!j.InCatalog || !j.Judge)) {
+ target = &js[i]
+ break
+ }
+ }
+ switch {
+ case target == nil && model != "":
+ target = &adminapi.JudgeView{Model: model}
+ fmt.Fprintf(out, "no policy asks %s yet; it is served, and a policy's judge.model names it\n", model)
+ case target == nil && len(js) == 0:
+ return errors.New("no policy asks a judge: give one a judge (llmgw ctl config edit, guard.policies..judge.model)")
+ case target == nil:
+ fmt.Fprintln(out, "every judge the policies ask is in the catalog, as a judge")
+ return nil
+ }
+ if target.InCatalog && target.Judge {
+ fmt.Fprintf(out, "%s is in the catalog as a judge already; llmgw ctl guard judge status says how it is\n", target.Model)
+ return nil
+ }
+ if target.InCatalog && !target.Judge {
+ if !replace {
+ return fmt.Errorf("%s is in the catalog as a model clients use, not as a judge: "+
+ "--replace removes it and serves it again as a judge", target.Model)
+ }
+ var res map[string]string
+ if err := client().delete(ctx, "/api/v1/models/"+target.Model, &res); err != nil {
+ return fmt.Errorf("removing %s: %w", target.Model, err)
+ }
+ fmt.Fprintf(out, "%s removed\n", target.Model)
+ }
+ req := modelserve.Request{
+ Repo: repo, File: file, Name: target.Model, GuardFormat: "llamaguard3",
+ Description: "The guardrails' judge: reads prompts and answers for unsafe content. Not offered to clients.",
+ CPU: true, Replicas: replicas, CPUCores: cores,
+ CtxPool: 8192, ContextLength: 4096,
+ }
+ body, _ := json.Marshal(req)
+ var job modelserve.Job
+ if err := client().patient(30*time.Second).post(ctx, "/api/v1/models/serve", body, &job); err != nil {
+ return err
+ }
+ if detach {
+ return detached(cmd, job)
+ }
+ if err := followJob(cmd, client, job, "is the judge"); err != nil {
+ return err
+ }
+ if strays, _ := strayJudges(cmd, client, js); len(strays) > 0 {
+ fmt.Fprintf(out, "\nno policy asks %s, served from a judge's weights as a model clients use: "+
+ "llmgw ctl model remove %s\n", strings.Join(strays, ", "), strings.Join(strays, " "))
+ }
+ return nil
+ },
+ }
+ f := c.Flags()
+ f.StringVar(&model, "model", "", "the judge to serve (default: the one the policies ask that is missing)")
+ f.StringVar(&repo, "repo", guardModelRepo, "the registry repository its weights come from")
+ f.StringVar(&file, "file", guardModelFile, "the file in it")
+ f.IntVar(&replicas, "replicas", 2, "replicas: every judged request waits on one")
+ f.IntVar(&cores, "cores", 4, "CPU cores a replica computes on")
+ f.BoolVar(&replace, "replace", false, "remove a model of that name that is not a judge first")
+ f.BoolVar(&detach, "detach", false, "start it and return; llmgw ctl jobs follows it")
+ return c
+}
diff --git a/internal/cli/server.go b/internal/cli/server.go
index 0dc97981..d0ed5657 100644
--- a/internal/cli/server.go
+++ b/internal/cli/server.go
@@ -361,7 +361,8 @@ func newServerCmd() *cobra.Command {
evals = newEvalService(cfg, rt, admin, log)
api.SetEval(evals)
if st := rt.Store(); st != nil && gate != nil {
- api.SetGuard(&adminapi.GuardAdmin{Library: gate.Library, Store: st, Invalidate: gate.Invalidate})
+ api.SetGuard(&adminapi.GuardAdmin{Library: gate.Library, Store: st, Invalidate: gate.Invalidate,
+ JudgeErrors: gate.JudgeErrors})
}
if st := rt.Store(); st != nil {
// Removing a node revokes its certificate.
diff --git a/internal/cli/status.go b/internal/cli/status.go
index 93e48839..6b99a0cc 100644
--- a/internal/cli/status.go
+++ b/internal/cli/status.go
@@ -153,6 +153,13 @@ func printSummary(cmd *cobra.Command, s adminapi.Summary) {
fmt.Fprintf(out, "STORE %s\n", strings.Join(parts, ", "))
}
+ if len(s.Judges) > 0 {
+ fmt.Fprintln(out, "\nGUARDRAILS' JUDGE")
+ for _, j := range s.Judges {
+ fmt.Fprintf(out, " %s: %s\n", j.Model, j.Problem)
+ }
+ }
+
if len(s.Parked) > 0 {
fmt.Fprintln(out, "\nPARKED — failed every time they started; not retried until fixed")
for _, p := range s.Parked {
diff --git a/internal/cli/tui_actions.go b/internal/cli/tui_actions.go
index 66dc17aa..e7862029 100644
--- a/internal/cli/tui_actions.go
+++ b/internal/cli/tui_actions.go
@@ -679,6 +679,8 @@ func (m *consoleModel) doEntry() tea.Cmd {
}}
case cBackup:
return m.runAfter("server", "backup", "--config="+m.file)
+ case cJudge:
+ return m.runAfter("ctl", server, "guard", "judge", "enable", "--replace")
}
return nil
}
diff --git a/internal/cli/tui_console.go b/internal/cli/tui_console.go
index 3d416701..0dda53b1 100644
--- a/internal/cli/tui_console.go
+++ b/internal/cli/tui_console.go
@@ -59,6 +59,7 @@ const (
cAddControl
cReplicate
cServe
+ cJudge
cUpgrade
cRestartAll
cBackup
@@ -68,7 +69,7 @@ const (
)
var consoleLabels = [cCount]string{"Overview", "Monitor", "Nodes", "GPUs", "Models", "Replicas", "Usage", "Quotas", "Guardrails", "Federation", "Mirror", "Jobs", "Logs", "Accounts", "API keys", "Organizations", "Audit", "Manual",
- "server.yaml", "Add a node", "Add a control plane", "Replicate the store", "Serve a model", "Upgrade the cluster",
+ "server.yaml", "Add a node", "Add a control plane", "Replicate the store", "Serve a model", "Serve the guardrails' judge", "Upgrade the cluster",
"Restart all replicas", "Back up now", "Wipe this machine", "Leave"}
// consoleAction is what is done once the screen closes: each runs its own
@@ -496,7 +497,7 @@ func (m consoleModel) key(k tea.KeyMsg) (tea.Model, tea.Cmd) {
return m.leave()
case cWipe:
return m.askWipe()
- case cUpgrade, cRestartAll, cBackup:
+ case cUpgrade, cRestartAll, cBackup, cJudge:
return m, m.doEntry()
case cOverview:
return m, nil
@@ -1299,6 +1300,9 @@ func (m consoleModel) panel() (title, body, keys string) {
item = "\n Moves the store onto two machines under Patroni, replicated,\n so losing one loses nothing.\n\n " +
sDim.Render("On the command line:") +
"\n sudo --preserve-env=SSH_AUTH_SOCK llmgw server init \\\n --patroni @ --patroni @ --no-mirror --no-node --no-model"
+ case cJudge:
+ item = "\n Serves the model the guardrail policies ask as their judge — Llama\n Guard 3 1B on CPUs, under the name the policies give it — so what they\n judge is not refused (guard_unavailable). A model of that name that is\n not a judge is replaced. Overview says when a judge is missing.\n\n " +
+ sDim.Render("On the command line:") + "\n llmgw ctl guard judge status\n llmgw ctl guard judge enable --replace"
case cUpgrade:
item = "\n Moves the cluster to a release: this machine first, then each node\n in turn. Engines keep serving; a node that does not come back stops it.\n\n " +
sDim.Render("On the command line:") + "\n sudo llmgw ctl upgrade VERSION"
diff --git a/internal/control/adminapi/guard.go b/internal/control/adminapi/guard.go
index 8dc128e0..60b14f63 100644
--- a/internal/control/adminapi/guard.go
+++ b/internal/control/adminapi/guard.go
@@ -28,6 +28,8 @@ type GuardAdmin struct {
Library func() guard.Config
Store GuardStore
Invalidate func()
+ // JudgeErrors are each judge's last failure to give a verdict.
+ JudgeErrors func() map[string]guard.JudgeError
}
// SetGuard enables the guard endpoints.
@@ -198,6 +200,7 @@ type GuardAttachmentRequest struct {
}
func (a *API) registerGuard(mux Mux) {
+ mux.HandleFunc("GET /api/v1/guard/judges", a.listJudges)
mux.HandleFunc("GET /api/v1/guard", func(w http.ResponseWriter, r *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)")
diff --git a/internal/control/adminapi/judges.go b/internal/control/adminapi/judges.go
new file mode 100644
index 00000000..9cd01c9e
--- /dev/null
+++ b/internal/control/adminapi/judges.go
@@ -0,0 +1,124 @@
+package adminapi
+
+import (
+ "fmt"
+ "net/http"
+ "slices"
+ "sort"
+ "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"`
+ // 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()
+ byModel := map[string]*JudgeView{}
+ for name, p := range lib.Policies {
+ if p.Judge == nil || p.Judge.Model == "" {
+ continue
+ }
+ v := byModel[p.Judge.Model]
+ if v == nil {
+ v = &JudgeView{Model: p.Judge.Model}
+ byModel[p.Judge.Model] = v
+ }
+ v.Policies = append(v.Policies, name)
+ if p.Fail == "closed" && p.Mode != "shadow" {
+ v.Closed = append(v.Closed, name)
+ }
+ }
+ 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"
+ if 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())
+}
diff --git a/internal/control/adminapi/judges_test.go b/internal/control/adminapi/judges_test.go
new file mode 100644
index 00000000..941dd5a1
--- /dev/null
+++ b/internal/control/adminapi/judges_test.go
@@ -0,0 +1,66 @@
+package adminapi
+
+import (
+ "strings"
+ "testing"
+ "time"
+
+ "gitea.mynodes.xyz/ilker/llmgw/internal/control/catalog"
+ "gitea.mynodes.xyz/ilker/llmgw/internal/guard"
+)
+
+func TestEachJudgeSaysWhatIsWrongWithIt(t *testing.T) {
+ models := append(testModels(),
+ // Served from the mirror page before it knew better: a model
+ // clients use, under the judge's name.
+ catalog.Model{Name: "shield", Source: "/s", Scaling: catalog.Scaling{Min: 1, Max: 1},
+ Profiles: []catalog.Profile{{Name: "cpu", Engine: "llamacpp"}}},
+ catalog.Model{Name: "llama-guard-3-1b", Source: "/g", Role: catalog.RoleGuard,
+ Guard: &catalog.GuardModel{Format: "llamaguard3"}, Scaling: catalog.Scaling{Min: 2, Max: 2},
+ Profiles: []catalog.Profile{{Name: "cpu", Engine: "llamacpp"}}})
+ a, _ := setup(t, models)
+ lib := guard.Config{Default: []string{"baseline"}, Policies: map[string]guard.Policy{
+ "baseline": {Fail: "closed", Judge: &guard.JudgePolicy{Model: "llama-guard-3-1b", Input: "block"}},
+ "strict": {Fail: "closed", Judge: &guard.JudgePolicy{Model: "missing-judge", Input: "block"}},
+ "shield": {Fail: "open", Judge: &guard.JudgePolicy{Model: "shield", Input: "flag"}},
+ }}
+ a.SetGuard(&GuardAdmin{Library: func() guard.Config { return lib }})
+
+ var js []JudgeView
+ if code := call(t, a, "GET", "/api/v1/guard/judges", "", &js); code != 200 || len(js) != 3 {
+ t.Fatalf("%d %+v", code, js)
+ }
+ by := map[string]JudgeView{}
+ for _, j := range js {
+ by[j.Model] = j
+ }
+ if j := by["missing-judge"]; j.InCatalog || !strings.Contains(j.Problem, "not in the catalog") ||
+ !strings.Contains(j.Problem, "refused") {
+ t.Errorf("missing: %+v", j)
+ }
+ if j := by["shield"]; !j.InCatalog || j.Judge || !strings.Contains(j.Problem, "--replace") ||
+ !strings.Contains(j.Problem, "unjudged") {
+ t.Errorf("not a judge: %+v", j)
+ }
+ if j := by["llama-guard-3-1b"]; !j.Judge || j.Wanted != 2 || !strings.Contains(j.Problem, "no replica ready") {
+ t.Errorf("not running: %+v", j)
+ }
+
+ var s Summary
+ call(t, a, "GET", "/api/v1/summary", "", &s)
+ if len(s.Judges) != 3 {
+ t.Errorf("the summary does not carry the judges' problems: %+v", s.Judges)
+ }
+
+ // A judge that failed lately says so, once it is otherwise fine.
+ at := time.Now()
+ v := JudgeView{Model: "x", InCatalog: true, Judge: true, Ready: 1, LastError: "deadline exceeded", LastErrorAt: &at}
+ if p := judgeProblem(v); !strings.Contains(p, "deadline exceeded") {
+ t.Errorf("a recent failure: %q", p)
+ }
+ old := at.Add(-time.Hour)
+ v.LastErrorAt = &old
+ if p := judgeProblem(v); p != "" {
+ t.Errorf("an old failure still counts: %q", p)
+ }
+}
diff --git a/internal/control/adminapi/routes.go b/internal/control/adminapi/routes.go
index 0ea0d56e..4a3e05fd 100644
--- a/internal/control/adminapi/routes.go
+++ b/internal/control/adminapi/routes.go
@@ -254,6 +254,8 @@ var Routes = []Route{
{Name: "since", Description: "How far back: 90m, 24h, 7d; 24h when absent."},
},
Response: GuardEventsView{}},
+ {Method: "GET", Path: "/api/v1/guard/judges", Summary: "The judge models the policies ask, and what is wrong with each",
+ Response: []JudgeView{}},
{Method: "POST", Path: "/api/v1/guard/attachments", Summary: "Attach a guardrail policy",
Request: GuardAttachmentRequest{}, Response: guard.Attachment{}},
{Method: "DELETE", Path: "/api/v1/guard/attachments", Summary: "Detach a guardrail policy",
diff --git a/internal/control/adminapi/summary.go b/internal/control/adminapi/summary.go
index 9c0d9e3d..600aa12e 100644
--- a/internal/control/adminapi/summary.go
+++ b/internal/control/adminapi/summary.go
@@ -42,6 +42,8 @@ type Summary struct {
// ControlPlanes is the cluster's control planes when there is more than
// one: which leads, which stand by, and the floating address in front.
ControlPlanes ControlPlanes `json:"control_planes,omitzero"`
+ // Judges are the guardrails' judge models with something wrong.
+ Judges []JudgeView `json:"judges,omitempty"`
}
// StoreMember is one member of a replicated store, as its Patroni reports it.
@@ -149,6 +151,11 @@ const idleThreshold = 10 * time.Minute
// Summary builds the fleet summary.
func (a *API) Summary() Summary {
s := Summary{Version: version.Version}
+ for _, j := range a.Judges() {
+ if j.Problem != "" {
+ s.Judges = append(s.Judges, j)
+ }
+ }
if a.storeStatus != nil {
members, err := a.storeStatus()
s.Store = members
diff --git a/internal/guard/gate.go b/internal/guard/gate.go
index 620f0c15..56a7d4f2 100644
--- a/internal/guard/gate.go
+++ b/internal/guard/gate.go
@@ -5,6 +5,7 @@ import (
"encoding/json"
"fmt"
"log/slog"
+ "maps"
"regexp"
"sort"
"strings"
@@ -43,6 +44,9 @@ type Gate struct {
m *gateMetrics
// alarm is told each time a judge gives no verdict; nil tells no one.
alarm func(model string, err error)
+ // lastErr is each judge's last failure to give a verdict.
+ errMu sync.Mutex
+ lastErr map[string]JudgeError
mu sync.Mutex
attached map[string][]string // "scope/id" → policy names
@@ -448,11 +452,30 @@ func (g *Gate) Input(ctx context.Context, s Subject, body []byte) (Verdict, erro
func (g *Gate) SetJudgeAlarm(f func(model string, err error)) { g.alarm = f }
func (g *Gate) judgeFailed(model string, err error) {
+ g.errMu.Lock()
+ if g.lastErr == nil {
+ g.lastErr = map[string]JudgeError{}
+ }
+ g.lastErr[model] = JudgeError{Err: err.Error(), At: g.now()}
+ g.errMu.Unlock()
if g.alarm != nil {
g.alarm(model, err)
}
}
+// JudgeError is a judge's last failure to give a verdict.
+type JudgeError struct {
+ Err string
+ At time.Time
+}
+
+// JudgeErrors are each judge's last failure to give a verdict, by model.
+func (g *Gate) JudgeErrors() map[string]JudgeError {
+ g.errMu.Lock()
+ defer g.errMu.Unlock()
+ return maps.Clone(g.lastErr)
+}
+
func judgeFinding(p Policy, action string, res judgment, err error) finding {
if err != nil {
// Unanswered: closed stops it, open lets it through, said.
diff --git a/internal/web/account.go b/internal/web/account.go
index d37b1288..33002427 100644
--- a/internal/web/account.go
+++ b/internal/web/account.go
@@ -88,8 +88,10 @@ type AccountPages struct {
oidc *oidcLogin
// audit records every change made through these pages; nil records none.
audit audit.Sink
- // hasGuard is set when /admin/guard is registered.
- hasGuard bool
+ // hasGuard is set when /admin/guard is registered, and guardLibrary
+ // with it: the policies in force, whose judges the mirror page serves.
+ hasGuard bool
+ guardLibrary func() guard.Config
// guardFor names the guardrail policies in force for someone, and
// whether they keep nothing; nil when there are none.
guardFor func(guard.Subject) (string, bool)
diff --git a/internal/web/admin_guard.go b/internal/web/admin_guard.go
index f9119d59..46621189 100644
--- a/internal/web/admin_guard.go
+++ b/internal/web/admin_guard.go
@@ -50,7 +50,7 @@ var guardWindows = map[string]time.Duration{"24h": 24 * time.Hour, "7d": 7 * 24
// RegisterAdminGuard mounts /admin/guard: what the guardrails did, where, by
// which policy — names and counts, never text.
func (p *AccountPages) RegisterAdminGuard(mux *http.ServeMux, s GuardAdminSource, library func() guard.Config) {
- p.hasGuard = true
+ p.hasGuard, p.guardLibrary = true, library
mux.HandleFunc("GET /admin/guard", p.adminOnly(func(w http.ResponseWriter, r *http.Request, admin store.User) {
ctx, l := r.Context(), langOf(r)
lib := library()
diff --git a/internal/web/admin_mirror.go b/internal/web/admin_mirror.go
index e84ebbd5..0c7bd271 100644
--- a/internal/web/admin_mirror.go
+++ b/internal/web/admin_mirror.go
@@ -324,10 +324,14 @@ func (p *AccountPages) RegisterAdminMirror(mux *http.ServeMux, d MirrorDeps) {
if err != nil {
return "", err
}
+ // A judge's weights are served as a judge, under the name the
+ // policies ask: served as a model clients use, the policies
+ // never find it and refuse every request they judge.
+ judge, renamed := p.asJudge(&model)
// The CPU while GPUs sit free is a choice to make on purpose:
// made by default, it was a model running at a tenth of the
- // speed with nothing saying why.
- if model.Profiles[0].CPUOnly() && r.FormValue("cpu_ok") == "" {
+ // speed with nothing saying why. A judge is meant for the CPU.
+ if !judge && model.Profiles[0].CPUOnly() && r.FormValue("cpu_ok") == "" {
if n := freeGPUs(p.fleet); n > 0 {
return "", errors.New(l.N("serve.err.cpu_with_free_gpus", int64(n), model.Name))
}
@@ -346,8 +350,15 @@ func (p *AccountPages) RegisterAdminMirror(mux *http.ServeMux, d MirrorDeps) {
}
// Placed on the next pass, not now: saying it is serving would
// be wrong for however long the weights take to reach a node.
- return l.T("serve.msg.added", model.Name,
- l.N("serve.replicas_n", int64(model.Scaling.Min)), describeServe(l, model)), nil
+ msg := l.T("serve.msg.added", model.Name,
+ l.N("serve.replicas_n", int64(model.Scaling.Min)), describeServe(l, model))
+ switch {
+ case renamed != "":
+ msg += " " + l.T("serve.msg.judge_renamed", renamed, model.Name)
+ case judge:
+ msg += " " + l.T("serve.msg.judge", model.Name)
+ }
+ return msg, nil
}))
}
diff --git a/internal/web/admin_mirror_test.go b/internal/web/admin_mirror_test.go
index 2dec8b03..a91242d0 100644
--- a/internal/web/admin_mirror_test.go
+++ b/internal/web/admin_mirror_test.go
@@ -4,6 +4,7 @@ import (
"context"
"errors"
"gitea.mynodes.xyz/ilker/llmgw/internal/control/catalog"
+ "gitea.mynodes.xyz/ilker/llmgw/internal/guard"
"io"
"log/slog"
"net/http"
@@ -745,3 +746,23 @@ func TestASafetensorsSetIsFetchedWithWhatTheEngineReadsBesideIt(t *testing.T) {
t.Errorf("the companions are not said; got %s", body)
}
}
+
+func TestAJudgesWeightsAreServedAsTheJudgeThePoliciesAsk(t *testing.T) {
+ p := &AccountPages{guardLibrary: func() guard.Config {
+ return guard.Config{Policies: map[string]guard.Policy{
+ "baseline": {Judge: &guard.JudgePolicy{Model: "llama-guard-3-1b"}},
+ }}
+ }}
+ m := catalog.Model{Name: "llama-guard", Source: "http://10.0.0.146:8081/llama-guard/x/Llama-Guard-3-1B.Q4_K_M.gguf",
+ RoutingProfile: "chat"}
+ judge, renamed := p.asJudge(&m)
+ if !judge || renamed != "llama-guard" || m.Name != "llama-guard-3-1b" || !m.IsGuard() ||
+ m.Guard.Format != "llamaguard3" || m.RoutingProfile != "" {
+ t.Errorf("judge %v renamed %q: %+v", judge, renamed, m)
+ }
+
+ plain := catalog.Model{Name: "qwen", Source: "http://m/qwen.gguf", RoutingProfile: "chat"}
+ if judge, _ := p.asJudge(&plain); judge || plain.IsGuard() || plain.Name != "qwen" {
+ t.Errorf("a model clients use became a judge: %+v", plain)
+ }
+}
diff --git a/internal/web/admin_serve.go b/internal/web/admin_serve.go
index dc211599..e9889147 100644
--- a/internal/web/admin_serve.go
+++ b/internal/web/admin_serve.go
@@ -585,3 +585,36 @@ func argInt(args []string, flag string) int {
}
return 0
}
+
+// isJudgeWeights is whether a source is a guard model's weights: Llama
+// Guard, which reads a conversation and says whether it is safe.
+func isJudgeWeights(source string) bool {
+ return strings.Contains(strings.ToLower(source), "llama-guard")
+}
+
+// asJudge makes a model built from the serve form a judge when its weights
+// are one's: role guard, Llama Guard's format, under the name the policies
+// give their judge when they give one and the form gave another. It says
+// whether it did, and the name the form gave when it was changed.
+func (p *AccountPages) asJudge(m *catalog.Model) (judge bool, renamed string) {
+ if !isJudgeWeights(m.Source) {
+ return false, ""
+ }
+ m.Role, m.Guard, m.RoutingProfile = catalog.RoleGuard, &catalog.GuardModel{Format: "llamaguard3"}, ""
+ if p.guardLibrary == nil {
+ return true, ""
+ }
+ asked := map[string]bool{}
+ for _, pol := range p.guardLibrary().Policies {
+ if pol.Judge != nil && pol.Judge.Model != "" {
+ asked[pol.Judge.Model] = true
+ }
+ }
+ if asked[m.Name] || len(asked) != 1 {
+ return true, ""
+ }
+ for name := range asked {
+ renamed, m.Name = m.Name, name
+ }
+ return true, renamed
+}
diff --git a/internal/web/locales/en/serve.json b/internal/web/locales/en/serve.json
index 9a4b17ba..d36fa626 100644
--- a/internal/web/locales/en/serve.json
+++ b/internal/web/locales/en/serve.json
@@ -77,5 +77,7 @@
"serve.err.cpu_with_free_gpus.other": "this fleet has %d GPUs free and %s was set to run on the CPU; choose a GPU placement, or tick \"run on the CPU anyway\"",
"serve.msg.added": "%s added to the catalog, %s: %s; it is placed on the next pass",
"serve.replicas_n.one": "%d replica",
- "serve.replicas_n.other": "%d replicas"
+ "serve.replicas_n.other": "%d replicas",
+ "serve.msg.judge": "It is the guardrails' judge (role: guard): policies naming %s ask it; clients do not see it.",
+ "serve.msg.judge_renamed": "Its weights are a judge's, so it is the guardrails' judge (role: guard), named %[2]s as the policies ask rather than %[1]s."
}
diff --git a/internal/web/locales/tr/serve.json b/internal/web/locales/tr/serve.json
index 2798b719..6113f7f1 100644
--- a/internal/web/locales/tr/serve.json
+++ b/internal/web/locales/tr/serve.json
@@ -74,5 +74,7 @@
"serve.err.placement": "%q geçerli bir yerleşim değil",
"serve.err.cpu_with_free_gpus.other": "bu kümede %d GPU boşta, %s ise CPU üzerinde çalışacak biçimde ayarlandı; bir GPU yerleşimi seçin ya da \"yine de CPU üzerinde çalıştır\" kutusunu işaretleyin",
"serve.msg.added": "%s kataloğa eklendi, %s: %s; bir sonraki geçişte yerleştirilecek",
- "serve.replicas_n.other": "%d replika"
+ "serve.replicas_n.other": "%d replika",
+ "serve.msg.judge": "Guardrail'lerin judge modeli olarak eklendi (role: guard): %s adını veren politikalar ona soruyor; istemciler görmüyor.",
+ "serve.msg.judge_renamed": "Ağırlıkları bir judge modelinin olduğu için guardrail judge'ı olarak eklendi (role: guard); adı %[1]s yerine politikaların istediği gibi %[2]s."
}