diff --git a/Dockerfile b/Dockerfile index d9f6d8e..3fce9d7 100644 --- a/Dockerfile +++ b/Dockerfile @@ -87,8 +87,12 @@ LABEL org.opencontainers.image.revision="${LOKI_VERSION}" \ # via npx ; tini en PID 1. curl et libgomp1 sont déjà dans l'image amont. # Node 22 depuis NodeSource et non depuis Ubuntu (qui livre Node 18) : Playwright # et la plupart des serveurs MCP réclament Node ≥ 20. +# python3 + python3-venv : l'installeur et le serveur du moteur Strata +# (backend_strata.go) sont en Python ≥ 3.10 et créent leur propre .venv dans +# /data/strata. Le moteur et le modèle ne sont PAS dans l'image : téléchargés +# à la demande depuis Paramètres → Moteur. RUN apt-get update && apt-get install -y --no-install-recommends \ - git tini ca-certificates curl gnupg \ + git tini ca-certificates curl gnupg python3 python3-venv \ && curl -fsSL https://deb.nodesource.com/setup_22.x | bash - \ && apt-get install -y --no-install-recommends nodejs \ && rm -rf /var/lib/apt/lists/* diff --git a/NOTICE.md b/NOTICE.md index d5c1c59..fcc63b9 100644 --- a/NOTICE.md +++ b/NOTICE.md @@ -52,6 +52,16 @@ et le **chiffrement de la mémoire** (`mem_crypto.go`, `mem_vault.go`, **pas** reprise : elle vise ajean.link, le service de l'auteur amont. Loki expose à la place un export/import de paquet chiffré en fichier local. +## Moteur Strata + +Le second moteur de Loki (`internal/loki/backend_strata.go`) est +**[Strata](https://github.com/Niko1221/Strata)** (MIT), spécialisé dans +Qwen3.8-Flash-Next. Son intégration est reprise d'AJEAN (« AJEAN MoE 1.0 », +`backend_moe.go`) : Loki télécharge le paquet figé publié par AJEAN sur sa +release `moe-v1.0` (sources de Strata et binaire Linux CUDA 12 compilé, avec +les modifications d'AJEAN), vérifié par SHA-256. Ni Strata ni le modèle ne sont +inclus dans l'image : ils sont téléchargés à la demande. + ## Services externes Le tunnel d'accès distant continue de pointer vers **ajean.link**, le relais diff --git a/README.md b/README.md index 3c5fe7e..aeca61b 100644 --- a/README.md +++ b/README.md @@ -93,6 +93,33 @@ la recherche qu'au téléchargement. À défaut, la variable d'environnement priorité. Un jeton en **lecture** suffit (huggingface.co/settings/tokens), et il n'est envoyé qu'aux adresses Hugging Face. +## Choisir son moteur : llama.cpp ou Strata + +Loki a deux moteurs, et le choix se fait **par preset** (clé `ENGINE`) : + +- **llama.cpp** (par défaut) — n'importe quel GGUF, réglages complets dans + l'éditeur de preset. +- **[Strata](https://github.com/Niko1221/Strata)** — spécialisé dans + **Qwen3.8 Flash Next** (MoE de 125 B) : les experts sont répartis entre + VRAM, RAM et disque pour faire tourner ce modèle sur une carte de joueur. + Intégration reprise d'AJEAN (paquet figé « AJEAN MoE 1.0 », vérifié par + SHA-256). + +**Paramètres → Moteur → Strata** : la fenêtre lit la machine (cartes, RAM, +disque), propose la version (Classique ou Swift 1.5) et la qualité qui tient, +puis installe et active le modèle en un clic (téléchargement de 66 à 84 Go dans +`/data/strata`). Le modèle installé devient un preset comme les autres : +basculer dessus lance Strata, basculer sur un autre preset revient à llama.cpp. +Son engrenage ouvre les réglages propres à Strata (contexte jusqu'à 262 144, +cache KV fp16/8 bits, prédiction MTP, lecture des images, carte d'appoint). + +Pré-requis : Linux + NVIDIA RTX 20 ou plus récente (8 Go de VRAM minimum, une +deuxième carte est utilisée comme carte d'appoint), environ 46 Go de RAM pour +la plus petite qualité, pilote compatible CUDA 12.8. Le conteneur doit pouvoir +verrouiller la mémoire : `ulimits: memlock: -1` (déjà présent dans les +fichiers compose fournis). Le benchmark et l'optimiseur ne s'appliquent qu'à +llama.cpp. + ## Installation sur Unraid L'image est construite et publiée par GitHub Actions sur GHCR diff --git a/docker-compose.unraid.yml b/docker-compose.unraid.yml index 28ccbb7..32101f5 100644 --- a/docker-compose.unraid.yml +++ b/docker-compose.unraid.yml @@ -64,4 +64,8 @@ services: - /mnt/user/appdata/loki/data:/data # /models : dossier de .gguf déposés à la main (gros fichiers) - /mnt/user/appdata/loki/models:/models + # Moteur Strata (Qwen3.8 Flash Next) : il verrouille ses experts en RAM + # (mlock, 30 à 50 Go). La limite Docker par défaut (64 Ko) l'en empêche. + ulimits: + memlock: -1 restart: unless-stopped diff --git a/docker-compose.yml b/docker-compose.yml index c6fce55..668140f 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -37,4 +37,8 @@ services: - driver: nvidia count: all capabilities: ["gpu"] + # Moteur Strata (Qwen3.8 Flash Next) : il verrouille ses experts en RAM + # (mlock, 30 à 50 Go). La limite Docker par défaut (64 Ko) l'en empêche. + ulimits: + memlock: -1 restart: unless-stopped diff --git a/internal/loki/backend_external_serve_test.go b/internal/loki/backend_external_serve_test.go new file mode 100644 index 0000000..2364627 --- /dev/null +++ b/internal/loki/backend_external_serve_test.go @@ -0,0 +1,43 @@ +package loki + +import "testing" + +// Preset externe actif : `loki serve` n'a aucun moteur local à lancer. Il +// sortait en erreur « MODEL non défini », que systemd (Restart=on-failure) +// relançait en boucle (amont #95). +func TestServeExitsCleanlyForExternalPreset(t *testing.T) { + testHome(t) + if err := WriteConfig(map[string]string{extKeyFlag: "1", "PORT": "8080"}); err != nil { + t.Fatal(err) + } + if err := cmdServe(nil); err != nil { + t.Fatalf("loki serve sur un preset externe : %v", err) + } +} + +// Le pré-vol refusait `loki start` / `loki restart` sur un preset externe, +// en invitant à réinstaller llama.cpp. +func TestPreflightAcceptsExternalPreset(t *testing.T) { + testHome(t) + if err := WriteConfig(map[string]string{extKeyFlag: "1"}); err != nil { + t.Fatal(err) + } + if err := preflightEngine(); err != nil { + t.Fatalf("pré-vol refusé sur un preset externe : %v", err) + } +} + +// Strata : le pré-vol vérifie sa configuration à lui, pas BIN/MODEL. +func TestPreflightStrataWithoutConfig(t *testing.T) { + testHome(t) + if err := WriteConfig(map[string]string{"ENGINE": "strata"}); err != nil { + t.Fatal(err) + } + err := preflightEngine() + if err == nil { + t.Fatal("un preset Strata sans STRATA_CONFIG devrait être refusé") + } + if want := "STRATA_CONFIG"; !contains(err.Error(), want) { + t.Fatalf("message attendu sur %s : %v", want, err) + } +} diff --git a/internal/loki/backend_presets.go b/internal/loki/backend_presets.go index 6c76c5b..ee15ac1 100644 --- a/internal/loki/backend_presets.go +++ b/internal/loki/backend_presets.go @@ -113,7 +113,69 @@ func ListPresets() ([]Preset, error) { }) } sort.Slice(out, func(i, j int) bool { return out[i].Name < out[j].Name }) - return out, nil + return applyPresetOrder(out), nil +} + +// presetOrderKey : ordre d'affichage choisi par glisser-déposer dans l'UI +// (repris d'AJEAN v0.10.9), une liste d'IDs persistée en base. +const presetOrderKey = "preset_order" + +func loadPresetOrder() []string { + var ids []string + getJSON(bkState, presetOrderKey, &ids) + return ids +} + +func savePresetOrder(ids []string) error { return putJSON(bkState, presetOrderKey, ids) } + +// applyPresetOrder réordonne `list` (déjà trié par nom) selon l'ordre stocké : +// d'abord les presets classés dans l'ordre choisi, puis les non classés +// (fraîchement créés) dans leur ordre alphabétique. Tri STABLE. +func applyPresetOrder(list []Preset) []Preset { + order := loadPresetOrder() + if len(order) == 0 { + return list + } + pos := map[string]int{} + for i, id := range order { + pos[id] = i + } + sort.SliceStable(list, func(i, j int) bool { + pi, oki := pos[list[i].ID] + pj, okj := pos[list[j].ID] + if oki && okj { + return pi < pj + } + if oki != okj { + return oki // un preset classé passe avant un non classé + } + return false // deux non classés : l'ordre alphabétique reste + }) + return list +} + +// presetCtx : la taille de contexte d'un preset, formatée pour la pastille de +// la liste (« 32K », « 128K », « 1M »). CTX absent = 32768, le défaut de serve. +func presetCtx(content string) string { + raw := strings.TrimSpace(parseEnv(content)["CTX"]) + if raw == "" { + raw = "32768" + } + n, err := strconv.Atoi(raw) + if err != nil || n <= 0 { + return "" + } + // Multiple de 1024 (32768, 131072…) : écrit comme on le pense, « 32K », + // « 128K » ; sinon décimal, comme on le tape (40000 → « 40K »). + switch { + case n >= 1024 && n%1024 == 0 && n < 1<<20: + return strconv.Itoa(n/1024) + "K" + case n >= 1000000: + return strings.TrimSuffix(strconv.FormatFloat(float64(n)/1000000, 'f', 1, 64), ".0") + "M" + case n >= 1000: + return strconv.Itoa((n+500)/1000) + "K" + } + return strconv.Itoa(n) } // uniquePresetID derives a unique filename id from a display name, appending diff --git a/internal/loki/backend_presets_order_test.go b/internal/loki/backend_presets_order_test.go new file mode 100644 index 0000000..050c592 --- /dev/null +++ b/internal/loki/backend_presets_order_test.go @@ -0,0 +1,60 @@ +package loki + +import ( + "os" + "path/filepath" + "testing" +) + +// Ordre choisi par glisser-déposer : les presets classés d'abord, dans cet +// ordre ; les nouveaux (non classés) ensuite, par nom. +func TestPresetOrder(t *testing.T) { + testHome(t) + _ = os.MkdirAll(presetsDir(), 0o755) + for _, id := range []string{"alpha", "bravo", "charlie", "delta"} { + if err := os.WriteFile(filepath.Join(presetsDir(), id+".env"), []byte("# NAME="+id+"\nCTX=4096\n"), 0o644); err != nil { + t.Fatal(err) + } + } + ids := func() []string { + list, err := ListPresets() + if err != nil { + t.Fatal(err) + } + var out []string + for _, p := range list { + out = append(out, p.ID) + } + return out + } + if got := ids(); got[0] != "alpha" || got[3] != "delta" { + t.Fatalf("sans ordre enregistré, tri par nom attendu : %v", got) + } + if err := savePresetOrder([]string{"charlie", "alpha"}); err != nil { + t.Fatal(err) + } + want := []string{"charlie", "alpha", "bravo", "delta"} + got := ids() + for i := range want { + if got[i] != want[i] { + t.Fatalf("ordre %v, attendu %v", got, want) + } + } +} + +func TestPresetCtx(t *testing.T) { + cases := map[string]string{ + "": "32K", // défaut de serve : 32768 + "CTX=40000\n": "40K", + "CTX=\"131072\"\n": "128K", + "CTX=1000000\n": "1M", + "CTX=1500000\n": "1.5M", + "CTX=512\n": "512", + "CTX=abc\n": "", + } + for in, want := range cases { + if got := presetCtx(in); got != want { + t.Errorf("presetCtx(%q) = %q, attendu %q", in, got, want) + } + } +} diff --git a/internal/loki/backend_serve.go b/internal/loki/backend_serve.go index 4774edf..58715a3 100644 --- a/internal/loki/backend_serve.go +++ b/internal/loki/backend_serve.go @@ -272,6 +272,20 @@ func cmdServe(args []string) error { } cfg = c } + // API externe : aucun moteur local. On sort SANS erreur, sinon systemd + // (Restart=on-failure) relance en boucle « MODEL non défini » (amont #95). + if isExternalConfig(cfg) { + fmt.Println("[info] preset externe actif : pas de moteur local à lancer") + return nil + } + // Preset ENGINE=strata (backend_strata.go) : un autre moteur, lancé par son + // propre serveur. L'optimiseur ne règle que llama-server. + if isStrataConfig(cfg) { + if trialDir != "" { + return fmt.Errorf("l'optimiseur ne s'applique pas à un preset Strata") + } + return serveStrata(cfg) + } bin := cfg["BIN"] if bin == "" { return fmt.Errorf("BIN non défini — lance « loki edit »") @@ -463,6 +477,10 @@ func cmdServe(args []string) error { fmt.Fprintf(os.Stderr, "[loki serve] %s model=%s port=%s\n", bin, filepath.Base(model), port) + // Ligne de commande réellement lancée, gardée pour l'interface (amont #108). + if trialDir == "" { + recordEngineCmdline(env, llmArgs) + } // Hand off to the llama-server process. On Unix this replaces the current // process (exec); on Windows it runs as a child and waits. See sys_platform_*.go. diff --git a/internal/loki/backend_strata.go b/internal/loki/backend_strata.go new file mode 100644 index 0000000..f45f03d --- /dev/null +++ b/internal/loki/backend_strata.go @@ -0,0 +1,1226 @@ +package loki + +// backend_strata.go : deuxième moteur de Loki, Strata (github.com/Niko1221/Strata, +// MIT), spécialisé dans la famille Qwen3.8-Flash-Next (MoE de 125 B) : il +// répartit les experts entre VRAM, RAM et disque pour faire tenir le modèle sur +// une carte de joueur. +// +// Repris d'AJEAN (backend_moe.go, « AJEAN MoE 1.0 ») : le moteur est le paquet +// FIGÉ publié par AJEAN sur sa release GitHub moe-v1.0 — sources de Strata et +// binaire Linux CUDA 12 compilé et validé par l'auteur d'AJEAN (avec ses +// modifications : experts de la carte d'appoint gardés hors RAM). Les deux +// fichiers sont vérifiés par SHA-256 avant usage. +// +// Le choix du moteur se fait par PRESET : ENGINE=strata. Sans la clé (ou +// ENGINE=llamacpp), rien ne change, llama-server reste le moteur. +// +// Déroulé : +// 1. installation (tâche « strata », même suivi que llama.cpp) : télécharge le +// paquet figé, puis lance l'installeur de Strata en mode automatique en lui +// imposant le binaire du paquet (--prebuilt) ; l'installeur télécharge le +// modèle depuis Hugging Face à des révisions figées et le prépare ; +// 2. Loki écrit un preset ENGINE=strata et l'active ; +// 3. `loki serve` lance le serveur de Strata au lieu de llama-server +// (serveStrata), avec les réglages validés par AJEAN. +// +// Le serveur de Strata expose les mêmes points d'entrée que llama-server +// (/health, /props, /slots, /metrics, /v1/*) : le reste de Loki le voit comme un +// moteur local ordinaire. + +import ( + "archive/zip" + "crypto/sha256" + "encoding/hex" + "encoding/json" + "fmt" + "io" + "net/http" + "os" + "path/filepath" + "regexp" + "runtime" + "sort" + "strconv" + "strings" + "time" + + "os/exec" +) + +// Paquet figé (release moe-v1.0 d'AJEAN). Le moteur est compilé en CUDA 12.8, +// mode portable AVX2, sm_75/80/86/89/120 : RTX 20 à 50. Un moteur installé d'un +// 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" +) + +// strataAssetSHA : empreintes des fichiers de la release, vérifiées avant usage. +var strataAssetSHA = map[string]string{ + strataSrcAsset: "5747f69426973d957e4f9894a8e3333c6ef0d6ac8565e26ddb8d36f52211cb3b", + strataEngineAsset: "b5851bc138ab32ca3ffefc16bf91c8f557e4d503503473a22f2b6c9e9b8fd34c", +} + +// Valeurs de la clé ENGINE d'un preset. +const ( + engineLlamacpp = "llamacpp" + engineStrata = "strata" +) + +// engineOf : le moteur d'une configuration. « moe » est accepté pour un preset +// recopié depuis AJEAN. +func engineOf(cfg map[string]string) string { + switch strings.ToLower(strings.TrimSpace(cfg["ENGINE"])) { + case engineStrata, "moe": + return engineStrata + } + return engineLlamacpp +} + +// isStrataConfig : la configuration fait tourner Strata. +func isStrataConfig(cfg map[string]string) bool { return engineOf(cfg) == engineStrata } + +// strataActive : le preset actif fait tourner Strata. +func strataActive() bool { return isStrataConfig(ReadConfig()) } + +// strataFamily / strataQuant : ce que l'installeur de Strata sait installer, +// limité à ce qu'on propose en un clic. +type strataFamily struct { + ID string `json:"id"` // --family + Label string `json:"label"` // affiché + About string `json:"about"` // une ligne + Quants []string `json:"quants"` +} + +type strataQuant struct { + ID string `json:"id"` // --model + About string `json:"about"` // une ligne + DownloadGB float64 `json:"download_gb"` // modèle + tête MTP + encodeur vision + ArenaGB float64 `json:"arena_gb"` // experts à garder en RAM (ou en mmap) +} + +// Valeurs reprises de la table MODELS de l'installeur. +var strataFamilies = []strataFamily{ + {ID: "swift", Label: "Swift 1.5", About: "réfléchit moins, répond plus vite", Quants: []string{"IQ2_XS", "IQ3_XXS"}}, + {ID: "qwen", Label: "Classique", About: "le Qwen3.8-Flash-Next original", Quants: []string{"Q2_0", "IQ2_XS", "IQ3_XXS", "IQ3_S"}}, +} + +var strataQuants = map[string]strataQuant{ + "Q2_0": {ID: "Q2_0", About: "le plus rapide", DownloadGB: 66.4, ArenaGB: 34.0}, + "IQ2_XS": {ID: "IQ2_XS", About: "un peu plus fidèle, presque aussi rapide", DownloadGB: 68.0, ArenaGB: 35.5}, + "IQ3_XXS": {ID: "IQ3_XXS", About: "99 % de la qualité du modèle complet", DownloadGB: 75.8, ArenaGB: 42.9}, + "IQ3_S": {ID: "IQ3_S", About: "la meilleure qualité, le plus lent", DownloadGB: 83.6, ArenaGB: 50.3}, +} + +// strataLowRAMHeadroomGB : RAM à laisser à côté des experts (OS, moteur, +// serveur, et ici l'UI de Loki). En dessous, les experts passent en mmap. +const strataLowRAMHeadroomGB = 10 + +// strataDropHeadroomGB : RAM à laisser à côté des experts verrouillés quand la +// carte d'aide garde les siens hors RAM (mesuré par AJEAN : 46 Go de RAM, +// 34,7 Gio d'experts verrouillés, ~2,3 Go encore libres). +const strataDropHeadroomGB = 7 + +// strataHelperExpertsGB : ce que la carte d'aide garde d'experts (sa VRAM moins +// le contexte CUDA, l'encodeur d'images et la marge : 5,3 Gio sur une 3070 8 Go). +func strataHelperExpertsGB(vramGB float64) float64 { return max(vramGB-2.5, 0) } + +// strataMmapExtraGB : le mode mmap écrit les experts dans un fichier à part +// (experts.bin), à compter en plus du téléchargement. +const strataMmapExtraGB = 45 + +// strataMaxCtx : contexte natif maximal de Qwen3.8 Flash Next. +const strataMaxCtx = 262144 + +func strataHome() string { return filepath.Join(LokiHome(), "strata") } +func strataSrcDir() string { return filepath.Join(strataHome(), "ajean-moe-"+strataVersion) } +func strataDataDir() string { return filepath.Join(strataHome(), "data") } +func strataDLDir() string { return filepath.Join(strataHome(), "dl") } + +// --------------------------------------------------------------------------- +// Matériel et recommandation +// --------------------------------------------------------------------------- + +// strataGPU : une carte NVIDIA vue par nvidia-smi (index = numérotation +// nvidia-smi, alignée sur CUDA avec CUDA_DEVICE_ORDER=PCI_BUS_ID). +type strataGPU struct { + Index int `json:"index"` + Name string `json:"name"` + VRAMGB float64 `json:"vram_gb"` + Arch int `json:"arch"` // compute capability ×10 (8.6 → 86) +} + +// strataEnv : ce que l'écran d'installation doit savoir de la machine. +type strataEnv struct { + Supported bool `json:"supported"` + Reason string `json:"reason,omitempty"` // pourquoi pas, en clair + GPUs []strataGPU `json:"gpus"` + Main int `json:"main"` // index nvidia-smi de la carte principale + Helper int `json:"helper"` // index de la carte d'aide, -1 sans + RAMGB float64 `json:"ram_gb"` + DiskFreeGB float64 `json:"disk_free_gb"` + Python string `json:"python,omitempty"` +} + +// strataMinArch : la plus ancienne carte pour laquelle le moteur est compilé (RTX 20). +const strataMinArch = 75 + +func strataDetect() strataEnv { + env := strataEnv{Main: -1, Helper: -1, RAMGB: totalRAMGB()} + if f := diskFree(LokiHome()); f > 0 { + env.DiskFreeGB = float64(f) / 1e9 + } + if runtime.GOOS != "linux" || runtime.GOARCH != "amd64" { + env.Reason = "Strata n'est proposé que sous Linux (x86_64)" + return env + } + gpus, err := detectGPUs() + if err != nil || len(gpus) == 0 { + why := "aucune carte détectée" + if err != nil { + why = err.Error() + } + env.Reason = "Strata demande une carte NVIDIA (" + why + ")" + return env + } + for _, g := range gpus { + mib, _ := strconv.ParseFloat(g.MemTotal, 64) + cc, _ := strconv.ParseFloat(g.Cap, 64) + env.GPUs = append(env.GPUs, strataGPU{Index: g.Index, Name: g.Name, VRAMGB: mib / 1024, Arch: int(cc*10 + 0.5)}) + } + // Carte principale = la plus grosse VRAM (à égalité : la plus récente) ; la + // suivante, si elle est assez récente, calcule une part des experts. + cards := append([]strataGPU(nil), env.GPUs...) + sort.SliceStable(cards, func(i, j int) bool { + if cards[i].VRAMGB != cards[j].VRAMGB { + return cards[i].VRAMGB > cards[j].VRAMGB + } + return cards[i].Arch > cards[j].Arch + }) + if cards[0].Arch < strataMinArch { + env.Reason = fmt.Sprintf("la carte %s est trop ancienne pour Strata (RTX 20 ou plus récente)", cards[0].Name) + return env + } + if cards[0].VRAMGB < 7.5 { + env.Reason = fmt.Sprintf("la carte %s n'a que %.0f Go de VRAM (8 Go minimum)", cards[0].Name, cards[0].VRAMGB) + return env + } + env.Main = cards[0].Index + if len(cards) > 1 && cards[1].Arch >= strataMinArch && cards[1].VRAMGB >= 5.5 { + env.Helper = cards[1].Index + } + py, perr := strataCheckPython() + env.Python = py + if perr != nil { + env.Reason = perr.Error() + return env + } + env.Supported = true + return env +} + +var rePyVersion = regexp.MustCompile(`Python (\d+)\.(\d+)`) + +// strataCheckPython : l'installeur de Strata est en Python (3.10 ou plus +// récent) et crée son propre environnement (module venv). L'image Docker de +// Loki les embarque ; hors image, il faut python3 et python3-venv. +func strataCheckPython() (string, error) { + out, err := hideCmd(exec.Command("python3", "--version")).CombinedOutput() + if err != nil { + return "", fmt.Errorf("Python 3 est introuvable : installe-le (apt install python3 python3-venv)") + } + m := rePyVersion.FindStringSubmatch(string(out)) + if m == nil { + return "", fmt.Errorf("version de Python illisible : %s", strings.TrimSpace(string(out))) + } + maj, _ := strconv.Atoi(m[1]) + mnr, _ := strconv.Atoi(m[2]) + ver := m[1] + "." + m[2] + if maj < 3 || (maj == 3 && mnr < 10) { + return ver, fmt.Errorf("Python %s est trop ancien : il faut Python 3.10 ou plus récent", ver) + } + if e := hideCmd(exec.Command("python3", "-c", "import venv, ensurepip")).Run(); e != nil { + return ver, fmt.Errorf("le module venv de Python manque : apt install python%s-venv", ver) + } + return ver, nil +} + +// strataTag : le nom que l'installeur donne aux dossiers d'un choix +// (models/, packs/) : « swift-IQ3_XXS », « IQ3_XXS ». +func strataTag(family, quant string) string { + if family == "swift" { + return "swift-" + quant + } + return quant +} + +func strataDirGB(dir string) float64 { + var total int64 + _ = filepath.WalkDir(dir, func(_ string, d os.DirEntry, err error) error { + if err == nil && d.Type().IsRegular() { + if fi, e := d.Info(); e == nil { + total += fi.Size() + } + } + return nil + }) + return float64(total) / 1e9 +} + +// strataHaveGB : ce qui est déjà sur le disque pour CE choix (installation +// reprise ou réinstallation) : ses fichiers de modèle, son experts.bin, et la +// tête MTP commune à tous les choix. Autant de téléchargement en moins. +func strataHaveGB(family, quant string) float64 { + tag := strataTag(family, quant) + return strataDirGB(filepath.Join(strataDataDir(), "models", tag)) + + strataDirGB(filepath.Join(strataDataDir(), "packs", strings.ToLower(tag))) + + strataDirGB(filepath.Join(strataDataDir(), "mtp")) +} + +// strataNeedsMmap : les experts ne tiennent pas en RAM avec la marge +// nécessaire, ils seront lus en mmap depuis un fichier à part (experts.bin). +func strataNeedsMmap(q strataQuant, ramGB float64) bool { + return ramGB < q.ArenaGB+strataLowRAMHeadroomGB +} + +// strataDropFits : avec une carte d'aide, les experts qu'elle garde ne sont pas +// en RAM ; le reste est verrouillé en RAM (plus rapide que le mmap : génération +// ~64 au lieu de ~56 tok/s, lecture +17 à +38 %). Il faut que ce reste tienne. +func strataDropFits(q strataQuant, env strataEnv) bool { + if env.Helper < 0 { + return false + } + for _, g := range env.GPUs { + if g.Index == env.Helper { + return env.RAMGB >= q.ArenaGB-strataHelperExpertsGB(g.VRAMGB)+strataDropHeadroomGB + } + } + return false +} + +// strataFit : ce qu'un quant demande sur cette machine, et s'il y tient. +type strataFit struct { + Quant strataQuant `json:"quant"` + DiskGB float64 `json:"disk_gb"` + Mmap bool `json:"mmap"` + Drop bool `json:"drop"` // experts de la carte d'aide hors RAM, le reste verrouillé + OK bool `json:"ok"` + Why string `json:"why,omitempty"` + Preset string `json:"preset,omitempty"` // id du preset si ce choix est déjà installé + Active bool `json:"active,omitempty"` // ... et si c'est le preset actif + // Details : la config de lancement de ce preset, calculée par la MÊME + // fonction que le lancement (strataBuildConfig) : ce qui est affiché tourne. + Details *strataDetails `json:"details,omitempty"` +} + +// strataDetails : la config d'un preset installé, en clair pour l'interface. +type strataDetails struct { + Version string `json:"version"` + Ctx string `json:"ctx"` + KV string `json:"kv"` + KVResident string `json:"kv_resident,omitempty"` + Spec string `json:"spec"` + Prefill string `json:"prefill"` + ShortRead string `json:"short_read"` + CacheRoot string `json:"cache_root"` + Mmap bool `json:"mmap"` + Drop bool `json:"drop"` + MainGPU string `json:"main_gpu"` + HelperGPU string `json:"helper_gpu,omitempty"` + VisionGPU string `json:"vision_gpu,omitempty"` + // pour les réglages : ce que la machine permet et l'état actuel + HasHelper bool `json:"has_helper"` // une carte d'aide est disponible + HelperOn bool `json:"helper_on"` + Vision bool `json:"vision"` +} + +// strataDetailsFor lit un preset installé et calcule sa config de lancement. +func strataDetailsFor(presetID string, env strataEnv) *strataDetails { + content, err := ReadPreset(presetID) + if err != nil { + return nil + } + pc := parseEnv(content) + raw, err := os.ReadFile(pc["STRATA_CONFIG"]) + if err != nil { + return nil + } + var base map[string]any + if json.Unmarshal(raw, &base) != nil { + return nil + } + out, err := strataBuildConfig(base, pc, "") + if err != nil { + return nil + } + args := strataArgList(out) + val := func(flag string) string { + for i := 0; i+1 < len(args); i++ { + if args[i] == flag { + return args[i+1] + } + } + return "" + } + gpuName := func(idx string) string { + for _, g := range env.GPUs { + if strconv.Itoa(g.Index) == strings.TrimSpace(idx) { + return fmt.Sprintf("%s (%.0f Go)", strings.TrimPrefix(g.Name, "NVIDIA GeForce "), g.VRAMGB) + } + } + return "" + } + d := &strataDetails{ + Version: strataVersion, Ctx: val("--max-context"), KV: val("--kv"), KVResident: val("--kv-resident"), + Spec: val("--spec"), Prefill: val("--prefill"), ShortRead: val("--short-read"), + CacheRoot: val("--prompt-cache-root"), MainGPU: gpuName(pc["STRATA_MAIN_GPU"]), + } + if e, ok := out["env"].(map[string]any); ok { + d.Mmap = fmt.Sprint(e["STRATA_ARENA_MMAP"]) == "1" + d.Drop = fmt.Sprint(e["STRATA_REMOTE_DROP"]) == "1" + } + if h := strings.TrimSpace(pc["STRATA_HELPER_GPU"]); h != "" && h != "-1" { + d.HasHelper = true + if strataHelperOn(pc) { + d.HelperOn = true + d.HelperGPU = gpuName(h) + } + } + d.Vision = pc["STRATA_VISION"] != "0" + if v, ok := out["vision"].(map[string]any); ok && d.Vision { + if dev, has := v["cuda_device"]; has { + d.VisionGPU = gpuName(fmt.Sprint(dev)) + } else { + d.VisionGPU = d.MainGPU + } + } + return d +} + +func strataFitFor(q strataQuant, env strataEnv, haveGB float64) strataFit { + f := strataFit{Quant: q, Mmap: strataNeedsMmap(q, env.RAMGB)} + if f.Mmap && strataDropFits(q, env) { + f.Mmap, f.Drop = false, true + } + f.DiskGB = q.DownloadGB + // les deux modes lisent les experts dans experts.bin, écrit au premier lancement + if f.Mmap || f.Drop { + f.DiskGB += strataMmapExtraGB + } + // ce qui est déjà là ne se retélécharge pas ; il reste toujours les + // environnements (Python, bibliothèques CUDA, moteur) : ~10 Go + f.DiskGB = max(f.DiskGB-haveGB, 10) + // En mmap, le système garde les experts en cache tant que la RAM le permet ; + // au-delà, ils sont relus depuis le disque à chaque jeton : trop lent. On + // demande que la RAM + la VRAM des cartes couvrent au moins les experts. + vram := 0.0 + for _, g := range env.GPUs { + if g.Index == env.Main || g.Index == env.Helper { + vram += g.VRAMGB + } + } + switch { + case env.RAMGB+vram-12 < q.ArenaGB: + f.Why = fmt.Sprintf("demande plus de mémoire : %.0f Go de RAM + VRAM, il en faut ~%.0f", env.RAMGB+vram, q.ArenaGB+12) + case env.DiskFreeGB > 0 && env.DiskFreeGB < f.DiskGB+5: + f.Why = fmt.Sprintf("pas assez d'espace disque : %.0f Go libres, il en faut ~%.0f", env.DiskFreeGB, f.DiskGB+5) + default: + f.OK = true + } + return f +} + +// strataRecommend : le quant le plus fidèle qui tient, IQ3_XXS de préférence +// (meilleur compromis mesuré) ; IQ3_S seulement si la RAM le loge sans mmap. +func strataRecommend(family string, env strataEnv) string { + var fam *strataFamily + for i := range strataFamilies { + if strataFamilies[i].ID == family { + fam = &strataFamilies[i] + } + } + if fam == nil { + return "" + } + best := "" + for _, id := range []string{"IQ3_XXS", "IQ2_XS", "Q2_0"} { + if !strataHas(fam.Quants, id) { + continue + } + if strataFitFor(strataQuants[id], env, strataHaveGB(family, id)).OK { + best = id + break + } + } + if strataHas(fam.Quants, "IQ3_S") { + if f := strataFitFor(strataQuants["IQ3_S"], env, strataHaveGB(family, "IQ3_S")); f.OK && !f.Mmap { + best = "IQ3_S" + } + } + return best +} + +func strataHas(list []string, s string) bool { + for _, x := range list { + if x == s { + return true + } + } + return false +} + +// --------------------------------------------------------------------------- +// Installation +// --------------------------------------------------------------------------- + +// strataInstallReq : le choix de l'utilisateur. Le reste est déduit de la machine. +type strataInstallReq struct { + Family string `json:"family"` + Quant string `json:"quant"` +} + +// strataPresetName : nom du fichier preset (et donc de son id) pour un choix donné. +func strataPresetName(family, quant string) string { + label := "Classique" + if family == "swift" { + label = "Swift 1.5" + } + return "STRATA FLASH NEXT " + strings.ToUpper(label) + " " + quant +} + +func strataFamilyLabel(id string) string { + for _, f := range strataFamilies { + if f.ID == id { + if id == "qwen" { + return "Qwen3.8 Flash Next" + } + return "Qwen3.8 Flash Next " + f.Label + } + } + return id +} + +// strataFetch télécharge un fichier de la release figée et vérifie son SHA-256. +func strataFetch(asset string) (string, error) { + want := strataAssetSHA[asset] + dst := filepath.Join(strataDLDir(), asset) + if got, err := strataSHA256File(dst); err == nil && got == want { + lcAppend(asset + " déjà téléchargé") + return dst, nil + } + if err := os.MkdirAll(strataDLDir(), 0o755); err != nil { + return "", err + } + lcPhase("téléchargement de " + asset + "…") + tmp := dst + ".part" + if err := downloadWithProgress(strataReleaseBase+asset, tmp, 0, lcAppend); err != nil { + _ = os.Remove(tmp) + return "", fmt.Errorf("téléchargement de %s : %w", asset, err) + } + got, err := strataSHA256File(tmp) + if err != nil { + return "", err + } + if got != want { + _ = os.Remove(tmp) + return "", fmt.Errorf("%s : empreinte SHA-256 inattendue (fichier altéré ou incomplet)", asset) + } + return dst, os.Rename(tmp, dst) +} + +func strataSHA256File(p string) (string, error) { + f, err := os.Open(p) + if err != nil { + return "", err + } + defer f.Close() + h := sha256.New() + if _, err := io.Copy(h, f); err != nil { + return "", err + } + return hex.EncodeToString(h.Sum(nil)), nil +} + +func strataCopyFile(src, dst string) error { + in, err := os.Open(src) + if err != nil { + return err + } + defer in.Close() + out, err := os.Create(dst) + if err != nil { + return err + } + if _, err := io.Copy(out, in); err != nil { + out.Close() + return err + } + return out.Close() +} + +// strataRunInstall : le corps de la tâche d'installation. +func strataRunInstall(req strataInstallReq) { + env := strataDetect() + if !env.Supported { + lcFail(fmt.Errorf("%s", env.Reason)) + return + } + q, ok := strataQuants[req.Quant] + if !ok { + lcFail(fmt.Errorf("quant inconnu : %s", req.Quant)) + return + } + fit := strataFitFor(q, env, strataHaveGB(req.Family, q.ID)) + if !fit.OK { + lcFail(fmt.Errorf("%s ne tient pas sur cette machine : %s", q.ID, fit.Why)) + return + } + + // 1. le paquet figé : sources (installeur + serveur) et moteur compilé + src, err := strataFetch(strataSrcAsset) + if err != nil { + lcFail(err) + return + } + engine, err := strataFetch(strataEngineAsset) + if err != nil { + lcFail(err) + return + } + if _, err := os.Stat(filepath.Join(strataSrcDir(), "setup.py")); err != nil { + lcPhase("décompression de Strata…") + if err := extractArchive(src, strataHome()); err != nil { + lcFail(fmt.Errorf("décompression : %w", err)) + return + } + } + // L'installeur prend le moteur dans un dossier local (--prebuilt) : il le + // décompresse dans engine-cuda12/ et contrôle qu'il couvre la carte. + pre := filepath.Join(strataHome(), "prebuilt") + _ = os.MkdirAll(pre, 0o755) + if err := strataCopyFile(engine, filepath.Join(pre, strataEngineLocal)); err != nil { + lcFail(err) + return + } + + // 2. l'installeur, sans question : environnement Python, moteur, modèle + // (Hugging Face, révisions figées), tête MTP, encodeur vision, pack. + lcPhase(fmt.Sprintf("installation de %s %s (téléchargement de ~%.0f Go)…", strataFamilyLabel(req.Family), q.ID, q.DownloadGB)) + // setup.sh crée l'environnement Python (.venv) puis y relance setup.py : + // Python 3.10+ et venv sont vérifiés plus haut (strataDetect). + args := []string{"setup.sh", "--setup", "--yes", "--no-start", "--no-browser", + "--family", req.Family, "--model", q.ID, + "--context", "131072", "--vision", "gpu", + "--cuda", "12", "--prebuilt", pre + string(os.PathSeparator), + "--data-dir", strataDataDir(), + "--gpu", strconv.Itoa(env.Main), + // Le mode « peu de RAM » est décidé et appliqué par Loki (STRATA_MMAP, + // voir strataBuildConfig) : l'installeur tourne en mode normal, sans + // réserver sa propre variante (qui ne voit pas un experts.bin présent). + "--low-ram", "off", + } + extra := "CUDA_DEVICE_ORDER=PCI_BUS_ID\x00PYTHONUNBUFFERED=1" + if err := runStepEnv("installation de Strata", strataSrcDir(), extra, "bash", args...); err != nil { + lcFail(fmt.Errorf("l'installation a échoué : %w (voir le journal)", err)) + return + } + // Marque le moteur installé : c'est celui du paquet (strataEnsureEngine). + _ = os.WriteFile(strataEngineMarker(), []byte(strataEngineAsset+"\n"), 0o644) + cfgPath, err := strataFindConfig(q.ID) + if err != nil { + lcFail(err) + return + } + + // 3. le preset Loki, puis bascule dessus + name := strataPresetName(req.Family, q.ID) + preset := filepath.Join(presetsDir(), name+".env") + body := strings.Join([]string{ + "# NAME=" + strataFamilyLabel(req.Family) + " " + q.ID + " (Strata)", + "ENGINE=" + engineStrata, + "STRATA_CONFIG=" + cfgPath, + "STRATA_MAIN_GPU=" + strconv.Itoa(env.Main), + "STRATA_HELPER_GPU=" + strconv.Itoa(env.Helper), + "STRATA_VISION=1", + "STRATA_MMAP=" + strataBool(fit.Mmap), + "STRATA_DROP=" + strataBool(fit.Drop), + // la meilleure qualité validée par AJEAN : cache KV en fp16 (32K en + // VRAM, le reste en RAM pour ne pas prendre la place des experts) et 3 + // jetons de brouillon MTP (mesuré meilleur que 4) + "STRATA_KV=fp16", + "STRATA_KV_RESIDENT=32768", + "STRATA_SPEC=3", + "CTX=131072", + "REASONING=off", + "", + }, "\n") + if err := os.MkdirAll(presetsDir(), 0o755); err != nil { + lcFail(err) + return + } + if err := os.WriteFile(preset, []byte(body), 0o644); err != nil { + lcFail(err) + return + } + lcPhase("activation du modèle (premier chargement : quelques minutes)…") + if err := SwitchToPreset(preset); err != nil { + lcFail(fmt.Errorf("modèle installé mais bascule impossible : %w", err)) + return + } + lcDone(strataFamilyLabel(req.Family) + " " + q.ID + " installé (Strata)") +} + +func strataBool(b bool) string { + if b { + return "1" + } + return "0" +} + +// strataFindConfig : la config de lancement que l'installeur a écrite +// (strata-.json à la racine des sources), celle du quant demandé. +func strataFindConfig(quant string) (string, error) { + matches, _ := filepath.Glob(filepath.Join(strataSrcDir(), "strata-*.json")) + q := strings.ToLower(quant) + for _, m := range matches { + if strings.Contains(strings.ToLower(filepath.Base(m)), q) { + return m, nil + } + } + return "", fmt.Errorf("l'installeur n'a pas laissé de configuration pour %s dans %s", quant, strataSrcDir()) +} + +// --------------------------------------------------------------------------- +// Lancement (loki serve) +// --------------------------------------------------------------------------- + +// strataTuning : réglages validés par AJEAN (2026-10-05), ajoutés à la config +// de l'installeur. Chacun a été mesuré en A/B. +// +// - --prefill 16384 : +15 % de lecture sur les longs prompts (40K) ; +// - --short-read 150 : les petits ajouts (< 150 jetons) sont lus comme en +// génération, sans le coût fixe du chemin par blocs : ~40 % plus rapides ; +// - --prompt-cache-root 256 : point de reprise posé à la fin du prompt système +// (défaut 2048) : une nouvelle conversation démarre en ~0,3 s au lieu de 4-5 s. +var strataTuning = map[string]string{ + "--prefill": "16384", + "--short-read": "150", + "--prompt-cache-root": "256", +} + +// strataEnvTuning : variables d'environnement du moteur. +// - STRATA_PLE_BATCH=0 : le PLE batché du prefill plante sur certaines cartes +// (« native PLE postops launch: illegal memory access ») ; +// - STRATA_NO_LARGEPAGES=1 : sans, MADV_HUGEPAGE fige le démarrage de longues +// minutes dans le compactage mémoire du noyau. +var strataEnvTuning = []string{"STRATA_PLE_BATCH=0", "STRATA_NO_LARGEPAGES=1"} + +func strataSetArg(args []string, flag, value string) []string { + for i := 0; i+1 < len(args); i++ { + if args[i] == flag { + args[i+1] = value + return args + } + } + return append(args, flag, value) +} + +// strataDropFlag retire un drapeau SANS valeur. +func strataDropFlag(args []string, flag string) []string { + out := args[:0:0] + for _, a := range args { + if a != flag { + out = append(out, a) + } + } + return out +} + +// strataDropFlagValue retire un drapeau ET sa valeur. +func strataDropFlagValue(args []string, flag string) []string { + out := args[:0:0] + for i := 0; i < len(args); i++ { + if args[i] == flag { + i++ + continue + } + out = append(out, args[i]) + } + return out +} + +func strataHasArg(args []string, flag string) bool { + for _, a := range args { + if a == flag { + return true + } + } + return false +} + +func strataArgList(cfg map[string]any) []string { + var args []string + if list, ok := cfg["args"].([]any); ok { + for _, a := range list { + args = append(args, fmt.Sprint(a)) + } + } + return args +} + +// strataBuildConfig lit la config de l'installeur et y applique les réglages +// du preset. Pure (testable) : ne lance rien. +func strataBuildConfig(base map[string]any, cfg map[string]string, apiKey string) (map[string]any, error) { + raw, _ := json.Marshal(base) + var out map[string]any + if err := json.Unmarshal(raw, &out); err != nil { + return nil, err + } + if _, ok := out["args"].([]any); !ok { + return nil, fmt.Errorf("configuration de Strata sans « args »") + } + args := strataArgList(out) + for flag, v := range strataTuning { + args = strataSetArg(args, flag, v) + } + helper := strings.TrimSpace(cfg["STRATA_HELPER_GPU"]) + hasHelper := strataHelperOn(cfg) + // Deuxième carte en aide : elle garde et CALCULE sa part des experts (le + // chemin « helper » du moteur), la carte principale reste CUDA0. Les cartes + // sont posées par CUDA_VISIBLE_DEVICES (serveStrata), PAS par la clé « gpu » : + // avec deux cartes, le serveur y lirait une répartition des couches + // (--layer-split), un autre mode que celui mesuré. + if hasHelper { + args = strataSetArg(args, "--expert-cache-device1", "auto") + if !strataHasArg(args, "--remote-expert-opt") { + args = append(args, "--remote-expert-opt") + } + delete(out, "gpu") + // réserve de VRAM posée par l'installeur : absente de la config mesurée, + // elle retire du cache d'experts à la principale + args = strataDropFlagValue(args, "--vram-reserve-mib") + // l'encodeur d'images sur la carte d'aide : la principale garde toute sa + // VRAM pour les experts (mesuré : -1,5 % au lieu de -3 % sur la principale) + if v, ok := out["vision"].(map[string]any); ok { + if n, err := strconv.Atoi(helper); err == nil { + v["cuda_device"] = n + } + } + } + // Mode « peu de RAM » : l'installeur écrit --mmap-experts / --resident-experts. + // On applique à la place la variante mesurée par AJEAN : le pack experts.bin + // mappé tel quel (STRATA_ARENA_MMAP, le système garde en cache ce que la RAM + // permet), sans part PCIe (le mappage n'en donne pas l'adresse GPU). Le + // moteur écrit experts.bin lui-même au premier démarrage s'il manque. + env := map[string]any{} + if e, ok := out["env"].(map[string]any); ok { + env = e + } + if hasHelper && cfg["STRATA_DROP"] == "1" && strataDropReady(args) { + // Les experts de la carte d'aide hors RAM, le reste verrouillé en RAM + // (strataDropFits) : la principale relit aussi par PCIe une part de ce + // qui lui manque (part automatique) ; la réserve de la carte d'aide est figée. + args = strataDropFlag(strataDropFlag(args, "--mmap-experts"), "--resident-experts") + args = strataDropFlagValue(args, "--pcie-frac") + delete(env, "STRATA_ARENA_MMAP") + env["STRATA_REMOTE_DROP"] = "1" + // ses experts sont relus de sa VRAM à la lecture d'un prompt, par les fils + // de copie : 8 fils et 32 tampons (le moteur en prendrait 32 et 128) + env["STRATA_STAGER_THREADS"] = "8" + env["STRATA_STAGER_RING"] = "32" + } else if cfg["STRATA_MMAP"] == "1" || cfg["STRATA_DROP"] == "1" || strataHasArg(args, "--mmap-experts") || strataHasArg(args, "--resident-experts") { + // (STRATA_DROP sans experts.bin : ce lancement l'écrit, en mmap) + args = strataDropFlag(strataDropFlag(args, "--mmap-experts"), "--resident-experts") + args = strataSetArg(args, "--pcie-frac", "0") + env["STRATA_ARENA_MMAP"] = "1" + } + for _, kv := range strataEnvTuning { + k, v, _ := strings.Cut(kv, "=") + env[k] = v + } + out["env"] = env + if ctx := strings.TrimSpace(cfg["CTX"]); ctx != "" { + args = strataSetArg(args, "--max-context", ctx) + } + // réglages imposés par le preset (sinon : ceux de l'installeur) + if kv := strings.TrimSpace(cfg["STRATA_KV"]); kv != "" { + args = strataSetArg(args, "--kv", kv) + } + if sp := strings.TrimSpace(cfg["STRATA_SPEC"]); sp != "" { + args = strataSetArg(args, "--spec", sp) + } + if kr := strings.TrimSpace(cfg["STRATA_KV_RESIDENT"]); kr != "" { + args = strataSetArg(args, "--kv-resident", kr) + } + // lecture des images coupée dans les réglages : ni encodeur ni --vision + if cfg["STRATA_VISION"] == "0" { + args = strataDropFlag(args, "--vision") + delete(out, "vision") + } + list := make([]any, len(args)) + for i, a := range args { + list[i] = a + } + out["args"] = list + out["port"], _ = strconv.Atoi(firstNonEmpty(cfg["PORT"], "8080")) + out["host"] = firstNonEmpty(cfg["HOST"], "127.0.0.1") + if apiKey != "" { + out["api_key"] = apiKey + } + return out, nil +} + +// strataDropReady : le mode « experts de la carte d'aide hors RAM » lit les +// experts dans experts.bin (écrit au premier lancement, en mmap) et demande le +// moteur du paquet. +func strataDropReady(args []string) bool { + pack := "" + for i := 0; i+1 < len(args); i++ { + if args[i] == "--pack" { + pack = args[i+1] + } + } + if pack == "" { + return false + } + if _, err := os.Stat(filepath.Join(pack, "experts.bin")); err != nil { + return false + } + return strataEngineInstalled() == strataEngineAsset +} + +// strataEngineMarker : le paquet dont vient le moteur installé. +func strataEngineMarker() string { + return filepath.Join(strataSrcDir(), "engine-cuda12", ".loki-engine") +} + +func strataEngineInstalled() string { + b, _ := os.ReadFile(strataEngineMarker()) + return strings.TrimSpace(string(b)) +} + +// strataEnsureEngine : remplace un moteur installé d'une autre révision par +// celui du paquet attendu (téléchargé et vérifié si besoin). En cas d'échec +// l'ancien reste, et le mode qui demande le nouveau n'est pas activé. +func strataEnsureEngine() error { + if strataEngineInstalled() == strataEngineAsset { + return nil + } + dir := filepath.Join(strataSrcDir(), "engine-cuda12") + if _, err := os.Stat(dir); err != nil { + return err + } + zp, err := strataFetch(strataEngineAsset) + if err != nil { + return err + } + z, err := zip.OpenReader(zp) + if err != nil { + return err + } + defer z.Close() + for _, f := range z.File { + name := filepath.Base(f.Name) + if name != "strata" && name != "strata-vision" && name != "BUILD.json" { + continue + } + rc, err := f.Open() + if err != nil { + return err + } + tmp := filepath.Join(dir, name+".new") + w, err := os.OpenFile(tmp, os.O_CREATE|os.O_WRONLY|os.O_TRUNC, 0o755) + if err == nil { + _, err = io.Copy(w, rc) + if cerr := w.Close(); err == nil { + err = cerr + } + } + rc.Close() + if err != nil { + _ = os.Remove(tmp) + return err + } + // rename : remplace le binaire même s'il tourne encore + if err := os.Rename(tmp, filepath.Join(dir, name)); err != nil { + return err + } + } + return os.WriteFile(strataEngineMarker(), []byte(strataEngineAsset+"\n"), 0o644) +} + +// strataHelperOn : la carte d'aide existe et n'a pas été coupée dans les +// réglages (STRATA_HELPER=0 la coupe en gardant son index pour la remettre). +func strataHelperOn(cfg map[string]string) bool { + h := strings.TrimSpace(cfg["STRATA_HELPER_GPU"]) + return h != "" && h != "-1" && cfg["STRATA_HELPER"] != "0" +} + +// strataCudaDevices : ordre des cartes vu par le moteur, la principale en CUDA0. +func strataCudaDevices(cfg map[string]string) string { + main := strings.TrimSpace(cfg["STRATA_MAIN_GPU"]) + if main == "" || main == "-1" { + return "" + } + if strataHelperOn(cfg) { + return main + "," + strings.TrimSpace(cfg["STRATA_HELPER_GPU"]) + } + return main +} + +// strataPreflight : ce qu'il faut pour lancer Strata, vérifié avant le +// démarrage du service (preflightEngine) pour un message clair. +func strataPreflight(cfg map[string]string) error { + path := strings.TrimSpace(cfg["STRATA_CONFIG"]) + if path == "" { + return fmt.Errorf("STRATA_CONFIG non défini : réinstalle le modèle depuis Paramètres → Moteur → Strata") + } + if _, err := os.Stat(path); err != nil { + return fmt.Errorf("configuration de Strata introuvable : %s — réinstalle le modèle", path) + } + py := filepath.Join(filepath.Dir(path), ".venv", "bin", "python") + if _, err := os.Stat(py); err != nil { + return fmt.Errorf("environnement Python de Strata absent (%s) : réinstalle le modèle", py) + } + return nil +} + +// serveStrata : la branche Strata de `loki serve`. Écrit la config finale à +// côté de celle de l'installeur puis remplace le process par le serveur de +// Strata (comme pour llama-server : le superviseur suit directement le moteur). +func serveStrata(cfg map[string]string) error { + if err := strataPreflight(cfg); err != nil { + return err + } + path := strings.TrimSpace(cfg["STRATA_CONFIG"]) + raw, err := os.ReadFile(path) + if err != nil { + return fmt.Errorf("configuration de Strata introuvable : %s", path) + } + var base map[string]any + if err := json.Unmarshal(raw, &base); err != nil { + return fmt.Errorf("configuration de Strata illisible (%s) : %w", path, err) + } + if err := strataEnsureEngine(); err != nil { + fmt.Fprintf(os.Stderr, "[loki serve] moteur %s non installé (%v) : l'ancien reste en place\n", strataEngineAsset, err) + } + apiKey, _ := effectiveAPIKeyErr() + final, err := strataBuildConfig(base, cfg, apiKey) + if err != nil { + return err + } + srcDir := filepath.Dir(path) + py := filepath.Join(srcDir, ".venv", "bin", "python") + tag := strataConfigTag(path) + final["log"] = filepath.Join(strataHome(), "strata-"+tag+".log") + out := filepath.Join(strataHome(), "loki-strata-"+tag+".json") + data, _ := json.MarshalIndent(final, "", " ") + if err := os.WriteFile(out, data, 0o644); err != nil { + return err + } + _ = os.Setenv("CUDA_DEVICE_ORDER", "PCI_BUS_ID") + if dev := strataCudaDevices(cfg); dev != "" { + _ = os.Setenv("CUDA_VISIBLE_DEVICES", dev) + } + _ = os.Setenv("PYTHONUNBUFFERED", "1") + port := fmt.Sprint(final["port"]) + if err := waitPortFree(fmt.Sprint(final["host"]), port, 5*time.Second); err != nil { + return err + } + server := filepath.Join(srcDir, "serve", "server.py") + args := []string{py, server, "--engine", "strata", "--config", out, "--port", port} + _ = os.Chdir(srcDir) + fmt.Fprintf(os.Stderr, "%s (paquet AJEAN MoE %s) config=%s port=%s gpu=%s\n", + strataServeMarker, strataVersion, filepath.Base(out), port, os.Getenv("CUDA_VISIBLE_DEVICES")) + recordEngineCmdline(map[string]string{"CUDA_VISIBLE_DEVICES": os.Getenv("CUDA_VISIBLE_DEVICES")}, args) + return execServer(py, args) +} + +// strataServeMarker : la ligne de journal d'un lancement de Strata, où +// modelLoadError fait commencer la dernière tentative. +const strataServeMarker = "[loki serve] Strata" + +// strataConfigTag : le modèle d'une config de l'installeur +// (strata-swift-iq3_xxs.json → swift-iq3_xxs). +func strataConfigTag(path string) string { + b := strings.TrimSuffix(filepath.Base(path), ".json") + if i := strings.Index(b, "-"); i >= 0 { + b = b[i+1:] + } + return b +} + +// strataVisionActive : le preset Strata actif lit les images (encodeur installé). +func strataVisionActive() bool { + cfg := ReadConfig() + return isStrataConfig(cfg) && cfg["STRATA_VISION"] != "0" +} + +// --------------------------------------------------------------------------- +// API +// --------------------------------------------------------------------------- + +// handleStrata (GET) : la machine, ce qui y tient, la recommandation par +// version, et les modèles déjà installés (presets ENGINE=strata). +func handleStrata(w http.ResponseWriter, r *http.Request) { + env := strataDetect() + type famView struct { + strataFamily + Fits []strataFit `json:"fits"` + Recommend string `json:"recommend"` + } + fams := []famView{} + for _, f := range strataFamilies { + v := famView{strataFamily: f, Recommend: strataRecommend(f.ID, env)} + for _, id := range f.Quants { + v.Fits = append(v.Fits, strataFitFor(strataQuants[id], env, strataHaveGB(f.ID, id))) + } + fams = append(fams, v) + } + installed := []string{} + active := map[string]bool{} + if list, err := ListPresets(); err == nil { + for _, p := range list { + if c, err := ReadPreset(p.ID); err == nil && isStrataConfig(parseEnv(c)) { + installed = append(installed, p.ID) + active[p.ID] = p.Active + } + } + } + // chaque choix déjà installé est marqué : la fenêtre propose alors de + // l'activer au lieu de le réinstaller + for i := range fams { + for j := range fams[i].Fits { + id := strataPresetName(fams[i].ID, fams[i].Fits[j].Quant.ID) + if a, ok := active[id]; ok { + fams[i].Fits[j].Preset, fams[i].Fits[j].Active = id, a + fams[i].Fits[j].Details = strataDetailsFor(id, env) + } + } + } + sendJSON(w, 200, map[string]any{ + "version": strataVersion, "env": env, "families": fams, "installed": installed, + "active": strataActive(), + }) +} + +// strataSettingsReq : les réglages modifiables d'un modèle installé. +type strataSettingsReq struct { + Preset string `json:"preset"` + Ctx int `json:"ctx"` + KV string `json:"kv"` + Spec int `json:"spec"` + Vision bool `json:"vision"` + Helper bool `json:"helper"` +} + +// strataApplySettings réécrit les clés du preset (pure, testable). +func strataApplySettings(content string, r strataSettingsReq) (string, error) { + switch { + // contexte : de 32K au maximum du modèle (262 144), par pas de 4096 + case r.Ctx < 32768 || r.Ctx > strataMaxCtx || r.Ctx%4096 != 0: + return "", fmt.Errorf("contexte hors limites : %d", r.Ctx) + case r.KV != "fp16" && r.KV != "int8": + return "", fmt.Errorf("cache KV inconnu : %s", r.KV) + case r.Spec < 2 || r.Spec > 4: + return "", fmt.Errorf("MTP hors limites : %d", r.Spec) + } + set := map[string]string{ + "CTX": strconv.Itoa(r.Ctx), "STRATA_KV": r.KV, "STRATA_SPEC": strconv.Itoa(r.Spec), + "STRATA_VISION": strataBool(r.Vision), "STRATA_HELPER": strataBool(r.Helper), + } + var lines []string + for _, l := range strings.Split(strings.TrimRight(content, "\n"), "\n") { + k, _, _ := strings.Cut(strings.TrimSpace(l), "=") + if _, ok := set[k]; ok { + continue + } + lines = append(lines, l) + } + for _, k := range []string{"CTX", "STRATA_KV", "STRATA_SPEC", "STRATA_VISION", "STRATA_HELPER"} { + lines = append(lines, k+"="+set[k]) + } + return strings.Join(lines, "\n") + "\n", nil +} + +// handleStrataSettings (POST) : enregistre les réglages d'un modèle installé ; +// s'il est actif, la config est réappliquée et le moteur relancé. +func handleStrataSettings(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPost { + sendJSON(w, 405, map[string]any{"ok": false, "error": "POST attendu"}) + return + } + var req strataSettingsReq + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + sendJSON(w, 400, map[string]any{"ok": false, "error": err.Error()}) + return + } + if strings.ContainsAny(req.Preset, "/\\") || req.Preset == "" { + sendJSON(w, 400, map[string]any{"ok": false, "error": "preset invalide"}) + return + } + content, err := ReadPreset(req.Preset) + if err != nil || !isStrataConfig(parseEnv(content)) { + sendJSON(w, 404, map[string]any{"ok": false, "error": "modèle introuvable"}) + return + } + next, err := strataApplySettings(content, req) + if err != nil { + sendJSON(w, 400, map[string]any{"ok": false, "error": err.Error()}) + return + } + 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 + } + } + } + sendJSON(w, 200, map[string]any{"ok": true, "restarted": restarted}) +} + +// handleStrataInstall (POST {family, quant}) : lance la tâche d'installation. +// Une seule tâche à la fois, partagée avec llama.cpp (même suivi dans l'UI). +func handleStrataInstall(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPost { + sendJSON(w, 405, map[string]any{"ok": false, "error": "POST attendu"}) + return + } + var req strataInstallReq + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + sendJSON(w, 400, map[string]any{"ok": false, "error": err.Error()}) + return + } + ok := false + for _, f := range strataFamilies { + if f.ID == req.Family && strataHas(f.Quants, req.Quant) { + ok = true + } + } + if !ok { + sendJSON(w, 400, map[string]any{"ok": false, "error": "version ou quant inconnu"}) + return + } + if err := startLcJob("strata", func() { strataRunInstall(req) }); err != nil { + sendJSON(w, 409, map[string]any{"ok": false, "error": err.Error()}) + return + } + sendJSON(w, 200, map[string]any{"ok": true}) +} diff --git a/internal/loki/backend_strata_test.go b/internal/loki/backend_strata_test.go new file mode 100644 index 0000000..92ab9a6 --- /dev/null +++ b/internal/loki/backend_strata_test.go @@ -0,0 +1,343 @@ +package loki + +import ( + "os" + "path/filepath" + "strings" + "testing" +) + +func strataArgs(t *testing.T, cfg map[string]any) []string { + t.Helper() + var out []string + for _, a := range cfg["args"].([]any) { + out = append(out, a.(string)) + } + return out +} + +func argAfter(args []string, flag string) string { + for i := 0; i+1 < len(args); i++ { + if args[i] == flag { + return args[i+1] + } + } + return "" +} + +func TestStrataBuildConfigApplyTuning(t *testing.T) { + base := map[string]any{ + "exe": "/x/engine-cuda12/strata", + "args": []any{"--pack", "/p", "--prefill", "8192", "--max-context", "131072"}, + "port": 8080.0, + } + out, err := strataBuildConfig(base, map[string]string{"PORT": "8081", "CTX": "65536"}, "k") + if err != nil { + t.Fatal(err) + } + a := strataArgs(t, out) + for flag, want := range map[string]string{"--prefill": "16384", "--short-read": "150", "--prompt-cache-root": "256", "--max-context": "65536", "--pack": "/p"} { + if got := argAfter(a, flag); got != want { + t.Errorf("%s = %q, attendu %q (args %v)", flag, got, want, a) + } + } + if strings.Count(strings.Join(a, " "), "--prefill") != 1 { + t.Errorf("--prefill dupliqué : %v", a) + } + if strataHasArg(a, "--remote-expert-opt") || strataHasArg(a, "--expert-cache-device1") { + t.Errorf("aide 2e carte sans 2e carte : %v", a) + } + if out["port"] != 8081 || out["api_key"] != "k" || out["host"] != "127.0.0.1" { + t.Errorf("port/clé/hôte : %v %v %v", out["port"], out["api_key"], out["host"]) + } + // la config de l'installeur n'est pas modifiée + if base["args"].([]any)[3] != "8192" { + t.Error("la config de base a été modifiée") + } +} + +func TestStrataBuildConfigHelperGPU(t *testing.T) { + base := map[string]any{"args": []any{"--pack", "/p", "--mmap-experts", "--vram-reserve-mib", "700"}, "gpu": 1.0, + "vision": map[string]any{"exe": "v", "gpu": true}} + out, err := strataBuildConfig(base, map[string]string{"STRATA_HELPER_GPU": "0"}, "") + if err != nil { + t.Fatal(err) + } + a := strataArgs(t, out) + if argAfter(a, "--expert-cache-device1") != "auto" || !strataHasArg(a, "--remote-expert-opt") { + t.Errorf("aide 2e carte absente : %v", a) + } + if strataHasArg(a, "--vram-reserve-mib") || strataHasArg(a, "700") { + t.Errorf("réserve de VRAM gardée : %v", a) + } + if _, has := out["api_key"]; has { + t.Error("api_key posée sans clé") + } + if _, has := out["gpu"]; has { + t.Error("clé gpu gardée avec une carte d'aide (le serveur en ferait un --layer-split)") + } + if out["vision"].(map[string]any)["cuda_device"] != 0 { + t.Errorf("encodeur pas sur la carte d'aide : %v", out["vision"]) + } + if out["env"].(map[string]any)["STRATA_PLE_BATCH"] != "0" { + t.Errorf("STRATA_PLE_BATCH absent : %v", out["env"]) + } +} + +func TestStrataDropFits(t *testing.T) { + q := strataQuants["IQ3_XXS"] + env := strataEnv{Main: 1, Helper: 0, RAMGB: 46.9, GPUs: []strataGPU{{Index: 0, VRAMGB: 7.8}, {Index: 1, VRAMGB: 15.9}}} + if !strataDropFits(q, env) { + t.Error("serveur de test (46 Go + 3070) : le mode hors RAM devrait tenir") + } + f := strataFitFor(q, env, 0) + if f.Mmap || !f.Drop { + t.Errorf("fit = mmap %v drop %v, attendu drop", f.Mmap, f.Drop) + } + env.Helper = -1 + if strataDropFits(q, env) { + t.Error("sans carte d'aide, pas de mode hors RAM") + } + env.Helper, env.RAMGB = 0, 32 + if strataDropFits(q, env) { + t.Error("32 Go : ne tient pas, doit rester en mmap") + } +} + +func TestStrataBuildConfigDrop(t *testing.T) { + home := t.TempDir() + t.Setenv("LOKI_HOME", home) + pack := filepath.Join(home, "pack") + _ = os.MkdirAll(pack, 0o755) + base := map[string]any{"args": []any{"--pack", pack, "--pcie-frac", "0.3"}, "env": map[string]any{}} + cfg := map[string]string{"STRATA_HELPER_GPU": "0", "STRATA_DROP": "1", "STRATA_MMAP": "0"} + // sans experts.bin ni moteur à jour : repli mmap (ce lancement écrit experts.bin) + out, _ := strataBuildConfig(base, cfg, "") + if out["env"].(map[string]any)["STRATA_ARENA_MMAP"] != "1" || out["env"].(map[string]any)["STRATA_REMOTE_DROP"] != nil { + t.Fatalf("repli mmap attendu : %v", out["env"]) + } + _ = os.WriteFile(filepath.Join(pack, "experts.bin"), []byte("x"), 0o644) + _ = os.MkdirAll(filepath.Join(strataSrcDir(), "engine-cuda12"), 0o755) + _ = os.WriteFile(strataEngineMarker(), []byte(strataEngineAsset+"\n"), 0o644) + out, _ = strataBuildConfig(base, cfg, "") + e := out["env"].(map[string]any) + if e["STRATA_REMOTE_DROP"] != "1" || e["STRATA_ARENA_MMAP"] != nil { + t.Errorf("mode hors RAM attendu : %v", e) + } + if a := strataArgs(t, out); strataHasArg(a, "--pcie-frac") { + t.Errorf("--pcie-frac gardé (la part PCIe doit être automatique) : %v", a) + } + // carte d'aide coupée : pas de mode hors RAM + cfg["STRATA_HELPER"] = "0" + out, _ = strataBuildConfig(base, cfg, "") + if out["env"].(map[string]any)["STRATA_REMOTE_DROP"] != nil { + t.Error("mode hors RAM sans carte d'aide") + } +} + +func TestStrataBuildConfigMmap(t *testing.T) { + base := map[string]any{"args": []any{"--pack", "/p", "--mmap-experts"}, "env": map[string]any{"X": "1"}} + out, err := strataBuildConfig(base, map[string]string{"STRATA_HELPER_GPU": "0"}, "") + if err != nil { + t.Fatal(err) + } + a := strataArgs(t, out) + env := out["env"].(map[string]any) + if strataHasArg(a, "--mmap-experts") || argAfter(a, "--pcie-frac") != "0" || env["STRATA_ARENA_MMAP"] != "1" || env["X"] != "1" { + t.Errorf("mmap validé non appliqué : %v %v", a, env) + } +} + +func TestStrataCudaDevices(t *testing.T) { + cases := []struct { + main, helper, want string + }{ + {"1", "0", "1,0"}, + {"0", "-1", "0"}, + {"0", "", "0"}, + {"", "", ""}, + } + for _, c := range cases { + got := strataCudaDevices(map[string]string{"STRATA_MAIN_GPU": c.main, "STRATA_HELPER_GPU": c.helper}) + if got != c.want { + t.Errorf("main=%q helper=%q : %q, attendu %q", c.main, c.helper, got, c.want) + } + } +} + +func TestStrataRecommend(t *testing.T) { + // la machine de test : 5060 Ti 16 Go + 3070 8 Go, 47 Go de RAM, disque large + env := strataEnv{Supported: true, Main: 1, Helper: 0, RAMGB: 47, DiskFreeGB: 400, + GPUs: []strataGPU{{Index: 0, VRAMGB: 8, Arch: 86}, {Index: 1, VRAMGB: 16, Arch: 120}}} + if got := strataRecommend("swift", env); got != "IQ3_XXS" { + t.Errorf("swift : %q, attendu IQ3_XXS", got) + } + // IQ3_S demande de garder 50 Go d'experts en RAM : pas sur 47 Go + if got := strataRecommend("qwen", env); got != "IQ3_XXS" { + t.Errorf("qwen 47 Go : %q, attendu IQ3_XXS", got) + } + f := strataFitFor(strataQuants["IQ3_XXS"], env, 0) + // trop juste pour tout garder en RAM, mais la 3070 garde les siens : mode hors RAM + if !f.OK || f.Mmap || !f.Drop { + t.Errorf("IQ3_XXS sur 47 Go : ok=%v mmap=%v drop=%v (attendu ok, hors RAM)", f.OK, f.Mmap, f.Drop) + } + // 64 Go : IQ3_S tient en RAM + env.RAMGB = 64 + if got := strataRecommend("qwen", env); got != "IQ3_S" { + t.Errorf("qwen 64 Go : %q, attendu IQ3_S", got) + } + // 16 Go de RAM et une seule carte de 8 Go : rien ne tient + small := strataEnv{Supported: true, Main: 0, Helper: -1, RAMGB: 16, DiskFreeGB: 400, + GPUs: []strataGPU{{Index: 0, VRAMGB: 8, Arch: 86}}} + if got := strataRecommend("swift", small); got != "" { + t.Errorf("petite machine : %q, attendu aucune recommandation", got) + } + // disque trop petit + env.DiskFreeGB = 50 + if f := strataFitFor(strataQuants["Q2_0"], env, 0); f.OK { + t.Error("Q2_0 accepté avec 50 Go de disque") + } +} + +func TestStrataPresetName(t *testing.T) { + if got := strataPresetName("swift", "IQ3_XXS"); got != "STRATA FLASH NEXT SWIFT 1.5 IQ3_XXS" { + t.Errorf("%q", got) + } + if got := strataPresetName("qwen", "Q2_0"); got != "STRATA FLASH NEXT CLASSIQUE Q2_0" { + t.Errorf("%q", got) + } +} + +func TestStrataBuildConfigMmapFromPreset(t *testing.T) { + base := map[string]any{"args": []any{"--pack", "/p"}} + out, err := strataBuildConfig(base, map[string]string{"STRATA_MMAP": "1"}, "") + if err != nil { + t.Fatal(err) + } + a := strataArgs(t, out) + if argAfter(a, "--pcie-frac") != "0" || out["env"].(map[string]any)["STRATA_ARENA_MMAP"] != "1" { + t.Errorf("STRATA_MMAP=1 non appliqué : %v %v", a, out["env"]) + } + out, _ = strataBuildConfig(base, map[string]string{"STRATA_MMAP": "0"}, "") + if out["env"].(map[string]any)["STRATA_ARENA_MMAP"] != nil { + t.Errorf("mmap appliqué sans STRATA_MMAP : %v", out["env"]) + } +} + +func TestStrataFitCountsExistingData(t *testing.T) { + env := strataEnv{Supported: true, Main: 1, Helper: 0, RAMGB: 47, DiskFreeGB: 32, + GPUs: []strataGPU{{Index: 0, VRAMGB: 8, Arch: 86}, {Index: 1, VRAMGB: 16, Arch: 120}}} + if f := strataFitFor(strataQuants["IQ3_XXS"], env, 120); !f.OK { + t.Errorf("données déjà présentes, refusé quand même : %s", f.Why) + } + if f := strataFitFor(strataQuants["IQ3_XXS"], env, 0); f.OK { + t.Error("32 Go libres sans données : accepté") + } +} + +func TestStrataBuildConfigPresetOverrides(t *testing.T) { + base := map[string]any{"args": []any{"--kv", "int8", "--spec", "4"}} + out, _ := strataBuildConfig(base, map[string]string{"STRATA_KV": "fp16", "STRATA_SPEC": "3"}, "") + a := strataArgs(t, out) + if argAfter(a, "--kv") != "fp16" || argAfter(a, "--spec") != "3" { + t.Errorf("%v", a) + } +} + +func TestStrataDetailsFor(t *testing.T) { + home := t.TempDir() + t.Setenv("LOKI_HOME", home) + base := filepath.Join(home, "base.json") + if err := os.WriteFile(base, []byte(`{"args":["--pack","/p","--kv","int8","--spec","4","--max-context","131072"],"vision":{"exe":"v"}}`), 0o644); err != nil { + t.Fatal(err) + } + _ = os.MkdirAll(presetsDir(), 0o755) + preset := "ENGINE=strata\nSTRATA_CONFIG=" + base + "\nSTRATA_MAIN_GPU=1\nSTRATA_HELPER_GPU=0\nSTRATA_MMAP=1\nSTRATA_KV=fp16\nSTRATA_KV_RESIDENT=32768\nSTRATA_SPEC=3\n" + if err := os.WriteFile(filepath.Join(presetsDir(), "X.env"), []byte(preset), 0o644); err != nil { + t.Fatal(err) + } + env := strataEnv{GPUs: []strataGPU{{Index: 0, Name: "NVIDIA GeForce RTX 3070", VRAMGB: 8}, {Index: 1, Name: "NVIDIA GeForce RTX 5060 Ti", VRAMGB: 16}}} + d := strataDetailsFor("X", env) + if d == nil { + t.Fatal("pas de détails") + } + if d.KV != "fp16" || d.KVResident != "32768" || d.Spec != "3" || d.Ctx != "131072" || !d.Mmap || + d.MainGPU != "RTX 5060 Ti (16 Go)" || d.HelperGPU != "RTX 3070 (8 Go)" || d.VisionGPU != "RTX 3070 (8 Go)" || + d.ShortRead != "150" || d.CacheRoot != "256" || d.Prefill != "16384" { + t.Errorf("%+v", *d) + } +} + +func TestStrataHaveGBPerChoice(t *testing.T) { + t.Setenv("LOKI_HOME", t.TempDir()) + write := func(rel string, n int) { + p := filepath.Join(strataDataDir(), rel) + _ = os.MkdirAll(filepath.Dir(p), 0o755) + if err := os.WriteFile(p, make([]byte, n), 0o644); err != nil { + t.Fatal(err) + } + } + 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) // un autre choix : ne compte pas pour swift IQ3_XXS + if got := strataHaveGB("swift", "IQ3_XXS") * 1e9; got != 5500 { + t.Errorf("swift IQ3_XXS : %v octets, attendu 5500", got) + } + if got := strataHaveGB("swift", "IQ2_XS") * 1e9; got != 500 { + t.Errorf("swift IQ2_XS : %v octets, attendu 500 (seulement la tête MTP commune)", got) + } +} + +func TestStrataApplySettings(t *testing.T) { + in := "# NAME=x\nENGINE=strata\nCTX=131072\nSTRATA_KV=fp16\nSTRATA_SPEC=3\nSTRATA_HELPER_GPU=0\n" + out, err := strataApplySettings(in, strataSettingsReq{Ctx: 65536, KV: "int8", Spec: 4, Vision: false, Helper: false}) + if err != nil { + t.Fatal(err) + } + cfg := parseEnv(out) + if cfg["CTX"] != "65536" || cfg["STRATA_KV"] != "int8" || cfg["STRATA_SPEC"] != "4" || cfg["STRATA_VISION"] != "0" || + cfg["STRATA_HELPER"] != "0" || cfg["STRATA_HELPER_GPU"] != "0" || cfg["ENGINE"] != "strata" { + t.Errorf("%q", out) + } + if strings.Count(out, "CTX=") != 1 { + t.Errorf("CTX dupliqué : %q", out) + } + if strataHelperOn(cfg) || strataCudaDevices(map[string]string{"STRATA_MAIN_GPU": "1", "STRATA_HELPER_GPU": "0", "STRATA_HELPER": "0"}) != "1" { + t.Error("carte d'aide coupée mais encore utilisée") + } + base := map[string]any{"args": []any{"--vision", "--pack", "/p"}, "vision": map[string]any{"exe": "v"}} + b, _ := strataBuildConfig(base, cfg, "") + if strataHasArg(strataArgs(t, b), "--vision") || b["vision"] != nil || strataHasArg(strataArgs(t, b), "--expert-cache-device1") { + t.Errorf("vision / aide coupées mais présentes : %v", b) + } + for _, bad := range []strataSettingsReq{{Ctx: 1000, KV: "fp16", Spec: 3}, {Ctx: 32768, KV: "q2", Spec: 3}, {Ctx: 32768, KV: "fp16", Spec: 9}} { + if _, err := strataApplySettings(in, bad); err == nil { + t.Errorf("accepté : %+v", bad) + } + } +} + +func TestEngineOf(t *testing.T) { + cases := map[string]string{"": engineLlamacpp, "llamacpp": engineLlamacpp, "strata": engineStrata, " Strata ": engineStrata, "moe": engineStrata, "autre": engineLlamacpp} + for in, want := range cases { + if got := engineOf(map[string]string{"ENGINE": in}); got != want { + t.Errorf("ENGINE=%q : %q, attendu %q", in, got, want) + } + } +} + +func TestModelLoadErrorStrata(t *testing.T) { + ok := strataServeMarker + " (paquet AJEAN MoE 1.0)\nloading the model (the first start takes a minute or two) ...\nready: http://127.0.0.1:8080/v1 (OpenAI: /v1/chat/completions)\n" + if got := modelLoadErrorFrom(ok); got != "" { + t.Errorf("Strata prêt signalé en échec : %q", got) + } + crash := strataServeMarker + "\nTraceback (most recent call last):\n File \"server.py\", line 1\nRuntimeError: boom\n" + if got := modelLoadErrorFrom(crash); !strings.Contains(got, "Strata") { + t.Errorf("trace Python non signalée : %q", got) + } + // une trace d'un lancement PRÉCÉDENT ne compte pas + if got := modelLoadErrorFrom(crash + strataServeMarker + "\nloading the model ...\n"); got != "" { + t.Errorf("ancienne trace prise pour l'échec en cours : %q", got) + } +} diff --git a/internal/loki/backend_tune_run.go b/internal/loki/backend_tune_run.go index b120253..f3b0343 100644 --- a/internal/loki/backend_tune_run.go +++ b/internal/loki/backend_tune_run.go @@ -180,6 +180,9 @@ func tunePreflight(ctx context.Context, inWeb bool) error { if externalActive() { return errors.New("optimisation indisponible : le preset actif est une API externe") } + if strataActive() { + return errors.New("optimisation indisponible : le preset actif tourne sur Strata, réglé par sa propre fenêtre") + } if err := tuneGuard(); err != nil { return err } diff --git a/internal/loki/engine_cmdline.go b/internal/loki/engine_cmdline.go new file mode 100644 index 0000000..0edae87 --- /dev/null +++ b/internal/loki/engine_cmdline.go @@ -0,0 +1,87 @@ +package loki + +// engine_cmdline.go — ce que l'interface montre du dernier lancement du moteur +// (repris d'AJEAN, #108 et #43) : +// +// /api/engine/cmdline la commande exacte lancée par `loki serve` (variables +// d'environnement posées par Loki comprises), copiable +// pour la vérifier ou la partager ; clé API masquée +// /api/model/layers le nombre de couches du modèle, lu dans l'en-tête +// GGUF : la valeur de NGL qui met tout sur le GPU + +import ( + "net/http" + "sort" + "strings" +) + +// engineCmdlineKey : dernière commande du moteur (bkState), lue par l'UI. +const engineCmdlineKey = "engine_cmdline" + +// engineCmdline met les arguments sous forme copiable dans un terminal : +// variables d'environnement en tête (KEY=VAL, triées), guillemets autour de ce +// qui contient un espace, clé API masquée. +func engineCmdline(env map[string]string, args []string) string { + quote := func(a string) string { + if a == "" || strings.ContainsAny(a, " \t\"'") { + return `"` + strings.ReplaceAll(a, `"`, `\"`) + `"` + } + return a + } + var out []string + keys := make([]string, 0, len(env)) + for k := range env { + keys = append(keys, k) + } + sort.Strings(keys) + for _, k := range keys { + out = append(out, k+"="+quote(env[k])) + } + for i, a := range args { + if i > 0 && (args[i-1] == "--api-key" || args[i-1] == "--api-key-file") { + a = "" + } + out = append(out, quote(a)) + } + return strings.Join(out, " ") +} + +// recordEngineCmdline garde la commande du lancement en cours. Jamais pour un +// essai de l'optimiseur : il écraserait celle du vrai moteur. +func recordEngineCmdline(env map[string]string, args []string) { + _ = putBytes(bkState, engineCmdlineKey, []byte(engineCmdline(env, args))) +} + +// handleEngineCmdline (GET) : la commande du dernier lancement du moteur local. +func handleEngineCmdline(w http.ResponseWriter, r *http.Request) { + sendJSON(w, 200, map[string]any{"ok": true, "cmdline": string(getBytes(bkState, engineCmdlineKey))}) +} + +// handleModelLayers (GET ?model=…, sinon MODEL de la config) : nombre de blocs +// du modèle. llama.cpp en offloade block_count + 1 (la couche de sortie en +// plus) : c'est la valeur de -ngl qui met tout le modèle sur le GPU. +func handleModelLayers(w http.ResponseWriter, r *http.Request) { + model := strings.TrimSpace(r.URL.Query().Get("model")) + if model == "" { + model = strings.TrimSpace(ReadConfig()["MODEL"]) + } + if model == "" { + sendJSON(w, 200, map[string]any{"ok": false, "error": "aucun modèle"}) + return + } + path, err := resolveServeModelPath(model) + if err != nil { + sendJSON(w, 200, map[string]any{"ok": false, "error": err.Error()}) + return + } + info, err := ggufMeta(path) + if err != nil || info.BlockCount <= 0 { + msg := "nombre de couches absent de l'en-tête" + if err != nil { + msg = err.Error() + } + sendJSON(w, 200, map[string]any{"ok": false, "error": msg}) + return + } + sendJSON(w, 200, map[string]any{"ok": true, "layers": info.BlockCount, "ngl_max": info.BlockCount + 1}) +} diff --git a/internal/loki/llm_bench.go b/internal/loki/llm_bench.go index 7212bc1..6d36889 100644 --- a/internal/loki/llm_bench.go +++ b/internal/loki/llm_bench.go @@ -828,6 +828,12 @@ func runBench(ctx context.Context, opts benchOpts, progress benchProgress) (*ben if externalActive() { return nil, fmt.Errorf("benchmark indisponible : le preset actif est une API externe") } + // Le protocole s'appuie sur /tokenize et sur la reprise du cache de + // llama-server ; Strata n'a pas le premier et garde sa conversation en cache + // malgré cache_prompt:false (la lecture serait comptée deux fois trop vite). + if strataActive() { + return nil, fmt.Errorf("benchmark indisponible : le preset actif tourne sur Strata (le débit s'affiche sous chaque réponse)") + } if err := tuneGuard(); err != nil { return nil, err } diff --git a/internal/loki/llm_oai.go b/internal/loki/llm_oai.go index f9b05eb..a1c411b 100644 --- a/internal/loki/llm_oai.go +++ b/internal/loki/llm_oai.go @@ -96,6 +96,11 @@ func oaiHandler() http.Handler { Rewrite: func(pr *httputil.ProxyRequest) { pr.SetURL(&url.URL{Scheme: "http", Host: fmt.Sprintf("127.0.0.1:%d", LLMPort())}) pr.Out.Host = pr.In.Host + // Strata refuse (403) un Host qu'il ne connaît pas (protection contre + // le DNS rebinding) : il se voit appelé sous son propre nom. + if strataActive() { + pr.Out.Host = pr.Out.URL.Host + } }, ErrorHandler: func(w http.ResponseWriter, r *http.Request, e error) { sendOAIError(w, http.StatusBadGateway, diff --git a/internal/loki/mem_migrate.go b/internal/loki/mem_migrate.go index 56e3244..7d9202b 100644 --- a/internal/loki/mem_migrate.go +++ b/internal/loki/mem_migrate.go @@ -259,52 +259,158 @@ func DisableMemEncryption() error { // page, et on ne baisse le drapeau qu'à la toute fin. Ainsi une interruption // laisse un état parfaitement lisible et RE-JOUABLE (le drapeau est encore // levé, donc DisableMemEncryption peut être relancé). - if err := decryptAllPages(); err != nil { + return finishDecryption() +} + +// finishDecryption : fichiers (pages, images des conversations), puis +// conversations, puis retrait de la clé, dans cet ordre et seulement si tout ce +// qui précède a réussi. Sûr à rejouer. Partagé avec la reprise au démarrage, qui +// retirait la clé après les seules pages : les conversations restaient +// chiffrées sans plus aucun moyen de les rouvrir (amont cac0cda). +func finishDecryption() error { + bad, err := decryptAllPagesSkipping() + if err != nil { return err } - if err := decryptChatStores(); err != nil { + badKV, err := decryptChatStoresSkipping() + if err != nil { return err } - // Toutes les pages sont en clair et vérifiées : on peut baisser le drapeau, - // retirer le keyvault et purger la DEK. + bad = append(bad, badKV...) + if len(bad) > 0 { + // Valeurs illisibles avec la clé actuelle : déjà inaccessibles avant ce + // déchiffrement. On les met en quarantaine (toujours chiffrées) avec une + // copie du keyvault, pour pouvoir encore les rouvrir plus tard, puis on + // les retire de la base pour que le reste fonctionne en clair. + if err := quarantineUnreadable(bad); err != nil { + return fmt.Errorf("mise en quarantaine des valeurs illisibles impossible, déchiffrement interrompu (rien n'est perdu, il peut être relancé) : %w", err) + } + for _, u := range bad { + if u.Bucket != "fichier" { // un fichier reste en place, sa copie est en quarantaine + _ = putBytes(u.Bucket, u.Key, nil) + } + } + } + // Tout est en clair et vérifié : on peut baisser le drapeau, retirer le + // keyvault et purger la DEK. if err := SetConfigKey("MEM_ENCRYPTED", ""); err != nil { return err } removeVault() clearMemDEK() clearMigrationJournal() + if n := scrubEncryptedResidue(); n > 0 { + fmt.Fprintf(os.Stderr, "[mémoire] %d copie(s) .bak chiffrée(s), illisibles sans la clé, retirée(s)\n", n) + } return nil } -// decryptAllPages réécrit en clair toutes les pages encore chiffrées, DANS TOUS LES -// PROJETS, en vérifiant chacune. Exige la DEK en RAM. Sûr à rejouer (idempotent). -func decryptAllPages() error { - for _, p := range allMemPageFiles() { +// decryptAllPagesSkipping réécrit en clair tous les fichiers encore chiffrés de +// la mémoire (tous projets) et des images de conversation, en vérifiant chacun. +// Exige la DEK en RAM. Sûr à rejouer. Un fichier indéchiffrable est laissé tel +// quel et renvoyé au lieu de tout arrêter. +func decryptAllPagesSkipping() ([]unreadableValue, error) { + var bad []unreadableValue + for _, p := range allMemFilesForDecrypt() { raw, err := os.ReadFile(p) if err != nil { - return fmt.Errorf("lecture %s : %w", p, err) + return bad, fmt.Errorf("lecture %s : %w", p, err) } if !looksEncrypted(raw) { continue // déjà en clair } plain, err := decodeMemContent(raw) if err != nil { - return fmt.Errorf("déchiffrement %s impossible, on n'écrase rien : %w", p, err) + bad = append(bad, unreadableValue{Bucket: "fichier", Key: p, Blob: raw, Error: err.Error()}) + continue // on n'écrase rien } if old, err := os.ReadFile(p); err == nil { _ = memWriteFileAtomic(p+".bak", old, 0o600) } if err := memWriteFileVerified(p, plain, 0o600); err != nil { - return fmt.Errorf("écriture claire %s : %w", p, err) + return bad, fmt.Errorf("écriture claire %s : %w", p, err) } back, err := os.ReadFile(p) if err != nil || looksEncrypted(back) || string(back) != string(plain) { - return fmt.Errorf("vérification post-déchiffrement de %s échouée", p) + return bad, fmt.Errorf("vérification post-déchiffrement de %s échouée", p) } } + return bad, nil +} + +// allMemFilesForDecrypt : TOUS les fichiers de la mémoire, sous-dossiers compris, +// et les images de conversation (chatimg/, chiffrées comme le fil), sauf les +// copies .bak et les écritures en cours (.tmp). Ne déchiffrer que les pages .md +// laissait le reste chiffré sans plus aucune clé une fois le keyvault retiré. +func allMemFilesForDecrypt() []string { + var out []string + for _, root := range []string{projectsRoot(), chatImgDir()} { + _ = filepath.WalkDir(root, func(p string, d os.DirEntry, err error) error { + if err != nil || d.IsDir() || strings.HasSuffix(d.Name(), ".bak") || strings.HasSuffix(d.Name(), ".tmp") { + return nil + } + out = append(out, p) + return nil + }) + } + return out +} + +// quarantineUnreadable écrit les valeurs indéchiffrables, encore chiffrées, dans +// un dossier daté de LOKI_HOME, avec une copie du keyvault à côté : elles +// restent récupérables si l'ancienne clé est retrouvée. +func quarantineUnreadable(bad []unreadableValue) error { + dir := filepath.Join(LokiHome(), "chiffre-illisible-"+time.Now().Format("20060102-150405")) + if err := os.MkdirAll(dir, 0o700); err != nil { + return err + } + raw, err := json.MarshalIndent(bad, "", " ") + if err != nil { + return err + } + if err := memWriteFileAtomic(filepath.Join(dir, "valeurs.json"), raw, 0o600); err != nil { + return err + } + for _, p := range []string{vaultPathPrimary(), vaultPathBackup()} { + if b, err := os.ReadFile(p); err == nil { + _ = memWriteFileAtomic(filepath.Join(dir, filepath.Base(p)), b, 0o600) + } + } + if b := getBytes(bkState, vaultDBKey); len(b) > 0 { + _ = memWriteFileAtomic(filepath.Join(dir, "keyvault-base.json"), b, 0o600) + } + for _, u := range bad { + fmt.Fprintf(os.Stderr, "[mémoire] illisible avec la clé actuelle, mis en quarantaine : %s / %s (%s)\n", u.Bucket, u.Key, u.Error) + } + fmt.Fprintf(os.Stderr, "[mémoire] %d valeur(s) en quarantaine dans %s\n", len(bad), dir) return nil } +// scrubEncryptedResidue retire les .bak encore CHIFFRÉS de la mémoire une fois +// le chiffrement désactivé : la clé est partie avec le keyvault, ils ne pourront +// plus jamais être lus, et ce sont des copies de pages qui viennent d'être +// remises en clair. Pendant symétrique de scrubPlaintextResidue. Les valeurs en +// quarantaine et les snapshots vivent hors de memory/ : jamais touchés. Rien +// n'est fait tant que le chiffrement est actif. +func scrubEncryptedResidue() int { + if memEncActive() { + return 0 + } + n := 0 + for _, root := range []string{projectsRoot(), chatImgDir()} { + _ = filepath.WalkDir(root, func(p string, d os.DirEntry, err error) error { + if err != nil || d.IsDir() || !strings.HasSuffix(d.Name(), ".bak") { + return nil + } + if b, err := os.ReadFile(p); err == nil && looksEncrypted(b) && os.Remove(p) == nil { + n++ + } + return nil + }) + } + return n +} + // resumeMemMigration reprend une migration interrompue au démarrage. Pour une // migration "encrypt", si la DEK est déjà en RAM (déverrouillée), on rejoue le // (re)chiffrement des pages claires ; sinon on laisse le journal en place pour un @@ -328,12 +434,12 @@ func resumeMemMigration() { // encore levé, on termine proprement (écrit le clair restant), puis on // baisse le drapeau. Sans DEK, on laisse le journal : l'état reste lisible // et l'écran de santé signalera le mélange. + // Même chemin complet que DisableMemEncryption : la reprise retirait la clé + // après les seules pages, en laissant les conversations chiffrées sans + // plus aucun moyen de les rouvrir. if memEncActive() && memUnlocked() { - if err := decryptAllPages(); err == nil { - _ = SetConfigKey("MEM_ENCRYPTED", "") - removeVault() - clearMemDEK() - clearMigrationJournal() + if err := finishDecryption(); err != nil { + fmt.Fprintf(os.Stderr, "[mémoire] reprise du déchiffrement : %v\n", err) } } } diff --git a/internal/loki/mem_migrate_test.go b/internal/loki/mem_migrate_test.go index e56e89d..e79357d 100644 --- a/internal/loki/mem_migrate_test.go +++ b/internal/loki/mem_migrate_test.go @@ -2,6 +2,7 @@ package loki import ( "os" + "path/filepath" "strings" "testing" ) @@ -261,3 +262,84 @@ func TestChiffrementGardeLesPointeursLisibles(t *testing.T) { t.Fatalf("pointeur chiffré non réparé : %q", got) } } + +// Une seule valeur indéchiffrable bloquait toute la désactivation, et les images +// de conversation (chatimg/) restaient chiffrées sans clé une fois le keyvault +// retiré. Désormais : tout ce qui se lit est déchiffré, l'illisible part en +// quarantaine, et la désactivation aboutit. +func TestDisableMemEncryptionSurvitAuxValeursIllisibles(t *testing.T) { + testHome(t) + clearMemDEK() + seedPages(t) + if _, err := EnableMemEncryption("pass-de-test-1234"); err != nil { + t.Fatal(err) + } + img := []byte("\x89PNG octets d'image de test") + ref, err := storeChatImage(img, "image/png") + if err != nil { + t.Fatal(err) + } + imgPath := filepath.Join(chatImgDir(), strings.TrimPrefix(ref, imgRefScheme)) + if raw, _ := os.ReadFile(imgPath); !looksEncrypted(raw) { + t.Fatal("l'image devrait être chiffrée") + } + other := make([]byte, 32) + other[0] = 7 + alien, err := encPage(other, []byte("conversation chiffrée avec une autre clé")) + if err != nil { + t.Fatal(err) + } + if err := putBytes(bkChat, "vieille", alien); err != nil { + t.Fatal(err) + } + if err := DisableMemEncryption(); err != nil { + t.Fatalf("la désactivation aurait dû aboutir : %v", err) + } + if memEncActive() { + t.Fatal("le chiffrement devrait être désactivé") + } + if b, _ := os.ReadFile(imgPath); string(b) != string(img) { + t.Fatalf("image pas déchiffrée : %q", b) + } + if v := getBytes(bkChat, "vieille"); len(v) != 0 { + t.Fatal("la valeur illisible aurait dû quitter la base") + } + q, _ := filepath.Glob(filepath.Join(LokiHome(), "chiffre-illisible-*", "valeurs.json")) + if len(q) != 1 { + t.Fatalf("quarantaine attendue, trouvée : %v", q) + } + if qb, _ := os.ReadFile(q[0]); !strings.Contains(string(qb), "vieille") { + t.Fatal("la quarantaine doit contenir la valeur illisible") + } + if baks, _ := filepath.Glob(filepath.Join(chatImgDir(), "*.bak")); len(baks) != 0 { + t.Fatalf(".bak chiffrés restés derrière : %v", baks) + } +} + +// Déchiffrement interrompu puis repris au démarrage : la reprise retirait la clé +// après les seules pages, et les conversations restaient chiffrées pour toujours. +func TestResumeInterruptedDecryptDechiffreLesConversations(t *testing.T) { + testHome(t) + clearMemDEK() + seedPages(t) + if _, err := EnableMemEncryption("pw"); err != nil { + t.Fatal(err) + } + enc, err := encodeMemContent([]byte(`{"titre":"conversation"}`)) + if err != nil { + t.Fatal(err) + } + if err := putBytes(bkChat, "conv:x", enc); err != nil { + t.Fatal(err) + } + if err := writeMigrationJournal("decrypt"); err != nil { + t.Fatal(err) + } + resumeMemMigration() + if memEncActive() { + t.Fatal("la reprise aurait dû terminer le déchiffrement") + } + if v := getBytes(bkChat, "conv:x"); string(v) != `{"titre":"conversation"}` { + t.Fatalf("conversation restée chiffrée après la reprise : %q", v) + } +} diff --git a/internal/loki/mem_store.go b/internal/loki/mem_store.go index 37bc302..db39837 100644 --- a/internal/loki/mem_store.go +++ b/internal/loki/mem_store.go @@ -188,18 +188,41 @@ func reencryptChatStores() error { // decryptChatStores remet en clair les buckets de conversation. Exige la DEK. func decryptChatStores() error { + _, err := decryptChatStoresSkipping() + return err +} + +// unreadableValue : une valeur chiffrée qui ne se déchiffre pas avec la clé +// actuelle (écrite avec une autre clé, ou abîmée). Gardée telle quelle. +type unreadableValue struct { + Bucket string `json:"bucket"` + Key string `json:"key"` + Blob []byte `json:"blob"` // base64 en JSON + Error string `json:"error"` +} + +// decryptChatStoresSkipping déchiffre tout ce qui peut l'être et RENVOIE ce qui +// ne le peut pas, au lieu de s'arrêter au premier échec : une seule valeur +// illisible bloquait toute la désactivation du chiffrement, sans dire laquelle +// (amont, 2026-10-03). +func decryptChatStoresSkipping() ([]unreadableValue, error) { persistQ.flush() // même raison que reencryptChatStores + var bad []unreadableValue for _, b := range encryptedBuckets { - if err := decryptBucket(b); err != nil { - return err + u, err := decryptBucket(b) + if err != nil { + return bad, err } + bad = append(bad, u...) } - return nil + return bad, nil } // decryptBucket remet en clair toutes les valeurs chiffrées d'un bucket. Exige -// la DEK en RAM. Sûr à rejouer. -func decryptBucket(bucket string) error { +// la DEK en RAM. Sûr à rejouer. Une valeur indéchiffrable est laissée telle +// quelle et renvoyée ; seule une erreur d'écriture est fatale. +func decryptBucket(bucket string) ([]unreadableValue, error) { + var bad []unreadableValue for k, v := range allKV(bucket) { raw := []byte(v) if !looksEncrypted(raw) { @@ -207,11 +230,12 @@ func decryptBucket(bucket string) error { } plain, err := decodeMemContent(raw) if err != nil { - return err + bad = append(bad, unreadableValue{Bucket: bucket, Key: k, Blob: raw, Error: err.Error()}) + continue } if err := putBytes(bucket, k, plain); err != nil { - return err + return bad, err } } - return nil + return bad, nil } diff --git a/internal/loki/relay_link.go b/internal/loki/relay_link.go index 85efa84..a199e68 100644 --- a/internal/loki/relay_link.go +++ b/internal/loki/relay_link.go @@ -472,6 +472,9 @@ func newLinkHandler(mux *http.ServeMux) http.Handler { base := lp.Director lp.Director = func(req *http.Request) { base(req) + // Le moteur se voit appelé sous son propre nom, pas sous celui du relais : + // Strata refuse (403) un Host inconnu (protection DNS rebinding). + req.Host = llama.Host // Le client distant n'a pas la clé API de llama-server ; on l'injecte ici // (l'auth réelle est faite par le relais via la clé de liaison du compte). if apiKey != "" { diff --git a/internal/loki/sys_install_linux.go b/internal/loki/sys_install_linux.go index aa1dfb9..db8aef8 100644 --- a/internal/loki/sys_install_linux.go +++ b/internal/loki/sys_install_linux.go @@ -32,6 +32,9 @@ WorkingDirectory=%s ExecStart=%s Restart=on-failure RestartSec=3 +# llama-server ignore SIGTERM en pleine génération : sans borne, une bascule de +# preset restait bloquée 90 s (défaut systemd) avant le SIGKILL. +TimeoutStopSec=5 # Lancement refusé d'avance par LOAD_GUARD (le mode de chargement ne tient pas # en RAM) : relancer n'y changerait rien. RestartPreventExitStatus=78 diff --git a/internal/loki/sys_service.go b/internal/loki/sys_service.go index 54b03ca..b1a093f 100644 --- a/internal/loki/sys_service.go +++ b/internal/loki/sys_service.go @@ -41,6 +41,16 @@ func serviceAction(action string) error { // visible que dans le journal. func preflightEngine() error { cfg := ReadConfig() + // Preset externe : aucun llama-server local à lancer. Exiger BIN/MODEL + // refusait `loki start` et `loki restart` avec un message invitant à + // réinstaller llama.cpp (amont #95) ; cmdServe sort déjà proprement. + if isExternalConfig(cfg) { + return nil + } + // Strata n'a ni BIN ni MODEL : son installeur a tout rangé (backend_strata.go). + if isStrataConfig(cfg) { + return strataPreflight(cfg) + } bin := strings.TrimSpace(cfg["BIN"]) if bin == "" { return fmt.Errorf("BIN non défini — installe un moteur : %s (ou renseigne BIN avec %s)", diff --git a/internal/loki/sys_service_container.go b/internal/loki/sys_service_container.go index 1288ff3..4c59ac1 100644 --- a/internal/loki/sys_service_container.go +++ b/internal/loki/sys_service_container.go @@ -65,6 +65,12 @@ func userSvcAction(action string) error { } func userSvcStart() error { + // Preset externe : `loki serve` sortirait aussitôt (rien à lancer), et + // userCheckStarted le prendrait pour un plantage. + if isExternalConfig(ReadConfig()) { + fmt.Printf("%s preset externe actif : pas de moteur local à démarrer\n", dim("[info]")) + return nil + } if pid := readServicePID(); pid > 0 && processAlive(pid) { fmt.Printf("%s déjà démarré (PID %d)\n", yellow("[info]"), pid) return nil diff --git a/internal/loki/ui/index.html b/internal/loki/ui/index.html index 8f0069e..abb3b27 100644 --- a/internal/loki/ui/index.html +++ b/internal/loki/ui/index.html @@ -205,7 +205,7 @@ option,optgroup{background:var(--panel);color:var(--text)} /* Desktop : menu plus large et bulles (badges) un peu plus grosses. */ @media (min-width:721px){ .side{width:380px} - .preset-meta .qtag,.preset-meta .rtag,.preset-meta .btag,.preset-meta .xtag{font-size:11px;padding:2px 9px;border-radius:4px} + .preset-meta .qtag,.preset-meta .rtag,.preset-meta .btag,.preset-meta .xtag,.preset-meta .ctag,.preset-meta .captag{font-size:11px;padding:2px 9px;border-radius:4px} .preset-meta .qtag svg,.preset-meta .rtag svg,.preset-meta .btag svg,.preset-meta .xtag svg{width:11px;height:11px} } h1{margin:0;font-size:18px;color:var(--accent)} @@ -301,6 +301,9 @@ h1{margin:0;font-size:18px;color:var(--accent)} (étiquette comprise), plus sur la carte seule. */ .gated-group{margin:0 0 10px;transition:opacity .15s} .gated-group.gated{opacity:.4;pointer-events:none;filter:grayscale(.4)} +/* L'aide « ? » reste survolable sur un réglage grisé : c'est justement là qu'on + veut savoir à quoi il sert (repris d'AJEAN, #104). */ +.gated-group.gated .help{pointer-events:auto} .switch{position:relative;display:inline-block;width:36px;height:20px;flex:none} .switch input{position:absolute;opacity:0;width:0;height:0} /* Piste décochée : un gris franc (pas --border), sinon le pavé blanc ne se @@ -398,6 +401,17 @@ button:disabled{opacity:.5;cursor:not-allowed} .preset-meta{display:flex;align-items:center;gap:6px;margin-top:3px;flex-wrap:wrap} .qtag{flex-shrink:0;font-size:9px;padding:1px 6px;border:1px solid var(--border);color:var(--dim);border-radius:4px;letter-spacing:.03em;text-transform:uppercase} .rtag{flex-shrink:0;display:inline-flex;align-items:center;gap:3px;font-size:9px;padding:1px 6px;border:1px solid var(--border);color:var(--dim);border-radius:4px;letter-spacing:.03em} +.ctag{flex-shrink:0;display:inline-flex;align-items:center;gap:3px;font-size:9px;padding:1px 6px;border:1px solid var(--border);color:var(--dim);border-radius:4px;letter-spacing:.03em} +/* Pastille « capacités » (repris d'AJEAN) : vision (œil) et raisonnement + (ampoule + niveau). Icônes en em : la bulle a la hauteur des autres tags. */ +.captag{flex-shrink:0;display:inline-flex;align-items:center;gap:4px;font-size:9px;padding:1px 6px;border:1px solid var(--border);color:var(--dim);border-radius:4px;letter-spacing:.03em} +.captag::before{content:"\200b";margin-right:-4px} +.captag svg{display:block;width:1.3em;height:1.3em} +/* Réordonnancement des presets (SortableJS) : clone plein qui suit le curseur, + emplacement cible discret. */ +.preset-drag{cursor:grabbing;background:var(--row-bg-on);border:none;border-radius:6px;box-shadow:0 6px 18px rgba(0,0,0,.35);outline:none;opacity:1!important} +.preset-ghost{opacity:.35} +.preset-chosen{cursor:grabbing} /* Preset externe : le seul tag qui prend la couleur d'accent. Il ne décrit pas un réglage mais une DESTINATION — la réponse part sur internet, pas sur la carte graphique d'à côté, et ça mérite de se voir du premier coup d'œil. @@ -802,6 +816,32 @@ textarea.sctl{min-height:76px;resize:vertical;line-height:1.5} gris atténué du thème monochrome et devenait illisible en mode sombre. */ .lc-reco{font-size:9px;font-weight:600;text-transform:uppercase;letter-spacing:.05em;background:var(--text);color:var(--bg);border:1px solid var(--text);border-radius:4px;padding:1px 6px} .lc-mode-d{font-size:11px;color:var(--dim);margin-top:3px} +/* Strata (10b-strata.js) : liste des qualités, réglages et configuration. */ +.slist>.lc-modes#lc-engines{border-top:1px solid var(--border)} +#lc-strata.moe-off{cursor:default} +#lc-strata.moe-off:hover{background:none} +#lc-strata.moe-off .lc-mode-t{color:var(--dim)} +#strata-quants{overflow:hidden} +#strata-quants .lc-mode.moe-sel{background:var(--accent-bg)} +#strata-quants .lc-mode.moe-sel .lc-mode-t{color:var(--accent)} +#strata-quants .lc-mode.moe-off{cursor:default} +#strata-quants .lc-mode.moe-off:hover{background:none} +#strata-quants .lc-mode.moe-off .lc-mode-t{color:var(--dim)} +.moe-details{padding:4px 10px} +.moe-details .moe-kv{display:flex;justify-content:space-between;align-items:center;gap:12px;font-size:12px;padding:5px 0} +.moe-details .moe-kv .switch{display:block} +.moe-details .moe-kv:not(:last-child){border-bottom:1px solid var(--border)} +.moe-details .moe-kv span:first-child{color:var(--dim)} +.moe-details .moe-kv span:last-child{text-align:right} +.moe-ctx{display:flex;align-items:center;gap:10px} +.moe-ctx input[type=range]{width:160px;height:16px;margin:0;padding:0;background:transparent;border:none;-webkit-appearance:none;appearance:none;cursor:ew-resize} +.moe-ctx input[type=range]:focus{outline:none} +.moe-ctx input[type=range]::-webkit-slider-runnable-track{height:4px;border:none;border-radius:999px;background:linear-gradient(to right,var(--accent) var(--p,50%),color-mix(in srgb,var(--dim) 25%,transparent) var(--p,50%))} +.moe-ctx input[type=range]::-moz-range-track{height:4px;border:none;border-radius:999px;background:color-mix(in srgb,var(--dim) 25%,transparent)} +.moe-ctx input[type=range]::-moz-range-progress{height:4px;border-radius:999px;background:var(--accent)} +.moe-ctx input[type=range]::-webkit-slider-thumb{-webkit-appearance:none;appearance:none;width:14px;height:14px;margin-top:-5px;border:none;border-radius:50%;background:var(--accent)} +.moe-ctx input[type=range]::-moz-range-thumb{width:14px;height:14px;border:none;border-radius:50%;background:var(--accent)} +.moe-ctx span{min-width:62px;text-align:right;font-variant-numeric:tabular-nums} /* Hauteur réservée : la ligne d'état arrive après l'appel réseau, sans elle les trois cartes grandissaient d'un cran une fois l'état connu. */ .lc-mode-state{font-size:11px;min-height:16px;margin-top:7px;display:flex;align-items:center;gap:12px;flex-wrap:wrap} @@ -2560,7 +2600,7 @@ html[data-files="1"] #files-btn{color:var(--accent)}
Moteur
- +
Application
@@ -2762,7 +2802,7 @@ html[data-files="1"] #files-btn{color:var(--accent)}
-
Moteur
+
Moteuractif : …
@@ -2828,6 +2868,18 @@ html[data-files="1"] #files-btn{color:var(--accent)}
+ +
+
+
Strata — Qwen3.8 Flash Next
+
moteur spécialisé pour ce MoE de 125 B (experts répartis entre VRAM, RAM et disque), installation en un clic
+
+
+
+
-
Moteur
+
Moteuractif : …
@@ -597,6 +597,18 @@ document.documentElement.setAttribute('data-side',localStorage.getItem('loki-sid
+ +
+
+
Strata — Qwen3.8 Flash Next
+
moteur spécialisé pour ce MoE de 125 B (experts répartis entre VRAM, RAM et disque), installation en un clic
+
+
+
+