From ac40a2556a4eb081473dbca434c4f457c05efddb Mon Sep 17 00:00:00 2001 From: Michael SCHAL Date: Sun, 4 Oct 2026 14:41:53 +0200 Subject: [PATCH] =?UTF-8?q?Optimiseur=20:=20=C2=AB=20loki=20tune=20=C2=BB?= =?UTF-8?q?=20refuse=20un=20moteur=20qui=20n'est=20pas=20celui=20de=20son?= =?UTF-8?q?=20LOKI=5FHOME?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit En ligne de commande, « loki tune » et le processus web avec des LOKI_HOME différents (sudo qui retire un LOKI_HOME exporté, variable posée dans un seul shell) ne se voient pas : le verrou tombait dans un dossier que l'interface ne lit pas, l'arrêt du « vrai » moteur visait celui d'une autre configuration, et l'essai chargeait à côté d'un moteur bien vivant — VRAM saturée, mesures fausses, relance possible par l'interface en pleine mesure. - Avant de commencer, la CLI regarde ce qui répond sur le port de son LOKI_HOME : un llama-server (/health 200, 503 ou 401) alors que ce dossier dit son moteur arrêté, un 401 sur /props avec sa clé d'API, ou un model_path qui n'est pas son MODEL (même fichier vérifié par os.SameFile) — refus, avec LOKI_HOME en clair et la marche à suivre (relancer avec le bon LOKI_HOME, ou le bouton « Optimiser… »). - Tout ce qui ne conclut pas laisse passer : rien n'écoute, autre service (404), /props absent d'un moteur ancien, chemin illisible d'ici. Le processus web n'est pas concerné : il fait foi pour son moteur. - Test : faux moteurs httptest pour chaque cas, refus et passages. Co-Authored-By: Claude Opus 5.5 --- README.md | 6 ++- internal/loki/backend_tune_run.go | 63 ++++++++++++++++++++++++++++++ internal/loki/backend_tune_test.go | 60 ++++++++++++++++++++++++++++ 3 files changed, 128 insertions(+), 1 deletion(-) diff --git a/README.md b/README.md index fe19047..a25cd70 100644 --- a/README.md +++ b/README.md @@ -685,7 +685,11 @@ Ajoutées par ce fork : par `kill -9` ne laisse plus le moteur arrêté. **Refus d'entrée** : preset externe, aucun preset actif, génération, tâche, bench ou job `bash_bg` en cours, moteur occupé (`/slots`) — en ligne de commande, seul ce dernier - contrôle voit le processus web : préférer le bouton. **Essais** (descente étape par étape depuis + contrôle voit le processus web : préférer le bouton. En ligne de commande + aussi, un moteur qui ne serait pas celui de ce `LOKI_HOME` (il répond alors + que ce dossier le dit arrêté, refuse sa clé d'API ou sert un autre `MODEL` : + typiquement `sudo loki tune`, qui perd un `LOKI_HOME` exporté) fait + refuser, avec la marche à suivre. **Essais** (descente étape par étape depuis la meilleure configuration du moment, chaque axe seulement si l'aide du moteur et la machine le permettent) : placement — seulement avec « inclure le placement » / `--placement` : `--fit` à la place des experts placés à la diff --git a/internal/loki/backend_tune_run.go b/internal/loki/backend_tune_run.go index 2e07da3..b120253 100644 --- a/internal/loki/backend_tune_run.go +++ b/internal/loki/backend_tune_run.go @@ -197,6 +197,17 @@ func tunePreflight(ctx context.Context, inWeb bool) error { bgJobsRunning()) } } + // En ligne de commande : le moteur qui répond sur le port de CE LOKI_HOME + // est-il bien le sien ? (Le processus web, lui, fait foi pour son moteur.) + if !inWeb { + cfg := ReadConfig() + ours, _ := resolveServeModelPath(strings.TrimSpace(cfg["MODEL"])) + auth := tuneAuth() + if err := tuneForeignEngine(ctx, fmt.Sprintf("http://localhost:%d", LLMPort()), serviceIsActive(), + func(r *http.Request) { auth(r.Header.Set) }, ours); err != nil { + return err + } + } if serviceIsActive() { e := benchEngine{base: fmt.Sprintf("http://localhost:%d", LLMPort()), auth: tuneAuth(), client: http.DefaultClient} if err := e.idle(ctx); err != nil { @@ -206,6 +217,58 @@ func tunePreflight(ctx context.Context, inWeb bool) error { return nil } +// tuneForeignEngine : « loki tune » et le processus web avec des LOKI_HOME +// différents (sudo qui retire la variable, LOKI_HOME exporté dans un seul +// shell) ne se voient pas : le verrou de l'optimisation tombe dans un dossier +// que l'interface ne lit pas, et l'arrêt du « vrai » moteur vise celui d'une +// autre configuration. L'essai chargeait alors à côté d'un moteur bien vivant +// — VRAM saturée, mesures fausses — et l'interface pouvait relancer le sien +// en pleine mesure. On regarde donc ce qui répond sur le port de CE +// LOKI_HOME : +// +// - un serveur répond alors que ce LOKI_HOME dit son moteur arrêté : un autre +// Loki, ou un llama-server orphelin ; +// - le moteur refuse la clé d'API de ce LOKI_HOME (401 sur /props), ou sert +// un autre fichier que son MODEL : le moteur d'une autre configuration. +// +// Tout ce qui ne conclut pas (pas de réponse, /props absent d'un moteur +// ancien, chemin illisible d'ici) laisse passer : seul un désaccord constaté +// refuse. +func tuneForeignEngine(ctx context.Context, base string, active bool, auth func(*http.Request), ourModel string) error { + p := tplProber{base: base, auth: auth, client: &http.Client{Timeout: 2 * time.Second}} + // /health d'un llama-server : 200, 503 en chargement, 401 derrière une clé + // sur certaines versions. Rien n'écoute, ou un autre service (404…) : aucun + // moteur à craindre. + var he *tplHTTPError + if _, err := p.do(ctx, http.MethodGet, "/health", nil); err != nil && + (!errors.As(err, &he) || (he.status != http.StatusServiceUnavailable && he.status != http.StatusUnauthorized)) { + return nil + } + home := LokiHome() + foreign := func(why string) error { + return fmt.Errorf("%s, alors que LOKI_HOME=%s — un autre Loki tourne sans doute avec un autre LOKI_HOME "+ + "(sudo retire la variable) : relance avec le sien (LOKI_HOME=… loki tune) ou utilise le bouton "+ + "« Optimiser… » de l'interface ; un llama-server orphelin, lui, s'arrête à la main", why, home) + } + if !active { + return foreign("un moteur répond sur " + base + " sans que ce dossier le suive") + } + props, err := p.props(ctx) + switch { + case errors.As(err, &he) && he.status == http.StatusUnauthorized: + return foreign("le moteur de " + base + " refuse la clé d'API de ce dossier") + case err != nil || props.ModelPath == "" || ourModel == "": + return nil + } + a, errA := os.Stat(props.ModelPath) + b, errB := os.Stat(ourModel) + if errA == nil && errB == nil && !os.SameFile(a, b) { + return foreign(fmt.Sprintf("le moteur de %s sert %s, pas le MODEL de ce dossier (%s ; config.env modifiée "+ + "sans redémarrage ? « loki restart »)", base, filepath.Base(props.ModelPath), filepath.Base(ourModel))) + } + return nil +} + // tunePhysCores : cœurs physiques (Linux ; ailleurs inconnu, l'axe threads est // sauté). Un conteneur à l'étroit (cpuset, quota) donne sa propre limite. func tunePhysCores(probe *tuneProbe) int { diff --git a/internal/loki/backend_tune_test.go b/internal/loki/backend_tune_test.go index 3369e6f..d0ed8c0 100644 --- a/internal/loki/backend_tune_test.go +++ b/internal/loki/backend_tune_test.go @@ -4,6 +4,8 @@ import ( "context" "encoding/json" "errors" + "net/http" + "net/http/httptest" "os" "os/exec" "path/filepath" @@ -897,3 +899,61 @@ func TestTuneRecoverWatch(t *testing.T) { t.Error("verrou périmé non retiré") } } + +// « loki tune » d'un autre LOKI_HOME que le processus web (sudo) : le moteur +// qui répond sur le port de ce dossier n'est pas le sien — refus en clair. +// Tout ce qui ne conclut pas laisse passer. +func TestTuneForeignEngine(t *testing.T) { + home := testHome(t) + ours := filepath.Join(home, "ours.gguf") + other := filepath.Join(home, "other.gguf") + for _, p := range []string{ours, other} { + if err := os.WriteFile(p, []byte("GGUF"), 0o644); err != nil { + t.Fatal(err) + } + } + engine := func(health, props int, model string) *httptest.Server { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + switch r.URL.Path { + case "/health": + w.WriteHeader(health) + case "/props": + w.WriteHeader(props) + if props == http.StatusOK { + _ = json.NewEncoder(w).Encode(map[string]any{"model_path": model}) + } + default: + w.WriteHeader(http.StatusNotFound) + } + })) + t.Cleanup(srv.Close) + return srv + } + closed := httptest.NewServer(http.NotFoundHandler()) + closed.Close() + ctx := context.Background() + for _, c := range []struct { + name string + base string + active bool + refused bool + }{ + {"rien n'écoute", closed.URL, false, false}, + {"autre service (404) sur le port, moteur arrêté", engine(404, 404, "").URL, false, false}, + {"moteur qui répond, ce dossier le dit arrêté", engine(200, 200, ours).URL, false, true}, + {"moteur en chargement (503), ce dossier le dit arrêté", engine(503, 503, "").URL, false, true}, + {"notre moteur", engine(200, 200, ours).URL, true, false}, + {"un autre MODEL", engine(200, 200, other).URL, true, true}, + {"clé d'API refusée", engine(200, 401, "").URL, true, true}, + {"/props absent (moteur ancien)", engine(200, 404, "").URL, true, false}, + {"chemin illisible d'ici", engine(200, 200, filepath.Join(home, "absent.gguf")).URL, true, false}, + } { + err := tuneForeignEngine(ctx, c.base, c.active, nil, ours) + if (err != nil) != c.refused { + t.Errorf("%s : %v", c.name, err) + } + if err != nil && !strings.Contains(err.Error(), "LOKI_HOME="+home) { + t.Errorf("%s : message sans LOKI_HOME : %v", c.name, err) + } + } +}