Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f285aa0548 | ||
|
|
4721b8e353 | ||
|
|
249a91aeec | ||
|
|
3f5a809373 | ||
|
|
a8bb417618 |
No files matched your search
@@ -22,6 +22,62 @@ 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.2 — 2026-10-06
|
||||
|
||||
The guardrails turned off and on as a whole, and a judge that refuses
|
||||
ordinary requests explained: the hazard it saw is told to the client and
|
||||
kept with the request, and `llmgw ctl guard judge ask` shows what it says of
|
||||
any text. Nothing to do to upgrade; the store gains a column.
|
||||
|
||||
### Added
|
||||
|
||||
- **The guardrails turn off and on as a whole**: `llmgw ctl guard off` /
|
||||
`on`, which is `guard.enabled` in the cluster's settings — in force within
|
||||
seconds, the policies and their attachments kept. `ctl status`, `ctl guard
|
||||
list` and the guardrails page say when they are off.
|
||||
- **`llmgw ctl guard judge ask TEXT`** has the judge read a text and shows
|
||||
its answer as given and as read — for finding out why ordinary requests
|
||||
are refused. Nothing of the text is kept, not even in the audit.
|
||||
|
||||
### Changed
|
||||
|
||||
- **A judge's refusal names the hazard**: the client is told `(judge:
|
||||
S6,Specialized Advice)` rather than `(judge)`, and the verdict is kept with
|
||||
the request (`guard_rule`): `ctl request`, the server's log and the
|
||||
guardrails page show it. Llama Guard 3's codes come with their names,
|
||||
S9 Indiscriminate Weapons, without a categories list.
|
||||
|
||||
## 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
|
||||
|
||||
@@ -894,6 +894,78 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"/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/guard/judges/ask": {
|
||||
"post": {
|
||||
"operationId": "postGuardJudgesAsk",
|
||||
"summary": "Have a judge read a text and say what it answered; nothing is kept",
|
||||
"tags": [
|
||||
"guard"
|
||||
],
|
||||
"parameters": [
|
||||
{
|
||||
"$ref": "#/components/parameters/Actor"
|
||||
}
|
||||
],
|
||||
"requestBody": {
|
||||
"required": true,
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/JudgeAskRequest"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "OK",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/JudgeAskView"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"401": {
|
||||
"$ref": "#/components/responses/Unauthorized"
|
||||
},
|
||||
"default": {
|
||||
"$ref": "#/components/responses/Error"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"/api/v1/jobs": {
|
||||
"get": {
|
||||
"operationId": "getJobs",
|
||||
@@ -4099,6 +4171,10 @@
|
||||
"category": {
|
||||
"type": "string"
|
||||
},
|
||||
"rule": {
|
||||
"type": "string",
|
||||
"description": "Rule is what decided it within the category: a pattern's name, or a judge's verdict, \"S6,Specialized Advice\"."
|
||||
},
|
||||
"guard_ms": {
|
||||
"type": "integer",
|
||||
"format": "int64"
|
||||
@@ -4161,6 +4237,10 @@
|
||||
"type": "object",
|
||||
"description": "GuardView is the library and where its policies are attached.",
|
||||
"properties": {
|
||||
"enabled": {
|
||||
"type": "boolean",
|
||||
"description": "Enabled is whether the guardrails check anything (guard.enabled)."
|
||||
},
|
||||
"policies": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
@@ -4359,6 +4439,96 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"JudgeAskRequest": {
|
||||
"type": "object",
|
||||
"description": "JudgeAskRequest is a text to have a judge read.",
|
||||
"properties": {
|
||||
"model": {
|
||||
"type": "string",
|
||||
"description": "Model is the judge; empty is the one the policies ask, when they ask one."
|
||||
},
|
||||
"text": {
|
||||
"type": "string"
|
||||
},
|
||||
"answer": {
|
||||
"type": "string",
|
||||
"description": "Answer, when given, is read as the assistant's reply to Text."
|
||||
}
|
||||
}
|
||||
},
|
||||
"JudgeAskView": {
|
||||
"type": "object",
|
||||
"description": "JudgeAskView is what the judge answered.",
|
||||
"properties": {
|
||||
"model": {
|
||||
"type": "string"
|
||||
},
|
||||
"verdict": {
|
||||
"type": "string",
|
||||
"description": "Verdict is safe or unsafe, as the gateway reads the answer."
|
||||
},
|
||||
"categories": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"raw": {
|
||||
"type": "string",
|
||||
"description": "Raw is the judge's answer as it gave it."
|
||||
},
|
||||
"took_ms": {
|
||||
"type": "integer",
|
||||
"format": "int64"
|
||||
}
|
||||
}
|
||||
},
|
||||
"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.",
|
||||
@@ -5325,6 +5495,9 @@
|
||||
"guard_category": {
|
||||
"type": "string"
|
||||
},
|
||||
"guard_rule": {
|
||||
"type": "string"
|
||||
},
|
||||
"guard_ms": {
|
||||
"type": "integer",
|
||||
"format": "int64"
|
||||
@@ -5696,6 +5869,17 @@
|
||||
"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."
|
||||
},
|
||||
"guard_off": {
|
||||
"type": "boolean",
|
||||
"description": "GuardOff says the guardrails are turned off (guard.enabled: false): no request is checked, whatever the policies say."
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
+49
-1
@@ -15,6 +15,19 @@ checked and nothing costs anything. With one, every request is judged once it is
|
||||
within its limits, with its stored conversation spliced in, and before it is
|
||||
sent to a model; and the answer is judged before the client sees it.
|
||||
|
||||
## Turning them off and on
|
||||
|
||||
```
|
||||
llmgw ctl guard off --note "why" # no request is checked, whatever the policies say
|
||||
llmgw ctl guard on # every policy checks again, as it was
|
||||
```
|
||||
|
||||
That is `guard.enabled` in the cluster's settings, in force on every control
|
||||
plane within seconds without a restart. The policies and where they are
|
||||
attached are kept. While off, `llmgw ctl status`, `llmgw ctl guard list` and
|
||||
the guardrails page say so. To stop one policy rather than all, give it
|
||||
`mode: off`, or `mode: shadow` to have it record what it would do.
|
||||
|
||||
## A policy
|
||||
|
||||
```yaml
|
||||
@@ -103,7 +116,42 @@ 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.
|
||||
|
||||
**When it refuses ordinary requests.** A refusal by a judge names the hazard
|
||||
it saw — the client is told `refused by the gateway's policy (judge:
|
||||
S6,Specialized Advice)` — and the same is kept with the request: `llmgw ctl
|
||||
request <id>`, the server's log (`guard_rule`) and the guardrails page show
|
||||
it. To see what the judge says of any text, without anything being kept:
|
||||
|
||||
```
|
||||
llmgw ctl guard judge ask 'How do I sort a list in Python?'
|
||||
# llama-guard-3-1b: safe, 140 ms
|
||||
# it answered: "safe"
|
||||
```
|
||||
|
||||
A judge that says `unsafe` to ordinary text is usually the wrong weights
|
||||
(another repository's or quantisation's, without Llama Guard's template:
|
||||
`judge enable --replace` serves the right ones), or a policy counting every
|
||||
hazard: give its judge `categories`, leaving out S5, S6, S8 and S13 as the
|
||||
baseline does. While finding out, `judge.input: flag` records instead of
|
||||
refusing. 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)
|
||||
|
||||
@@ -372,6 +372,7 @@ rather than repeated.
|
||||
| `llamacpp` | `LlamaCppConfig` | [engines.Config](#enginesconfig) |
|
||||
| `trtllm` | `TRTLLMConfig` | [engines.Config](#enginesconfig) |
|
||||
| `sim` | `SimConfig` | [engines.Config](#enginesconfig) |
|
||||
| `enabled` | `*bool` | [guard.Config](#guardconfig) |
|
||||
| `default` | `[]string` | [guard.Config](#guardconfig) |
|
||||
| `policies` | `map[string]Policy` | [guard.Config](#guardconfig) |
|
||||
| `webhooks` | `[]Webhook` | [notify.Config](#notifyconfig) |
|
||||
@@ -3067,6 +3068,13 @@ Takes a [SimConfig](#simconfig) block.
|
||||
|
||||
## guard.Config
|
||||
|
||||
### `enabled`
|
||||
|
||||
`*bool`
|
||||
|
||||
Enabled turns every policy on or off at once: false checks nothing,
|
||||
whatever is defined or attached. Unset is on.
|
||||
|
||||
### `default`
|
||||
|
||||
`[]string`
|
||||
|
||||
@@ -12,6 +12,7 @@ import (
|
||||
"net/http"
|
||||
"net/url"
|
||||
"regexp"
|
||||
"slices"
|
||||
"sort"
|
||||
"strings"
|
||||
"time"
|
||||
@@ -110,11 +111,16 @@ func (w *statusWriter) Flush() {
|
||||
}
|
||||
}
|
||||
|
||||
// notChanges are POSTs that change nothing and carry text that must not be
|
||||
// kept: asking a judge about a sentence.
|
||||
var notChanges = []string{"/api/v1/guard/judges/ask"}
|
||||
|
||||
// API records every change to the admin API: a request that is not a GET or
|
||||
// HEAD, under /api/. actor says who made it.
|
||||
func API(sink Sink, actor func(*http.Request) string, log *slog.Logger, next http.Handler) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method == http.MethodGet || r.Method == http.MethodHead || !strings.HasPrefix(r.URL.Path, "/api/") {
|
||||
if r.Method == http.MethodGet || r.Method == http.MethodHead || !strings.HasPrefix(r.URL.Path, "/api/") ||
|
||||
slices.Contains(notChanges, r.URL.Path) {
|
||||
next.ServeHTTP(w, r)
|
||||
return
|
||||
}
|
||||
|
||||
@@ -72,3 +72,13 @@ func TestABodyLargerThanWhatIsRecordedReachesTheHandlerWhole(t *testing.T) {
|
||||
t.Errorf("the handler was given %d of %d bytes", got, len(big))
|
||||
}
|
||||
}
|
||||
|
||||
func TestAskingAJudgeKeepsNoText(t *testing.T) {
|
||||
sink := &memSink{}
|
||||
h := API(sink, func(*http.Request) string { return "ilker" }, nil, http.HandlerFunc(func(http.ResponseWriter, *http.Request) {}))
|
||||
h.ServeHTTP(httptest.NewRecorder(), httptest.NewRequest("POST", "/api/v1/guard/judges/ask",
|
||||
strings.NewReader(`{"text":"something a user wrote"}`)))
|
||||
if len(sink.got) != 0 {
|
||||
t.Errorf("a judge's question was recorded: %+v", sink.got)
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -76,8 +76,11 @@ func newRequestCmd(client func() *ctlClient, asJSON *bool) *cobra.Command {
|
||||
fmt.Fprintf(out, "tags %s\n", strings.Join(kv, ", "))
|
||||
}
|
||||
if v.GuardAction != "" {
|
||||
fmt.Fprintf(out, "guard %s by policy %s (%s), %d ms\n", v.GuardAction, v.GuardPolicy,
|
||||
or(v.GuardCategory, "-"), v.GuardMs)
|
||||
what := or(v.GuardCategory, "-")
|
||||
if v.GuardRule != "" {
|
||||
what += ": " + v.GuardRule
|
||||
}
|
||||
fmt.Fprintf(out, "guard %s by policy %s (%s), %d ms\n", v.GuardAction, v.GuardPolicy, what, v.GuardMs)
|
||||
} else if v.GuardMs > 0 {
|
||||
fmt.Fprintf(out, "guard allowed, %d ms\n", v.GuardMs)
|
||||
}
|
||||
|
||||
@@ -3,11 +3,13 @@ package cli
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
|
||||
"gitea.mynodes.xyz/ilker/llmgw/internal/control/adminapi"
|
||||
"gitea.mynodes.xyz/ilker/llmgw/internal/settings"
|
||||
)
|
||||
|
||||
func newGuardCmd(client func() *ctlClient, asJSON *bool) *cobra.Command {
|
||||
@@ -24,6 +26,35 @@ 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))
|
||||
for _, sw := range []struct {
|
||||
use, short string
|
||||
on bool
|
||||
}{
|
||||
{"on", "Turn the guardrails on: every policy checks again", true},
|
||||
{"off", "Turn the guardrails off: no request is checked, whatever the policies say", false},
|
||||
} {
|
||||
var note string
|
||||
cmd := &cobra.Command{
|
||||
Use: sw.use,
|
||||
Short: sw.short,
|
||||
Long: "guard.enabled in the cluster's settings: in force on every control plane\n" +
|
||||
"within seconds, without a restart. The policies and where they are attached\n" +
|
||||
"stay as they are, and come back as they were when turned on again. While\n" +
|
||||
"off, llmgw ctl status says so.",
|
||||
Args: cobra.NoArgs,
|
||||
RunE: func(cmd *cobra.Command, _ []string) error {
|
||||
if note == "" {
|
||||
note = "guardrails " + sw.use
|
||||
}
|
||||
return changeSettings(cmd, client, note, func(doc *settings.Doc) error {
|
||||
return doc.SetYAML([]string{"guard", "enabled"}, strconv.FormatBool(sw.on))
|
||||
})
|
||||
},
|
||||
}
|
||||
cmd.Flags().StringVar(¬e, "note", "", "why, kept with the settings' version")
|
||||
c.AddCommand(cmd)
|
||||
}
|
||||
c.AddCommand(&cobra.Command{
|
||||
Use: "list",
|
||||
Short: "The policies, and where each is attached",
|
||||
@@ -37,6 +68,9 @@ func newGuardCmd(client func() *ctlClient, asJSON *bool) *cobra.Command {
|
||||
return json.NewEncoder(cmd.OutOrStdout()).Encode(v)
|
||||
}
|
||||
out := cmd.OutOrStdout()
|
||||
if !v.Enabled {
|
||||
fmt.Fprintln(out, "The guardrails are OFF: no request is checked (llmgw ctl guard on).")
|
||||
}
|
||||
fmt.Fprintf(out, "Policies: %s\n", strings.Join(v.Policies, ", "))
|
||||
def := strings.Join(v.Default, ", ")
|
||||
if def == "" {
|
||||
|
||||
@@ -0,0 +1,225 @@
|
||||
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), newGuardJudgeAskCmd(client, asJSON))
|
||||
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
|
||||
}
|
||||
|
||||
func newGuardJudgeAskCmd(client func() *ctlClient, asJSON *bool) *cobra.Command {
|
||||
var model, answer string
|
||||
c := &cobra.Command{
|
||||
Use: "ask TEXT",
|
||||
Short: "Have a judge read a text and say what it answered",
|
||||
Long: "For finding out why a request was refused: the judge reads TEXT as a user's\n" +
|
||||
"message (with --answer, ANSWER as the reply to it), past the cache, and its\n" +
|
||||
"answer is shown as it gave it and as the gateway reads it — \"unsafe\" and\n" +
|
||||
"the hazards it named, S6 Specialized Advice and the like. Nothing is kept:\n" +
|
||||
"not in the audit, the usage or the logs.",
|
||||
Example: " llmgw ctl guard judge ask 'How do I sort a list in Python?'\n" +
|
||||
" llmgw ctl guard judge ask 'Write a poem' --answer 'Roses are red'",
|
||||
Args: cobra.ExactArgs(1),
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
body, _ := json.Marshal(adminapi.JudgeAskRequest{Model: model, Text: args[0], Answer: answer})
|
||||
var v adminapi.JudgeAskView
|
||||
if err := client().patient(45*time.Second).post(cmd.Context(), "/api/v1/guard/judges/ask", body, &v); err != nil {
|
||||
return err
|
||||
}
|
||||
if *asJSON {
|
||||
return emitJSON(v)
|
||||
}
|
||||
out := cmd.OutOrStdout()
|
||||
fmt.Fprintf(out, "%s: %s", v.Model, v.Verdict)
|
||||
if len(v.Categories) > 0 {
|
||||
fmt.Fprintf(out, " (%s)", strings.Join(v.Categories, ", "))
|
||||
}
|
||||
fmt.Fprintf(out, ", %d ms\nit answered: %q\n", v.TookMs, v.Raw)
|
||||
return nil
|
||||
},
|
||||
}
|
||||
c.Flags().StringVar(&model, "model", "", "the judge (default: the one the policies ask)")
|
||||
c.Flags().StringVar(&answer, "answer", "", "an assistant's reply to TEXT, judged as output is")
|
||||
return c
|
||||
}
|
||||
@@ -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, Ask: gate.AskJudge})
|
||||
}
|
||||
if st := rt.Store(); st != nil {
|
||||
// Removing a node revokes its certificate.
|
||||
|
||||
@@ -153,6 +153,16 @@ func printSummary(cmd *cobra.Command, s adminapi.Summary) {
|
||||
fmt.Fprintf(out, "STORE %s\n", strings.Join(parts, ", "))
|
||||
}
|
||||
|
||||
if s.GuardOff {
|
||||
fmt.Fprintln(out, "GUARD off: no request is checked, whatever the policies say (llmgw ctl guard on)")
|
||||
}
|
||||
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 {
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -1545,6 +1545,7 @@ type RequestView struct {
|
||||
GuardAction string `json:"guard_action,omitempty"`
|
||||
GuardPolicy string `json:"guard_policy,omitempty"`
|
||||
GuardCategory string `json:"guard_category,omitempty"`
|
||||
GuardRule string `json:"guard_rule,omitempty"`
|
||||
GuardMs int64 `json:"guard_ms,omitempty"`
|
||||
// What the client said about it: OpenAI's user field, and its tags.
|
||||
EndUser string `json:"end_user,omitempty"`
|
||||
@@ -1573,7 +1574,7 @@ func (a *API) request(w http.ResponseWriter, r *http.Request) {
|
||||
Model: u.Model, ServedModel: u.ServedModel, Replica: u.Replica, Status: u.Status,
|
||||
LatencyMs: u.LatencyMs, QueueMs: u.QueueMs, TTFTMs: u.TTFTMs,
|
||||
PromptTokens: u.PromptTokens, CompletionTokens: u.CompletionTokens, CostMicros: u.CostMicros,
|
||||
GuardAction: u.GuardAction, GuardPolicy: u.GuardPolicy, GuardCategory: u.GuardCategory, GuardMs: u.GuardMs,
|
||||
GuardAction: u.GuardAction, GuardPolicy: u.GuardPolicy, GuardCategory: u.GuardCategory, GuardRule: u.GuardRule, GuardMs: u.GuardMs,
|
||||
EndUser: u.EndUser, Tags: u.Tags, CacheHit: u.CacheHit,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -28,6 +28,10 @@ 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
|
||||
// Ask has a judge read a text, past the cache.
|
||||
Ask func(ctx context.Context, model, text, answer string) (guard.JudgeAnswer, error)
|
||||
}
|
||||
|
||||
// SetGuard enables the guard endpoints.
|
||||
@@ -35,6 +39,8 @@ func (a *API) SetGuard(g *GuardAdmin) { a.guard = g }
|
||||
|
||||
// GuardView is the library and where its policies are attached.
|
||||
type GuardView struct {
|
||||
// Enabled is whether the guardrails check anything (guard.enabled).
|
||||
Enabled bool `json:"enabled"`
|
||||
Policies []string `json:"policies"`
|
||||
Default []string `json:"default"`
|
||||
Attachments []GuardAttachmentView `json:"attachments"`
|
||||
@@ -89,7 +95,10 @@ type GuardEventView struct {
|
||||
Action string `json:"action"`
|
||||
Policy string `json:"policy"`
|
||||
Category string `json:"category,omitempty"`
|
||||
GuardMs int64 `json:"guard_ms"`
|
||||
// Rule is what decided it within the category: a pattern's name, or a
|
||||
// judge's verdict, "S6,Specialized Advice".
|
||||
Rule string `json:"rule,omitempty"`
|
||||
GuardMs int64 `json:"guard_ms"`
|
||||
}
|
||||
|
||||
// policyView sums one policy up.
|
||||
@@ -198,6 +207,8 @@ type GuardAttachmentRequest struct {
|
||||
}
|
||||
|
||||
func (a *API) registerGuard(mux Mux) {
|
||||
mux.HandleFunc("GET /api/v1/guard/judges", a.listJudges)
|
||||
mux.HandleFunc("POST /api/v1/guard/judges/ask", a.askJudge)
|
||||
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)")
|
||||
@@ -208,7 +219,7 @@ func (a *API) registerGuard(mux Mux) {
|
||||
writeError(w, http.StatusInternalServerError, err.Error())
|
||||
return
|
||||
}
|
||||
v := GuardView{Default: a.guard.Library().Default, Attachments: []GuardAttachmentView{}}
|
||||
v := GuardView{Enabled: a.guard.Library().On(), Default: a.guard.Library().Default, Attachments: []GuardAttachmentView{}}
|
||||
var names map[string]string
|
||||
if a.quotas != nil {
|
||||
names, _ = a.scopeNames(r.Context())
|
||||
@@ -269,7 +280,7 @@ func (a *API) registerGuard(mux Mux) {
|
||||
slices.SortStableFunc(v.Counts, func(a, b GuardCountView) int { return int(b.Count - a.Count) })
|
||||
for _, e := range events {
|
||||
v.Events = append(v.Events, GuardEventView{At: e.At, RequestID: e.RequestID, Project: project(e.ProjectID),
|
||||
Model: e.Model, Status: e.Status, Action: e.Action, Policy: e.Policy, Category: e.Category, GuardMs: e.GuardMs})
|
||||
Model: e.Model, Status: e.Status, Action: e.Action, Policy: e.Policy, Category: e.Category, Rule: e.Rule, GuardMs: e.GuardMs})
|
||||
}
|
||||
writeJSON(w, http.StatusOK, v)
|
||||
})
|
||||
|
||||
@@ -0,0 +1,181 @@
|
||||
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"`
|
||||
// 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 {
|
||||
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())
|
||||
}
|
||||
|
||||
// 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)
|
||||
}
|
||||
@@ -0,0 +1,91 @@
|
||||
package adminapi
|
||||
|
||||
import (
|
||||
"context"
|
||||
"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)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAJudgeIsAskedAndItsWordsShown(t *testing.T) {
|
||||
a, _ := setup(t, testModels())
|
||||
lib := guard.Config{Policies: map[string]guard.Policy{
|
||||
"baseline": {Judge: &guard.JudgePolicy{Model: "llama-guard-3-1b", Input: "block"}}}}
|
||||
var asked string
|
||||
a.SetGuard(&GuardAdmin{Library: func() guard.Config { return lib },
|
||||
Ask: func(_ context.Context, model, text, _ string) (guard.JudgeAnswer, error) {
|
||||
asked = model + "|" + text
|
||||
return guard.JudgeAnswer{Model: model, Raw: "unsafe\nS6", Unsafe: true,
|
||||
Categories: []string{"S6", "Specialized Advice"}}, nil
|
||||
}})
|
||||
var v JudgeAskView
|
||||
if code := call(t, a, "POST", "/api/v1/guard/judges/ask", `{"text":"how do I sort a list"}`, &v); code != 200 {
|
||||
t.Fatalf("%d", code)
|
||||
}
|
||||
if asked != "llama-guard-3-1b|how do I sort a list" || v.Verdict != "unsafe" || v.Raw != "unsafe\nS6" ||
|
||||
len(v.Categories) != 2 {
|
||||
t.Errorf("asked %q, got %+v", asked, v)
|
||||
}
|
||||
if code := call(t, a, "POST", "/api/v1/guard/judges/ask", `{"text":" "}`, nil); code != 400 {
|
||||
t.Errorf("no text: %d", code)
|
||||
}
|
||||
}
|
||||
@@ -254,6 +254,10 @@ 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/judges/ask", Summary: "Have a judge read a text and say what it answered; nothing is kept",
|
||||
Request: JudgeAskRequest{}, Response: JudgeAskView{}},
|
||||
{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",
|
||||
|
||||
@@ -42,6 +42,11 @@ 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"`
|
||||
// GuardOff says the guardrails are turned off (guard.enabled: false):
|
||||
// no request is checked, whatever the policies say.
|
||||
GuardOff bool `json:"guard_off,omitempty"`
|
||||
}
|
||||
|
||||
// StoreMember is one member of a replicated store, as its Patroni reports it.
|
||||
@@ -149,6 +154,14 @@ const idleThreshold = 10 * time.Minute
|
||||
// Summary builds the fleet summary.
|
||||
func (a *API) Summary() Summary {
|
||||
s := Summary{Version: version.Version}
|
||||
if a.guard != nil && a.guard.Library != nil && !a.guard.Library().On() {
|
||||
s.GuardOff = true
|
||||
}
|
||||
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
|
||||
|
||||
@@ -484,6 +484,7 @@ var Reference = []Block{
|
||||
{Key: "sim", Type: "SimConfig", Doc: "", Nested: "engines.SimConfig"},
|
||||
}},
|
||||
{Package: "guard", Name: "Config", Title: "guard.Config", Doc: "", Fields: []Field{
|
||||
{Key: "enabled", Type: "*bool", Doc: "Enabled turns every policy on or off at once: false checks nothing,\nwhatever is defined or attached. Unset is on.", Nested: ""},
|
||||
{Key: "default", Type: "[]string", Doc: "", Nested: ""},
|
||||
{Key: "policies", Type: "map[string]Policy", Doc: "", Nested: ""},
|
||||
}},
|
||||
|
||||
+39
-1
@@ -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
|
||||
@@ -152,6 +156,10 @@ func (g *Gate) Invalidate() { g.reload(context.Background()) }
|
||||
// from.
|
||||
func (g *Gate) Policy(s Subject) (Policy, string) {
|
||||
g.mu.Lock()
|
||||
if !g.cfg.On() {
|
||||
g.mu.Unlock()
|
||||
return Policy{}, ""
|
||||
}
|
||||
if !g.loading && g.store != nil && g.now().Sub(g.loadedAt) > attachmentsTTL {
|
||||
g.loading = true
|
||||
go g.reload(context.WithoutCancel(context.Background()))
|
||||
@@ -324,7 +332,7 @@ func (g *Gate) Input(ctx context.Context, s Subject, body []byte) (Verdict, erro
|
||||
done := func(f finding) Verdict {
|
||||
v.Action, v.Category, v.Rule = f.action, f.category, f.rule
|
||||
if f.action == Block {
|
||||
v.Message = fmt.Sprintf("this request was refused by the gateway's policy (%s)", f.category)
|
||||
v.Message = fmt.Sprintf("this request was refused by the gateway's policy (%s)", refusedFor(f))
|
||||
v.Body = nil
|
||||
}
|
||||
v.Took = g.now().Sub(start)
|
||||
@@ -448,11 +456,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.
|
||||
@@ -501,3 +528,14 @@ func toolAllowed(t Tools, name string) bool {
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// refusedFor is what a refusal names: the category, and for a judge the
|
||||
// hazard it saw — "judge: S6,Specialized Advice" — so whoever is refused
|
||||
// can say why, and a judge refusing ordinary questions can be told apart.
|
||||
// Never the text.
|
||||
func refusedFor(f finding) string {
|
||||
if f.category == "judge" && f.rule != "" && f.rule != "unavailable" {
|
||||
return "judge: " + f.rule
|
||||
}
|
||||
return f.category
|
||||
}
|
||||
@@ -278,3 +278,34 @@ func TestCapabilitiesAndStructuredAnswersWhenAskedFor(t *testing.T) {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestTheGuardrailsTurnOffAndOnAsAWhole(t *testing.T) {
|
||||
off := false
|
||||
cfg := lib(map[string]Policy{"base": {Input: Rules{PII: map[string]string{"secret": "block"}}}}, "base")
|
||||
cfg.Enabled = &off
|
||||
g := gate(t, cfg)
|
||||
secret := chat("user", "my key is sk-proj-abcdefghijklmnopqrstuvwx1234")
|
||||
if v, err := g.Input(context.Background(), Subject{}, secret); err != nil || v.Action != "" {
|
||||
t.Errorf("off, a secret was acted on: %+v %v", v, err)
|
||||
}
|
||||
on := true
|
||||
cfg.Enabled = &on
|
||||
if err := g.Replace(cfg); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if v, _ := g.Input(context.Background(), Subject{}, secret); v.Action != Block {
|
||||
t.Errorf("on again, a secret went through: %+v", v)
|
||||
}
|
||||
if !(Config{}).On() {
|
||||
t.Error("unset is not on")
|
||||
}
|
||||
}
|
||||
|
||||
func TestAJudgesRefusalNamesTheHazard(t *testing.T) {
|
||||
if got := refusedFor(finding{category: "judge", rule: "S6,Specialized Advice"}); got != "judge: S6,Specialized Advice" {
|
||||
t.Errorf("%q", got)
|
||||
}
|
||||
if got := refusedFor(finding{category: "secret", rule: "pii:secret"}); got != "secret" {
|
||||
t.Errorf("%q", got)
|
||||
}
|
||||
}
|
||||
@@ -294,6 +294,10 @@ func parseJudgment(format string, names []string, body []byte, answer string) (j
|
||||
}
|
||||
switch format {
|
||||
case "llamaguard3":
|
||||
if len(names) == 0 {
|
||||
// Its own: S1 is Violent Crimes, S9 Indiscriminate Weapons.
|
||||
names = llamaGuard3Categories
|
||||
}
|
||||
lines := strings.Split(text, "\n")
|
||||
switch strings.ToLower(strings.TrimSpace(lines[0])) {
|
||||
case "safe":
|
||||
@@ -369,3 +373,53 @@ func DefaultCategories(format string) []string {
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// JudgeAnswer is what a judge said of a text: its words as it gave them,
|
||||
// and as the gateway reads them.
|
||||
type JudgeAnswer struct {
|
||||
Model string
|
||||
Raw string
|
||||
Unsafe bool
|
||||
Categories []string
|
||||
Took time.Duration
|
||||
}
|
||||
|
||||
// AskJudge has a judge read a text, past the cache, and says what it
|
||||
// answered: for an operator finding out why ordinary requests are refused.
|
||||
// With answer, the judge reads it as the assistant's reply to text.
|
||||
func (g *Gate) AskJudge(ctx context.Context, model, text, answer string) (JudgeAnswer, error) {
|
||||
out := JudgeAnswer{Model: model}
|
||||
if g.judger == nil {
|
||||
return out, errNoJudge
|
||||
}
|
||||
format, names, ok := g.judger.format(model)
|
||||
if !ok {
|
||||
return out, fmt.Errorf("%w: %q is not a model with role: guard", errNoJudge, model)
|
||||
}
|
||||
ctx, cancel := context.WithTimeout(ctx, 30*time.Second)
|
||||
defer cancel()
|
||||
start := time.Now()
|
||||
path, body := judgeRequest(model, format, names, text, answer)
|
||||
reply, err := g.judger.call(ctx, model, path, body)
|
||||
out.Took = time.Since(start)
|
||||
if err != nil {
|
||||
return out, err
|
||||
}
|
||||
var resp struct {
|
||||
Choices []struct {
|
||||
Message struct {
|
||||
Content string `json:"content"`
|
||||
} `json:"message"`
|
||||
Text string `json:"text"`
|
||||
} `json:"choices"`
|
||||
}
|
||||
if json.Unmarshal(reply, &resp) == nil && len(resp.Choices) > 0 {
|
||||
out.Raw = strings.TrimSpace(resp.Choices[0].Message.Content + resp.Choices[0].Text)
|
||||
}
|
||||
res, err := parseJudgment(format, names, reply, answer)
|
||||
if err != nil {
|
||||
return out, err
|
||||
}
|
||||
out.Unsafe, out.Categories = res.unsafe, res.categories
|
||||
return out, nil
|
||||
}
|
||||
@@ -25,10 +25,16 @@ import (
|
||||
// Policies are attached to parts of the tenancy tree in the store; Default
|
||||
// applies to every request.
|
||||
type Config struct {
|
||||
// Enabled turns every policy on or off at once: false checks nothing,
|
||||
// whatever is defined or attached. Unset is on.
|
||||
Enabled *bool `koanf:"enabled"`
|
||||
Default []string `koanf:"default"`
|
||||
Policies map[string]Policy `koanf:"policies"`
|
||||
}
|
||||
|
||||
// On is whether the guardrails check anything.
|
||||
func (c Config) On() bool { return c.Enabled == nil || *c.Enabled }
|
||||
|
||||
// Policy is one named set of rules.
|
||||
type Policy struct {
|
||||
// Mode: enforce acts, shadow only records what it would have done, off
|
||||
|
||||
@@ -191,7 +191,7 @@ func guardFields(u *store.Usage, in, out guard.Verdict) {
|
||||
if out.Stricter(in) {
|
||||
v = out
|
||||
}
|
||||
u.GuardAction, u.GuardPolicy, u.GuardCategory = string(v.Action), v.Policy, v.Category
|
||||
u.GuardAction, u.GuardPolicy, u.GuardCategory, u.GuardRule = string(v.Action), v.Policy, v.Category, v.Rule
|
||||
if v.Shadow && v.Action != guard.Allow {
|
||||
u.GuardAction = "shadow-" + u.GuardAction
|
||||
}
|
||||
|
||||
@@ -34,7 +34,7 @@ func (g *wordGuard) Input(_ context.Context, s guard.Subject, body []byte) (guar
|
||||
case g.err != nil:
|
||||
return guard.Verdict{}, g.err
|
||||
case bytes.Contains(body, []byte("forbidden")):
|
||||
return guard.Verdict{Action: guard.Block, Policy: "words", Category: "test"}, nil
|
||||
return guard.Verdict{Action: guard.Block, Policy: "words", Category: "test", Rule: "S6,Specialized Advice"}, nil
|
||||
case bytes.Contains(body, []byte("alice@example.com")):
|
||||
return guard.Verdict{Action: guard.Redact, Policy: "pii", Category: "email",
|
||||
Body: bytes.ReplaceAll(body, []byte("alice@example.com"), []byte("[EMAIL_1]"))}, nil
|
||||
@@ -157,6 +157,7 @@ func TestABlockedRequestIsRecordedWithItsVerdictAndCostsNothing(t *testing.T) {
|
||||
}
|
||||
if ok {
|
||||
if u.GuardAction != "blocked" || u.GuardPolicy != "words" || u.GuardCategory != "test" ||
|
||||
u.GuardRule != "S6,Specialized Advice" ||
|
||||
u.CostMicros != 0 || u.PromptTokens != 0 || u.Status != http.StatusBadRequest {
|
||||
t.Errorf("recorded %+v", u)
|
||||
}
|
||||
|
||||
@@ -1217,9 +1217,11 @@ func (r *Router) accessLog(req *parsedRequest, res dispatchResult, status int, t
|
||||
}
|
||||
// What a guard did, by name: never what it matched.
|
||||
if v := req.guardIn; res.guard.Stricter(v) {
|
||||
attrs = append(attrs, "guard", string(res.guard.Action), "guard_stage", "output", "guard_policy", res.guard.Policy)
|
||||
attrs = append(attrs, "guard", string(res.guard.Action), "guard_stage", "output", "guard_policy", res.guard.Policy,
|
||||
"guard_category", res.guard.Category, "guard_rule", res.guard.Rule)
|
||||
} else if v.Action != "" {
|
||||
attrs = append(attrs, "guard", string(v.Action), "guard_stage", "input", "guard_policy", v.Policy)
|
||||
attrs = append(attrs, "guard", string(v.Action), "guard_stage", "input", "guard_policy", v.Policy,
|
||||
"guard_category", v.Category, "guard_rule", v.Rule)
|
||||
}
|
||||
if res.outcome.Stopped {
|
||||
attrs = append(attrs, "stopped_by_guard", true)
|
||||
|
||||
@@ -50,6 +50,7 @@ type GuardEvent struct {
|
||||
Action string
|
||||
Policy string
|
||||
Category string
|
||||
Rule string
|
||||
GuardMs int64
|
||||
}
|
||||
|
||||
@@ -57,14 +58,14 @@ type GuardEvent struct {
|
||||
// flagged, redacted, blocked, or would have in shadow — since a moment.
|
||||
func (s *DB) GuardEvents(ctx context.Context, since time.Time, limit int) ([]GuardEvent, error) {
|
||||
return queryAll(ctx, s.db, `SELECT ts, request_id, key_id, COALESCE(user_id, ''), project_id, model, status,
|
||||
guard_action, guard_policy, guard_category, guard_ms
|
||||
guard_action, guard_policy, guard_category, guard_rule, guard_ms
|
||||
FROM usage WHERE ts >= ? AND guard_action <> '' ORDER BY ts DESC LIMIT ?`,
|
||||
[]any{millis(since), limit},
|
||||
func(rows *sql.Rows) (GuardEvent, error) {
|
||||
var e GuardEvent
|
||||
var ts int64
|
||||
err := rows.Scan(&ts, &e.RequestID, &e.KeyID, &e.UserID, &e.ProjectID, &e.Model, &e.Status,
|
||||
&e.Action, &e.Policy, &e.Category, &e.GuardMs)
|
||||
&e.Action, &e.Policy, &e.Category, &e.Rule, &e.GuardMs)
|
||||
e.At = fromMillis(ts)
|
||||
return e, err
|
||||
})
|
||||
|
||||
@@ -0,0 +1,2 @@
|
||||
-- Ported from the SQLite migration of the same number.
|
||||
ALTER TABLE usage ADD COLUMN guard_rule TEXT NOT NULL DEFAULT '';
|
||||
@@ -0,0 +1,5 @@
|
||||
-- What in a guard policy decided a request, beside its category: the
|
||||
-- pattern's name, or the judge's verdict — "S6,Specialized Advice" — so a
|
||||
-- request a judge refused says which hazard it saw, and a judge that
|
||||
-- refuses ordinary questions can be told from one that is right to.
|
||||
ALTER TABLE usage ADD COLUMN guard_rule TEXT NOT NULL DEFAULT '';
|
||||
@@ -435,8 +435,8 @@ func (s *DB) WriteUsage(ctx context.Context, records []Usage) error {
|
||||
`INSERT INTO usage (ts, key_id, project_id, model, served_model,
|
||||
prompt_tokens, completion_tokens, cost_micros, status, latency_ms, user_id,
|
||||
request_id, replica, queue_ms, ttft_ms,
|
||||
guard_action, guard_policy, guard_category, guard_ms, end_user, tags, cache_hit)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`)
|
||||
guard_action, guard_policy, guard_category, guard_rule, guard_ms, end_user, tags, cache_hit)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -450,7 +450,7 @@ func (s *DB) WriteUsage(ctx context.Context, records []Usage) error {
|
||||
r.Model, r.ServedModel, r.PromptTokens, r.CompletionTokens,
|
||||
r.CostMicros, r.Status, r.LatencyMs, r.UserID,
|
||||
r.RequestID, r.Replica, r.QueueMs, r.TTFTMs,
|
||||
r.GuardAction, r.GuardPolicy, r.GuardCategory, r.GuardMs, r.EndUser, tagsJSON(r.Tags), hit); err != nil {
|
||||
r.GuardAction, r.GuardPolicy, r.GuardCategory, r.GuardRule, r.GuardMs, r.EndUser, tagsJSON(r.Tags), hit); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
@@ -864,11 +864,11 @@ func (s *DB) UsageByRequest(ctx context.Context, requestID string) (Usage, bool,
|
||||
err := s.db.QueryRowContext(ctx,
|
||||
`SELECT ts, key_id, project_id, COALESCE(user_id, ''), model, served_model, prompt_tokens,
|
||||
completion_tokens, cost_micros, status, latency_ms, request_id, replica, queue_ms, ttft_ms,
|
||||
guard_action, guard_policy, guard_category, guard_ms, end_user, tags, cache_hit
|
||||
guard_action, guard_policy, guard_category, guard_rule, guard_ms, end_user, tags, cache_hit
|
||||
FROM usage WHERE request_id = ? ORDER BY ts DESC LIMIT 1`, requestID).
|
||||
Scan(&ts, &u.KeyID, &u.ProjectID, &u.UserID, &u.Model, &u.ServedModel, &u.PromptTokens,
|
||||
&u.CompletionTokens, &u.CostMicros, &u.Status, &u.LatencyMs, &u.RequestID, &u.Replica,
|
||||
&u.QueueMs, &u.TTFTMs, &u.GuardAction, &u.GuardPolicy, &u.GuardCategory, &u.GuardMs,
|
||||
&u.QueueMs, &u.TTFTMs, &u.GuardAction, &u.GuardPolicy, &u.GuardCategory, &u.GuardRule, &u.GuardMs,
|
||||
&u.EndUser, &tags, &hit)
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
return Usage{}, false, nil
|
||||
|
||||
@@ -257,11 +257,13 @@ type Usage struct {
|
||||
|
||||
// GuardAction is what a guard did — flagged, redacted, blocked — empty
|
||||
// when it let the request through untouched or there is none;
|
||||
// GuardPolicy and GuardCategory what decided it, GuardMs how long
|
||||
// GuardPolicy, GuardCategory and GuardRule what decided it — a judge's
|
||||
// rule is its verdict, "S6,Specialized Advice" — GuardMs how long
|
||||
// judging took. Never the text.
|
||||
GuardAction string
|
||||
GuardPolicy string
|
||||
GuardCategory string
|
||||
GuardRule string
|
||||
GuardMs int64
|
||||
|
||||
// EndUser is OpenAI's user field as the client sent it: its own name
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -24,8 +24,10 @@ type guardPageData struct {
|
||||
Window string
|
||||
Windows []string
|
||||
// Policies are the library, Default those applied to every request.
|
||||
Policies []string
|
||||
Default []string
|
||||
Policies []string
|
||||
Default []string
|
||||
// Off is the guardrails turned off (guard.enabled: false).
|
||||
Off bool
|
||||
Attachments []guardAttachmentRow
|
||||
Counts []guardCountRow
|
||||
Events []guardEventRow
|
||||
@@ -40,9 +42,9 @@ type guardCountRow struct {
|
||||
}
|
||||
|
||||
type guardEventRow struct {
|
||||
At, RequestID, Where, Model, Action, Policy, Category string
|
||||
Status int
|
||||
Ms int64
|
||||
At, RequestID, Where, Model, Action, Policy, Category, Rule string
|
||||
Status int
|
||||
Ms int64
|
||||
}
|
||||
|
||||
var guardWindows = map[string]time.Duration{"24h": 24 * time.Hour, "7d": 7 * 24 * time.Hour, "30d": 30 * 24 * time.Hour}
|
||||
@@ -50,12 +52,13 @@ 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()
|
||||
off := !lib.On()
|
||||
d := guardPageData{Nav: p.navFor(r, admin, "guard"), Version: p.version, Window: r.URL.Query().Get("window"),
|
||||
Windows: []string{"24h", "7d", "30d"}, Default: lib.Default}
|
||||
Windows: []string{"24h", "7d", "30d"}, Default: lib.Default, Off: off}
|
||||
since, ok := guardWindows[d.Window]
|
||||
if !ok {
|
||||
d.Window, since = "7d", guardWindows["7d"]
|
||||
@@ -104,7 +107,7 @@ func (p *AccountPages) RegisterAdminGuard(mux *http.ServeMux, s GuardAdminSource
|
||||
at := e.At.UTC()
|
||||
d.Events = append(d.Events, guardEventRow{At: l.Date(at) + " " + at.Format("15:04:05"),
|
||||
RequestID: e.RequestID, Where: name(e.ProjectID), Model: e.Model, Action: guardActionWord(l, e.Action),
|
||||
Policy: e.Policy, Category: e.Category, Status: e.Status, Ms: e.GuardMs})
|
||||
Policy: e.Policy, Category: e.Category, Rule: e.Rule, Status: e.Status, Ms: e.GuardMs})
|
||||
}
|
||||
noStore(w)
|
||||
render(w, r, "admin_guard", d)
|
||||
|
||||
@@ -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
|
||||
}))
|
||||
}
|
||||
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -32,5 +32,6 @@
|
||||
"guard.col.request": "Request",
|
||||
"guard.col.model": "Model",
|
||||
"guard.col.status": "Status",
|
||||
"guard.request_hint": "<code>llmgw ctl request ID</code> says what else a request went through."
|
||||
"guard.request_hint": "<code>llmgw ctl request ID</code> says what else a request went through.",
|
||||
"guard.off": "The guardrails are <strong>off</strong>: no request is checked, whatever the policies below say. <code>llmgw ctl guard on</code> turns them back on."
|
||||
}
|
||||
@@ -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."
|
||||
}
|
||||
@@ -32,5 +32,6 @@
|
||||
"guard.col.request": "İstek",
|
||||
"guard.col.model": "Model",
|
||||
"guard.col.status": "Durum",
|
||||
"guard.request_hint": "Bir isteğin başka nelerden geçtiğini <code>llmgw ctl request ID</code> söyler."
|
||||
"guard.request_hint": "Bir isteğin başka nelerden geçtiğini <code>llmgw ctl request ID</code> söyler.",
|
||||
"guard.off": "Guardrail'ler <strong>kapalı</strong>: aşağıdaki politikalar ne derse desin hiçbir istek kontrol edilmiyor. <code>llmgw ctl guard on</code> yeniden açar."
|
||||
}
|
||||
@@ -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."
|
||||
}
|
||||
@@ -9,6 +9,7 @@
|
||||
{{template "nav" .Nav}}
|
||||
</header>
|
||||
{{if .Error}}<p class="error" role="alert">{{.Error}}</p>{{end}}
|
||||
{{if .Off}}<p class="error" role="alert">{{th "guard.off"}}</p>{{end}}
|
||||
|
||||
<h2>{{t "guard.policies"}}</h2>
|
||||
{{if .Policies}}
|
||||
@@ -39,7 +40,7 @@
|
||||
<table class="rows">
|
||||
<tr><th>{{t "guard.col.when"}}</th><th>{{t "guard.col.request"}}</th><th>{{t "guard.col.where"}}</th><th>{{t "guard.col.model"}}</th><th>{{t "guard.col.action"}}</th><th>{{t "guard.col.policy"}}</th><th>{{t "guard.col.category"}}</th><th class="num">{{t "guard.col.status"}}</th><th class="num">ms</th></tr>
|
||||
{{range .Events}}<tr><td>{{.At}}</td><td><code>{{.RequestID}}</code></td><td>{{.Where}}</td><td>{{.Model}}</td>
|
||||
<td>{{.Action}}</td><td><code>{{.Policy}}</code></td><td>{{.Category}}</td><td class="num">{{.Status}}</td><td class="num">{{count .Ms}}</td></tr>{{end}}
|
||||
<td>{{.Action}}</td><td><code>{{.Policy}}</code></td><td>{{.Category}}{{if .Rule}} · {{.Rule}}{{end}}</td><td class="num">{{.Status}}</td><td class="num">{{count .Ms}}</td></tr>{{end}}
|
||||
</table>
|
||||
<p class="sub">{{th "guard.request_hint"}}</p>
|
||||
{{end}}
|
||||
|
||||
Reference in new issue
Block a user