Files
Loki/internal/loki/backend_external_test.go
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

246 lines
9.2 KiB
Go

package loki
import (
"context"
"encoding/json"
"net/http"
"net/http/httptest"
"strings"
"testing"
)
// Les trois formes d'URL qu'on colle en pratique. Exiger la forme exacte serait
// une source d'échecs muets : la page d'un fournisseur donne tantôt la base,
// tantôt l'URL complète, et se tromper ne produit qu'un 404 illisible.
func TestNormalisationDeLURLDeComplétions(t *testing.T) {
cas := map[string]string{
"https://api.openai.com/v1": "https://api.openai.com/v1/chat/completions",
"https://api.groq.com/openai/v1": "https://api.groq.com/openai/v1/chat/completions",
"https://api.openai.com/v1/chat/completions": "https://api.openai.com/v1/chat/completions",
"https://api.openai.com/v1/": "https://api.openai.com/v1/chat/completions",
"http://192.168.1.20:8080": "http://192.168.1.20:8080/v1/chat/completions",
"": "",
}
for in, want := range cas {
if got := completionsURL(in); got != want {
t.Errorf("completionsURL(%q) = %q, attendu %q", in, got, want)
}
}
}
// Un preset externe actif détourne les complétions ; sans lui, on reste sur le
// llama-server local avec la clé LOCALE.
func TestEndpointSuitLePresetActif(t *testing.T) {
testHome(t)
if err := SetConfigKey("PORT", "9999"); err != nil {
t.Fatal(err)
}
ep := resolveChatEndpoint()
if ep.External {
t.Fatal("aucun preset externe : l'endpoint doit rester local")
}
if ep.URL != "http://localhost:9999/v1/chat/completions" {
t.Fatalf("URL locale = %q", ep.URL)
}
if ep.Model != "loki" {
t.Fatalf("modèle local = %q, attendu loki", ep.Model)
}
if err := WriteConfig(parseEnv(externalPresetContent(
"https://api.groq.com/openai/v1", "llama-3.3-70b", "sk-secret", "65536", false))); err != nil {
t.Fatal(err)
}
ep = resolveChatEndpoint()
if !ep.External {
t.Fatal("EXTERNAL=1 non reconnu")
}
if ep.URL != "https://api.groq.com/openai/v1/chat/completions" {
t.Fatalf("URL externe = %q", ep.URL)
}
if ep.Model != "llama-3.3-70b" || ep.Key != "sk-secret" {
t.Fatalf("modèle/clé externes = %q / %q", ep.Model, ep.Key)
}
// CTX passe par la clé standard : c'est elle qui pilote la jauge de contexte
// et le seuil de compaction, exactement comme pour un preset local.
if got := ReadConfig()["CTX"]; got != "65536" {
t.Fatalf("CTX = %q, attendu 65536", got)
}
}
// La clé du serveur LOCAL ne doit jamais partir chez un tiers : ce serait fuiter
// un secret en même temps qu'un 401 garanti.
func TestLaCleLocaleNePartJamaisChezUnTiers(t *testing.T) {
testHome(t)
if err := WriteConfig(parseEnv(externalPresetContent(
"https://api.openai.com/v1", "gpt-4o-mini", "", "", false))); err != nil {
t.Fatal(err)
}
// Une clé d'API locale existe et n'a rien à faire dans la requête sortante.
if err := SetConfigKey("API_KEY", "sk-loki-locale"); err != nil {
t.Fatal(err)
}
ep := resolveChatEndpoint()
if ep.Key != "" {
t.Fatalf("clé envoyée à l'API externe = %q — le preset n'en déclare aucune", ep.Key)
}
got := map[string]string{}
ep.auth(func(k, v string) { got[k] = v })
if len(got) != 0 {
t.Fatalf("en-tête posé sans clé de preset : %v", got)
}
}
// Un preset externe se reconnaît, un preset local ne se prend pas pour un externe.
func TestReconnaissanceDunPresetExterne(t *testing.T) {
if !isExternalConfig(map[string]string{extKeyFlag: "1"}) {
t.Error("EXTERNAL=1 non reconnu")
}
for _, v := range []string{"", "0", "off", "false"} {
if isExternalConfig(map[string]string{extKeyFlag: v}) {
t.Errorf("EXTERNAL=%q pris pour un preset externe", v)
}
}
if isExternalConfig(parseEnv("MODEL=\"m.gguf\"\nCTX=4096\n")) {
t.Error("un preset local est pris pour un externe")
}
}
// La route de test rend un message LISIBLE plutôt que le JSON brut : c'est tout
// l'intérêt de tester avant d'enregistrer.
func TestRouteDeTestRemonteLErreurDeLAPI(t *testing.T) {
testHome(t)
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.Header.Get("Authorization") != "Bearer sk-bonne" {
w.WriteHeader(401)
_, _ = w.Write([]byte(`{"error":{"message":"Incorrect API key provided"}}`))
return
}
sendJSON(w, 200, map[string]any{"choices": []any{}})
}))
t.Cleanup(srv.Close)
post := func(body string) map[string]any {
w := httptest.NewRecorder()
handlePresetExternalTest(w, httptest.NewRequest("POST", "/api/preset/external/test", strings.NewReader(body)))
var out map[string]any
_ = json.Unmarshal(w.Body.Bytes(), &out)
return out
}
out := post(`{"url":"` + srv.URL + `","model":"m","key":"sk-mauvaise","keyTouched":true}`)
if out["ok"] == true {
t.Fatal("clé refusée annoncée comme valide")
}
if msg, _ := out["error"].(string); msg != "Incorrect API key provided" {
t.Fatalf("message d'erreur = %q, attendu celui de l'API et non le JSON brut", msg)
}
if out = post(`{"url":"` + srv.URL + `","model":"m","key":"sk-bonne","keyTouched":true}`); out["ok"] != true {
t.Fatalf("clé valide refusée : %v", out)
}
}
// Rééditer un preset sans toucher au champ clé ne doit PAS l'effacer : la modale
// ne la reçoit jamais en clair, donc un champ vide n'est pas une demande
// d'effacement.
func TestEditionSansToucherALaCleLaConserve(t *testing.T) {
testHome(t)
id, err := SavePreset("", "Groq", externalPresetContent("https://api.groq.com/openai/v1", "llama", "sk-gardee", "", false))
if err != nil {
t.Fatal(err)
}
w := httptest.NewRecorder()
handlePresetExternalSave(w, httptest.NewRequest("POST", "/api/preset/external/save",
strings.NewReader(`{"id":"`+id+`","name":"Groq","url":"https://api.groq.com/openai/v1","model":"llama-3.3","key":""}`)))
if w.Code != 200 {
t.Fatalf("code %d : %s", w.Code, w.Body.String())
}
content, err := ReadPreset(id)
if err != nil {
t.Fatal(err)
}
cfg := parseEnv(content)
if cfg[extKeyToken] != "sk-gardee" {
t.Fatalf("clé = %q — effacée par une édition qui n'y touchait pas", cfg[extKeyToken])
}
if cfg[extKeyModel] != "llama-3.3" {
t.Fatalf("modèle non mis à jour : %q", cfg[extKeyModel])
}
// Et la clé ne ressort jamais en clair par la route de lecture.
w = httptest.NewRecorder()
handlePresetExternal(w, httptest.NewRequest("GET", "/api/preset/external?id="+id, nil))
if strings.Contains(w.Body.String(), "sk-gardee") {
t.Fatalf("la clé est renvoyée en clair : %s", w.Body.String())
}
var out map[string]any
_ = json.Unmarshal(w.Body.Bytes(), &out)
if out["hasKey"] != true {
t.Fatal("hasKey doit signaler la présence d'une clé")
}
}
// Un preset externe demande explicitement d'écraser la clé par une vide.
func TestEffacementExpliciteDeLaCle(t *testing.T) {
testHome(t)
id, err := SavePreset("", "Local distant", externalPresetContent("http://192.168.1.20:8080", "qwen", "sk-a-jeter", "", false))
if err != nil {
t.Fatal(err)
}
w := httptest.NewRecorder()
handlePresetExternalSave(w, httptest.NewRequest("POST", "/api/preset/external/save",
strings.NewReader(`{"id":"`+id+`","name":"Local distant","url":"http://192.168.1.20:8080","model":"qwen","key":"","keyTouched":true}`)))
if w.Code != 200 {
t.Fatalf("code %d : %s", w.Code, w.Body.String())
}
content, _ := ReadPreset(id)
if k := parseEnv(content)[extKeyToken]; k != "" {
t.Fatalf("clé = %q, attendu effacée (keyTouched avec champ vide)", k)
}
}
// Le résumé de compactage n'envoie pas chat_template_kwargs (propre à
// llama.cpp) à une API distante : une API stricte répond 400 et la compaction
// échouait à chaque fois.
func TestCompactageExterneSansKwargsLlamaCpp(t *testing.T) {
testHome(t)
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
var body map[string]any
_ = json.NewDecoder(r.Body).Decode(&body)
if _, ok := body["chat_template_kwargs"]; ok {
w.WriteHeader(400)
_, _ = w.Write([]byte(`{"error":{"message":"Unrecognized request argument supplied: chat_template_kwargs"}}`))
return
}
sendJSON(w, 200, map[string]any{"choices": []any{map[string]any{"message": map[string]any{"content": "- résumé"}}}})
}))
t.Cleanup(srv.Close)
if err := WriteConfig(parseEnv(externalPresetContent(srv.URL, "gpt-4o-mini", "", "", false))); err != nil {
t.Fatal(err)
}
if _, err := summarizeTranscript(context.Background(), "user: bonjour\nassistant: salut"); err != nil {
t.Fatalf("compactage refusé par l'API distante : %v", err)
}
}
// Vision et benchmark d'un preset externe : la case du preset décide, et le
// bench (qui mesure le moteur local) refuse de tourner.
func TestPresetExterneVisionEtBench(t *testing.T) {
testHome(t)
if err := WriteConfig(parseEnv(externalPresetContent("https://api.openai.com/v1", "gpt-4o", "", "", true))); err != nil {
t.Fatal(err)
}
if !visionEnabled() || !engineSeesImages() {
t.Error("preset externe déclaré multimodal : la vision devrait être active")
}
if _, err := runBench(context.Background(), benchOpts{Prompt: 10, Predict: 10}, nil); err == nil {
t.Error("benchmark lancé sur un preset externe")
}
if got := chatModelName(); got != "gpt-4o" {
t.Errorf("badge de modèle = %q, attendu le modèle distant", got)
}
if err := WriteConfig(parseEnv(externalPresetContent("https://api.openai.com/v1", "gpt-4o", "", "", false))); err != nil {
t.Fatal(err)
}
if visionEnabled() {
t.Error("preset externe texte seul : la vision devrait être coupée")
}
}