mirror of
https://github.com/R0m1k3/Loki.git
synced 2026-10-11 17:26:57 +02:00
Socle du renommage jean -> AJEAN, sans aucun impact visible pour le parc installe. Trois briques : - module github.com/nathaninline/ajean, cmd/ajean, internal/ajean, package ajean. Purement interne. - AjeanHome() remplace JeanHome(). Les defauts deviennent %ProgramData%\ajean et /etc/ajean, avec migration du dossier existant au premier lancement. La migration est un os.Rename dans le dossier parent de l'ancien chemin : intra-volume, donc atomique et instantane meme avec des .gguf de plusieurs dizaines de Go. Si le rename echoue (handle ouvert sous Windows, droits, volume en lecture seule) on CONTINUE sur l'ancien chemin et on retentera : jamais de copie, jamais de suppression, le pire cas est "rien n'a bouge". $AJEAN_HOME et $JEAN_HOME restent tous deux honores, ainsi que /etc/default/ajean et /etc/default/jean : un chemin impose a la main est un ordre, on ne le migre pas. - l'auto-MAJ accepte desormais ajean-<os>-<arch> ET jean-<os>-<arch>, dans cet ordre. Indispensable dans les deux sens : les binaires deja installes ne connaissent que jean-*, et ce binaire-ci doit pouvoir s'installer depuis une release anterieure au renommage. Couvert par des tests, dont le repli quand un handle ouvert bloque le rename sous Windows.
525 lines
15 KiB
Go
525 lines
15 KiB
Go
// backend_prebuilt.go — backend llama.cpp SANS compilation : télécharge les
|
|
// binaires officiels précompilés publiés à chaque release de ggml-org/llama.cpp
|
|
// (zip Windows, tar.gz macOS/Linux), choisit l'asset adapté à la machine
|
|
// (CUDA / ROCm / Vulkan / CPU), l'extrait dans backends/llama.cpp-prebuilt et
|
|
// pointe BIN dessus. ~2 minutes au lieu d'une compilation complète.
|
|
//
|
|
// Limites assumées : builds génériques (pas de tuning natif), et pas de build
|
|
// CUDA officiel pour Linux (on retombe sur Vulkan — la compilation locale
|
|
// reste la voie CUDA sous Linux). La compilation locale reste indispensable
|
|
// pour les forks (ex. PrismML).
|
|
package ajean
|
|
|
|
import (
|
|
"archive/tar"
|
|
"archive/zip"
|
|
"compress/gzip"
|
|
"encoding/json"
|
|
"fmt"
|
|
"io"
|
|
"net/http"
|
|
"os"
|
|
"os/exec"
|
|
"path/filepath"
|
|
"regexp"
|
|
"runtime"
|
|
"strconv"
|
|
"strings"
|
|
"time"
|
|
)
|
|
|
|
const llamaReleasesAPI = "https://api.github.com/repos/ggml-org/llama.cpp/releases/latest"
|
|
|
|
type ghAsset struct {
|
|
Name string `json:"name"`
|
|
URL string `json:"browser_download_url"`
|
|
Size int64 `json:"size"`
|
|
}
|
|
|
|
func prebuiltDir() string {
|
|
return filepath.Join(AjeanHome(), "backends", "llama.cpp-prebuilt")
|
|
}
|
|
|
|
// prebuiltVersion lit le marqueur VERSION du dossier prebuilt : "tag cudaVer"
|
|
// (cudaVer vide hors CUDA Windows).
|
|
func prebuiltVersion() (tag, cudaVer string) {
|
|
b, err := os.ReadFile(filepath.Join(prebuiltDir(), "VERSION"))
|
|
if err != nil {
|
|
return "", ""
|
|
}
|
|
f := strings.Fields(strings.TrimSpace(string(b)))
|
|
if len(f) > 0 {
|
|
tag = f[0]
|
|
}
|
|
if len(f) > 1 {
|
|
cudaVer = f[1]
|
|
}
|
|
return
|
|
}
|
|
|
|
// prebuiltFormat identifie la façon dont l'archive a été extraite. « fmt2 » =
|
|
// extraction qui recrée les liens des archives (indispensable aux .dylib macOS
|
|
// et .so Linux). Une installation sans ce marqueur est réputée incomplète et
|
|
// sera refaite au lieu d'être déclarée « déjà à jour ».
|
|
const prebuiltFormat = "fmt2"
|
|
|
|
// prebuiltVersionFormat lit le 3e champ du marqueur VERSION ("" si absent).
|
|
func prebuiltVersionFormat() string {
|
|
b, err := os.ReadFile(filepath.Join(prebuiltDir(), "VERSION"))
|
|
if err != nil {
|
|
return ""
|
|
}
|
|
f := strings.Fields(strings.TrimSpace(string(b)))
|
|
if len(f) > 2 {
|
|
return f[2]
|
|
}
|
|
return ""
|
|
}
|
|
|
|
// prebuiltServerBin localise llama-server(.exe) sous le dossier prebuilt
|
|
// (l'arborescence interne des archives officielles varie : racine, build/bin…).
|
|
func prebuiltServerBin() string {
|
|
want := "llama-server"
|
|
if runtime.GOOS == "windows" {
|
|
want += ".exe"
|
|
}
|
|
var found string
|
|
_ = filepath.WalkDir(prebuiltDir(), func(p string, d os.DirEntry, err error) error {
|
|
if err != nil || d.IsDir() {
|
|
return nil
|
|
}
|
|
if d.Name() == want {
|
|
found = p
|
|
return filepath.SkipAll
|
|
}
|
|
return nil
|
|
})
|
|
return found
|
|
}
|
|
|
|
// fetchLlamaLatest interroge l'API GitHub pour la dernière release officielle.
|
|
func fetchLlamaLatest() (string, []ghAsset, error) {
|
|
req, err := http.NewRequest("GET", llamaReleasesAPI, nil)
|
|
if err != nil {
|
|
return "", nil, err
|
|
}
|
|
req.Header.Set("Accept", "application/vnd.github+json")
|
|
client := &http.Client{Timeout: 30 * time.Second}
|
|
resp, err := client.Do(req)
|
|
if err != nil {
|
|
return "", nil, err
|
|
}
|
|
defer resp.Body.Close()
|
|
if resp.StatusCode != 200 {
|
|
return "", nil, fmt.Errorf("GitHub API : HTTP %d", resp.StatusCode)
|
|
}
|
|
var rel struct {
|
|
TagName string `json:"tag_name"`
|
|
Assets []ghAsset `json:"assets"`
|
|
}
|
|
if err := json.NewDecoder(resp.Body).Decode(&rel); err != nil {
|
|
return "", nil, err
|
|
}
|
|
if rel.TagName == "" {
|
|
return "", nil, fmt.Errorf("release invalide (tag vide)")
|
|
}
|
|
return rel.TagName, rel.Assets, nil
|
|
}
|
|
|
|
// driverCudaVersion renvoie la version CUDA max supportée par le pilote NVIDIA
|
|
// (bandeau de nvidia-smi : « CUDA Version: 12.8 »), ou 0 si inconnue.
|
|
func driverCudaVersion() float64 {
|
|
out, err := hideCmd(exec.Command("nvidia-smi")).Output()
|
|
if err != nil {
|
|
return 0
|
|
}
|
|
m := regexp.MustCompile(`CUDA Version:\s*([0-9]+\.[0-9]+)`).FindSubmatch(out)
|
|
if m == nil {
|
|
return 0
|
|
}
|
|
v, _ := strconv.ParseFloat(string(m[1]), 64)
|
|
return v
|
|
}
|
|
|
|
// assetMatch garde les assets dont le nom contient TOUS les fragments.
|
|
func assetMatch(assets []ghAsset, frags ...string) []ghAsset {
|
|
var out []ghAsset
|
|
for _, a := range assets {
|
|
ok := true
|
|
for _, f := range frags {
|
|
if !strings.Contains(a.Name, f) {
|
|
ok = false
|
|
break
|
|
}
|
|
}
|
|
if ok {
|
|
out = append(out, a)
|
|
}
|
|
}
|
|
return out
|
|
}
|
|
|
|
var reCudaAssetVer = regexp.MustCompile(`cuda-([0-9]+\.[0-9]+)`)
|
|
|
|
// pickPrebuilt choisit l'asset principal (+ cudart pour CUDA Windows) adapté à
|
|
// la machine, et renvoie un label lisible du variant retenu.
|
|
func pickPrebuilt(assets []ghAsset) (main *ghAsset, cudart *ghAsset, label, cudaVer string, err error) {
|
|
pickOne := func(list []ghAsset) *ghAsset {
|
|
if len(list) == 0 {
|
|
return nil
|
|
}
|
|
return &list[0]
|
|
}
|
|
switch runtime.GOOS {
|
|
case "windows":
|
|
if runtime.GOARCH == "arm64" {
|
|
main = pickOne(assetMatch(assets, "llama-", "bin-win-cpu-arm64"))
|
|
label = "CPU (Windows arm64)"
|
|
break
|
|
}
|
|
if hasNvidiaGPU() {
|
|
// Plusieurs versions CUDA publiées (ex. 12.4 et 13.3) : on prend la plus
|
|
// haute supportée par le pilote (sinon la plus basse, la plus compatible).
|
|
// NB : les archives cudart-llama-bin-win-cuda-… contiennent aussi ces
|
|
// fragments — on les écarte explicitement du choix du binaire principal.
|
|
var cand []ghAsset
|
|
for _, a := range assetMatch(assets, "llama-", "bin-win-cuda-", "-x64.zip") {
|
|
if !strings.HasPrefix(a.Name, "cudart") {
|
|
cand = append(cand, a)
|
|
}
|
|
}
|
|
maxV := driverCudaVersion()
|
|
var best *ghAsset
|
|
bestV := 0.0
|
|
for i := range cand {
|
|
m := reCudaAssetVer.FindStringSubmatch(cand[i].Name)
|
|
if m == nil {
|
|
continue
|
|
}
|
|
v, _ := strconv.ParseFloat(m[1], 64)
|
|
ok := maxV == 0 && (best == nil || v < bestV) || // pilote inconnu → la plus basse
|
|
maxV > 0 && v <= maxV && v > bestV // sinon la plus haute compatible
|
|
if ok {
|
|
best = &cand[i]
|
|
bestV = v
|
|
cudaVer = m[1]
|
|
}
|
|
}
|
|
if best != nil {
|
|
main = best
|
|
cudart = pickOne(assetMatch(assets, "cudart-", "win-cuda-"+cudaVer))
|
|
label = "CUDA " + cudaVer + " (Windows x64)"
|
|
break
|
|
}
|
|
}
|
|
if a := pickOne(assetMatch(assets, "llama-", "bin-win-vulkan-x64")); a != nil {
|
|
main, label = a, "Vulkan (Windows x64)"
|
|
break
|
|
}
|
|
main = pickOne(assetMatch(assets, "llama-", "bin-win-cpu-x64"))
|
|
label = "CPU (Windows x64)"
|
|
case "darwin":
|
|
arch := "x64"
|
|
if runtime.GOARCH == "arm64" {
|
|
arch = "arm64"
|
|
}
|
|
main = pickOne(assetMatch(assets, "llama-", "bin-macos-"+arch))
|
|
label = "Metal (macOS " + arch + ")"
|
|
default: // linux
|
|
arch := "x64"
|
|
if runtime.GOARCH == "arm64" {
|
|
arch = "arm64"
|
|
}
|
|
if hasTool("hipcc") || isDir("/opt/rocm") {
|
|
if a := pickOne(assetMatch(assets, "llama-", "bin-ubuntu-rocm-", arch)); a != nil {
|
|
main, label = a, "ROCm (Linux "+arch+")"
|
|
break
|
|
}
|
|
}
|
|
// Pas de build CUDA officiel pour Linux : sur GPU NVIDIA le variant Vulkan
|
|
// fonctionne via le pilote (moins optimal que le build CUDA local).
|
|
if hasNvidiaGPU() || hasTool("vulkaninfo") {
|
|
if a := pickOne(assetMatch(assets, "llama-", "bin-ubuntu-vulkan-"+arch)); a != nil {
|
|
main, label = a, "Vulkan (Linux "+arch+")"
|
|
if hasNvidiaGPU() {
|
|
label += " — pas de build CUDA officiel Linux ; compile localement pour du CUDA natif"
|
|
}
|
|
break
|
|
}
|
|
}
|
|
main = pickOne(assetMatch(assets, "llama-", "bin-ubuntu-"+arch+".tar.gz"))
|
|
label = "CPU (Linux " + arch + ")"
|
|
}
|
|
if main == nil {
|
|
return nil, nil, "", "", fmt.Errorf("aucun binaire précompilé adapté à cette machine dans la release officielle")
|
|
}
|
|
return main, cudart, label, cudaVer, nil
|
|
}
|
|
|
|
// prebuiltInstall télécharge et installe (ou met à jour) les binaires
|
|
// précompilés. logf reçoit chaque ligne de log ; phasef la phase courante.
|
|
// Renvoie le chemin du binaire installé.
|
|
func prebuiltInstall(logf, phasef func(string)) (string, error) {
|
|
phasef("récupération de la dernière release llama.cpp…")
|
|
tag, assets, err := fetchLlamaLatest()
|
|
if err != nil {
|
|
return "", fmt.Errorf("impossible d'interroger les releases llama.cpp : %w", err)
|
|
}
|
|
curTag, curCuda := prebuiltVersion()
|
|
main, cudart, label, cudaVer, err := pickPrebuilt(assets)
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
logf(fmt.Sprintf("release %s — variant retenu : %s", tag, label))
|
|
|
|
// Réinstaller à l'identique est inutile SAUF si l'extraction date d'une
|
|
// version de Jean qui ignorait les liens des archives (backend installé mais
|
|
// bibliothèques introuvables au lancement) : le marqueur de format force alors
|
|
// une ré-extraction propre au lieu d'un « déjà à jour » trompeur.
|
|
if curTag == tag && prebuiltVersionFormat() == prebuiltFormat && prebuiltServerBin() != "" {
|
|
logf("déjà à jour (" + tag + ")")
|
|
return prebuiltServerBin(), nil
|
|
}
|
|
|
|
dir := prebuiltDir()
|
|
if err := os.MkdirAll(dir, 0o755); err != nil {
|
|
return "", err
|
|
}
|
|
|
|
// cudart (DLLs runtime CUDA, ~400 Mo) : seulement si absent ou si la version
|
|
// CUDA du variant a changé depuis la dernière installation.
|
|
if cudart != nil {
|
|
haveDLL, _ := filepath.Glob(filepath.Join(dir, "**", "cudart64*.dll"))
|
|
if len(haveDLL) == 0 {
|
|
haveDLL, _ = filepath.Glob(filepath.Join(dir, "cudart64*.dll"))
|
|
}
|
|
if len(haveDLL) > 0 && curCuda == cudaVer {
|
|
logf("cudart " + cudaVer + " déjà présent — téléchargement évité")
|
|
cudart = nil
|
|
}
|
|
}
|
|
|
|
for _, a := range []*ghAsset{main, cudart} {
|
|
if a == nil {
|
|
continue
|
|
}
|
|
phasef(fmt.Sprintf("téléchargement de %s (%d Mo)…", a.Name, a.Size/1_000_000))
|
|
tmp := filepath.Join(dir, a.Name+".part")
|
|
if err := downloadWithProgress(a.URL, tmp, a.Size, logf); err != nil {
|
|
_ = os.Remove(tmp)
|
|
return "", fmt.Errorf("téléchargement de %s : %w", a.Name, err)
|
|
}
|
|
phasef("extraction de " + a.Name + "…")
|
|
if err := extractArchive(tmp, dir); err != nil {
|
|
_ = os.Remove(tmp)
|
|
return "", fmt.Errorf("extraction de %s : %w", a.Name, err)
|
|
}
|
|
_ = os.Remove(tmp)
|
|
}
|
|
|
|
bin := prebuiltServerBin()
|
|
if bin == "" {
|
|
return "", fmt.Errorf("archives extraites mais llama-server introuvable sous %s", dir)
|
|
}
|
|
if runtime.GOOS != "windows" {
|
|
_ = os.Chmod(bin, 0o755)
|
|
}
|
|
if err := os.WriteFile(filepath.Join(dir, "VERSION"), []byte(tag+" "+cudaVer+" "+prebuiltFormat+"\n"), 0o644); err != nil {
|
|
return "", err
|
|
}
|
|
logf("binaire installé : " + bin + " (release " + tag + ")")
|
|
return bin, nil
|
|
}
|
|
|
|
// downloadWithProgress télécharge url vers dest en journalisant la progression
|
|
// par tranches de ~25 Mo.
|
|
func downloadWithProgress(url, dest string, total int64, logf func(string)) error {
|
|
client := &http.Client{Timeout: 0}
|
|
resp, err := client.Get(url)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
defer resp.Body.Close()
|
|
if resp.StatusCode != 200 {
|
|
return fmt.Errorf("HTTP %d", resp.StatusCode)
|
|
}
|
|
if total <= 0 {
|
|
total = resp.ContentLength
|
|
}
|
|
f, err := os.Create(dest)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
defer f.Close()
|
|
buf := make([]byte, 1<<20)
|
|
var done, lastLog int64
|
|
for {
|
|
n, rerr := resp.Body.Read(buf)
|
|
if n > 0 {
|
|
if _, werr := f.Write(buf[:n]); werr != nil {
|
|
return werr
|
|
}
|
|
done += int64(n)
|
|
if done-lastLog >= 25<<20 {
|
|
lastLog = done
|
|
if total > 0 {
|
|
logf(fmt.Sprintf("⬇ %d / %d Mo (%d%%)", done/1_000_000, total/1_000_000, done*100/total))
|
|
} else {
|
|
logf(fmt.Sprintf("⬇ %d Mo", done/1_000_000))
|
|
}
|
|
}
|
|
}
|
|
if rerr == io.EOF {
|
|
return nil
|
|
}
|
|
if rerr != nil {
|
|
return rerr
|
|
}
|
|
}
|
|
}
|
|
|
|
// extractArchive extrait un .zip ou un .tar.gz dans dir, en refusant toute
|
|
// entrée qui s'échapperait du dossier (zip-slip).
|
|
func extractArchive(path, dir string) error {
|
|
safe := func(name string) (string, error) {
|
|
p := filepath.Join(dir, filepath.FromSlash(name))
|
|
if rel, err := filepath.Rel(dir, p); err != nil || strings.HasPrefix(rel, "..") {
|
|
return "", fmt.Errorf("entrée d'archive suspecte : %s", name)
|
|
}
|
|
return p, nil
|
|
}
|
|
if strings.HasSuffix(path, ".zip") || strings.HasSuffix(path, ".zip.part") {
|
|
zr, err := zip.OpenReader(path)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
defer zr.Close()
|
|
for _, f := range zr.File {
|
|
p, err := safe(f.Name)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if f.FileInfo().IsDir() {
|
|
if err := os.MkdirAll(p, 0o755); err != nil {
|
|
return err
|
|
}
|
|
continue
|
|
}
|
|
if err := os.MkdirAll(filepath.Dir(p), 0o755); err != nil {
|
|
return err
|
|
}
|
|
rc, err := f.Open()
|
|
if err != nil {
|
|
return err
|
|
}
|
|
w, err := os.Create(p)
|
|
if err != nil {
|
|
rc.Close()
|
|
return err
|
|
}
|
|
_, err = io.Copy(w, rc)
|
|
rc.Close()
|
|
w.Close()
|
|
if err != nil {
|
|
return err
|
|
}
|
|
}
|
|
return nil
|
|
}
|
|
// tar.gz
|
|
f, err := os.Open(path)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
defer f.Close()
|
|
gz, err := gzip.NewReader(f)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
defer gz.Close()
|
|
tr := tar.NewReader(gz)
|
|
// Les liens sont appliqués APRÈS coup : leur cible n'est pas forcément déjà
|
|
// extraite au moment où on croise l'entrée.
|
|
var links []archiveLink
|
|
for {
|
|
h, err := tr.Next()
|
|
if err == io.EOF {
|
|
return applyLinks(links)
|
|
}
|
|
if err != nil {
|
|
return err
|
|
}
|
|
p, err := safe(h.Name)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
switch h.Typeflag {
|
|
case tar.TypeDir:
|
|
if err := os.MkdirAll(p, 0o755); err != nil {
|
|
return err
|
|
}
|
|
case tar.TypeReg:
|
|
if err := os.MkdirAll(filepath.Dir(p), 0o755); err != nil {
|
|
return err
|
|
}
|
|
w, err := os.Create(p)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if _, err := io.Copy(w, tr); err != nil {
|
|
w.Close()
|
|
return err
|
|
}
|
|
w.Close()
|
|
_ = os.Chmod(p, os.FileMode(h.Mode)&0o777)
|
|
case tar.TypeSymlink, tar.TypeLink:
|
|
// Indispensable sur macOS : les archives llama.cpp livrent les .dylib
|
|
// sous leur nom versionné (libllama-common.0.0.10107.dylib) PLUS un lien
|
|
// portant le nom que cherche le binaire (libllama-common.0.dylib). Ignorer
|
|
// ces entrées donnait un backend installé mais impossible à lancer :
|
|
// « dyld: Library not loaded: @rpath/libllama-common.0.dylib ».
|
|
target := h.Linkname
|
|
if h.Typeflag == tar.TypeLink {
|
|
// Lien dur : la cible est un chemin dans l'archive, pas un chemin relatif.
|
|
t, err := safe(h.Linkname)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
target = t
|
|
}
|
|
links = append(links, archiveLink{path: p, target: target})
|
|
}
|
|
}
|
|
}
|
|
|
|
// archiveLink : un lien (symbolique ou dur) relevé dans une archive.
|
|
type archiveLink struct{ path, target string }
|
|
|
|
// applyLinks crée les liens relevés pendant l'extraction. Sur les systèmes où la
|
|
// création de liens symboliques est refusée (Windows sans mode développeur), on
|
|
// copie le fichier cible : moins élégant, mais fonctionnel.
|
|
func applyLinks(links []archiveLink) error {
|
|
for _, l := range links {
|
|
_ = os.Remove(l.path)
|
|
if err := os.MkdirAll(filepath.Dir(l.path), 0o755); err != nil {
|
|
return err
|
|
}
|
|
if err := os.Symlink(l.target, l.path); err == nil {
|
|
continue
|
|
}
|
|
// Repli par copie. La cible peut être relative au dossier du lien.
|
|
src := l.target
|
|
if !filepath.IsAbs(src) {
|
|
src = filepath.Join(filepath.Dir(l.path), l.target)
|
|
}
|
|
data, err := os.ReadFile(src)
|
|
if err != nil {
|
|
continue // cible absente de l'archive : on n'échoue pas l'installation pour ça
|
|
}
|
|
if err := os.WriteFile(l.path, data, 0o755); err != nil {
|
|
return err
|
|
}
|
|
}
|
|
return nil
|
|
}
|