Lot 25 : Strata tenu par la façade (LOKI_STRATA=rust)

Port de backend_strata.go (rust/loki-rs/src/strata) : la machine (cartes
par nvidia-smi, Python 3.10+ et venv, RAM totale et disponible, RAM du
moteur Strata en marche, place libre), ce qui y tient et la
recommandation, la configuration de lancement en clair des modèles
installés (placement des experts drop/ram/arena/disk, carte d'aide,
réglages du preset), les réglages d'un modèle installé et l'installation
(job des moteurs : paquet figé vérifié par SHA-256, installeur lancé et
journalisé comme le sinkWriter du Go, preset écrit puis activé par le Go).

Tenu seulement avec LOKI_BACKENDS=rust ; le lancement (loki serve) reste
au Go, qui relaie les routes du tunnel.

Correction du Go : /api/strata/settings décidait « en service » après
avoir écrit les nouveaux réglages, donc jamais pour un réglage changé ;
décidé désormais avant l'écriture, comme SavePresetApplying. Le port suit.

Parité : parity_strata_test.go (crochets de test : release, meminfo,
place libre, action de service) tire du Go un journal rejoué par
tests/strata.rs ; les tests du Go portés dans strata/tests.rs ;
TestFacadeStrata côté Go. Mutations : 71 tuées sur 71.

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 22:43:04 +00:00
1 parent 8c9bb6b450
commit ed9b2e8c1c
22 files changed
+9079 -19

No files matched your search

+1
View File
@@ -239,6 +239,7 @@ redémarrages (volume `/data`). Variables d'environnement du conteneur :
| `LOKI_UPDATE` | `rust` : la mise à jour de Loki (`/api/update`, `/api/update/apply`) est servie par la façade Rust ; en conteneur, l'image se met à jour par Docker (expérimental, migration en cours) | Go |
| `LOKI_MCP` | `rust` : les serveurs MCP — configuration, catalogue, état, pool de sessions stdio et HTTP — sont tenus par la façade Rust ; le Go lui demande les outils MCP de l'agent et leurs appels (expérimental, migration en cours) | Go |
| `LOKI_BACKENDS` | `rust` : les backends llama.cpp — statut, vérification git, binaires précompilés, bascule fast/opt, backends custom, GPU vus par un moteur — et le job des moteurs sont tenus par la façade Rust ; les jobs du Go (compilation, moteur de l'image, Strata) lui rapportent leurs étapes (expérimental, migration en cours) | Go |
| `LOKI_STRATA` | `rust` (avec `LOKI_BACKENDS=rust`) : l'écran de Strata — machine, ce qui y tient, recommandation, modèles installés —, ses réglages et son installation sont tenus par la façade Rust ; le lancement reste au Go (expérimental, migration en cours) | Go |
| `LOKI_ENGINE_IMAGE` | `rust` (avec `LOKI_BACKENDS=rust`) : le panneau « moteur de l'image » — version qui tourne, dernier build publié, version recommandée, mise à jour depuis le registre officiel avec essai à blanc, bascules, retour à la version précédente, vérification du rendu du gabarit — est tenu par la façade Rust (expérimental, migration en cours) | Go |
| `LOKI_PROJECTS` | `rust` : le hub des projets (liste et comptes, création, renommage, description, suppression, bascule) est tenu par la façade Rust ; les discussions restent à qui tient la conversation (expérimental, migration en cours) | Go |
| `LOKI_SYSTEM` | `rust` : les interrupteurs système — accès internet et Crawl4AI, pilotage du navigateur, clé de complétion, écoute réseau du moteur — sont tenus par la façade Rust ; le navigateur piloté reste au Go (expérimental, migration en cours) | Go |
@@ -0,0 +1,121 @@
# Lot 25 — Strata tenu par la façade
## Contexte
Strata est le deuxième moteur de Loki. Il répartit les experts d'un MoE de
125 B (famille Qwen3.8-Flash-Next) entre VRAM, RAM et disque. Le moteur est
le paquet **figé** publié par AJEAN : les sources et le binaire Linux CUDA 12,
vérifiés par SHA-256. Un preset le choisit (`ENGINE=strata`).
`backend_strata.go` sert trois routes :
- `/api/strata` : la machine (cartes, RAM, disque, Python), ce qui y tient,
la recommandation par version et les modèles installés, avec leur
configuration de lancement en clair ;
- `/api/strata/settings` : les réglages d'un modèle installé. S'il est en
service, la configuration est réappliquée et le moteur relancé ;
- `/api/strata/install` : un job des moteurs. Il télécharge et vérifie le
paquet, lance l'installeur de Strata, écrit le preset et bascule dessus.
Le lancement (`loki serve` d'un preset Strata) vit aussi dans ce fichier.
## Le lot
Avec `LOKI_STRATA=rust` **et** `LOKI_BACKENDS=rust` (expérimental), la façade
(`rust/loki-rs/src/strata`) sert les trois routes :
- **la décision pure** (`strata/mod.rs`) :
- placement des experts : drop, ram, arena ou disk, selon la RAM
disponible, la carte d'appoint et `experts.bin` ;
- configuration de lancement : réglages validés, carte d'aide, mode des
experts, réglages du preset ;
- ce qui tient sur la machine, la recommandation, les réglages, les noms ;
- **la machine** (`strata/api.rs`) :
- cartes via `nvidia-smi` (la sonde des backends) ;
- Python 3.10+ et son module venv ;
- RAM totale et disponible (`/proc/meminfo`) ;
- RAM du moteur Strata en marche (arbre du service dans `/proc`) ;
- place libre ;
- **l'installation** : le job des moteurs. La sortie de l'installeur est
découpée en lignes comme le `sinkWriter` du Go : une barre `\r` n'est
montrée que toutes les 10 s. La bascule finale passe par le Go
(`/internal/v1/preset/apply`, mode `switch`), qui applique le preset et
relance le service.
Sans les backends, loki-rs ne passe pas la variable au Go et Strata reste au
Go. Le lancement reste au Go (`loki serve`, que `loki-rs engine serve` lui
délègue pour un preset Strata).
Côté Go, `facade_strata.go` relaie à la façade les routes reçues par le
tunnel.
## Correction du Go
`/api/strata/settings` décidait si le preset était en service **après** y
avoir écrit les nouveaux réglages. L'empreinte du nouveau contenu ne
correspondait alors plus à la configuration active : changer un réglage du
modèle en service ne l'appliquait jamais, et le moteur n'était pas relancé.
« En service » se décide désormais avant l'écriture, comme dans
`SavePresetApplying`. Le port Rust suit.
## Parité
`internal/loki/parity_strata_test.go` (Linux x86_64) tire du Go un journal
rejoué par `tests/strata.rs`. Pour le tirer, le Go reçoit des crochets de
test : `strataReleaseBase` (variable), `meminfoPath`, `strataDiskFree` et
`serviceActionOSFn`. Le journal couvre :
- **la machine** :
- sans carte, nvidia-smi en panne ou vide, carte trop ancienne ou trop
petite, carte d'aide trop ancienne ;
- trois cartes de même VRAM : la plus récente devant (capacité de calcul
arrondie au dixième) ;
- Python absent, en panne, illisible, trop ancien, sans venv ;
- 16, 47 ou 64 Go de RAM, meminfo sans MemAvailable ;
- disque presque plein ou juste trop petit (marge de 5 Go), données déjà
présentes ;
- **les réglages** :
- GET, corps illisible, preset vide ou avec chemin, preset absent ou
llama.cpp ;
- hors limites, experts inconnus ;
- preset inactif ; preset actif (appliqué et relancé), et pendant une
optimisation ;
- **l'installation** :
- GET, corps illisible, quant inconnu, optimisation en cours ;
- machine sans carte, modèle qui ne tient pas ;
- release introuvable, moteur altéré (empreinte) ;
- installation complète, puis réinstallation (sources déjà là et
décompressées, moteur téléchargé périmé retéléchargé) ;
- installeur en échec, installeur sans configuration ;
- **les modèles installés** :
- détails de lancement du modèle actif et de modèles inactifs (experts
sur disque ou en RAM, vision coupée, sans carte d'aide) ;
- mode du dernier lancement ;
- `experts.bin` avec le moteur du paquet ou d'un autre paquet ;
- RAM juste sous ou au-dessus du seuil du mode hors RAM, mode hors RAM
coupé dans le preset ;
- configuration de lancement disparue ou illisible ;
- **après chaque appel** : les actions du service, les clés de la
configuration et l'arborescence de `presets/` et `strata/` (sans le
contenu des archives).
Autres tests :
- les tests du Go (`backend_strata_test.go`), portés (`strata/tests.rs`) :
configuration de lancement, placement des experts, recommandation,
réglages, noms ;
- la RAM du moteur en marche, le découpage en lignes et les nombres écrits
à la Go ;
- le Go (`TestFacadeStrata`) : les trois routes partent à la façade, et
elles seules.
## Mutations
71 mutations du port Rust, toutes tuées (les dix survivantes du premier
passage l'ont été par les étapes et les tests ajoutés ci-dessus).
## Écarts assumés
- Les erreurs réseau (release injoignable) gardent le texte de `reqwest`.
- Une copie du moteur qui échoue (`prebuilt/`) ne donne pas exactement le
message du Go, qui ouvre la source puis la destination.
+33 -15
View File
@@ -52,13 +52,23 @@ import (
// autre paquet est remplacé au lancement (strataEnsureEngine).
const (
strataVersion = "1.0"
strataReleaseBase = "https://github.com/nathaninline/ajean/releases/download/moe-v" + strataVersion + "/"
strataSrcAsset = "ajean-moe-src-" + strataVersion + ".tar.gz"
strataEngineAsset = "ajean-moe-engine-" + strataVersion + "-linux-x64-cuda12.zip"
// strataEngineLocal : le nom sous lequel l'installeur attend le moteur (CUDA12_ASSET).
strataEngineLocal = "strata-linux-x64-cuda12.zip"
)
// strataReleaseBase : la release figée. Une variable pour que les tests la
// pointent sur un faux serveur.
var strataReleaseBase = "https://github.com/nathaninline/ajean/releases/download/moe-v" + strataVersion + "/"
// meminfoPath, strataDiskFree : la RAM et la place libre lues sur la machine,
// remplaçables par les tests (journal de parité de Strata).
var (
meminfoPath = "/proc/meminfo"
strataDiskFree = diskFree
)
// strataAssetSHA : empreintes des fichiers de la release, vérifiées avant usage.
var strataAssetSHA = map[string]string{
strataSrcAsset: "5747f69426973d957e4f9894a8e3333c6ef0d6ac8565e26ddb8d36f52211cb3b",
@@ -154,7 +164,7 @@ const (
// memAvailableGB : la RAM réellement disponible (MemAvailable : libre + cache
// récupérable), en Go. Hors Linux ou illisible : la RAM totale.
func memAvailableGB() float64 {
b, err := os.ReadFile("/proc/meminfo")
b, err := os.ReadFile(meminfoPath)
if err != nil {
return totalRAMGB()
}
@@ -286,7 +296,7 @@ func strataDetect() strataEnv {
env.AvailGB = min(memAvailableGB()+strataEngineRSSGB(), env.RAMGB)
// strataHome() et pas LokiHome() : /data/strata peut être un disque à part
// (SSD dédié monté dans le conteneur), c'est sa place libre qui compte.
if f := diskFree(strataHome()); f > 0 {
if f := strataDiskFree(strataHome()); f > 0 {
env.DiskFreeGB = float64(f) / 1e9
}
if runtime.GOOS != "linux" || runtime.GOARCH != "amd64" {
@@ -1380,25 +1390,33 @@ func handleStrataSettings(w http.ResponseWriter, r *http.Request) {
sendJSON(w, 400, map[string]any{"ok": false, "error": err.Error()})
return
}
// « Actif » se décide AVANT l'écriture (comme SavePresetApplying) : c'est
// l'ancien contenu qui porte l'empreinte de la configuration courante. Lu
// après, un réglage changé faisait passer le preset en service pour
// inactif, et rien n'était appliqué.
active := false
if list, err := ListPresets(); err == nil {
for _, p := range list {
if p.ID == req.Preset && p.Active {
active = true
}
}
}
path := filepath.Join(presetsDir(), req.Preset+".env")
if err := os.WriteFile(path, []byte(next), 0o644); err != nil {
sendJSON(w, 500, map[string]any{"ok": false, "error": err.Error()})
return
}
restarted := false
if list, err := ListPresets(); err == nil {
for _, p := range list {
if p.ID == req.Preset && p.Active {
if tuneDenyHTTP(w) {
return
}
if err := SwitchToPreset(path); err != nil {
sendJSON(w, 500, map[string]any{"ok": false, "error": err.Error()})
return
}
restarted = true
}
if active {
if tuneDenyHTTP(w) {
return
}
if err := SwitchToPreset(path); err != nil {
sendJSON(w, 500, map[string]any{"ok": false, "error": err.Error()})
return
}
restarted = true
}
sendJSON(w, 200, map[string]any{"ok": true, "restarted": restarted})
}
+3 -1
View File
@@ -142,7 +142,7 @@ var facadeChatRoutes = map[string]bool{
// moteur (facade_engine.go), les interrupteurs système (facade_system.go), les
// projets (facade_projects.go), les serveurs MCP (facade_mcp.go), la mise à
// jour (facade_update.go), les backends llama.cpp (facade_backends.go), le
// moteur de l'image (facade_engineimg.go).
// moteur de l'image (facade_engineimg.go), Strata (facade_strata.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 {
@@ -177,6 +177,8 @@ func withFacadeChat(path string, h http.HandlerFunc) http.HandlerFunc {
held, unavailable = facadeBackends, "moteurs indisponibles : "
case facadeEngineImageRoutes[path]:
held, unavailable = facadeEngineImage, "moteur de l'image indisponible : "
case facadeStrataRoutes[path]:
held, unavailable = facadeStrata, "Strata indisponible : "
default:
return h
}
+28
View File
@@ -0,0 +1,28 @@
package loki
import (
"os"
"strings"
)
// facade_strata.go — Strata tenu par la façade Rust (LOKI_STRATA=rust, lot 25
// de la migration ; expérimental, le Go reste le défaut) : la machine et ce
// qui y tient, les réglages d'un modèle installé et l'installation (job des
// moteurs, tenu par la façade avec LOKI_BACKENDS=rust) —
// rust/loki-rs/src/strata. loki-rs ne passe la variable qu'avec les backends.
// Le lancement (`loki serve` d'un preset ENGINE=strata) reste ici.
const facadeStrataEnv = "LOKI_STRATA"
// facadeStrata : la façade tient-elle Strata ?
func facadeStrata() bool {
return strings.EqualFold(strings.TrimSpace(os.Getenv(facadeStrataEnv)), "rust") &&
facadeAddr() != "" && internalToken() != ""
}
// facadeStrataRoutes : les routes que la façade sert alors.
var facadeStrataRoutes = map[string]bool{
"/api/strata": true,
"/api/strata/install": true,
"/api/strata/settings": true,
}
+67
View File
@@ -0,0 +1,67 @@
package loki
import (
"io"
"net/http"
"net/http/httptest"
"strings"
"sync"
"testing"
)
// Strata part à la façade avec LOKI_STRATA=rust (lot 25) ; le reste du
// moteur, non.
func TestFacadeStrata(t *testing.T) {
testHome(t)
var mu sync.Mutex
var calls []string
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.Header.Get(internalTokenHeader) != jetonTest {
w.WriteHeader(403)
return
}
mu.Lock()
calls = append(calls, r.Method+" "+r.URL.Path)
mu.Unlock()
io.WriteString(w, `{"ok":true,"de":"facade"}`)
}))
defer srv.Close()
t.Setenv(internalTokenEnv, jetonTest)
t.Setenv(facadeAddrEnv, strings.TrimPrefix(srv.URL, "http://"))
local := func(w http.ResponseWriter, r *http.Request) { io.WriteString(w, "go") }
t.Setenv(facadeStrataEnv, "")
for p := range facadeStrataRoutes {
w := httptest.NewRecorder()
withFacadeChat(p, local)(w, httptest.NewRequest("POST", p, strings.NewReader("{}")))
if w.Body.String() != "go" {
t.Fatalf("sans LOKI_STRATA, %s : %q", p, w.Body.String())
}
}
t.Setenv(facadeStrataEnv, " RUST")
if len(facadeStrataRoutes) != 3 {
t.Fatalf("routes : %v", facadeStrataRoutes)
}
for p := range facadeStrataRoutes {
w := httptest.NewRecorder()
withFacadeChat(p, local)(w, httptest.NewRequest("POST", p, strings.NewReader("{}")))
if !strings.Contains(w.Body.String(), "facade") {
t.Fatalf("%s non relayé : %q", p, w.Body.String())
}
}
mu.Lock()
n := len(calls)
mu.Unlock()
if n != len(facadeStrataRoutes) {
t.Fatalf("relais : %v", calls)
}
w := httptest.NewRecorder()
withFacadeChat("/api/llamacpp/install", local)(w, httptest.NewRequest("POST", "/api/llamacpp/install", nil))
if w.Body.String() != "go" {
t.Fatalf("la compilation reste au Go : %q", w.Body.String())
}
t.Setenv(internalTokenEnv, "")
if facadeStrata() {
t.Fatal("tenu sans jeton interne")
}
}
+464
View File
@@ -0,0 +1,464 @@
//go:build linux
package loki
import (
"crypto/sha256"
"encoding/hex"
"encoding/json"
"io/fs"
"net/http"
"net/http/httptest"
"os"
"os/exec"
"path/filepath"
"runtime"
"sort"
"strings"
"sync"
"testing"
"time"
)
// parity_strata_test.go — journal de référence de Strata (lot 25 de la
// migration Rust) : la machine vue par l'écran d'installation (cartes,
// Python, RAM, disque) et ce qui y tient, les réglages d'un modèle installé
// (et la bascule qui les applique), l'installation (paquet figé vérifié,
// installeur lancé, preset écrit puis activé) et ses échecs.
// Rangé dans rust/loki-rs/tests/parity_strata/strata.json ; tests/strata.rs
// le rejoue par la façade.
//
// LOKI_PARITY_UPDATE=1 go test ./internal/loki -run TestPariteStrata
const parityStrataFile = "../../rust/loki-rs/tests/parity_strata/strata.json"
// Ce que sert la fausse release : les sources (archive), le moteur (des
// octets), un statut imposé, des empreintes attendues fausses.
type parityStrataRel struct {
Status int `json:"status,omitempty"`
Src []parityArchEntry `json:"src"`
Engine string `json:"engine"`
BadSHA bool `json:"bad_sha,omitempty"`
}
type parityStrataStep struct {
Name string `json:"name"`
Machine string `json:"machine,omitempty"`
Meminfo string `json:"meminfo,omitempty"`
DiskFree int64 `json:"disk_free,omitempty"`
Files map[string]string `json:"files,omitempty"`
Config map[string]string `json:"config,omitempty"`
State map[string]string `json:"state,omitempty"`
Release *parityStrataRel `json:"release,omitempty"`
Tune bool `json:"tune,omitempty"`
Method string `json:"method"`
Path string `json:"path"`
Body string `json:"body,omitempty"`
Wait bool `json:"wait,omitempty"`
Status int `json:"status"`
Out any `json:"out"`
Job any `json:"job,omitempty"`
Actions []string `json:"actions"`
After map[string]string `json:"after"`
Tree []string `json:"tree"`
}
// Les machines : nvidia-smi et python3 dans le PATH (bash et mkdir toujours).
func parityStrataMachines() map[string]map[string]string {
py := func(version string, venv bool) string {
s := "#!/bin/sh\ncase \"$1\" in\n--version) echo '" + version + "';;\n-c) "
if venv {
s += "exit 0;;\n"
} else {
s += "echo 'ModuleNotFoundError: No module named ensurepip'; exit 1;;\n"
}
return s + "esac\n"
}
ok := py("Python 3.12.3", true)
smi := func(rows string) string {
return "#!/bin/sh\ncase \"$1\" in\n--query-gpu=*) printf '" + rows + "';;\nesac\n"
}
deux := smi(`0, NVIDIA GeForce RTX 3070, 8192, 100, 8.6, 00000000:01:00.0, 4, 4, 16, 16, 7001\n1, NVIDIA GeForce RTX 5060 Ti, 16311, 5, 12.0, 00000000:02:00.0, 5, 5, 16, 16, 14001\n`)
return map[string]map[string]string{
"sans-carte": {"python3": ok},
"smi-en-panne": {"nvidia-smi": "#!/bin/sh\nexit 9\n", "python3": ok},
"smi-vide": {"nvidia-smi": smi(``), "python3": ok},
"vieille-carte": {"nvidia-smi": smi(`0, NVIDIA GeForce GTX 1080 Ti, 11264, 0, 6.1, 00000000:01:00.0, 3, 3, 16, 16, 5505\n`), "python3": ok},
"petite-carte": {"nvidia-smi": smi(`0, NVIDIA GeForce RTX 3050, 6144, 0, 8.6, 00000000:01:00.0, 4, 4, 8, 8, 7001\n`), "python3": ok},
"sans-python": {"nvidia-smi": deux},
"python-casse": {"nvidia-smi": deux, "python3": "#!/bin/sh\nexit 1\n"},
"python-muet": {"nvidia-smi": deux, "python3": py("Python inconnu", true)},
"python-ancien": {"nvidia-smi": deux, "python3": py("Python 3.8.10", true)},
"sans-venv": {"nvidia-smi": deux, "python3": py("Python 3.11.2", false)},
"deux-cartes": {"nvidia-smi": deux, "python3": ok},
"jumelles": {"nvidia-smi": smi(`0, NVIDIA GeForce RTX 4060 Ti, 16311, 0, 8.9, 00000000:01:00.0, 4, 4, 8, 8, 9001\n1, NVIDIA GeForce RTX 5060 Ti, 16311, 0, 12.0, 00000000:02:00.0, 5, 5, 8, 8, 14001\n2, NVIDIA GeForce GTX 1650, 4096, 0, 7.46, 00000000:03:00.0, 3, 3, 16, 16, 4001\n`),
"python3": ok},
"aide-ancienne": {"nvidia-smi": smi(`0, NVIDIA GeForce GTX 1070, 8192, 0, 6.1, 00000000:01:00.0, 3, 3, 16, 16, 4004\n1, NVIDIA GeForce RTX 4090, 24564, 0, 8.9, 00000000:02:00.0, 4, 4, 16, 16, 10501\n`),
"python3": ok},
}
}
// L'installeur factice : ses arguments, une barre de progression, puis ce
// que le modèle demandé lui fait faire.
const parityStrataSetup = `#!/bin/bash
echo "installeur : $*"
echo "cache : $XDG_CACHE_HOME"
printf 'modèle 10%%\r'
printf 'modèle 20%%\r'
echo "modèle téléchargé"
mkdir -p engine-cuda12 .venv/bin
fam=""; model=""; data=""
while [ $# -gt 0 ]; do
case "$1" in
--family) fam="$2"; shift;;
--model) model="$2"; shift;;
--data-dir) data="$2"; shift;;
esac
shift
done
case "$model" in
Q2_0) echo "erreur : téléchargement interrompu" >&2; exit 3;;
IQ2_XS) echo "rien d'écrit"; exit 0;;
esac
low=$(echo "$model" | tr 'A-Z' 'a-z')
if [ "$fam" = "swift" ]; then tag="swift-$low"; else tag="$low"; fi
mkdir -p "$data/packs/$tag"
printf '{"exe":"engine-cuda12/strata","args":["--pack","%s","--kv","int8","--spec","4","--max-context","131072","--vision","--vram-reserve-mib","700"],"vision":{"exe":"v","gpu":true},"gpu":0,"port":8080}\n' "$data/packs/$tag" > "strata-$tag.json"
echo "prêt : strata-$tag.json"
`
func TestPariteStrata(t *testing.T) {
if runtime.GOARCH != "amd64" {
t.Skip("Strata n'est proposé que sous Linux x86_64")
}
home := testHome(t)
resetBackendsState()
t.Cleanup(resetBackendsState)
tools := t.TempDir()
bash, err := exec.LookPath("bash")
if err != nil {
t.Skip("bash introuvable")
}
mkdir, _ := exec.LookPath("mkdir")
tr, _ := exec.LookPath("tr")
t.Setenv("PATH", tools)
machines := parityStrataMachines()
setMachine := func(name string) {
entries, _ := os.ReadDir(tools)
for _, e := range entries {
_ = os.Remove(filepath.Join(tools, e.Name()))
}
for n, p := range map[string]string{"bash": bash, "mkdir": mkdir, "tr": tr} {
if err := os.Symlink(p, filepath.Join(tools, n)); err != nil {
t.Fatal(err)
}
}
for n, body := range machines[name] {
if err := os.WriteFile(filepath.Join(tools, n), []byte(body), 0o755); err != nil {
t.Fatal(err)
}
}
}
meminfo := filepath.Join(t.TempDir(), "meminfo")
oldMem, oldDisk, oldBase, oldSHA, oldSvc := meminfoPath, strataDiskFree, strataReleaseBase, strataAssetSHA, serviceActionOSFn
var mu sync.Mutex
var disk int64
var actions []string
meminfoPath = meminfo
strataDiskFree = func(string) int64 { mu.Lock(); defer mu.Unlock(); return disk }
serviceActionOSFn = func(a string) error { mu.Lock(); defer mu.Unlock(); actions = append(actions, a); return nil }
t.Cleanup(func() {
meminfoPath, strataDiskFree, strataReleaseBase, strataAssetSHA, serviceActionOSFn = oldMem, oldDisk, oldBase, oldSHA, oldSvc
})
// La fausse release.
var rel parityStrataRel
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
mu.Lock()
cur := rel
mu.Unlock()
if cur.Status != 0 {
w.WriteHeader(cur.Status)
return
}
switch strings.TrimPrefix(r.URL.Path, "/") {
case strataSrcAsset:
_, _ = w.Write(parityTarGz(t, cur.Src))
case strataEngineAsset:
_, _ = w.Write([]byte(cur.Engine))
default:
w.WriteHeader(404)
}
}))
defer srv.Close()
strataReleaseBase = srv.URL + "/"
setRelease := func(r parityStrataRel) {
mu.Lock()
rel = r
mu.Unlock()
sum := func(b []byte) string { h := sha256.Sum256(b); return hex.EncodeToString(h[:]) }
strataAssetSHA = map[string]string{
strataSrcAsset: sum(parityTarGz(t, r.Src)),
strataEngineAsset: sum([]byte(r.Engine)),
}
if r.BadSHA {
strataAssetSHA[strataEngineAsset] = strings.Repeat("0", 64)
}
}
mask := func(s string) string {
s = strings.ReplaceAll(s, srv.URL, "{{REL}}")
s = strings.ReplaceAll(s, home, "{{HOME}}")
return parityTSRe.ReplaceAllString(s, `"$1":"{{T}}"`)
}
unmask := func(s string) string { return strings.ReplaceAll(s, "{{HOME}}", home) }
handlers := map[string]http.HandlerFunc{
"/api/strata": handleStrata,
"/api/strata/settings": handleStrataSettings,
"/api/strata/install": handleStrataInstall,
}
// L'arborescence : presets/ et strata/ (sans le contenu des archives, qui
// diffère d'une implémentation à l'autre).
tree := func() []string {
var out []string
for _, root := range []string{"presets", "strata"} {
_ = filepath.WalkDir(filepath.Join(home, root), func(p string, d fs.DirEntry, err error) error {
if err != nil {
return nil
}
rel, _ := filepath.Rel(home, p)
info, err := os.Lstat(p)
if err != nil {
return nil
}
switch {
case info.IsDir():
out = append(out, "d "+rel)
case strings.HasPrefix(rel, "strata/dl/") || strings.HasPrefix(rel, "strata/prebuilt/"):
out = append(out, "f "+rel)
default:
b, _ := os.ReadFile(p)
out = append(out, "f "+rel+" "+info.Mode().Perm().String()+" "+mask(string(b)))
}
return nil
})
}
sort.Strings(out)
return out
}
src := []parityArchEntry{
{Name: "ajean-moe-1.0/", Type: "dir", Mode: 0o755},
{Name: "ajean-moe-1.0/setup.sh", Type: "file", Body: parityStrataSetup, Mode: 0o755},
{Name: "ajean-moe-1.0/setup.py", Type: "file", Body: "# installeur\n", Mode: 0o644},
{Name: "ajean-moe-1.0/serve/server.py", Type: "file", Body: "# serveur\n", Mode: 0o644},
}
goodRel := parityStrataRel{Src: src, Engine: "moteur strata 1.0"}
mem47 := "MemTotal: 49283072 kB\nMemFree: 1000000 kB\nMemAvailable: 41943040 kB\n"
mem64 := "MemTotal: 67108864 kB\nMemAvailable: 65011712 kB\n"
mem16 := "MemTotal: 16777216 kB\nMemAvailable: 12582912 kB\n"
settings := func(preset string, ctx int, extra string) string {
return `{"preset":"` + preset + `","ctx":` + jsonInt(ctx) + `,"kv":"int8","spec":4,"vision":false,"helper":true` + extra + `}`
}
var steps []parityStrataStep
run := func(st parityStrataStep) {
if st.Machine != "" {
setMachine(st.Machine)
}
if st.Meminfo != "" {
if err := os.WriteFile(meminfo, []byte(st.Meminfo), 0o644); err != nil {
t.Fatal(err)
}
}
if st.DiskFree != 0 {
mu.Lock()
disk = st.DiskFree
mu.Unlock()
}
files := map[string]string{}
for k, v := range st.Files {
files[k] = unmask(v)
}
parityApplyFiles(t, home, files)
for k, v := range st.Config {
if err := SetConfigKey(k, unmask(v)); err != nil {
t.Fatal(err)
}
}
for k, v := range st.State {
if err := putStr(bkState, k, v); err != nil {
t.Fatal(err)
}
}
if st.Release != nil {
setRelease(*st.Release)
}
mu.Lock()
actions = nil
mu.Unlock()
var lock *tuneLock
if st.Tune {
l, err := tuneLockAcquire("web", false)
if err != nil {
t.Fatal(err)
}
lock = l
}
rec := httptest.NewRecorder()
handlers[st.Path](rec, httptest.NewRequest(st.Method, st.Path, strings.NewReader(unmask(st.Body))))
if lock != nil {
lock.release()
}
st.Status = rec.Code
if err := json.Unmarshal([]byte(mask(rec.Body.String())), &st.Out); err != nil {
t.Fatalf("%s : corps illisible %q", st.Name, rec.Body.String())
}
if st.Wait {
deadline := time.Now().Add(30 * time.Second)
for lcJobSnapshot(0, false)["running"] == true {
if time.Now().After(deadline) {
t.Fatalf("%s : le job ne finit pas", st.Name)
}
time.Sleep(10 * time.Millisecond)
}
b, _ := json.Marshal(lcJobSnapshot(0, true))
_ = json.Unmarshal([]byte(mask(string(b))), &st.Job)
}
mu.Lock()
st.Actions = append([]string{}, actions...)
mu.Unlock()
st.After = map[string]string{}
cfg := ReadConfig()
for _, k := range []string{"ENGINE", "STRATA_CONFIG", "CTX", "STRATA_KV", "STRATA_HELPER"} {
if v := cfg[k]; v != "" {
st.After["config/"+k] = mask(v)
}
}
st.Tree = tree()
steps = append(steps, st)
}
for _, st := range []parityStrataStep{
{Name: "aucune carte", Machine: "sans-carte", Meminfo: mem47, DiskFree: 400e9, Method: "GET", Path: "/api/strata"},
{Name: "nvidia-smi en panne", Machine: "smi-en-panne", Method: "GET", Path: "/api/strata"},
{Name: "nvidia-smi sans carte", Machine: "smi-vide", Method: "GET", Path: "/api/strata"},
{Name: "carte trop ancienne", Machine: "vieille-carte", Method: "GET", Path: "/api/strata"},
{Name: "carte trop petite", Machine: "petite-carte", Method: "GET", Path: "/api/strata"},
{Name: "sans Python", Machine: "sans-python", Method: "GET", Path: "/api/strata"},
{Name: "Python en panne", Machine: "python-casse", Method: "GET", Path: "/api/strata"},
{Name: "Python illisible", Machine: "python-muet", Method: "GET", Path: "/api/strata"},
{Name: "Python trop ancien", Machine: "python-ancien", Method: "GET", Path: "/api/strata"},
{Name: "sans venv", Machine: "sans-venv", Method: "GET", Path: "/api/strata"},
{Name: "deux cartes, 47 Go", Machine: "deux-cartes", Method: "GET", Path: "/api/strata"},
{Name: "trois cartes, la plus récente devant", Machine: "jumelles", Method: "GET", Path: "/api/strata"},
{Name: "carte d'aide trop ancienne", Machine: "aide-ancienne", Method: "GET", Path: "/api/strata"},
{Name: "64 Go de RAM", Machine: "deux-cartes", Meminfo: mem64, Method: "GET", Path: "/api/strata"},
{Name: "16 Go de RAM", Meminfo: mem16, Method: "GET", Path: "/api/strata"},
{Name: "disque presque plein", Meminfo: mem47, DiskFree: 50e9, Method: "GET", Path: "/api/strata"},
{Name: "disque juste trop petit", DiskFree: 78e9, Method: "GET", Path: "/api/strata"},
{Name: "données déjà là", DiskFree: 60e9, Files: map[string]string{
"strata/data/models/swift-IQ3_XXS/a.gguf": strings.Repeat("x", 2000),
}, Method: "GET", Path: "/api/strata"},
{Name: "meminfo sans MemAvailable", DiskFree: 400e9, Meminfo: "MemTotal: 49283072 kB\n", Method: "GET", Path: "/api/strata"},
// Réglages.
{Name: "réglages en GET", Meminfo: mem47, Method: "GET", Path: "/api/strata/settings"},
{Name: "réglages illisibles", Method: "POST", Path: "/api/strata/settings", Body: `{"ctx":"x"}`},
{Name: "réglages sans preset", Method: "POST", Path: "/api/strata/settings", Body: settings("", 65536, "")},
{Name: "réglages, preset avec chemin", Method: "POST", Path: "/api/strata/settings", Body: settings("../x", 65536, "")},
{Name: "réglages, preset absent", Method: "POST", Path: "/api/strata/settings", Body: settings("absent", 65536, "")},
{Name: "réglages, preset llama.cpp", Files: map[string]string{"presets/llama.env": "MODEL=/m.gguf\n"},
Method: "POST", Path: "/api/strata/settings", Body: settings("llama", 65536, "")},
{Name: "réglages hors limites", Files: map[string]string{"presets/S.env": "# NAME=Strata S\nENGINE=strata\nSTRATA_HELPER_GPU=0\nCTX=131072\n"},
Method: "POST", Path: "/api/strata/settings", Body: settings("S", 1000, "")},
{Name: "réglages, experts inconnus", Method: "POST", Path: "/api/strata/settings", Body: settings("S", 65536, `,"experts":"nvme"`)},
{Name: "réglages d'un preset inactif", Method: "POST", Path: "/api/strata/settings", Body: settings("S", 65536, `,"experts":"disk"`)},
{Name: "réglages du preset actif", Config: map[string]string{"ENGINE": "strata", "STRATA_HELPER_GPU": "0", "CTX": "65536",
"STRATA_KV": "int8", "STRATA_SPEC": "4", "STRATA_VISION": "0", "STRATA_HELPER": "1", "STRATA_EXPERTS": "disk"},
Method: "POST", Path: "/api/strata/settings", Body: settings("S", 98304, "")},
{Name: "réglages du preset actif pendant une optimisation", Tune: true,
Method: "POST", Path: "/api/strata/settings", Body: settings("S", 98304, "")},
// Installation.
{Name: "installation en GET", Method: "GET", Path: "/api/strata/install"},
{Name: "installation illisible", Method: "POST", Path: "/api/strata/install", Body: `[`},
{Name: "installation d'un quant inconnu", Method: "POST", Path: "/api/strata/install", Body: `{"family":"swift","quant":"Q2_0"}`},
{Name: "installation pendant une optimisation", Tune: true, Method: "POST", Path: "/api/strata/install", Body: `{"family":"swift","quant":"IQ3_XXS"}`},
{Name: "installation sur une machine sans carte", Machine: "sans-carte", Method: "POST", Path: "/api/strata/install",
Body: `{"family":"swift","quant":"IQ3_XXS"}`, Wait: true},
{Name: "installation qui ne tient pas", Machine: "deux-cartes", Meminfo: mem16, Method: "POST", Path: "/api/strata/install",
Body: `{"family":"qwen","quant":"IQ3_S"}`, Wait: true},
{Name: "release introuvable", Meminfo: mem47, Release: &parityStrataRel{Status: 404, Src: src, Engine: "x"},
Method: "POST", Path: "/api/strata/install", Body: `{"family":"swift","quant":"IQ3_XXS"}`, Wait: true},
{Name: "moteur altéré", Release: &parityStrataRel{Src: src, Engine: "moteur strata 1.0", BadSHA: true},
Method: "POST", Path: "/api/strata/install", Body: `{"family":"swift","quant":"IQ3_XXS"}`, Wait: true},
{Name: "installation", Release: &goodRel, Method: "POST", Path: "/api/strata/install",
Body: `{"family":"swift","quant":"IQ3_XXS"}`, Wait: true},
{Name: "après l'installation", Method: "GET", Path: "/api/strata"},
{Name: "mode lancé connu, modèles inactifs", State: map[string]string{"strata_experts_mode": "drop"}, Files: map[string]string{
"presets/STRATA FLASH NEXT CLASSIQUE IQ2_XS.env": "# NAME=Classique IQ2_XS\nENGINE=strata\nSTRATA_CONFIG={{HOME}}/strata/ajean-moe-1.0/strata-swift-iq3_xxs.json\n" +
"STRATA_MAIN_GPU=1\nSTRATA_HELPER_GPU=-1\nSTRATA_EXPERTS=disk\n",
"presets/STRATA FLASH NEXT CLASSIQUE Q2_0.env": "ENGINE=moe\nSTRATA_CONFIG={{HOME}}/strata/ajean-moe-1.0/strata-swift-iq3_xxs.json\n" +
"STRATA_MAIN_GPU=0\nSTRATA_HELPER_GPU=1\nSTRATA_HELPER=0\nSTRATA_VISION=0\nSTRATA_EXPERTS=ram\n",
}, Method: "GET", Path: "/api/strata"},
{Name: "experts.bin et moteur du paquet", Files: map[string]string{
"strata/data/packs/swift-iq3_xxs/experts.bin": "x",
}, State: map[string]string{"strata_experts_mode": ""}, Method: "GET", Path: "/api/strata"},
{Name: "RAM juste sous le seuil du mode hors RAM", Meminfo: "MemTotal: 49283072 kB\nMemAvailable: 43164063 kB\n",
Method: "GET", Path: "/api/strata"},
{Name: "RAM au-dessus du seuil du mode hors RAM", Meminfo: "MemTotal: 49283072 kB\nMemAvailable: 43457032 kB\n",
Method: "GET", Path: "/api/strata"},
{Name: "mode hors RAM coupé dans le preset", Files: map[string]string{
"presets/STRATA FLASH NEXT SWIFT 1.5 IQ3_XXS.env": "# NAME=Qwen3.8 Flash Next Swift 1.5 IQ3_XXS (Strata)\nENGINE=strata\n" +
"STRATA_CONFIG={{HOME}}/strata/ajean-moe-1.0/strata-swift-iq3_xxs.json\nSTRATA_MAIN_GPU=1\nSTRATA_HELPER_GPU=0\n" +
"STRATA_VISION=1\nSTRATA_EXPERTS=auto\nSTRATA_DROP=0\nSTRATA_KV=fp16\nSTRATA_KV_RESIDENT=32768\nSTRATA_SPEC=3\nCTX=131072\nREASONING=off\n",
}, Method: "GET", Path: "/api/strata"},
{Name: "moteur d'un autre paquet", Meminfo: mem47, Files: map[string]string{
"strata/ajean-moe-1.0/engine-cuda12/.loki-engine": "ajean-moe-engine-0.9-linux-x64-cuda12.zip\n",
}, Method: "GET", Path: "/api/strata"},
{Name: "réinstallation, moteur téléchargé périmé", Files: map[string]string{
"strata/dl/ajean-moe-engine-1.0-linux-x64-cuda12.zip": "ancien moteur",
}, Method: "POST", Path: "/api/strata/install", Body: `{"family":"swift","quant":"IQ3_XXS"}`, Wait: true},
{Name: "installeur en échec", Method: "POST", Path: "/api/strata/install", Body: `{"family":"qwen","quant":"Q2_0"}`, Wait: true},
{Name: "installeur sans configuration", Method: "POST", Path: "/api/strata/install", Body: `{"family":"qwen","quant":"IQ2_XS"}`, Wait: true},
{Name: "configuration de lancement disparue", Files: map[string]string{
"strata/ajean-moe-1.0/strata-swift-iq3_xxs.json": "-",
}, Method: "GET", Path: "/api/strata"},
{Name: "configuration de lancement illisible", Files: map[string]string{
"strata/ajean-moe-1.0/strata-swift-iq3_xxs.json": "[1]",
}, Method: "GET", Path: "/api/strata"},
} {
run(st)
}
got, err := parityJSON(map[string]any{"machines": machines, "setup": parityStrataSetup, "steps": steps})
if err != nil {
t.Fatal(err)
}
if os.Getenv("LOKI_PARITY_UPDATE") == "1" {
if err := os.MkdirAll(filepath.Dir(parityStrataFile), 0o755); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(parityStrataFile, got, 0o644); err != nil {
t.Fatal(err)
}
return
}
want, err := os.ReadFile(parityStrataFile)
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("Strata ne produit plus le journal rangé dans %s.\n"+
"Si c'est voulu : LOKI_PARITY_UPDATE=1 go test ./internal/loki -run TestPariteStrata,\n"+
"puis cargo test --test strata dit si le port Rust suit.\n--- obtenu ---\n%s", parityStrataFile, got)
}
}
func jsonInt(n int) string {
b, _ := json.Marshal(n)
return string(b)
}
+1 -1
View File
@@ -85,7 +85,7 @@ func totalRAMGB() float64 {
}
return 0
}
b, err := os.ReadFile("/proc/meminfo")
b, err := os.ReadFile(meminfoPath)
if err != nil {
return 0
}
+5 -1
View File
@@ -31,9 +31,13 @@ func serviceAction(action string) error {
}
tuneReapStale()
}
return serviceActionOS(action)
return serviceActionOSFn(action)
}
// serviceActionOSFn : serviceActionOS, remplaçable par les tests (une bascule
// de preset ne doit pas lancer de vrai service depuis `go test`).
var serviceActionOSFn = serviceActionOS
// preflightEngine vérifie ce sans quoi le moteur ne PEUT pas démarrer, avant de
// lancer le service. Sinon llama-server sortait en erreur, systemd le relançait
// toutes les 3 s, et `loki start` affichait un « activating » rassurant pendant
+4
View File
@@ -59,6 +59,9 @@ pub struct Ctx {
/// Panneau « moteur de l'image » tenu par la façade
/// (`LOKI_ENGINE_IMAGE=rust`, avec les backends).
pub engineimg: Option<std::sync::Arc<crate::engineimg::EngineImg>>,
/// Strata (écran, réglages, installation) tenu par la façade
/// (`LOKI_STRATA=rust`, avec les backends).
pub strata: Option<std::sync::Arc<crate::strata::api::Strata>>,
/// Jeton de l'API interne : une requête qui le porte vient du Go.
pub internal_token: String,
}
@@ -82,6 +85,7 @@ impl Ctx {
|| self.update
|| self.backends.is_some()
|| self.engineimg.is_some()
|| self.strata.is_some()
}
/// La requête vient du Go (jeton interne valide) : déjà authentifiée par
+1
View File
@@ -40,6 +40,7 @@ pub mod push;
pub mod serve;
pub mod settings;
pub mod store;
pub mod strata;
pub mod sysinfo;
pub mod system;
pub mod sysupdate;
+14
View File
@@ -174,6 +174,12 @@ fn serve() -> ExitCode {
if held_by_facade("LOKI_ENGINE_IMAGE") && !backends_rust {
eprintln!("[loki-rs] moteur de l'image tenu par le Go (LOKI_BACKENDS=rust requis)");
}
// LOKI_STRATA=rust : l'écran de Strata, ses réglages et son
// installation (un job des moteurs : avec les backends seulement).
let strata_rust = held_by_facade("LOKI_STRATA") && backends_rust;
if held_by_facade("LOKI_STRATA") && !backends_rust {
eprintln!("[loki-rs] Strata tenu par le Go (LOKI_BACKENDS=rust requis)");
}
let engine_rust = held_by_facade("LOKI_ENGINE")
&& match loki_rs::enginectl::usable(
std::env::var("LOKI_CONTAINER").ok().as_deref(),
@@ -207,6 +213,7 @@ fn serve() -> ExitCode {
("LOKI_UPDATE", update_rust),
("LOKI_BACKENDS", backends_rust),
("LOKI_ENGINE_IMAGE", engineimg_rust),
("LOKI_STRATA", strata_rust),
];
for (var, on) in held {
if on {
@@ -332,6 +339,12 @@ fn serve() -> ExitCode {
],
))
});
let strata = strata_rust.then(|| {
std::sync::Arc::new(loki_rs::strata::api::Strata::new(
engine_paths.home.clone(),
loki_rs::backends::probe::Probe::system(),
))
});
let cfg = proxy::Config {
backend,
internal_token: token,
@@ -352,6 +365,7 @@ fn serve() -> ExitCode {
update: update_rust,
backends,
engineimg,
strata,
mcp: mcp_rust.then(|| std::sync::Arc::new(loki_rs::mcp::Mcp::new(mem_home))),
};
let res = proxy::serve(listener, cfg, shutdown_signal()).await;
+1 -1
View File
@@ -832,7 +832,7 @@ async fn tune_deny(store: &Store) -> Option<Response<Body>> {
/// Applique un preset à la configuration vivante (au Go, voir
/// `facade_presets.go`).
async fn preset_apply(store: &Store, path: &Path, mode: &str) -> Result<(), String> {
pub(crate) async fn preset_apply(store: &Store, path: &Path, mode: &str) -> Result<(), String> {
let (code, v) = store
.internal_post(
"/internal/v1/preset/apply",
+9
View File
@@ -100,6 +100,9 @@ pub struct Config {
/// registre, bascules, rendu du gabarit) tenu par la façade
/// (`LOKI_ENGINE_IMAGE=rust`) ; `None` : au Go.
pub engineimg: Option<Arc<crate::engineimg::EngineImg>>,
/// Strata (machine, réglages, installation) tenu par la façade
/// (`LOKI_STRATA=rust`) ; `None` : au Go.
pub strata: Option<Arc<crate::strata::api::Strata>>,
}
struct Proxy {
@@ -141,6 +144,7 @@ pub async fn serve(
update: cfg.update,
backends: cfg.backends,
engineimg: cfg.engineimg,
strata: cfg.strata,
internal_token: cfg.internal_token.clone(),
},
});
@@ -250,6 +254,11 @@ impl Proxy {
{
return crate::engineimg::serve(&self.ctx, e, req, from_go).await;
}
if let Some(s) = &self.ctx.strata
&& crate::strata::api::handles(path)
{
return crate::strata::api::serve(&self.ctx, s, req, from_go).await;
}
if let Some(b) = &self.ctx.backends
&& crate::backends::handles(path)
{
+926
View File
@@ -0,0 +1,926 @@
//! Routes de Strata tenues par la façade (lot 25) : la machine et ce qui y
//! tient (`/api/strata`), les réglages d'un modèle installé
//! (`/api/strata/settings`) et l'installation (`/api/strata/install`, un job
//! des moteurs : paquet figé vérifié, installeur de Strata, preset, bascule).
use std::collections::HashMap;
use std::path::{Path, PathBuf};
use std::sync::{Arc, Mutex};
use std::time::{Duration, Instant};
use http_body_util::{BodyExt, Limited};
use hyper::body::Incoming;
use hyper::{Method, Request, Response, StatusCode};
use serde_json::{Value, json};
use sha2::Digest;
use super::*;
use crate::api::Ctx;
use crate::auth::json as send_json;
use crate::backends::probe::Probe;
use crate::backends::{Backends, Log};
use crate::gojson::{self, Kind};
use crate::proxy::Body;
use crate::store::{BK_STATE, Store};
const SETTINGS_REQ: &str = "strataSettingsReq";
const INSTALL_REQ: &str = "strataInstallReq";
/// Strata, tenu par la façade.
pub struct Strata {
pub paths: Paths,
/// PATH des outils (nvidia-smi, python3, bash) et racine de /proc.
pub probe: Probe,
/// `strataReleaseBase`.
pub release_base: String,
/// `strataAssetSHA`.
pub asset_sha: Mutex<HashMap<String, String>>,
/// Linux x86_64 (le seul paquet publié).
pub platform_ok: bool,
/// Place libre imposée (tests) ; `None` : celle du disque.
pub disk_free: Mutex<Option<i64>>,
client: reqwest::Client,
}
impl Strata {
pub fn new(home: PathBuf, probe: Probe) -> Self {
crate::chat::engine::install_crypto();
Strata {
paths: Paths { home },
probe,
release_base: RELEASE_BASE.into(),
asset_sha: Mutex::new(default_asset_sha()),
platform_ok: cfg!(all(target_os = "linux", target_arch = "x86_64")),
disk_free: Mutex::new(None),
client: reqwest::Client::builder().build().unwrap_or_default(),
}
}
fn meminfo(&self) -> Option<String> {
std::fs::read_to_string(self.probe.sys("/proc/meminfo")).ok()
}
/// `totalRAMGB` (Linux) : MemTotal, en Gio.
pub fn total_ram_gb(&self) -> f64 {
let Some(b) = self.meminfo() else {
return 0.0;
};
for line in b.split('\n') {
if line.starts_with("MemTotal:") {
let f: Vec<&str> = line.split_whitespace().collect();
if f.len() >= 2
&& let Ok(kb) = f[1].parse::<f64>()
{
return kb / (1024.0 * 1024.0);
}
}
}
0.0
}
/// `memAvailableGB` : MemAvailable, en Go ; la RAM totale à défaut.
pub fn mem_available_gb(&self) -> f64 {
let Some(b) = self.meminfo() else {
return self.total_ram_gb();
};
for line in b.split('\n') {
if let Some(v) = line.strip_prefix("MemAvailable:")
&& let Some(f) = v.split_whitespace().next()
&& let Ok(kb) = f.parse::<f64>()
{
return kb * 1024.0 / 1e9;
}
}
self.total_ram_gb()
}
/// `strataEngineRSSGB` : la RAM tenue par le moteur Strata en marche
/// (arbre du service), en Go.
pub fn engine_rss_gb(&self, cfg: &Cfg) -> f64 {
if !is_strata(cfg) {
return 0.0;
}
let svc = std::env::var("LOKI_SERVICE")
.ok()
.filter(|s| !s.is_empty())
.unwrap_or_else(|| "loki-engine".into());
let root: i64 = std::fs::read_to_string(self.paths.home.join(format!("{svc}.pid")))
.ok()
.and_then(|s| s.trim().parse().ok())
.unwrap_or(0);
if root <= 0 {
return 0.0;
}
let proc_dir = self.probe.sys("/proc");
let Ok(rd) = std::fs::read_dir(&proc_dir) else {
return 0.0;
};
let mut parent: HashMap<i64, i64> = HashMap::new();
let mut rss: HashMap<i64, f64> = HashMap::new();
for e in rd.flatten() {
let Ok(pid) = e.file_name().to_string_lossy().parse::<i64>() else {
continue;
};
let Ok(b) = std::fs::read_to_string(e.path().join("status")) else {
continue;
};
for line in b.split('\n') {
if let Some(v) = line.strip_prefix("PPid:") {
parent.insert(pid, v.trim().parse().unwrap_or(0));
} else if let Some(v) = line.strip_prefix("VmRSS:")
&& let Some(f) = v.split_whitespace().next()
{
rss.insert(pid, f.parse::<f64>().unwrap_or(0.0) * 1024.0 / 1e9);
}
}
}
let mut total = 0.0;
for (pid, r) in &rss {
let (mut p, mut n) = (*pid, 0);
while p > 0 && n < 64 {
if p == root {
total += r;
break;
}
p = parent.get(&p).copied().unwrap_or(0);
n += 1;
}
}
total
}
/// `strataCheckPython` : Python 3.10+ et son module venv.
pub async fn check_python(&self) -> Result<String, (String, String)> {
let Some(py) = self.probe.look_path("python3") else {
return Err((
String::new(),
"Python 3 est introuvable : installe-le (apt install python3 python3-venv)".into(),
));
};
let py_s = py.to_string_lossy().into_owned();
let mut c = tokio::process::Command::new(&py);
c.arg("--version").stdin(std::process::Stdio::null());
let (out, err) = crate::backends::devices::combined(c, &py_s, None).await;
if err.is_some() {
return Err((
String::new(),
"Python 3 est introuvable : installe-le (apt install python3 python3-venv)".into(),
));
}
let re = regex::Regex::new(r"Python ([0-9]+)\.([0-9]+)").expect("motif");
let Some(m) = re.captures(&out) else {
return Err((
String::new(),
format!("version de Python illisible : {}", out.trim()),
));
};
let maj: i64 = m[1].parse().unwrap_or(0);
let mnr: i64 = m[2].parse().unwrap_or(0);
let ver = format!("{}.{}", &m[1], &m[2]);
if maj < 3 || (maj == 3 && mnr < 10) {
return Err((
ver.clone(),
format!("Python {ver} est trop ancien : il faut Python 3.10 ou plus récent"),
));
}
let mut c = tokio::process::Command::new(&py);
c.args(["-c", "import venv, ensurepip"])
.stdin(std::process::Stdio::null());
let (_, err) = crate::backends::devices::combined(c, &py_s, None).await;
if err.is_some() {
return Err((
ver.clone(),
format!("le module venv de Python manque : apt install python{ver}-venv"),
));
}
Ok(ver)
}
/// `strataDetect` : la machine, pour l'écran d'installation.
pub async fn detect(&self, cfg: &Cfg) -> Env {
let mut env = Env {
ram_gb: self.total_ram_gb(),
..Env::default()
};
env.avail_gb = (self.mem_available_gb() + self.engine_rss_gb(cfg)).min(env.ram_gb);
let forced = *self.disk_free.lock().unwrap_or_else(|e| e.into_inner());
let free = forced.unwrap_or_else(|| {
crate::downloads::disk_free(&self.paths.strata_home().to_string_lossy())
});
if free > 0 {
env.disk_free_gb = free as f64 / 1e9;
}
if !self.platform_ok {
env.reason = "Strata n'est proposé que sous Linux (x86_64)".into();
return env;
}
let gpus = match self.probe.detect_gpus().await {
Ok(g) if !g.is_empty() => g,
Ok(_) => {
env.reason = "Strata demande une carte NVIDIA (aucune carte détectée)".into();
return env;
}
Err(e) => {
env.reason = format!("Strata demande une carte NVIDIA ({e})");
return env;
}
};
for g in gpus {
let mib: f64 = g.mem_total.trim().parse().unwrap_or(0.0);
let cc: f64 = g.cap.trim().parse().unwrap_or(0.0);
env.gpus.push(Gpu {
index: g.index,
name: g.name,
vram_gb: mib / 1024.0,
arch: (cc * 10.0 + 0.5) as i64,
});
}
let mut cards = env.gpus.clone();
cards.sort_by(|a, b| {
if a.vram_gb != b.vram_gb {
b.vram_gb
.partial_cmp(&a.vram_gb)
.unwrap_or(std::cmp::Ordering::Equal)
} else {
b.arch.cmp(&a.arch)
}
});
if cards[0].arch < MIN_ARCH {
env.reason = format!(
"la carte {} est trop ancienne pour Strata (RTX 20 ou plus récente)",
cards[0].name
);
return env;
}
if cards[0].vram_gb < 7.5 {
env.reason = format!(
"la carte {} n'a que {} Go de VRAM (8 Go minimum)",
cards[0].name,
f0(cards[0].vram_gb)
);
return env;
}
env.main = cards[0].index;
if cards.len() > 1 && cards[1].arch >= MIN_ARCH && cards[1].vram_gb >= 5.5 {
env.helper = cards[1].index;
}
match self.check_python().await {
Ok(v) => {
env.python = v;
env.supported = true;
}
Err((v, e)) => {
env.python = v;
env.reason = e;
}
}
env
}
/// `strataDetailsFor` : la config de lancement d'un preset installé, en
/// clair (celle que le lancement calculerait).
pub async fn details_for(
&self,
store: &Store,
cfg: &Cfg,
order: &[String],
id: &str,
env: &Env,
) -> Option<Value> {
let content = crate::presets_api::read_preset(&self.paths.home, id).ok()?;
let pc = crate::presets::parse_env(&content);
let raw = std::fs::read(cfg_get(&pc, "STRATA_CONFIG")).ok()?;
let base: Value = serde_json::from_slice(&raw).ok()?;
if !base.is_object() {
return None;
}
let mut mode = resolve_experts(
&self.paths,
&pc,
&arg_list(&base),
env.avail_gb,
helper_vram(&pc, &env.gpus),
)
.to_string();
let active = crate::presets::list_presets(&self.paths.home, cfg, order)
.ok()
.and_then(|l| l.into_iter().find(|p| p.id == id))
.is_some_and(|p| p.active);
if active {
let m = store.get_str(BK_STATE, LAUNCH_MODE_KEY).await;
if !m.is_empty() {
mode = m;
}
}
let out = build_config(&base, &pc, "", &mode).ok()?;
let args = arg_list(&out);
let val = |flag: &str| -> String {
args.windows(2)
.find(|w| w[0] == flag)
.map(|w| w[1].clone())
.unwrap_or_default()
};
let gpu_name = |idx: &str| -> String {
env.gpus
.iter()
.find(|g| g.index.to_string() == idx.trim())
.map(|g| {
format!(
"{} ({} Go)",
g.name.strip_prefix("NVIDIA GeForce ").unwrap_or(&g.name),
f0(g.vram_gb)
)
})
.unwrap_or_default()
};
let main_gpu = gpu_name(cfg_get(&pc, "STRATA_MAIN_GPU"));
let mut experts = cfg_get(&pc, "STRATA_EXPERTS").trim().to_lowercase();
if experts != EXPERTS_RAM && experts != EXPERTS_DISK {
experts = EXPERTS_AUTO.into();
}
let mut d = json!({
"version": VERSION, "ctx": val("--max-context"), "kv": val("--kv"),
"spec": val("--spec"), "prefill": val("--prefill"), "short_read": val("--short-read"),
"cache_root": val("--prompt-cache-root"),
"mmap": mode == "disk" || mode == "arena", "drop": mode == "drop",
"experts": experts, "mode_label": experts_label(&mode), "mode": mode,
"main_gpu": main_gpu,
"has_helper": false, "helper_on": false,
});
let kvr = val("--kv-resident");
if !kvr.is_empty() {
d["kv_resident"] = json!(kvr);
}
let h = cfg_get(&pc, "STRATA_HELPER_GPU").trim();
if !h.is_empty() && h != "-1" {
d["has_helper"] = json!(true);
if helper_on(&pc) {
d["helper_on"] = json!(true);
let hg = gpu_name(h);
if !hg.is_empty() {
d["helper_gpu"] = json!(hg);
}
}
}
let vision = cfg_get(&pc, "STRATA_VISION") != "0";
d["vision"] = json!(vision);
if vision && let Some(Value::Object(v)) = out.get("vision") {
let vg = match v.get("cuda_device") {
Some(dev) => gpu_name(&go_sprint(dev)),
None => main_gpu.clone(),
};
if !vg.is_empty() {
d["vision_gpu"] = json!(vg);
}
}
Some(d)
}
/// `handleStrata`.
pub async fn overview(&self, store: &Store) -> Value {
let cfg = store.config().await;
let order = crate::presets::parse_preset_order(
store
.get(BK_STATE, crate::presets::PRESET_ORDER_KEY)
.await
.ok()
.flatten()
.as_deref(),
);
let env = self.detect(&cfg).await;
let mut installed: Vec<String> = Vec::new();
let mut active: HashMap<String, bool> = HashMap::new();
if let Ok(list) = crate::presets::list_presets(&self.paths.home, &cfg, &order) {
for p in list {
if let Ok(c) = crate::presets_api::read_preset(&self.paths.home, &p.id)
&& is_strata(&crate::presets::parse_env(&c))
{
installed.push(p.id.clone());
active.insert(p.id, p.active);
}
}
}
let mut fams = Vec::new();
for f in FAMILIES {
let mut fits = Vec::new();
for id in f.quants {
let q = quant(id).expect("quant");
let mut fit = fit_for(q, &env, have_gb(&self.paths, f.id, id));
let pid = preset_name(f.id, id);
if let Some(a) = active.get(&pid) {
fit.preset = pid.clone();
fit.active = *a;
fit.details = self.details_for(store, &cfg, &order, &pid, &env).await;
}
fits.push(fit.to_json());
}
fams.push(json!({
"id": f.id, "label": f.label, "about": f.about, "quants": f.quants,
"fits": fits, "recommend": recommend(&self.paths, f.id, &env),
}));
}
json!({
"version": VERSION, "env": env.to_json(), "families": fams,
"installed": installed, "active": is_strata(&cfg),
})
}
// --- installation ----------------------------------------------------
fn sha256_file(p: &Path) -> Result<String, String> {
let mut f = std::fs::File::open(p)
.map_err(|e| crate::paths::os_err("open", &p.to_string_lossy(), &e))?;
let mut h = sha2::Sha256::new();
let mut buf = vec![0u8; 1 << 16];
loop {
let n = std::io::Read::read(&mut f, &mut buf).map_err(|e| e.to_string())?;
if n == 0 {
break;
}
h.update(&buf[..n]);
}
Ok(hex::encode(h.finalize()))
}
/// `strataFetch` : un fichier de la release figée, vérifié par SHA-256.
async fn fetch(&self, log: &Log<'_>, asset: &str) -> Result<PathBuf, String> {
let want = self
.asset_sha
.lock()
.unwrap_or_else(|e| e.into_inner())
.get(asset)
.cloned()
.unwrap_or_default();
let dl = self.paths.dl_dir();
let dst = dl.join(asset);
if Self::sha256_file(&dst).is_ok_and(|g| g == want) {
log.line(&format!("{asset} déjà téléchargé")).await;
return Ok(dst);
}
std::fs::create_dir_all(&dl)
.map_err(|e| crate::paths::os_err("mkdir", &dl.to_string_lossy(), &e))?;
log.phase(&format!("téléchargement de {asset}…")).await;
let tmp = PathBuf::from(format!("{}.part", dst.to_string_lossy()));
let rq = self.client.get(format!("{}{asset}", self.release_base));
if let Err(e) = crate::backends::log_download(log, rq, &tmp, 0).await {
let _ = std::fs::remove_file(&tmp);
return Err(format!("téléchargement de {asset} : {e}"));
}
let got = Self::sha256_file(&tmp)?;
if got != want {
let _ = std::fs::remove_file(&tmp);
return Err(format!(
"{asset} : empreinte SHA-256 inattendue (fichier altéré ou incomplet)"
));
}
std::fs::rename(&tmp, &dst).map_err(|e| {
// *os.LinkError : « rename ancien nouveau: erreur ».
crate::paths::os_err(
"rename",
&format!("{} {}", tmp.to_string_lossy(), dst.to_string_lossy()),
&e,
)
})?;
Ok(dst)
}
/// `runStepEnv` avec le job pour journal : la sortie mêlée, découpée en
/// lignes comme le `sinkWriter` du Go.
async fn run_logged(
&self,
log: &Log<'_>,
dir: &Path,
name: &str,
args: &[String],
env: &[(&str, String)],
) -> Result<(), String> {
let Some(bin) = self.probe.look_path(name) else {
return Err(format!(
"exec: \"{name}\": executable file not found in $PATH"
));
};
let (reader, writer) = std::io::pipe().map_err(|e| e.to_string())?;
let writer2 = writer.try_clone().map_err(|e| e.to_string())?;
let mut c = tokio::process::Command::new(&bin);
c.args(args)
.current_dir(dir)
.stdin(std::process::Stdio::null())
.stdout(std::process::Stdio::from(writer))
.stderr(std::process::Stdio::from(writer2));
for (k, v) in env {
c.env(k, v);
}
let mut child = c
.spawn()
.map_err(|e| crate::paths::os_err("fork/exec", &bin.to_string_lossy(), &e))?;
drop(c);
let (tx, mut rx) = tokio::sync::mpsc::unbounded_channel::<Vec<u8>>();
let read = tokio::task::spawn_blocking(move || {
let mut r = reader;
let mut buf = [0u8; 8192];
loop {
match std::io::Read::read(&mut r, &mut buf) {
Ok(0) | Err(_) => break,
Ok(n) => {
if tx.send(buf[..n].to_vec()).is_err() {
break;
}
}
}
}
});
let mut sink = SinkWriter::default();
while let Some(chunk) = rx.recv().await {
for l in sink.write(&chunk) {
log.line(&l).await;
}
}
let _ = read.await;
let status = child.wait().await.map_err(|e| e.to_string())?;
match status.code() {
Some(0) => Ok(()),
Some(code) => Err(format!("exit status {code}")),
None => Err(status.to_string()),
}
}
/// `strataRunInstall` : le corps du job d'installation.
async fn run_install(&self, ctx: &Ctx, b: &Backends, family: &str, quant_id: &str) {
let store = &ctx.store;
let log = Log {
jobs: &b.jobs,
store,
};
let cfg = store.config().await;
let env = self.detect(&cfg).await;
if !env.supported {
return b.jobs.fail(store, &env.reason).await;
}
let Some(q) = quant(quant_id) else {
return b
.jobs
.fail(store, &format!("quant inconnu : {quant_id}"))
.await;
};
let fit = fit_for(q, &env, have_gb(&self.paths, family, q.id));
if !fit.ok {
return b
.jobs
.fail(
store,
&format!("{} ne tient pas sur cette machine : {}", q.id, fit.why),
)
.await;
}
let src = match self.fetch(&log, SRC_ASSET).await {
Ok(p) => p,
Err(e) => return b.jobs.fail(store, &e).await,
};
let engine = match self.fetch(&log, ENGINE_ASSET).await {
Ok(p) => p,
Err(e) => return b.jobs.fail(store, &e).await,
};
let src_dir = self.paths.src_dir();
let home = self.paths.strata_home();
if std::fs::metadata(src_dir.join("setup.py")).is_err() {
log.phase("décompression de Strata…").await;
let (s2, h2) = (src.clone(), home.clone());
let r =
tokio::task::spawn_blocking(move || crate::backends::prebuilt::extract(&s2, &h2))
.await
.unwrap_or_else(|e| Err(e.to_string()));
if let Err(e) = r {
return b.jobs.fail(store, &format!("décompression : {e}")).await;
}
}
let pre = home.join("prebuilt");
let _ = std::fs::create_dir_all(&pre);
let dst = pre.join(ENGINE_LOCAL);
if let Err(e) = std::fs::copy(&engine, &dst) {
return b
.jobs
.fail(
store,
&crate::paths::os_err("open", &dst.to_string_lossy(), &e),
)
.await;
}
log.phase(&format!(
"installation de {} {} (téléchargement de ~{} Go)…",
family_label(family),
q.id,
f0(q.download_gb)
))
.await;
let mut pre_s = pre.to_string_lossy().into_owned();
pre_s.push(std::path::MAIN_SEPARATOR);
let args: Vec<String> = [
"setup.sh",
"--setup",
"--yes",
"--no-start",
"--no-browser",
"--family",
family,
"--model",
q.id,
"--context",
"131072",
"--vision",
"gpu",
"--cuda",
"12",
"--prebuilt",
&pre_s,
"--data-dir",
&self.paths.data_dir().to_string_lossy(),
"--gpu",
&env.main.to_string(),
"--low-ram",
"off",
]
.iter()
.map(|s| s.to_string())
.collect();
let extra = [
("CUDA_DEVICE_ORDER", "PCI_BUS_ID".to_string()),
("PYTHONUNBUFFERED", "1".to_string()),
(
"XDG_CACHE_HOME",
home.join("cache").to_string_lossy().into_owned(),
),
];
if let Err(e) = self.run_logged(&log, &src_dir, "bash", &args, &extra).await {
return b
.jobs
.fail(
store,
&format!("l'installation a échoué : {e} (voir le journal)"),
)
.await;
}
let _ = std::fs::write(self.paths.engine_marker(), format!("{ENGINE_ASSET}\n"));
let cfg_path = match find_config(&src_dir, q.id) {
Ok(p) => p,
Err(e) => return b.jobs.fail(store, &e).await,
};
let name = preset_name(family, q.id);
let presets = self.paths.presets_dir();
let preset = presets.join(format!("{name}.env"));
let body = [
format!("# NAME={} {} (Strata)", family_label(family), q.id),
format!("ENGINE={ENGINE_STRATA}"),
format!("STRATA_CONFIG={cfg_path}"),
format!("STRATA_MAIN_GPU={}", env.main),
format!("STRATA_HELPER_GPU={}", env.helper),
"STRATA_VISION=1".into(),
format!("STRATA_EXPERTS={EXPERTS_AUTO}"),
"STRATA_DROP=1".into(),
"STRATA_KV=fp16".into(),
"STRATA_KV_RESIDENT=32768".into(),
"STRATA_SPEC=3".into(),
"CTX=131072".into(),
"REASONING=off".into(),
String::new(),
]
.join("\n");
if let Err(e) = std::fs::create_dir_all(&presets) {
return b
.jobs
.fail(
store,
&crate::paths::os_err("mkdir", &presets.to_string_lossy(), &e),
)
.await;
}
if let Err(e) = std::fs::write(&preset, body) {
return b
.jobs
.fail(
store,
&crate::paths::os_err("open", &preset.to_string_lossy(), &e),
)
.await;
}
log.phase("activation du modèle (premier chargement : quelques minutes)…")
.await;
if let Err(e) = switch(store, &preset).await {
return b
.jobs
.fail(
store,
&format!("modèle installé mais bascule impossible : {e}"),
)
.await;
}
b.jobs
.done(
store,
&format!("{} {} installé (Strata)", family_label(family), q.id),
)
.await;
}
}
/// `SwitchToPreset` : le garde de l'optimiseur, puis l'application et le
/// redémarrage par le Go.
async fn switch(store: &Store, path: &Path) -> Result<(), String> {
if let Some(e) = crate::backends::tune_busy(store).await {
return Err(e);
}
crate::presets_api::preset_apply(store, path, "switch").await
}
/// Le `sinkWriter` du Go : un flux découpé en lignes ; une barre qui se
/// redessine (`\r`) n'est montrée que toutes les 10 s.
#[derive(Default)]
pub struct SinkWriter {
buf: Vec<u8>,
last_prog: Option<Instant>,
}
/// `sinkProgressEvery`.
const PROGRESS_EVERY: Duration = Duration::from_secs(10);
impl SinkWriter {
pub fn write(&mut self, p: &[u8]) -> Vec<String> {
self.buf.extend_from_slice(p);
let mut out = Vec::new();
while let Some(i) = self.buf.iter().position(|c| *c == b'\r' || *c == b'\n') {
if self.buf[i] == b'\n' {
let line = String::from_utf8_lossy(&self.buf[..i]).into_owned();
out.push(line.trim_end_matches('\r').to_string());
self.buf.drain(..=i);
continue;
}
if i + 1 >= self.buf.len() {
break;
}
if self.buf[i + 1] == b'\n' {
out.push(String::from_utf8_lossy(&self.buf[..i]).into_owned());
self.buf.drain(..i + 2);
continue;
}
let prog = String::from_utf8_lossy(&self.buf[..i]).trim().to_string();
if !prog.is_empty() && self.last_prog.is_none_or(|t| t.elapsed() >= PROGRESS_EVERY) {
self.last_prog = Some(Instant::now());
out.push(prog);
}
self.buf.drain(..=i);
}
out
}
}
/// Les routes servies par ce module.
pub fn handles(path: &str) -> bool {
matches!(
path,
"/api/strata" | "/api/strata/install" | "/api/strata/settings"
)
}
fn fail(code: StatusCode, e: impl Into<String>) -> Response<Body> {
send_json(code, &json!({ "ok": false, "error": e.into() }))
}
/// `from_go` : remise par le Go (tunnel), déjà authentifiée par lui.
pub async fn serve(
ctx: &Ctx,
s: &Arc<Strata>,
req: Request<Incoming>,
from_go: bool,
) -> Response<Body> {
let Some(b) = ctx.backends.clone() else {
return fail(
StatusCode::SERVICE_UNAVAILABLE,
"backends non tenus par la façade",
);
};
let (parts, body) = req.into_parts();
if !from_go && let Err(refus) = ctx.guard(&parts.headers).await {
return *refus;
}
let raw = Limited::new(body, 1 << 20)
.collect()
.await
.map(|b| b.to_bytes().to_vec())
.unwrap_or_default();
let store = &ctx.store;
let post = parts.method == Method::POST;
match parts.uri.path() {
"/api/strata" => send_json(StatusCode::OK, &s.overview(store).await),
"/api/strata/settings" => {
if !post {
return fail(StatusCode::METHOD_NOT_ALLOWED, "POST attendu");
}
let d = gojson::decode_struct(
&raw,
SETTINGS_REQ,
&[
("preset", Kind::Str),
("ctx", Kind::Int),
("kv", Kind::Str),
("spec", Kind::Int),
("vision", Kind::Bool),
("helper", Kind::Bool),
("experts", Kind::Str),
],
);
if let Some(e) = d.err.clone() {
return fail(StatusCode::BAD_REQUEST, e);
}
let r = SettingsReq {
preset: d.str("preset"),
ctx: d.int("ctx"),
kv: d.str("kv"),
spec: d.int("spec"),
vision: d.bool("vision"),
helper: d.bool("helper"),
experts: d.str("experts"),
};
if r.preset.contains(['/', '\\']) || r.preset.is_empty() {
return fail(StatusCode::BAD_REQUEST, "preset invalide");
}
let home = &s.paths.home;
let content = match crate::presets_api::read_preset(home, &r.preset) {
Ok(c) if is_strata(&crate::presets::parse_env(&c)) => c,
_ => return fail(StatusCode::NOT_FOUND, "modèle introuvable"),
};
let next = match apply_settings(&content, &r) {
Ok(n) => n,
Err(e) => return fail(StatusCode::BAD_REQUEST, e),
};
// « En service » se décide AVANT l'écriture (comme save_applying) :
// c'est l'ancien contenu qui porte l'empreinte de la configuration.
let cfg = store.config().await;
let order = crate::presets::parse_preset_order(
store
.get(BK_STATE, crate::presets::PRESET_ORDER_KEY)
.await
.ok()
.flatten()
.as_deref(),
);
let active = crate::presets::list_presets(home, &cfg, &order)
.is_ok_and(|l| l.iter().any(|p| p.id == r.preset && p.active));
let path = s.paths.presets_dir().join(format!("{}.env", r.preset));
if let Err(e) = std::fs::write(&path, next) {
return fail(
StatusCode::INTERNAL_SERVER_ERROR,
crate::paths::os_err("open", &path.to_string_lossy(), &e),
);
}
let mut restarted = false;
if active {
if let Some(e) = crate::backends::tune_busy(store).await {
return send_json(
StatusCode::CONFLICT,
&json!({ "ok": false, "tuning": true, "error": e }),
);
}
if let Err(e) = crate::presets_api::preset_apply(store, &path, "switch").await {
return fail(StatusCode::INTERNAL_SERVER_ERROR, e);
}
restarted = true;
}
send_json(
StatusCode::OK,
&json!({ "ok": true, "restarted": restarted }),
)
}
"/api/strata/install" => {
if !post {
return fail(StatusCode::METHOD_NOT_ALLOWED, "POST attendu");
}
let d = gojson::decode_struct(
&raw,
INSTALL_REQ,
&[("family", Kind::Str), ("quant", Kind::Str)],
);
if let Some(e) = d.err.clone() {
return fail(StatusCode::BAD_REQUEST, e);
}
let (family, q) = (d.str("family"), d.str("quant"));
if !FAMILIES
.iter()
.any(|f| f.id == family && f.quants.contains(&q.as_str()))
{
return fail(StatusCode::BAD_REQUEST, "version ou quant inconnu");
}
if let Some(err) = crate::backends::tune_busy(store).await {
return fail(StatusCode::CONFLICT, err);
}
if let Err(err) = b.jobs.start(store, "strata").await {
return fail(StatusCode::CONFLICT, err);
}
let (ctx2, s2, b2) = (ctx.clone(), s.clone(), b.clone());
tokio::spawn(async move {
s2.run_install(&ctx2, &b2, &family, &q).await;
b2.jobs.end(&ctx2.store).await;
});
send_json(StatusCode::OK, &json!({ "ok": true }))
}
_ => fail(StatusCode::NOT_FOUND, "route inconnue"),
}
}
+848
View File
@@ -0,0 +1,848 @@
//! Strata, deuxième moteur de Loki (lot 25 de la migration,
//! `LOKI_STRATA=rust` ; expérimental, le Go reste le défaut) — port de
//! `backend_strata.go`.
//!
//! Strata répartit les experts d'un MoE de 125 B (famille Qwen3.8-Flash-Next)
//! entre VRAM, RAM et disque. Le moteur est le paquet FIGÉ publié par AJEAN
//! (sources + binaire Linux CUDA 12, vérifiés par SHA-256) ; le choix se fait
//! par preset (`ENGINE=strata`).
//!
//! Ici : la décision pure (placement des experts, configuration de lancement,
//! ce qui tient sur la machine, recommandation, réglages), la machine (cartes,
//! RAM, disque, Python) et les chemins. Les routes et l'installation sont dans
//! [`api`].
pub mod api;
use std::collections::HashMap;
use std::path::{Path, PathBuf};
use serde_json::{Map, Value, json};
/// Paquet figé (release moe-v1.0 d'AJEAN).
pub const VERSION: &str = "1.0";
pub const RELEASE_BASE: &str = "https://github.com/nathaninline/ajean/releases/download/moe-v1.0/";
pub const SRC_ASSET: &str = "ajean-moe-src-1.0.tar.gz";
pub const ENGINE_ASSET: &str = "ajean-moe-engine-1.0-linux-x64-cuda12.zip";
/// Le nom sous lequel l'installeur attend le moteur (CUDA12_ASSET).
pub const ENGINE_LOCAL: &str = "strata-linux-x64-cuda12.zip";
/// `strataAssetSHA`.
pub fn default_asset_sha() -> HashMap<String, String> {
HashMap::from([
(
SRC_ASSET.to_string(),
"5747f69426973d957e4f9894a8e3333c6ef0d6ac8565e26ddb8d36f52211cb3b".to_string(),
),
(
ENGINE_ASSET.to_string(),
"b5851bc138ab32ca3ffefc16bf91c8f557e4d503503473a22f2b6c9e9b8fd34c".to_string(),
),
])
}
pub const ENGINE_LLAMACPP: &str = "llamacpp";
pub const ENGINE_STRATA: &str = "strata";
pub type Cfg = HashMap<String, String>;
fn cfg_get<'a>(cfg: &'a Cfg, k: &str) -> &'a str {
cfg.get(k).map_or("", String::as_str)
}
/// `engineOf` : « moe » est accepté pour un preset recopié d'AJEAN.
pub fn engine_of(cfg: &Cfg) -> &'static str {
match cfg_get(cfg, "ENGINE").trim().to_lowercase().as_str() {
ENGINE_STRATA | "moe" => ENGINE_STRATA,
_ => ENGINE_LLAMACPP,
}
}
/// `isStrataConfig`.
pub fn is_strata(cfg: &Cfg) -> bool {
engine_of(cfg) == ENGINE_STRATA
}
/// `strataFamily`.
pub struct Family {
pub id: &'static str,
pub label: &'static str,
pub about: &'static str,
pub quants: &'static [&'static str],
}
/// `strataQuant`.
#[derive(Clone, Copy, Debug, PartialEq)]
pub struct Quant {
pub id: &'static str,
pub about: &'static str,
pub download_gb: f64,
pub arena_gb: f64,
}
impl Quant {
fn to_json(self) -> Value {
json!({
"id": self.id, "about": self.about,
"download_gb": go_num(self.download_gb), "arena_gb": go_num(self.arena_gb),
})
}
}
pub const FAMILIES: &[Family] = &[
Family {
id: "swift",
label: "Swift 1.5",
about: "réfléchit moins, répond plus vite",
quants: &["IQ2_XS", "IQ3_XXS"],
},
Family {
id: "qwen",
label: "Classique",
about: "le Qwen3.8-Flash-Next original",
quants: &["Q2_0", "IQ2_XS", "IQ3_XXS", "IQ3_S"],
},
];
const QUANTS: &[Quant] = &[
Quant {
id: "Q2_0",
about: "le plus rapide",
download_gb: 66.4,
arena_gb: 34.0,
},
Quant {
id: "IQ2_XS",
about: "un peu plus fidèle, presque aussi rapide",
download_gb: 68.0,
arena_gb: 35.5,
},
Quant {
id: "IQ3_XXS",
about: "99 % de la qualité du modèle complet",
download_gb: 75.8,
arena_gb: 42.9,
},
Quant {
id: "IQ3_S",
about: "la meilleure qualité, le plus lent",
download_gb: 83.6,
arena_gb: 50.3,
},
];
/// `strataQuants[id]`.
pub fn quant(id: &str) -> Option<Quant> {
QUANTS.iter().find(|q| q.id == id).copied()
}
pub const LOW_RAM_HEADROOM_GB: f64 = 10.0;
pub const DROP_HEADROOM_GB: f64 = 7.0;
/// `strataHelperExpertsGB`.
pub fn helper_experts_gb(vram_gb: f64) -> f64 {
(vram_gb - 2.5).max(0.0)
}
pub const EXPERTS_AUTO: &str = "auto";
pub const EXPERTS_RAM: &str = "ram";
pub const EXPERTS_DISK: &str = "disk";
/// `strataMaxCtx`.
pub const MAX_CTX: i64 = 262144;
/// `strataMinArch`.
pub const MIN_ARCH: i64 = 75;
/// `strataLaunchModeKey` (bkState).
pub const LAUNCH_MODE_KEY: &str = "strata_experts_mode";
/// `strataServeMarker`.
pub const SERVE_MARKER: &str = "[loki serve] Strata";
/// Un nombre comme le Go l'écrit en JSON (entier sans « .0 »).
pub fn go_num(f: f64) -> Value {
if f.fract() == 0.0 && f.abs() < 1e15 {
json!(f as i64)
} else {
json!(f)
}
}
/// `fmt.Sprint` d'une valeur JSON décodée par le Go (`any`).
pub fn go_sprint(v: &Value) -> String {
match v {
Value::String(s) => s.clone(),
Value::Number(n) => go_g(n.as_f64().unwrap_or(0.0)),
Value::Bool(b) => b.to_string(),
Value::Null => "<nil>".into(),
Value::Array(a) => format!(
"[{}]",
a.iter().map(go_sprint).collect::<Vec<_>>().join(" ")
),
Value::Object(m) => format!(
"map[{}]",
m.iter()
.map(|(k, v)| format!("{k}:{}", go_sprint(v)))
.collect::<Vec<_>>()
.join(" ")
),
}
}
/// `strconv.FormatFloat(f, 'g', -1, 64)` (le `%v` d'un float64) : le plus
/// court, en notation exponentielle hors de [1e-4, 1e6[.
pub fn go_g(f: f64) -> String {
if f.is_nan() {
return "NaN".into();
}
if f.is_infinite() {
return if f > 0.0 { "+Inf" } else { "-Inf" }.into();
}
if f == 0.0 {
return if f.is_sign_negative() { "-0" } else { "0" }.into();
}
let e = format!("{f:e}");
let (mant, exp) = e.split_once('e').unwrap_or((&e, "0"));
let exp: i32 = exp.parse().unwrap_or(0);
if !(-4..6).contains(&exp) {
let sign = if exp < 0 { '-' } else { '+' };
return format!("{mant}e{sign}{:02}", exp.abs());
}
format!("{f}")
}
// ---------------------------------------------------------------------------
// Chemins
// ---------------------------------------------------------------------------
/// Les chemins de Strata sous `$LOKI_HOME`.
#[derive(Clone, Debug)]
pub struct Paths {
pub home: PathBuf,
}
impl Paths {
pub fn strata_home(&self) -> PathBuf {
self.home.join("strata")
}
pub fn src_dir(&self) -> PathBuf {
self.strata_home().join(format!("ajean-moe-{VERSION}"))
}
pub fn data_dir(&self) -> PathBuf {
self.strata_home().join("data")
}
pub fn dl_dir(&self) -> PathBuf {
self.strata_home().join("dl")
}
pub fn presets_dir(&self) -> PathBuf {
self.home.join("presets")
}
/// `strataEngineMarker` : le paquet dont vient le moteur installé.
pub fn engine_marker(&self) -> PathBuf {
self.src_dir().join("engine-cuda12").join(".loki-engine")
}
/// `strataEngineInstalled`.
pub fn engine_installed(&self) -> String {
std::fs::read_to_string(self.engine_marker())
.map(|s| s.trim().to_string())
.unwrap_or_default()
}
}
// ---------------------------------------------------------------------------
// Experts
// ---------------------------------------------------------------------------
/// `strataConfigTag` : strata-swift-iq3_xxs.json → swift-iq3_xxs.
pub fn config_tag(path: &str) -> String {
let b = crate::paths::go_base(path);
let b = b.strip_suffix(".json").unwrap_or(&b);
match b.find('-') {
Some(i) => b[i + 1..].to_string(),
None => b.to_string(),
}
}
/// `strataArenaGB` : la taille des experts du quant d'un preset (0 = inconnu).
pub fn arena_gb(cfg: &Cfg) -> f64 {
let tag = config_tag(cfg_get(cfg, "STRATA_CONFIG").trim()).to_uppercase();
let tag = tag.strip_prefix("SWIFT-").unwrap_or(&tag);
quant(tag).map_or(0.0, |q| q.arena_gb)
}
/// `strataPackHasExpertsBin`.
pub fn pack_has_experts_bin(args: &[String]) -> bool {
args.windows(2)
.any(|w| w[0] == "--pack" && Path::new(&w[1]).join("experts.bin").exists())
}
/// `strataHelperOn` : la carte d'aide existe et n'a pas été coupée.
pub fn helper_on(cfg: &Cfg) -> bool {
let h = cfg_get(cfg, "STRATA_HELPER_GPU").trim();
!h.is_empty() && h != "-1" && cfg_get(cfg, "STRATA_HELPER") != "0"
}
/// `strataResolveExperts` : le mode lancé (drop, ram, arena, disk), d'après
/// le choix du preset et la RAM disponible.
pub fn resolve_experts(
paths: &Paths,
cfg: &Cfg,
args: &[String],
avail_gb: f64,
helper_gb: f64,
) -> &'static str {
let bin_ready = pack_has_experts_bin(args) && paths.engine_installed() == ENGINE_ASSET;
let drop_ready = bin_ready && helper_on(cfg) && cfg_get(cfg, "STRATA_DROP") != "0";
let arena = arena_gb(cfg);
match cfg_get(cfg, "STRATA_EXPERTS")
.trim()
.to_lowercase()
.as_str()
{
EXPERTS_DISK => return if bin_ready { "arena" } else { "disk" },
EXPERTS_RAM => return if drop_ready { "drop" } else { "ram" },
_ => {}
}
if arena <= 0.0 {
return "disk";
}
if drop_ready && avail_gb >= arena - helper_experts_gb(helper_gb) + DROP_HEADROOM_GB {
return "drop";
}
if avail_gb >= arena + LOW_RAM_HEADROOM_GB {
return "ram";
}
if bin_ready { "arena" } else { "disk" }
}
/// `strataExpertsLabel`.
pub fn experts_label(mode: &str) -> String {
match mode {
"drop" => "verrouillés en RAM, hors ceux de la carte d'appoint".into(),
"ram" => "chargés et verrouillés en RAM".into(),
"arena" => "experts.bin mappé (cache système)".into(),
"disk" => "lus depuis le disque (mmap)".into(),
m => m.into(),
}
}
// ---------------------------------------------------------------------------
// Configuration de lancement
// ---------------------------------------------------------------------------
/// `strataTuning` (ordre des clés de la map Go, itérée au hasard mais
/// idempotente : chaque drapeau est posé une fois).
const TUNING: [(&str, &str); 3] = [
("--prefill", "16384"),
("--short-read", "150"),
("--prompt-cache-root", "256"),
];
/// `strataEnvTuning`.
const ENV_TUNING: [(&str, &str); 2] = [("STRATA_PLE_BATCH", "0"), ("STRATA_NO_LARGEPAGES", "1")];
/// `strataSetArg`.
pub fn set_arg(mut args: Vec<String>, flag: &str, value: &str) -> Vec<String> {
for i in 0..args.len().saturating_sub(1) {
if args[i] == flag {
args[i + 1] = value.to_string();
return args;
}
}
args.push(flag.to_string());
args.push(value.to_string());
args
}
/// `strataDropFlag` : retire un drapeau SANS valeur.
pub fn drop_flag(args: Vec<String>, flag: &str) -> Vec<String> {
args.into_iter().filter(|a| a != flag).collect()
}
/// `strataDropFlagValue` : retire un drapeau ET sa valeur.
pub fn drop_flag_value(args: Vec<String>, flag: &str) -> Vec<String> {
let mut out = Vec::new();
let mut i = 0;
while i < args.len() {
if args[i] == flag {
i += 2;
continue;
}
out.push(args[i].clone());
i += 1;
}
out
}
/// `strataHasArg`.
pub fn has_arg(args: &[String], flag: &str) -> bool {
args.iter().any(|a| a == flag)
}
/// `strataArgList`.
pub fn arg_list(cfg: &Value) -> Vec<String> {
cfg.get("args")
.and_then(Value::as_array)
.map(|l| l.iter().map(go_sprint).collect())
.unwrap_or_default()
}
fn first_non_empty<'a>(a: &'a str, b: &'a str) -> &'a str {
if a.is_empty() { b } else { a }
}
/// `strataBuildConfig` : la config de l'installeur avec les réglages du
/// preset et les experts placés selon `mode`. Pure.
pub fn build_config(base: &Value, cfg: &Cfg, api_key: &str, mode: &str) -> Result<Value, String> {
let mut out: Map<String, Value> = match base {
Value::Object(m) => m.clone(),
_ => Map::new(),
};
if !out.get("args").is_some_and(Value::is_array) {
return Err("configuration de Strata sans « args »".into());
}
let mut args = arg_list(&Value::Object(out.clone()));
for (flag, v) in TUNING {
args = set_arg(args, flag, v);
}
let helper = cfg_get(cfg, "STRATA_HELPER_GPU").trim();
let has_helper = helper_on(cfg);
if has_helper {
args = set_arg(args, "--expert-cache-device1", "auto");
if !has_arg(&args, "--remote-expert-opt") {
args.push("--remote-expert-opt".into());
}
out.remove("gpu");
args = drop_flag_value(args, "--vram-reserve-mib");
if let Some(Value::Object(v)) = out.get_mut("vision")
&& let Ok(n) = helper.parse::<i64>()
{
v.insert("cuda_device".into(), json!(n));
}
}
let mut env: Map<String, Value> = match out.get("env") {
Some(Value::Object(e)) => e.clone(),
_ => Map::new(),
};
args = drop_flag(drop_flag(args, "--mmap-experts"), "--resident-experts");
env.remove("STRATA_ARENA_MMAP");
env.remove("STRATA_REMOTE_DROP");
match mode {
"drop" if has_helper => {
args = drop_flag_value(args, "--pcie-frac");
env.insert("STRATA_REMOTE_DROP".into(), json!("1"));
env.insert("STRATA_STAGER_THREADS".into(), json!("8"));
env.insert("STRATA_STAGER_RING".into(), json!("32"));
}
"arena" => {
args = set_arg(args, "--pcie-frac", "0");
env.insert("STRATA_ARENA_MMAP".into(), json!("1"));
}
"disk" => {
args.push("--mmap-experts".into());
args = set_arg(args, "--pcie-frac", "0");
}
_ => {}
}
for (k, v) in ENV_TUNING {
env.insert(k.into(), json!(v));
}
out.insert("env".into(), Value::Object(env));
let ctx = cfg_get(cfg, "CTX").trim();
if !ctx.is_empty() {
args = set_arg(args, "--max-context", ctx);
}
for (key, flag) in [
("STRATA_KV", "--kv"),
("STRATA_SPEC", "--spec"),
("STRATA_KV_RESIDENT", "--kv-resident"),
] {
let v = cfg_get(cfg, key).trim();
if !v.is_empty() {
args = set_arg(args, flag, v);
}
}
if cfg_get(cfg, "STRATA_VISION") == "0" {
args = drop_flag(args, "--vision");
out.remove("vision");
}
out.insert(
"args".into(),
Value::Array(args.into_iter().map(Value::String).collect()),
);
let port = first_non_empty(cfg_get(cfg, "PORT"), "8080")
.parse::<i64>()
.unwrap_or(0);
out.insert("port".into(), json!(port));
out.insert(
"host".into(),
json!(first_non_empty(cfg_get(cfg, "HOST"), "127.0.0.1")),
);
if !api_key.is_empty() {
out.insert("api_key".into(), json!(api_key));
}
Ok(Value::Object(out))
}
/// `strataCudaDevices` : la principale en CUDA0.
pub fn cuda_devices(cfg: &Cfg) -> String {
let main = cfg_get(cfg, "STRATA_MAIN_GPU").trim();
if main.is_empty() || main == "-1" {
return String::new();
}
if helper_on(cfg) {
format!("{main},{}", cfg_get(cfg, "STRATA_HELPER_GPU").trim())
} else {
main.to_string()
}
}
/// `strataPreflight` : ce qu'il faut pour lancer Strata.
pub fn preflight(cfg: &Cfg) -> Result<(), String> {
let path = cfg_get(cfg, "STRATA_CONFIG").trim();
if path.is_empty() {
return Err(
"STRATA_CONFIG non défini : réinstalle le modèle depuis Paramètres → Moteur → Strata"
.into(),
);
}
if std::fs::metadata(path).is_err() {
return Err(format!(
"configuration de Strata introuvable : {path} — réinstalle le modèle"
));
}
let py = Path::new(&crate::paths::go_dir(path))
.join(".venv")
.join("bin")
.join("python");
if std::fs::metadata(&py).is_err() {
return Err(format!(
"environnement Python de Strata absent ({}) : réinstalle le modèle",
py.to_string_lossy()
));
}
Ok(())
}
// ---------------------------------------------------------------------------
// Machine
// ---------------------------------------------------------------------------
/// `strataGPU`.
#[derive(Clone, Debug, PartialEq)]
pub struct Gpu {
pub index: i64,
pub name: String,
pub vram_gb: f64,
pub arch: i64,
}
impl Gpu {
fn to_json(&self) -> Value {
json!({ "index": self.index, "name": self.name, "vram_gb": go_num(self.vram_gb), "arch": self.arch })
}
}
/// `strataEnv`.
#[derive(Clone, Debug, PartialEq)]
pub struct Env {
pub supported: bool,
pub reason: String,
pub gpus: Vec<Gpu>,
pub main: i64,
pub helper: i64,
pub ram_gb: f64,
pub avail_gb: f64,
pub disk_free_gb: f64,
pub python: String,
}
impl Default for Env {
fn default() -> Self {
Env {
supported: false,
reason: String::new(),
gpus: Vec::new(),
main: -1,
helper: -1,
ram_gb: 0.0,
avail_gb: 0.0,
disk_free_gb: 0.0,
python: String::new(),
}
}
}
impl Env {
pub fn to_json(&self) -> Value {
let mut v = json!({
"supported": self.supported,
"gpus": if self.gpus.is_empty() { Value::Null } else { Value::Array(self.gpus.iter().map(Gpu::to_json).collect()) },
"main": self.main, "helper": self.helper,
"ram_gb": go_num(self.ram_gb), "avail_gb": go_num(self.avail_gb),
"disk_free_gb": go_num(self.disk_free_gb),
});
if !self.reason.is_empty() {
v["reason"] = json!(self.reason);
}
if !self.python.is_empty() {
v["python"] = json!(self.python);
}
v
}
}
/// `strataTag` : « swift-IQ3_XXS », « IQ3_XXS ».
pub fn tag(family: &str, quant: &str) -> String {
if family == "swift" {
format!("swift-{quant}")
} else {
quant.to_string()
}
}
/// `strataDirGB` : la taille des fichiers d'un dossier, en Go.
pub fn dir_gb(dir: &Path) -> f64 {
fn walk(p: &Path) -> u64 {
let Ok(rd) = std::fs::read_dir(p) else {
return 0;
};
let mut t = 0;
for e in rd.flatten() {
let Ok(ft) = e.file_type() else { continue };
if ft.is_dir() {
t += walk(&e.path());
} else if ft.is_file() {
t += e.metadata().map_or(0, |m| m.len());
}
}
t
}
walk(dir) as f64 / 1e9
}
/// `strataHaveGB` : ce qui est déjà sur le disque pour CE choix.
pub fn have_gb(paths: &Paths, family: &str, quant: &str) -> f64 {
let t = tag(family, quant);
let data = paths.data_dir();
dir_gb(&data.join("models").join(&t))
+ dir_gb(&data.join("packs").join(t.to_lowercase()))
+ dir_gb(&data.join("mtp"))
}
/// `strataFit`.
#[derive(Clone, Debug, PartialEq)]
pub struct Fit {
pub quant: Quant,
pub disk_gb: f64,
pub mmap: bool,
pub drop: bool,
pub ok: bool,
pub why: String,
pub preset: String,
pub active: bool,
pub details: Option<Value>,
}
impl Fit {
pub fn to_json(&self) -> Value {
let mut v = json!({
"quant": self.quant.to_json(), "disk_gb": go_num(self.disk_gb),
"mmap": self.mmap, "drop": self.drop, "ok": self.ok,
});
if !self.why.is_empty() {
v["why"] = json!(self.why);
}
if !self.preset.is_empty() {
v["preset"] = json!(self.preset);
}
if self.active {
v["active"] = json!(true);
}
if let Some(d) = &self.details {
v["details"] = d.clone();
}
v
}
}
/// `%.0f` du Go (arrondi au plus proche, demi vers le pair).
pub fn f0(x: f64) -> String {
format!("{x:.0}")
}
/// `strataFitFor` : ce qu'un quant demande sur cette machine, et s'il y tient.
pub fn fit_for(q: Quant, env: &Env, have: f64) -> Fit {
let mut f = Fit {
quant: q,
disk_gb: (q.download_gb - have).max(10.0),
mmap: env.avail_gb < q.arena_gb + LOW_RAM_HEADROOM_GB,
drop: false,
ok: false,
why: String::new(),
preset: String::new(),
active: false,
details: None,
};
let vram: f64 = env
.gpus
.iter()
.filter(|g| g.index == env.main || g.index == env.helper)
.map(|g| g.vram_gb)
.sum();
if env.ram_gb + vram - 12.0 < q.arena_gb {
f.why = format!(
"demande plus de mémoire : {} Go de RAM + VRAM, il en faut ~{}",
f0(env.ram_gb + vram),
f0(q.arena_gb + 12.0)
);
} else if env.disk_free_gb > 0.0 && env.disk_free_gb < f.disk_gb + 5.0 {
f.why = format!(
"pas assez d'espace disque : {} Go libres, il en faut ~{}",
f0(env.disk_free_gb),
f0(f.disk_gb + 5.0)
);
} else {
f.ok = true;
}
f
}
/// `strataRecommend` : le quant le plus fidèle qui tient.
pub fn recommend(paths: &Paths, family: &str, env: &Env) -> String {
let Some(fam) = FAMILIES.iter().find(|f| f.id == family) else {
return String::new();
};
let mut best = String::new();
for id in ["IQ3_XXS", "IQ2_XS", "Q2_0"] {
if !fam.quants.contains(&id) {
continue;
}
let q = quant(id).expect("quant");
if fit_for(q, env, have_gb(paths, family, id)).ok {
best = id.to_string();
break;
}
}
if fam.quants.contains(&"IQ3_S") {
let q = quant("IQ3_S").expect("quant");
let f = fit_for(q, env, have_gb(paths, family, "IQ3_S"));
if f.ok && !f.mmap {
best = "IQ3_S".into();
}
}
best
}
/// `strataPresetName`.
pub fn preset_name(family: &str, quant: &str) -> String {
let label = if family == "swift" {
"Swift 1.5"
} else {
"Classique"
};
format!("STRATA FLASH NEXT {} {quant}", label.to_uppercase())
}
/// `strataFamilyLabel`.
pub fn family_label(id: &str) -> String {
match FAMILIES.iter().find(|f| f.id == id) {
Some(f) if f.id == "qwen" => "Qwen3.8 Flash Next".into(),
Some(f) => format!("Qwen3.8 Flash Next {}", f.label),
None => id.into(),
}
}
/// `strataBool`.
pub fn sbool(b: bool) -> &'static str {
if b { "1" } else { "0" }
}
/// `strataSettingsReq`.
#[derive(Clone, Debug, Default)]
pub struct SettingsReq {
pub preset: String,
pub ctx: i64,
pub kv: String,
pub spec: i64,
pub vision: bool,
pub helper: bool,
pub experts: String,
}
/// `strataApplySettings` : réécrit les clés du preset. Pure.
pub fn apply_settings(content: &str, r: &SettingsReq) -> Result<String, String> {
if r.ctx < 32768 || r.ctx > MAX_CTX || r.ctx % 4096 != 0 {
return Err(format!("contexte hors limites : {}", r.ctx));
}
if r.kv != "fp16" && r.kv != "int8" {
return Err(format!("cache KV inconnu : {}", r.kv));
}
if r.spec < 2 || r.spec > 4 {
return Err(format!("MTP hors limites : {}", r.spec));
}
if !r.experts.is_empty()
&& r.experts != EXPERTS_AUTO
&& r.experts != EXPERTS_RAM
&& r.experts != EXPERTS_DISK
{
return Err(format!("placement des experts inconnu : {}", r.experts));
}
let mut set: Vec<(&str, String)> = vec![
("CTX", r.ctx.to_string()),
("STRATA_KV", r.kv.clone()),
("STRATA_SPEC", r.spec.to_string()),
("STRATA_VISION", sbool(r.vision).into()),
("STRATA_HELPER", sbool(r.helper).into()),
];
if !r.experts.is_empty() {
set.push(("STRATA_EXPERTS", r.experts.clone()));
}
let mut lines: Vec<String> = Vec::new();
for l in content.trim_end_matches('\n').split('\n') {
let k = l.trim().split_once('=').map_or(l.trim(), |(k, _)| k);
if set.iter().any(|(s, _)| *s == k) {
continue;
}
lines.push(l.to_string());
}
for (k, v) in set {
lines.push(format!("{k}={v}"));
}
Ok(lines.join("\n") + "\n")
}
/// `strataHelperVRAM` : VRAM de la carte d'appoint du preset, 0 sans.
pub fn helper_vram(cfg: &Cfg, gpus: &[Gpu]) -> f64 {
if !helper_on(cfg) {
return 0.0;
}
let h = cfg_get(cfg, "STRATA_HELPER_GPU").trim();
gpus.iter()
.find(|g| g.index.to_string() == h)
.map_or(0.0, |g| g.vram_gb)
}
/// `strataFindConfig` : la config que l'installeur a écrite pour ce quant.
pub fn find_config(src_dir: &Path, quant: &str) -> Result<String, String> {
let q = quant.to_lowercase();
let mut names: Vec<String> = std::fs::read_dir(src_dir)
.map(|rd| {
rd.flatten()
.map(|e| e.file_name().to_string_lossy().into_owned())
.filter(|n| n.starts_with("strata-") && n.ends_with(".json"))
.collect()
})
.unwrap_or_default();
names.sort();
for n in names {
if n.to_lowercase().contains(&q) {
return Ok(src_dir.join(n).to_string_lossy().into_owned());
}
}
Err(format!(
"l'installeur n'a pas laissé de configuration pour {quant} dans {}",
src_dir.to_string_lossy()
))
}
#[cfg(test)]
mod tests;
+485
View File
@@ -0,0 +1,485 @@
//! Les tests du Go (`backend_strata_test.go`), portés.
use super::*;
fn cfg(kv: &[(&str, &str)]) -> Cfg {
kv.iter()
.map(|(k, v)| (k.to_string(), v.to_string()))
.collect()
}
fn args_of(v: &Value) -> Vec<String> {
arg_list(v)
}
fn arg_after(args: &[String], flag: &str) -> String {
args.windows(2)
.find(|w| w[0] == flag)
.map(|w| w[1].clone())
.unwrap_or_default()
}
#[test]
fn reglages_valides_appliques() {
let base = json!({
"exe": "/x/engine-cuda12/strata",
"args": ["--pack", "/p", "--prefill", "8192", "--max-context", "131072"],
"port": 8080.0,
});
let out = build_config(
&base,
&cfg(&[("PORT", "8081"), ("CTX", "65536")]),
"k",
"ram",
)
.unwrap();
let a = args_of(&out);
for (flag, want) in [
("--prefill", "16384"),
("--short-read", "150"),
("--prompt-cache-root", "256"),
("--max-context", "65536"),
("--pack", "/p"),
] {
assert_eq!(arg_after(&a, flag), want, "{flag} : {a:?}");
}
assert_eq!(a.join(" ").matches("--prefill").count(), 1);
assert!(!has_arg(&a, "--remote-expert-opt") && !has_arg(&a, "--expert-cache-device1"));
assert_eq!(out["port"], 8081);
assert_eq!(out["api_key"], "k");
assert_eq!(out["host"], "127.0.0.1");
assert_eq!(base["args"][3], "8192", "la config de base est modifiée");
}
#[test]
fn carte_d_aide() {
let base = json!({
"args": ["--pack", "/p", "--mmap-experts", "--vram-reserve-mib", "700"],
"gpu": 1.0,
"vision": { "exe": "v", "gpu": true },
});
let out = build_config(&base, &cfg(&[("STRATA_HELPER_GPU", "0")]), "", "ram").unwrap();
let a = args_of(&out);
assert_eq!(arg_after(&a, "--expert-cache-device1"), "auto");
assert!(has_arg(&a, "--remote-expert-opt"));
assert!(!has_arg(&a, "--vram-reserve-mib") && !has_arg(&a, "700"));
assert!(out.get("api_key").is_none());
assert!(out.get("gpu").is_none());
assert_eq!(out["vision"]["cuda_device"], 0);
assert_eq!(out["env"]["STRATA_PLE_BATCH"], "0");
assert!(build_config(&json!({}), &Cfg::new(), "", "ram").is_err());
}
#[test]
fn cartes_cuda() {
for (main, helper, want) in [
("1", "0", "1,0"),
("0", "-1", "0"),
("0", "", "0"),
("", "", ""),
("-1", "0", ""),
] {
assert_eq!(
cuda_devices(&cfg(&[
("STRATA_MAIN_GPU", main),
("STRATA_HELPER_GPU", helper)
])),
want
);
}
}
fn machine() -> Env {
Env {
supported: true,
main: 1,
helper: 0,
ram_gb: 47.0,
avail_gb: 47.0,
disk_free_gb: 400.0,
gpus: vec![
Gpu {
index: 0,
name: String::new(),
vram_gb: 8.0,
arch: 86,
},
Gpu {
index: 1,
name: String::new(),
vram_gb: 16.0,
arch: 120,
},
],
..Env::default()
}
}
#[test]
fn recommandation() {
let d = tempfile::tempdir().unwrap();
let p = Paths {
home: d.path().to_path_buf(),
};
let mut env = machine();
assert_eq!(recommend(&p, "swift", &env), "IQ3_XXS");
assert_eq!(recommend(&p, "qwen", &env), "IQ3_XXS");
assert_eq!(recommend(&p, "autre", &env), "");
let f = fit_for(quant("IQ3_XXS").unwrap(), &env, 0.0);
assert!(f.ok && f.mmap && !f.drop);
env.ram_gb = 64.0;
env.avail_gb = 64.0;
assert_eq!(recommend(&p, "qwen", &env), "IQ3_S");
let small = Env {
supported: true,
main: 0,
helper: -1,
ram_gb: 16.0,
disk_free_gb: 400.0,
gpus: vec![Gpu {
index: 0,
name: String::new(),
vram_gb: 8.0,
arch: 86,
}],
..Env::default()
};
assert_eq!(recommend(&p, "swift", &small), "");
env.disk_free_gb = 50.0;
assert!(!fit_for(quant("Q2_0").unwrap(), &env, 0.0).ok);
}
#[test]
fn noms() {
assert_eq!(
preset_name("swift", "IQ3_XXS"),
"STRATA FLASH NEXT SWIFT 1.5 IQ3_XXS"
);
assert_eq!(
preset_name("qwen", "Q2_0"),
"STRATA FLASH NEXT CLASSIQUE Q2_0"
);
assert_eq!(family_label("qwen"), "Qwen3.8 Flash Next");
assert_eq!(family_label("swift"), "Qwen3.8 Flash Next Swift 1.5");
assert_eq!(family_label("x"), "x");
assert_eq!(config_tag("/a/strata-swift-iq3_xxs.json"), "swift-iq3_xxs");
assert_eq!(config_tag("autre.json"), "autre");
}
#[test]
fn donnees_deja_la() {
let mut env = machine();
env.avail_gb = 0.0;
env.disk_free_gb = 32.0;
assert!(fit_for(quant("IQ3_XXS").unwrap(), &env, 120.0).ok);
assert!(!fit_for(quant("IQ3_XXS").unwrap(), &env, 0.0).ok);
}
#[test]
fn reglages_du_preset() {
let base = json!({ "args": ["--kv", "int8", "--spec", "4"] });
let out = build_config(
&base,
&cfg(&[("STRATA_KV", "fp16"), ("STRATA_SPEC", "3")]),
"",
"ram",
)
.unwrap();
let a = args_of(&out);
assert_eq!(arg_after(&a, "--kv"), "fp16");
assert_eq!(arg_after(&a, "--spec"), "3");
}
#[test]
fn deja_sur_le_disque() {
let d = tempfile::tempdir().unwrap();
let p = Paths {
home: d.path().to_path_buf(),
};
let write = |rel: &str, n: usize| {
let f = p.data_dir().join(rel);
std::fs::create_dir_all(f.parent().unwrap()).unwrap();
std::fs::write(f, vec![0u8; n]).unwrap();
};
write("models/swift-IQ3_XXS/a.gguf", 3000);
write("packs/swift-iq3_xxs/experts.bin", 2000);
write("mtp/rt/x", 500);
write("models/IQ2_XS/b.gguf", 7000);
assert_eq!((have_gb(&p, "swift", "IQ3_XXS") * 1e9).round(), 5500.0);
assert_eq!((have_gb(&p, "swift", "IQ2_XS") * 1e9).round(), 500.0);
}
#[test]
fn reglages() {
let input =
"# NAME=x\nENGINE=strata\nCTX=131072\nSTRATA_KV=fp16\nSTRATA_SPEC=3\nSTRATA_HELPER_GPU=0\n";
let r = SettingsReq {
ctx: 65536,
kv: "int8".into(),
spec: 4,
..SettingsReq::default()
};
let out = apply_settings(input, &r).unwrap();
let c = crate::presets::parse_env(&out);
for (k, v) in [
("CTX", "65536"),
("STRATA_KV", "int8"),
("STRATA_SPEC", "4"),
("STRATA_VISION", "0"),
("STRATA_HELPER", "0"),
("STRATA_HELPER_GPU", "0"),
("ENGINE", "strata"),
] {
assert_eq!(c.get(k).map(String::as_str), Some(v), "{k} : {out:?}");
}
assert_eq!(out.matches("CTX=").count(), 1);
// Les bornes acceptées.
for (ctx, spec) in [(32768, 2), (MAX_CTX, 4)] {
let ok = SettingsReq {
ctx,
kv: "fp16".into(),
spec,
..SettingsReq::default()
};
assert!(apply_settings(input, &ok).is_ok(), "{ctx} {spec}");
}
assert!(!helper_on(&c));
assert_eq!(
cuda_devices(&cfg(&[
("STRATA_MAIN_GPU", "1"),
("STRATA_HELPER_GPU", "0"),
("STRATA_HELPER", "0")
])),
"1"
);
let base = json!({ "args": ["--vision", "--pack", "/p"], "vision": { "exe": "v" } });
let b = build_config(&base, &c, "", "ram").unwrap();
let a = args_of(&b);
assert!(!has_arg(&a, "--vision") && b.get("vision").is_none());
assert!(!has_arg(&a, "--expert-cache-device1"));
for (ctx, kv, spec, why) in [
(1000, "fp16", 3, "contexte hors limites : 1000"),
(32768, "q2", 3, "cache KV inconnu : q2"),
(32768, "fp16", 9, "MTP hors limites : 9"),
(36864 + 1, "fp16", 3, "contexte hors limites : 36865"),
(MAX_CTX + 4096, "fp16", 3, "contexte hors limites : 266240"),
(32768, "fp16", 1, "MTP hors limites : 1"),
(32768, "fp16", 5, "MTP hors limites : 5"),
(28672, "fp16", 3, "contexte hors limites : 28672"),
] {
let bad = SettingsReq {
ctx,
kv: kv.into(),
spec,
..SettingsReq::default()
};
assert_eq!(apply_settings(input, &bad).unwrap_err(), why);
}
}
#[test]
fn moteur_du_preset() {
for (v, want) in [
("", ENGINE_LLAMACPP),
("llamacpp", ENGINE_LLAMACPP),
("strata", ENGINE_STRATA),
(" Strata ", ENGINE_STRATA),
("moe", ENGINE_STRATA),
("autre", ENGINE_LLAMACPP),
] {
assert_eq!(engine_of(&cfg(&[("ENGINE", v)])), want, "{v:?}");
}
}
#[test]
fn placement_des_experts() {
let d = tempfile::tempdir().unwrap();
let p = Paths {
home: d.path().to_path_buf(),
};
let pack = d.path().join("pack");
std::fs::create_dir_all(&pack).unwrap();
let args = vec!["--pack".to_string(), pack.to_string_lossy().into_owned()];
let mut c = cfg(&[
("STRATA_CONFIG", "/x/strata-swift-iq3_xxs.json"),
("STRATA_HELPER_GPU", "1"),
("STRATA_DROP", "1"),
]);
for (experts, avail, want) in [
("", 44.0, "disk"),
("", 60.0, "ram"),
("auto", 52.8, "disk"),
("disk", 60.0, "disk"),
("ram", 20.0, "ram"),
] {
c.insert("STRATA_EXPERTS".into(), experts.into());
assert_eq!(
resolve_experts(&p, &c, &args, avail, 12.0),
want,
"{experts} {avail}"
);
}
std::fs::write(pack.join("experts.bin"), "x").unwrap();
std::fs::create_dir_all(p.src_dir().join("engine-cuda12")).unwrap();
std::fs::write(p.engine_marker(), format!("{ENGINE_ASSET}\n")).unwrap();
c.insert("STRATA_EXPERTS".into(), String::new());
assert_eq!(resolve_experts(&p, &c, &args, 44.0, 12.0), "drop");
assert_eq!(resolve_experts(&p, &c, &args, 30.0, 12.0), "arena");
c.insert("STRATA_HELPER".into(), "0".into());
assert_eq!(resolve_experts(&p, &c, &args, 44.0, 12.0), "arena");
c.insert("STRATA_EXPERTS".into(), "disk".into());
assert_eq!(resolve_experts(&p, &c, &args, 60.0, 12.0), "arena");
assert_eq!(
resolve_experts(
&p,
&cfg(&[("STRATA_CONFIG", "/x/autre.json")]),
&[],
500.0,
0.0
),
"disk"
);
}
#[test]
fn modes_des_experts() {
let base = json!({
"args": ["--pack", "/p", "--resident-experts", "--pcie-frac", "0.3"],
"env": { "X": "1" },
});
let c = cfg(&[("STRATA_HELPER_GPU", "1")]);
let out = build_config(&base, &c, "", "disk").unwrap();
let a = args_of(&out);
assert!(has_arg(&a, "--mmap-experts") && !has_arg(&a, "--resident-experts"));
assert_eq!(arg_after(&a, "--pcie-frac"), "0");
assert!(out["env"].get("STRATA_ARENA_MMAP").is_none());
assert_eq!(out["env"]["X"], "1");
let out = build_config(&base, &c, "", "ram").unwrap();
let a = args_of(&out);
assert!(!has_arg(&a, "--mmap-experts") && !has_arg(&a, "--resident-experts"));
assert!(out["env"].get("STRATA_REMOTE_DROP").is_none());
let out = build_config(&base, &c, "", "arena").unwrap();
assert_eq!(arg_after(&args_of(&out), "--pcie-frac"), "0");
assert_eq!(out["env"]["STRATA_ARENA_MMAP"], "1");
let out = build_config(&base, &c, "", "drop").unwrap();
assert!(!has_arg(&args_of(&out), "--pcie-frac"));
assert_eq!(out["env"]["STRATA_REMOTE_DROP"], "1");
assert_eq!(out["env"]["STRATA_STAGER_THREADS"], "8");
let out = build_config(&base, &cfg(&[("STRATA_HELPER_GPU", "-1")]), "", "drop").unwrap();
assert!(out["env"].get("STRATA_REMOTE_DROP").is_none());
}
#[test]
fn reglages_des_experts() {
let input = "ENGINE=strata\nSTRATA_EXPERTS=auto\n";
let mut r = SettingsReq {
ctx: 131072,
kv: "fp16".into(),
spec: 3,
experts: "disk".into(),
..SettingsReq::default()
};
let out = apply_settings(input, &r).unwrap();
assert_eq!(crate::presets::parse_env(&out)["STRATA_EXPERTS"], "disk");
r.experts = "nvme".into();
assert_eq!(
apply_settings(input, &r).unwrap_err(),
"placement des experts inconnu : nvme"
);
r.experts = String::new();
let out = apply_settings(input, &r).unwrap();
assert_eq!(crate::presets::parse_env(&out)["STRATA_EXPERTS"], "auto");
}
#[test]
fn nombres_a_la_go() {
assert_eq!(go_g(8192.0), "8192");
assert_eq!(go_g(123456.0), "123456");
assert_eq!(go_g(1e6), "1e+06");
assert_eq!(go_g(1234567.0), "1.234567e+06");
assert_eq!(go_g(0.3), "0.3");
assert_eq!(go_g(0.0001), "0.0001");
assert_eq!(go_g(0.00001), "1e-05");
assert_eq!(go_g(-2.5), "-2.5");
assert_eq!(go_sprint(&json!("x")), "x");
assert_eq!(go_sprint(&json!(true)), "true");
assert_eq!(go_num(34.0), json!(34));
assert_eq!(go_num(35.5), json!(35.5));
}
#[test]
fn ligne_par_ligne() {
let mut s = api::SinkWriter::default();
assert_eq!(s.write(b"a\nb"), vec!["a"]);
assert_eq!(s.write(b"c\r"), Vec::<String>::new());
assert_eq!(s.write(b"\n10%\r20%\r"), vec!["bc", "10%"]);
assert_eq!(s.write(b"fin\r\n"), vec!["fin"]);
}
/// La RAM du moteur Strata en marche : l'arbre du service, lu dans /proc.
#[test]
fn ram_du_moteur_en_marche() {
let home = tempfile::tempdir().unwrap();
let root = tempfile::tempdir().unwrap();
let probe = crate::backends::probe::Probe {
path: Some(vec![]),
root: root.path().to_path_buf(),
};
let st = api::Strata::new(home.path().to_path_buf(), probe);
let status = |pid: &str, ppid: i64, kb: i64| {
let d = root.path().join("proc").join(pid);
std::fs::create_dir_all(&d).unwrap();
std::fs::write(
d.join("status"),
format!("Name:\tx\nPPid:\t{ppid}\nVmRSS:\t {kb} kB\n"),
)
.unwrap();
};
status("100", 1, 1_048_576);
status("101", 100, 2048);
status("102", 101, 1024);
status("200", 1, 999_999);
std::fs::create_dir_all(root.path().join("proc/self")).unwrap();
let strata = cfg(&[("ENGINE", "strata")]);
assert_eq!(st.engine_rss_gb(&strata), 0.0, "sans fichier de PID");
std::fs::write(home.path().join("loki-engine.pid"), "100\n").unwrap();
let want = (1_048_576.0 + 2048.0 + 1024.0) * 1024.0 / 1e9;
assert!((st.engine_rss_gb(&strata) - want).abs() < 1e-9);
assert_eq!(st.engine_rss_gb(&Cfg::new()), 0.0, "llama.cpp actif");
std::fs::write(
root.path().join("proc/meminfo"),
"MemTotal: 8388608 kB\nMemAvailable: 4194304 kB\n",
)
.unwrap();
assert_eq!(st.total_ram_gb(), 8.0);
assert!((st.mem_available_gb() - 4194304.0 * 1024.0 / 1e9).abs() < 1e-9);
}
/// Les drapeaux posés une seule fois, où qu'ils soient dans la liste.
#[test]
fn drapeaux_uniques() {
let v = |a: &[&str]| a.iter().map(|s| s.to_string()).collect::<Vec<_>>();
assert_eq!(
set_arg(v(&["--a", "1", "--kv", "int8"]), "--kv", "fp16"),
v(&["--a", "1", "--kv", "fp16"])
);
assert_eq!(
set_arg(v(&["--kv"]), "--kv", "x"),
v(&["--kv", "--kv", "x"])
);
assert_eq!(
drop_flag_value(v(&["--a", "1", "--b", "2"]), "--b"),
v(&["--a", "1"])
);
assert_eq!(drop_flag(v(&["--a", "--b", "--a"]), "--a"), v(&["--b"]));
let base = json!({ "args": ["--remote-expert-opt", "--pack", "/p"] });
let out = build_config(&base, &cfg(&[("STRATA_HELPER_GPU", "1")]), "", "drop").unwrap();
let a = args_of(&out);
assert_eq!(
a.iter().filter(|x| *x == "--remote-expert-opt").count(),
1,
"{a:?}"
);
assert_eq!(out["env"]["STRATA_STAGER_RING"], "32");
assert_eq!(out["env"]["STRATA_NO_LARGEPAGES"], "1");
}
+2
View File
@@ -661,6 +661,7 @@ pub struct Opts {
pub update: bool,
pub backends: Option<Arc<loki_rs::backends::Backends>>,
pub engineimg: Option<Arc<loki_rs::engineimg::EngineImg>>,
pub strata: Option<Arc<loki_rs::strata::api::Strata>>,
}
/// Comme [`start`], avec la conversation tenue par la façade
@@ -764,6 +765,7 @@ pub async fn start_with(
update: opts.update,
backends: opts.backends,
engineimg: opts.engineimg,
strata: opts.strata,
};
tokio::spawn(loki_rs::proxy::serve(fl, cfg, async move {
let _ = rx.await;
+1
View File
@@ -108,6 +108,7 @@ async fn banc(cle: Option<&str>) -> Banc {
update: false,
backends: None,
engineimg: None,
strata: None,
};
tokio::spawn(loki_rs::proxy::serve(l, cfg, async move {
let _ = rx.await;
File diff suppressed because it is too large. Load diff
+1
View File
@@ -114,6 +114,7 @@ async fn facade(backend: SocketAddr) -> (SocketAddr, oneshot::Sender<()>) {
update: false,
backends: None,
engineimg: None,
strata: None,
};
tokio::spawn(loki_rs::proxy::serve(l, cfg, async move {
let _ = rx.await;
+356
View File
@@ -0,0 +1,356 @@
//! Lot 25 : Strata tenu par la façade (`LOKI_STRATA=rust`, avec
//! `LOKI_BACKENDS=rust`).
//!
//! Le journal tiré du Go (`internal/loki/parity_strata_test.go` →
//! `tests/parity_strata/strata.json`) est rejoué par une façade : fausses
//! machines dans le PATH des sondes (nvidia-smi, python3), faux /proc/meminfo,
//! place libre imposée, fausse release (sources, moteur, empreintes) et
//! installeur factice ; la bascule finale part au faux Go, qui applique le
//! preset comme `applyPresetFile`.
mod common;
use std::collections::BTreeMap;
use std::convert::Infallible;
use std::net::SocketAddr;
use std::path::{Path, PathBuf};
use std::sync::{Arc, Mutex};
use std::time::Duration;
use bytes::Bytes;
use common::fakego;
use common::parity::{apply_files, tar_gz, write_file};
use http_body_util::Full;
use hyper::service::service_fn;
use hyper::{Request, Response};
use hyper_util::rt::TokioIo;
use loki_rs::backends::Backends;
use loki_rs::backends::probe::Probe;
use loki_rs::strata::api::Strata;
use regex::Regex;
use serde_json::{Value, json};
use sha2::Digest;
use tokio::net::TcpListener;
fn fixture() -> Value {
serde_json::from_str(include_str!("parity_strata/strata.json")).expect("journal")
}
fn s(v: &Value) -> &str {
v.as_str().unwrap_or("")
}
const TUNE_BUSY: &str = "optimisation en cours (loki tune) : le moteur est réservé aux essais — attends la fin ou annule-la";
/// La fausse release : `{status, src, engine}`.
async fn fake_release(rel: Arc<Mutex<Value>>) -> String {
let l = TcpListener::bind("127.0.0.1:0").await.unwrap();
let addr = l.local_addr().unwrap();
tokio::spawn(async move {
while let Ok((st, _)) = l.accept().await {
let rel = rel.clone();
tokio::spawn(async move {
let svc = service_fn(move |req: Request<hyper::body::Incoming>| {
let cur = rel.lock().unwrap().clone();
async move {
let path = req.uri().path().trim_start_matches('/').to_string();
let (code, body) = match cur["status"].as_u64() {
Some(c) if c != 0 => (c as u16, Vec::new()),
_ if path == loki_rs::strata::SRC_ASSET => (200, src_bytes(&cur)),
_ if path == loki_rs::strata::ENGINE_ASSET => {
(200, s(&cur["engine"]).as_bytes().to_vec())
}
_ => (404, Vec::new()),
};
let mut r = Response::new(Full::new(Bytes::from(body)));
*r.status_mut() = hyper::StatusCode::from_u16(code).unwrap();
Ok::<_, Infallible>(r)
}
});
let _ = hyper::server::conn::http1::Builder::new()
.serve_connection(TokioIo::new(st), svc)
.await;
});
}
});
format!("http://{addr}")
}
fn src_bytes(rel: &Value) -> Vec<u8> {
tar_gz(rel["src"].as_array().map_or(&[][..], Vec::as_slice))
}
fn sha(b: &[u8]) -> String {
hex::encode(sha2::Sha256::digest(b))
}
struct Bench {
facade: SocketAddr,
go: Arc<fakego::FakeGo>,
_stop: tokio::sync::oneshot::Sender<()>,
home: PathBuf,
tools: PathBuf,
root: PathBuf,
strata: Arc<Strata>,
rel: Arc<Mutex<Value>>,
rel_base: String,
_tmp: Vec<tempfile::TempDir>,
}
async fn bench() -> Bench {
let tmp = tempfile::tempdir().unwrap();
let tools = tempfile::tempdir().unwrap();
let root = tempfile::tempdir().unwrap();
let home = tmp.path().to_path_buf();
let probe = || Probe {
path: Some(vec![tools.path().to_path_buf()]),
root: root.path().to_path_buf(),
};
std::fs::create_dir_all(root.path().join("proc")).unwrap();
let rel = Arc::new(Mutex::new(json!({})));
let rel_base = fake_release(rel.clone()).await;
let b = Arc::new(Backends::with(
home.clone(),
String::new(),
probe(),
String::new(),
));
let mut st = Strata::new(home.clone(), probe());
st.release_base = format!("{rel_base}/");
st.platform_ok = true;
let strata = Arc::new(st);
let (facade, go, stop, _) = fakego::start_with(
home.clone(),
fakego::Opts {
backends: Some(b),
strata: Some(strata.clone()),
..Default::default()
},
)
.await;
*go.apply_presets.lock().unwrap() = true;
Bench {
facade,
go,
_stop: stop,
home,
tools: tools.path().to_path_buf(),
root: root.path().to_path_buf(),
strata,
rel,
rel_base,
_tmp: vec![tmp, tools, root],
}
}
impl Bench {
fn mask(&self, v: &str) -> String {
let ts = Regex::new(r#""(started_at|ended_at)":[1-9][0-9]*"#).unwrap();
let v = v
.replace(&self.rel_base, "{{REL}}")
.replace(&self.home.to_string_lossy().into_owned(), "{{HOME}}");
ts.replace_all(&v, r#""$1":"{{T}}""#).into_owned()
}
fn unmask(&self, v: &str) -> String {
v.replace("{{HOME}}", &self.home.to_string_lossy())
}
async fn call(&self, method: &str, path: &str, body: &str) -> (u16, Value) {
let (code, b) = fakego::send(self.facade, method, path, &[], body).await;
let out = serde_json::from_str(&self.mask(&String::from_utf8_lossy(&b)))
.unwrap_or_else(|_| panic!("{path} : corps illisible {b:?}"));
(code.as_u16(), out)
}
async fn wait_job(&self) {
for _ in 0..1500 {
let (_, v) = self.call("GET", "/api/llamacpp/job", "").await;
if v["running"] != json!(true) {
return;
}
tokio::time::sleep(Duration::from_millis(20)).await;
}
panic!("le job ne finit pas");
}
/// Les outils d'une machine (bash, mkdir et tr toujours).
fn set_machine(&self, machines: &Value, name: &str) {
for e in std::fs::read_dir(&self.tools).unwrap().flatten() {
std::fs::remove_file(e.path()).unwrap();
}
for tool in ["bash", "mkdir", "tr"] {
let p = loki_rs::paths::look_path(tool).expect(tool);
std::os::unix::fs::symlink(p, self.tools.join(tool)).unwrap();
}
for (tool, script) in machines[name].as_object().into_iter().flatten() {
write_file(&self.tools.join(tool), s(script), 0o755);
}
}
fn set_release(&self, r: &Value) {
*self.rel.lock().unwrap() = r.clone();
let mut want = std::collections::HashMap::from([
(loki_rs::strata::SRC_ASSET.to_string(), sha(&src_bytes(r))),
(
loki_rs::strata::ENGINE_ASSET.to_string(),
sha(s(&r["engine"]).as_bytes()),
),
]);
if r["bad_sha"] == json!(true) {
want.insert(loki_rs::strata::ENGINE_ASSET.to_string(), "0".repeat(64));
}
*self.strata.asset_sha.lock().unwrap() = want;
}
/// presets/ et strata/, sans le contenu des archives.
fn tree(&self) -> Vec<String> {
use std::os::unix::fs::PermissionsExt;
fn walk(b: &Bench, p: &Path, out: &mut Vec<String>) {
let Ok(m) = std::fs::symlink_metadata(p) else {
return;
};
let rel = p
.strip_prefix(&b.home)
.unwrap()
.to_string_lossy()
.into_owned();
if m.is_dir() {
out.push(format!("d {rel}"));
for e in std::fs::read_dir(p).unwrap().flatten() {
walk(b, &e.path(), out);
}
} else if rel.starts_with("strata/dl/") || rel.starts_with("strata/prebuilt/") {
out.push(format!("f {rel}"));
} else {
let c = std::fs::read(p).unwrap();
out.push(format!(
"f {rel} {} {}",
common::parity::perm_string(m.permissions().mode() & 0o777),
b.mask(&String::from_utf8_lossy(&c))
));
}
}
let mut out = Vec::new();
for root in ["presets", "strata"] {
walk(self, &self.home.join(root), &mut out);
}
out.sort();
out
}
}
#[tokio::test(flavor = "multi_thread", worker_threads = 4)]
async fn parite_de_strata() {
if !cfg!(target_arch = "x86_64") {
eprintln!("Strata n'est proposé que sous Linux x86_64 : rejeu sauté");
return;
}
let fx = fixture();
let bn = bench().await;
let machines = &fx["machines"];
for st in fx["steps"].as_array().unwrap() {
let name = s(&st["name"]);
if let Some(m) = st["machine"].as_str() {
bn.set_machine(machines, m);
}
if let Some(m) = st["meminfo"].as_str() {
std::fs::write(bn.root.join("proc/meminfo"), m).unwrap();
}
if let Some(d) = st["disk_free"].as_i64() {
*bn.strata.disk_free.lock().unwrap() = Some(d);
}
let files: serde_json::Map<String, Value> = st["files"]
.as_object()
.into_iter()
.flatten()
.map(|(k, v)| (k.clone(), Value::String(bn.unmask(s(v)))))
.collect();
apply_files(&bn.home, &Value::Object(files));
for (k, v) in st["config"].as_object().into_iter().flatten() {
let v = bn.unmask(s(v));
if v.is_empty() {
bn.go.del("config", k);
} else {
bn.go.set("config", k, &v);
}
}
for (k, v) in st["state"].as_object().into_iter().flatten() {
if s(v).is_empty() {
bn.go.del("state", k);
} else {
bn.go.set("state", k, s(v));
}
}
if !st["release"].is_null() {
bn.set_release(&st["release"]);
}
bn.go.preset_applies.lock().unwrap().clear();
*bn.go.tune_busy.lock().unwrap() =
(st["tune"].as_bool() == Some(true)).then(|| TUNE_BUSY.to_string());
let (code, out) = bn
.call(s(&st["method"]), s(&st["path"]), &bn.unmask(s(&st["body"])))
.await;
*bn.go.tune_busy.lock().unwrap() = None;
assert_eq!(code as i64, st["status"].as_i64().unwrap(), "{name}");
assert_eq!(out, st["out"], "{name}");
if st["wait"].as_bool() == Some(true) {
bn.wait_job().await;
let (_, job) = bn.call("GET", "/api/llamacpp/job?from=0", "").await;
assert_eq!(job, st["job"], "{name} : job");
}
// Une bascule faite par le Go : application du preset puis
// redémarrage du service.
let actions: Vec<Value> = bn
.go
.preset_applies
.lock()
.unwrap()
.iter()
.filter(|(_, mode)| mode == "switch")
.map(|_| json!("restart"))
.collect();
assert_eq!(Value::Array(actions), st["actions"], "{name} : service");
let mut after = BTreeMap::new();
for k in [
"ENGINE",
"STRATA_CONFIG",
"CTX",
"STRATA_KV",
"STRATA_HELPER",
] {
if let Some(v) = bn.go.get("config", k).filter(|v| !v.is_empty()) {
after.insert(format!("config/{k}"), Value::String(bn.mask(&v)));
}
}
assert_eq!(
Value::Object(after.into_iter().collect()),
st["after"],
"{name} : après"
);
let t: Vec<Value> = bn.tree().into_iter().map(Value::String).collect();
let t = if t.is_empty() {
Value::Null
} else {
Value::Array(t)
};
assert_eq!(t, st["tree"], "{name} : arborescence");
}
}
/// Sans les backends dans la façade (pas de job), Strata répond 503.
#[tokio::test]
async fn sans_backends() {
let tmp = tempfile::tempdir().unwrap();
let st = Strata::new(tmp.path().to_path_buf(), Probe::system());
let (facade, _go, _stop, _) = fakego::start_with(
tmp.path().to_path_buf(),
fakego::Opts {
strata: Some(Arc::new(st)),
..Default::default()
},
)
.await;
let (c, b) = fakego::send(facade, "GET", "/api/strata", &[], "").await;
assert_eq!(c.as_u16(), 503, "{b:?}");
}