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>
97 lines
3.3 KiB
Go
97 lines
3.3 KiB
Go
package modelserve
|
|
|
|
import (
|
|
"context"
|
|
"errors"
|
|
"fmt"
|
|
|
|
"gitea.mynodes.xyz/ilker/llmgw/internal/bench/eval"
|
|
"gitea.mynodes.xyz/ilker/llmgw/internal/control/catalog"
|
|
"gitea.mynodes.xyz/ilker/llmgw/internal/store"
|
|
)
|
|
|
|
// A gate: new weights asked a quality suite before they replace a model's,
|
|
// and refused below its score. A promotion asks the canary, which is
|
|
// already running; an update starts the new weights as a canary given none
|
|
// of the model's requests, asks it, and promotes it or takes it out — so
|
|
// the old weights serve until the new have passed.
|
|
|
|
// Evaluator runs quality suites: the control plane's eval service.
|
|
type Evaluator interface {
|
|
// Check says whether a suite can be run, before anything is started
|
|
// for it.
|
|
Check(model string, r eval.Request) error
|
|
// Run asks a model a suite and waits for its score.
|
|
Run(ctx context.Context, model string, r eval.Request, by, job string) (store.EvalRun, error)
|
|
}
|
|
|
|
// Change is how an update or a promotion is held to quality.
|
|
type Change struct {
|
|
// Eval is a suite the new weights must pass first. Nil takes the
|
|
// model's own (eval: in its catalog entry), if it has one.
|
|
Eval *eval.Request `json:"eval,omitempty"`
|
|
// SkipEval moves without the model's own suite; the job says so.
|
|
SkipEval bool `json:"skip_eval,omitempty"`
|
|
}
|
|
|
|
// gateFor is the suite a change is held to, and whether one was skipped.
|
|
func gateFor(m catalog.Model, ch Change) (r *eval.Request, skipped bool) {
|
|
switch {
|
|
case ch.Eval != nil:
|
|
return ch.Eval, false
|
|
case m.Eval == nil:
|
|
return nil, false
|
|
case ch.SkipEval:
|
|
return nil, true
|
|
}
|
|
return &eval.Request{Suite: m.Eval.Suite, MinScore: m.Eval.MinScore, Judge: m.Eval.Judge}, false
|
|
}
|
|
|
|
// checkGate refuses, before a job starts, a gate that cannot be run.
|
|
func (s *Server) checkGate(name string, r *eval.Request) error {
|
|
if r == nil {
|
|
return nil
|
|
}
|
|
if s.deps.Eval == nil {
|
|
return errors.New("this control plane cannot run quality suites: it routes no requests")
|
|
}
|
|
if err := s.deps.Eval.Check(name, *r); err != nil {
|
|
return fmt.Errorf("the quality suite: %w", err)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// gate asks the new weights, running as target, a suite, and says whether
|
|
// they may replace the old; when they may not, the job fails saying why,
|
|
// and what refused does about the new weights, which it says too.
|
|
func (s *Server) gate(ctx context.Context, j *Job, target string, r *eval.Request, skipped bool, refused func() string) bool {
|
|
if skipped {
|
|
s.step(j, "eval", Skipped, "the model's quality suite was skipped by "+orUnknown(j.Request.By))
|
|
return true
|
|
}
|
|
if r == nil {
|
|
return true
|
|
}
|
|
s.step(j, "eval", Running, fmt.Sprintf("asking %s the cases of %s", target, r.Named()))
|
|
run, err := s.deps.Eval.Run(ctx, target, *r, j.Request.By, j.ID)
|
|
if err != nil {
|
|
s.fail(j, "eval", fmt.Errorf("%w; %s", err, refused()))
|
|
return false
|
|
}
|
|
run.Results = nil
|
|
s.mu.Lock()
|
|
j.Eval = &run
|
|
s.mu.Unlock()
|
|
detail := fmt.Sprintf("%s scored %.2f on %s, %d of %d cases passed; at least %.2f is asked", target,
|
|
run.Score, run.Suite, run.Cases-run.Below, run.Cases, run.Threshold)
|
|
if run.Error != "" {
|
|
detail += "; " + run.Error
|
|
}
|
|
if !run.Passed {
|
|
s.fail(j, "eval", fmt.Errorf("%s; %s. `llmgw ctl eval --run %s` shows each case", detail, refused(), run.ID))
|
|
return false
|
|
}
|
|
s.step(j, "eval", Done, detail)
|
|
return true
|
|
}
|