Files
Loki/internal/loki/backend_engine_oci.go
T
MichaelandClaude Opus 5.5 7f49310b7f Moteur : corrections de relecture des points de reprise — un slot de plus compte, et un « --version » coincé ne bloque plus le lancement
La liste des points de reprise de llama-server est PAR SLOT : la garde de RAM
de l'espacement d'office ne comptait qu'une liste, et sous-estimait d'autant
le pire cas avec PARALLEL > 1. Et « loki serve » lit désormais le build du
moteur avant de le lancer (hybrides seulement) : sans délai, un binaire coincé
à l'initialisation de ses backends bloquait le lancement, là où binHelp, lui,
était déjà borné.

- ckptSlots : --parallel d'EXTRA_ARGS, sinon PARALLEL, sinon 1 ; « auto » ou
  illisible compté pour 4. Le pire cas devient points × slots × poids.
- engineBuildOf borné à 15 s (WaitDelay pour un descendant qui garde la
  sortie) : passé le délai, build inconnu, ni avis ni drapeau. Profite aussi
  au panneau Moteur.
- Tests : PARALLEL=2 garde l'espacement, PARALLEL=4 et --parallel 4 le
  retirent ; table de ckptSlots.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-03 23:57:08 +02:00

660 lines
23 KiB
Go

// backend_engine_oci.go — mise à jour du moteur llama.cpp SANS reconstruire
// l'image et sans compiler : on va chercher le moteur là où l'équipe llama.cpp
// le publie déjà tout compilé — dans son image de conteneur officielle — et on
// en extrait le seul dossier qui nous intéresse, /app.
//
// Pourquoi ce détour plutôt que les releases GitHub (backend_prebuilt.go) :
// llama.cpp ne publie AUCUN binaire CUDA pour Linux dans ses releases. La seule
// distribution CUDA/Linux officielle et précompilée, ce sont ces images
// (ghcr.io/ggml-org/llama.cpp:server-cuda-b#####). C'est aussi exactement ce
// dont l'image de Loki hérite : mettre à jour par ce chemin, c'est obtenir le
// même moteur qu'une reconstruction complète de l'image, en ~170 Mo au lieu de
// 2,6 Go et sans passer par un rebuild.
//
// Ce qu'on télécharge : PAS l'image. Un manifeste OCI liste ses couches ; le
// runtime CUDA (2 Go) et les paquets système sont déjà présents dans l'image de
// Loki, donc inutiles. Seules les couches du haut portent /app (llama-server et
// ses .so, ~170 Mo). On les prend en partant du sommet et on s'arrête dès qu'on
// tient un moteur complet.
//
// Ce que ça NE remplace PAS : le runtime CUDA, lui, vient de l'image. Un
// llama.cpp compilé pour un CUDA majeur plus récent que celui de l'image ne
// démarrera pas — d'où le test à blanc (--list-devices) avant toute bascule, et
// la possibilité de revenir au moteur d'origine en un clic.
package loki
import (
"archive/tar"
"compress/gzip"
"context"
"encoding/json"
"fmt"
"io"
"net/http"
"os"
"os/exec"
"path/filepath"
"regexp"
"runtime"
"sort"
"strconv"
"strings"
"time"
)
// ociBase est une variable et non une constante pour que les tests puissent la
// pointer sur un faux registre : tout ce qui suit se joue dans le dialogue avec
// le registre, et le vérifier en frappant ghcr.io rendrait la CI dépendante du
// réseau.
// LOKI_OCI_REGISTRY le remplace : c'est la porte de sortie pour un réseau qui
// n'atteint pas ghcr.io directement — miroir d'entreprise, cache local — sans
// quoi la mise à jour du moteur y serait tout simplement impossible.
var ociBase = "https://ghcr.io"
func init() {
if v := strings.TrimRight(strings.TrimSpace(os.Getenv("LOKI_OCI_REGISTRY")), "/"); v != "" {
if !strings.Contains(v, "://") {
v = "https://" + v
}
ociBase = v
}
}
// ociHost : l'hôte seul, tel que l'attend le paramètre `service` du jeton.
func ociHost() string {
h := strings.TrimPrefix(strings.TrimPrefix(ociBase, "https://"), "http://")
return strings.TrimSuffix(h, "/")
}
const (
ociEngineRepo = "ggml-org/llama.cpp"
// Au-delà de cette taille, une couche n'est plus une couche « applicative » :
// c'est le runtime CUDA (2 Go) ou une base système, qu'on ne veut à aucun
// prix télécharger. On descend les couches du sommet vers la base ; croiser
// une couche de cette taille signifie qu'on a dépassé /app.
ociLayerCap = 600 << 20
)
// ociLayer : une couche du manifeste (on n'a besoin que de ça).
type ociLayer struct {
MediaType string `json:"mediaType"`
Digest string `json:"digest"`
Size int64 `json:"size"`
}
// ociDoc couvre à la fois un index multi-architecture et un manifeste d'image :
// les deux arrivent sur la même URL, distingués par le champ qui est rempli.
type ociDoc struct {
MediaType string `json:"mediaType"`
Manifests []struct {
Digest string `json:"digest"`
Platform struct {
Architecture string `json:"architecture"`
OS string `json:"os"`
} `json:"platform"`
} `json:"manifests"`
Layers []ociLayer `json:"layers"`
Annotations map[string]string `json:"annotations"`
}
// ociAccept : tous les types de manifeste qu'on sait lire. Sans cet en-tête, le
// registre répond en schéma 1 (obsolète) et la lecture échoue sans expliquer.
const ociAccept = "application/vnd.oci.image.index.v1+json," +
"application/vnd.docker.distribution.manifest.list.v2+json," +
"application/vnd.oci.image.manifest.v1+json," +
"application/vnd.docker.distribution.manifest.v2+json"
// ociToken obtient un jeton de lecture ANONYME. Les images publiques de ghcr.io
// sont lisibles sans compte, mais le registre exige quand même un jeton porteur.
func ociToken(repo string) (string, error) {
u := fmt.Sprintf("%s/token?scope=repository:%s:pull&service=%s", ociBase, repo, ociHost())
resp, err := (&http.Client{Timeout: 30 * time.Second}).Get(u)
if err != nil {
return "", fmt.Errorf("registre %s injoignable : %w", ociHost(), err)
}
defer resp.Body.Close()
if resp.StatusCode != 200 {
return "", fmt.Errorf("%s/token : HTTP %d", ociHost(), resp.StatusCode)
}
var out struct {
Token string `json:"token"`
}
if err := json.NewDecoder(resp.Body).Decode(&out); err != nil || out.Token == "" {
return "", fmt.Errorf("jeton de lecture illisible")
}
return out.Token, nil
}
// ociFetch fait un GET authentifié sur le registre.
func ociFetch(url, token, accept string) (*http.Response, error) {
req, err := http.NewRequest("GET", url, nil)
if err != nil {
return nil, err
}
req.Header.Set("Authorization", "Bearer "+token)
if accept != "" {
req.Header.Set("Accept", accept)
}
return (&http.Client{Timeout: 0}).Do(req)
}
// ociArch traduit GOARCH vers le nom de plateforme OCI.
func ociArch() string {
if runtime.GOARCH == "arm64" {
return "arm64"
}
return "amd64"
}
// ociResolve lit le manifeste d'un tag et renvoie les couches de l'image pour
// CETTE architecture, plus le numéro de build annoncé par l'image
// (org.opencontainers.image.version, ex. « b10450 »).
func ociResolve(repo, tag string) (layers []ociLayer, version string, err error) {
token, err := ociToken(repo)
if err != nil {
return nil, "", err
}
get := func(ref string) (*ociDoc, error) {
resp, err := ociFetch(fmt.Sprintf("%s/v2/%s/manifests/%s", ociBase, repo, ref), token, ociAccept)
if err != nil {
return nil, err
}
defer resp.Body.Close()
if resp.StatusCode == 404 {
return nil, fmt.Errorf("version inconnue dans le registre : %s", ref)
}
if resp.StatusCode != 200 {
return nil, fmt.Errorf("manifeste %s : HTTP %d", ref, resp.StatusCode)
}
var d ociDoc
if err := json.NewDecoder(resp.Body).Decode(&d); err != nil {
return nil, fmt.Errorf("manifeste %s illisible : %w", ref, err)
}
return &d, nil
}
doc, err := get(tag)
if err != nil {
return nil, "", err
}
version = doc.Annotations["org.opencontainers.image.version"]
if len(doc.Manifests) > 0 {
want := ociArch()
digest := ""
for _, m := range doc.Manifests {
if m.Platform.OS == "linux" && m.Platform.Architecture == want {
digest = m.Digest
break
}
}
if digest == "" {
return nil, "", fmt.Errorf("aucune image linux/%s pour %s", want, tag)
}
if doc, err = get(digest); err != nil {
return nil, "", err
}
if v := doc.Annotations["org.opencontainers.image.version"]; v != "" {
version = v
}
}
if len(doc.Layers) == 0 {
return nil, "", fmt.Errorf("manifeste %s sans couche", tag)
}
return doc.Layers, version, nil
}
// ---------------------------------------------------------------------------
// Variante, version et emplacement du moteur
// ---------------------------------------------------------------------------
// engineOCISupported : ce mécanisme n'a de sens que là où ces images tournent.
// Les images llama.cpp sont linux/amd64 et linux/arm64, rien d'autre.
func engineOCISupported() bool {
return runtime.GOOS == "linux" && (runtime.GOARCH == "amd64" || runtime.GOARCH == "arm64")
}
// engineVariant déduit la variante d'image correspondant au moteur COURANT, en
// regardant les backends ggml posés à côté du binaire. C'est la seule source
// fiable : le conteneur ne sait pas de quelle image il a été bâti, et demander
// à l'utilisateur de retenir « server-cuda » serait un piège à erreur.
//
// Renvoie un préfixe de tag : "server-cuda", "server-vulkan", "server-intel",
// "server-musa", ou "server" (CPU) par défaut.
func engineVariant() string {
dir := ""
if bin := currentEngineBin(); bin != "" {
dir = filepath.Dir(bin)
}
if dir == "" {
return "server"
}
has := func(prefix string) bool {
m, _ := filepath.Glob(filepath.Join(dir, prefix+"*"))
return len(m) > 0
}
switch {
case has("libggml-cuda.so"):
return "server-cuda"
case has("libggml-musa.so"):
return "server-musa"
case has("libggml-sycl.so"):
return "server-intel"
case has("libggml-vulkan.so"):
return "server-vulkan"
}
return "server"
}
// currentEngineBin : le binaire réellement utilisé (BIN de la configuration,
// avec le repli sur le moteur de l'image quand BIN n'est pas encore posé).
func currentEngineBin() string {
bin := prebuiltResolveBin(ReadConfig()["BIN"])
if bin != "" && isFile(bin) {
return bin
}
return providedEngineBin()
}
// La bannière de version de llama.cpp a changé de forme plusieurs fois, et un
// binaire ancien reste parfaitement utilisable : on lit les trois formes.
//
// version: 0.1.0-dev (build 10450, commit ece963f41) ← actuelle
// version: 10450 (ece963f) ← précédente
// build: 4589 (a1b2c3d) ← plus ancienne
//
// C'est le NUMÉRO DE BUILD qui compte : c'est lui qui figure dans les tags
// d'image (server-cuda-b10450) et donc la seule chose comparable entre ce qui
// tourne et ce qui est publié.
var reEngineBuildLong = regexp.MustCompile(`build\s+(\d+),\s*commit\s+([0-9a-fA-F]+)`)
var reEngineBuildShort = regexp.MustCompile(`(?m)^\s*(?:version|build)\s*:\s*(\d+)\s*\(([0-9a-fA-F]+)\)`)
func parseEngineBuild(out string) (build int, commit string) {
for _, re := range []*regexp.Regexp{reEngineBuildLong, reEngineBuildShort} {
if m := re.FindStringSubmatch(out); m != nil {
n, _ := strconv.Atoi(m[1])
return n, m[2]
}
}
return 0, ""
}
// engineBuildOf interroge un llama-server pour son numéro de build. Renvoie
// (0, "") si le binaire ne répond pas — un moteur cassé ne doit pas empêcher
// d'en installer un autre.
//
// Borné dans le temps comme binHelp : « loki serve » le lance avant le moteur
// (points de reprise d'un hybride, voir ckptArgs), et un binaire qui reste
// coincé à l'initialisation de ses backends ne doit pas bloquer le lancement —
// passé le délai, build inconnu, donc ni avis ni drapeau.
func engineBuildOf(bin string) (int, string) {
if bin == "" || !isFile(bin) {
return 0, ""
}
ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second)
defer cancel()
cmd := hideCmd(exec.CommandContext(ctx, bin, "--version"))
cmd.WaitDelay = 2 * time.Second // un descendant qui garde la sortie ouverte ne retient pas Wait
cmd.Env = libraryPathEnv(filepath.Dir(bin))
out, _ := cmd.CombinedOutput() // --version sort parfois en code non nul
return parseEngineBuild(string(out))
}
// engineDir : racine des moteurs installés par ce mécanisme, sous LOKI_HOME —
// c'est-à-dire sur le volume /data en conteneur, donc conservés d'un
// redémarrage (et d'une mise à jour d'image) à l'autre.
func engineDir() string { return filepath.Join(LokiHome(), "engine") }
// engineOwns dit si un chemin désigne un moteur installé ici.
func engineOwns(p string) bool {
if p == "" {
return false
}
norm := func(s string) string { return strings.ToLower(filepath.ToSlash(filepath.Clean(s))) }
return strings.HasPrefix(norm(p), norm(engineDir())+"/")
}
// engineInstalled liste les moteurs présents sous engineDir(), du plus ancien
// au plus récent (ordre des numéros de build).
func engineInstalled() []string {
entries, err := os.ReadDir(engineDir())
if err != nil {
return nil
}
var tags []string
for _, e := range entries {
if e.IsDir() && isFile(filepath.Join(engineDir(), e.Name(), "llama-server")) {
tags = append(tags, e.Name())
}
}
sort.Slice(tags, func(i, j int) bool { return engineTagBuild(tags[i]) < engineTagBuild(tags[j]) })
return tags
}
var reTagBuild = regexp.MustCompile(`-b(\d+)$`)
// engineTagBuild extrait le numéro de build d'un tag (« server-cuda-b10450 » →
// 10450), 0 si le tag n'en porte pas (tag mouvant « server-cuda »).
func engineTagBuild(tag string) int {
m := reTagBuild.FindStringSubmatch(tag)
if m == nil {
return 0
}
n, _ := strconv.Atoi(m[1])
return n
}
// engineTagOf : le tag d'un moteur téléchargé ici (« server-cuda-b10450 »), ""
// pour tout autre chemin.
func engineTagOf(bin string) string {
if !engineOwns(bin) {
return ""
}
return filepath.Base(filepath.Dir(bin))
}
// engineBinFor : chemin du llama-server d'une version installée.
func engineBinFor(tag string) string {
p := filepath.Join(engineDir(), filepath.Base(tag), "llama-server")
if isFile(p) {
return p
}
return ""
}
// enginePrune supprime les versions installées autres que celles à garder.
// Chaque version pèse ~450 Mo une fois décompressée : en laisser traîner cinq
// remplit le volume /data sans rien apporter.
func enginePrune(keep map[string]bool, logf func(string)) {
for _, tag := range engineInstalled() {
if keep[tag] {
continue
}
if err := os.RemoveAll(filepath.Join(engineDir(), tag)); err == nil && logf != nil {
logf("ancienne version supprimée : " + tag)
}
}
}
// ---------------------------------------------------------------------------
// Installation
// ---------------------------------------------------------------------------
// engineOCIInstall télécharge le moteur du tag demandé et l'installe dans
// engineDir()/<tag>. Renvoie le chemin du llama-server installé.
//
// Réentrant : une version déjà installée est renvoyée telle quelle, et une
// installation interrompue laisse un dossier « .part » qui est repris de zéro.
func engineOCIInstall(tag string, logf, phasef func(string)) (string, error) {
tag = filepath.Base(strings.TrimSpace(tag)) // jamais de séparateur dans un nom de dossier
if tag == "" || tag == "." {
return "", fmt.Errorf("version du moteur non précisée")
}
if bin := engineBinFor(tag); bin != "" {
logf("déjà installé : " + tag)
return bin, nil
}
phasef("lecture du manifeste " + tag + "…")
layers, version, err := ociResolve(ociEngineRepo, tag)
if err != nil {
return "", err
}
if version != "" {
logf(fmt.Sprintf("image %s:%s — llama.cpp %s, %d couches", ociEngineRepo, tag, version, len(layers)))
}
dest := filepath.Join(engineDir(), tag)
tmp := dest + ".part"
if err := os.RemoveAll(tmp); err != nil {
return "", err
}
if err := os.MkdirAll(tmp, 0o755); err != nil {
return "", err
}
defer os.RemoveAll(tmp) // no-op après le rename final
token, err := ociToken(ociEngineRepo)
if err != nil {
return "", err
}
// On descend du SOMMET vers la base : /app est ajouté en dernier dans le
// Dockerfile amont, et les couches du bas sont le runtime CUDA et le système,
// déjà présents ici. Une couche du haut peut masquer un fichier d'une couche
// plus basse : le premier extrait gagne, comme le ferait un runtime OCI.
seen := map[string]bool{}
var links []archiveLink
for i := len(layers) - 1; i >= 0; i-- {
l := layers[i]
if l.Size > ociLayerCap {
logf(fmt.Sprintf("couche %d ignorée (%d Mo) : c'est le runtime de l'image, pas le moteur", i, l.Size/1_000_000))
break
}
part := filepath.Join(tmp, "layer.tgz")
if l.Size > 4<<20 {
phasef(fmt.Sprintf("téléchargement du moteur (%d Mo)…", l.Size/1_000_000))
}
if err := ociDownloadBlob(ociEngineRepo, l.Digest, token, part, l.Size, logf); err != nil {
return "", fmt.Errorf("téléchargement de la couche %d : %w", i, err)
}
n, err := extractAppLayer(part, tmp, seen, &links)
_ = os.Remove(part)
if err != nil {
return "", fmt.Errorf("extraction de la couche %d : %w", i, err)
}
if n > 0 {
logf(fmt.Sprintf("couche %d : %d fichiers du moteur", i, n))
}
if engineSetComplete(seen) {
break
}
}
if err := applyLinks(links); err != nil {
return "", err
}
if !seen["llama-server"] {
return "", fmt.Errorf("llama-server absent de l'image %s — variante inattendue ?", tag)
}
if err := os.RemoveAll(dest); err != nil {
return "", err
}
if err := os.MkdirAll(filepath.Dir(dest), 0o755); err != nil {
return "", err
}
if err := os.Rename(tmp, dest); err != nil {
return "", err
}
bin := filepath.Join(dest, "llama-server")
if err := os.Chmod(bin, 0o755); err != nil {
return "", err
}
logf("moteur installé : " + bin)
return bin, nil
}
// engineSetComplete : a-t-on de quoi lancer le moteur ? Le binaire seul ne
// suffit pas — depuis que llama.cpp éclate son code en bibliothèques, l'exécutable
// tient dans une couche et ses .so dans la précédente. S'arrêter au binaire
// donnait un moteur qui meurt sur « libllama.so: cannot open shared object ».
func engineSetComplete(seen map[string]bool) bool {
if !seen["llama-server"] {
return false
}
base, llama := false, false
for name := range seen {
switch {
case strings.HasPrefix(name, "libggml-base.so"):
base = true
case strings.HasPrefix(name, "libllama.so"):
llama = true
}
}
return base && llama
}
// ociDownloadBlob télécharge une couche vers dest, avec la progression du
// téléchargement dans le journal du job.
func ociDownloadBlob(repo, digest, token, dest string, size int64, logf func(string)) error {
req, err := http.NewRequest("GET", fmt.Sprintf("%s/v2/%s/blobs/%s", ociBase, repo, digest), nil)
if err != nil {
return err
}
req.Header.Set("Authorization", "Bearer "+token)
return downloadRequest(req, dest, size, logf)
}
// extractAppLayer extrait les entrées « app/… » d'une couche (tar.gz) à PLAT
// dans dir, en ignorant celles déjà vues (une couche supérieure fait foi) et
// les marqueurs de suppression OCI (.wh.*). Renvoie le nombre de fichiers
// extraits. Les liens sont accumulés dans links : leur cible n'est pas
// forcément déjà extraite au moment où on croise l'entrée.
func extractAppLayer(archive, dir string, seen map[string]bool, links *[]archiveLink) (int, error) {
f, err := os.Open(archive)
if err != nil {
return 0, err
}
defer f.Close()
gz, err := gzip.NewReader(f)
if err != nil {
return 0, err
}
defer gz.Close()
count := 0
tr := tar.NewReader(gz)
for {
h, err := tr.Next()
if err == io.EOF {
return count, nil
}
if err != nil {
return count, err
}
name, ok := appEntryName(h.Name)
if !ok || seen[name] {
continue
}
p := filepath.Join(dir, name)
switch h.Typeflag {
case tar.TypeReg:
w, err := os.Create(p)
if err != nil {
return count, err
}
if _, err := io.Copy(w, tr); err != nil {
w.Close()
return count, err
}
w.Close()
_ = os.Chmod(p, os.FileMode(h.Mode)&0o777)
seen[name] = true
count++
case tar.TypeSymlink, tar.TypeLink:
// Indispensable : les .so sont livrés sous leur nom versionné
// (libllama.so.0.1.0) plus deux liens (libllama.so.0, libllama.so) —
// c'est le nom court que l'éditeur de liens cherche au lancement.
target, ok := appEntryName(h.Linkname)
if !ok {
target = filepath.Base(h.Linkname) // lien relatif au sein de /app
}
*links = append(*links, archiveLink{path: p, target: target})
seen[name] = true
count++
}
}
}
// appEntryName ramène « app/libllama.so » à « libllama.so ». Renvoie ok=false
// pour tout ce qui n'est pas un fichier direct de /app : autres dossiers,
// sous-dossiers, et marqueurs de suppression OCI.
func appEntryName(raw string) (string, bool) {
p := strings.TrimPrefix(filepath.ToSlash(strings.TrimSpace(raw)), "./")
rest, ok := strings.CutPrefix(p, "app/")
if !ok || rest == "" || strings.Contains(rest, "/") {
return "", false
}
if strings.HasPrefix(rest, ".wh.") {
return "", false
}
return rest, true
}
// ---------------------------------------------------------------------------
// Vérification et bascule
// ---------------------------------------------------------------------------
// engineOCILatest renvoie le dernier build publié pour la variante (numéro et
// tag figé correspondant). On lit le tag MOUVANT (« server-cuda ») et son
// annotation de version plutôt que de lister les 1000 tags du dépôt : une seule
// requête, et c'est l'amont qui dit ce qu'est « la dernière ».
func engineOCILatest(variant string) (build int, tag string, err error) {
_, version, err := ociResolve(ociEngineRepo, variant)
if err != nil {
return 0, "", err
}
if version == "" {
// Pas d'annotation : on retombe sur le tag mouvant lui-même, qui pointe
// bien sur la dernière image — on ne saura juste pas dire son numéro.
return 0, variant, nil
}
// L'annotation vaut « b10450 » : le tag figé est « server-cuda-b10450 ».
n, _ := strconv.Atoi(strings.TrimPrefix(version, "b"))
return n, variant + "-" + version, nil
}
// engineSmokeTest lance à blanc le moteur fraîchement installé — c'est là que
// se joue le seul vrai risque de ce mécanisme. La mise à jour apporte llama.cpp,
// PAS le runtime CUDA, qui reste celui de l'image : un llama.cpp compilé pour un
// CUDA plus récent ne chargera pas son backend GPU ici.
//
// Deux échecs distincts, et le second est le sournois :
//
// 1. le binaire ne démarre pas du tout (bibliothèque manquante) — bruyant ;
// 2. il démarre mais ne voit plus AUCUNE carte, alors que le moteur courant en
// voyait. Rien ne casse : le modèle se charge, tourne sur le processeur, et
// on ne s'en aperçoit qu'au débit. C'est le pire résultat possible, donc on
// le traite comme un échec de mise à jour.
//
// Ne voir aucune carte n'est un échec que si le moteur courant en voyait :
// sur une machine sans GPU, « aucune carte » est le résultat normal.
func engineSmokeTest(bin, ancien string) error {
devs, err := engineListDevices(bin)
if err != nil {
return err
}
if len(devs) > 0 {
return nil
}
if ancien == "" || ancien == bin {
return nil
}
avant, err := engineListDevices(ancien)
if err != nil || len(avant) == 0 {
return nil // le moteur courant n'en voyait pas plus : rien à reprocher au nouveau
}
return fmt.Errorf("le moteur téléchargé ne voit aucune carte graphique, alors que le moteur actuel en voit %d — "+
"il tournerait sur le processeur, beaucoup plus lentement", len(avant))
}
// engineListDevices lance `llama-server --list-devices` sur un binaire donné.
func engineListDevices(bin string) ([]map[string]any, error) {
cmd := hideCmd(exec.Command(bin, "--list-devices"))
cmd.Env = libraryPathEnv(filepath.Dir(bin))
out, err := cmd.CombinedOutput()
if err != nil {
return nil, fmt.Errorf("le moteur ne démarre pas ici : %v\n%s", err, tailLines(string(out), 12))
}
return parseListDevices(string(out)), nil
}
// tailLines garde les n dernières lignes non vides d'une sortie.
func tailLines(s string, n int) string {
lines := strings.Split(strings.TrimRight(s, "\n"), "\n")
if len(lines) > n {
lines = lines[len(lines)-n:]
}
return strings.Join(lines, "\n")
}