Files
Loki/internal/loki/llm_bench_test.go
T
MichaelandClaude Opus 5.5 cf83f5f629 Bench : une mesure honnête à profondeur réelle, en tâche de fond
L'ancien bench ne lisait pas le statut HTTP, inventait un partage 15/85
du temps quand les timings manquaient (et l'enregistrait comme mesuré),
tuilait un petit corpus qu'un brouillon recopiait, et ne disait rien du
prefill à 30k ni du chemin où le cache est repris. Synchrone, il était
coupé par les proxys (60-100 s) pendant que le moteur continuait, et un
simple GET suffisait à le lancer.

- Tâche de fond : POST /api/bench (202, 405 sur GET, 409 si occupé),
  /api/bench/status pour la progression, /api/bench/cancel ; chaque
  requête au moteur porte le contexte annulable.
- Verrou de génération tenu pendant toute la mesure : chat, tâches et
  compaction reçoivent un refus clair ; les clients /v1 un 503 avec
  Retry-After ; /slots occupé (client externe, CLI) refuse aussi.
- Mode rapide par défaut : préchauffage jeté + la ligne courte, sur le
  chat avec gabarit, raisonnement et échantillonnage du preset, seed fixe.
- Mode complet (bouton « bench complet », loki bench --full) : prefill à
  froid à D = min(CTX/2, 32k, ce qui laisse tenir les tours), 16k si des
  poids tournent sur CPU, puis 3 tours user → assistant → user de code
  jamais vu. Reprise du cache lue dans cache_n, jamais supposée ; note
  pour les hybrides (points de contrôle). Pas d'ignore_eos.
- Rien n'est enregistré sans réponses 200 et timings réels, ni pour un
  résultat partiel (budget de 6 min par requête, contexte plein).
- Empreinte enregistrée (configuration + CUDA_VISIBLE_DEVICES + protocole) ;
  les anciennes mesures retombent sur le nom du modèle. Types de cache KV
  et build du moteur affichés, KV quantifié signalé (zone grise).
- Linux : indication « possible thrash » (read_bytes, VmRSS), jamais
  enregistrée.
- Effacement du slot en fin de bench inchangé (engineSideJob, différé :
  il tourne aussi sur erreur et annulation).

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

544 lines
20 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
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
}
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"))
}
}