diff --git a/internal/loki/backend_presets.go b/internal/loki/backend_presets.go index 797b5f0..f26dc88 100644 --- a/internal/loki/backend_presets.go +++ b/internal/loki/backend_presets.go @@ -225,9 +225,38 @@ func applyPresetFile(target string) error { } next[k] = v } + // Le moteur suit la machine, pas le preset (voir presetImposesEngine). + if cur["BIN"] != "" && !presetImposesEngine(next["BIN"]) { + next["BIN"] = cur["BIN"] + } return WriteConfig(next) } +// presetImposesEngine : le BIN d'un preset a-t-il son mot à dire ? +// +// Le moteur est un réglage de MACHINE. Un preset créé avant une mise à jour du +// moteur (Réglages → Moteur, qui installe sous $LOKI_HOME/engine/) embarque +// encore le chemin de l'image, /app/llama-server ; l'appliquer — changer de +// modèle, une tâche planifiée — ramenait silencieusement ce vieux moteur, et +// un modèle qui chargeait la veille mourait sur « unknown model architecture » +// sans qu'aucun réglage visible ait bougé. Le moteur de l'image, un moteur +// téléchargé par Loki ou un précompilé ne sont donc jamais imposés par un +// preset : le courant reste. +// +// Un backend PERSONNALISÉ (compilé pour un modèle précis, backend_llamacpp.go : +// « un backend custom se choisit par modèle »), lui, est un vrai choix par +// modèle : le preset gagne. +func presetImposesEngine(p string) bool { + p = strings.TrimSpace(p) + if p == "" || engineOwns(p) || prebuiltOwns(p) { + return false + } + if img := strings.TrimSpace(os.Getenv("LOKI_ENGINE_BIN")); img != "" && samePath(p, img) { + return false + } + return true +} + // SwitchToPreset installe le preset et redémarre le service. Les réglages // « appareil » (preservedKeys) sont conservés à travers la bascule. func SwitchToPreset(target string) error { @@ -287,6 +316,10 @@ func cmdSwitch(args []string) error { // mélangeaient donc aux options cochées pour le nouveau, et il fallait penser à // tout nettoyer à la main. On repart d'une base vide : les valeurs non // renseignées sont les défauts documentés (CTX 32768, NGL 999, BATCH 2048…). +// +// BIN n'est repris que s'il désigne un backend personnalisé (presetImposesEngine) : +// figer /app/llama-server dans chaque preset, c'est ce qui faisait perdre le +// moteur mis à jour à la première bascule. var newPresetSeedKeys = []string{"BIN", "HOST", "PORT"} // newPresetSeed renvoie la configuration de départ d'un preset créé depuis l'UI. @@ -294,9 +327,11 @@ func newPresetSeed() map[string]string { cur := ReadConfig() seed := map[string]string{} for _, k := range newPresetSeedKeys { - if v := cur[k]; v != "" { - seed[k] = v + v := cur[k] + if v == "" || (k == "BIN" && !presetImposesEngine(v)) { + continue } + seed[k] = v } return seed } diff --git a/internal/loki/backend_presets_test.go b/internal/loki/backend_presets_test.go index f05a5f0..7846c5b 100644 --- a/internal/loki/backend_presets_test.go +++ b/internal/loki/backend_presets_test.go @@ -68,6 +68,64 @@ func TestSwitchToPresetGardeLesReglagesMachine(t *testing.T) { } } +// Le moteur suit la machine. Vécu : moteur mis à jour depuis l'interface +// (BIN → /data/engine/server-cuda-b10680), Qwen3.8-Flash-Next charge ; on +// change de modèle — le preset, créé avant, porte BIN=/app/llama-server — et +// le vieux moteur revient : « unknown model architecture: 'qwen4exp' ». Seul +// un backend personnalisé, choix par modèle, a le droit d'imposer son BIN. +func TestSwitchToPresetGardeLeMoteur(t *testing.T) { + home := testHome(t) + t.Setenv("LOKI_ENGINE_BIN", "/app/llama-server") + write := func(p, body string) { + if err := os.WriteFile(p, []byte(body), 0o644); err != nil { + t.Fatal(err) + } + } + engine := filepath.Join(home, "engine", "server-cuda-b10680", "llama-server") + setConfig(t, "MODEL=ancien.gguf\nBIN="+engine+"\n") + + // Preset d'avant la mise à jour : il porte le moteur de l'image. + image := filepath.Join(home, "image.env") + write(image, "MODEL=nouveau.gguf\nBIN=/app/llama-server\n") + if err := applyPresetFile(image); err != nil { + t.Fatal(err) + } + if got := ReadConfig()["BIN"]; got != engine { + t.Errorf("BIN = %q après bascule, attendu le moteur mis à jour %q", got, engine) + } + // Preset muet sur le moteur : idem. + muet := filepath.Join(home, "muet.env") + write(muet, "MODEL=autre.gguf\n") + if err := applyPresetFile(muet); err != nil { + t.Fatal(err) + } + if got := ReadConfig()["BIN"]; got != engine { + t.Errorf("BIN = %q après preset muet, attendu %q conservé", got, engine) + } + // Backend personnalisé : le preset gagne. + custom := filepath.Join(home, "custom.env") + write(custom, "MODEL=exotique.gguf\nBIN=/opt/mon-fork/llama-server\n") + if err := applyPresetFile(custom); err != nil { + t.Fatal(err) + } + if got := ReadConfig()["BIN"]; got != "/opt/mon-fork/llama-server" { + t.Errorf("BIN = %q, attendu le backend personnalisé du preset", got) + } + // Un nouveau preset ne fige pas ce backend-là… si, justement : c'est un + // choix par modèle. Mais il ne fige ni l'image ni un moteur téléchargé. + if seed := newPresetSeed(); seed["BIN"] != "/opt/mon-fork/llama-server" { + t.Errorf("seed BIN = %q, attendu le backend personnalisé", seed["BIN"]) + } + setConfig(t, "MODEL=ancien.gguf\nBIN="+engine+"\n") + if seed := newPresetSeed(); seed["BIN"] != "" { + t.Errorf("seed BIN = %q, un moteur téléchargé ne doit pas être figé dans un preset", seed["BIN"]) + } + setConfig(t, "MODEL=ancien.gguf\nBIN=/app/llama-server\n") + if seed := newPresetSeed(); seed["BIN"] != "" { + t.Errorf("seed BIN = %q, le moteur de l'image ne doit pas être figé dans un preset", seed["BIN"]) + } +} + // Un preset qui définit LUI-MÊME la sélection de cartes doit gagner sur celle // de la machine : « FABLE 2 GPU » impose CUDA_VISIBLE_DEVICES=1,0 pour que son // --tensor-split ait deux cartes. Écraser ça par une sélection mono-GPU faisait