Compare commits

...
2 Commits
Author SHA1 Message Date
Ilker ManapandClaude Opus 5.5 3f5a809373 v0.12.1
CI / test (push) Failing after 4m20s
Release / build (push) Successful in 4m36s
Release / notes (push) Successful in 3s
The guardrails' judge served and watched without server init.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-06 10:52:24 +02:00
Ilker ManapandClaude Opus 5.5 a8bb417618 The guardrails' judge has its own command, and its trouble is shown
llmgw ctl guard judge status lists each judge model the policies ask:
in the catalog, as a judge, replicas ready, its last failure to answer, and
what is wrong in words. enable serves the missing one through model serve -
Llama Guard 3 1B on CPUs, role guard, under the name the policies give it -
with --replace for a model of that name that is not a judge; the console has
it under Do. GET /api/v1/guard/judges, and the summary's judges, put the
problem on ctl status and the console's Overview.

A Llama Guard file served from the mirror page is added as a judge, under
the name the policies ask, rather than as a model clients use the policies
never find. ctl says plainly when the API answers with a page.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-06 10:47:37 +02:00
24 changed files with 672 additions and 13 deletions

No files matched your search

+31
View File
@@ -22,6 +22,37 @@ 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.
## v0.12.1 — 2026-10-06
The guardrails' judge, served and watched without server init: a judge
missing, served as the wrong kind of model, or not answering is said on
`ctl status` and the console, and `llmgw ctl guard judge enable` puts it
right. Nothing to do to upgrade.
### 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
+83
View File
@@ -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."
}
}
},
+17 -1
View File
@@ -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)
+7
View File
@@ -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)
}
+22
View File
@@ -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("<!doctype html><title>llmgw</title>"))
}))
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)
}
}
+1
View File
@@ -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",
+189
View File
@@ -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.<name>.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
}
+2 -1
View File
@@ -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.
+7
View File
@@ -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 {
+2
View File
@@ -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
}
+6 -2
View File
@@ -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 <user>@<a> --patroni <user>@<b> --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"
+3
View File
@@ -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)")
+124
View File
@@ -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())
}
+66
View File
@@ -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)
}
}
+2
View File
@@ -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",
+7
View File
@@ -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
+23
View File
@@ -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.
+4 -2
View File
@@ -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)
+1 -1
View File
@@ -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()
+15 -4
View File
@@ -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
}))
}
+21
View File
@@ -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)
}
}
+33
View File
@@ -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
}
+3 -1
View File
@@ -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."
}
+3 -1
View File
@@ -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."
}