mirror of
https://github.com/R0m1k3/Loki.git
synced 2026-10-16 19:59:56 +02:00
parity_engine_test.go appelle lastErrorLine (sys_service_rust_linux.go) et suppose la supervision par fichier PID du conteneur ; la façade ne tourne qu'en conteneur Linux. La CI macOS ne compilait plus les tests. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_016tcGfJWefW5kUGoC2wL8cv
295 lines
11 KiB
Go
295 lines
11 KiB
Go
//go:build linux
|
|
|
|
package loki
|
|
|
|
import (
|
|
"encoding/json"
|
|
"net/http"
|
|
"net/http/httptest"
|
|
"os"
|
|
"path/filepath"
|
|
"strings"
|
|
"testing"
|
|
)
|
|
|
|
// parity_engine_test.go — journal de référence du pilotage du moteur (lot 16
|
|
// de la migration Rust) : le bilan de VRAM après déchargement (gpuSettle), le
|
|
// contrôle d'avant démarrage (preflightEngine) sur une série d'installations,
|
|
// la dernière ligne d'erreur du superviseur (lastErrorLine) et les réponses
|
|
// des routes qui ne lancent aucun processus (GET refusé, optimisation en
|
|
// cours, génération en cours, moteur déjà arrêté, préflight en échec). Rangé
|
|
// dans rust/loki-rs/tests/parity_engine/engine.json ; tests/enginectl.rs le
|
|
// rejoue par la façade.
|
|
//
|
|
// LOKI_PARITY_UPDATE=1 go test ./internal/loki -run TestPariteMoteur
|
|
|
|
const parityEngineFile = "../../rust/loki-rs/tests/parity_engine/engine.json"
|
|
|
|
type parityPreflight struct {
|
|
Name string `json:"name"`
|
|
Config map[string]string `json:"config"` // « {{HOME}} » = LOKI_HOME
|
|
Files []string `json:"files,omitempty"` // relatifs à LOKI_HOME ; « / » final = dossier
|
|
Dirs []string `json:"dirs,omitempty"` // state/model_dirs, relatifs à LOKI_HOME
|
|
Out string `json:"out"`
|
|
}
|
|
|
|
type parityEngineStep struct {
|
|
Name string `json:"name"`
|
|
Method string `json:"method"`
|
|
Path string `json:"path"`
|
|
Body string `json:"body,omitempty"`
|
|
Tune bool `json:"tune,omitempty"` // une optimisation tient le moteur
|
|
Generating bool `json:"generating,omitempty"` // une réponse est en cours
|
|
Config map[string]string `json:"config,omitempty"`
|
|
Status int `json:"status"`
|
|
Allow string `json:"allow,omitempty"`
|
|
Out any `json:"out"`
|
|
}
|
|
|
|
func TestPariteMoteur(t *testing.T) {
|
|
home := testHome(t)
|
|
t.Setenv("LOKI_CONTAINER", "1")
|
|
t.Setenv("LOKI_MODEL_DIRS", "")
|
|
t.Setenv("LOKI_ENGINE_BIN", "")
|
|
|
|
var fx struct {
|
|
Settle []map[string]any `json:"settle"`
|
|
LastError map[string]string `json:"last_error"`
|
|
Plain map[string]string `json:"plain"`
|
|
Preflight []parityPreflight `json:"preflight"`
|
|
Steps []parityEngineStep `json:"steps"`
|
|
}
|
|
|
|
// --- gpuSettle ---
|
|
seq := func(vals []int) func() (int, bool) {
|
|
i := 0
|
|
return func() (int, bool) {
|
|
if i >= len(vals) {
|
|
return vals[len(vals)-1], true
|
|
}
|
|
v := vals[i]
|
|
i++
|
|
if v < 0 {
|
|
return 0, false
|
|
}
|
|
return v, true
|
|
}
|
|
}
|
|
for _, c := range []struct {
|
|
before int
|
|
seq []int
|
|
}{
|
|
{14000, []int{20000, 20000, 12000, 3000, 500, 500}},
|
|
{14000, []int{15000, 8000, 400, 400}},
|
|
{14000, []int{14000, -1, 600, 600}},
|
|
{14000, []int{-1, -1, -1, -1, -1, -1, -1, -1}},
|
|
{14000, []int{14000, 14000, 14000, 14000, 14000, 14000, 14000, 14000}},
|
|
{0, []int{0, 0, 0}},
|
|
{9000, []int{9000, 8999, 8999}},
|
|
{9000, []int{-1, 9000, -1, 9000, 100, -1, 100}},
|
|
{5000, []int{6000, 7000, 7000, 7000, 7000, 7000, 7000, 7000}},
|
|
{5000, []int{4000, 3000, 2000, 1000, 900, 800, 700, 600}},
|
|
} {
|
|
fx.Settle = append(fx.Settle, map[string]any{"before": c.before, "seq": c.seq,
|
|
"out": gpuSettle(c.before, seq(c.seq), 0)})
|
|
}
|
|
|
|
// --- lastErrorLine / plainErr ---
|
|
fx.LastError = map[string]string{}
|
|
for _, s := range []string{
|
|
"[loki-rs engine] service loki-engine non démarré\n",
|
|
"ligne 1\n[loki-rs engine] moteur absent \n\n",
|
|
"pas de préfixe",
|
|
"",
|
|
"[ok] démarré\n[attention] 2 processus du moteur toujours là\n",
|
|
} {
|
|
fx.LastError[s] = lastErrorLine(s)
|
|
}
|
|
fx.Plain = map[string]string{}
|
|
for _, s := range []string{
|
|
"BIN non défini — installe un moteur : \x1b[1mloki llamacpp install\x1b[0m",
|
|
"\x1b[1;36mcouleur\x1b[0m et \x1b[32mvert\x1b[0m",
|
|
"rien à retirer",
|
|
"\x1b[mvide\x1b[0;1;2m",
|
|
} {
|
|
fx.Plain[s] = ansiRe.ReplaceAllString(s, "")
|
|
}
|
|
|
|
// --- preflightEngine ---
|
|
writeFiles := func(files []string) {
|
|
for _, f := range files {
|
|
p := filepath.Join(home, f)
|
|
if strings.HasSuffix(f, "/") {
|
|
if err := os.MkdirAll(p, 0o755); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
continue
|
|
}
|
|
if err := os.MkdirAll(filepath.Dir(p), 0o755); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if err := os.WriteFile(p, []byte("x"), 0o755); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
}
|
|
}
|
|
reset := func() {
|
|
for _, d := range []string{"bin", "models", "strata", "ailleurs", "disque"} {
|
|
_ = os.RemoveAll(filepath.Join(home, d))
|
|
}
|
|
cfg := ReadConfig()
|
|
for k := range cfg {
|
|
_ = SetConfigKey(k, "")
|
|
}
|
|
_ = putJSON(bkState, "model_dirs", []string{})
|
|
}
|
|
setCfg := func(cfg map[string]string) {
|
|
for k, v := range cfg {
|
|
if err := SetConfigKey(k, strings.ReplaceAll(v, "{{HOME}}", home)); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
}
|
|
}
|
|
bin := "bin/llama-server"
|
|
ok := []string{bin, "models/m.gguf"}
|
|
for _, c := range []parityPreflight{
|
|
{Name: "externe", Config: map[string]string{"EXTERNAL": "1"}},
|
|
{Name: "externe sans BIN ni MODEL", Config: map[string]string{"EXTERNAL": " 1 ", "BIN": ""}},
|
|
{Name: "strata sans config", Config: map[string]string{"ENGINE": "strata"}},
|
|
{Name: "strata config absente", Config: map[string]string{"ENGINE": "Strata", "STRATA_CONFIG": "{{HOME}}/strata/m/config.json"}},
|
|
{Name: "strata sans venv", Config: map[string]string{"ENGINE": "strata", "STRATA_CONFIG": "{{HOME}}/strata/m/config.json"},
|
|
Files: []string{"strata/m/config.json"}},
|
|
{Name: "strata prêt", Config: map[string]string{"ENGINE": "moe", "STRATA_CONFIG": "{{HOME}}/strata/m/config.json"},
|
|
Files: []string{"strata/m/config.json", "strata/m/.venv/bin/python"}},
|
|
{Name: "BIN vide", Config: map[string]string{"MODEL": "m.gguf"}},
|
|
{Name: "BIN espaces", Config: map[string]string{"BIN": " ", "MODEL": "m.gguf"}},
|
|
{Name: "BIN relatif absent", Config: map[string]string{"BIN": "bin/absent", "MODEL": "m.gguf"}},
|
|
{Name: "BIN absolu absent", Config: map[string]string{"BIN": "{{HOME}}/ailleurs/llama-server", "MODEL": "m.gguf"}},
|
|
{Name: "BIN dossier", Config: map[string]string{"BIN": "bin/", "MODEL": "m.gguf"}, Files: []string{"bin/"}},
|
|
{Name: "MODEL vide", Config: map[string]string{"BIN": bin}, Files: []string{bin}},
|
|
{Name: "MODEL absent", Config: map[string]string{"BIN": bin, "MODEL": "absent.gguf"}, Files: []string{bin}},
|
|
{Name: "MODEL relatif", Config: map[string]string{"BIN": "{{HOME}}/" + bin, "MODEL": "m.gguf"}, Files: ok},
|
|
{Name: "MODEL remonte", Config: map[string]string{"BIN": bin, "MODEL": "../m.gguf"}, Files: ok},
|
|
{Name: "MODEL sous-dossier", Config: map[string]string{"BIN": bin, "MODEL": "sub/m.gguf"}, Files: ok},
|
|
{Name: "MODEL absolu hors dossiers", Config: map[string]string{"BIN": bin, "MODEL": "{{HOME}}/ailleurs/x.gguf"},
|
|
Files: []string{bin, "ailleurs/x.gguf"}},
|
|
{Name: "MODEL absolu pas gguf", Config: map[string]string{"BIN": bin, "MODEL": "{{HOME}}/ailleurs/x.bin"},
|
|
Files: []string{bin, "ailleurs/x.bin"}},
|
|
{Name: "MODEL absolu absent", Config: map[string]string{"BIN": bin, "MODEL": "{{HOME}}/ailleurs/y.gguf"},
|
|
Files: []string{bin}},
|
|
{Name: "MODEL dossier déclaré", Config: map[string]string{"BIN": bin, "MODEL": "d.gguf"},
|
|
Files: []string{bin, "disque/d.gguf"}, Dirs: []string{"disque"}},
|
|
{Name: "MODEL chemin dans dossier déclaré", Config: map[string]string{"BIN": bin, "MODEL": "{{HOME}}/disque/d.gguf"},
|
|
Files: []string{bin, "disque/d.gguf"}, Dirs: []string{"disque"}},
|
|
{Name: "tranches manquantes", Config: map[string]string{"BIN": bin, "MODEL": "big-00001-of-00003.gguf"},
|
|
Files: []string{bin, "models/big-00001-of-00003.gguf", "models/big-00003-of-00003.gguf"}},
|
|
{Name: "tranches dossier", Config: map[string]string{"BIN": bin, "MODEL": "big-00001-of-00002.gguf"},
|
|
Files: []string{bin, "models/big-00001-of-00002.gguf", "models/big-00002-of-00002.gguf/"}},
|
|
{Name: "tranches complètes", Config: map[string]string{"BIN": bin, "MODEL": "big-00001-of-00002.gguf"},
|
|
Files: []string{bin, "models/big-00001-of-00002.gguf", "models/big-00002-of-00002.gguf"}},
|
|
{Name: "tranche 2 désignée", Config: map[string]string{"BIN": bin, "MODEL": "big-00002-of-00002.gguf"},
|
|
Files: []string{bin, "models/big-00002-of-00002.gguf"}},
|
|
{Name: "MODEL guillemets", Config: map[string]string{"BIN": bin, "MODEL": " m.gguf "}, Files: ok},
|
|
} {
|
|
reset()
|
|
writeFiles(c.Files)
|
|
setCfg(c.Config)
|
|
if len(c.Dirs) > 0 {
|
|
var abs []string
|
|
for _, d := range c.Dirs {
|
|
abs = append(abs, filepath.Join(home, d))
|
|
}
|
|
if err := putJSON(bkState, "model_dirs", abs); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
}
|
|
c.Out = strings.ReplaceAll(plainErr(preflightEngine()), home, "{{HOME}}")
|
|
fx.Preflight = append(fx.Preflight, c)
|
|
}
|
|
|
|
// --- routes sans processus lancé ---
|
|
reset()
|
|
run := func(st parityEngineStep) parityEngineStep {
|
|
reset()
|
|
setCfg(st.Config)
|
|
var lock *tuneLock
|
|
if st.Tune {
|
|
l, err := tuneLockAcquire("web", false)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
lock = l
|
|
}
|
|
conv.mu.Lock()
|
|
conv.Generating = st.Generating
|
|
conv.mu.Unlock()
|
|
defer func() {
|
|
if lock != nil {
|
|
lock.release()
|
|
}
|
|
conv.mu.Lock()
|
|
conv.Generating = false
|
|
conv.mu.Unlock()
|
|
}()
|
|
h := map[string]http.HandlerFunc{
|
|
"/api/vram/unload": handleVramUnload,
|
|
"/api/vram/reload": handleVramReload,
|
|
}[st.Path]
|
|
rec := httptest.NewRecorder()
|
|
h(rec, httptest.NewRequest(st.Method, st.Path, strings.NewReader(st.Body)))
|
|
st.Status = rec.Code
|
|
st.Allow = rec.Header().Get("Allow")
|
|
st.Out = parityDecode(t, rec.Body.String(), home)
|
|
return st
|
|
}
|
|
for _, st := range []parityEngineStep{
|
|
{Name: "déchargement en GET", Method: "GET", Path: "/api/vram/unload"},
|
|
{Name: "rechargement en GET", Method: "GET", Path: "/api/vram/reload"},
|
|
{Name: "rechargement en PUT", Method: "PUT", Path: "/api/vram/reload"},
|
|
{Name: "génération en cours", Method: "POST", Path: "/api/vram/unload", Body: `{}`, Generating: true},
|
|
{Name: "génération en cours, corps illisible", Method: "POST", Path: "/api/vram/unload", Body: `nimp`, Generating: true},
|
|
{Name: "moteur déjà arrêté", Method: "POST", Path: "/api/vram/unload", Body: `{"force":true}`, Generating: true},
|
|
{Name: "moteur arrêté, sans corps", Method: "POST", Path: "/api/vram/unload"},
|
|
{Name: "optimisation en cours", Method: "POST", Path: "/api/vram/reload", Tune: true},
|
|
{Name: "préflight sans BIN", Method: "POST", Path: "/api/vram/reload", Config: map[string]string{"MODEL": "m.gguf"}},
|
|
{Name: "préflight sans MODEL", Method: "POST", Path: "/api/vram/reload",
|
|
Config: map[string]string{"BIN": "/bin/sh"}},
|
|
} {
|
|
fx.Steps = append(fx.Steps, run(st))
|
|
}
|
|
|
|
got, err := parityJSON(fx)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
got = []byte(strings.ReplaceAll(string(got), home, "{{HOME}}"))
|
|
if os.Getenv("LOKI_PARITY_UPDATE") == "1" {
|
|
if err := os.MkdirAll(filepath.Dir(parityEngineFile), 0o755); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if err := os.WriteFile(parityEngineFile, got, 0o644); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
return
|
|
}
|
|
want, err := os.ReadFile(parityEngineFile)
|
|
if err != nil {
|
|
t.Fatalf("journal de référence absent (%v) : LOKI_PARITY_UPDATE=1 pour le créer", err)
|
|
}
|
|
if string(got) != string(want) {
|
|
t.Errorf("le pilotage du moteur ne produit plus le journal rangé dans %s.\n"+
|
|
"Si c'est voulu : LOKI_PARITY_UPDATE=1 go test ./internal/loki -run TestPariteMoteur,\n"+
|
|
"puis cargo test --test enginectl dit si le port Rust suit.\n--- obtenu ---\n%s", parityEngineFile, got)
|
|
}
|
|
}
|
|
|
|
// parityDecode : le corps JSON d'une réponse, tel quel s'il n'en est pas un.
|
|
func parityDecode(t *testing.T, body, home string) any {
|
|
t.Helper()
|
|
var v any
|
|
if err := json.Unmarshal([]byte(strings.ReplaceAll(body, home, "{{HOME}}")), &v); err != nil {
|
|
return body
|
|
}
|
|
return v
|
|
}
|