Lot 15 : presets tenus par la façade (LOKI_PRESETS=rust)

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
This commit is contained in:
Claude committed 2026-10-15 18:13:13 +00:00
1 parent 13bcbc74fe
commit a50b8f3cc6
19 files changed
+4805 -3

No files matched your search

+1
View File
@@ -235,6 +235,7 @@ redémarrages (volume `/data`). Variables d'environnement du conteneur :
| `LOKI_PUSH` | `rust` : les notifications Web Push (clé VAPID, abonnements, envoi « réponse prête ») sont tenues par la façade Rust (expérimental, migration en cours) | Go |
| `LOKI_MEM` | `rust` : la mémoire (éditeur des pages, index, déplacement entre projets, trackers, mode mémoire, interrupteurs du mode agent) est tenue par la façade Rust ; le chiffrement reste au Go (expérimental, migration en cours) | Go |
| `LOKI_SERVE` | `rust` : la ligne de commande de llama-server est composée par la façade Rust (`loki-rs engine serve`) au lieu de `loki serve` ; Strata et l'optimiseur restent au Go (expérimental, migration en cours) | Go |
| `LOKI_PRESETS` | `rust` : les presets (liste, éditeur et aperçus, suppression, placement auto, presets externes, bascule) sont tenus par la façade Rust ; l'application au moteur reste au Go (expérimental, migration en cours) | Go |
**Accès par nom de domaine.** Sans clé de pilotage, l'API n'accepte que les
hôtes locaux (IP, `localhost`, nom sans point, `.local`/`.lan`…) et refuse
@@ -0,0 +1,86 @@
# Lot 15 — presets tenus par la façade
## Contexte
Les presets vivaient dans le Go :
- `backend_presets.go` pour l'écriture ;
- les handlers de `web_api.go` et `web_external.go` ;
- les aperçus de l'éditeur (`cacheRAMPreview`, `sideSlotPreview`,
`moePreview`, `autoPlacementPreset`).
Lot 15 : la façade tient ces routes quand `LOKI_PRESETS=rust`
(`presets_api.rs`, expérimental). Sans la variable, rien ne change.
| Route | Rôle |
|---|---|
| `GET /api/presets` | liste, avec pastilles (contexte, vision, quantification, raisonnement, Strata, externe) et mesure du bench si elle correspond à la configuration actuelle |
| `POST /api/presets/order` | ordre choisi par glisser-déposer |
| `GET /api/preset?id=` | contenu et prompt système ; sans id, la graine d'un preset neuf (BIN personnalisé, HOST, PORT) |
| `POST /api/preset/save` | crée (nom de fichier unique) ou met à jour ; le preset EN SERVICE est appliqué tout de suite |
| `POST /api/preset/delete` | refusé pour le preset actif ; emporte prompt système et mesure, et le modèle sur demande |
| `POST /api/preset/cacheram`, `/api/preset/moe` | aperçus de l'éditeur, calculés comme au lancement (lot 14) |
| `POST /api/preset/autoplace` | copie en placement automatique d'un preset MoE placé à la main |
| `GET /api/preset/external?id=`, `POST /api/preset/external/save`, `/api/preset/external/test` | presets externes ; la clé n'est jamais rendue, le test fait un vrai appel de complétion |
| `POST /api/switch` | bascule de preset |
Les presets restent des fichiers `presets/*.env`, et leur prompt système une
clé de la base. Les aperçus réutilisent le port de `loki serve` (lot 14),
sondes comprises.
## Restent au Go
Tout ce qui touche au moteur reste au Go, jusqu'au lot qui le portera. Le Go
expose deux routes internes pour cela (`facade_presets.go`).
`POST /internal/v1/preset/apply {path, mode}` applique un preset à la
configuration vivante :
- `switch` : l'état du slot est relevé avant (SLOT_PERSIST), puis le moteur
est relancé, ou arrêté pour un preset externe ;
- `save` : application, puis relance ;
- `apply` : la configuration seule (preset externe en service modifié).
`GET /internal/v1/tune/guard` dit si une optimisation tient le moteur. La
réponse est alors un 409 `{ok: false, tuning: true}`, comme le Go.
## Écarts assumés
- La suppression d'un preset retire sa mesure sans réécrire les autres avec
leurs champs à zéro, comme le faisait la struct du Go. Le Go les relit
pareil.
- Le message d'un échec de réseau du test externe est celui de reqwest.
## Parité
`internal/loki/parity_presets_test.go` tire du Go deux séries.
Les fonctions pures :
- nom d'affichage, guillemets, format `.env`, noms refusés ;
- quantification, raisonnement, modèle ;
- URL de complétions, contenu d'un preset externe, message d'erreur d'API ;
- arguments rejoints, clé réécrite dans un preset ;
- huit copies en placement auto ;
- empreinte de mesure, moteur imposé par un preset.
Puis 70 appels de route, avec après chacun les dossiers `presets/` et
`models/`, et les clés touchées en base. Ils couvrent :
- créations, doublons, noms refusés, erreurs JSON ;
- ordre, pastilles et mesures (empreinte, repli sur le modèle, autre
configuration) ;
- preset actif et suppressions (modèle compris, partagé, absent) ;
- presets externes et leur test contre un faux service (succès, 401 au
format OpenAI, 404 brut, 500 vide) ;
- placement auto et aperçus.
`tests/presets_api.rs` rejoue le tout par la façade, et une mutation fait
échouer le rejeu.
La bascule et l'enregistrement du preset en service sont vérifiés de bout en
bout contre le faux Go :
- appels d'application, avec leur mode ;
- refus quand une optimisation tourne ;
- erreur d'application rendue ;
- prompt système.
Côté Go, `TestFacadePresetApply` vérifie `presetApply` : configuration
appliquée, appareils préservés, moteur relancé, chemin hors du dossier
refusé.
+2
View File
@@ -162,6 +162,8 @@ func withFacadeChat(path string, h http.HandlerFunc) http.HandlerFunc {
held, unavailable = facadePush, "notifications indisponibles : "
case facadeMemRoutes[path]:
held, unavailable = facadeMem, "mémoire indisponible : "
case facadePresetsRoutes[path]:
held, unavailable = facadePresets, "presets indisponibles : "
default:
return h
}
+25
View File
@@ -545,3 +545,28 @@ func TestFacadeMemoire(t *testing.T) {
}
}
}
func TestFacadePresets(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 facadePresetsRoutes {
w := httptest.NewRecorder()
withFacadeChat(p, local)(w, httptest.NewRequest("GET", p, nil))
if w.Body.String() != "go" {
t.Fatalf("%s servi ailleurs sans LOKI_PRESETS : %q", p, w.Body.String())
}
}
t.Setenv(facadePresetsEnv, "rust")
for p := range facadePresetsRoutes {
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(facadePresetsRoutes) {
t.Fatalf("reçu par la façade : %d appels", n)
}
}
+136
View File
@@ -0,0 +1,136 @@
package loki
import (
"fmt"
"net/http"
"os"
"path/filepath"
"strings"
)
// facade_presets.go — presets tenus par la façade Rust (LOKI_PRESETS=rust,
// lot 15 de la migration ; expérimental, le Go reste le défaut) : liste,
// ordre, lecture, éditeur, suppression, aperçus de l'éditeur, copie en
// placement auto, presets externes, bascule.
//
// Les presets restent des fichiers presets/*.env et leur prompt système une
// clé de la base. Ce qui touche au moteur reste ici jusqu'au lot qui le
// portera : verrou de l'optimiseur (/internal/v1/tune/guard) et application
// d'un preset à la configuration vivante, avec l'état du slot gardé et le
// moteur relancé ou arrêté (/internal/v1/preset/apply).
const facadePresetsEnv = "LOKI_PRESETS"
// facadePresets : la façade tient-elle les presets ?
func facadePresets() bool {
return strings.EqualFold(strings.TrimSpace(os.Getenv(facadePresetsEnv)), "rust") &&
facadeAddr() != "" && internalToken() != ""
}
// facadePresetsRoutes : les routes que la façade sert alors (presets_api.rs).
var facadePresetsRoutes = map[string]bool{
"/api/presets": true,
"/api/presets/order": true,
"/api/preset": true,
"/api/preset/save": true,
"/api/preset/cacheram": true,
"/api/preset/moe": true,
"/api/preset/autoplace": true,
"/api/preset/delete": true,
"/api/preset/external": true,
"/api/preset/external/save": true,
"/api/preset/external/test": true,
"/api/switch": true,
}
// presetServiceAction : le pilotage du moteur après une application de
// preset. Variable pour les tests (aucun moteur n'y tourne).
var presetServiceAction = serviceAction
// presetApply applique un preset (chemin dans presets/) à la configuration
// vivante, comme les handlers d'origine :
//
// switch : bascule (handleSwitch) — état du slot relevé avant, moteur arrêté
// pour un preset externe, relancé sinon, en arrière-plan ;
// save : preset en service modifié (handlePresetSave) — relancé ;
// apply : preset externe en service modifié (handlePresetExternalSave) —
// configuration seule.
func presetApply(path, mode string) error {
root, err := filepath.Abs(presetsDir())
if err != nil {
return err
}
abs, err := filepath.Abs(path)
if err != nil || !strings.HasPrefix(abs, root+string(filepath.Separator)) || !strings.HasSuffix(abs, ".env") {
return fmt.Errorf("preset hors du dossier des presets")
}
switch mode {
case "switch":
persist := slotPersistPrepare()
if err := applyPresetFile(abs); err != nil {
return err
}
fmt.Printf("%s config.env <- %s\n", green("[ok]"), filepath.Base(abs))
if isExternalConfig(ReadConfig()) {
fmt.Println(dim("[info] preset externe — arrêt du moteur local"))
go func() {
if serviceIsActive() {
persist.save()
if err := presetServiceAction("stop"); err != nil {
fmt.Printf("%s arrêt du moteur après bascule externe : %v\n", red("[ERREUR]"), err)
}
}
}()
return nil
}
fmt.Println(dim("[info] redémarrage du service..."))
go func() {
persist.save()
if err := presetServiceAction("restart"); err != nil {
fmt.Printf("%s redémarrage après bascule: %v\n", red("[ERREUR]"), err)
}
}()
return nil
case "save":
if err := applyPresetFile(abs); err != nil {
return err
}
fmt.Printf("%s config.env <- %s (preset actif modifié)\n", green("[ok]"), filepath.Base(abs))
fmt.Println(dim("[info] redémarrage du service..."))
go func() {
if err := presetServiceAction("restart"); err != nil {
fmt.Printf("%s redémarrage après modification du preset: %v\n", red("[ERREUR]"), err)
}
}()
return nil
case "apply":
return applyPresetFile(abs)
}
return fmt.Errorf("mode inconnu : %q", mode)
}
// handleInternalPresetApply : POST {path, mode} → 204, ou 400/500 + {error}.
func handleInternalPresetApply(w http.ResponseWriter, r *http.Request) {
var in struct {
Path string `json:"path"`
Mode string `json:"mode"`
}
if !readInternalJSON(w, r, &in) {
return
}
if err := presetApply(in.Path, in.Mode); err != nil {
sendJSON(w, http.StatusInternalServerError, map[string]any{"error": err.Error()})
return
}
w.WriteHeader(http.StatusNoContent)
}
// handleInternalTuneGuard : GET → {busy, error} — une optimisation tient-elle
// le moteur (tuneGuard) ?
func handleInternalTuneGuard(w http.ResponseWriter, r *http.Request) {
if err := tuneGuard(); err != nil {
sendJSON(w, http.StatusOK, map[string]any{"busy": true, "error": err.Error()})
return
}
sendJSON(w, http.StatusOK, map[string]any{"busy": false})
}
+425
View File
@@ -0,0 +1,425 @@
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())
}
}
+4
View File
@@ -109,6 +109,10 @@ func withInternalAPI(next http.Handler) http.Handler {
handleInternalConvDropped(w, r)
case "/internal/v1/conv/workspace":
handleInternalConvWorkspace(w, r)
case "/internal/v1/preset/apply":
handleInternalPresetApply(w, r)
case "/internal/v1/tune/guard":
handleInternalTuneGuard(w, r)
default:
http.NotFound(w, r)
}
+3
View File
@@ -40,6 +40,8 @@ pub struct Ctx {
pub push: Option<std::sync::Arc<crate::push::Push>>,
/// Mémoire tenue par la façade (`LOKI_MEM=rust`).
pub mem: Option<std::sync::Arc<crate::memory::Memory>>,
/// Presets tenus par la façade (`LOKI_PRESETS=rust`).
pub presets: bool,
/// Jeton de l'API interne : une requête qui le porte vient du Go.
pub internal_token: String,
}
@@ -55,6 +57,7 @@ impl Ctx {
|| self.files.is_some()
|| self.push.is_some()
|| self.mem.is_some()
|| self.presets
}
/// La requête vient du Go (jeton interne valide) : déjà authentifiée par
+1
View File
@@ -28,6 +28,7 @@ pub mod models;
pub mod oai;
pub mod paths;
pub mod presets;
pub mod presets_api;
pub mod proxy;
pub mod push;
pub mod serve;
+5
View File
@@ -145,6 +145,9 @@ fn serve() -> ExitCode {
// LOKI_MEM=rust : la mémoire (pages, index, trackers, mode mémoire,
// interrupteurs du mode agent).
let mem_rust = held_by_facade("LOKI_MEM");
// LOKI_PRESETS=rust : les presets (liste, éditeur, aperçus, externes,
// bascule ; l'application au moteur reste au Go).
let presets_rust = held_by_facade("LOKI_PRESETS");
let mut env = vec![
(auth::INTERNAL_ENV.to_string(), token.clone()),
// Adresse de l'API interne du Go, héritée par ses enfants :
@@ -159,6 +162,7 @@ fn serve() -> ExitCode {
("LOKI_FILES", files_rust),
("LOKI_PUSH", push_rust),
("LOKI_MEM", mem_rust),
("LOKI_PRESETS", presets_rust),
];
for (var, on) in held {
if on {
@@ -238,6 +242,7 @@ fn serve() -> ExitCode {
files,
push: push_rust.then(|| std::sync::Arc::new(loki_rs::push::Push::new())),
mem: mem_rust.then(|| std::sync::Arc::new(loki_rs::memory::Memory::new(mem_home))),
presets: presets_rust,
};
let res = proxy::serve(listener, cfg, shutdown_signal()).await;
eprintln!("[loki-rs] arrêt demandé — arrêt du serveur Go");
+6 -2
View File
@@ -244,7 +244,7 @@ impl ModelRefs {
}
/// `modelUsers` : pour UN modèle (nom de fichier ou chemin).
fn users(&self, dirs: &ModelDirs, name: &str) -> (Vec<String>, bool) {
pub(crate) fn users(&self, dirs: &ModelDirs, name: &str) -> (Vec<String>, bool) {
let k = dirs.ref_key(name);
if k.is_empty() {
return (Vec::new(), false);
@@ -400,7 +400,11 @@ pub fn delete_model(
}
/// `deleteModelFile` : rend les octets libérés.
fn delete_model_file(dirs: &ModelDirs, refs: &ModelRefs, name: &str) -> Result<i64, String> {
pub(crate) fn delete_model_file(
dirs: &ModelDirs,
refs: &ModelRefs,
name: &str,
) -> Result<i64, String> {
let p = gguf::resolve_model_path(name, &dirs.home, &dirs.stored, dirs.env.as_deref())?
.to_string_lossy()
.into_owned();
File diff suppressed because it is too large. Load diff
+6
View File
@@ -77,6 +77,8 @@ pub struct Config {
pub push: Option<Arc<crate::push::Push>>,
/// Mémoire (pages, trackers, mode) tenue par la façade (`LOKI_MEM=rust`).
pub mem: Option<Arc<crate::memory::Memory>>,
/// Presets tenus par la façade (`LOKI_PRESETS=rust`).
pub presets: bool,
}
struct Proxy {
@@ -110,6 +112,7 @@ pub async fn serve(
files: cfg.files,
push: cfg.push,
mem: cfg.mem,
presets: cfg.presets,
internal_token: cfg.internal_token.clone(),
},
});
@@ -197,6 +200,9 @@ impl Proxy {
{
return chatapi::serve(&self.ctx, chat, req, from_go).await;
}
if self.ctx.presets && crate::presets_api::handles(path) {
return crate::presets_api::serve(&self.ctx, req, from_go).await;
}
if let Some(m) = &self.ctx.mem
&& crate::memory::handles(path)
{
+1 -1
View File
@@ -384,7 +384,7 @@ fn probe_serve_gpus(env: &Env, cfg: &Cfg, extra: &[String], si: &mut SysInfo) {
}
/// `probeCacheRAM`.
fn probe_cache_ram(env: &Env, cfg: &Cfg, extra: &[String], si: &mut SysInfo) {
pub(crate) fn probe_cache_ram(env: &Env, cfg: &Cfg, extra: &[String], si: &mut SysInfo) {
probe_env(env, si, CACHE_ARG_ENV, true);
if !si.model.is_empty() {
let mp = Path::new(&si.model);
+24
View File
@@ -74,6 +74,10 @@ pub struct FakeGo {
pub turn_view: Mutex<Option<serde_json::Value>>,
pub turn_verifier: Mutex<Option<serde_json::Value>>,
pub tool_results: Mutex<HashMap<String, String>>,
/// /internal/v1/tune/guard : message d'une optimisation en cours.
pub tune_busy: Mutex<Option<String>>,
/// /internal/v1/preset/apply reçus : (chemin, mode).
pub preset_applies: Mutex<Vec<(String, String)>>,
/// Comportement de /internal/v1/crypt/* (préfixe « SCELLE: » par défaut).
pub crypt_mode: Mutex<CryptMode>,
/// Réponse de /internal/v1/conv/workspace (None : 404).
@@ -325,6 +329,24 @@ async fn handle(st: Arc<FakeGo>, req: Request<Incoming>) -> Response<Full<Bytes>
*st.go_busy.lock().unwrap() = None;
resp(204, "")
}
"/internal/v1/tune/guard" => match st.tune_busy.lock().unwrap().clone() {
Some(e) => resp(
200,
serde_json::json!({ "busy": true, "error": e }).to_string(),
),
None => resp(200, r#"{"busy":false}"#),
},
"/internal/v1/preset/apply" => {
let body = req.into_body().collect().await.unwrap().to_bytes();
let v: serde_json::Value = serde_json::from_slice(&body).unwrap_or_default();
let path = v["path"].as_str().unwrap_or("").to_string();
let mode = v["mode"].as_str().unwrap_or("").to_string();
if mode == "refus" || path.ends_with("refus.env") {
return resp(500, r#"{"error":"application refusée"}"#);
}
st.preset_applies.lock().unwrap().push((path, mode));
resp(204, "")
}
"/internal/v1/conv/workspace" => match st.conv_workspace.lock().unwrap().clone() {
Some(v) => resp(200, v.to_string()),
None => resp(404, "404 page not found"),
@@ -507,6 +529,7 @@ pub struct Opts {
pub files: Option<Arc<loki_rs::convfiles::ConvFiles>>,
pub push: Option<Arc<loki_rs::push::Push>>,
pub mem: Option<Arc<loki_rs::memory::Memory>>,
pub presets: bool,
}
/// Comme [`start`], avec la conversation tenue par la façade
@@ -602,6 +625,7 @@ pub async fn start_with(
files: opts.files,
push: opts.push,
mem: opts.mem,
presets: opts.presets,
};
tokio::spawn(loki_rs::proxy::serve(fl, cfg, async move {
let _ = rx.await;
+1
View File
@@ -100,6 +100,7 @@ async fn banc(cle: Option<&str>) -> Banc {
files: None,
push: None,
mem: None,
presets: false,
};
tokio::spawn(loki_rs::proxy::serve(l, cfg, async move {
let _ = rx.await;
File diff suppressed because it is too large. Load diff
+414
View File
@@ -0,0 +1,414 @@
//! Lot 15 : presets tenus par la façade (`LOKI_PRESETS=rust`).
//!
//! Le journal tiré du Go (`internal/loki/parity_presets_test.go` →
//! `tests/parity_presets/presets.json`) est rejoué : fonctions pures, puis la
//! suite d'appels aux routes par la façade, avec après chacun le dossier
//! presets/ (et models/) et les clés touchées en base. La bascule et
//! l'enregistrement du preset en service, qui pilotent le moteur au Go, sont
//! vérifiés de bout en bout contre le faux Go.
mod common;
use std::collections::{BTreeMap, HashMap};
use std::path::Path;
use common::fakego;
use loki_rs::presets_api as pa;
use serde_json::{Value, json};
fn fixture() -> Value {
serde_json::from_str(include_str!("parity_presets/presets.json")).expect("journal")
}
fn s(v: &Value) -> &str {
v.as_str().unwrap()
}
fn strs(v: &Value) -> Vec<String> {
v.as_array()
.map(|a| a.iter().map(|x| s(x).to_string()).collect())
.unwrap_or_default()
}
fn cfg(v: &Value) -> HashMap<String, String> {
v.as_object()
.unwrap()
.iter()
.map(|(k, x)| (k.clone(), s(x).to_string()))
.collect()
}
#[test]
fn parite_des_fonctions_pures() {
let fx = fixture();
for c in fx["display_name"].as_array().unwrap() {
assert_eq!(
pa::with_display_name(s(&c["content"]), s(&c["name"])),
s(&c["out"]),
"{c}"
);
}
for (v, want) in fx["quote"].as_object().unwrap() {
assert_eq!(pa::quote_value(v), s(want), "{v:?}");
}
let m: BTreeMap<String, String> = [("Z", "1"), ("A", "deux mots"), ("M", ""), ("Q", "q\"q")]
.iter()
.map(|(k, v)| (k.to_string(), v.to_string()))
.collect();
assert_eq!(pa::format_env(&m), s(&fx["format_env"]));
let home = Path::new("/h");
for (n, want) in fx["names"].as_object().unwrap() {
let got = match pa::safe_preset_path(home, n) {
Ok(_) => "ok".to_string(),
Err(e) => e,
};
assert_eq!(got, s(want), "{n:?}");
}
for (c, want) in fx["quant"].as_object().unwrap() {
assert_eq!(pa::detect_quant(c), s(want), "{c:?}");
}
for (c, want) in fx["reasoning"].as_object().unwrap() {
assert_eq!(pa::preset_reasoning(c), s(want), "{c:?}");
}
for (c, want) in fx["model"].as_object().unwrap() {
assert_eq!(pa::model_from_preset_content(c), s(want), "{c:?}");
}
for (u, want) in fx["completions"].as_object().unwrap() {
assert_eq!(pa::completions_url(u), s(want), "{u:?}");
}
for c in fx["external"].as_array().unwrap() {
assert_eq!(
pa::external_preset_content(
s(&c["url"]),
s(&c["model"]),
s(&c["key"]),
s(&c["ctx"]),
c["vision"].as_bool().unwrap()
),
s(&c["out"])
);
}
for (b, want) in fx["api_error"].as_object().unwrap() {
assert_eq!(pa::extract_api_error(b.as_bytes()), s(want), "{b:?}");
}
for c in fx["join"].as_array().unwrap() {
assert_eq!(pa::join_args(&strs(&c["args"])), s(&c["out"]), "{c}");
}
for c in fx["set_key"].as_array().unwrap() {
assert_eq!(
pa::preset_set_key(s(&c["content"]), s(&c["key"]), s(&c["val"])),
s(&c["out"]),
"{c}"
);
}
for c in fx["autoplace"].as_array().unwrap() {
match pa::auto_placement_preset(s(&c["in"])) {
Ok((out, changes)) => {
assert_eq!(s(&c["err"]), "", "{c}");
assert_eq!(out, s(&c["out"]), "{c}");
assert_eq!(changes, strs(&c["changes"]), "{c}");
}
Err(e) => assert_eq!(e, s(&c["err"]), "{c}"),
}
}
for c in fx["bench_fp"].as_array().unwrap() {
assert_eq!(
pa::bench_fingerprint(&cfg(&c["cfg"]), &cfg(&c["cur"])),
s(&c["out"]),
"{c}"
);
}
let tmp = tempfile::tempdir().unwrap();
for (p, want) in fx["imposes"].as_object().unwrap() {
let p = p.replace("{{HOME}}", tmp.path().to_str().unwrap());
assert_eq!(
pa::preset_imposes_engine(tmp.path(), &p),
want.as_bool().unwrap(),
"{p:?}"
);
}
}
/// Faux service de complétions : mêmes réponses que celui du test Go.
async fn fake_completions() -> String {
use http_body_util::{BodyExt, Full};
use hyper::service::service_fn;
use hyper_util::rt::TokioIo;
let l = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
let addr = l.local_addr().unwrap();
tokio::spawn(async move {
while let Ok((sock, _)) = l.accept().await {
tokio::spawn(async move {
let svc = service_fn(|req: hyper::Request<hyper::body::Incoming>| async move {
let path = req.uri().path().to_string();
let auth = req
.headers()
.get("authorization")
.map(|v| v.to_str().unwrap().to_string())
.unwrap_or_default();
let body = req.into_body().collect().await.unwrap().to_bytes();
let (code, out): (u16, &str) = if path.starts_with("/ok/") {
if String::from_utf8_lossy(&body).contains("\"max_tokens\":1")
&& auth == "Bearer k1"
{
(200, "{}")
} else {
(400, "")
}
} else if path.starts_with("/refus/") {
(401, r#"{"error":{"message":"clé invalide"}}"#)
} else if path.starts_with("/brut/") {
(404, " page absente ")
} else {
(500, "")
};
let mut r = hyper::Response::new(Full::new(bytes::Bytes::from(out)));
*r.status_mut() = hyper::StatusCode::from_u16(code).unwrap();
Ok::<_, std::convert::Infallible>(r)
});
let _ = hyper::server::conn::http1::Builder::new()
.serve_connection(TokioIo::new(sock), svc)
.await;
});
}
});
format!("http://{addr}")
}
fn apply_store(go: &fakego::FakeGo, m: &serde_json::Map<String, Value>) {
for (k, v) in m {
let (b, key) = k.split_once('/').unwrap();
let mut st = go.store.lock().unwrap();
let bucket = st.entry(b.to_string()).or_default();
if s(v).is_empty() {
bucket.remove(key);
} else {
bucket.insert(key.to_string(), s(v).as_bytes().to_vec());
}
}
}
fn disk(home: &Path) -> BTreeMap<String, String> {
let mut out = BTreeMap::new();
for d in ["presets", "models"] {
if let Ok(rd) = std::fs::read_dir(home.join(d)) {
for e in rd.flatten() {
out.insert(
format!("{d}/{}", e.file_name().to_string_lossy()),
String::from_utf8_lossy(&std::fs::read(e.path()).unwrap()).into_owned(),
);
}
}
}
out
}
/// Retire récursivement les champs à zéro : le Go réécrit les mesures restantes
/// par sa struct (champs nuls ajoutés), la façade les garde telles quelles —
/// même lecture pour le Go.
fn sans_zeros(v: &Value) -> Value {
match v {
Value::Object(m) => Value::Object(
m.iter()
.filter(|(_, x)| {
!(x.is_null()
|| x.as_f64() == Some(0.0)
|| x.as_str() == Some("")
|| x.as_bool() == Some(false))
})
.map(|(k, x)| (k.clone(), sans_zeros(x)))
.collect(),
),
Value::Array(a) => Value::Array(a.iter().map(sans_zeros).collect()),
x => x.clone(),
}
}
fn state(go: &fakego::FakeGo) -> Value {
let mut st = serde_json::Map::new();
if let Some(b) = go.store.lock().unwrap().get("state") {
for (k, v) in b {
let txt = String::from_utf8_lossy(v).into_owned();
if k.starts_with("sysprompt:") {
st.insert(k.clone(), json!(txt));
} else if k == "preset_order" || k == "bench_presets" {
st.insert(k.clone(), serde_json::from_str(&txt).unwrap_or(Value::Null));
}
}
}
Value::Object(st)
}
#[tokio::test]
async fn parite_des_routes() {
let fx = fixture();
let tmp = tempfile::tempdir().unwrap();
let home = tmp.path().to_path_buf();
let h = home.to_string_lossy().into_owned();
let url = fake_completions().await;
let opts = fakego::Opts {
presets: true,
..Default::default()
};
let (facade, go, _stop, _) = fakego::start_with(home.clone(), opts).await;
apply_store(&go, fx["seed"].as_object().unwrap());
for (i, st) in fx["steps"].as_array().unwrap().iter().enumerate() {
if let Some(files) = st.get("files").and_then(Value::as_object) {
for (rel, content) in files {
let p = home.join(rel);
if s(content).is_empty() {
let _ = std::fs::remove_file(&p);
} else {
std::fs::create_dir_all(p.parent().unwrap()).unwrap();
std::fs::write(&p, s(content)).unwrap();
}
}
}
if let Some(m) = st.get("store").and_then(Value::as_object) {
apply_store(&go, m);
}
let (m, p) = (s(&st["method"]), s(&st["path"]));
let body = st
.get("body")
.and_then(Value::as_str)
.unwrap_or("")
.replace("{{URL}}", &url);
let (code, b) = fakego::send(facade, m, p, &[], &body).await;
let ctx = format!("étape {i} : {m} {p} {body}");
let txt = String::from_utf8_lossy(&b)
.replace(&url, "{{URL}}")
.replace(&h, "{{HOME}}");
assert_eq!(
u64::from(code.as_u16()),
st["status"].as_u64().unwrap(),
"{ctx} : {txt}"
);
let got: Value = serde_json::from_str(&txt).unwrap();
assert_eq!(got, st["out"], "{ctx}");
let d: BTreeMap<String, String> = disk(&home)
.into_iter()
.map(|(k, v)| (k, v.replace(&url, "{{URL}}")))
.collect();
let want_disk: BTreeMap<String, String> =
serde_json::from_value(st["disk"].clone()).unwrap();
assert_eq!(d, want_disk, "{ctx} (disque)");
assert_eq!(
sans_zeros(&state(&go)),
sans_zeros(&st["state"]),
"{ctx} (base)"
);
}
assert!(go.preset_applies.lock().unwrap().is_empty());
}
/// Bascule, enregistrement du preset en service, verrou de l'optimiseur :
/// la façade fait appliquer par le Go.
#[tokio::test]
async fn bascule_et_application() {
let tmp = tempfile::tempdir().unwrap();
let home = tmp.path().to_path_buf();
let opts = fakego::Opts {
presets: true,
..Default::default()
};
let (facade, go, _stop, _) = fakego::start_with(home.clone(), opts).await;
let dir = home.join("presets");
std::fs::create_dir_all(&dir).unwrap();
std::fs::write(dir.join("a.env"), "# NAME=Alpha\nMODEL=a.gguf\n").unwrap();
std::fs::write(dir.join("b.env"), "# NAME=Beta\nMODEL=b.gguf\n").unwrap();
std::fs::write(dir.join("refus.env"), "# NAME=Zut\nMODEL=z.gguf\n").unwrap();
go.set("config", "MODEL", "a.gguf");
let (c, b) = fakego::send(facade, "POST", "/api/switch", &[], r#"{"id":"b"}"#).await;
assert_eq!(c, 200);
assert_eq!(
serde_json::from_slice::<Value>(&b).unwrap(),
json!({ "ok": true, "preset": "Beta" })
);
let (c, b) = fakego::send(facade, "POST", "/api/switch", &[], r#"{"n":1}"#).await;
assert_eq!(c, 200, "{}", String::from_utf8_lossy(&b));
let (c, b) = fakego::send(facade, "POST", "/api/switch", &[], r#"{"id":"zz"}"#).await;
assert_eq!(c, 404);
assert_eq!(
serde_json::from_slice::<Value>(&b).unwrap()["error"],
"preset introuvable (supprimé ou renommé ?)"
);
let (c, _) = fakego::send(facade, "POST", "/api/switch", &[], r#"{"n":9}"#).await;
assert_eq!(c, 400);
let (c, b) = fakego::send(facade, "POST", "/api/switch", &[], r#"{"id":"refus"}"#).await;
assert_eq!(c, 500);
assert_eq!(
serde_json::from_slice::<Value>(&b).unwrap()["error"],
"application refusée"
);
// Le preset en service (a) modifié : appliqué, moteur relancé par le Go.
let (c, b) = fakego::send(
facade,
"POST",
"/api/preset/save",
&[],
r#"{"id":"a","name":"Alpha","content":"MODEL=a.gguf\nCTX=100000\n","sysprompt":"Bref."}"#,
)
.await;
assert_eq!(c, 200);
assert_eq!(
serde_json::from_slice::<Value>(&b).unwrap()["applied"],
true
);
assert_eq!(go.get("state", "sysprompt:a").as_deref(), Some("Bref."));
let applies = go.preset_applies.lock().unwrap().clone();
let names: Vec<(String, String)> = applies
.iter()
.map(|(p, m)| {
(
Path::new(p)
.file_name()
.unwrap()
.to_string_lossy()
.into_owned(),
m.clone(),
)
})
.collect();
assert_eq!(
names,
vec![
("b.env".to_string(), "switch".to_string()),
("a.env".to_string(), "switch".to_string()),
("a.env".to_string(), "save".to_string()),
]
);
// Optimisation en cours : refus, rien d'appliqué.
*go.tune_busy.lock().unwrap() = Some("une optimisation tient le moteur".into());
let (c, b) = fakego::send(facade, "POST", "/api/switch", &[], r#"{"id":"b"}"#).await;
assert_eq!(c, 409);
assert_eq!(
serde_json::from_slice::<Value>(&b).unwrap(),
json!({ "ok": false, "tuning": true, "error": "une optimisation tient le moteur" })
);
let (c, _) = fakego::send(
facade,
"POST",
"/api/preset/save",
&[],
r#"{"name":"N","content":"X=1"}"#,
)
.await;
assert_eq!(c, 409);
assert_eq!(go.preset_applies.lock().unwrap().len(), 3);
}
#[tokio::test]
async fn sans_la_variable_les_presets_restent_au_go() {
let tmp = tempfile::tempdir().unwrap();
let (facade, _go, _stop) = fakego::start(tmp.path().to_path_buf()).await;
for p in [
"/api/presets",
"/api/preset/save",
"/api/switch",
"/api/preset/external/test",
] {
let (_, _, b) = fakego::get(facade, p, &[]).await;
assert_eq!(&b[..], format!("go:{p}").as_bytes());
}
}
+1
View File
@@ -106,6 +106,7 @@ async fn facade(backend: SocketAddr) -> (SocketAddr, oneshot::Sender<()>) {
files: None,
push: None,
mem: None,
presets: false,
};
tokio::spawn(loki_rs::proxy::serve(l, cfg, async move {
let _ = rx.await;