mirror of
https://github.com/R0m1k3/Loki.git
synced 2026-10-17 12:16:21 +02:00
La façade sert la liste des presets (pastilles, mesures du bench quand elles correspondent à la configuration), leur ordre, l'éditeur (lecture, graine d'un preset neuf, enregistrement avec nom de fichier unique, prompt système), la suppression (refusée pour l'actif, modèle compris sur demande), les aperçus de l'éditeur (cache de prompts, second slot, avis MoE et de chargement, réutilisant le port de loki serve), la copie en placement auto, les presets externes (la clé n'est jamais rendue, test par un vrai appel de complétion) et la bascule, quand LOKI_PRESETS=rust (expérimental ; le Go reste le défaut). Ce qui touche au moteur reste au Go jusqu'à son lot : facade_presets.go expose /internal/v1/preset/apply (bascule avec état du slot gardé et moteur relancé ou arrêté, preset en service réappliqué) et /internal/v1/tune/guard (verrou de l'optimiseur), et remet les routes du tunnel à la façade. Parité : parity_presets_test.go tire du Go les fonctions pures et 70 appels de route avec presets/, models/ et la base après chacun, rejoués par tests/presets_api.rs ; bascule et application vérifiées de bout en bout contre le faux Go, presetApply côté Go. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_016tcGfJWefW5kUGoC2wL8cv
426 lines
20 KiB
Go
426 lines
20 KiB
Go
package loki
|
|
|
|
import (
|
|
"encoding/json"
|
|
"io"
|
|
"net/http"
|
|
"net/http/httptest"
|
|
"os"
|
|
"path/filepath"
|
|
"sort"
|
|
"strings"
|
|
"testing"
|
|
)
|
|
|
|
// parity_presets_test.go — journal de référence des presets (lot 15 de la
|
|
// migration Rust) : fonctions pures (nom d'affichage, valeurs, environnement,
|
|
// quantification, raisonnement, URL des presets externes, copie en placement
|
|
// auto…) puis une suite d'appels aux routes (liste et pastilles, ordre,
|
|
// éditeur, suppression avec le modèle, presets externes et leur test, aperçus
|
|
// de l'éditeur), avec après chacun le dossier presets/ et les clés touchées en
|
|
// base. Rangé dans rust/loki-rs/tests/parity_presets/presets.json ;
|
|
// tests/presets_api.rs le rejoue par la façade.
|
|
//
|
|
// La bascule et l'enregistrement du preset EN SERVICE pilotent le moteur :
|
|
// ils sont vérifiés à part (TestFacadePresetApply côté Go, essais de bout en
|
|
// bout côté Rust).
|
|
//
|
|
// LOKI_PARITY_UPDATE=1 go test ./internal/loki -run TestParitePresets
|
|
|
|
const parityPresetsFile = "../../rust/loki-rs/tests/parity_presets/presets.json"
|
|
|
|
type parityPresetStep struct {
|
|
Files map[string]string `json:"files,omitempty"` // relatif à LOKI_HOME ; "" = suppression
|
|
Store map[string]string `json:"store,omitempty"` // « bucket/clé » ; "" = suppression
|
|
Method string `json:"method"`
|
|
Path string `json:"path"`
|
|
Body string `json:"body,omitempty"`
|
|
Status int `json:"status"`
|
|
Out any `json:"out"`
|
|
Disk map[string]string `json:"disk"`
|
|
State map[string]any `json:"state"`
|
|
}
|
|
|
|
func TestParitePresets(t *testing.T) {
|
|
home := testHome(t)
|
|
t.Setenv("LOKI_ENGINE_BIN", "")
|
|
t.Setenv("LOKI_MODEL_DIRS", "")
|
|
for _, k := range append(append(append(append([]string{}, serveArgEnv...), fidelityArgEnv...), cacheArgEnv...), sideSlotArgEnv...) {
|
|
t.Setenv(k, "")
|
|
os.Unsetenv(k)
|
|
}
|
|
|
|
var fx struct {
|
|
DisplayName []map[string]string `json:"display_name"`
|
|
Quote map[string]string `json:"quote"`
|
|
FormatEnv string `json:"format_env"`
|
|
Names map[string]string `json:"names"`
|
|
Quant map[string]string `json:"quant"`
|
|
Reasoning map[string]string `json:"reasoning"`
|
|
Model map[string]string `json:"model"`
|
|
Completions map[string]string `json:"completions"`
|
|
External []map[string]any `json:"external"`
|
|
APIError map[string]string `json:"api_error"`
|
|
Join []map[string]any `json:"join"`
|
|
SetKey []map[string]string `json:"set_key"`
|
|
Autoplace []map[string]any `json:"autoplace"`
|
|
BenchFP []map[string]any `json:"bench_fp"`
|
|
Imposes map[string]bool `json:"imposes"`
|
|
Seed map[string]string `json:"seed"`
|
|
Steps []parityPresetStep `json:"steps"`
|
|
}
|
|
for _, c := range [][2]string{
|
|
{"MODEL=a.gguf\n", "Alpha"}, {"# NAME=Vieux\nMODEL=a\n", "Neuf"}, {" name = x\nCTX=1\n", "Y"},
|
|
{"#NAME=a\n# name=b\n", "Deux"}, {"MODEL=a\n# NAME=x", "Prix $1 et ${x}"}, {"", "Vide"},
|
|
} {
|
|
fx.DisplayName = append(fx.DisplayName, map[string]string{"content": c[0], "name": c[1], "out": withDisplayName(c[0], c[1])})
|
|
}
|
|
fx.Quote = map[string]string{}
|
|
for _, v := range []string{"", "simple", "a b", "a\tb", `a"b`, "'x'", `"y"`, "'z"} {
|
|
fx.Quote[v] = quoteValue(v)
|
|
}
|
|
fx.FormatEnv = formatEnv(map[string]string{"Z": "1", "A": "deux mots", "M": "", "Q": `q"q`})
|
|
fx.Names = map[string]string{}
|
|
for _, n := range []string{"", " ", ".", "..", "a/b", `a\b`, "a\x00b", "a\x01b", "Qwen 3 (27B) é", " pad "} {
|
|
if _, err := safePresetPath(n); err != nil {
|
|
fx.Names[n] = err.Error()
|
|
} else {
|
|
fx.Names[n] = "ok"
|
|
}
|
|
}
|
|
fx.Quant, fx.Reasoning, fx.Model = map[string]string{}, map[string]string{}, map[string]string{}
|
|
for _, c := range []string{"MODEL=Qwen3-27B-Q4_K_M.gguf\n", "# QUANT=iq3_xs\nMODEL=x-Q8_0.gguf\n", "QUANT='q5'\n",
|
|
"MODEL=\"/m/Llama-3-UD-Q6_K_XL-00001-of-00002.gguf\"\nREASONING=On\n", "reasoning = 'off'\n#REASONING=on\n",
|
|
"export MODEL=z.gguf\n", "MODEL = C:\\models\\w-BF16.gguf\n", ""} {
|
|
fx.Quant[c], fx.Reasoning[c], fx.Model[c] = detectQuant(c), presetReasoning(c), modelFromPresetContent(c)
|
|
}
|
|
fx.Completions = map[string]string{}
|
|
for _, u := range []string{"", " https://api.groq.com/openai/v1/ ", "http://x/chat/completions", "http://loki:8090", "https://o.ai/api/v1"} {
|
|
fx.Completions[u] = completionsURL(u)
|
|
}
|
|
for _, c := range []struct {
|
|
url, model, key, ctx string
|
|
vision bool
|
|
}{{"https://a/v1", "gpt", "", "", false}, {" u ", " m ", " k ", " 128000 ", true}} {
|
|
fx.External = append(fx.External, map[string]any{"url": c.url, "model": c.model, "key": c.key, "ctx": c.ctx,
|
|
"vision": c.vision, "out": externalPresetContent(c.url, c.model, c.key, c.ctx, c.vision)})
|
|
}
|
|
fx.APIError = map[string]string{}
|
|
for _, b := range []string{`{"error":{"message":" clé invalide "}}`, `{"error":"x"}`, `pas du json`, `{}`} {
|
|
fx.APIError[b] = extractAPIError([]byte(b))
|
|
}
|
|
for _, a := range [][]string{{"-ngl", "30"}, {"--chat-template-file", "/a b/t.jinja"}, {"x", "l'apostrophe ici"}, {""}} {
|
|
fx.Join = append(fx.Join, map[string]any{"args": a, "out": joinArgs(a)})
|
|
}
|
|
for _, c := range [][3]string{
|
|
{"A=1\nB=2\n", "B", "3"}, {"A=1\nexport B=2\nB=4\n", "B", ""}, {"A=1", "C", "deux mots"}, {"", "X", "1"},
|
|
{"NGL=999\n# NGL=1\n", "NGL", ""}, {"K.E=1\nKXE=2\n", "K.E", "v"},
|
|
} {
|
|
fx.SetKey = append(fx.SetKey, map[string]string{"content": c[0], "key": c[1], "val": c[2], "out": presetSetKey(c[0], c[1], c[2])})
|
|
}
|
|
for _, c := range []string{
|
|
"# NAME=MoE\nMODEL=m.gguf\nCTX=65536\nNGL=999\nEXTRA_ARGS=-ot 'exps=CPU,per_layer_token_embd=CPU,blk\\.0=CUDA0' --n-cpu-moe 0 -ub 512 --mlock --fit off -ctk q8_0\n",
|
|
"MODEL=m.gguf\nEXTRA_ARGS=--n-cpu-moe 30 -ts 1,1\n", "MODEL=m.gguf\nEXTRA_ARGS=--cpu-moe -sm row\n",
|
|
"MODEL=m.gguf\nEXTRA_ARGS=--cpu-moe\nCTX=0\n", "MODEL=m.gguf\nEXTRA_ARGS=--cpu-moe\nSPLIT_MODE=tensor\n",
|
|
"MODEL=m.gguf\nEXTRA_ARGS=-ngl 40\n", "EXTERNAL=1\n", "MODEL=m\nEXTRA_ARGS=--cpu-moe --fit=on -b=1024\nKV_TYPE=q4_0\nUBATCH=1024\n",
|
|
} {
|
|
out, changes, err := autoPlacementPreset(c)
|
|
e := ""
|
|
if err != nil {
|
|
e = err.Error()
|
|
}
|
|
fx.Autoplace = append(fx.Autoplace, map[string]any{"in": c, "out": out, "changes": changes, "err": e})
|
|
}
|
|
for _, c := range []struct{ cfg, cur map[string]string }{
|
|
{map[string]string{"MODEL": "a.gguf"}, map[string]string{}},
|
|
{map[string]string{"MODEL": "a.gguf", "CUDA_VISIBLE_DEVICES": "1"}, map[string]string{"CUDA_VISIBLE_DEVICES": "0"}},
|
|
{map[string]string{"MODEL": "a.gguf"}, map[string]string{"CUDA_VISIBLE_DEVICES": " 0,1 "}},
|
|
} {
|
|
fx.BenchFP = append(fx.BenchFP, map[string]any{"cfg": c.cfg, "cur": c.cur, "out": benchFingerprint(c.cfg, c.cur)})
|
|
}
|
|
fx.Imposes = map[string]bool{}
|
|
for _, p := range []string{"", "/app/llama-server", filepath.Join(home, "engine", "b1", "llama-server"),
|
|
filepath.Join(home, "backends", "llama.cpp-prebuilt", "llama-b2", "llama-server"), "/opt/custom/llama-server"} {
|
|
fx.Imposes[strings.ReplaceAll(p, home, "{{HOME}}")] = presetImposesEngine(p)
|
|
}
|
|
|
|
fx.Seed = map[string]string{
|
|
"config/BIN": "/opt/custom/llama-server",
|
|
"config/HOST": "127.0.0.1",
|
|
"config/PORT": "8081",
|
|
"config/MODEL": "live.gguf",
|
|
}
|
|
apply := func(store map[string]string) {
|
|
keys := make([]string, 0, len(store))
|
|
for k := range store {
|
|
keys = append(keys, k)
|
|
}
|
|
sort.Strings(keys)
|
|
for _, k := range keys {
|
|
b, key, _ := strings.Cut(k, "/")
|
|
if err := putStr(b, key, store[k]); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
}
|
|
}
|
|
apply(fx.Seed)
|
|
|
|
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
b, _ := io.ReadAll(r.Body)
|
|
switch {
|
|
case strings.HasPrefix(r.URL.Path, "/ok/"):
|
|
if !strings.Contains(string(b), `"max_tokens":1`) || r.Header.Get("Authorization") != "Bearer k1" {
|
|
w.WriteHeader(400)
|
|
return
|
|
}
|
|
io.WriteString(w, "{}")
|
|
case strings.HasPrefix(r.URL.Path, "/refus/"):
|
|
w.WriteHeader(401)
|
|
io.WriteString(w, `{"error":{"message":"clé invalide"}}`)
|
|
case strings.HasPrefix(r.URL.Path, "/brut/"):
|
|
w.WriteHeader(404)
|
|
io.WriteString(w, " page absente ")
|
|
default:
|
|
w.WriteHeader(500)
|
|
}
|
|
}))
|
|
defer srv.Close()
|
|
|
|
disk := func() map[string]string {
|
|
out := map[string]string{}
|
|
for _, d := range []string{"presets", "models"} {
|
|
entries, _ := os.ReadDir(filepath.Join(home, d))
|
|
for _, e := range entries {
|
|
b, _ := os.ReadFile(filepath.Join(home, d, e.Name()))
|
|
out[d+"/"+e.Name()] = string(b)
|
|
}
|
|
}
|
|
return out
|
|
}
|
|
state := func() map[string]any {
|
|
st := map[string]any{}
|
|
for k, v := range allKV(bkState) {
|
|
switch {
|
|
case strings.HasPrefix(k, sysPromptKeyPrefix):
|
|
st[k] = v
|
|
case k == presetOrderKey || k == "bench_presets":
|
|
var x any
|
|
_ = json.Unmarshal([]byte(v), &x)
|
|
st[k] = x
|
|
}
|
|
}
|
|
return st
|
|
}
|
|
run := func(st parityPresetStep) parityPresetStep {
|
|
for rel, content := range st.Files {
|
|
p := filepath.Join(home, filepath.FromSlash(rel))
|
|
if content == "" {
|
|
_ = os.Remove(p)
|
|
continue
|
|
}
|
|
_ = os.MkdirAll(filepath.Dir(p), 0o755)
|
|
if err := os.WriteFile(p, []byte(content), 0o644); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
}
|
|
apply(st.Store)
|
|
body := strings.ReplaceAll(st.Body, "{{URL}}", srv.URL)
|
|
w := httptest.NewRecorder()
|
|
r := httptest.NewRequest(st.Method, st.Path, strings.NewReader(body))
|
|
switch r.URL.Path {
|
|
case "/api/presets":
|
|
handlePresets(w, r)
|
|
case "/api/presets/order":
|
|
handlePresetsOrder(w, r)
|
|
case "/api/preset":
|
|
handlePreset(w, r)
|
|
case "/api/preset/save":
|
|
handlePresetSave(w, r)
|
|
case "/api/preset/cacheram":
|
|
handlePresetCacheRAM(w, r)
|
|
case "/api/preset/moe":
|
|
handlePresetMoE(w, r)
|
|
case "/api/preset/autoplace":
|
|
handlePresetAutoPlace(w, r)
|
|
case "/api/preset/delete":
|
|
handlePresetDelete(w, r)
|
|
case "/api/preset/external":
|
|
handlePresetExternal(w, r)
|
|
case "/api/preset/external/save":
|
|
handlePresetExternalSave(w, r)
|
|
case "/api/preset/external/test":
|
|
handlePresetExternalTest(w, r)
|
|
default:
|
|
t.Fatalf("route inattendue %s", st.Path)
|
|
}
|
|
st.Status = w.Code
|
|
if err := json.Unmarshal(w.Body.Bytes(), &st.Out); err != nil {
|
|
t.Fatalf("%s %s : %v (%s)", st.Method, st.Path, err, w.Body.String())
|
|
}
|
|
st.Disk = disk()
|
|
st.State = state()
|
|
return st
|
|
}
|
|
|
|
moe := "# NAME=MoE\nMODEL=absent.gguf\nCTX=32768\nEXTRA_ARGS=--n-cpu-moe 20 --load-mode none\n"
|
|
for _, st := range []parityPresetStep{
|
|
{Method: "GET", Path: "/api/presets"},
|
|
{Method: "GET", Path: "/api/preset"},
|
|
{Method: "POST", Path: "/api/preset/save", Body: `{"name":"Qwen 27B","content":"MODEL=Qwen3-27B-Q4_K_M.gguf\nCTX=65536\nREASONING=on\n"}`},
|
|
{Method: "POST", Path: "/api/preset/save", Body: `{"name":"Qwen 27B","content":"MODEL=Qwen3-27B-Q8_0.gguf\nMMPROJ=mm.gguf\n","sysprompt":" Réponds en français. "}`},
|
|
{Method: "POST", Path: "/api/preset/save", Body: `{"name":"a/b\\c","content":"MODEL=x.gguf\nCTX=40000\n"}`},
|
|
{Method: "POST", Path: "/api/preset/save", Body: `{"name":" ","content":"X=1"}`},
|
|
{Method: "POST", Path: "/api/preset/save", Body: `{"id":"../evil","name":"E","content":"X=1"}`},
|
|
{Method: "POST", Path: "/api/preset/save", Body: `{"id":"Qwen 27B (2)","name":"Qwen Q8","content":"# NAME=ancien\nMODEL=Qwen3-27B-Q8_0.gguf\nQUANT=q8\n","sysprompt":""}`},
|
|
{Method: "POST", Path: "/api/preset/save", Body: `pas du json`},
|
|
{Method: "POST", Path: "/api/preset/save", Body: `{"deleteModel":"oui"}`},
|
|
{Method: "POST", Path: "/api/preset/save", Body: `{"name":"Strata","content":"ENGINE=strata\nSTRATA_VISION=0\nCTX=1048576\n"}`},
|
|
{Method: "GET", Path: "/api/presets"},
|
|
{Method: "GET", Path: "/api/preset?id=Qwen%2027B"},
|
|
{Method: "GET", Path: "/api/preset?id=Qwen%2027B%20(2)"},
|
|
{Method: "GET", Path: "/api/preset?id=absent"},
|
|
{Method: "GET", Path: "/api/preset?id=../x"},
|
|
{Method: "POST", Path: "/api/presets/order", Body: `{"ids":["Strata","Qwen 27B (2)"]}`},
|
|
{Method: "GET", Path: "/api/presets"},
|
|
{Method: "POST", Path: "/api/presets/order", Body: `{}`},
|
|
{Method: "GET", Path: "/api/presets/order"},
|
|
{Method: "POST", Path: "/api/presets/order", Body: `{"ids":"x"}`},
|
|
{Method: "POST", Path: "/api/presets/order", Body: `{"ids":["a-b-c"]}`},
|
|
// Mesures : empreinte, repli sur le nom du modèle, autre configuration.
|
|
{Store: map[string]string{"state/bench_presets": `{"Qwen 27B":{"result":{"prompt_per_second":1234.5,"predicted_per_second":40,"kv":"q8_0","kv_quantized":true,"depth":{"cold":{"new":30000},"decode_per_second":31.25}},"model":"Qwen3-27B-Q4_K_M.gguf","at":1700000000},` +
|
|
`"Qwen 27B (2)":{"result":{"prompt_per_second":900,"predicted_per_second":20.5},"model":"autre.gguf","at":1700000001},` +
|
|
`"a-b-c":{"result":{"prompt_per_second":1,"predicted_per_second":2},"model":"x.gguf","at":1,"fingerprint":"mauvaise"}}`},
|
|
Method: "GET", Path: "/api/presets"},
|
|
// Preset actif : la configuration est celle de « a-b-c ».
|
|
{Store: map[string]string{"config/BIN": "", "config/HOST": "", "config/PORT": "", "config/MODEL": "x.gguf", "config/CTX": "40000", "config/MEM_MODE": "always"},
|
|
Method: "GET", Path: "/api/presets"},
|
|
{Method: "POST", Path: "/api/preset/delete", Body: `{"id":"a-b-c"}`},
|
|
{Store: map[string]string{"config/MODEL": "autre.gguf"}, Method: "POST", Path: "/api/preset/delete", Body: `{"id":"a-b-c"}`},
|
|
{Method: "POST", Path: "/api/preset/delete", Body: `{"id":"absent"}`},
|
|
{Method: "POST", Path: "/api/preset/delete", Body: `{"id":"a/b"}`},
|
|
{Files: map[string]string{"models/Qwen3-27B-Q4_K_M.gguf": "poids"}, Method: "POST", Path: "/api/preset/delete", Body: `{"id":"Qwen 27B","deleteModel":true}`},
|
|
{Files: map[string]string{"models/Qwen3-27B-Q8_0.gguf": "poids8", "presets/Copie.env": "# NAME=Copie\nMODEL=Qwen3-27B-Q8_0.gguf\n"},
|
|
Method: "POST", Path: "/api/preset/delete", Body: `{"id":"Qwen 27B (2)","deleteModel":true}`},
|
|
{Method: "POST", Path: "/api/preset/delete", Body: `{"id":"Copie","deleteModel":true}`},
|
|
{Method: "POST", Path: "/api/preset/delete", Body: `{"id":3}`},
|
|
{Method: "GET", Path: "/api/presets"},
|
|
// Presets externes.
|
|
{Method: "GET", Path: "/api/preset/external"},
|
|
{Method: "POST", Path: "/api/preset/external/save", Body: `{"name":" Groq ","url":" https://api.groq.com/openai/v1 ","model":" llama ","key":"k1","ctx":"131072","vision":true}`},
|
|
{Method: "POST", Path: "/api/preset/external/save", Body: `{"name":"X","url":"","model":"m"}`},
|
|
{Method: "POST", Path: "/api/preset/external/save", Body: `{"name":"X","url":"u","model":" "}`},
|
|
{Method: "POST", Path: "/api/preset/external/save", Body: `{"name":"","url":"u","model":"m"}`},
|
|
{Method: "GET", Path: "/api/preset/external?id=Groq"},
|
|
{Method: "POST", Path: "/api/preset/external/save", Body: `{"id":"Groq","name":"Groq 2","url":"https://api.groq.com","model":"llama-3"}`},
|
|
{Method: "GET", Path: "/api/preset/external?id=Groq"},
|
|
{Method: "POST", Path: "/api/preset/external/save", Body: `{"id":"Groq","name":"Groq","url":"https://api.groq.com","model":"llama-3","keyTouched":true,"key":""}`},
|
|
{Method: "GET", Path: "/api/preset/external?id=Groq"},
|
|
{Method: "GET", Path: "/api/preset/external?id=Strata"},
|
|
{Method: "GET", Path: "/api/preset/external?id=absent"},
|
|
{Method: "GET", Path: "/api/presets"},
|
|
{Method: "POST", Path: "/api/preset/external/test", Body: `{"url":"{{URL}}/ok","model":"m","key":"k1"}`},
|
|
{Method: "POST", Path: "/api/preset/external/test", Body: `{"url":"{{URL}}/refus/v1","model":"m"}`},
|
|
{Method: "POST", Path: "/api/preset/external/test", Body: `{"url":"{{URL}}/brut/v1/chat/completions","model":"m"}`},
|
|
{Method: "POST", Path: "/api/preset/external/test", Body: `{"url":"{{URL}}/rien","model":"m"}`},
|
|
{Method: "POST", Path: "/api/preset/external/test", Body: `{"url":"","model":"m"}`},
|
|
{Method: "POST", Path: "/api/preset/external/save", Body: `{"name":"Clé","url":"{{URL}}/ok","model":"m","key":"k1"}`},
|
|
{Method: "POST", Path: "/api/preset/external/test", Body: `{"id":"Clé","url":"{{URL}}/ok","model":"m"}`},
|
|
{Method: "POST", Path: "/api/preset/external/test", Body: `[]`},
|
|
// Placement auto et aperçus de l'éditeur.
|
|
{Method: "GET", Path: "/api/preset/autoplace"},
|
|
{Method: "POST", Path: "/api/preset/autoplace", Body: `{"id":"MoE","content":` + jsonString(moe) + `}`},
|
|
{Method: "POST", Path: "/api/preset/autoplace", Body: `{"name":" Mon MoE ","content":"MODEL=m\nEXTRA_ARGS=-ngl 30\n"}`},
|
|
{Method: "POST", Path: "/api/preset/autoplace", Body: `{"content":"MODEL=m\nEXTRA_ARGS=--cpu-moe\n"}`},
|
|
{Method: "POST", Path: "/api/preset/moe", Body: `{"content":` + jsonString(moe) + `}`},
|
|
{Method: "POST", Path: "/api/preset/moe", Body: `{"content":"EXTERNAL=1\n"}`},
|
|
{Method: "POST", Path: "/api/preset/moe", Body: `{"content":"MODEL=absent.gguf\nEXTRA_ARGS=--cpu-moe -sm row\n"}`},
|
|
{Method: "POST", Path: "/api/preset/moe", Body: `{"content":5}`},
|
|
{Method: "POST", Path: "/api/preset/cacheram", Body: `{"content":"EXTERNAL=1\nSIDE_SLOT=on\n"}`},
|
|
{Method: "POST", Path: "/api/preset/cacheram", Body: `{"content":"EXTRA_ARGS=-cram 2048\n"}`},
|
|
{Method: "POST", Path: "/api/preset/cacheram", Body: `{"content":"CACHE_RAM=4096\nSIDE_SLOT=on\nMODEL=absent.gguf\n"}`},
|
|
{Method: "POST", Path: "/api/preset/cacheram", Body: `{"content":"CACHE_RAM=beaucoup\n"}`},
|
|
{Method: "POST", Path: "/api/preset/cacheram", Body: `{"content":"CACHE_RAM=auto\n"}`},
|
|
{Method: "POST", Path: "/api/preset/cacheram", Body: `{"content":"MODEL=absent.gguf\nSIDE_SLOT=on\nPARALLEL=3\n"}`},
|
|
{Method: "POST", Path: "/api/preset/cacheram", Body: `{}`},
|
|
{Method: "GET", Path: "/api/presets"},
|
|
} {
|
|
fx.Steps = append(fx.Steps, run(st))
|
|
}
|
|
|
|
got, err := parityJSON(fx)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
got = []byte(strings.ReplaceAll(strings.ReplaceAll(string(got), srv.URL, "{{URL}}"), home, "{{HOME}}"))
|
|
if os.Getenv("LOKI_PARITY_UPDATE") == "1" {
|
|
if err := os.MkdirAll(filepath.Dir(parityPresetsFile), 0o755); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if err := os.WriteFile(parityPresetsFile, got, 0o644); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
return
|
|
}
|
|
want, err := os.ReadFile(parityPresetsFile)
|
|
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("les presets ne produisent plus le journal rangé dans %s.\n"+
|
|
"Si c'est voulu : LOKI_PARITY_UPDATE=1 go test ./internal/loki -run TestParitePresets,\n"+
|
|
"puis cargo test --test presets_api dit si le port Rust suit.\n--- obtenu ---\n%s", parityPresetsFile, got)
|
|
}
|
|
}
|
|
|
|
func jsonString(s string) string {
|
|
b, _ := json.Marshal(s)
|
|
return string(b)
|
|
}
|
|
|
|
// La bascule et l'enregistrement du preset en service passent par
|
|
// presetApply (façade) : configuration appliquée, moteur relancé ou arrêté.
|
|
func TestFacadePresetApply(t *testing.T) {
|
|
testHome(t)
|
|
var actions []string
|
|
old := presetServiceAction
|
|
done := make(chan struct{}, 4)
|
|
presetServiceAction = func(a string) error { actions = append(actions, a); done <- struct{}{}; return nil }
|
|
t.Cleanup(func() { presetServiceAction = old })
|
|
if err := os.MkdirAll(presetsDir(), 0o755); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
local := filepath.Join(presetsDir(), "local.env")
|
|
ext := filepath.Join(presetsDir(), "ext.env")
|
|
_ = os.WriteFile(local, []byte("MODEL=a.gguf\nCTX=8192\n"), 0o644)
|
|
_ = os.WriteFile(ext, []byte("EXTERNAL=1\nEXTERNAL_URL=u\n"), 0o644)
|
|
_ = SetConfigKey("HOST", "127.0.0.1")
|
|
if err := presetApply(local, "switch"); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
<-done
|
|
if cfg := ReadConfig(); cfg["CTX"] != "8192" || cfg["HOST"] != "127.0.0.1" {
|
|
t.Fatalf("configuration après bascule : %v", cfg)
|
|
}
|
|
if err := presetApply(local, "save"); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
<-done
|
|
if err := presetApply(ext, "apply"); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if !isExternalConfig(ReadConfig()) || len(actions) != 2 || actions[0] != "restart" || actions[1] != "restart" {
|
|
t.Fatalf("actions %v, config %v", actions, ReadConfig())
|
|
}
|
|
if err := presetApply("/etc/passwd", "switch"); err == nil {
|
|
t.Fatal("preset hors du dossier accepté")
|
|
}
|
|
if err := presetApply(local, "bizarre"); err == nil {
|
|
t.Fatal("mode inconnu accepté")
|
|
}
|
|
w := httptest.NewRecorder()
|
|
handleInternalTuneGuard(w, httptest.NewRequest("GET", "/internal/v1/tune/guard", nil))
|
|
if w.Body.String() != "{\"busy\":false}\n" {
|
|
t.Fatalf("garde : %s", w.Body.String())
|
|
}
|
|
}
|