package adminapi import ( "fmt" "net/http" "slices" "sort" "time" "gitea.mynodes.xyz/ilker/llmgw/internal/control/reconciler" "gitea.mynodes.xyz/ilker/llmgw/internal/guard" ) // JudgeView is one judge model the guardrail policies ask, and whether it // can answer them. type JudgeView struct { Model string `json:"model"` // Policies are those that ask it; Closed those that refuse what it // cannot read (fail: closed). Policies []string `json:"policies"` Closed []string `json:"closed,omitempty"` // InCatalog is whether a model of that name is in the catalog, and // Judge whether it is there as a judge (role: guard). InCatalog bool `json:"in_catalog"` Judge bool `json:"judge"` Ready int `json:"ready_replicas"` Wanted int `json:"wanted_replicas"` // LastError is its last failure to give a verdict, if any. LastError string `json:"last_error,omitempty"` LastErrorAt *time.Time `json:"last_error_at,omitempty"` // Problem says, in words, what is wrong and what to do; empty when // nothing is. Problem string `json:"problem,omitempty"` } // judgeErrorFresh is how recent a judge's failure must be to count as a // problem now. const judgeErrorFresh = 5 * time.Minute // Judges are the judge models the policies in force ask, each with what is // wrong with it, if anything. func (a *API) Judges() []JudgeView { if a.guard == nil || a.guard.Library == nil { return nil } lib := a.guard.Library() byModel := map[string]*JudgeView{} for name, p := range lib.Policies { if p.Judge == nil || p.Judge.Model == "" { continue } v := byModel[p.Judge.Model] if v == nil { v = &JudgeView{Model: p.Judge.Model} byModel[p.Judge.Model] = v } v.Policies = append(v.Policies, name) if p.Fail == "closed" && p.Mode != "shadow" { v.Closed = append(v.Closed, name) } } ready := map[string]int{} for _, rep := range a.ctl.Reconciler().Replicas() { if rep.State == reconciler.Ready { ready[rep.Model]++ } } var errs map[string]guard.JudgeError if a.guard.JudgeErrors != nil { errs = a.guard.JudgeErrors() } out := make([]JudgeView, 0, len(byModel)) for _, v := range byModel { sort.Strings(v.Policies) sort.Strings(v.Closed) if m, ok := a.ctl.Catalog().Resolve(v.Model); ok && m.Name == v.Model { v.InCatalog, v.Judge = true, m.IsGuard() v.Wanted, v.Ready = m.Scaling.Min, ready[m.Name] } if e, ok := errs[v.Model]; ok { at := e.At v.LastError, v.LastErrorAt = e.Err, &at } v.Problem = judgeProblem(*v) out = append(out, *v) } slices.SortFunc(out, func(x, y JudgeView) int { switch { case x.Model < y.Model: return -1 case x.Model > y.Model: return 1 } return 0 }) return out } func judgeProblem(v JudgeView) string { consequence := "requests its policies judge are served unjudged" if len(v.Closed) > 0 { consequence = "requests its policies judge are refused (guard_unavailable)" } switch { case !v.InCatalog: return fmt.Sprintf("not in the catalog, so %s: llmgw ctl guard judge enable", consequence) case !v.Judge: return fmt.Sprintf("in the catalog as a model clients use, not as a judge (role: guard), so %s: "+ "llmgw ctl guard judge enable --replace", consequence) case v.Ready == 0: return fmt.Sprintf("no replica ready, so %s: llmgw ctl replicas --model %s", consequence, v.Model) case v.LastErrorAt != nil && time.Since(*v.LastErrorAt) < judgeErrorFresh: return fmt.Sprintf("gave no verdict %s ago (%s)", time.Since(*v.LastErrorAt).Round(time.Second), v.LastError) } return "" } func (a *API) listJudges(w http.ResponseWriter, _ *http.Request) { if a.guard == nil { writeError(w, http.StatusNotImplemented, "no guard policies: define them under guard in the cluster's settings (llmgw ctl config edit)") return } writeJSON(w, http.StatusOK, a.Judges()) }