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>
124 lines
4.0 KiB
Go
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")
|
|
}
|
|
}
|