Files
Loki/internal/loki/chat_tplprobe_test.go
MichaelandClaude Opus 5.5 c24472b91f Moteur : encart « version recommandée », retour à la version précédente, garde du rendu du raisonnement
Le lot 1 savait dire qu'un moteur officiel antérieur à b10864 évince trop tôt
les points de reprise des hybrides, mais laissait l'utilisateur chercher seul
quelle version prendre. Le panneau Moteur propose maintenant la version
recommandée, sur clic seulement, et garde un chemin de retour sans réseau.

La mise à jour elle-même n'est pas neutre pour le prompt : depuis b10763,
llama-server active preserve_reasoning par défaut, et un gabarit qui retirait
la réflexion des tours passés (Qwen3.6, clear_thinking) la rend alors — vide,
puisque Loki ne la renvoie pas sans REASONING_ECHO. Avant la bascule, Loki lit
le gabarit chargé et le verdict de la sonde ; si le rendu va changer, la
confirmation le dit et propose REASONING_PRESERVE=off (case cochée seulement
quand c'est établi : sur Qwen3.8, off changerait à son tour le rendu).
L'utilisateur choisit. Après la bascule, le rendu de conversations
synthétiques est comparé via /apply-template entre l'ancien et le nouveau
moteur, et le premier écart est signalé.

- encart sans réseau (build de confiance < b10864) : gains cités (points de
  reprise, MTP rapide b11009, MTP qwen4exp b11331), SPEC reste off
- POST /api/engine/plan, sur clic : dernier build publié, vérifié ≥ b10864 et
  tag figé existant ; sans réseau, une erreur et rien d'installé
- POST /api/engine/rollback + fichier engine/previous-bin : la version quittée
  (gardée par le ménage du lot 1) ; le retour la rend « précédente » à son tour
- REASONING_PRESERVE=off posée dans le preset actif, seulement si le nouveau
  moteur connaît --no-reasoning-preserve et que la clé est vide
- comparaison du rendu en tâche de fond, après chargement et changement de
  build_info (un ancien moteur survivant ne fausse pas le verdict)
- aucune clé nouvelle ; rien au démarrage ; aucune mise à jour sans clic

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

492 lines
16 KiB
Go

package loki
import (
"context"
"encoding/json"
"fmt"
"net/http"
"net/http/httptest"
"net/url"
"os"
"regexp"
"strings"
"sync"
"testing"
"time"
)
// Les faux moteurs des autres tests ne répondent pas tous en JSON sur /props :
// la sonde automatique (lancée après chaque complétion du fil principal) y est
// coupée, et appelée explicitement ici.
func TestMain(m *testing.M) {
tplProbeAuto = false
os.Exit(m.Run())
}
func freshTplProbe(t *testing.T) {
t.Helper()
reset := func() {
tplProbeMu.Lock()
tplProbeCache = map[string]tplProbeResult{}
tplProbeLast = nil
tplProbeByShape = map[string]tplProbeResult{}
tplProbeRunning = false
tplProbeTried = time.Time{}
tplProbeLogged = ""
tplProbeShapeLast = nil
tplProbeMu.Unlock()
}
reset()
old := tplProbeBackoff
tplProbeBackoff = time.Millisecond
t.Cleanup(func() { reset(); tplProbeBackoff = old })
}
// fakeTpl : un llama-server réduit à /health, /props et /apply-template, avec
// un gabarit façon ChatML écrit à la main.
//
// qwen3 : le raisonnement n'est rendu qu'après le dernier message utilisateur ;
// keep : il est toujours rendu ;
// noagp : comme keep, mais add_generation_prompt est ignoré (vieux moteur) ;
// drop : reasoning_content n'est jamais rendu.
type fakeTpl struct {
mu sync.Mutex
mode string
tpl string // texte du gabarit annoncé par /props (défaut : mode)
build string // build_info annoncé par /props (défaut : b7000-abc)
key string // Bearer exigé hors /health
applyStatus []int // statuts imposés aux prochains /apply-template (0 = rendu)
applyBody string
paths []string
applies []map[string]any
}
var reCallID = regexp.MustCompile(`^[A-Za-z0-9]{9}$`)
func (f *fakeTpl) render(body map[string]any) (string, int) {
msgs, _ := body["messages"].([]any)
lastUser := -1
for i, m := range msgs {
if m.(map[string]any)["role"] == "user" {
lastUser = i
}
}
var b strings.Builder
if tools, ok := body["tools"].([]any); ok {
fmt.Fprintf(&b, "<tools n=%d>\n", len(tools))
}
for i, raw := range msgs {
m := raw.(map[string]any)
role, _ := m["role"].(string)
b.WriteString("<|im_start|>" + role + "\n")
if r, _ := m["reasoning_content"].(string); r != "" && role == "assistant" && f.mode != "drop" && (f.mode != "qwen3" || i > lastUser) {
b.WriteString("<think>" + r + "</think>")
}
c, _ := m["content"].(string)
b.WriteString(c)
calls, _ := m["tool_calls"].([]any)
for _, rc := range calls {
call := rc.(map[string]any)
id, _ := call["id"].(string)
// Gabarit strict : identifiant de 9 alphanumériques, résultat qui suit.
if !reCallID.MatchString(id) || i+1 >= len(msgs) || msgs[i+1].(map[string]any)["tool_call_id"] != id {
return `{"error":{"code":500,"message":"Jinja Exception: tool call id invalide"}}`, 500
}
fn := call["function"].(map[string]any)
b.WriteString("<tool_call>" + fn["name"].(string) + "</tool_call>")
}
b.WriteString("<|im_end|>\n")
}
if agp, _ := body["add_generation_prompt"].(bool); agp || f.mode == "noagp" {
b.WriteString("<|im_start|>assistant\n")
}
out, _ := json.Marshal(map[string]string{"prompt": b.String()})
return string(out), 200
}
func (f *fakeTpl) handler(w http.ResponseWriter, r *http.Request) {
f.mu.Lock()
defer f.mu.Unlock()
f.paths = append(f.paths, r.URL.Path)
if r.URL.Path == "/health" {
_, _ = w.Write([]byte(`{"status":"ok"}`))
return
}
if f.key != "" && r.Header.Get("Authorization") != "Bearer "+f.key {
w.WriteHeader(http.StatusUnauthorized)
_, _ = w.Write([]byte(`{"error":{"code":401,"message":"Invalid API Key"}}`))
return
}
switch r.URL.Path {
case "/props":
tpl := f.tpl
if tpl == "" {
tpl = f.mode
}
build := f.build
if build == "" {
build = "b7000-abc"
}
fmt.Fprintf(w, `{"build_info":%q,"model_path":"/models/Qwen3-8B-Q8_0.gguf","chat_template":%q}`, build, tpl)
case "/apply-template":
var body map[string]any
_ = json.NewDecoder(r.Body).Decode(&body)
f.applies = append(f.applies, body)
if len(f.applyStatus) > 0 {
s := f.applyStatus[0]
f.applyStatus = f.applyStatus[1:]
if s != 0 {
w.WriteHeader(s)
_, _ = w.Write([]byte(f.applyBody))
return
}
}
out, status := f.render(body)
w.WriteHeader(status)
_, _ = w.Write([]byte(out))
default:
w.WriteHeader(http.StatusNotFound)
}
}
func (f *fakeTpl) start(t *testing.T) {
t.Helper()
srv := httptest.NewServer(http.HandlerFunc(f.handler))
t.Cleanup(srv.Close)
u, _ := url.Parse(srv.URL)
if err := SetConfigKey("PORT", u.Port()); err != nil {
t.Fatal(err)
}
}
func (f *fakeTpl) count(path string) int {
f.mu.Lock()
defer f.mu.Unlock()
n := 0
for _, p := range f.paths {
if p == path {
n++
}
}
return n
}
// Jamais de complétion : avec un seul slot, elle évincerait le cache de la
// conversation que la sonde est censée servir.
func (f *fakeTpl) assertNoCompletion(t *testing.T) {
t.Helper()
f.mu.Lock()
defer f.mu.Unlock()
for _, p := range f.paths {
if strings.Contains(p, "completion") {
t.Fatalf("la sonde a appelé %s", p)
}
}
}
func probeTools() []Tool {
return []Tool{{Type: "function", Function: ToolFunction{Name: "bash", Description: "d", Parameters: map[string]any{"type": "object"}}}}
}
func TestSondeGabaritQwen3(t *testing.T) {
testHome(t)
freshTplProbe(t)
f := &fakeTpl{mode: "qwen3"}
f.start(t)
kw := map[string]any{"enable_thinking": false}
r := tplProbeEnsure(context.Background(), newTplShape(probeTools(), kw, ""))
if r.PreservesHistory != tplNo || r.PrefixStable != tplNo || r.RendersReasoning != tplYes {
t.Fatalf("Qwen3 retire le raisonnement passé : %+v", r)
}
if r.Build != "b7000-abc" || r.Model != "Qwen3-8B-Q8_0.gguf" || r.Tools != 1 || !r.cacheable {
t.Fatalf("métadonnées : %+v", r)
}
f.assertNoCompletion(t)
// Mêmes outils et chat_template_kwargs que la complétion ; jamais
// d'amorce de réponse, sauf le rendu qui vérifie que le moteur l'honore.
gen := 0
for _, a := range f.applies {
if tools, _ := a["tools"].([]any); len(tools) != 1 {
t.Fatalf("outils non transmis : %v", a["tools"])
}
if k, _ := a["chat_template_kwargs"].(map[string]any); k["enable_thinking"] != false {
t.Fatalf("chat_template_kwargs : %v", a["chat_template_kwargs"])
}
if a["add_generation_prompt"] == true {
gen++
}
}
if len(f.applies) != 4 || gen != 1 {
t.Fatalf("%d rendus dont %d avec amorce", len(f.applies), gen)
}
// Même moteur, même modèle, même gabarit, même forme : réponse en cache.
tplProbeEnsure(context.Background(), newTplShape(probeTools(), kw, ""))
if n := f.count("/apply-template"); n != 4 {
t.Fatalf("cache ignoré : %d rendus", n)
}
// Autre forme (raisonnement non coupé) : nouvelle sonde.
tplProbeEnsure(context.Background(), newTplShape(probeTools(), nil, ""))
if n := f.count("/apply-template"); n != 8 {
t.Fatalf("forme différente non resondée : %d rendus", n)
}
// Exposé dans /api/perf/summary.
rec := httptest.NewRecorder()
handlePerfSummary(rec, httptest.NewRequest(http.MethodGet, "/api/perf/summary", nil))
var sum struct {
Template *struct {
PreservesHistory string `json:"preserves_history"`
PrefixStable string `json:"prefix_stable"`
} `json:"template"`
}
if err := json.Unmarshal(rec.Body.Bytes(), &sum); err != nil || sum.Template == nil || sum.Template.PrefixStable != "no" {
t.Fatalf("résumé : %s", rec.Body.String())
}
if strings.Contains(rec.Body.String(), "loki-sonde") {
t.Fatal("un rendu a fuité dans le résumé")
}
}
func TestSondeGabaritConserveLHistorique(t *testing.T) {
testHome(t)
freshTplProbe(t)
f := &fakeTpl{mode: "keep"}
f.start(t)
r := tplProbeEnsure(context.Background(), newTplShape(nil, nil, ""))
if r.PreservesHistory != tplYes || r.PrefixStable != tplYes || r.RendersReasoning != tplYes {
t.Fatalf("gabarit qui garde tout : %+v", r)
}
for _, a := range f.applies {
if _, ok := a["tools"]; ok {
t.Fatal("outils envoyés alors que la complétion n'en avait pas")
}
}
}
// Gabarit (ou moteur) qui ignore reasoning_content partout : « jamais rendu »,
// ce qui suffit à REASONING_ECHO pour ne rien renvoyer d'inutile.
func TestSondeGabaritRaisonnementJamaisRendu(t *testing.T) {
testHome(t)
freshTplProbe(t)
f := &fakeTpl{mode: "drop"}
f.start(t)
r := tplProbeEnsure(context.Background(), newTplShape(probeTools(), nil, ""))
if r.RendersReasoning != tplNo || r.PreservesHistory != tplNo || !strings.Contains(r.Note, "jamais rendu") {
t.Fatalf("raisonnement jamais rendu : %+v", r)
}
if !strings.Contains(tplProbeLine(r), "raisonnement_rendu=no") {
t.Fatalf("journal : %s", tplProbeLine(r))
}
}
// Un moteur qui ignore add_generation_prompt ne doit pas faire conclure
// « instable » : l'amorce rendue dans les deux cas fausserait la comparaison.
func TestSondeGabaritAmorceIgnoree(t *testing.T) {
testHome(t)
freshTplProbe(t)
f := &fakeTpl{mode: "noagp"}
f.start(t)
r := tplProbeEnsure(context.Background(), newTplShape(nil, nil, ""))
if r.PrefixStable != tplUnknown || r.PreservesHistory != tplYes {
t.Fatalf("amorce ignorée : %+v", r)
}
}
// Moteur ancien ou fork sans la route : inconnu, rangé (ça ne changera pas
// avant un autre binaire), et aucun repli sur une complétion.
func TestSondeGabarit404(t *testing.T) {
testHome(t)
freshTplProbe(t)
f := &fakeTpl{mode: "qwen3", applyStatus: []int{404, 404, 404, 404}, applyBody: "Not Found"}
f.start(t)
r := tplProbeEnsure(context.Background(), newTplShape(probeTools(), nil, ""))
if r.PreservesHistory != tplUnknown || r.PrefixStable != tplUnknown || r.RendersReasoning != tplUnknown || !r.cacheable {
t.Fatalf("404 : %+v", r)
}
f.assertNoCompletion(t)
tplProbeEnsure(context.Background(), newTplShape(probeTools(), nil, ""))
if n := f.count("/apply-template"); n != 2 {
t.Fatalf("404 resondé : %d rendus", n)
}
}
// Gabarit qui lève une exception (raise_exception) : inconnu, jamais oui/non.
func TestSondeGabaritExceptionDuGabarit(t *testing.T) {
testHome(t)
freshTplProbe(t)
f := &fakeTpl{mode: "qwen3", applyStatus: []int{500, 500, 500, 500},
applyBody: `{"error":{"code":500,"message":"Jinja Exception: Conversation roles must alternate"}}`}
f.start(t)
r := tplProbeEnsure(context.Background(), newTplShape(probeTools(), nil, ""))
if r.PreservesHistory != tplUnknown || r.PrefixStable != tplUnknown || !strings.Contains(r.Note, "500") {
t.Fatalf("exception du gabarit : %+v", r)
}
f.assertNoCompletion(t)
}
// Moteur protégé par API_KEY : la sonde s'authentifie comme les autres appels
// internes. Sans clé, inconnu — et pas rangé : la clé peut arriver.
func TestSondeGabarit401(t *testing.T) {
testHome(t)
freshTplProbe(t)
f := &fakeTpl{mode: "keep", key: "sk-loki-test"}
f.start(t)
r := tplProbeEnsure(context.Background(), newTplShape(nil, nil, ""))
if r.PreservesHistory != tplUnknown || r.PrefixStable != tplUnknown || !strings.Contains(r.Note, "401") {
t.Fatalf("401 : %+v", r)
}
if err := writeAPIKey("sk-loki-test"); err != nil {
t.Fatal(err)
}
r = tplProbeEnsure(context.Background(), newTplShape(nil, nil, ""))
if r.PreservesHistory != tplYes || r.PrefixStable != tplYes {
t.Fatalf("avec la clé : %+v", r)
}
}
// Modèle en chargement : les 503 sont relancés, sans rien conclure entre-temps.
func TestSondeGabarit503Relance(t *testing.T) {
testHome(t)
freshTplProbe(t)
f := &fakeTpl{mode: "qwen3", applyStatus: []int{503, 503}, applyBody: `{"error":{"code":503,"message":"Loading model"}}`}
f.start(t)
r := tplProbeEnsure(context.Background(), newTplShape(nil, nil, ""))
if r.PreservesHistory != tplNo || r.PrefixStable != tplNo {
t.Fatalf("après 503 : %+v", r)
}
// 503 persistant : inconnu, non rangé.
freshTplProbe(t)
f.mu.Lock()
f.applyStatus = []int{503, 503, 503, 503, 503, 503, 503, 503, 503, 503, 503, 503}
f.mu.Unlock()
r = tplProbeEnsure(context.Background(), newTplShape(nil, nil, ""))
if r.PreservesHistory != tplUnknown || r.cacheable {
t.Fatalf("503 persistant : %+v", r)
}
}
// Preset externe : aucune requête, nulle part, et rien d'affiché.
func TestSondeGabaritPresetExterne(t *testing.T) {
testHome(t)
freshTplProbe(t)
f := &fakeTpl{mode: "keep"}
srv := httptest.NewServer(http.HandlerFunc(f.handler))
t.Cleanup(srv.Close)
if err := WriteConfig(parseEnv(externalPresetContent(srv.URL, "gpt-4o-mini", "", "", false))); err != nil {
t.Fatal(err)
}
u, _ := url.Parse(srv.URL)
if err := SetConfigKey("PORT", u.Port()); err != nil {
t.Fatal(err)
}
r := tplProbeEnsure(context.Background(), newTplShape(nil, nil, ""))
if r.PreservesHistory != tplUnknown || r.PrefixStable != tplUnknown {
t.Fatalf("preset externe : %+v", r)
}
if n := len(f.paths); n != 0 {
t.Fatalf("preset externe sondé : %v", f.paths)
}
if _, ok := tplCapsCurrent(); ok {
t.Fatal("verdict affiché pour un preset externe")
}
}
// Bout en bout : une complétion du fil principal déclenche la sonde en tâche
// de fond, avec les outils et kwargs de CETTE requête, sans rien ajouter à la
// requête de complétion.
func TestSondeGabaritDeclencheeParRunChat(t *testing.T) {
testHome(t)
freshTplProbe(t)
freshPerf(t)
tplProbeAuto = true
t.Cleanup(func() { tplProbeAuto = false })
if err := SetConfigKey("REASONING", "off"); err != nil {
t.Fatal(err)
}
f := &fakeTpl{mode: "qwen3"}
var mu sync.Mutex
var chat map[string]any
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path == "/v1/chat/completions" {
mu.Lock()
_ = json.NewDecoder(r.Body).Decode(&chat)
mu.Unlock()
w.Header().Set("Content-Type", "text/event-stream")
_, _ = w.Write([]byte(sseChunk("ok") +
`data: {"choices":[{"delta":{},"finish_reason":"stop"}],"usage":{"prompt_tokens":50,"completion_tokens":1},"timings":{"prompt_n":50,"cache_n":0}}` + "\n\n" +
"data: [DONE]\n\n"))
return
}
f.handler(w, r)
}))
t.Cleanup(srv.Close)
u, _ := url.Parse(srv.URL)
if err := SetConfigKey("PORT", u.Port()); err != nil {
t.Fatal(err)
}
tools := probeTools()
if _, err := runChatTools(context.Background(), []Message{{Role: "user", Content: "salut"}}, tools, 0.7, Caps{}, func(StreamEvent) bool { return true }); err != nil {
t.Fatal(err)
}
deadline := time.Now().Add(5 * time.Second)
var r tplProbeResult
for {
var ok bool
if r, ok = tplCapsCurrent(); ok || time.Now().After(deadline) {
break
}
time.Sleep(10 * time.Millisecond)
}
if r.PrefixStable != tplNo || r.Tools != 1 || r.Kwargs["enable_thinking"] != false {
t.Fatalf("sonde après complétion : %+v", r)
}
f.mu.Lock()
for _, a := range f.applies {
mu.Lock()
same := fmt.Sprint(a["tools"]) == fmt.Sprint(chat["tools"]) && fmt.Sprint(a["chat_template_kwargs"]) == fmt.Sprint(chat["chat_template_kwargs"])
mu.Unlock()
if !same {
f.mu.Unlock()
t.Fatalf("forme différente de la complétion :\nsonde %v / %v\nchat %v / %v", a["tools"], a["chat_template_kwargs"], chat["tools"], chat["chat_template_kwargs"])
}
}
f.mu.Unlock()
// La sonde ne laisse aucune trace dans la requête de complétion.
mu.Lock()
defer mu.Unlock()
for k := range chat {
if strings.Contains(k, "template") && k != "chat_template_kwargs" || k == "add_generation_prompt" {
t.Fatalf("clé %q ajoutée à la complétion", k)
}
}
}
// Le verdict est rangé par forme de requête : la dernière forme sondée ne
// décide pas pour une autre (NUDGE_IN_TOOL), et une forme jamais sondée n'a
// pas de verdict.
func TestSondeGabaritVerdictParForme(t *testing.T) {
testHome(t)
freshTplProbe(t)
f := &fakeTpl{mode: "qwen3"}
f.start(t)
a := newTplShape(probeTools(), nil, "")
b := newTplShape(nil, nil, "")
if r := tplProbeEnsure(context.Background(), a); r.PrefixStable != tplNo {
t.Fatalf("forme A : %+v", r)
}
f.mu.Lock()
f.mode = "keep"
f.mu.Unlock()
if r := tplProbeEnsure(context.Background(), b); r.PrefixStable != tplYes {
t.Fatalf("forme B : %+v", r)
}
if r, ok := tplCapsCurrent(); !ok || r.PrefixStable != tplYes {
t.Errorf("affichage : la dernière sondée, B : %+v", r)
}
if r, ok := tplCapsFor(a); !ok || r.PrefixStable != tplNo {
t.Errorf("forme A écrasée par B : %+v %v", r, ok)
}
if _, ok := tplCapsFor(newTplShape(probeTools(), map[string]any{"enable_thinking": false}, "")); ok {
t.Error("verdict pour une forme jamais sondée")
}
}