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"` // Then are the judges its policies ask after it, when it gives no // verdict: a CPU judge behind a GPU one. Then []string `json:"then,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 { judges := p.Judge.Judges() for i, model := range judges { v := byModel[model] if v == nil { v = &JudgeView{Model: model} byModel[model] = v } if !slices.Contains(v.Policies, name) { v.Policies = append(v.Policies, name) } if p.Fail == "closed" && p.Mode != "shadow" && !slices.Contains(v.Closed, name) { v.Closed = append(v.Closed, name) } for _, after := range judges[i+1:] { if !slices.Contains(v.Then, after) { v.Then = append(v.Then, after) } } } } 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" switch { case len(v.Then) > 0: consequence = "its policies ask " + strings.Join(v.Then, ", else ") + " instead" case 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, unsafe or controversial, as the gateway reads the // answer; a policy's judge.controversial says what controversial counts as. 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 } switch { case ans.Unsafe: v.Verdict = "unsafe" case ans.Controversial: // Qwen3Guard's middle verdict: what it counts as is the policy's. v.Verdict = "controversial" default: v.Verdict = "safe" } writeJSON(w, http.StatusOK, v) }