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>
117 lines
3.9 KiB
Go
117 lines
3.9 KiB
Go
package cli
|
|
|
|
import (
|
|
"encoding/json"
|
|
"io"
|
|
"net/http"
|
|
"net/http/httptest"
|
|
"strings"
|
|
"sync"
|
|
"testing"
|
|
|
|
tea "github.com/charmbracelet/bubbletea"
|
|
|
|
"gitea.mynodes.xyz/ilker/llmgw/internal/bench/eval"
|
|
"gitea.mynodes.xyz/ilker/llmgw/internal/control/adminapi"
|
|
"gitea.mynodes.xyz/ilker/llmgw/internal/store"
|
|
)
|
|
|
|
func TestAModelIsAskedASuiteFromItsMenuAndItsScoreShown(t *testing.T) {
|
|
var mu sync.Mutex
|
|
var asked eval.Request
|
|
polls := 0
|
|
done := eval.Live{EvalRun: store.EvalRun{ID: "eval-1", Model: "qwen", Suite: "arithmetic", Score: 0.75,
|
|
Threshold: 0.7, Passed: true, Cases: 2, Below: 1, Results: []store.EvalCase{
|
|
{ID: "add", Kind: "number", Score: 1, Passed: true, Detail: "read 42"},
|
|
{ID: "mul", Kind: "number", Score: 0, Detail: "read 43, wanted 42"}}}, State: eval.Done, Done: 2}
|
|
admin := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
mu.Lock()
|
|
defer mu.Unlock()
|
|
switch {
|
|
case r.URL.Path == "/api/v1/eval/suites":
|
|
_ = json.NewEncoder(w).Encode([]eval.SuiteInfo{
|
|
{Name: "arithmetic", From: "built in", Cases: 2, Threshold: 0.7},
|
|
{Name: "broken", From: "/etc/llmgw/eval/broken.yaml", Error: "no cases"},
|
|
{Name: "json-format", From: "built in", Cases: 7, Threshold: 0.7}})
|
|
case r.Method == http.MethodPost && r.URL.Path == "/api/v1/models/qwen/eval":
|
|
b, _ := io.ReadAll(r.Body)
|
|
_ = json.Unmarshal(b, &asked)
|
|
w.WriteHeader(http.StatusAccepted)
|
|
_ = json.NewEncoder(w).Encode(eval.Live{EvalRun: store.EvalRun{ID: "eval-1", Model: "qwen",
|
|
Suite: "arithmetic", Cases: 2}, State: eval.Running})
|
|
case r.URL.Path == "/api/v1/eval/runs/eval-1":
|
|
polls++
|
|
if polls == 1 {
|
|
_ = json.NewEncoder(w).Encode(eval.Live{EvalRun: store.EvalRun{ID: "eval-1", Model: "qwen",
|
|
Suite: "arithmetic", Cases: 2}, State: eval.Running, Done: 1})
|
|
return
|
|
}
|
|
_ = json.NewEncoder(w).Encode(done)
|
|
case r.URL.Path == "/api/v1/eval/runs":
|
|
older := done
|
|
older.ID, older.Score, older.Passed = "eval-0", 0.5, false
|
|
_ = json.NewEncoder(w).Encode([]eval.Live{done, older})
|
|
default:
|
|
http.NotFound(w, r)
|
|
}
|
|
}))
|
|
t.Cleanup(admin.Close)
|
|
m := newConsoleModel(admin.URL, "/nonexistent/server.yaml")
|
|
m.loaded, m.menu, m.inPanel = true, cModels, true
|
|
md := adminapi.ModelView{Name: "qwen"}
|
|
|
|
// run plays a command and what it leads to, the way the program would.
|
|
run := func(tm tea.Model, cmd tea.Cmd) tea.Model {
|
|
for cmd != nil {
|
|
msg := cmd()
|
|
tm, cmd = tm.Update(msg)
|
|
if _, ok := msg.(actionDoneMsg); ok {
|
|
break
|
|
}
|
|
}
|
|
return tm
|
|
}
|
|
var tm tea.Model = m
|
|
items := m.evalItems(md)
|
|
if len(items) != 3 || !strings.Contains(items[0].label, "quality suite") {
|
|
t.Fatalf("%+v", items)
|
|
}
|
|
tm = run(tm, items[0].act(&m))
|
|
f := tm.(consoleModel).formD
|
|
if f == nil || f.fields[0].options == nil || len(f.fields[0].options) != 2 {
|
|
t.Fatalf("no form offering the suites that can run: %+v", f)
|
|
}
|
|
tm = press(tm, "tab")
|
|
f.fields[1].text.SetValue("2")
|
|
tm = press(tm, "tab", "tab")
|
|
tm, cmd := tm.Update(tea.KeyMsg{Type: tea.KeyEnter})
|
|
if busy := tm.(consoleModel).busy; busy != "qwen: arithmetic" {
|
|
t.Fatalf("busy %q", busy)
|
|
}
|
|
// Started, then followed: one poll still running, then its score.
|
|
tm, next := tm.Update(cmd())
|
|
if note := tm.(consoleModel).busyNote; !strings.Contains(note, "0 of 2 answered") {
|
|
t.Errorf("progress %q", note)
|
|
}
|
|
tm, next = tm.Update(next())
|
|
if note := tm.(consoleModel).busyNote; !strings.Contains(note, "1 of 2 answered") {
|
|
t.Errorf("progress %q", note)
|
|
}
|
|
tm, _ = tm.Update(next())
|
|
view := tm.View()
|
|
mu.Lock()
|
|
sent := asked
|
|
mu.Unlock()
|
|
if sent.Suite != "arithmetic" || sent.Concurrency != 2 {
|
|
t.Errorf("asked %+v", sent)
|
|
}
|
|
for _, want := range []string{"qwen scored 0.75 on arithmetic", "1 of 2 cases passed", "read 43, wanted 42", "Its last runs"} {
|
|
if !strings.Contains(view, want) {
|
|
t.Errorf("the result does not say %q:\n%s", want, view)
|
|
}
|
|
}
|
|
if c := tm.(consoleModel); c.busy != "" || c.evalWatch != "" {
|
|
t.Errorf("still busy: %q %q", c.busy, c.evalWatch)
|
|
}
|
|
}
|