mirror of
https://github.com/R0m1k3/Loki.git
synced 2026-10-17 04:10:04 +02:00
Lot 16 : pilotage du moteur tenu par la façade (LOKI_ENGINE=rust)
La façade sert /api/start, /api/stop, /api/restart, /api/vram/unload et /api/vram/reload quand LOKI_ENGINE=rust (expérimental ; le Go reste le défaut). Elle lance elle-même « loki-rs engine <action> », le superviseur que le Go appelait déjà en conteneur, avec l'environnement qu'il lui passait et de quoi joindre l'API interne du Go ; sa dernière ligne d'erreur devient l'erreur rendue. Le contrôle d'avant démarrage (preflightEngine : preset externe, Strata, BIN, MODEL, tranches) et le bilan de VRAM du déchargement (gpuSettle) sont portés. La façade ne tient ce lot qu'en conteneur : sous systemd ou avec LOKI_ENGINE_SUPERVISOR=go, loki-rs ne passe pas la variable au Go. Restent au Go derrière son API interne (facade_engine.go) : le verrou de l'optimiseur avant tout démarrage (/internal/v1/service/prestart), la réponse en cours quand il tient la conversation (/internal/v1/chat/generating) et l'arrêt de la dictée quand il la tient (/internal/v1/dictate/stop). Parité : parity_engine_test.go tire du Go dix bilans de VRAM, vingt-six installations passées au préflight, les messages du superviseur et dix réponses de route, rejoués par tests/enginectl.rs ; démarrage, arrêt, redémarrage, déchargement chiffré et rechargement vérifiés de bout en bout avec un faux superviseur et un faux nvidia-smi ; TestFacadeMoteur côté Go. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_016tcGfJWefW5kUGoC2wL8cv
This commit is contained in:
19 files changed
+2225
-2
No files matched your search
@@ -143,7 +143,9 @@ var facadeChatRoutes = map[string]bool{
|
||||
// withFacadeChat remet la requête à la façade quand elle tient la
|
||||
// conversation — ou les routes des modèles (facade_models.go), la dictée
|
||||
// (facade_dictate.go), les réglages partagés (facade_settings.go), les fichiers
|
||||
// de la discussion (facade_files.go), les notifications (facade_push.go).
|
||||
// de la discussion (facade_files.go), les notifications (facade_push.go), la
|
||||
// mémoire (facade_mem.go), les presets (facade_presets.go), le pilotage du
|
||||
// moteur (facade_engine.go).
|
||||
// Posé APRÈS requireWebAuth : seule une requête authentifiée ici part, avec le
|
||||
// jeton interne qui en tient lieu là-bas.
|
||||
func withFacadeChat(path string, h http.HandlerFunc) http.HandlerFunc {
|
||||
@@ -164,6 +166,8 @@ func withFacadeChat(path string, h http.HandlerFunc) http.HandlerFunc {
|
||||
held, unavailable = facadeMem, "mémoire indisponible : "
|
||||
case facadePresetsRoutes[path]:
|
||||
held, unavailable = facadePresets, "presets indisponibles : "
|
||||
case facadeEngineRoutes[path]:
|
||||
held, unavailable = facadeEngine, "pilotage du moteur indisponible : "
|
||||
default:
|
||||
return h
|
||||
}
|
||||
|
||||
@@ -570,3 +570,78 @@ func TestFacadePresets(t *testing.T) {
|
||||
t.Fatalf("reçu par la façade : %d appels", n)
|
||||
}
|
||||
}
|
||||
|
||||
// Le pilotage du moteur (LOKI_ENGINE=rust, lot 16) part à la façade, et l'API
|
||||
// interne lui rend ce qui reste ici : verrou de l'optimiseur avant un
|
||||
// démarrage, génération en cours, arrêt de la dictée.
|
||||
func TestFacadeMoteur(t *testing.T) {
|
||||
testHome(t)
|
||||
f := demarrerFacade(t)
|
||||
t.Setenv(facadeChatEnv, "")
|
||||
local := func(w http.ResponseWriter, r *http.Request) { io.WriteString(w, "go") }
|
||||
for p := range facadeEngineRoutes {
|
||||
w := httptest.NewRecorder()
|
||||
withFacadeChat(p, local)(w, httptest.NewRequest("POST", p, nil))
|
||||
if w.Body.String() != "go" {
|
||||
t.Fatalf("%s servi ailleurs sans LOKI_ENGINE : %q", p, w.Body.String())
|
||||
}
|
||||
}
|
||||
t.Setenv(facadeEngineEnv, "rust")
|
||||
for p := range facadeEngineRoutes {
|
||||
w := httptest.NewRecorder()
|
||||
withFacadeChat(p, local)(w, httptest.NewRequest("POST", p, strings.NewReader("{}")))
|
||||
if w.Body.String() != f.reply {
|
||||
t.Fatalf("%s : réponse %q", p, w.Body.String())
|
||||
}
|
||||
}
|
||||
if n := len(f.recu()); n != len(facadeEngineRoutes) {
|
||||
t.Fatalf("reçu par la façade : %d appels", n)
|
||||
}
|
||||
|
||||
// Avant un démarrage : le verrou, puis le ménage.
|
||||
appels := 0
|
||||
old := enginePrestart
|
||||
t.Cleanup(func() { enginePrestart = old })
|
||||
enginePrestart = func() error {
|
||||
appels++
|
||||
if appels == 2 {
|
||||
return errTuneBusy
|
||||
}
|
||||
return nil
|
||||
}
|
||||
for i, want := range []string{`{"error":""}`, `{"error":"` + errTuneBusy.Error() + `"}`} {
|
||||
w := httptest.NewRecorder()
|
||||
handleInternalServicePrestart(w, httptest.NewRequest("POST", "/internal/v1/service/prestart", nil))
|
||||
if got := strings.TrimSpace(w.Body.String()); w.Code != 200 || got != want {
|
||||
t.Fatalf("prestart %d : %d %s", i, w.Code, got)
|
||||
}
|
||||
}
|
||||
w := httptest.NewRecorder()
|
||||
handleInternalServicePrestart(w, httptest.NewRequest("GET", "/internal/v1/service/prestart", nil))
|
||||
if w.Code != 405 || appels != 2 {
|
||||
t.Fatalf("prestart en GET : %d (%d appels)", w.Code, appels)
|
||||
}
|
||||
|
||||
// La génération en cours de la conversation tenue ici.
|
||||
for _, gen := range []bool{false, true} {
|
||||
conv.mu.Lock()
|
||||
conv.Generating = gen
|
||||
conv.mu.Unlock()
|
||||
w := httptest.NewRecorder()
|
||||
handleInternalChatGenerating(w, httptest.NewRequest("GET", "/internal/v1/chat/generating", nil))
|
||||
var out struct{ Generating bool }
|
||||
if err := json.Unmarshal(w.Body.Bytes(), &out); err != nil || out.Generating != gen {
|
||||
t.Fatalf("generating=%v : %s", gen, w.Body.String())
|
||||
}
|
||||
}
|
||||
conv.mu.Lock()
|
||||
conv.Generating = false
|
||||
conv.mu.Unlock()
|
||||
|
||||
// L'arrêt de la dictée (rien ne tourne : immédiat).
|
||||
w = httptest.NewRecorder()
|
||||
handleInternalDictateStop(w, httptest.NewRequest("POST", "/internal/v1/dictate/stop", nil))
|
||||
if w.Code != 204 {
|
||||
t.Fatalf("arrêt de la dictée : %d", w.Code)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,90 @@
|
||||
package loki
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"os"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// facade_engine.go — pilotage du moteur tenu par la façade Rust
|
||||
// (LOKI_ENGINE=rust, lot 16 de la migration ; expérimental, le Go reste le
|
||||
// défaut) : démarrage, arrêt, redémarrage (/api/start|stop|restart) et
|
||||
// déchargement / rechargement de la VRAM (/api/vram/unload|reload).
|
||||
//
|
||||
// La façade lance elle-même « loki-rs engine <action> » — le superviseur que
|
||||
// le Go appelait déjà en conteneur (sys_service_rust_linux.go) — et contrôle
|
||||
// le moteur avant de le relancer (preflightEngine). Elle ne tient ce lot qu'en
|
||||
// conteneur avec ce superviseur : sous systemd, ou avec
|
||||
// LOKI_ENGINE_SUPERVISOR=go, le Go le garde (décidé par loki-rs au démarrage).
|
||||
//
|
||||
// Ce qui vit ici reste ici jusqu'au lot qui le portera, derrière l'API
|
||||
// interne :
|
||||
//
|
||||
// - le verrou de l'optimiseur, consulté puis débarrassé d'un essai orphelin
|
||||
// avant tout démarrage (/internal/v1/service/prestart) ;
|
||||
// - la génération en cours quand le Go tient la conversation
|
||||
// (/internal/v1/chat/generating) ;
|
||||
// - l'arrêt de la dictée quand le Go la tient (/internal/v1/dictate/stop).
|
||||
|
||||
const facadeEngineEnv = "LOKI_ENGINE"
|
||||
|
||||
// facadeEngine : la façade pilote-t-elle le moteur ?
|
||||
func facadeEngine() bool {
|
||||
return strings.EqualFold(strings.TrimSpace(os.Getenv(facadeEngineEnv)), "rust") &&
|
||||
facadeAddr() != "" && internalToken() != ""
|
||||
}
|
||||
|
||||
// facadeEngineRoutes : les routes que la façade sert alors (enginectl.rs).
|
||||
var facadeEngineRoutes = map[string]bool{
|
||||
"/api/start": true,
|
||||
"/api/stop": true,
|
||||
"/api/restart": true,
|
||||
"/api/vram/unload": true,
|
||||
"/api/vram/reload": true,
|
||||
}
|
||||
|
||||
// enginePrestart : ce que serviceAction fait avant un démarrage — refus
|
||||
// pendant une optimisation, puis ménage d'un verrou périmé (essai orphelin
|
||||
// arrêté, application interrompue défaite). Variable pour les tests.
|
||||
var enginePrestart = func() error {
|
||||
if err := tuneGuard(); err != nil {
|
||||
return err
|
||||
}
|
||||
tuneReapStale()
|
||||
return nil
|
||||
}
|
||||
|
||||
// handleInternalServicePrestart : POST → 200 {error} ("" : la voie est
|
||||
// libre).
|
||||
func handleInternalServicePrestart(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodPost {
|
||||
sendJSON(w, http.StatusMethodNotAllowed, map[string]any{"error": "POST attendu"})
|
||||
return
|
||||
}
|
||||
msg := ""
|
||||
if err := enginePrestart(); err != nil {
|
||||
msg = err.Error()
|
||||
}
|
||||
sendJSON(w, http.StatusOK, map[string]any{"error": msg})
|
||||
}
|
||||
|
||||
// handleInternalChatGenerating : GET → 200 {generating} — la conversation
|
||||
// tenue ici écrit-elle une réponse ?
|
||||
func handleInternalChatGenerating(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodGet {
|
||||
sendJSON(w, http.StatusMethodNotAllowed, map[string]any{"error": "GET attendu"})
|
||||
return
|
||||
}
|
||||
sendJSON(w, http.StatusOK, map[string]any{"generating": conv.isGenerating()})
|
||||
}
|
||||
|
||||
// handleInternalDictateStop : POST → 204 — arrête la dictée tenue ici, sans
|
||||
// attendre derrière un démarrage (asrShutdownVite).
|
||||
func handleInternalDictateStop(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodPost {
|
||||
sendJSON(w, http.StatusMethodNotAllowed, map[string]any{"error": "POST attendu"})
|
||||
return
|
||||
}
|
||||
asrShutdownVite()
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
}
|
||||
@@ -0,0 +1,292 @@
|
||||
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
|
||||
}
|
||||
@@ -113,6 +113,12 @@ func withInternalAPI(next http.Handler) http.Handler {
|
||||
handleInternalPresetApply(w, r)
|
||||
case "/internal/v1/tune/guard":
|
||||
handleInternalTuneGuard(w, r)
|
||||
case "/internal/v1/service/prestart":
|
||||
handleInternalServicePrestart(w, r)
|
||||
case "/internal/v1/chat/generating":
|
||||
handleInternalChatGenerating(w, r)
|
||||
case "/internal/v1/dictate/stop":
|
||||
handleInternalDictateStop(w, r)
|
||||
default:
|
||||
http.NotFound(w, r)
|
||||
}
|
||||
|
||||
Reference in new issue
Block a user