mirror of
https://github.com/R0m1k3/Loki.git
synced 2026-10-11 17:26:57 +02:00
Installer un modèle demandait d'aller sur huggingface.co, de naviguer dans l'arborescence d'un dépôt et de coller un lien à la main. Rien ne disait si le fichier tiendrait en mémoire, et surtout rien ne reliait un modèle à SON projecteur vision : c'est ainsi qu'un mmproj-Qwen3VL-8B s'est retrouvé configuré pour un Qwen3.8-27B — deux modèles sans rapport, moteur qui démarre et ne voit rien. La recherche Hugging Face arrive dans l'éditeur de preset. Elle ne remonte que les dépôts GGUF ; déplier un dépôt montre ses quantifications avec leur taille et un verdict mémoire, et propose le projecteur vision DU MÊME DÉPÔT — le seul qui corresponde. Quand le dépôt n'en publie pas, Loki le dit au lieu d'aller en chercher un ailleurs. Le nouveau code ne télécharge rien : il produit des URL que le chemin existant consomme tel quel (normalizeHFURL, shardURLSet, sonde d'espace disque, reprise et annulation). Une file d'attente enchaîne modèle puis projecteur — le serveur ne mène qu'un transfert à la fois, et le champ Vision ne se remplit que si le modèle est déjà sélectionné. Trois familles de .gguf cohabitent dans un dépôt et ne veulent pas dire la même chose : mmproj-* (projecteur), mtp-* (poids de décodage spéculatif) et le modèle. Les deux premières ressemblent à un modèle ; les proposer en vrac, ce serait offrir de lancer llama-server sur un encodeur d'images. Les tranches d'une famille sont repliées en une entrée de taille TOTALE : annoncer 15 Go pour un modèle qui en occupe 45 promet une place qui n'existe pas. Le verdict mémoire s'appuie enfin sur le GPU. detectHardware() ne renvoyait que la RAM système alors que detectGPUs() existait déjà : sur un serveur à carte NVIDIA, le verdict se prononçait sur la mauvaise grandeur. Le coût du cache KV reste une estimation assumée — l'exact demanderait de parser l'en-tête GGUF — et l'interface l'annonce comme telle plutôt que d'afficher un chiffre faussement sûr. Le catalogue ajean.link disparaît. Sa route n'avait aucun consommateur (l'écran d'accueil qu'elle attendait n'a jamais existé), son repli embarqué proposait du Qwen2.5 de 2024, et un fork qui laisse le serveur de l'amont décider de ce qu'il propose n'est pas vraiment un fork. Une piste écartée en cours de route : marquer les dépôts « vision » d'après les tags Hugging Face. Ils mentent — des deux dépôts GGUF de Qwen3.8-27B qui publient tous deux un mmproj, seul ggml-org est taggé image-text-to-text. Une pastille sur l'un et pas sur l'autre aurait été pire que rien. La vision est donc déduite de la seule source qui ne se trompe pas : la présence d'un mmproj-*.gguf dans l'arborescence. Vérifié : 8 tests unitaires sur les arborescences réelles des deux dépôts, puis au navigateur contre le vrai Hugging Face — 25 dépôts trouvés, 3 quants listés sans aucun mtp ni mmproj, projecteur Q8_0 proposé et coché, verdicts affichés avec leur explication, modale dans l'écran. L'ordre de la file (modèle puis projecteur) est testé en interceptant les appels, sans transfert. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01VueWA9xcYadaYq65tBisix
329 lines
11 KiB
Go
329 lines
11 KiB
Go
package loki
|
|
|
|
// backend_hf.go — chercher un modèle GGUF sur Hugging Face depuis Loki.
|
|
//
|
|
// Avant ce fichier, installer un modèle voulait dire aller sur huggingface.co,
|
|
// naviguer dans l'arborescence d'un dépôt, copier le lien d'un .gguf et le
|
|
// coller dans Loki. Rien ne disait si le fichier tenait en mémoire, et surtout
|
|
// rien ne reliait un modèle à SON projecteur vision : coller un mmproj pris
|
|
// dans un autre dépôt donne un moteur qui démarre et ne voit rien.
|
|
//
|
|
// Ce fichier ne télécharge rien. Il PRODUIT des URL directes, que le chemin
|
|
// existant consomme tel quel — normalizeHFURL, shardURLSet, la sonde d'espace
|
|
// disque et la reprise de téléchargement (backend_models.go) n'ont pas bougé.
|
|
//
|
|
// Trois familles de .gguf cohabitent dans un même dépôt et ne veulent pas dire
|
|
// la même chose :
|
|
//
|
|
// mmproj-*.gguf le projecteur vision, à passer en --mmproj
|
|
// mtp-*.gguf les poids de multi-token prediction (décodage spéculatif)
|
|
// le reste le modèle lui-même
|
|
//
|
|
// Les deux premiers ressemblent à s'y méprendre à un modèle : les proposer en
|
|
// vrac, c'est offrir de lancer llama-server sur un projecteur.
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"fmt"
|
|
"io"
|
|
"net/http"
|
|
"net/url"
|
|
"os"
|
|
"regexp"
|
|
"sort"
|
|
"strings"
|
|
"sync"
|
|
"time"
|
|
)
|
|
|
|
const (
|
|
hfHost = "https://huggingface.co"
|
|
// Les deux durées de cache disent la même chose : ces routes tapent un hôte
|
|
// externe à chaque appel, et l'UI en déclenche une par recherche. Une
|
|
// arborescence de dépôt ne bouge quasiment jamais, une liste de résultats un
|
|
// peu plus.
|
|
hfSearchTTL = 60 * time.Second
|
|
hfFilesTTL = 10 * time.Minute
|
|
hfMaxRepos = 25
|
|
hfMaxQuery = 100
|
|
hfTimeout = 10 * time.Second
|
|
)
|
|
|
|
// hfRepoRe borne ce qu'on accepte comme identifiant de dépôt. La valeur vient
|
|
// du navigateur et part dans un chemin d'URL : sans ce filtre, un « ../.. »
|
|
// permettrait d'atteindre n'importe quelle route de l'API Hugging Face.
|
|
var hfRepoRe = regexp.MustCompile(`^[A-Za-z0-9][A-Za-z0-9._-]*/[A-Za-z0-9][A-Za-z0-9._-]*$`)
|
|
|
|
// hfRepo — un dépôt trouvé par la recherche.
|
|
//
|
|
// Volontairement pauvre. On a essayé d'y porter un indicateur « vision » tiré
|
|
// des tags Hugging Face : il ment. Sur les deux dépôts GGUF de Qwen3.8-27B qui
|
|
// publient TOUS DEUX un mmproj, ggml-org est taggé image-text-to-text et
|
|
// unsloth ne l'est pas. Une pastille « vision » sur l'un et pas sur l'autre
|
|
// aurait été pire que rien. La seule source qui ne se trompe pas, c'est la
|
|
// présence d'un mmproj-*.gguf dans l'arborescence — donc hfFiles, pas ici.
|
|
type hfRepo struct {
|
|
ID string `json:"id"`
|
|
Downloads int `json:"downloads"`
|
|
Likes int `json:"likes"`
|
|
}
|
|
|
|
// hfEntry — un .gguf installable. Shards > 1 signale une famille de tranches :
|
|
// Size est alors le total de la famille, pas le poids du fichier pointé.
|
|
type hfEntry struct {
|
|
Name string `json:"name"`
|
|
URL string `json:"url"`
|
|
Size int64 `json:"size"`
|
|
Quant string `json:"quant"`
|
|
Shards int `json:"shards"`
|
|
// Renseignés par le handler, qui seul connaît le matériel et le contexte.
|
|
Verdict string `json:"verdict,omitempty"`
|
|
Why string `json:"why,omitempty"`
|
|
}
|
|
|
|
// hfListing — le contenu utile d'un dépôt, trié.
|
|
type hfListing struct {
|
|
Repo string `json:"repo"`
|
|
Models []hfEntry `json:"models"`
|
|
Projectors []hfEntry `json:"projectors"`
|
|
Drafts []hfEntry `json:"drafts"`
|
|
}
|
|
|
|
// hfAuth pose les en-têtes communs à tous les appels Hugging Face. Le jeton
|
|
// compte aussi pour la RECHERCHE et l'arborescence, pas seulement pour le
|
|
// transfert : sans lui un dépôt gated répond 401 et Loki afficherait « aucun
|
|
// résultat » là où il faudrait dire « dépôt à accès restreint ».
|
|
func hfAuth(req *http.Request) {
|
|
if k := os.Getenv("HF_TOKEN"); k != "" {
|
|
req.Header.Set("Authorization", "Bearer "+k)
|
|
}
|
|
req.Header.Set("User-Agent", "loki/"+Version)
|
|
req.Header.Set("Accept", "application/json")
|
|
}
|
|
|
|
// hfCache : mémo commun à la recherche et aux arborescences, clé = l'URL
|
|
// appelée. Sans lui, chaque frappe au clavier dans le champ de recherche
|
|
// produit une requête sortante.
|
|
var (
|
|
hfCacheMu sync.Mutex
|
|
hfCache = map[string]hfCacheItem{}
|
|
)
|
|
|
|
type hfCacheItem struct {
|
|
at time.Time
|
|
raw []byte
|
|
}
|
|
|
|
// hfGetJSON appelle l'API Hugging Face et décode la réponse dans out, avec
|
|
// cache. Le corps brut est mémorisé plutôt que la valeur décodée : deux
|
|
// appelants attendent des types différents et le partage d'une structure
|
|
// décodée les exposerait à se modifier mutuellement.
|
|
func hfGetJSON(ctx context.Context, endpoint string, ttl time.Duration, out any) error {
|
|
hfCacheMu.Lock()
|
|
if it, ok := hfCache[endpoint]; ok && time.Since(it.at) < ttl {
|
|
raw := it.raw
|
|
hfCacheMu.Unlock()
|
|
return json.Unmarshal(raw, out)
|
|
}
|
|
hfCacheMu.Unlock()
|
|
|
|
ctx, cancel := context.WithTimeout(ctx, hfTimeout)
|
|
defer cancel()
|
|
req, err := http.NewRequestWithContext(ctx, "GET", endpoint, nil)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
hfAuth(req)
|
|
resp, err := http.DefaultClient.Do(req)
|
|
if err != nil {
|
|
return fmt.Errorf("Hugging Face injoignable : %v", err)
|
|
}
|
|
defer resp.Body.Close()
|
|
switch resp.StatusCode {
|
|
case 200:
|
|
case 401, 403:
|
|
return fmt.Errorf("dépôt à accès restreint — renseigne HF_TOKEN pour y accéder")
|
|
case 404:
|
|
return fmt.Errorf("dépôt introuvable sur Hugging Face")
|
|
default:
|
|
return fmt.Errorf("Hugging Face a répondu %d", resp.StatusCode)
|
|
}
|
|
// Borne de lecture : la réponse vient d'un tiers, on ne lui laisse pas
|
|
// décider de la mémoire qu'on lui consacre.
|
|
raw, err := io.ReadAll(io.LimitReader(resp.Body, 4<<20))
|
|
if err != nil {
|
|
return err
|
|
}
|
|
hfCacheMu.Lock()
|
|
hfCache[endpoint] = hfCacheItem{at: time.Now(), raw: raw}
|
|
hfCacheMu.Unlock()
|
|
return json.Unmarshal(raw, out)
|
|
}
|
|
|
|
// hfSearch cherche des dépôts GGUF. Le filtre `gguf` est essentiel : sans lui la
|
|
// recherche remonte surtout des dépôts de poids safetensors, inutilisables par
|
|
// llama-server.
|
|
func hfSearch(ctx context.Context, q string) ([]hfRepo, error) {
|
|
q = strings.TrimSpace(q)
|
|
if q == "" {
|
|
return nil, fmt.Errorf("recherche vide")
|
|
}
|
|
if len(q) > hfMaxQuery {
|
|
q = q[:hfMaxQuery]
|
|
}
|
|
endpoint := hfHost + "/api/models?" + url.Values{
|
|
"search": {q},
|
|
"filter": {"gguf"},
|
|
"limit": {fmt.Sprint(hfMaxRepos)},
|
|
"sort": {"downloads"},
|
|
"direction": {"-1"},
|
|
}.Encode()
|
|
|
|
var raw []struct {
|
|
ID string `json:"id"`
|
|
Downloads int `json:"downloads"`
|
|
Likes int `json:"likes"`
|
|
}
|
|
if err := hfGetJSON(ctx, endpoint, hfSearchTTL, &raw); err != nil {
|
|
return nil, err
|
|
}
|
|
out := make([]hfRepo, 0, len(raw))
|
|
for _, r := range raw {
|
|
out = append(out, hfRepo{ID: r.ID, Downloads: r.Downloads, Likes: r.Likes})
|
|
}
|
|
return out, nil
|
|
}
|
|
|
|
// hfFiles liste les .gguf d'un dépôt et les range par famille.
|
|
//
|
|
// `recursive=1` n'est pas un confort : les quants volumineux vivent dans des
|
|
// sous-dossiers (UD-IQ4_XS/…), et sans lui l'arborescence ne renvoie que les
|
|
// dossiers, donc aucun modèle.
|
|
func hfFiles(ctx context.Context, repo string) (hfListing, error) {
|
|
repo = strings.Trim(strings.TrimSpace(repo), "/")
|
|
if !hfRepoRe.MatchString(repo) {
|
|
return hfListing{}, fmt.Errorf("nom de dépôt invalide (attendu « auteur/dépôt »)")
|
|
}
|
|
endpoint := hfHost + "/api/models/" + repo + "/tree/main?recursive=1"
|
|
|
|
var raw []struct {
|
|
Type string `json:"type"`
|
|
Path string `json:"path"`
|
|
Size int64 `json:"size"`
|
|
LFS struct {
|
|
Size int64 `json:"size"`
|
|
} `json:"lfs"`
|
|
}
|
|
if err := hfGetJSON(ctx, endpoint, hfFilesTTL, &raw); err != nil {
|
|
return hfListing{}, err
|
|
}
|
|
files := make([]hfFile, 0, len(raw))
|
|
for _, f := range raw {
|
|
if f.Type == "directory" {
|
|
continue
|
|
}
|
|
n := f.LFS.Size
|
|
if n == 0 {
|
|
n = f.Size
|
|
}
|
|
files = append(files, hfFile{Path: f.Path, Size: n})
|
|
}
|
|
return hfClassify(repo, files), nil
|
|
}
|
|
|
|
// hfFile — une entrée d'arborescence, réduite à ce dont le classement a besoin.
|
|
type hfFile struct {
|
|
Path string
|
|
Size int64
|
|
}
|
|
|
|
// hfClassify range les fichiers d'un dépôt en modèles, projecteurs et poids de
|
|
// décodage spéculatif. Séparé de l'appel réseau pour être testable : c'est ici
|
|
// que se jouent les erreurs qui coûtent cher (proposer un mmproj comme modèle,
|
|
// annoncer 15 Go pour une famille qui en pèse 45).
|
|
func hfClassify(repo string, files []hfFile) hfListing {
|
|
// Taille par chemin complet : une famille de tranches partage son dossier,
|
|
// et deux dossiers de quantification différents contiennent des fichiers de
|
|
// même nom de base.
|
|
size := map[string]int64{}
|
|
var paths []string
|
|
for _, f := range files {
|
|
if !strings.HasSuffix(strings.ToLower(f.Path), ".gguf") {
|
|
continue
|
|
}
|
|
size[f.Path] = f.Size
|
|
paths = append(paths, f.Path)
|
|
}
|
|
|
|
out := hfListing{Repo: repo}
|
|
for _, p := range paths {
|
|
base := baseName(p)
|
|
// Tranche 2..N : elle appartient à une famille déjà représentée par sa
|
|
// première, et ne démarre pas seule. On ne la propose jamais.
|
|
if isFollowerShard(base) {
|
|
continue
|
|
}
|
|
fam := shardFamily(base)
|
|
dir := ""
|
|
if i := strings.LastIndexByte(p, '/'); i >= 0 {
|
|
dir = p[:i+1]
|
|
}
|
|
total := int64(0)
|
|
for _, n := range fam {
|
|
total += size[dir+n]
|
|
}
|
|
e := hfEntry{
|
|
Name: base, URL: hfResolveURL(repo, p), Size: total,
|
|
Quant: quantFromName(base), Shards: len(fam),
|
|
}
|
|
switch {
|
|
case strings.HasPrefix(strings.ToLower(base), "mmproj"):
|
|
out.Projectors = append(out.Projectors, e)
|
|
case strings.HasPrefix(strings.ToLower(base), "mtp-"):
|
|
out.Drafts = append(out.Drafts, e)
|
|
default:
|
|
out.Models = append(out.Models, e)
|
|
}
|
|
}
|
|
bySize := func(s []hfEntry) { sort.Slice(s, func(i, j int) bool { return s[i].Size < s[j].Size }) }
|
|
bySize(out.Models)
|
|
bySize(out.Projectors)
|
|
bySize(out.Drafts)
|
|
return out
|
|
}
|
|
|
|
// hfResolveURL construit le lien de téléchargement direct. Chaque segment est
|
|
// échappé séparément : un nom de fichier peut contenir un espace, et les « / »
|
|
// du chemin doivent rester des séparateurs.
|
|
func hfResolveURL(repo, filePath string) string {
|
|
segs := strings.Split(filePath, "/")
|
|
for i, s := range segs {
|
|
segs[i] = url.PathEscape(s)
|
|
}
|
|
return hfHost + "/" + repo + "/resolve/main/" + strings.Join(segs, "/")
|
|
}
|
|
|
|
// hfPickProjector choisit le projecteur à proposer avec un modèle : le Q8_0
|
|
// d'abord (629 Mo contre 931 pour le BF16, sans différence perceptible sur un
|
|
// encodeur d'images), sinon le plus léger. Renvoie false si le dépôt n'en
|
|
// publie aucun — auquel cas l'UI doit le DIRE, pas aller en chercher un
|
|
// ailleurs : un projecteur d'un autre modèle ne correspond jamais.
|
|
func hfPickProjector(list []hfEntry) (hfEntry, bool) {
|
|
if len(list) == 0 {
|
|
return hfEntry{}, false
|
|
}
|
|
for _, e := range list {
|
|
if strings.EqualFold(e.Quant, "Q8_0") {
|
|
return e, true
|
|
}
|
|
}
|
|
best := list[0]
|
|
for _, e := range list[1:] {
|
|
if e.Size < best.Size {
|
|
best = e
|
|
}
|
|
}
|
|
return best, true
|
|
}
|