Files
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

261 lines
7.0 KiB
Go

package eval
import (
"context"
"encoding/json"
"errors"
"fmt"
"net/http"
"strings"
"sync"
"time"
"gitea.mynodes.xyz/ilker/llmgw/internal/store"
)
// Caller sends a chat completion to a model as the gateway does for itself:
// the router's Replay, past admission, accounting and the guardrails — a
// suite measures the weights, and a tenant's quota or a guardrail's refusal
// would measure something else. It answers the status, the body, and how
// long it took.
type Caller func(ctx context.Context, path, model string, body []byte) (int, []byte, time.Duration, error)
// Options are how a suite is run.
type Options struct {
// Model is the catalog model asked.
Model string
// Concurrency is how many cases are asked at once; 4 unless given.
Concurrency int
// Judge grades the cases scored by a rubric, instead of the suite's.
Judge string
// MinScore is the score to pass at, instead of the suite's threshold.
MinScore float64
// CaseTimeout bounds one case, its judging included; five minutes
// unless given, which a model loading on demand needs.
CaseTimeout time.Duration
// Progress is told as each case finishes.
Progress func(done, total int)
}
// PassAt is the score a case passes at: a judge's 0.5 is a coin toss, and
// counts as passed so a lenient rubric is not a hidden zero.
const PassAt = 0.5
// keptAnswer is how much of an answer is kept with its result.
const keptAnswer = 2000
// Evaluate asks a model every case of a suite and scores the answers. A
// case that got no answer scores 0 with its error; a suite that cannot be
// run at all — a rubric and no judge to read it — is an error before
// anything is asked.
func Evaluate(ctx context.Context, call Caller, s Suite, o Options) (store.EvalRun, error) {
if o.Model == "" {
return store.EvalRun{}, errors.New("no model to ask")
}
judge := o.Judge
if judge == "" {
judge = s.Judge
}
for _, c := range s.Cases {
if c.Judge != "" && c.JudgeModel == "" && judge == "" {
return store.EvalRun{}, fmt.Errorf("case %s is graded by a judge and the suite names none: "+
"give one with --judge MODEL, or judge: in the suite", c.ID)
}
}
threshold := s.Threshold
if o.MinScore > 0 {
threshold = o.MinScore
}
if threshold <= 0 {
threshold = DefaultThreshold
}
workers := o.Concurrency
if workers <= 0 {
workers = 4
}
timeout := o.CaseTimeout
if timeout <= 0 {
timeout = 5 * time.Minute
}
run := store.EvalRun{Model: o.Model, Suite: s.Name, SuiteHash: s.Hash, At: time.Now().UTC(),
Threshold: threshold, Cases: len(s.Cases), Results: make([]store.EvalCase, len(s.Cases))}
if s.NeedsJudge() {
run.Judge = judge
}
todo := make(chan int)
var wg sync.WaitGroup
var mu sync.Mutex
done := 0
for range min(workers, len(s.Cases)) {
wg.Go(func() {
for i := range todo {
cctx, cancel := context.WithTimeout(ctx, timeout)
res := askOne(cctx, call, s, &s.Cases[i], o.Model, judge)
cancel()
mu.Lock()
run.Results[i] = res
done++
if o.Progress != nil {
o.Progress(done, len(s.Cases))
}
mu.Unlock()
}
})
}
for i := range s.Cases {
select {
case todo <- i:
case <-ctx.Done():
}
if ctx.Err() != nil {
break
}
}
close(todo)
wg.Wait()
run.TookMs = time.Since(run.At).Milliseconds()
var sum, weights float64
firstErr := ""
for i, r := range run.Results {
if r.ID == "" {
// Not asked: the run was stopped first.
r = store.EvalCase{ID: s.Cases[i].ID, Kind: s.Cases[i].Kind(), Weight: s.Cases[i].Weight,
Error: "not asked: the run was stopped"}
run.Results[i] = r
}
sum += r.Weight * r.Score
weights += r.Weight
if !r.Passed {
run.Below++
}
if r.Error != "" {
run.Errors++
if firstErr == "" {
firstErr = r.Error
}
}
}
if weights > 0 {
run.Score = sum / weights
}
run.Passed = run.Score >= threshold
if run.Errors == run.Cases {
run.Passed = false
run.Error = "no case was answered: " + firstErr
}
return run, nil
}
// askedOf is the conversation a case sends.
func askedOf(c *Case, suiteSystem string) []Message {
var out []Message
system := c.System
if system == "" {
system = suiteSystem
}
if system != "" && (len(c.Messages) == 0 || c.Messages[0].Role != "system") {
out = append(out, Message{Role: "system", Content: system})
}
if len(c.Messages) > 0 {
return append(out, c.Messages...)
}
return append(out, Message{Role: "user", Content: c.Prompt})
}
// askOne asks one case and scores its answer.
func askOne(ctx context.Context, call Caller, s Suite, c *Case, model, judge string) store.EvalCase {
res := store.EvalCase{ID: c.ID, Kind: c.Kind(), Weight: c.Weight}
start := time.Now()
defer func() { res.TookMs = time.Since(start).Milliseconds() }()
maxTokens := c.MaxTokens
if maxTokens <= 0 {
maxTokens = s.MaxTokens
}
if maxTokens <= 0 {
maxTokens = defaultMaxTokens
}
temp := 0.0
if c.Temperature != nil {
temp = *c.Temperature
}
answer, finish, err := chat(ctx, call, model, askedOf(c, s.System), maxTokens, temp)
if err != nil {
res.Error = err.Error()
return res
}
answer = cleanAnswer(answer)
res.Answer = answer
if len(res.Answer) > keptAnswer {
res.Answer = res.Answer[:keptAnswer]
}
var v verdict
if c.Judge != "" {
jm := c.JudgeModel
if jm == "" {
jm = judge
}
reply, _, err := chat(ctx, call, jm, judgeRequest(c, answer), 256, 0)
if err != nil {
res.Error = "the judge " + jm + ": " + err.Error()
return res
}
if v, err = readJudgement(reply); err != nil {
res.Error = err.Error()
return res
}
} else {
v = scoreRule(c, answer)
}
res.Score, res.Detail = v.score, v.detail
res.Passed = res.Score >= PassAt
if finish == "length" && !res.Passed {
res.Detail += fmt.Sprintf("; the answer was cut off at %d tokens", maxTokens)
}
if answer == "" && !res.Passed {
res.Detail += "; it answered nothing"
}
return res
}
// chat asks a model one conversation and says what it answered and why it
// stopped.
func chat(ctx context.Context, call Caller, model string, msgs []Message, maxTokens int, temp float64) (string, string, error) {
body, err := json.Marshal(map[string]any{"model": model, "messages": msgs, "max_tokens": maxTokens,
"temperature": temp, "stream": false})
if err != nil {
return "", "", err
}
status, out, _, err := call(ctx, "/v1/chat/completions", model, body)
if err != nil {
return "", "", err
}
if status != http.StatusOK {
var e struct {
Error struct {
Message string `json:"message"`
} `json:"error"`
}
msg := strings.TrimSpace(string(out))
if json.Unmarshal(out, &e) == nil && e.Error.Message != "" {
msg = e.Error.Message
}
return "", "", fmt.Errorf("%s answered %d: %s", model, status, clip(msg, 200))
}
var resp struct {
Choices []struct {
Message struct {
Content string `json:"content"`
} `json:"message"`
FinishReason string `json:"finish_reason"`
} `json:"choices"`
}
if err := json.Unmarshal(out, &resp); err != nil || len(resp.Choices) == 0 {
return "", "", fmt.Errorf("%s's answer is not a chat completion: %s", model, clip(string(out), 120))
}
return resp.Choices[0].Message.Content, resp.Choices[0].FinishReason, nil
}