Files
llmgw/internal/cli/ctl_eval_test.go
Ilker ManapandClaude Opus 5.5 f0477f140d Quality suites, and new weights held to one: ctl eval, model update/promote --eval, model gate
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>
2026-10-05 00:04:43 +02:00

124 lines
4.0 KiB
Go

package cli
import (
"bytes"
"context"
"encoding/json"
"io"
"io/fs"
"net/http"
"net/http/httptest"
"os"
"path/filepath"
"strings"
"sync"
"testing"
llmgw "gitea.mynodes.xyz/ilker/llmgw"
"gitea.mynodes.xyz/ilker/llmgw/internal/bench/eval"
"gitea.mynodes.xyz/ilker/llmgw/internal/control/modelserve"
"gitea.mynodes.xyz/ilker/llmgw/internal/store"
)
func TestTheSuitesTheBinaryCarriesCanBeRun(t *testing.T) {
sub, err := fs.Sub(llmgw.EvalSuites, "deploy/eval")
if err != nil {
t.Fatal(err)
}
got := eval.NewService(eval.Deps{Builtin: sub}).Suites()
var names []string
for _, s := range got {
if s.Error != "" || s.Cases < 5 || s.Judge {
t.Errorf("%s: %+v", s.Name, s)
}
names = append(names, s.Name)
}
if strings.Join(names, " ") != "arithmetic json-format" {
t.Errorf("built in: %v", names)
}
}
func TestCtlEvalSendsAFileAndFailsBelowTheScore(t *testing.T) {
var mu sync.Mutex
var sent []string
finished := eval.Live{EvalRun: store.EvalRun{ID: "eval-1", Model: "qwen", Suite: "mine", Score: 0.5,
Threshold: 0.8, Cases: 2, Below: 1, Revision: "abcdef1234567", Repo: "o/r", Results: []store.EvalCase{
{ID: "add", Kind: "number", Weight: 1, Score: 1, Passed: true, Answer: "42", Detail: "read 42"},
{ID: "mul", Kind: "number", Weight: 1, Score: 0, Answer: "43", Detail: "read 43, wanted 42"}}},
State: eval.Done, Done: 2}
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
b, _ := io.ReadAll(r.Body)
mu.Lock()
sent = append(sent, r.Method+" "+r.URL.Path+" "+string(b))
mu.Unlock()
switch r.URL.Path {
case "/api/v1/models/qwen/eval":
w.WriteHeader(http.StatusAccepted)
_ = json.NewEncoder(w).Encode(eval.Live{EvalRun: store.EvalRun{ID: "eval-1", Model: "qwen", Cases: 2},
State: eval.Running})
case "/api/v1/eval/runs/eval-1":
_ = json.NewEncoder(w).Encode(finished)
case "/api/v1/models/qwen/update":
w.WriteHeader(http.StatusAccepted)
_ = json.NewEncoder(w).Encode(modelserve.Job{ID: "update-1", Model: "qwen", State: modelserve.Done})
default:
http.NotFound(w, r)
}
}))
t.Cleanup(srv.Close)
file := filepath.Join(t.TempDir(), "mine.yaml")
if err := os.WriteFile(file, []byte("cases:\n - prompt: 40 + 2?\n number: 42\n"), 0o600); err != nil {
t.Fatal(err)
}
ctl := func(args ...string) (string, error) {
cmd := newCtlCmd()
var out bytes.Buffer
cmd.SetOut(&out)
cmd.SetErr(&out)
cmd.SetArgs(append([]string{"--server", srv.URL}, args...))
err := cmd.ExecuteContext(context.Background())
return out.String(), err
}
out, err := ctl("eval", "qwen", "--suite", file, "--concurrency", "2")
if err == nil || !strings.Contains(err.Error(), "qwen scored 0.50 on mine, below 0.80") {
t.Fatalf("a run below its score did not fail: %v\n%s", err, out)
}
for _, want := range []string{"read 43, wanted 42", "1 of 2 cases passed", "weights o/r at abcdef1"} {
if !strings.Contains(out, want) {
t.Errorf("the output lacks %q:\n%s", want, out)
}
}
mu.Lock()
var req eval.Request
_ = json.Unmarshal([]byte(strings.SplitN(sent[0], " ", 3)[2]), &req)
mu.Unlock()
if req.File != "mine.yaml" || !strings.Contains(req.Content, "40 + 2?") || req.Concurrency != 2 {
t.Errorf("sent %+v", req)
}
// A broken file is said here, before anything is sent.
if err := os.WriteFile(file, []byte("cases: []\n"), 0o600); err != nil {
t.Fatal(err)
}
if _, err := ctl("eval", "qwen", "--suite", file); err == nil || !strings.Contains(err.Error(), "no cases") {
t.Errorf("%v", err)
}
// An update held to a suite by name carries it.
if _, err := ctl("model", "update", "qwen", "--eval", "arithmetic", "--min-score", "0.9"); err != nil {
t.Fatal(err)
}
mu.Lock()
last := sent[len(sent)-1]
mu.Unlock()
var ch modelserve.Change
if err := json.Unmarshal([]byte(strings.SplitN(last, " ", 3)[2]), &ch); err != nil || ch.Eval == nil ||
ch.Eval.Suite != "arithmetic" || ch.Eval.MinScore != 0.9 {
t.Errorf("sent %s", last)
}
if _, err := ctl("model", "update", "qwen", "--min-score", "0.9"); err == nil {
t.Error("--min-score without --eval was sent")
}
}