Files
Loki/internal/loki/llm_bench_test.go
T
MichaelandClaude Opus 5.5 09ee04037b Bench : corrections de relecture — niveau de raisonnement, préchauffage borné, verrou fiable
La relecture du bench en tâche de fond a trouvé quatre défauts qui le
faisaient échouer ou s'interrompre là où il n'aurait pas dû, et une
course entre la fin annoncée et le verrou rendu.

- Niveau de raisonnement refusé par le gabarit (Qwen3.8 et « high ») :
  le bench rejoue une fois avec le niveau accepté, comme runChatTools,
  et retient la traduction. Il échouait sur ce 500 dans un processus
  neuf. L'erreur HTTP garde le corps entier : les niveaux acceptés sont
  cités au-delà des 300 caractères affichés.
- Préchauffage borné au quart du contexte : -ub 4096 sur 8k de contexte
  envoyait un prompt plus grand que la fenêtre.
- Changer de discussion n'annule plus le bench, et ne lui reprend pas
  le verrou. La libération suit le drapeau benching au lieu de l'epoch,
  qu'une bascule de fil bumpe. Reset arrête toujours le bench et rend
  le verrou.
- Le verrou du chat est rendu avant que le statut ne dise « terminé » :
  un message envoyé aussitôt n'est plus refusé (et le test n'est plus
  instable).
- Indication « possible thrash » mesurée sur les tours seulement, pas
  sur le prefill à froid qui lit légitimement les pages du modèle.
- Interface : agrégats absents (omitempty) affichés à 0 au lieu de
  casser le rendu.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-04 03:09:01 +02:00

640 lines
24 KiB
Go

package loki
import (
"context"
"encoding/json"
"fmt"
"net/http"
"net/http/httptest"
"net/url"
"strings"
"sync"
"testing"
"time"
"unicode/utf8"
)
// fakeBenchEngine simule llama-server pour le protocole du bench : un compte
// de jetons à 4 octets par jeton, un cache qui reprend le prompt précédent
// quand cache_prompt est vrai, et le journal des requêtes de chat reçues.
type fakeBenchEngine struct {
mu sync.Mutex
nCtx int
busy bool
failAt int // n° (1-based) de la requête de chat qui répond 500 ; 0 = aucune
noTiming bool // réponses sans timings
rejectEffort string // niveau de reasoning_effort que le « gabarit » refuse (500)
slowHot time.Duration // délai des tours qui reprennent le cache
prev int
calls []map[string]any
}
func (f *fakeBenchEngine) handler(t *testing.T) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
switch r.URL.Path {
case "/health":
w.Write([]byte(`{"status":"ok"}`))
case "/slots":
fmt.Fprintf(w, `[{"id":0,"is_processing":%v}]`, f.busy)
case "/props":
fmt.Fprintf(w, `{"build_info":"b9999-test","total_slots":1,"default_generation_settings":{"n_ctx":%d}}`, f.nCtx)
case "/tokenize":
var b struct {
Content string `json:"content"`
}
json.NewDecoder(r.Body).Decode(&b)
toks := make([]int, len(b.Content)/4)
json.NewEncoder(w).Encode(map[string]any{"tokens": toks})
case "/v1/chat/completions":
var p map[string]any
if err := json.NewDecoder(r.Body).Decode(&p); err != nil {
t.Errorf("corps illisible : %v", err)
}
f.mu.Lock()
f.calls = append(f.calls, p)
n := len(f.calls)
f.mu.Unlock()
if f.failAt == n {
http.Error(w, `{"error":"boom"}`, http.StatusInternalServerError)
return
}
if e, _ := p["reasoning_effort"].(string); f.rejectEffort != "" && e == f.rejectEffort {
// Le vrai message cite la trace jinja d'abord : les niveaux acceptés
// arrivent bien après les 300 premiers caractères.
http.Error(w, `{"error":{"code":500,"message":"`+strings.Repeat("jinja trace ", 40)+
`Unexpected reasoning effort `+e+`. Supported types are xhigh (default), medium, and low."}}`, http.StatusInternalServerError)
return
}
cache, _ := p["cache_prompt"].(bool)
if d := f.slowHot; cache && d > 0 {
select {
case <-time.After(d):
case <-r.Context().Done():
return
}
}
total := 0
for _, m := range p["messages"].([]any) {
c, _ := m.(map[string]any)["content"].(string)
total += len(c)/4 + 4
}
cached := 0
f.mu.Lock()
if cache {
cached = min(f.prev, total)
}
f.prev = total
f.mu.Unlock()
gen := int(p["max_tokens"].(float64))
resp := map[string]any{
"choices": []any{map[string]any{"message": map[string]any{"role": "assistant", "content": "réponse"}}},
"usage": map[string]any{"prompt_tokens": total, "completion_tokens": gen},
}
if !f.noTiming {
resp["timings"] = map[string]any{
"prompt_n": total - cached, "prompt_ms": float64(total-cached) / 2, "prompt_per_second": 2000.0,
"predicted_n": gen, "predicted_ms": float64(gen) * 20, "predicted_per_second": 50.0,
"cache_n": cached, "draft_n": 10, "draft_n_accepted": 7,
}
}
json.NewEncoder(w).Encode(resp)
default:
http.NotFound(w, r)
}
})
}
func (f *fakeBenchEngine) start(t *testing.T) (benchEngine, *httptest.Server) {
srv := httptest.NewServer(f.handler(t))
t.Cleanup(srv.Close)
return benchEngine{base: srv.URL, client: srv.Client()}, srv
}
// testBenchCorpora : un code de 200 Ko sans aucune ligne répétée.
func testBenchCorpora() benchCorpora {
var b strings.Builder
for i := 0; b.Len() < 200<<10; i++ {
fmt.Fprintf(&b, "const ligne%06d = « contenu distinct %d » ;\n", i, i*7)
}
return benchCorpora{prose: benchCorpus, code: b.String()}
}
func testBenchSetup() benchSetup {
return benchSetupFrom(map[string]string{"TOP_K": "40", "MIN_P": "0.05", "REASONING": "off"}, nil)
}
// Le protocole complet, requête par requête : préchauffage, ligne courte et
// prefill à froid sans cache, puis trois tours qui prolongent la MÊME
// discussion avec le cache — et l'échantillonnage du preset partout.
func TestBenchProtocoleComplet(t *testing.T) {
f := &fakeBenchEngine{nCtx: 16384}
eng, _ := f.start(t)
var phases []string
res, err := benchRun(context.Background(), eng, benchOpts{Mode: benchModeFull}, testBenchSetup(), testBenchCorpora(),
func(phase string, step, steps int) {
phases = append(phases, fmt.Sprintf("%d/%d %s", step, steps, phase))
})
if err != nil {
t.Fatal(err)
}
if len(f.calls) != 6 {
t.Fatalf("%d requêtes de chat, attendu 6 (préchauffage, ligne courte, froid, 3 tours)", len(f.calls))
}
wantCache := []bool{false, false, false, true, true, true}
wantMax := []float64{benchWarmGen, 300, benchGenTokens, benchGenTokens, benchGenTokens, benchGenTokens}
wantMsgs := []int{1, 1, 1, 3, 5, 7}
for i, p := range f.calls {
if p["cache_prompt"] != wantCache[i] {
t.Errorf("requête %d : cache_prompt=%v, attendu %v", i+1, p["cache_prompt"], wantCache[i])
}
if p["max_tokens"] != wantMax[i] {
t.Errorf("requête %d : max_tokens=%v, attendu %v", i+1, p["max_tokens"], wantMax[i])
}
msgs := p["messages"].([]any)
if len(msgs) != wantMsgs[i] {
t.Errorf("requête %d : %d messages, attendu %d", i+1, len(msgs), wantMsgs[i])
}
for j, m := range msgs {
want := "user"
if j%2 == 1 {
want = "assistant"
}
if role := m.(map[string]any)["role"]; role != want {
t.Errorf("requête %d, message %d : rôle %v, attendu %s", i+1, j, role, want)
}
}
if p["top_k"] != float64(40) || p["min_p"] != 0.05 || p["seed"] != float64(benchSeed) {
t.Errorf("requête %d : échantillonnage du preset ou seed absents : %v", i+1, p)
}
if _, ok := p["ignore_eos"]; ok {
t.Errorf("requête %d : ignore_eos envoyé — génération hors distribution", i+1)
}
if kw, _ := p["chat_template_kwargs"].(map[string]any); kw["enable_thinking"] != false {
t.Errorf("requête %d : REASONING=off non transmis au gabarit : %v", i+1, p["chat_template_kwargs"])
}
}
// Chaque tour apporte du code JAMAIS vu : aucun morceau ne se répète.
seen := map[string]bool{}
for _, m := range f.calls[5]["messages"].([]any) {
c := m.(map[string]any)["content"].(string)
if c != "réponse" && seen[c] {
t.Error("un morceau de corpus est renvoyé deux fois")
}
seen[c] = true
}
if len(phases) != 6 || !strings.HasPrefix(phases[5], "6/6 ") {
t.Errorf("progression inattendue : %v", phases)
}
d := res.Depth
if d == nil || d.Skipped != "" || d.Partial != "" || d.Cold == nil || len(d.Turns) != benchTurns {
t.Fatalf("phase en profondeur incomplète : %+v", d)
}
if want, _ := benchDepthFor(16384, false, 2*512+benchWarmGen); d.Target != want || d.Cold.New < want*9/10 {
t.Errorf("profondeur : visée %d (attendu %d), froid %d jetons", d.Target, want, d.Cold.New)
}
if d.Reuse < 0.99 || d.ReuseNote != "" {
t.Errorf("reprise du cache : %v %q, attendu complète", d.Reuse, d.ReuseNote)
}
if d.DecodePerSec != 50 || d.CachedPerSec != 2000 {
t.Errorf("agrégats : decode %v, prefill %v", d.DecodePerSec, d.CachedPerSec)
}
if d.Draft == nil || d.Draft.N != 30 || d.Draft.Accepted != 21 || res.Draft == nil || res.Draft.N != 10 {
t.Errorf("acceptation du brouillon : profondeur %+v, ligne courte %+v", d.Draft, res.Draft)
}
if res.Engine != "b9999-test" || res.Protocol != benchProtocol || res.Mode != benchModeFull || !benchSavable(res) {
t.Errorf("métadonnées : %+v", res)
}
}
// Le mode rapide s'arrête à la ligne courte.
func TestBenchModeRapide(t *testing.T) {
f := &fakeBenchEngine{nCtx: 16384}
eng, _ := f.start(t)
res, err := benchRun(context.Background(), eng, benchOpts{Mode: benchModeQuick}, testBenchSetup(), testBenchCorpora(), nil)
if err != nil {
t.Fatal(err)
}
if len(f.calls) != 2 || res.Depth != nil || res.PredictedPerSec != 50 {
t.Fatalf("%d requêtes, profondeur %+v, decode %v", len(f.calls), res.Depth, res.PredictedPerSec)
}
}
// Une réponse non-200 ou sans timings n'est jamais une mesure : erreur, donc
// rien d'enregistré (runBench n'enregistre qu'un résultat sans erreur).
func TestBenchEchecsNonEnregistres(t *testing.T) {
for _, c := range []struct {
name string
f *fakeBenchEngine
want string
}{
{"500 sur la ligne courte", &fakeBenchEngine{nCtx: 16384, failAt: 2}, "ligne courte"},
{"500 sur un tour", &fakeBenchEngine{nCtx: 16384, failAt: 5}, "tour 2"},
{"sans timings", &fakeBenchEngine{nCtx: 16384, noTiming: true}, errBenchNoTimings.Error()},
{"moteur occupé", &fakeBenchEngine{nCtx: 16384, busy: true}, "déjà une requête"},
} {
t.Run(c.name, func(t *testing.T) {
eng, _ := c.f.start(t)
res, err := benchRun(context.Background(), eng, benchOpts{Mode: benchModeFull}, testBenchSetup(), testBenchCorpora(), nil)
if err == nil || res != nil || !strings.Contains(err.Error(), c.want) {
t.Fatalf("res=%v err=%v, attendu une erreur contenant %q", res, err, c.want)
}
if c.f.busy && len(c.f.calls) != 0 {
t.Error("moteur occupé : aucune requête ne devait partir")
}
})
}
}
// Contexte trop petit : la profondeur est sautée, la ligne courte reste.
func TestBenchProfondeurSautee(t *testing.T) {
f := &fakeBenchEngine{nCtx: 8192}
eng, _ := f.start(t)
res, err := benchRun(context.Background(), eng, benchOpts{Mode: benchModeFull}, testBenchSetup(), testBenchCorpora(), nil)
if err != nil {
t.Fatal(err)
}
if res.Depth == nil || res.Depth.Skipped == "" || len(f.calls) != 2 || !benchSavable(res) {
t.Fatalf("profondeur %+v, %d requêtes", res.Depth, len(f.calls))
}
}
// Un tour qui dépasse son budget rend un résultat PARTIEL, montré mais jamais
// enregistré.
func TestBenchBudgetPartiel(t *testing.T) {
old := benchReqTimeout
benchReqTimeout = 200 * time.Millisecond
t.Cleanup(func() { benchReqTimeout = old })
f := &fakeBenchEngine{nCtx: 16384, slowHot: 2 * time.Second}
eng, _ := f.start(t)
res, err := benchRun(context.Background(), eng, benchOpts{Mode: benchModeFull}, testBenchSetup(), testBenchCorpora(), nil)
if err != nil {
t.Fatal(err)
}
if res.Depth == nil || !strings.Contains(res.Depth.Partial, "tour 1") || benchSavable(res) {
t.Fatalf("attendu un résultat partiel non enregistrable : %+v", res.Depth)
}
}
// Annulé : erreur, rien d'enregistré.
func TestBenchAnnule(t *testing.T) {
f := &fakeBenchEngine{nCtx: 16384, slowHot: 5 * time.Second}
eng, _ := f.start(t)
ctx, cancel := context.WithCancel(context.Background())
go func() { time.Sleep(300 * time.Millisecond); cancel() }()
res, err := benchRun(ctx, eng, benchOpts{Mode: benchModeFull}, testBenchSetup(), testBenchCorpora(), nil)
if err == nil || res != nil {
t.Fatalf("annulation : res=%v err=%v", res, err)
}
}
func TestBenchDepthFor(t *testing.T) {
warm := 2*512 + benchWarmGen
for _, c := range []struct {
ctx int
cpu bool
want int
skipped bool
comments string
}{
{131072, false, benchDepthMax, false, "grand contexte : plafond"},
{131072, true, benchDepthCPU, false, "poids sur CPU : plafond réduit"},
{65536, false, 32768, false, "moitié du contexte"},
{16384, false, 16384 - benchTurns*(benchAppendTokens+benchGenTokens) - benchGenTokens - warm - benchMargin, false, "les tours doivent tenir"},
{8192, false, 0, true, "trop petit"},
{0, false, 0, true, "inconnu"},
} {
got, why := benchDepthFor(c.ctx, c.cpu, warm)
if got != c.want || (why != "") != c.skipped {
t.Errorf("%s : benchDepthFor(%d) = %d %q", c.comments, c.ctx, got, why)
}
if got > 0 && got+benchTurns*(benchAppendTokens+benchGenTokens)+benchGenTokens > c.ctx {
t.Errorf("%s : %d + tours déborde %d", c.comments, got, c.ctx)
}
}
}
func TestBenchKVLabel(t *testing.T) {
for _, c := range []struct {
k, v, label string
quant bool
}{
{"", "", "f16", false},
{"f16", "", "f16", false},
{"bf16", "bf16", "bf16", false},
{"q8_0", "q8_0", "q8_0", true},
{"q8_0", "", "q8_0/f16", true},
{"", "q4_0", "f16/q4_0", true},
} {
label, quant := benchKVLabel(c.k, c.v)
if label != c.label || quant != c.quant {
t.Errorf("benchKVLabel(%q,%q) = %q,%v ; attendu %q,%v", c.k, c.v, label, quant, c.label, c.quant)
}
}
// Le -ctk d'EXTRA_ARGS l'emporte, comme au lancement.
s := benchSetupFrom(map[string]string{"KV_TYPE": "f16", "EXTRA_ARGS": "-ctk q8_0 -ctv q8_0 --n-cpu-moe 20"}, nil)
if s.kv != "q8_0" || !s.kvQuant || !s.cpuPlaced {
t.Errorf("réglages tirés du preset : %+v", s)
}
}
func TestBenchPayload(t *testing.T) {
for _, c := range []struct {
name string
cfg map[string]string
want map[string]any
none []string
}{
{"échantillonnage du preset", map[string]string{"TEMP": "0.6", "TOP_P": "0.95", "TOP_K": "20"},
map[string]any{"temperature": 0.6, "top_p": 0.95, "top_k": 20, "seed": benchSeed}, []string{"chat_template_kwargs", "reasoning_effort"}},
{"sans réglage", map[string]string{},
map[string]any{"temperature": 0.7, "seed": benchSeed}, []string{"top_k", "chat_template_kwargs"}},
{"effort explicite", map[string]string{"REASONING_EFFORT": "high"},
map[string]any{"reasoning_effort": "high"}, nil},
} {
t.Run(c.name, func(t *testing.T) {
testHome(t) // effortResolve lit le modèle de la configuration
p := benchSetupFrom(c.cfg, nil).payload([]Message{{Role: "user", Content: "x"}}, 10, true)
for k, v := range c.want {
if fmt.Sprint(p[k]) != fmt.Sprint(v) {
t.Errorf("%s = %v, attendu %v", k, p[k], v)
}
}
for _, k := range append(c.none, "ignore_eos") {
if _, ok := p[k]; ok {
t.Errorf("%s ne devait pas être envoyé : %v", k, p[k])
}
}
if p["cache_prompt"] != true || p["stream"] != false {
t.Errorf("cache_prompt/stream : %v", p)
}
})
}
}
func TestBenchSlice(t *testing.T) {
text := strings.Repeat("ééé ligne\n", 100)
off := 0
for off < len(text) {
chunk, next := benchSlice(text, off, 37)
if chunk == "" || !utf8.ValidString(chunk) {
t.Fatalf("morceau invalide à %d : %q", off, chunk)
}
if next < len(text) && !strings.HasSuffix(chunk, "\n") {
t.Errorf("morceau coupé hors fin de ligne : %q", chunk)
}
off = next
}
if c, _ := benchSlice(text, len(text), 10); c != "" {
t.Error("corpus épuisé : morceau vide attendu")
}
if got := benchLineStart("ab\ncd", 1); got != 3 {
t.Errorf("benchLineStart = %d", got)
}
}
func TestBenchReuse(t *testing.T) {
full := []benchTurn{{Cached: 1000, Expected: 1004}, {Cached: 3000, Expected: 3000}}
if r, note := benchReuse(full, false); r < 0.99 || note != "" {
t.Errorf("reprise complète : %v %q", r, note)
}
part := []benchTurn{{Cached: 512, Expected: 3000}}
if _, note := benchReuse(part, true); !strings.Contains(note, "hybride") {
t.Errorf("hybride : %q", note)
}
if _, note := benchReuse(part, false); !strings.Contains(note, "gabarit") {
t.Errorf("non hybride : %q", note)
}
if r, note := benchReuse([]benchTurn{{Cached: -1, Expected: 3000}}, false); r != -1 || note == "" {
t.Errorf("inconnu : %v %q", r, note)
}
}
func TestBenchThrashHint(t *testing.T) {
if h := benchThrashHint(100<<20, 256, 0); h != "" {
t.Errorf("100 Mo / 256 jetons : pas d'indication attendue, %q", h)
}
if h := benchThrashHint(3<<30, 768, 60<<30); !strings.Contains(h, "thrash") {
t.Errorf("1 Go / 256 jetons : indication attendue, %q", h)
}
}
// Une mesure enregistrée ne s'affiche que pour la configuration et les cartes
// sur lesquelles elle a été prise ; une mesure d'avant l'empreinte retombe sur
// le nom du modèle.
func TestBenchEmpreinte(t *testing.T) {
cfg := map[string]string{"MODEL": "/models/Q.gguf", "CTX": "32768", "CUDA_VISIBLE_DEVICES": "0,1"}
res := &benchResult{PromptPerSecond: 1, Depth: &benchDepth{Hint: "possible thrash"}}
sb := newSavedBench(res, cfg)
if sb.Fingerprint == "" || sb.Result.Depth.Hint != "" || res.Depth.Hint == "" {
t.Fatalf("enregistrement : %+v (l'indication ne s'enregistre pas, le résultat montré la garde)", sb)
}
preset := map[string]string{"MODEL": "/models/Q.gguf", "CTX": "32768"}
if !benchMatchesPreset(sb, preset, cfg) {
t.Error("même configuration, mêmes cartes : la pastille doit s'afficher")
}
if benchMatchesPreset(sb, map[string]string{"MODEL": "/models/Q.gguf", "CTX": "65536"}, cfg) {
t.Error("contexte différent : pastille masquée attendue")
}
if benchMatchesPreset(sb, preset, map[string]string{"CUDA_VISIBLE_DEVICES": "1,0"}) {
t.Error("cartes dans un autre ordre : pastille masquée attendue")
}
own := map[string]string{"MODEL": "/models/Q.gguf", "CTX": "32768", "CUDA_VISIBLE_DEVICES": "0,1"}
if !benchMatchesPreset(sb, own, map[string]string{"CUDA_VISIBLE_DEVICES": "0"}) {
t.Error("preset qui impose ses cartes : ce sont elles qui comptent")
}
legacy := savedBench{Model: "Q.gguf"}
if !benchMatchesPreset(legacy, preset, cfg) {
t.Error("mesure d'avant l'empreinte : repli sur le nom du modèle")
}
}
// Le lancement par l'interface : POST seulement, 409 pendant une génération,
// puis un seul bench à la fois, qui tient le verrou du chat et s'annule.
func TestBenchHandlers(t *testing.T) {
testHome(t)
f := &fakeBenchEngine{nCtx: 16384}
_, srv := f.start(t)
u, _ := url.Parse(srv.URL)
if err := WriteConfig(map[string]string{"PORT": u.Port()}); err != nil {
t.Fatal(err)
}
started := make(chan struct{})
old := benchRunner
benchRunner = func(ctx context.Context, o benchOpts, p benchProgress) (*benchResult, error) {
p("mesure", 1, 2)
close(started)
<-ctx.Done()
return nil, ctx.Err()
}
t.Cleanup(func() { benchRunner = old })
do := func(method, path, body string) *httptest.ResponseRecorder {
rec := httptest.NewRecorder()
req := httptest.NewRequest(method, path, strings.NewReader(body))
switch path {
case "/api/bench":
handleBench(rec, req)
case "/api/bench/cancel":
handleBenchCancel(rec, req)
default:
handleBenchStatus(rec, req)
}
return rec
}
if rec := do(http.MethodGet, "/api/bench", ""); rec.Code != http.StatusMethodNotAllowed || rec.Header().Get("Allow") != http.MethodPost {
t.Fatalf("GET : %d, Allow=%q", rec.Code, rec.Header().Get("Allow"))
}
conv.mu.Lock()
conv.Generating = true
conv.mu.Unlock()
rec := do(http.MethodPost, "/api/bench", `{"mode":"full"}`)
conv.mu.Lock()
conv.Generating = false
conv.mu.Unlock()
if rec.Code != http.StatusConflict {
t.Fatalf("pendant une génération : %d, attendu 409", rec.Code)
}
if rec := do(http.MethodPost, "/api/bench", `{"mode":"full"}`); rec.Code != http.StatusAccepted {
t.Fatalf("lancement : %d %s", rec.Code, rec.Body)
}
<-started
if rec := do(http.MethodPost, "/api/bench", `{}`); rec.Code != http.StatusConflict {
t.Errorf("second bench : %d, attendu 409", rec.Code)
}
if err := conv.StartTurn("bonjour", nil, Caps{}, 0); err != ErrBusy {
t.Errorf("tour de chat pendant le bench : %v, attendu ErrBusy", err)
}
if err := conv.busyReason(); err != errBenchBusy {
t.Errorf("motif du refus : %v", err)
}
if queued, err := conv.EnqueueOrStart("cid-bench", "x", nil, Caps{}, 0); queued || err == nil {
t.Errorf("message pendant le bench : mis en file=%v err=%v, attendu un refus", queued, err)
}
var st map[string]any
json.Unmarshal(do(http.MethodGet, "/api/bench/status", "").Body.Bytes(), &st)
if st["running"] != true || st["phase"] != "mesure" || st["mode"] != benchModeFull {
t.Errorf("statut en cours : %v", st)
}
if rec := do(http.MethodPost, "/api/bench/cancel", ""); rec.Code != 200 {
t.Fatalf("annulation : %d", rec.Code)
}
deadline := time.Now().Add(3 * time.Second)
for benchRunning() && time.Now().Before(deadline) {
time.Sleep(10 * time.Millisecond)
}
json.Unmarshal(do(http.MethodGet, "/api/bench/status", "").Body.Bytes(), &st)
if st["running"] != false || st["canceled"] != true {
t.Errorf("statut après annulation : %v", st)
}
if conv.isGenerating() {
t.Error("verrou de génération non rendu après le bench")
}
}
// Les clients /v1 reçoivent un 503 à retenter pendant un benchmark.
func TestBenchRefuseV1(t *testing.T) {
benchJob.mu.Lock()
benchJob.running = true
benchJob.mu.Unlock()
t.Cleanup(func() {
benchJob.mu.Lock()
benchJob.running = false
benchJob.mu.Unlock()
})
rec := httptest.NewRecorder()
oaiHandler().ServeHTTP(rec, httptest.NewRequest(http.MethodPost, "/v1/chat/completions", strings.NewReader("{}")))
if rec.Code != http.StatusServiceUnavailable || rec.Header().Get("Retry-After") == "" {
t.Fatalf("pendant le bench : %d, Retry-After=%q", rec.Code, rec.Header().Get("Retry-After"))
}
}
// Un gabarit qui refuse le niveau de raisonnement du preset : le bench rejoue
// une fois avec le niveau accepté, comme le chat, et le retient — au lieu
// d'échouer sur le 500.
func TestBenchNiveauRaisonnementRefuse(t *testing.T) {
f := &fakeBenchEngine{nCtx: 16384, rejectEffort: "high"}
eng, _ := f.start(t)
s := benchSetupFrom(map[string]string{"REASONING_EFFORT": "high"}, nil)
var learned []string
s.learn = func(want, got string) { learned = append(learned, want+">"+got) }
res, err := benchRun(context.Background(), eng, benchOpts{Mode: benchModeQuick}, s, testBenchCorpora(), nil)
if err != nil {
t.Fatalf("refus du niveau non rattrapé : %v", err)
}
if len(f.calls) != 3 || res.PredictedPerSec != 50 {
t.Fatalf("%d requêtes (attendu 3 : refus, préchauffage rejoué, ligne courte)", len(f.calls))
}
for i, p := range f.calls[1:] {
kw, _ := p["chat_template_kwargs"].(map[string]any)
if p["reasoning_effort"] != "xhigh" || kw["reasoning_effort"] != "xhigh" {
t.Errorf("requête %d : niveau %v / %v, attendu xhigh", i+2, p["reasoning_effort"], kw)
}
}
if len(learned) != 1 || learned[0] != "high>xhigh" {
t.Errorf("traduction retenue : %v", learned)
}
// Une autre erreur que ce refus n'est pas rejouée.
f2 := &fakeBenchEngine{nCtx: 16384, failAt: 1}
eng2, _ := f2.start(t)
if _, err := benchRun(context.Background(), eng2, benchOpts{}, benchSetupFrom(map[string]string{"REASONING_EFFORT": "high"}, nil), testBenchCorpora(), nil); err == nil || len(f2.calls) != 1 {
t.Errorf("500 ordinaire : err=%v, %d requêtes", err, len(f2.calls))
}
}
// Un gros ubatch sur un petit contexte : le préchauffage reste sous le quart
// de la fenêtre au lieu de la dépasser.
func TestBenchPrechauffageBorne(t *testing.T) {
f := &fakeBenchEngine{nCtx: 8192}
eng, _ := f.start(t)
s := benchSetupFrom(map[string]string{"UBATCH": "4096"}, nil)
if _, err := benchRun(context.Background(), eng, benchOpts{}, s, testBenchCorpora(), nil); err != nil {
t.Fatal(err)
}
c := f.calls[0]["messages"].([]any)[0].(map[string]any)["content"].(string)
if toks := len(c) / 4; toks > 8192/4+64 {
t.Errorf("préchauffage de %d jetons sur 8192 de contexte", toks)
}
}
// Changer de discussion n'arrête pas le bench et ne rend pas le verrou ; un
// Reset l'arrête et le rend, et la libération tardive du bench ne déclare pas
// libre le tour qui a démarré depuis.
func TestBenchVerrouEtDiscussions(t *testing.T) {
testHome(t)
c := newTestConv()
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
release, err := c.benchLease(cancel)
if err != nil {
t.Fatal(err)
}
c.loadFrom("autre", nil)
if ctx.Err() != nil || !c.isGenerating() || c.busyReason() != errBenchBusy {
t.Fatal("changer de discussion a arrêté le bench ou rendu son verrou")
}
release()
if c.isGenerating() {
t.Fatal("verrou non rendu à la fin du bench, après un changement de discussion")
}
ctx2, cancel2 := context.WithCancel(context.Background())
defer cancel2()
release2, err := c.benchLease(cancel2)
if err != nil {
t.Fatal(err)
}
c.Reset()
if ctx2.Err() == nil || c.isGenerating() {
t.Fatal("Reset doit arrêter le bench et rendre le verrou")
}
c.mu.Lock()
c.Generating = true // un tour démarré depuis
c.mu.Unlock()
release2()
if !c.isGenerating() {
t.Fatal("la libération tardive du bench a déclaré libre le tour suivant")
}
}