Files
Ilker ManapandClaude Opus 5.5 d41104a452 Merge C21 quality suites and gating
Conflicts: docs.go (manual, OpenAPI and suites all kept), modelserve Job
(trace and eval both kept). The eval routes are added to the OpenAPI route
table, update and promote documented with their optional body, and the
document regenerated.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-05 00:08:33 +02:00

187 lines
5.9 KiB
Go

package adminapi
import (
"context"
"encoding/json"
"errors"
"io"
"net/http"
"strconv"
"strings"
"gitea.mynodes.xyz/ilker/llmgw/internal/bench/eval"
"gitea.mynodes.xyz/ilker/llmgw/internal/control/catalog"
"gitea.mynodes.xyz/ilker/llmgw/internal/control/modelserve"
"gitea.mynodes.xyz/ilker/llmgw/internal/store"
)
// Quality suites: run against a model on the control plane, through the
// router as the gateway's own requests (like a replay), followed while they
// run, and kept; and a model's standing suite that its updates must pass.
// SetEval gives the API the service that runs quality suites.
func (a *API) SetEval(s *eval.Service) { a.evals = s }
// maxSuiteBytes bounds a suite sent whole: a few hundred cases with their
// prompts, which is more than a gate should take to run.
const maxSuiteBytes = 4 << 20
// EvalGateRequest sets a model's standing quality suite; an empty suite
// takes it away.
type EvalGateRequest struct {
Suite string `json:"suite"`
MinScore float64 `json:"min_score,omitempty"`
Judge string `json:"judge,omitempty"`
}
type evalGater interface {
SetEval(ctx context.Context, name string, g *catalog.EvalGate, by string) (catalog.Model, error)
}
// readChange reads how an update or a promotion is held to quality; an
// empty body is no change to how.
func readChange(w http.ResponseWriter, r *http.Request) (modelserve.Change, bool) {
var ch modelserve.Change
b, err := io.ReadAll(io.LimitReader(r.Body, maxSuiteBytes+1))
if err != nil {
writeError(w, http.StatusBadRequest, err.Error())
return ch, false
}
if len(b) > maxSuiteBytes {
writeError(w, http.StatusRequestEntityTooLarge, "the suite is over 4 MiB")
return ch, false
}
if len(strings.TrimSpace(string(b))) == 0 {
return ch, true
}
if err := json.Unmarshal(b, &ch); err != nil {
writeError(w, http.StatusBadRequest, "the request is not valid JSON: "+err.Error())
return ch, false
}
return ch, true
}
func (a *API) registerEval(mux Mux) {
need := func(w http.ResponseWriter) bool {
if a.evals == nil {
writeError(w, http.StatusNotImplemented, "this control plane does not route requests, so it cannot ask a model a suite")
return false
}
return true
}
mux.HandleFunc("GET /api/v1/eval/suites", func(w http.ResponseWriter, r *http.Request) {
if !need(w) {
return
}
out := a.evals.Suites()
if out == nil {
out = []eval.SuiteInfo{}
}
writeJSON(w, http.StatusOK, out)
})
// Started and answered at once: a suite against a model on CPUs takes
// minutes, longer than a client waits on one request.
mux.HandleFunc("POST /api/v1/models/{name}/eval", func(w http.ResponseWriter, r *http.Request) {
if !need(w) {
return
}
var req eval.Request
if err := json.NewDecoder(io.LimitReader(r.Body, maxSuiteBytes)).Decode(&req); err != nil {
writeError(w, http.StatusBadRequest, "the request is not valid JSON: "+err.Error())
return
}
name := r.PathValue("name")
if a.ctl != nil {
// By its own name, so an alias's runs are filed with the rest.
m, ok := a.ctl.Catalog().Resolve(name)
if !ok {
writeError(w, http.StatusNotFound, "no model called "+name+": `llmgw ctl models` lists them")
return
}
name = m.Name
}
live, err := a.evals.Start(name, req, actorOf(r))
if err != nil {
writeError(w, http.StatusBadRequest, err.Error())
return
}
writeJSON(w, http.StatusAccepted, live)
})
mux.HandleFunc("GET /api/v1/eval/runs", func(w http.ResponseWriter, r *http.Request) {
if !need(w) {
return
}
limit, _ := strconv.Atoi(r.URL.Query().Get("limit"))
out, err := a.evals.History(r.Context(), r.URL.Query().Get("model"), limit)
if err != nil {
writeError(w, http.StatusInternalServerError, err.Error())
return
}
if out == nil {
out = []eval.Live{}
}
writeJSON(w, http.StatusOK, out)
})
mux.HandleFunc("GET /api/v1/eval/runs/{id}", func(w http.ResponseWriter, r *http.Request) {
if !need(w) {
return
}
live, err := a.evals.Get(r.Context(), r.PathValue("id"))
if errors.Is(err, store.ErrNotFound) {
writeError(w, http.StatusNotFound, "no eval run "+r.PathValue("id"))
return
}
if err != nil {
writeError(w, http.StatusInternalServerError, err.Error())
return
}
writeJSON(w, http.StatusOK, live)
})
setGate := func(w http.ResponseWriter, r *http.Request, g *catalog.EvalGate) {
gater, ok := a.models.(evalGater)
if !ok {
writeError(w, http.StatusNotImplemented, "this control plane has no store to keep the setting in")
return
}
name := r.PathValue("name")
if g != nil && a.evals != nil {
if err := a.evals.Check(name, eval.Request{Suite: g.Suite, Judge: g.Judge, MinScore: g.MinScore}); err != nil {
writeError(w, http.StatusBadRequest, err.Error())
return
}
}
m, err := gater.SetEval(r.Context(), name, g, actorOf(r))
if err != nil {
writeError(w, http.StatusBadRequest, err.Error())
return
}
writeJSON(w, http.StatusOK, EvalGateView{Model: m.Name, Eval: m.Eval})
}
mux.HandleFunc("PUT /api/v1/models/{name}/eval-gate", func(w http.ResponseWriter, r *http.Request) {
var req EvalGateRequest
if err := json.NewDecoder(io.LimitReader(r.Body, 1<<12)).Decode(&req); err != nil {
writeError(w, http.StatusBadRequest, "the request is not valid JSON: "+err.Error())
return
}
if strings.TrimSpace(req.Suite) == "" {
writeError(w, http.StatusBadRequest, "name a suite: one in the control plane's eval directory, or built in")
return
}
if req.MinScore < 0 || req.MinScore > 1 {
writeError(w, http.StatusBadRequest, "the minimum score is 0 to 1")
return
}
setGate(w, r, &catalog.EvalGate{Suite: strings.TrimSpace(req.Suite), MinScore: req.MinScore, Judge: req.Judge})
})
mux.HandleFunc("DELETE /api/v1/models/{name}/eval-gate", func(w http.ResponseWriter, r *http.Request) {
setGate(w, r, nil)
})
}
// EvalGateView is a model's standing quality gate, as it is now; Eval is
// absent when it has none.
type EvalGateView struct {
Model string `json:"model"`
Eval *catalog.EvalGate `json:"eval,omitempty"`
}