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_serve.go b/internal/loki/backend_serve.go index 4774edf..76bb3e3 100644 --- a/internal/loki/backend_serve.go +++ b/internal/loki/backend_serve.go @@ -272,6 +272,14 @@ func cmdServe(args []string) error { } cfg = c } + // 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 »") diff --git a/internal/loki/backend_strata.go b/internal/loki/backend_strata.go new file mode 100644 index 0000000..a402078 --- /dev/null +++ b/internal/loki/backend_strata.go @@ -0,0 +1,1225 @@ +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")) + 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/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/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_service.go b/internal/loki/sys_service.go index 54b03ca..ff12968 100644 --- a/internal/loki/sys_service.go +++ b/internal/loki/sys_service.go @@ -41,6 +41,10 @@ func serviceAction(action string) error { // visible que dans le journal. func preflightEngine() error { cfg := ReadConfig() + // 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/ui/index.html b/internal/loki/ui/index.html index 8f0069e..5ccaf8d 100644 --- a/internal/loki/ui/index.html +++ b/internal/loki/ui/index.html @@ -802,6 +802,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 +2586,7 @@ html[data-files="1"] #files-btn{color:var(--accent)}
Moteur
- +
Application
@@ -2762,7 +2788,7 @@ html[data-files="1"] #files-btn{color:var(--accent)}
-
Moteur
+
Moteuractif : …
@@ -2828,6 +2854,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
+
+
+
+