Files
Loki/internal/loki/web_engine.go
T
MichaelandClaude Opus 5.5 dd27b81af0 Moteur : points de reprise des hybrides protégés sur les moteurs anciens, et la version précédente gardée après une mise à jour
Avant b10864 (PR #28302), llama-server effaçait un point de reprise trop
proche du précédent même quand la liste avait de la place : sur un hybride
(Qwen3.5/3.6, Qwen3-Next), une reprise retombait jusqu'à ~8 k jetons en
arrière, recalculés à chaque tour d'outil. Un point de reprise est une copie
exacte de l'état récurrent : rien de ce que voit le modèle ne change, seul le
temps de prefill et la RAM hôte. Aucune mise à jour du moteur ici.

- Build cru seulement pour un moteur officiel (image, téléchargé, précompilé)
  et au-dessus de 5000 : un compilé en clone superficiel (build 1) ou un fork
  n'a ni avis ni drapeau. --version gardé par binaire, taille et date.
- Avis « moteur trop ancien » dans le panneau Moteur et au lancement d'un
  hybride.
- Opt-in CTX_CHECKPOINTS (--ctx-checkpoints) et CKPT_MIN_STEP
  (--checkpoint-min-step, > 0 : 0 laisserait l'éviction FIFO jeter le point du
  début de la conversation). Gardes séparées : -ctxcp/--ctx-checkpoints/
  --swa-checkpoints et -cms/--checkpoint-min-step, plus LLAMA_ARG_*.
- D'office : --checkpoint-min-step 2048 seulement sur un hybride lu dans le
  GGUF, build officiel < 10864, aide qui connaît le drapeau, aucun réglage de
  l'utilisateur, points de reprise actifs, modèle sous 70 % de la RAM et 32
  points sous le quart de la RAM libre. Sinon l'avis seul ; sur un MoE mmappé
  trop gros, conseil CTX_CHECKPOINTS=8 à 16.
- REASONING_PRESERVE=on|off → --reasoning-preserve / --no-reasoning-preserve,
  seulement si la clé est posée et que l'aide le connaît. Rien par défaut :
  Loki ne renvoie pas la réflexion passée, et aucun choix global ne garde le
  rendu de tous les gabarits (Qwen3.6 contre Qwen3.8).
- Mise à jour du moteur : la version téléchargée qui tournait avant n'est plus
  supprimée ; le panneau propose d'y revenir sans réseau.

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

262 lines
9.0 KiB
Go

// web_engine.go — panneau « Moteur de l'image » : voir la version de llama.cpp
// qui tourne, savoir s'il en existe une plus récente, l'installer, et revenir
// en arrière si elle ne convient pas.
//
// GET /api/engine → version courante, versions installées, variante
// POST /api/engine/check → dernier build publié vs celui qui tourne
// POST /api/engine/update → job : téléchargement + test à blanc + bascule
// POST /api/engine/use → bascule vers une version déjà installée
// POST /api/engine/remove → supprime une version installée
//
// Jusqu'ici, mettre à jour llama.cpp imposait de reconstruire l'image de Loki —
// donc un rebuild complet et un redéploiement pour un composant qui publie
// plusieurs versions par jour. Le panneau ne le disait même pas : il annonçait
// « rien à mettre à jour ici ». Ces quatre routes suppriment ce détour.
package loki
import (
"encoding/json"
"fmt"
"net/http"
"os"
"path/filepath"
"strings"
)
// engineUseImage est la valeur de `tag` qui désigne le moteur d'origine, celui
// que l'image Docker fournit dans /app. C'est le retour arrière garanti : il ne
// dépend d'aucun téléchargement et ne peut pas manquer.
const engineUseImage = "image"
// handleEngineStatus décrit le moteur courant et ce qui est installé à côté.
func handleEngineStatus(w http.ResponseWriter, r *http.Request) {
bin := currentEngineBin()
build, commit := engineBuildOf(bin)
imageBin := providedEngineBin()
source := engineSource(bin)
var installed []map[string]any
for _, tag := range engineInstalled() {
p := engineBinFor(tag)
installed = append(installed, map[string]any{
"tag": tag,
"bin": p,
"build": engineTagBuild(tag),
"in_use": samePath(p, bin),
})
}
sendJSON(w, 200, map[string]any{
"supported": engineOCISupported(),
"variant": engineVariant(),
"repo": ociHost() + "/" + ociEngineRepo,
"bin": bin,
"build": build,
"commit": commit,
"source": source,
"image_bin": imageBin,
"dir": engineDir(),
"installed": installed,
"job": lcJobSnapshot(0, false),
// Avis « moteur trop ancien » : seulement pour un build digne de foi
// (voir engineBuildTrust), jamais pour un compilé ou un fork.
"notice": engineBuildNotice(engineBuildTrust(source, build)),
})
}
// handleEngineCheck interroge le registre : quel est le dernier build publié
// pour cette variante, et sommes-nous en retard ? Synchrone, quelques secondes.
func handleEngineCheck(w http.ResponseWriter, r *http.Request) {
if !engineOCISupported() {
sendJSON(w, 200, map[string]any{"ok": false, "error": engineUnsupportedWhy()})
return
}
variant := engineVariant()
build, tag, err := engineOCILatest(variant)
if err != nil {
sendJSON(w, 200, map[string]any{"ok": false, "error": err.Error()})
return
}
cur, _ := engineBuildOf(currentEngineBin())
sendJSON(w, 200, map[string]any{
"ok": true,
"variant": variant,
"latest": build,
"tag": tag,
"current": cur,
// Un build courant inconnu (0) ne prouve pas qu'on est à jour : on
// propose alors la mise à jour plutôt que d'affirmer le contraire.
"update": build == 0 || cur == 0 || build > cur,
})
}
// handleEngineUpdate lance le job de mise à jour. {tag} permet d'épingler une
// version précise ; sans lui, on prend la dernière publiée pour la variante.
func handleEngineUpdate(w http.ResponseWriter, r *http.Request) {
if !engineOCISupported() {
sendJSON(w, 400, map[string]any{"ok": false, "error": engineUnsupportedWhy()})
return
}
var req struct {
Tag string `json:"tag"`
}
_ = json.NewDecoder(r.Body).Decode(&req)
tag := strings.TrimSpace(req.Tag)
if err := startLcJob("engine", func() { engineRunUpdate(tag) }); err != nil {
sendJSON(w, 409, map[string]any{"ok": false, "error": err.Error()})
return
}
sendJSON(w, 200, map[string]any{"ok": true})
}
func engineUnsupportedWhy() string {
return "les images officielles llama.cpp n'existent que pour Linux x86-64 et arm64 ; " +
"sur cette machine, utilise « llama.cpp précompilé » ou « llama.cpp compilé »."
}
// engineRunUpdate : corps du job. Télécharger, ESSAYER, puis seulement basculer.
// L'ordre compte — le seul vrai risque ici est un moteur plus récent que le
// runtime CUDA de l'image, et on le découvre au test à blanc, moteur courant
// encore intact.
func engineRunUpdate(tag string) {
variant := engineVariant()
ancien := currentEngineBin()
if tag == "" {
lcPhase("recherche de la dernière version publiée…")
build, latest, err := engineOCILatest(variant)
if err != nil {
lcFail(err)
return
}
tag = latest
if build > 0 {
if cur, _ := engineBuildOf(currentEngineBin()); cur == build {
lcDone(fmt.Sprintf("déjà à jour (build %d)", build))
return
}
}
}
lcAppend("variante : " + variant + " — version visée : " + tag)
bin, err := engineOCIInstall(tag, lcAppend, lcPhase)
if err != nil {
lcFail(err)
return
}
lcPhase("essai du nouveau moteur…")
if err := engineSmokeTest(bin, ancien); err != nil {
// On ne garde pas un moteur inutilisable : il encombrerait la liste et
// inviterait à le sélectionner.
_ = os.RemoveAll(filepath.Dir(bin))
lcFail(fmt.Errorf("%w\n\nLe moteur courant n'a PAS été touché. Cette version demande sans doute un runtime CUDA plus récent que celui de l'image : reconstruis l'image avec un LLAMACPP_IMAGE récent, ou épingle un build antérieur", err))
return
}
if build, commit := engineBuildOf(bin); build > 0 {
lcAppend(fmt.Sprintf("moteur testé : build %d (%s)", build, commit))
}
if err := engineSwitchTo(bin); err != nil {
lcFail(err)
return
}
enginePrune(engineKeepAfterUpdate(bin, ancien, lcAppend), lcAppend)
lcDone("moteur mis à jour (" + tag + ")")
}
// engineKeepAfterUpdate : les versions téléchargées que le ménage épargne après
// une bascule. La nouvelle, et celle qui tournait juste avant si c'en était une
// : le moteur de l'image n'est pas forcément l'ancien build (il dépend de
// LLAMACPP_IMAGE), et revenir sur la version d'hier ne doit demander ni réseau
// ni que le tag soit encore publié. Les plus anciennes partent (~450 Mo chacune).
func engineKeepAfterUpdate(bin, ancien string, logf func(string)) map[string]bool {
keep := map[string]bool{filepath.Base(filepath.Dir(bin)): true}
if prev := engineTagOf(ancien); prev != "" && !keep[prev] {
keep[prev] = true
if logf != nil {
logf("version précédente gardée pour revenir en arrière : " + prev)
}
}
return keep
}
// engineSwitchTo pointe BIN sur un moteur et redémarre le service s'il tournait.
func engineSwitchTo(bin string) error {
wasUp := serviceIsActive()
if wasUp {
lcPhase("arrêt du moteur…")
if err := serviceAction("stop"); err != nil {
lcAppend("[warn] arrêt du moteur : " + err.Error())
}
}
if err := SetConfigKey("BIN", bin); err != nil {
if wasUp {
_ = serviceAction("start")
}
return fmt.Errorf("moteur installé mais échec d'écriture de BIN : %w", err)
}
lcAppend("BIN → " + bin)
if wasUp {
lcPhase("redémarrage du moteur…")
if err := serviceAction("start"); err != nil {
lcAppend("[warn] redémarrage du moteur : " + err.Error())
}
}
return nil
}
// handleEngineUse bascule vers une version DÉJÀ installée, ou revient au moteur
// de l'image ({tag:"image"}). Aucun réseau : c'est la sortie de secours quand une
// mise à jour s'avère décevante.
func handleEngineUse(w http.ResponseWriter, r *http.Request) {
var req struct {
Tag string `json:"tag"`
}
_ = json.NewDecoder(r.Body).Decode(&req)
tag := strings.TrimSpace(req.Tag)
var bin string
if tag == engineUseImage {
if bin = providedEngineBin(); bin == "" {
sendJSON(w, 400, map[string]any{"ok": false, "error": "cette installation n'a pas de moteur d'image (LOKI_ENGINE_BIN)"})
return
}
} else if bin = engineBinFor(tag); bin == "" {
sendJSON(w, 400, map[string]any{"ok": false, "error": "version non installée : " + tag})
return
}
if err := SetConfigKey("BIN", bin); err != nil {
sendJSON(w, 500, map[string]any{"ok": false, "error": err.Error()})
return
}
if serviceIsActive() {
_ = serviceAction("restart")
}
sendJSON(w, 200, map[string]any{"ok": true, "bin": bin})
}
// handleEngineRemove supprime une version téléchargée. On refuse de retirer
// celle qui tourne : ça laisserait une configuration pointant dans le vide, et
// le moteur ne redémarrerait plus.
func handleEngineRemove(w http.ResponseWriter, r *http.Request) {
var req struct {
Tag string `json:"tag"`
}
_ = json.NewDecoder(r.Body).Decode(&req)
tag := filepath.Base(strings.TrimSpace(req.Tag))
bin := engineBinFor(tag)
if bin == "" {
sendJSON(w, 400, map[string]any{"ok": false, "error": "version non installée : " + tag})
return
}
if samePath(bin, currentEngineBin()) {
sendJSON(w, 409, map[string]any{"ok": false, "error": "c'est le moteur utilisé — bascule d'abord sur une autre version"})
return
}
if err := os.RemoveAll(filepath.Join(engineDir(), tag)); err != nil {
sendJSON(w, 500, map[string]any{"ok": false, "error": err.Error()})
return
}
sendJSON(w, 200, map[string]any{"ok": true})
}