mirror of
https://github.com/R0m1k3/Loki.git
synced 2026-10-16 11:49:43 +02:00
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:
22 files changed
+9079
-19
No files matched your search
@@ -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.
|
||||
@@ -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})
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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)
|
||||
{
|
||||
|
||||
@@ -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"),
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
@@ -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");
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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
@@ -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;
|
||||
|
||||
@@ -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:?}");
|
||||
}
|
||||
Reference in new issue
Block a user