A suite is a set of cases in YAML or JSON lines, each scored by exact, contains, regex, a number within a tolerance, a JSON Schema subset, or a judge model with a rubric; weighed into one score against a threshold. internal/bench/eval runs one on the control plane through Router.Replay, as the gateway's own requests, and keeps every run and its cases in the store (migration 0028: eval_runs, eval_cases). - llmgw ctl eval MODEL --suite FILE|NAME, --history, --run, --suites; exits non-zero below the score. Suites by name from control.eval_dir, or the two built in from deploy/eval (arithmetic, json-format). - model update and promote take --eval SUITE --min-score N, or a model's standing gate (ctl model gate, eval: in the catalog): a promote asks the canary first and leaves it below the score; an update tries the new weights as a canary given no share, then promotes or removes it. - The console's model menu: ask a suite with its progress, its runs, its gate. - The audit middleware no longer hands a handler a body cut off at 64 KiB. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
435 lines
12 KiB
Go
435 lines
12 KiB
Go
package eval
|
|
|
|
import (
|
|
"context"
|
|
"crypto/rand"
|
|
"encoding/hex"
|
|
"errors"
|
|
"fmt"
|
|
"io/fs"
|
|
"log/slog"
|
|
"os"
|
|
"path/filepath"
|
|
"slices"
|
|
"sort"
|
|
"strings"
|
|
"sync"
|
|
"time"
|
|
|
|
"gitea.mynodes.xyz/ilker/llmgw/internal/store"
|
|
)
|
|
|
|
// The control plane's side: suites found on its disk or carried in the
|
|
// binary, runs started and followed, and kept in the store.
|
|
|
|
// Store keeps finished runs.
|
|
type Store interface {
|
|
PutEvalRun(ctx context.Context, r store.EvalRun) error
|
|
EvalRuns(ctx context.Context, model string, limit int) ([]store.EvalRun, error)
|
|
EvalRunByID(ctx context.Context, id string) (store.EvalRun, error)
|
|
}
|
|
|
|
// Deps are what the service runs suites with.
|
|
type Deps struct {
|
|
// Call asks a model: the router's Replay.
|
|
Call Caller
|
|
// Store keeps runs; without one they are kept here until a restart.
|
|
Store Store
|
|
// Dir is where suites are read from by name: control.eval_dir.
|
|
Dir string
|
|
// Builtin are the suites the binary carries, found when Dir has none
|
|
// of the name.
|
|
Builtin fs.FS
|
|
// Origin is where a catalog model's weights came from, when known.
|
|
Origin func(model string) (repo, revision string)
|
|
// CanaryOf is the model a name is the canary of, or "": a run on a
|
|
// canary is filed under the model it is a canary of.
|
|
CanaryOf func(name string) string
|
|
Log *slog.Logger
|
|
}
|
|
|
|
// Request is a suite to run against a model: one on the control plane, by
|
|
// its name, or one sent whole.
|
|
type Request struct {
|
|
// Suite is a suite's name in the eval directory, or carried in the
|
|
// binary (arithmetic, json-format).
|
|
Suite string `json:"suite,omitempty"`
|
|
// Content is a suite's text instead, and File the name of the file it
|
|
// was read from, which names the suite unless it names itself.
|
|
Content string `json:"content,omitempty"`
|
|
File string `json:"file,omitempty"`
|
|
// Concurrency, Judge and MinScore are as Options has them.
|
|
Concurrency int `json:"concurrency,omitempty"`
|
|
Judge string `json:"judge,omitempty"`
|
|
MinScore float64 `json:"min_score,omitempty"`
|
|
}
|
|
|
|
// Named says which suite a request is, for a step's detail.
|
|
func (r Request) Named() string {
|
|
if r.Suite != "" {
|
|
return r.Suite
|
|
}
|
|
return r.File
|
|
}
|
|
|
|
// The states a run can be in.
|
|
const (
|
|
Running = "running"
|
|
Done = "done"
|
|
Failed = "failed"
|
|
)
|
|
|
|
// Live is a run as it stands: one running, with how many of its cases are
|
|
// done, or one finished.
|
|
type Live struct {
|
|
store.EvalRun
|
|
State string `json:"state"`
|
|
Done int `json:"done"`
|
|
}
|
|
|
|
// SuiteInfo is a suite the control plane can run by name.
|
|
type SuiteInfo struct {
|
|
Name string `json:"name"`
|
|
// From is the file it is read from, or "built in".
|
|
From string `json:"from"`
|
|
Description string `json:"description,omitempty"`
|
|
Cases int `json:"cases"`
|
|
Threshold float64 `json:"threshold"`
|
|
Judge bool `json:"judge,omitempty"`
|
|
Hash string `json:"hash,omitempty"`
|
|
// Error is why the file cannot be run.
|
|
Error string `json:"error,omitempty"`
|
|
}
|
|
|
|
// Service runs suites and remembers them.
|
|
type Service struct {
|
|
deps Deps
|
|
mu sync.Mutex
|
|
live map[string]*Live
|
|
// order is the live runs oldest first, so the finished ones past
|
|
// keepLive are forgotten — the store has them.
|
|
order []string
|
|
}
|
|
|
|
const keepLive = 50
|
|
|
|
// NewService builds a service.
|
|
func NewService(d Deps) *Service {
|
|
if d.Log == nil {
|
|
d.Log = slog.Default()
|
|
}
|
|
return &Service{deps: d, live: map[string]*Live{}}
|
|
}
|
|
|
|
var suiteExts = []string{".yaml", ".yml", ".jsonl", ".json"}
|
|
|
|
// Load reads the suite a request names or carries.
|
|
func (s *Service) Load(r Request) (Suite, error) {
|
|
if r.Content != "" {
|
|
file := r.File
|
|
if file == "" {
|
|
file = "suite"
|
|
}
|
|
return Parse(file, []byte(r.Content))
|
|
}
|
|
name := strings.TrimSpace(r.Suite)
|
|
if name == "" {
|
|
return Suite{}, errors.New("no suite: name one on the control plane, or send one's text")
|
|
}
|
|
// A name, not a path: this reads the control plane's disk on behalf of
|
|
// whoever holds the admin API, and only inside the eval directory.
|
|
if strings.ContainsAny(name, `/\`) || name == "." || name == ".." {
|
|
return Suite{}, fmt.Errorf("%q is not a suite's name: the control plane reads suites from %s by name; "+
|
|
"send a file from elsewhere with its text", name, s.dirOrNone())
|
|
}
|
|
tries := []string{name}
|
|
if !slices.Contains(suiteExts, filepath.Ext(name)) {
|
|
tries = nil
|
|
for _, e := range suiteExts {
|
|
tries = append(tries, name+e)
|
|
}
|
|
}
|
|
if s.deps.Dir != "" {
|
|
for _, t := range tries {
|
|
b, err := os.ReadFile(filepath.Join(s.deps.Dir, t))
|
|
if err == nil {
|
|
return Parse(t, b)
|
|
}
|
|
if !errors.Is(err, fs.ErrNotExist) {
|
|
return Suite{}, err
|
|
}
|
|
}
|
|
}
|
|
if s.deps.Builtin != nil {
|
|
for _, t := range tries {
|
|
if b, err := fs.ReadFile(s.deps.Builtin, t); err == nil {
|
|
return Parse(t, b)
|
|
}
|
|
}
|
|
}
|
|
return Suite{}, fmt.Errorf("no suite called %s in %s or built in: `llmgw ctl eval --suites` lists them", name, s.dirOrNone())
|
|
}
|
|
|
|
func (s *Service) dirOrNone() string {
|
|
if s.deps.Dir == "" {
|
|
return "no eval directory"
|
|
}
|
|
return s.deps.Dir
|
|
}
|
|
|
|
// Suites are those the control plane can run by name: its directory's, and
|
|
// the binary's that the directory does not replace.
|
|
func (s *Service) Suites() []SuiteInfo {
|
|
var out []SuiteInfo
|
|
seen := map[string]bool{}
|
|
add := func(fsys fs.FS, from func(string) string) {
|
|
entries, err := fs.ReadDir(fsys, ".")
|
|
if err != nil {
|
|
return
|
|
}
|
|
for _, e := range entries {
|
|
ext := filepath.Ext(e.Name())
|
|
if e.IsDir() || !slices.Contains(suiteExts, ext) {
|
|
continue
|
|
}
|
|
name := strings.TrimSuffix(e.Name(), ext)
|
|
if seen[name] {
|
|
continue
|
|
}
|
|
seen[name] = true
|
|
info := SuiteInfo{Name: name, From: from(e.Name())}
|
|
b, err := fs.ReadFile(fsys, e.Name())
|
|
var su Suite
|
|
if err == nil {
|
|
su, err = Parse(e.Name(), b)
|
|
}
|
|
if err != nil {
|
|
info.Error = err.Error()
|
|
} else {
|
|
info.Description, info.Cases, info.Threshold = su.Description, len(su.Cases), su.Threshold
|
|
info.Judge, info.Hash = su.NeedsJudge(), su.Hash
|
|
}
|
|
out = append(out, info)
|
|
}
|
|
}
|
|
if s.deps.Dir != "" {
|
|
add(os.DirFS(s.deps.Dir), func(n string) string { return filepath.Join(s.deps.Dir, n) })
|
|
}
|
|
if s.deps.Builtin != nil {
|
|
add(s.deps.Builtin, func(string) string { return "built in" })
|
|
}
|
|
sort.Slice(out, func(i, j int) bool { return out[i].Name < out[j].Name })
|
|
return out
|
|
}
|
|
|
|
func newID() string {
|
|
b := make([]byte, 4)
|
|
_, _ = rand.Read(b)
|
|
return fmt.Sprintf("eval-%s-%s", time.Now().UTC().Format("20060102-150405"), hex.EncodeToString(b))
|
|
}
|
|
|
|
// prepare reads the suite and checks it can be run against model, before
|
|
// anything is asked.
|
|
func (s *Service) prepare(model string, r Request) (Suite, error) {
|
|
if s.deps.Call == nil {
|
|
return Suite{}, errors.New("this control plane does not route requests, so it cannot ask a model")
|
|
}
|
|
if strings.TrimSpace(model) == "" {
|
|
return Suite{}, errors.New("no model to ask")
|
|
}
|
|
su, err := s.Load(r)
|
|
if err != nil {
|
|
return Suite{}, err
|
|
}
|
|
if r.MinScore < 0 || r.MinScore > 1 {
|
|
return Suite{}, fmt.Errorf("the minimum score %v is not 0 to 1", r.MinScore)
|
|
}
|
|
if su.NeedsJudge() && r.Judge == "" && su.Judge == "" {
|
|
for _, c := range su.Cases {
|
|
if c.Judge != "" && c.JudgeModel == "" {
|
|
return Suite{}, fmt.Errorf("case %s of %s is graded by a judge and the suite names none: give one "+
|
|
"with --judge MODEL", c.ID, su.Name)
|
|
}
|
|
}
|
|
}
|
|
return su, nil
|
|
}
|
|
|
|
// Check says whether a suite can be run against a model, without asking it
|
|
// anything: for a gate, refused before its job starts rather than after the
|
|
// new weights are fetched.
|
|
func (s *Service) Check(model string, r Request) error {
|
|
_, err := s.prepare(model, r)
|
|
return err
|
|
}
|
|
|
|
// file fills in what a run is filed under: the model it is about, the one
|
|
// that answered, and where its weights came from.
|
|
func (s *Service) file(run *store.EvalRun, asked string) {
|
|
run.Model = asked
|
|
if s.deps.CanaryOf != nil {
|
|
if parent := s.deps.CanaryOf(asked); parent != "" {
|
|
run.Model, run.Target = parent, asked
|
|
}
|
|
}
|
|
if s.deps.Origin != nil {
|
|
run.Repo, run.Revision = s.deps.Origin(asked)
|
|
}
|
|
}
|
|
|
|
// Start runs a suite against a model in the background and returns at once;
|
|
// Get follows it.
|
|
func (s *Service) Start(model string, r Request, by string) (Live, error) {
|
|
su, err := s.prepare(model, r)
|
|
if err != nil {
|
|
return Live{}, err
|
|
}
|
|
l := &Live{State: Running}
|
|
l.ID, l.Suite, l.SuiteHash, l.Cases, l.By, l.At = newID(), su.Name, su.Hash, len(su.Cases), by, time.Now().UTC()
|
|
s.file(&l.EvalRun, model)
|
|
s.mu.Lock()
|
|
s.keep(l)
|
|
s.mu.Unlock()
|
|
go func() {
|
|
_, _ = s.run(context.Background(), l, model, su, r)
|
|
}()
|
|
return s.snapshot(l), nil
|
|
}
|
|
|
|
// Run runs a suite against a model and waits for it: for a gate, which
|
|
// cannot go on until it knows. The run is kept like any other; job is the
|
|
// update or promotion it gates.
|
|
func (s *Service) Run(ctx context.Context, model string, r Request, by, job string) (store.EvalRun, error) {
|
|
su, err := s.prepare(model, r)
|
|
if err != nil {
|
|
return store.EvalRun{}, err
|
|
}
|
|
l := &Live{State: Running}
|
|
l.ID, l.Suite, l.SuiteHash, l.Cases, l.By, l.Job, l.At = newID(), su.Name, su.Hash, len(su.Cases), by, job, time.Now().UTC()
|
|
s.file(&l.EvalRun, model)
|
|
s.mu.Lock()
|
|
s.keep(l)
|
|
s.mu.Unlock()
|
|
return s.run(ctx, l, model, su, r)
|
|
}
|
|
|
|
func (s *Service) run(ctx context.Context, l *Live, model string, su Suite, r Request) (store.EvalRun, error) {
|
|
run, err := Evaluate(ctx, s.deps.Call, su, Options{Model: model, Concurrency: r.Concurrency, Judge: r.Judge,
|
|
MinScore: r.MinScore, Progress: func(done, _ int) {
|
|
s.mu.Lock()
|
|
l.Done = done
|
|
s.mu.Unlock()
|
|
}})
|
|
s.mu.Lock()
|
|
if err != nil {
|
|
l.State, l.Error = Failed, err.Error()
|
|
out := l.EvalRun
|
|
s.mu.Unlock()
|
|
return out, err
|
|
}
|
|
run.ID, run.By, run.Job = l.ID, l.By, l.Job
|
|
run.Model, run.Target, run.Repo, run.Revision = l.Model, l.Target, l.Repo, l.Revision
|
|
s.mu.Unlock()
|
|
|
|
// Kept before it is said to be done, so a history read in between does
|
|
// not find it in neither place.
|
|
if s.deps.Store != nil {
|
|
// Its own context: a gate's caller giving up does not unkeep a run
|
|
// that finished.
|
|
sctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
|
|
defer cancel()
|
|
if err := s.deps.Store.PutEvalRun(sctx, run); err != nil {
|
|
s.deps.Log.Warn("could not keep an eval run", "run", run.ID, "model", run.Model, "err", err)
|
|
}
|
|
}
|
|
s.mu.Lock()
|
|
l.EvalRun, l.State, l.Done = run, Done, run.Cases
|
|
s.mu.Unlock()
|
|
s.deps.Log.Info("eval run finished", "run", run.ID, "model", run.Model, "target", run.Target,
|
|
"suite", run.Suite, "score", fmt.Sprintf("%.3f", run.Score), "threshold", run.Threshold, "passed", run.Passed)
|
|
return run, nil
|
|
}
|
|
|
|
// keep remembers a run, and forgets the oldest finished ones past keepLive.
|
|
// Called with mu held.
|
|
func (s *Service) keep(l *Live) {
|
|
s.live[l.ID] = l
|
|
s.order = append(s.order, l.ID)
|
|
for len(s.order) > keepLive {
|
|
oldest := s.live[s.order[0]]
|
|
if oldest != nil && oldest.State == Running {
|
|
break
|
|
}
|
|
delete(s.live, s.order[0])
|
|
s.order = s.order[1:]
|
|
}
|
|
}
|
|
|
|
func (s *Service) snapshot(l *Live) Live {
|
|
s.mu.Lock()
|
|
defer s.mu.Unlock()
|
|
out := *l
|
|
out.Results = slices.Clone(l.Results)
|
|
return out
|
|
}
|
|
|
|
// Get is a run by its id: one running or finished here, else one kept.
|
|
func (s *Service) Get(ctx context.Context, id string) (Live, error) {
|
|
s.mu.Lock()
|
|
l, ok := s.live[id]
|
|
s.mu.Unlock()
|
|
if ok {
|
|
return s.snapshot(l), nil
|
|
}
|
|
if s.deps.Store == nil {
|
|
return Live{}, store.ErrNotFound
|
|
}
|
|
r, err := s.deps.Store.EvalRunByID(ctx, id)
|
|
if err != nil {
|
|
return Live{}, err
|
|
}
|
|
return Live{EvalRun: r, State: Done, Done: r.Cases}, nil
|
|
}
|
|
|
|
// History is a model's runs, newest first, without their cases: those
|
|
// running here, then those kept.
|
|
func (s *Service) History(ctx context.Context, model string, limit int) ([]Live, error) {
|
|
if limit <= 0 {
|
|
limit = 20
|
|
}
|
|
var out []Live
|
|
seen := map[string]bool{}
|
|
s.mu.Lock()
|
|
for i := len(s.order) - 1; i >= 0; i-- {
|
|
l := s.live[s.order[i]]
|
|
if l == nil || (model != "" && l.Model != model && l.Target != model) {
|
|
continue
|
|
}
|
|
// Finished ones are listed from the store, when there is one, in
|
|
// its order.
|
|
if l.State != Running && s.deps.Store != nil {
|
|
continue
|
|
}
|
|
v := *l
|
|
v.Results = nil
|
|
out = append(out, v)
|
|
seen[l.ID] = true
|
|
}
|
|
s.mu.Unlock()
|
|
if s.deps.Store != nil {
|
|
runs, err := s.deps.Store.EvalRuns(ctx, model, limit)
|
|
if err != nil {
|
|
return out, err
|
|
}
|
|
for _, r := range runs {
|
|
if !seen[r.ID] {
|
|
out = append(out, Live{EvalRun: r, State: Done, Done: r.Cases})
|
|
}
|
|
}
|
|
}
|
|
if len(out) > limit {
|
|
out = out[:limit]
|
|
}
|
|
return out, nil
|
|
}
|