Guardrails in the console
CI / test (push) Successful in 2m52s

The console never mentioned them. A Guardrails view shows each policy with
its mode, fail behaviour and checks, whether its judge models have replicas
ready, where policies are attached (attach and detach from it), and what
they did in a window. The admin API gains GET /api/v1/guard/events, and GET
/api/v1/guard says what each policy does.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
Ilker ManapandClaude Opus 5.5 committed 2026-10-05 03:36:17 +02:00
1 parent 77fb48ba32
commit c5b34978f5
9 files changed
+644 -1

No files matched your search

+14
View File
@@ -21,6 +21,20 @@ is only released once its **exit criterion** is met, not when its code lands.
Until v1.0.0 the minor version is the breaking one: configuration formats and Go
APIs change between minors without a deprecation period.
## Unreleased
### Added
- **Guardrails in the console**: a view of the policies and what each checks,
whether their judge models are serving, where they are attached (attach
and detach from it), and what they did in a window. `GET
/api/v1/guard/events` answers the same, and `GET /api/v1/guard` now says
what each policy does.
### Fixed
- The `CONTROL` line named no floating address.
## v0.11.3-rc.1 — 2026-10-05
Two control planes, run in anger on the reference cluster.
+153
View File
@@ -854,6 +854,43 @@
}
}
},
"/api/v1/guard/events": {
"get": {
"operationId": "getGuardEvents",
"summary": "What the guardrails did: totals and the newest requests",
"tags": [
"guard"
],
"parameters": [
{
"name": "since",
"in": "query",
"description": "How far back: 90m, 24h, 7d; 24h when absent.",
"schema": {
"type": "string"
}
}
],
"responses": {
"200": {
"description": "OK",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/GuardEventsView"
}
}
}
},
"401": {
"$ref": "#/components/responses/Unauthorized"
},
"default": {
"$ref": "#/components/responses/Error"
}
}
}
},
"/api/v1/jobs": {
"get": {
"operationId": "getJobs",
@@ -3849,6 +3886,115 @@
}
}
},
"GuardCountView": {
"type": "object",
"description": "GuardCountView is how many requests one policy did one thing to.",
"properties": {
"project": {
"type": "string"
},
"action": {
"type": "string"
},
"policy": {
"type": "string"
},
"category": {
"type": "string"
},
"count": {
"type": "integer",
"format": "int64"
}
}
},
"GuardEventView": {
"type": "object",
"description": "GuardEventView is one request the guardrails did something to.",
"properties": {
"at": {
"type": "string",
"format": "date-time"
},
"request_id": {
"type": "string"
},
"project": {
"type": "string"
},
"model": {
"type": "string"
},
"status": {
"type": "integer"
},
"action": {
"type": "string"
},
"policy": {
"type": "string"
},
"category": {
"type": "string"
},
"guard_ms": {
"type": "integer",
"format": "int64"
}
}
},
"GuardEventsView": {
"type": "object",
"description": "GuardEventsView is what the guardrails did since a moment: totals, and the newest requests.",
"properties": {
"since": {
"type": "string",
"format": "date-time"
},
"counts": {
"type": "array",
"items": {
"$ref": "#/components/schemas/GuardCountView"
}
},
"events": {
"type": "array",
"items": {
"$ref": "#/components/schemas/GuardEventView"
}
}
}
},
"GuardPolicyView": {
"type": "object",
"description": "GuardPolicyView is what one policy does, in brief.",
"properties": {
"name": {
"type": "string"
},
"mode": {
"type": "string",
"description": "Mode is enforce, shadow or off; Fail is open or closed."
},
"fail": {
"type": "string"
},
"no_store": {
"type": "boolean"
},
"judge": {
"type": "string",
"description": "Judge is the judge model, when one reads the text."
},
"checks": {
"type": "array",
"items": {
"type": "string"
},
"description": "Checks are what it looks for, one line each: \"input: pii email → redact\"."
}
}
},
"GuardView": {
"type": "object",
"description": "GuardView is the library and where its policies are attached.",
@@ -3870,6 +4016,13 @@
"items": {
"$ref": "#/components/schemas/GuardAttachmentView"
}
},
"details": {
"type": "array",
"items": {
"$ref": "#/components/schemas/GuardPolicyView"
},
"description": "Details says what each policy does, in the order of Policies."
}
}
},
+11
View File
@@ -209,3 +209,14 @@ Each request's usage row has the action, policy, category and the time the
checks took. The access log has the action, stage and policy. Neither has
the text, a matched value, or the placeholder map: the map exists only in
the gateway's memory while the request is served.
## In the console
`sudo llmgw` has a **Guardrails** view: each policy with its mode, what it
does when it cannot judge, and what it checks; whether each judge model it
leans on has replicas ready — a policy that fails closed refuses everything
while its judge has none; where policies are attached, with A to attach one
and Enter on a row to detach it; and what they did in the last day (W for an
hour or a week), totals first and the newest requests after, each with the
request id `llmgw ctl request` reads. The same is at `GET /api/v1/guard` and
`GET /api/v1/guard/events?since=24h`, and on the portal's Guardrails page.
+24 -1
View File
@@ -43,6 +43,7 @@ const (
cReplicas
cUsage
cQuotas
cGuard
cFederation
cMirror
cJobs
@@ -64,7 +65,7 @@ const (
cCount
)
var consoleLabels = [cCount]string{"Overview", "Monitor", "Nodes", "GPUs", "Models", "Replicas", "Usage", "Quotas", "Federation", "Mirror", "Jobs", "Logs", "Accounts", "API keys", "Organizations", "Audit", "Manual",
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",
"Restart all replicas", "Back up now", "Leave"}
@@ -169,6 +170,7 @@ type consoleModel struct {
quotas []adminapi.QuotaView
quotasErr error
guard guardState
st *storeHolder
people peopleData
@@ -292,6 +294,9 @@ func (m consoleModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
if m.menu == cQuotas {
cmds = append(cmds, m.fetchQuotas())
}
if m.menu == cGuard {
cmds = append(cmds, m.fetchGuard())
}
if m.menu == cMirror {
cmds = append(cmds, m.fetchMirror())
}
@@ -350,6 +355,9 @@ func (m consoleModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
m.people = peopleData(msg)
case consoleQuotasMsg:
m.quotas, m.quotasErr = msg.rows, msg.err
case consoleGuardMsg:
window := m.guard.window
m.guard = guardState{view: msg.view, events: msg.events, err: msg.err, window: window}
case evalSuitesMsg:
return m.onEvalSuites(msg)
case evalPollMsg:
@@ -501,6 +509,8 @@ func (m consoleModel) key(k tea.KeyMsg) (tea.Model, tea.Cmd) {
m.menuD = m.replicaMenu(m.data.replicas[m.row])
case cQuotas:
m.menuD = m.quotaMenu(m.quotas[m.row])
case cGuard:
m.menuD = m.guardAttachmentMenu(m.guard.view.Attachments[m.row])
case cMirror:
m.menuD = m.mirrorMenu(m.mirrorGroups()[m.row])
case cJobs:
@@ -556,6 +566,8 @@ func (m consoleModel) key(k tea.KeyMsg) (tea.Model, tea.Cmd) {
switch m.menu {
case cQuotas:
m.quotaForm(adminapi.QuotaView{})
case cGuard:
m.guardAttachForm()
case cModels:
m.addModelForm()
case cMirror:
@@ -567,6 +579,11 @@ func (m consoleModel) key(k tea.KeyMsg) (tea.Model, tea.Cmd) {
case cOrgs:
m.createOrgForm()
}
case "w":
if m.menu == cGuard {
m.guard.window = (m.guard.window + 1) % len(guardWindows)
return m, m.fetchGuard()
}
case "i":
if m.menu == cMirror {
m.mirrorImportOllamaForm()
@@ -604,6 +621,8 @@ func (m consoleModel) onEnter() tea.Cmd {
return m.fetchUsage()
case cQuotas:
return m.fetchQuotas()
case cGuard:
return m.fetchGuard()
case cMirror:
return m.fetchMirror()
case cMonitor:
@@ -939,6 +958,8 @@ func (m consoleModel) rowCount() int {
return len(m.usage)
case cQuotas:
return len(m.quotas)
case cGuard:
return len(m.guard.view.Attachments)
case cMirror:
return len(m.mirrorGroups())
case cJobs:
@@ -1166,6 +1187,8 @@ func (m consoleModel) panel() (title, body, keys string) {
return "Usage", head + table([]string{"WHO", "REQUESTS", "TOKENS", "ERRORS", "SPENT"}, rows, m.row, pw, m.inPanel), keys
case cQuotas:
return m.quotasPanel(pw)
case cGuard:
return m.guardPanel(pw)
case cAccounts, cKeys, cOrgs:
return m.peoplePanel(pw)
case cManual:
+189
View File
@@ -0,0 +1,189 @@
package cli
import (
"context"
"fmt"
"net/http"
"strings"
"time"
tea "github.com/charmbracelet/bubbletea"
"gitea.mynodes.xyz/ilker/llmgw/internal/control/adminapi"
)
// The guardrails, as the console shows them: the policies server.yaml
// defines and what each checks, where they are attached, whether the judge
// models they lean on are serving, and what they did in a window.
// guardWindows are the windows W steps through.
var guardWindows = []string{"24h", "7d", "1h"}
type guardState struct {
view adminapi.GuardView
events adminapi.GuardEventsView
err error
// window is an index into guardWindows.
window int
}
type consoleGuardMsg struct {
view adminapi.GuardView
events adminapi.GuardEventsView
err error
}
func (m consoleModel) fetchGuard() tea.Cmd {
c, since := m.client, guardWindows[m.guard.window]
return func() tea.Msg {
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
var msg consoleGuardMsg
if msg.err = c.get(ctx, "/api/v1/guard", &msg.view); msg.err != nil {
return msg
}
// What they did is a second question; a store without the record
// still shows the policies.
_ = c.get(ctx, "/api/v1/guard/events?since="+since, &msg.events)
return msg
}
}
func (m consoleModel) guardPanel(pw int) (string, string, string) {
keys := "→ or Enter open · ↑↓ choose · Q leave"
if m.inPanel {
keys = "↑↓ attachment · Enter actions · A attach · W window · ← back · Q leave"
}
g := m.guard
var b strings.Builder
b.WriteString("\n " + sDim.Render("What requests and answers are checked for, where, and what that did. A to attach a policy.") + "\n\n")
if g.err != nil {
if strings.Contains(g.err.Error(), "501") || strings.Contains(g.err.Error(), "no guard policies") {
b.WriteString(" " + sDim.Render("No policies: define them under guard: in server.yaml (Settings), then restart."))
} else {
b.WriteString(" " + sBad.Render(g.err.Error()))
}
return "Guardrails", b.String(), keys
}
if len(g.view.Details) == 0 && len(g.view.Policies) == 0 {
b.WriteString(" " + sDim.Render("asking the control plane…"))
return "Guardrails", b.String(), keys
}
def := "none"
if len(g.view.Default) > 0 {
def = strings.Join(g.view.Default, ", ")
}
b.WriteString(" " + sTitle.Render("POLICIES") + " " + sDim.Render("on every request: "+def) + "\n")
for _, p := range g.view.Details {
line := fmt.Sprintf(" %-14s %s · fail %s", p.Name, p.Mode, p.Fail)
if p.NoStore {
line += " · keeps nothing"
}
if p.Judge != "" {
line += " · judge " + p.Judge + " " + m.judgeState(p.Judge)
}
b.WriteString(line + "\n")
for _, c := range p.Checks {
b.WriteString(" " + strings.Repeat(" ", 15) + sDim.Render(c) + "\n")
}
}
b.WriteString("\n " + sTitle.Render("ATTACHED") + " " + sDim.Render("besides the default, to part of the tenancy tree, a person or a key") + "\n")
if len(g.view.Attachments) == 0 {
b.WriteString(" " + sDim.Render("Nowhere. A to attach one.") + "\n")
} else {
var rows [][]string
for _, a := range g.view.Attachments {
who := a.Name
if who == "" {
who = a.ScopeID
}
rows = append(rows, []string{a.Scope, who, a.Policy})
}
b.WriteString(table([]string{"SCOPE", "WHO", "POLICY"}, rows, m.row, pw, m.inPanel) + "\n")
}
window := guardWindows[g.window]
b.WriteString("\n " + sTitle.Render("IN THE LAST "+strings.ToUpper(window)) + " " + sDim.Render("W for another window") + "\n")
if len(g.events.Counts) == 0 {
b.WriteString(" " + sDim.Render("Nothing flagged, redacted or blocked.") + "\n")
} else {
var rows [][]string
for i, c := range g.events.Counts {
if i == 10 {
break
}
rows = append(rows, []string{c.Action, c.Policy, dash(c.Category), dash(c.Project), fmt.Sprint(c.Count)})
}
b.WriteString(table([]string{"ACTION", "POLICY", "CATEGORY", "PROJECT", "REQUESTS"}, rows, -1, pw, false) + "\n")
}
if len(g.events.Events) > 0 {
b.WriteString("\n " + sTitle.Render("NEWEST") + "\n")
var rows [][]string
for i, e := range g.events.Events {
if i == 10 {
break
}
rows = append(rows, []string{e.At.Local().Format("01-02 15:04:05"), e.Action, e.Policy, dash(e.Category),
e.Model, fmt.Sprint(e.Status), fmt.Sprintf("%dms", e.GuardMs), e.RequestID})
}
b.WriteString(table([]string{"AT", "ACTION", "POLICY", "CATEGORY", "MODEL", "STATUS", "GUARD", "REQUEST"}, rows, -1, pw, false))
}
return "Guardrails", b.String(), keys
}
// judgeState is how many of a judge model's replicas are ready: a policy
// that fails closed refuses everything while it has none.
func (m consoleModel) judgeState(model string) string {
for _, md := range m.data.models {
if md.Name == model {
s := fmt.Sprintf("(%d/%d ready)", md.Ready, md.Desired)
if md.Ready == 0 {
return sBad.Render(s)
}
return sOK.Render(s)
}
}
return sBad.Render("(not in the catalog)")
}
// guardAttachForm attaches a policy.
func (m *consoleModel) guardAttachForm() {
if len(m.guard.view.Policies) == 0 {
return
}
m.openForm(&formDialog{title: "Attach a guardrail policy",
note: "Requests from there are checked by it as well as by the default.",
fields: []formField{
choiceF("to", []string{"project", "team", "org", "user", "key"}, "project"),
textF("which", "", "ORG/TEAM/PROJECT, ORG/TEAM, ORG, or the ID"),
choiceF("policy", m.guard.view.Policies, m.guard.view.Policies[0]),
},
submit: func(m *consoleModel, v []string) (tea.Cmd, error) {
if strings.TrimSpace(v[1]) == "" {
return nil, fmt.Errorf("which %s?", v[0])
}
req := adminapi.GuardAttachmentRequest{Target: v[0] + ":" + strings.TrimSpace(v[1]), Policy: v[2]}
return m.call("Attach "+v[2], http.MethodPost, "/api/v1/guard/attachments", req, nil), nil
}})
}
// guardAttachmentMenu is what can be done to one attachment.
func (m consoleModel) guardAttachmentMenu(a adminapi.GuardAttachmentView) *menuDialog {
who := a.Name
if who == "" {
who = a.ScopeID
}
return &menuDialog{title: a.Policy + " on " + a.Scope + " " + who, items: []menuItem{
{"Detach…", func(m *consoleModel) tea.Cmd {
m.confirmD = &confirmDialog{title: "Detach " + a.Policy,
text: "Requests from " + who + " are then checked by the default policies alone.",
yes: func(m *consoleModel) tea.Cmd {
req := adminapi.GuardAttachmentRequest{Target: a.Scope + ":" + a.ScopeID, Policy: a.Policy}
return m.call("Detach "+a.Policy, http.MethodDelete, "/api/v1/guard/attachments", req, nil)
}}
return nil
}},
}}
}
+26
View File
@@ -218,3 +218,29 @@ func TestTheConsoleOnAStandbySaysWhoseClusterItShows(t *testing.T) {
t.Error("the leader's console says it is standing by")
}
}
func TestTheConsoleShowsTheGuardrails(t *testing.T) {
m := consoleModel{menu: cGuard, loaded: true, width: 140, height: 60}
m.data.models = []adminapi.ModelView{{Name: "llama-guard-3-1b", Ready: 2, Desired: 2}}
m.guard.view = adminapi.GuardView{
Policies: []string{"baseline"}, Default: []string{"baseline"},
Details: []adminapi.GuardPolicyView{{Name: "baseline", Mode: "enforce", Fail: "closed", Judge: "llama-guard-3-1b",
Checks: []string{"input: pii email → redact"}}},
Attachments: []adminapi.GuardAttachmentView{{Name: "acme/ml"}},
}
m.guard.view.Attachments[0].Scope, m.guard.view.Attachments[0].Policy = "team", "baseline"
m.guard.events = adminapi.GuardEventsView{Counts: []adminapi.GuardCountView{{Action: "block", Policy: "baseline", Category: "S1", Count: 7}}}
v := m.View()
for _, want := range []string{"Guardrails", "baseline", "fail closed", "judge llama-guard-3-1b (2/2 ready)",
"input: pii email → redact", "acme/ml", "IN THE LAST 24H", "S1"} {
if !strings.Contains(v, want) {
t.Errorf("%q missing:\n%s", want, v)
}
}
// W moves the window, and asks again.
m.inPanel = true
mm, cmd := m.Update(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune("w")})
if c := mm.(consoleModel); guardWindows[c.guard.window] != "7d" || cmd == nil {
t.Errorf("window %q", guardWindows[c.guard.window])
}
}
+171
View File
@@ -4,11 +4,14 @@ import (
"context"
"encoding/json"
"fmt"
"maps"
"net/http"
"slices"
"strings"
"time"
"gitea.mynodes.xyz/ilker/llmgw/internal/guard"
"gitea.mynodes.xyz/ilker/llmgw/internal/store"
)
// GuardStore is where policy attachments are kept.
@@ -34,6 +37,114 @@ type GuardView struct {
Policies []string `json:"policies"`
Default []string `json:"default"`
Attachments []GuardAttachmentView `json:"attachments"`
// Details says what each policy does, in the order of Policies.
Details []GuardPolicyView `json:"details"`
}
// GuardPolicyView is what one policy does, in brief.
type GuardPolicyView struct {
Name string `json:"name"`
// Mode is enforce, shadow or off; Fail is open or closed.
Mode string `json:"mode"`
Fail string `json:"fail"`
NoStore bool `json:"no_store,omitempty"`
// Judge is the judge model, when one reads the text.
Judge string `json:"judge,omitempty"`
// Checks are what it looks for, one line each: "input: pii email → redact".
Checks []string `json:"checks"`
}
// GuardHistory is what the guardrails did, from the usage record; the store
// has it, and a store that does not leaves the events endpoint unanswered.
type GuardHistory interface {
GuardEvents(ctx context.Context, since time.Time, limit int) ([]store.GuardEvent, error)
GuardCounts(ctx context.Context, since time.Time) ([]store.GuardCount, error)
}
// GuardEventsView is what the guardrails did since a moment: totals, and the
// newest requests.
type GuardEventsView struct {
Since time.Time `json:"since"`
Counts []GuardCountView `json:"counts"`
Events []GuardEventView `json:"events"`
}
// GuardCountView is how many requests one policy did one thing to.
type GuardCountView struct {
Project string `json:"project,omitempty"`
Action string `json:"action"`
Policy string `json:"policy"`
Category string `json:"category,omitempty"`
Count int64 `json:"count"`
}
// GuardEventView is one request the guardrails did something to.
type GuardEventView struct {
At time.Time `json:"at"`
RequestID string `json:"request_id"`
Project string `json:"project,omitempty"`
Model string `json:"model"`
Status int `json:"status"`
Action string `json:"action"`
Policy string `json:"policy"`
Category string `json:"category,omitempty"`
GuardMs int64 `json:"guard_ms"`
}
// policyView sums one policy up.
func policyView(name string, p guard.Policy) GuardPolicyView {
v := GuardPolicyView{Name: name, Mode: or(p.Mode, "enforce"), Fail: or(p.Fail, "open"), NoStore: p.NoStore, Checks: []string{}}
rules := func(dir string, r guard.Rules) {
for _, kind := range slices.Sorted(maps.Keys(r.PII)) {
v.Checks = append(v.Checks, fmt.Sprintf("%s: pii %s → %s", dir, kind, r.PII[kind]))
}
if r.Injection != "" {
v.Checks = append(v.Checks, fmt.Sprintf("%s: prompt injection → %s", dir, r.Injection))
}
for _, d := range r.Deny {
v.Checks = append(v.Checks, fmt.Sprintf("%s: pattern %s → %s", dir, d.Name, or(d.Action, "block")))
}
if r.MaxChars > 0 {
v.Checks = append(v.Checks, fmt.Sprintf("%s: at most %d characters", dir, r.MaxChars))
}
}
rules("input", p.Input)
rules("output", p.Output)
if j := p.Judge; j != nil {
v.Judge = j.Model
var dirs []string
if j.Input != "" {
dirs = append(dirs, "input → "+j.Input)
}
if j.Output != "" {
dirs = append(dirs, "output → "+j.Output)
}
line := "judge " + j.Model + ": " + strings.Join(dirs, ", ")
if len(j.Categories) > 0 {
line += " (" + strings.Join(j.Categories, " ") + ")"
}
v.Checks = append(v.Checks, line)
}
if len(p.Tools.Allow) > 0 {
v.Checks = append(v.Checks, "tools: only "+strings.Join(p.Tools.Allow, ", "))
}
if len(p.Tools.Deny) > 0 {
v.Checks = append(v.Checks, "tools: never "+strings.Join(p.Tools.Deny, ", "))
}
if p.MaxOutputTokens > 0 {
v.Checks = append(v.Checks, fmt.Sprintf("answers of at most %d tokens", p.MaxOutputTokens))
}
if p.SystemPrompt != "" {
v.Checks = append(v.Checks, "a system prompt of its own")
}
return v
}
func or(s, dflt string) string {
if s == "" {
return dflt
}
return s
}
// GuardAttachmentView is one attachment, with the name people know its
@@ -43,6 +154,15 @@ type GuardAttachmentView struct {
Name string `json:"name,omitempty"`
}
// guardHistory is where what the guardrails did is read, when there is one.
func (a *API) guardHistory() (GuardHistory, bool) {
if a.guard == nil {
return nil, false
}
h, ok := a.guard.Store.(GuardHistory)
return h, ok
}
// guardTarget reads where a policy is to be attached.
func (a *API) guardTarget(ctx context.Context, target string) (guard.Attachment, error) {
kind, what, ok := strings.Cut(strings.TrimSpace(target), ":")
@@ -99,6 +219,57 @@ func (a *API) registerGuard(mux Mux) {
v.Policies = append(v.Policies, name)
}
slices.Sort(v.Policies)
for _, name := range v.Policies {
v.Details = append(v.Details, policyView(name, a.guard.Library.Policies[name]))
}
writeJSON(w, http.StatusOK, v)
})
mux.HandleFunc("GET /api/v1/guard/events", func(w http.ResponseWriter, r *http.Request) {
h, ok := a.guardHistory()
if !ok {
writeError(w, http.StatusNotImplemented, "no guard policies, or no store to record what they did")
return
}
since := 24 * time.Hour
if q := r.URL.Query().Get("since"); q != "" {
d, err := parseSince(q)
if err != nil {
writeError(w, http.StatusBadRequest, err.Error())
return
}
since = d
}
from := time.Now().Add(-since)
counts, err := h.GuardCounts(r.Context(), from)
if err != nil {
writeError(w, http.StatusInternalServerError, err.Error())
return
}
events, err := h.GuardEvents(r.Context(), from, 100)
if err != nil {
writeError(w, http.StatusInternalServerError, err.Error())
return
}
var names map[string]string
if a.quotas != nil {
names, _ = a.scopeNames(r.Context())
}
project := func(id string) string {
if n := names["project:"+id]; n != "" {
return n
}
return id
}
v := GuardEventsView{Since: from, Counts: []GuardCountView{}, Events: []GuardEventView{}}
for _, c := range counts {
v.Counts = append(v.Counts, GuardCountView{Project: project(c.ProjectID), Action: c.Action,
Policy: c.Policy, Category: c.Category, Count: c.Count})
}
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})
}
writeJSON(w, http.StatusOK, v)
})
change := func(attach bool) http.HandlerFunc {
+51
View File
@@ -116,3 +116,54 @@ func TestUsageIsExportedAsCSVThatASpreadsheetCannotRun(t *testing.T) {
t.Errorf("%d %s %+v", rec.Code, body, m)
}
}
// memGuardHistory is a guard store that also remembers what was done.
type memGuardHistory struct {
memGuard
since time.Time
}
func (m *memGuardHistory) GuardEvents(_ context.Context, since time.Time, _ int) ([]store.GuardEvent, error) {
m.since = since
return []store.GuardEvent{{RequestID: "r1", Model: "m", Status: 400, Action: "block", Policy: "base", Category: "S1", GuardMs: 410}}, nil
}
func (m *memGuardHistory) GuardCounts(context.Context, time.Time) ([]store.GuardCount, error) {
return []store.GuardCount{
{Action: "flag", Policy: "base", Category: "email", Count: 2},
{Action: "block", Policy: "base", Category: "S1", Count: 7},
}, nil
}
func TestTheGuardrailsSayWhatEachPolicyDoesAndWhatTheyDid(t *testing.T) {
a, _ := setup(t, testModels())
h := &memGuardHistory{}
a.SetGuard(&GuardAdmin{Store: h, Library: guard.Config{Policies: map[string]guard.Policy{
"base": {Fail: "closed", Input: guard.Rules{PII: map[string]string{"email": "redact"}, Injection: "block"},
Judge: &guard.JudgePolicy{Model: "llama-guard-3-1b", Input: "block", Categories: []string{"S1", "S9"}}},
}}})
var v GuardView
if code := call(t, a, "GET", "/api/v1/guard", "", &v); code != 200 || len(v.Details) != 1 {
t.Fatalf("%d %+v", code, v)
}
d := v.Details[0]
want := []string{"input: pii email → redact", "input: prompt injection → block", "judge llama-guard-3-1b: input → block (S1 S9)"}
if d.Mode != "enforce" || d.Fail != "closed" || d.Judge != "llama-guard-3-1b" || !slices.Equal(d.Checks, want) {
t.Errorf("%+v", d)
}
var ev GuardEventsView
if code := call(t, a, "GET", "/api/v1/guard/events?since=1h", "", &ev); code != 200 {
t.Fatalf("events: %d", code)
}
if time.Since(h.since) > time.Hour+time.Minute || time.Since(h.since) < time.Hour-time.Minute {
t.Errorf("asked since %s", h.since)
}
if len(ev.Counts) != 2 || ev.Counts[0].Count != 7 || len(ev.Events) != 1 || ev.Events[0].GuardMs != 410 {
t.Errorf("most first, and the events: %+v", ev)
}
if code := call(t, a, "GET", "/api/v1/guard/events?since=soon", "", nil); code != 400 {
t.Errorf("a bad window: %d", code)
}
}
+5
View File
@@ -249,6 +249,11 @@ var Routes = []Route{
Response: Removed{}},
{Method: "GET", Path: "/api/v1/guard", Summary: "The guardrail policies and where they are attached",
Response: GuardView{}},
{Method: "GET", Path: "/api/v1/guard/events", Summary: "What the guardrails did: totals and the newest requests",
Query: []Param{
{Name: "since", Description: "How far back: 90m, 24h, 7d; 24h when absent."},
},
Response: GuardEventsView{}},
{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",