Files
MichaelandClaude Opus 5.5 127013b32b Correctifs repris d'AJEAN : chiffrement, preset externe, heartbeat, commande du moteur
- Désactivation du chiffrement (amont cac0cda, 19918c6) : une valeur
  illisible ne bloque plus tout (quarantaine datée + copie du keyvault), et la
  reprise au démarrage déchiffre aussi les conversations au lieu de retirer la
  clé après les seules pages. Propre à Loki : les images de conversation
  (chatimg/) sont maintenant déchiffrées elles aussi — elles restaient
  chiffrées sans clé. Les .bak chiffrés devenus illisibles sont retirés.
- Preset externe (amont #95) : `loki serve` sort sans erreur (plus de boucle
  systemd « MODEL non défini »), le pré-vol l'accepte, le superviseur du
  conteneur ne le prend plus pour un plantage.
- Unité systemd du moteur : TimeoutStopSec=5 (bascule de preset bloquée 90 s
  en pleine génération).
- Heartbeat SSE (amont #105) : plus d'écriture après le retour du handler.
- Paramètres → Configuration : commande exacte du moteur copiable (clé API
  masquée, Strata compris) et nombre de couches du modèle à côté de NGL, lu
  dans l'en-tête GGUF (amont #108, #43) ; aussi dans l'éditeur de preset.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-07 09:28:42 +02:00

291 lines
17 KiB
Go
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
package loki
import (
"fmt"
"os"
"os/exec"
"path/filepath"
"strconv"
"strings"
)
// sys_service.go holds the platform-neutral pieces of service management. The
// actual start/stop/restart/status/logs implementation is platform-specific:
// - sys_service_linux.go → systemd (systemctl/journalctl)
// - sys_service_darwin.go → launchd (launchctl)
// - sys_service_windows.go → PID-file background process supervisor
//
// editConfig and showVram live here because they work the same everywhere.
// serviceAction : le seul passage vers serviceActionOS pour le reste de Loki.
// Pendant une optimisation (backend_tune_lock.go), le moteur d'essai tient la
// VRAM : démarrer le vrai moteur à côté — bascule de preset, GPU, mise à jour,
// rechargement… — chargerait deux modèles. Refus, quel que soit l'appelant et la
// plateforme ; l'optimiseur, lui, appelle serviceActionOS directement. Un verrou
// périmé (optimisation tuée) est écarté AVANT tout démarrage, son essai
// orphelin arrêté : sinon le moteur relancé partagerait la carte avec lui.
func serviceAction(action string) error {
if action == "start" || action == "restart" {
if err := tuneGuard(); err != nil {
return err
}
tuneReapStale()
}
return serviceActionOS(action)
}
// preflightEngine vérifie ce sans quoi le moteur ne PEUT pas démarrer, avant de
// lancer le service. Sinon llama-server sortait en erreur, systemd le relançait
// toutes les 3 s, et `loki start` affichait un « activating » rassurant pendant
// que `loki test` répondait « /health ne répond pas ». Le diagnostic n'était
// visible que dans le journal.
func preflightEngine() error {
cfg := ReadConfig()
// Preset externe : aucun llama-server local à lancer. Exiger BIN/MODEL
// refusait `loki start` et `loki restart` avec un message invitant à
// réinstaller llama.cpp (amont #95) ; cmdServe sort déjà proprement.
if isExternalConfig(cfg) {
return nil
}
// Strata n'a ni BIN ni MODEL : son installeur a tout rangé (backend_strata.go).
if isStrataConfig(cfg) {
return strataPreflight(cfg)
}
bin := strings.TrimSpace(cfg["BIN"])
if bin == "" {
return fmt.Errorf("BIN non défini — installe un moteur : %s (ou renseigne BIN avec %s)",
bold("loki llamacpp install"), bold("loki edit"))
}
if !filepath.IsAbs(bin) {
bin = filepath.Join(LokiHome(), bin)
}
if _, err := os.Stat(prebuiltResolveBin(bin)); err != nil {
return fmt.Errorf("moteur introuvable : %s — relance %s", bin, bold("loki llamacpp install"))
}
model := strings.TrimSpace(cfg["MODEL"])
if model == "" {
return fmt.Errorf("MODEL non défini — indique un .gguf avec %s (dossier des modèles : %s)",
bold("loki edit"), modelsDir())
}
p, err := resolveServeModelPath(model)
if err != nil {
return fmt.Errorf("MODEL=%s : %w", model, err)
}
if _, err := os.Stat(p); err != nil {
return fmt.Errorf("modèle introuvable : %s — corrige MODEL avec %s", p, bold("loki edit"))
}
// Modèle en plusieurs fichiers : une tranche manquante ne se voit qu'au moment
// où llama-server réclame un tenseur absent, dans le journal du service.
if missing := shardFamilyMissing(filepath.Dir(p), filepath.Base(p)); len(missing) > 0 {
return fmt.Errorf("modèle incomplet : il manque %s dans %s — ce modèle tient en %d fichiers, télécharge-les tous",
strings.Join(missing, ", "), filepath.Dir(p), len(shardFamily(filepath.Base(p))))
}
return nil
}
// configTemplate est le squelette commenté proposé par `loki edit` : la
// configuration vit en base, donc sur une installation neuve le fichier
// temporaire était QUASI VIDE — impossible de deviner quoi écrire. On y déroule
// donc les clés utiles avec leur rôle, les valeurs déjà définies telles quelles,
// les autres commentées.
var configTemplate = []struct{ key, help string }{
{"BIN", "chemin de llama-server (posé par « loki llamacpp install »)"},
{"MODEL", "nom de fichier .gguf ou chemin complet"},
{"HOST", "adresse d'écoute du moteur (défaut 0.0.0.0)"},
{"PORT", "port du moteur (défaut 8080)"},
{"CTX", "taille du contexte (défaut 32768)"},
{"NGL", "couches déportées sur le GPU (999 ou auto = ce qui tient en VRAM ; all = tout, quitte à saturer)"},
{"BATCH", "batch (défaut 2048)"},
{"UBATCH", "micro-batch (défaut 512)"},
{"THREADS", "threads CPU ; vide ou 0 = auto (cœurs physiques ; cœurs P seulement sur Intel hybride Linux)"},
{"THREADS_BATCH", "threads CPU du prefill et de la vérification spéculative (MTP) ; vide ou 0 = comme THREADS"},
{"FIT_TARGET", "Mio laissés libres par carte par le placement auto (--fit-target), ex. 1024,3072 dans l'ordre des cartes ; " +
"vide = 1024, minimum 1024 ; sans effet si NGL chiffré, --tensor-split, -ot ou --n-cpu-moe"},
{"LOAD_GUARD", "off = lancer quand même un --load-mode none/mlock/dio/mmap+mlock (ou --no-mmap, --mlock) dont les poids " +
"restés en RAM dépassent à coup sûr 90 % de la RAM (limite du conteneur comprise) ; vide (défaut) = refus clair au " +
"lancement dans ce cas, simple avertissement au-delà de 80 % estimés"},
{"OP_OFFLOAD_MIN_BATCH", "experts MoE sur CPU : taille de lot à partir de laquelle ils sont recopiés vers le GPU ; " +
"vide = défaut du moteur (32) ; plus haut = lots courts sur CPU, à mesurer"},
{"SPLIT_MODE", "tensor = parallélisme de tenseurs entre cartes CUDA (-sm tensor, expérimental) : décodage plus rapide, " +
"prefill plus lent, à mesurer par tour. Loki pose -ngl all, -ts au prorata de la VRAM, -fa on (sauf réglés à la main) " +
"et GGML_CUDA_ALLREDUCE=internal (NCCL compresse en BF16, avec perte). Refusé sans la liste {none,layer,row,tensor} " +
"dans l'aide du moteur, avec un cache KV autre que f16/bf16/f32, des poids ou le KV sur CPU, --backend-sampling, " +
"-fa off, -sm à la main, SPEC, SIDE_SLOT, CTX non chiffré, une seule carte ou une VRAM estimée insuffisante. " +
"Vide/off (défaut) = découpe par couches"},
{"CUDA_GRAPH_OPT", "on = GGML_CUDA_GRAPH_OPT=1, branches Q/K/V en parallèle au décodage (expérimental, " +
"sortie identique, gain de 0 à quelques %) ; off puis redémarrage s'il plante"},
{"KV_TYPE", "quantization du cache KV ; vide = f16, sorties de référence ; q8_0 les modifie légèrement, " +
"q4_0 avec perte mesurable ; KV_TYPE_K / KV_TYPE_V pour les séparer"},
{"CACHE_RAM", "cache de prompts en RAM hôte (--cache-ram, Mio), copie exacte des conversations quittées, jamais la VRAM ; " +
"vide = auto (agrandi seulement pour un modèle tout-GPU), -1 = sans limite, 0 = coupé"},
{"CACHE_ISOLATE", "off = ne plus effacer le slot après un sous-agent, une vérification, une tâche ou un bench"},
{"SIDE_SLOT", "on = second slot pour les travaux annexes : le moteur ouvre 2 slots de CTX jetons chacun (-c 2×CTX, " +
"--parallel 2, cache KV non unifié : aucun slot ne déborde sur l'autre), la discussion garde le slot 0, vérification, " +
"sous-agents, tâches, bench, résumé sur transcription et clients /v1 passent par le 1 (id_slot) — l'état de la " +
"discussion ne bouge plus. Cache KV et état récurrent en double en VRAM : refusé si le compte ne tient pas, si des " +
"poids sont sur CPU, ou si PARALLEL≠2, -c, -np, -kvu, --cache-idle-slots… sont réglés à la main. Vide/off (défaut) = un slot"},
{"SLOT_PERSIST", "on = garder sur disque (LOKI_HOME/slots, 2 fichiers au plus, 8 Gio chacun au plus) l'état du slot de " +
"la discussion à la bascule de preset, et le recharger au retour avant son premier message : moteur, modèle et réglages " +
"identiques à l'octet près (empreinte), slot encore vierge, sinon calcul normal. Refusé avec le décodage spéculatif, " +
"des poids sur CPU, ou un moteur joignable sans clé d'API hors 127.0.0.1. Jamais à travers une mise à jour du moteur. " +
"Réglage de machine, gardé à la bascule. Vide/off (défaut) = rien n'est écrit"},
{"CTX_CHECKPOINTS", "points de reprise par slot d'un modèle hybride (--ctx-checkpoints) ; vide = défaut du moteur (32) ; " +
"chacun pèse 70 à 200 Mio de RAM hôte : 8 à 16 si le modèle remplit déjà la RAM"},
{"CKPT_MIN_STEP", "espacement minimal en jetons entre deux points de reprise (--checkpoint-min-step), > 0 ; " +
"vide = défaut du moteur, ou 2048 d'office sur un hybride avec un moteur officiel antérieur à b10864"},
{"SPEC", "décodage spéculatif, sortie inchangée : off (défaut) / auto = tête MTP du modèle ou MODEL_DRAFT si --fit place " +
"tout et qu'aucun essai n'a échoué / mtp = imposé ; ~1-2 Go de VRAM en plus / ngram = n-grammes du contexte " +
"(ngram-mod 24/48/64, sans brouillon, utile en mode code, à mesurer ; refusé avec des poids sur CPU sauf " +
"OP_OFFLOAD_MIN_BATCH ≥ 66) / mtp+ngram = les deux, n-grammes seuls si pas de tête MTP"},
{"MODEL_DRAFT", "tête MTP publiée à part (mtp-*.gguf) ou petit modèle brouillon, nom ou chemin comme MODEL ; utilisé avec SPEC=auto, mtp ou mtp+ngram"},
{"SPEC_N_MAX", "jetons anticipés par étape (--spec-draft-n-max) ; vide = défaut du moteur (3)"},
{"SPEC_SAMPLING", "tirage du brouillon : greedy (défaut, exact) ; probabilistic seulement avec SPEC=mtp ou mtp+ngram"},
{"REASONING", "passthrough du mode raisonnement (on/auto/deepseek)"},
{"REASONING_PRESERVE", "on/off = garder ou non la réflexion des tours passés dans le gabarit (--reasoning-preserve) ; " +
"vide = défaut du moteur (on depuis b10763). Sans REASONING_ECHO, Loki ne renvoie pas cette réflexion : off rend " +
"l'ancien historique aux gabarits type Qwen3.6, mais change celui des gabarits qui la gardent d'eux-mêmes (Qwen3.8). " +
"Avec REASONING_ECHO=on, on = préfixe stable d'un message à l'autre (mode entraîné mais optionnel selon la fiche " +
"Qwen3.6), au prix de plus de contexte par tour, donc d'une compaction plus tôt"},
{"REASONING_ECHO", "on = renvoyer au moteur local le raisonnement du modèle (reasoning_content), au format entraîné : " +
"les étapes d'une boucle d'outils se relisent avec leur réflexion au lieu de blocs vides, et le moteur ne recalcule " +
"plus le dernier message ; plus de contexte par tour, compaction plus tôt. Vide/off (défaut) = rien n'est renvoyé. " +
"Jamais vers une API externe ni vers un autre modèle ; suspendu de lui-même si le gabarit le refuse"},
{"PROJ_SNAPSHOT", "on = figer le bloc projet (description, index mémoire, trackers, AGENTS.md) par discussion et livrer " +
"ses changements en <context_update> en tête du message suivant : une page créée ou une valeur de tracker ne fait plus " +
"recalculer toute la conversation. Repris tout neuf à chaque compaction, redémarrage ou changement de modèle/projet. " +
"Vide/off (défaut) = bloc reconstruit à chaque tour ; sans effet sur un preset externe"},
{"PREWARM", "on = préparer le prochain tour pendant que tu lis : après un tour (compaction comprise) ou une tâche, " +
"Loki envoie au moteur local la requête suivante avec un message « . » et 1 jeton de réponse, jetés ; le vrai message " +
"ne calcule plus que lui-même. Annulé dès qu'une autre requête part, sauf si elle prolonge exactement ce préfixe ; " +
"jamais avec plus d'un slot (sauf les deux de SIDE_SLOT : slot de la discussion). full = aussi au changement de discussion " +
"(après 3 s). Vide/off (défaut) = aucune requête de plus"},
{"KEEP_TURN_IMAGES", "zone grise — on = garder dans l'historique les images montrées par les outils (captures, see_image), " +
"rangées par référence, au lieu de les oublier en fin de tour : le tour suivant ne recalcule plus la boucle d'outils " +
"depuis la première capture. Coût mesuré par le moteur, total borné à 10 % de la fenêtre, retirées d'abord à toute " +
"compaction (qui arrive donc plus tôt) ; vision active seulement, preset externe s'il déclare la vision (images " +
"refacturées à chaque tour). Vide/off (défaut) = images éphémères, requête inchangée"},
{"NUDGE_IN_TOOL", "zone grise — on = le rappel de budget d'outils part au bout du dernier résultat d'outil au lieu d'un " +
"message à part, sur le moteur local et seulement si la sonde de gabarit dit que le rendu bouge quand un message " +
"s'ajoute (Qwen3.5) : la boucle d'outils du tour n'est plus recalculée. Un modèle peut moins bien suivre une consigne " +
"lue dans une sortie d'outil : à comparer avant de l'adopter. Vide/off (défaut) = message à part"},
{"REASONING_BUDGET", "plafond de tokens de réflexion ; -1 = illimité"},
{"REASONING_EFFORT", "intensité du raisonnement : vide (auto) / none / low / medium / high / xhigh"},
{"TEMP", "température d'échantillonnage ; vide = défaut du moteur"},
{"TOP_P", "noyau de probabilité (top_p) ; vide = défaut du moteur"},
{"TOP_K", "top_k ; 0 = désactivé, vide = défaut du moteur"},
{"MIN_P", "seuil de probabilité (min_p) ; vide = défaut du moteur"},
{"PRESENCE_PENALTY", "pénalité de présence ; vide = défaut du moteur"},
{"REPEAT_PENALTY", "pénalité de répétition (1 = neutre) ; vide = défaut du moteur"},
{"COMPACT", "compactage automatique du contexte (off pour couper)"},
{"COMPACT_CONTINUATION", "on = le résumé d'une compaction prolonge la requête du tour que le moteur local a en cache " +
"(mêmes messages, outils et réglages du gabarit, plus une demande de résumé) au lieu d'une transcription calculée à froid ; " +
"repli sur la transcription au moindre écart (autre requête passée par le slot, marge, refus, appel d'outil, résumé vide). " +
"Ne redemande pas un résumé voué au refus. Vide/off (défaut) = compaction d'avant ; sans effet sur un preset externe"},
{"MEM_MODE", "mémoire de l'IA : off / ondemand / always"},
{"EXTRA_ARGS", "ajouté tel quel à la ligne de commande de llama-server"},
}
// configEditorText rend la configuration au format présenté dans $EDITOR.
func configEditorText(cfg map[string]string) string {
var b strings.Builder
b.WriteString("# Configuration du moteur Loki (loki-engine).\n")
b.WriteString("# Une clé par ligne : CLE=valeur. Les lignes commentées (#) sont ignorées :\n")
b.WriteString("# décommente celles dont tu as besoin. « loki restart » applique.\n\n")
seen := map[string]bool{}
for _, f := range configTemplate {
seen[f.key] = true
fmt.Fprintf(&b, "# %s\n", f.help)
if v, ok := cfg[f.key]; ok && v != "" {
fmt.Fprintf(&b, "%s=%s\n\n", f.key, quoteValue(v))
} else {
fmt.Fprintf(&b, "#%s=\n\n", f.key)
}
}
// Tout ce que le squelette ne connaît pas (clés d'une version plus récente,
// réglages posés par l'UI) : conservé tel quel, en fin de fichier.
rest := map[string]string{}
for k, v := range cfg {
if !seen[k] {
rest[k] = v
}
}
if len(rest) > 0 {
b.WriteString("# --- autres clés déjà définies ---\n")
b.WriteString(formatEnv(rest))
}
return b.String()
}
// editConfig ouvre la configuration dans $EDITOR. La configuration vit en base
// (voir store.go) : on la déroule dans un fichier temporaire au format clé=valeur,
// on laisse l'éditeur faire son travail, puis on relit. Le contenu n'est réécrit
// que si l'éditeur sort proprement — un éditeur avorté ne doit rien effacer.
func editConfig() error {
editor := os.Getenv("EDITOR")
if editor == "" {
editor = defaultEditor()
}
tmp, err := os.CreateTemp("", "loki-config-*.env")
if err != nil {
return err
}
path := tmp.Name()
defer os.Remove(path)
if _, err := tmp.WriteString(configEditorText(ReadConfig())); err != nil {
tmp.Close()
return err
}
tmp.Close()
cmd := exec.Command(editor, path)
cmd.Stdin, cmd.Stdout, cmd.Stderr = os.Stdin, os.Stdout, os.Stderr
if err := cmd.Run(); err != nil {
return err
}
b, err := os.ReadFile(path)
if err != nil {
return err
}
if err := WriteConfig(parseEnv(string(b))); err != nil {
return err
}
fmt.Println(dim("[info] loki restart pour appliquer"))
return nil
}
// showVram parses `nvidia-smi --query-gpu=...` and renders a colored bar.
// nvidia-smi is available on both Linux and Windows when an NVIDIA driver is
// installed, so this is platform-neutral.
func showVram() error {
out, err := hideCmd(exec.Command("nvidia-smi",
"--query-gpu=name,memory.used,memory.total,utilization.gpu,temperature.gpu",
"--format=csv,noheader,nounits")).Output()
if err != nil {
return fmt.Errorf("nvidia-smi indisponible: %w", err)
}
for _, line := range strings.Split(strings.TrimSpace(string(out)), "\n") {
parts := strings.Split(line, ",")
if len(parts) != 5 {
continue
}
for i := range parts {
parts[i] = strings.TrimSpace(parts[i])
}
name := parts[0]
used, _ := strconv.Atoi(parts[1])
total, _ := strconv.Atoi(parts[2])
util, _ := strconv.Atoi(parts[3])
temp, _ := strconv.Atoi(parts[4])
pct := 0
if total > 0 {
pct = used * 100 / total
}
full := pct / 5
bar := strings.Repeat("█", full) + strings.Repeat("░", 20-full)
fmt.Printf("\n %s\n", cyan(name))
fmt.Printf(" VRAM %s %3d%% %.1f / %.1f GiB\n", green(bar), pct, float64(used)/1024, float64(total)/1024)
fmt.Printf(" GPU %3d%% Temp %d°C\n\n", util, temp)
}
return nil
}