diff --git a/CHANGELOG.md b/CHANGELOG.md index 0d7f555..a08a344 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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. diff --git a/docs/admin-api.openapi.json b/docs/admin-api.openapi.json index 9cf25a0..6693c38 100644 --- a/docs/admin-api.openapi.json +++ b/docs/admin-api.openapi.json @@ -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." } } }, diff --git a/docs/guardrails.md b/docs/guardrails.md index 97f471c..20ce134 100644 --- a/docs/guardrails.md +++ b/docs/guardrails.md @@ -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. diff --git a/internal/cli/tui_console.go b/internal/cli/tui_console.go index 3f15914..5b7d7a8 100644 --- a/internal/cli/tui_console.go +++ b/internal/cli/tui_console.go @@ -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: diff --git a/internal/cli/tui_guard.go b/internal/cli/tui_guard.go new file mode 100644 index 0000000..76467b6 --- /dev/null +++ b/internal/cli/tui_guard.go @@ -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 + }}, + }} +} diff --git a/internal/cli/tui_setup_test.go b/internal/cli/tui_setup_test.go index fc0b4e5..f99945d 100644 --- a/internal/cli/tui_setup_test.go +++ b/internal/cli/tui_setup_test.go @@ -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]) + } +} diff --git a/internal/control/adminapi/guard.go b/internal/control/adminapi/guard.go index 78a744f..b44b574 100644 --- a/internal/control/adminapi/guard.go +++ b/internal/control/adminapi/guard.go @@ -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 { diff --git a/internal/control/adminapi/guard_test.go b/internal/control/adminapi/guard_test.go index 74f8b44..5ee80a6 100644 --- a/internal/control/adminapi/guard_test.go +++ b/internal/control/adminapi/guard_test.go @@ -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) + } +} diff --git a/internal/control/adminapi/routes.go b/internal/control/adminapi/routes.go index cdb9152..af44fae 100644 --- a/internal/control/adminapi/routes.go +++ b/internal/control/adminapi/routes.go @@ -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",