Files
Loki/internal/loki/web_engine_reco.go
T
MichaelandClaude Opus 5.5 5d37bef0ce Moteur : une bascule plus récente périme la vérification du rendu, plan et retour en POST
La vérification du rendu du gabarit après une mise à jour attend jusqu'à
vingt minutes que le nouveau moteur réponde. Un retour à la version
précédente, une autre version installée ou une seconde mise à jour pendant
cette attente la laissaient courir : elle comparait alors l'ancien moteur à
celui d'une autre bascule, ou écrasait le relevé de la suivante.

- Chaque bascule ouvre une génération ; une vérification périmée s'arrête
  sans rien ranger, et son relevé n'écrase plus le suivant. Retour et
  « utiliser » effacent le relevé, qui parlerait d'un moteur arrêté.
- /api/engine/plan (registre) et /api/engine/rollback (configuration et
  redémarrage) refusent tout autre verbe que POST — l'interface n'utilise
  que POST.
- Tests : vérification annulée en attente, relevé périmé ignoré, GET refusé.

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

529 lines
20 KiB
Go

// web_engine_reco.go — « version recommandée » du panneau Moteur, retour à la
// version précédente, et garde du rendu des gabarits à travers une mise à jour.
//
// POST /api/engine/plan → choisit la version à installer (sur clic) et
// dit si le prompt rendu va changer
// POST /api/engine/rollback → revient au moteur d'avant la dernière bascule
//
// RIEN D'AUTOMATIQUE. Aucune de ces routes n'est appelée au démarrage ni par
// l'état du panneau : le registre n'est interrogé que sur un clic, et rien ne
// s'installe sans la confirmation qui suit. L'encart « version recommandée »
// lui-même se décide sans réseau, sur le build du moteur courant.
package loki
import (
"context"
"encoding/json"
"fmt"
"net/http"
"os"
"os/exec"
"path/filepath"
"strings"
"sync"
"time"
)
const (
// enginePreserveDefaultBuild : premier build où llama-server active
// preserve_reasoning quand ni --reasoning-preserve ni
// --no-reasoning-preserve n'est passé (PR #28174).
enginePreserveDefaultBuild = 10763
// engineQwen4MTPBuild : premier build qui sait la tête MTP de qwen4exp
// (PR #29761). Cité dans l'encart, jamais activé par Loki : SPEC reste off
// tant que l'utilisateur ne le pose pas.
engineQwen4MTPBuild = 11331
)
// ---------------------------------------------------------------------------
// Choix de la version recommandée
// ---------------------------------------------------------------------------
// engineRecommendedTag : le tag figé le plus récent publié pour la variante,
// à condition qu'il atteigne engineMinRecommended et qu'il existe vraiment.
//
// Le registre n'offre pas de « plus récent au-dessus de N » ; lister ses
// milliers de tags pour le trouver serait lent et fragile. Le plus récent
// au-dessus du minimum, c'est le dernier publié — l'annotation du tag mouvant
// le nomme (engineOCILatest) — et on vérifie que son tag figé répond avant de
// le proposer : c'est lui qu'on installera, pas le tag mouvant, pour que la
// version confirmée soit la version installée.
func engineRecommendedTag(variant string) (int, string, error) {
build, tag, err := engineOCILatest(variant)
if err != nil {
return 0, "", fmt.Errorf("registre injoignable, aucune version choisie : %w", err)
}
if build == 0 {
// Sans numéro, impossible de promettre « au moins b10864 » : on ne
// propose pas un tag mouvant dont on ne sait pas ce qu'il contient.
return 0, "", fmt.Errorf("le registre n'annonce pas le build du tag « %s » : impossible de vérifier qu'il atteint b%d", variant, engineMinRecommended)
}
if build < engineMinRecommended {
return 0, "", fmt.Errorf("dernier build publié pour %s : b%d, antérieur à la version recommandée b%d", variant, build, engineMinRecommended)
}
if _, _, err := ociResolve(ociEngineRepo, tag); err != nil {
return 0, "", fmt.Errorf("%s annoncé mais introuvable sur le registre : %w", tag, err)
}
return build, tag, nil
}
// engineRecommendation : l'encart du panneau, ou nil. Sans réseau : seulement
// le build de confiance du moteur courant (engineBuildTrust — jamais pour un
// compilé ou un fork), comparé au minimum recommandé.
func engineRecommendation(trusted int, supported bool) map[string]any {
if trusted <= 0 || trusted >= engineMinRecommended || !supported {
return nil
}
return map[string]any{
"current": trusted,
"min": engineMinRecommended,
"gains": []string{
fmt.Sprintf("points de reprise des modèles hybrides (Qwen3.5/3.6, Qwen3-Next…) gardés : plus de ~8 k jetons recalculés à chaque reprise (b%d)", engineMinRecommended),
fmt.Sprintf("MTP rapide (graphes CUDA) à partir de b%d", specAutoMinBuild),
fmt.Sprintf("MTP de qwen4exp à partir de b%d", engineQwen4MTPBuild),
},
}
}
// ---------------------------------------------------------------------------
// Rendu du raisonnement : le défaut qui change à b10763
// ---------------------------------------------------------------------------
// preserveIn : ce qu'il faut savoir pour dire si la bascule change le prompt.
type preserveIn struct {
External bool // preset externe : le gabarit n'est pas rendu par ce moteur
Current int // build de confiance du moteur courant (0 = inconnu)
Target int // build visé (0 = inconnu)
Key string // REASONING_PRESERVE de la configuration active
Overridden string // EXTRA_ARGS ou LLAMA_ARG_* qui règlent déjà la question
Template string // gabarit chargé (/props)
TplKnown bool
Preserves tplTri // la sonde, sur le moteur courant
}
// preserveRisk : le verdict, pour l'avertissement et la case à cocher.
type preserveRisk struct {
Risk string `json:"risk"` // "yes", "no" ou "unknown"
Why string `json:"why"`
// Suggest : cocher d'office REASONING_PRESERVE=off. Seulement quand le
// changement est établi : sur un gabarit qui garde de lui-même la réflexion
// (Qwen3.8), « off » changerait à son tour le rendu.
Suggest bool `json:"suggest"`
}
// tplPreserveVars : les variables que llama-server pose dans le contexte du
// gabarit avec preserve_reasoning (caps_apply_preserve_reasoning). Un gabarit
// qui n'en lit aucune rend la même chose sur les deux moteurs.
var tplPreserveVars = []string{"preserve_thinking", "clear_thinking", "truncate_history_thinking", "preserve_reasoning"}
// reasoningPreserveRisk : la bascule vers le build visé change-t-elle le prompt
// rendu ? Fonction pure.
func reasoningPreserveRisk(in preserveIn) preserveRisk {
no := func(why string) preserveRisk { return preserveRisk{Risk: "no", Why: why} }
switch {
case in.External:
return no("preset externe : son gabarit n'est pas rendu par ce moteur")
case in.Target > 0 && in.Target < enginePreserveDefaultBuild:
return no(fmt.Sprintf("b%d n'a pas encore preserve_reasoning par défaut (b%d)", in.Target, enginePreserveDefaultBuild))
case in.Current >= enginePreserveDefaultBuild:
return no(fmt.Sprintf("le moteur courant (b%d) a déjà preserve_reasoning par défaut", in.Current))
case in.Key != "":
return no("REASONING_PRESERVE=" + in.Key + " est posée : le rendu ne dépend pas du défaut du moteur")
case in.Overridden != "":
return no(in.Overridden + " règle déjà preserve_reasoning")
case !in.TplKnown:
return preserveRisk{Risk: "unknown", Why: "moteur arrêté ou gabarit illisible : impossible de vérifier. " +
"À partir de b" + fmt.Sprint(enginePreserveDefaultBuild) + ", un gabarit de type Qwen3.6 rend la réflexion des tours passés, " +
"vide puisque Loki ne la renvoie pas (sans REASONING_ECHO) ; REASONING_PRESERVE=off garde le rendu actuel. " +
"À l'inverse, sur un gabarit qui la garde déjà (Qwen3.8), off changerait le rendu."}
}
tpl := false
for _, v := range tplPreserveVars {
if strings.Contains(in.Template, v) {
tpl = true
break
}
}
switch {
case !tpl:
return no("le gabarit chargé ne lit pas preserve_thinking / clear_thinking : rendu inchangé")
case in.Preserves == tplYes:
return no("le gabarit garde déjà la réflexion des tours passés : rendu inchangé (ne pas poser off)")
case in.Preserves == tplNo:
return preserveRisk{Risk: "yes", Suggest: true, Why: fmt.Sprintf("le gabarit chargé retire la réflexion des tours passés ; "+
"à partir de b%d le moteur la lui fait garder, et sans REASONING_ECHO Loki ne la renvoie pas : chaque tour passé "+
"serait rendu avec un bloc de réflexion vide. REASONING_PRESERVE=off garde le rendu actuel.", enginePreserveDefaultBuild)}
}
return preserveRisk{Risk: "unknown", Why: "le gabarit lit preserve_thinking mais la sonde n'a pas conclu : " +
"REASONING_PRESERVE=off garde le rendu actuel s'il retire la réflexion des tours passés"}
}
// preserveOverridden : EXTRA_ARGS ou variable d'environnement qui fixent déjà
// preserve_reasoning (mêmes gardes que reasoningPreserveArgs). Vide = aucune.
func preserveOverridden(cfg map[string]string) string {
extra := splitArgs(cfg["EXTRA_ARGS"])
switch {
case hasAnyFlag(extra, "--reasoning-preserve", "--no-reasoning-preserve"):
return "EXTRA_ARGS"
case strings.Contains(strings.Join(flagValues(extra, "--chat-template-kwargs"), " "), "preserve_reasoning"):
return "--chat-template-kwargs"
case strings.TrimSpace(os.Getenv("LLAMA_ARG_REASONING_PRESERVE")) != "":
return "LLAMA_ARG_REASONING_PRESERVE"
case strings.Contains(os.Getenv("LLAMA_ARG_CHAT_TEMPLATE_KWARGS"), "preserve_reasoning"):
return "LLAMA_ARG_CHAT_TEMPLATE_KWARGS"
}
return ""
}
// enginePreserveInputs : les lectures de reasoningPreserveRisk. Le moteur
// courant n'est interrogé qu'en lecture (/health, /props, /apply-template) :
// rien n'y touche au cache de la conversation.
func enginePreserveInputs(ctx context.Context, target int) preserveIn {
cfg := ReadConfig()
in := preserveIn{
External: isExternalConfig(cfg),
Current: engineBuildTrusted(currentEngineBin()),
Target: target,
Key: strings.TrimSpace(cfg["REASONING_PRESERVE"]),
Overridden: preserveOverridden(cfg),
Preserves: tplUnknown,
}
if in.External || in.Key != "" || in.Overridden != "" || (target > 0 && target < enginePreserveDefaultBuild) {
return in // verdict déjà acquis : pas la peine d'interroger le moteur
}
p := tplLocalProber()
if !p.healthy(ctx) {
return in
}
props, err := p.props(ctx)
if err != nil {
return in
}
in.Template, in.TplKnown = props.ChatTemplate, props.ChatTemplate != ""
in.Preserves = tplProbeEnsure(ctx, tplLastShape()).PreservesHistory
return in
}
// engineHelpHas : l'aide du binaire mentionne-t-elle ce drapeau ? Lancé avec
// ses bibliothèques à côté, comme le test à blanc : un moteur téléchargé n'a
// pas encore servi, binHelp ne le connaît pas.
func engineHelpHas(bin, flag string) bool {
ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second)
defer cancel()
cmd := hideCmd(exec.CommandContext(ctx, bin, "--help"))
cmd.WaitDelay = 2 * time.Second
cmd.Env = libraryPathEnv(filepath.Dir(bin))
out, _ := cmd.CombinedOutput()
return strings.Contains(string(out), flag)
}
// enginePreserveOff pose REASONING_PRESERVE=off, choisi par l'utilisateur
// avant la bascule. Dans le preset actif s'il y en a un — sinon la prochaine
// bascule de preset l'effacerait sans un mot —, dans la configuration sinon.
// Rien si la clé est déjà posée : c'est un choix antérieur, on ne le contredit
// pas.
func enginePreserveOff(newBin string) (string, error) {
return enginePreserveOffIf(func() bool { return engineHelpHas(newBin, "--no-reasoning-preserve") })
}
// enginePreserveOffIf : le corps d'enginePreserveOff, l'aide du nouveau moteur
// passée en fonction (les tests n'ont pas de llama-server à lancer).
func enginePreserveOffIf(knowsFlag func() bool) (string, error) {
if v := strings.TrimSpace(ReadConfig()["REASONING_PRESERVE"]); v != "" {
return "REASONING_PRESERVE=" + v + " déjà posée : inchangée", nil
}
if !knowsFlag() {
return "", fmt.Errorf("le nouveau moteur ne connaît pas --no-reasoning-preserve")
}
if err := tuneGuard(); err != nil {
return "", err
}
if p, content, err := tuneActivePreset(); err == nil {
if _, _, err := SavePresetApplying(p.ID, p.Name, presetSetKey(content, "REASONING_PRESERVE", "off")); err != nil {
return "", err
}
return "REASONING_PRESERVE=off posée dans le preset « " + p.Name + " »", nil
}
if err := SetConfigKey("REASONING_PRESERVE", "off"); err != nil {
return "", err
}
return "REASONING_PRESERVE=off posée dans la configuration active (aucun preset ne lui correspond)", nil
}
// ---------------------------------------------------------------------------
// Vérification du rendu après la bascule
// ---------------------------------------------------------------------------
// engineRenderCheck : le dernier relevé avant/après une bascule de moteur.
type engineRenderCheck struct {
Status string `json:"status"` // pending, same, changed, unknown
Tag string `json:"tag"`
Detail string `json:"detail,omitempty"`
At time.Time `json:"at"`
}
var (
engineRenderMu sync.Mutex
engineRenderLast *engineRenderCheck
// engineRenderGen : la vérification en cours. Une bascule plus récente
// (autre mise à jour, retour à la version précédente, autre version
// installée) la périme : une vérification d'avant, encore à attendre son
// moteur, ne doit ni écraser le relevé suivant ni comparer l'ancien
// moteur au moteur d'une autre bascule.
engineRenderGen uint64
// engineRenderWait : délai laissé au nouveau moteur pour charger le modèle
// (un gros MoE en mmap peut demander plusieurs minutes). Variable pour les
// tests.
engineRenderWait = 20 * time.Minute
engineRenderPoll = 5 * time.Second
)
// engineRenderBegin ouvre une vérification : celles d'avant sont périmées.
func engineRenderBegin() uint64 {
engineRenderMu.Lock()
defer engineRenderMu.Unlock()
engineRenderGen++
return engineRenderGen
}
// engineRenderCancel : une bascule sans vérification (retour à la version
// précédente, autre version installée) périme la vérification en cours et
// efface son relevé, qui parlerait d'un moteur qui ne tourne plus.
func engineRenderCancel() {
engineRenderMu.Lock()
defer engineRenderMu.Unlock()
engineRenderGen++
engineRenderLast = nil
}
// engineRenderCurrent : la vérification gen est-elle encore la dernière ?
func engineRenderCurrent(gen uint64) bool {
engineRenderMu.Lock()
defer engineRenderMu.Unlock()
return gen == engineRenderGen
}
// engineRenderSet range le relevé de la vérification gen — rien si une
// bascule plus récente l'a périmée.
func engineRenderSet(gen uint64, c engineRenderCheck) {
c.At = time.Now()
engineRenderMu.Lock()
if gen != engineRenderGen {
engineRenderMu.Unlock()
return
}
engineRenderLast = &c
engineRenderMu.Unlock()
if c.Status != "pending" {
line := "[moteur] rendu du gabarit après " + c.Tag + " : " + c.Status
if c.Detail != "" {
line += " — " + c.Detail
}
fmt.Fprintln(os.Stderr, line)
}
}
func engineRenderSnapshot() *engineRenderCheck {
engineRenderMu.Lock()
defer engineRenderMu.Unlock()
if engineRenderLast == nil {
return nil
}
c := *engineRenderLast
return &c
}
// engineRenderRef : le rendu relevé sur le moteur qui tourne encore, la forme
// rejouée et le build qu'il annonce (/props).
type engineRenderRef struct {
text string
shape tplShape
build string
}
// engineRenderBefore relève le rendu sur le moteur qui tourne encore. ok=false
// : moteur arrêté, preset externe ou rendu impossible — pas de comparaison.
func engineRenderBefore(ctx context.Context) (engineRenderRef, bool) {
ref := engineRenderRef{shape: tplLastShape()}
if externalActive() {
return ref, false
}
p := tplLocalProber()
if !p.healthy(ctx) {
return ref, false
}
if props, err := p.props(ctx); err == nil {
ref.build = props.BuildInfo
}
fp, err := tplRenderPrint(ctx, p, ref.shape)
ref.text = fp
return ref, err == nil
}
// engineRenderAfter attend le nouveau moteur (modèle chargé) et compare. En
// tâche de fond : le job de mise à jour est fini, la bascule faite ; ceci ne
// fait que signaler.
//
// « Prêt » veut dire /health à 200 ET un build différent de celui relevé
// avant : un ancien moteur qui aurait survécu à l'arrêt répondrait aussi, et
// comparer l'ancien à lui-même conclurait à tort « identique ».
func engineRenderAfter(ref engineRenderRef, tag string) {
gen := engineRenderBegin()
engineRenderSet(gen, engineRenderCheck{Status: "pending", Tag: tag})
ctx, cancel := context.WithTimeout(context.Background(), engineRenderWait)
defer cancel()
p := tplLocalProber()
ready := func() bool {
if !p.healthy(ctx) {
return false
}
if ref.build == "" {
return true
}
props, err := p.props(ctx)
return err == nil && props.BuildInfo != ref.build
}
for !ready() {
if !engineRenderCurrent(gen) {
return // bascule plus récente : plus rien à comparer
}
select {
case <-ctx.Done():
engineRenderSet(gen, engineRenderCheck{Status: "unknown", Tag: tag, Detail: "le nouveau moteur n'a pas répondu à temps : rendu non vérifié"})
return
case <-time.After(engineRenderPoll):
}
}
after, err := tplRenderPrint(ctx, p, ref.shape)
if err != nil {
engineRenderSet(gen, engineRenderCheck{Status: "unknown", Tag: tag, Detail: "rendu impossible sur le nouveau moteur : " + err.Error()})
return
}
if same, d := tplRenderDiff(ref.text, after); same {
engineRenderSet(gen, engineRenderCheck{Status: "same", Tag: tag})
} else {
engineRenderSet(gen, engineRenderCheck{Status: "changed", Tag: tag, Detail: d})
}
}
// ---------------------------------------------------------------------------
// Version précédente
// ---------------------------------------------------------------------------
// enginePrevFile : le moteur d'avant la dernière bascule faite par le panneau.
// Un fichier à part, pas une clé de configuration : un preset n'a rien à en
// dire, et il ne doit pas changer l'empreinte de la configuration active.
func enginePrevFile() string { return filepath.Join(engineDir(), "previous-bin") }
// engineRememberPrevious note le moteur qu'on quitte. Best-effort : sans ce
// fichier, le bouton « revenir » n'apparaît simplement pas.
func engineRememberPrevious(left, next string) {
if left == "" || samePath(left, next) {
return
}
_ = os.MkdirAll(engineDir(), 0o755)
_ = os.WriteFile(enginePrevFile(), []byte(left+"\n"), 0o644)
}
// enginePrevious : le moteur précédent s'il est encore là et n'est pas celui
// qui tourne. Le ménage d'après mise à jour le garde (engineKeepAfterUpdate) ;
// une suppression à la main le fait disparaître, et le bouton avec.
func enginePrevious(cur string) map[string]any {
b, err := os.ReadFile(enginePrevFile())
if err != nil {
return nil
}
bin := strings.TrimSpace(string(b))
if bin == "" || !isFile(bin) || samePath(bin, cur) {
return nil
}
out := map[string]any{"bin": bin, "source": engineSource(bin)}
if tag := engineTagOf(bin); tag != "" {
out["tag"] = tag
out["build"] = engineTagBuild(tag)
} else {
out["build"] = engineBuildCached(bin)
}
return out
}
// handleEnginePlan : sur clic, la version qu'on installerait et ce qu'elle
// changerait au prompt. {recommended:true} : la plus récente ≥ b10864
// réellement publiée ; sinon la dernière publiée. Rien n'est téléchargé.
func handleEnginePlan(w http.ResponseWriter, r *http.Request) {
// Interroge le registre : POST seulement, comme le clic qui l'appelle.
if r.Method != http.MethodPost {
sendJSON(w, http.StatusMethodNotAllowed, map[string]any{"ok": false, "error": "POST requis"})
return
}
if !engineOCISupported() {
sendJSON(w, 200, map[string]any{"ok": false, "error": engineUnsupportedWhy()})
return
}
var req struct {
Recommended bool `json:"recommended"`
}
_ = json.NewDecoder(r.Body).Decode(&req)
variant := engineVariant()
var (
build int
tag string
err error
)
if req.Recommended {
build, tag, err = engineRecommendedTag(variant)
} else {
build, tag, err = engineOCILatest(variant)
}
if err != nil {
sendJSON(w, 200, map[string]any{"ok": false, "error": err.Error()})
return
}
cur := currentEngineBin()
curBuild, _ := engineBuildOf(cur)
ctx, cancel := context.WithTimeout(r.Context(), 20*time.Second)
defer cancel()
sendJSON(w, 200, map[string]any{
"ok": true,
"tag": tag,
"build": build,
"current": curBuild,
"uptodate": build > 0 && curBuild == build,
"preserve": reasoningPreserveRisk(enginePreserveInputs(ctx, build)),
// Ce qui restera pour revenir en arrière : la version téléchargée qui
// tourne (gardée par le ménage), ou le moteur de l'image.
"keeps": engineTagOf(cur),
})
}
// handleEngineRollback revient au moteur d'avant la dernière bascule. Aucun
// réseau. Le moteur quitté devient à son tour « le précédent » : deux clics
// ramènent où l'on était.
func handleEngineRollback(w http.ResponseWriter, r *http.Request) {
// Change la configuration et redémarre le moteur : POST seulement.
if r.Method != http.MethodPost {
sendJSON(w, http.StatusMethodNotAllowed, map[string]any{"ok": false, "error": "POST requis"})
return
}
if tuneDenyHTTP(w) {
return
}
cur := currentEngineBin()
prev := enginePrevious(cur)
if prev == nil {
sendJSON(w, 400, map[string]any{"ok": false, "error": "aucune version précédente conservée"})
return
}
bin := prev["bin"].(string)
if err := SetConfigKey("BIN", bin); err != nil {
sendJSON(w, 500, map[string]any{"ok": false, "error": err.Error()})
return
}
engineRememberPrevious(cur, bin)
engineRenderCancel()
if serviceIsActive() {
_ = serviceAction("restart")
}
sendJSON(w, 200, map[string]any{"ok": true, "bin": bin})
}