Optimiseur : « loki tune » refuse un moteur qui n'est pas celui de son LOKI_HOME

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 <noreply@anthropic.com>
This commit is contained in:
MichaelandClaude Opus 5.5 committed 2026-10-04 14:41:53 +02:00
1 parent a325e98dad
commit ac40a2556a
3 files changed
+128 -1

No files matched your search

+5 -1
View File
@@ -685,7 +685,11 @@ Ajoutées par ce fork :
par `kill -9` ne laisse plus le moteur arrêté. **Refus d'entrée** : preset 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 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 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 la meilleure configuration du moment, chaque axe seulement si l'aide du
moteur et la machine le permettent) : placement — seulement avec « inclure moteur et la machine le permettent) : placement — seulement avec « inclure
le placement » / `--placement` : `--fit` à la place des experts placés à la le placement » / `--placement` : `--fit` à la place des experts placés à la
+63
View File
@@ -197,6 +197,17 @@ func tunePreflight(ctx context.Context, inWeb bool) error {
bgJobsRunning()) 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() { if serviceIsActive() {
e := benchEngine{base: fmt.Sprintf("http://localhost:%d", LLMPort()), auth: tuneAuth(), client: http.DefaultClient} e := benchEngine{base: fmt.Sprintf("http://localhost:%d", LLMPort()), auth: tuneAuth(), client: http.DefaultClient}
if err := e.idle(ctx); err != nil { if err := e.idle(ctx); err != nil {
@@ -206,6 +217,58 @@ func tunePreflight(ctx context.Context, inWeb bool) error {
return nil 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 // tunePhysCores : cœurs physiques (Linux ; ailleurs inconnu, l'axe threads est
// sauté). Un conteneur à l'étroit (cpuset, quota) donne sa propre limite. // sauté). Un conteneur à l'étroit (cpuset, quota) donne sa propre limite.
func tunePhysCores(probe *tuneProbe) int { func tunePhysCores(probe *tuneProbe) int {
+60
View File
@@ -4,6 +4,8 @@ import (
"context" "context"
"encoding/json" "encoding/json"
"errors" "errors"
"net/http"
"net/http/httptest"
"os" "os"
"os/exec" "os/exec"
"path/filepath" "path/filepath"
@@ -897,3 +899,61 @@ func TestTuneRecoverWatch(t *testing.T) {
t.Error("verrou périmé non retiré") 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)
}
}
}