Files
Loki/internal/loki/backend_hf.go
T
Claude 7f5765160d Jeton Hugging Face réglable dans l'interface
Le jeton ne vivait que dans la variable d'environnement HF_TOKEN : découvrir
depuis l'interface qu'un dépôt est verrouillé, c'était devoir éditer un
docker-compose et recréer le conteneur pour y répondre.

- Éditeur de preset → Modèle → « Jeton Hugging Face » : ligne repliée comme
  « Dossiers de modèles », qui affiche l'état (aucun / masqué / fourni par
  l'environnement). Le bandeau d'un dépôt verrouillé y mène d'un clic.
- Le jeton est VÉRIFIÉ auprès de /api/whoami-v2 avant d'être enregistré (le
  compte s'affiche) : un jeton mal collé accepté en silence rendrait le 401
  qu'on cherchait à expliquer. Il est rangé avec les secrets en base d'état, pas
  dans config.env que le changement de preset réécrit en bloc, et n'est jamais
  renvoyé en clair — seulement masqué.
- Priorité : jeton enregistré, puis HF_TOKEN. Rien d'enregistré = comportement
  d'avant à l'identique. L'enregistrement vide le cache des réponses obtenues
  sans jeton.
- Le jeton n'est envoyé qu'aux adresses Hugging Face : un lien collé vers un
  autre hébergeur n'a aucune raison de recevoir un secret.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Hz1QWmvZqW3t53YC5SJBKC
2026-08-20 10:32:44 +00:00

509 lines
17 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"`
// Gated : le dépôt exige d'avoir accepté ses conditions ET un jeton. Il se
// LIT pourtant sans rien (arborescence en 200), et ne refuse qu'au moment du
// transfert : sans cette pastille, l'interface propose des quants avec leur
// verdict mémoire et le téléchargement échoue en 401 sans prévenir.
Gated bool `json:"gated"`
}
// 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"`
Gated bool `json:"gated"`
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 := hfToken(); k != "" {
req.Header.Set("Authorization", "Bearer "+k)
}
req.Header.Set("User-Agent", "loki/"+Version)
req.Header.Set("Accept", "application/json")
}
// ---- Jeton Hugging Face -----------------------------------------------------
//
// Le jeton vivait UNIQUEMENT dans la variable d'environnement HF_TOKEN. En
// conteneur, c'était l'exiger au mauvais moment : on découvre qu'un dépôt est
// verrouillé depuis l'interface, et il faudrait éditer un docker-compose puis
// recréer le conteneur pour y répondre. Il se règle donc aussi dans l'interface,
// et se range là où sont déjà les secrets (base d'état) — pas dans config.env,
// que le changement de preset réécrit en bloc.
//
// Priorité : le jeton enregistré d'abord, la variable d'environnement ensuite.
// Rien d'enregistré = comportement d'avant, à l'octet près.
// hfToken renvoie le jeton effectivement utilisé, ou "".
func hfToken() string {
if t := strings.TrimSpace(getStr(bkState, "hf_token")); t != "" {
return t
}
return strings.TrimSpace(os.Getenv("HF_TOKEN"))
}
// hfTokenSource dit D'OÙ vient le jeton : « config » (enregistré ici), « env »
// (HF_TOKEN du conteneur) ou "" (aucun). L'interface en a besoin pour ne pas
// proposer d'effacer un jeton qu'elle ne peut pas effacer.
func hfTokenSource() string {
switch {
case strings.TrimSpace(getStr(bkState, "hf_token")) != "":
return "config"
case strings.TrimSpace(os.Getenv("HF_TOKEN")) != "":
return "env"
}
return ""
}
// hfTokenSet dit si un jeton est disponible, pour que l'interface distingue
// « il faut en poser un » de « celui qui est posé ne suffit pas ».
func hfTokenSet() bool { return hfToken() != "" }
// writeHFToken enregistre (ou efface, tok == "") le jeton. Le cache des réponses
// Hugging Face est vidé au passage : il contient des listes obtenues SANS jeton,
// où un dépôt verrouillé peut manquer ou paraître illisible.
func writeHFToken(tok string) error {
if err := putStr(bkState, "hf_token", strings.TrimSpace(tok)); err != nil {
return err
}
hfCacheMu.Lock()
hfCache = map[string]hfCacheItem{}
hfCacheMu.Unlock()
return nil
}
// maskHFToken n'affiche qu'assez de caractères pour reconnaître le jeton posé.
// Il n'est jamais renvoyé en clair : contrairement à la clé API du serveur,
// personne n'a besoin de le recopier depuis Loki.
func maskHFToken(t string) string {
t = strings.TrimSpace(t)
if t == "" {
return ""
}
if len(t) <= 8 {
return "…" + t[len(t)-2:]
}
return t[:3] + "…" + t[len(t)-4:]
}
// hfWhoAmI vérifie un jeton auprès de Hugging Face et renvoie le nom du compte.
// Enregistrer un jeton sans le vérifier, c'est déplacer l'échec : on croirait le
// problème réglé jusqu'au prochain 401, quinze minutes de téléchargement plus
// tard.
func hfWhoAmI(ctx context.Context, tok string) (string, error) {
ctx, cancel := context.WithTimeout(ctx, hfTimeout)
defer cancel()
req, err := http.NewRequestWithContext(ctx, "GET", hfHost+"/api/whoami-v2", nil)
if err != nil {
return "", err
}
req.Header.Set("Authorization", "Bearer "+strings.TrimSpace(tok))
req.Header.Set("User-Agent", "loki/"+Version)
req.Header.Set("Accept", "application/json")
resp, err := http.DefaultClient.Do(req)
if err != nil {
return "", fmt.Errorf("Hugging Face injoignable : %v", err)
}
defer resp.Body.Close()
if resp.StatusCode == 401 || resp.StatusCode == 403 {
return "", fmt.Errorf("jeton refusé par Hugging Face (expiré, révoqué ou mal copié)")
}
if resp.StatusCode != 200 {
return "", fmt.Errorf("Hugging Face a répondu %d", resp.StatusCode)
}
var who struct {
Name string `json:"name"`
}
raw, err := io.ReadAll(io.LimitReader(resp.Body, 1<<20))
if err != nil {
return "", err
}
if err := json.Unmarshal(raw, &who); err != nil {
return "", err
}
return who.Name, nil
}
// 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]
}
// `expand[]` remplace les champs par défaut de la réponse : `gated` s'y
// ajoute, mais downloads et likes doivent alors être redemandés
// explicitement, sinon ils disparaissent et la liste perd son classement
// lisible.
endpoint := hfHost + "/api/models?" + url.Values{
"search": {q},
"filter": {"gguf"},
"limit": {fmt.Sprint(hfMaxRepos)},
"sort": {"downloads"},
"direction": {"-1"},
"expand[]": {"gated", "downloads", "likes"},
}.Encode()
var raw []struct {
ID string `json:"id"`
Downloads int `json:"downloads"`
Likes int `json:"likes"`
Gated any `json:"gated"`
}
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, Gated: hfGatedFlag(r.Gated)})
}
return out, nil
}
// hfGatedFlag lit le champ `gated` de l'API, qui n'est PAS un booléen : il vaut
// false, "auto" (accepter les conditions suffit) ou "manual" (l'auteur valide
// chaque demande). Les deux dernières valeurs verrouillent le téléchargement de
// la même façon ; seule la présence d'un verrou nous intéresse ici.
func hfGatedFlag(v any) bool {
switch g := v.(type) {
case bool:
return g
case string:
switch strings.ToLower(strings.TrimSpace(g)) {
case "", "false", "none", "no":
return false
}
return true
}
return false
}
// hfRepoGated dit si un dépôt est verrouillé. L'arborescence (hfFiles) ne porte
// pas l'information : elle se lit sur la fiche du dépôt, en un appel séparé et
// mis en cache comme elle. Une erreur ici ne doit RIEN casser — au pire on
// n'affiche pas l'avertissement, et le téléchargement dira lui-même pourquoi il
// a été refusé.
func hfRepoGated(ctx context.Context, repo string) bool {
var info struct {
Gated any `json:"gated"`
}
endpoint := hfHost + "/api/models/" + repo + "?" + url.Values{"expand[]": {"gated"}}.Encode()
if err := hfGetJSON(ctx, endpoint, hfFilesTTL, &info); err != nil {
return false
}
return hfGatedFlag(info.Gated)
}
// isHFHost dit si un hôte appartient à Hugging Face. Sert à décider qui a le
// droit de recevoir le jeton.
func isHFHost(host string) bool {
h := strings.ToLower(host)
if i := strings.IndexByte(h, ':'); i >= 0 {
h = h[:i]
}
return h == "huggingface.co" || strings.HasSuffix(h, ".huggingface.co") ||
h == "hf.co" || strings.HasSuffix(h, ".hf.co")
}
// hfRepoFromURL retrouve « auteur/dépôt » dans un lien de téléchargement
// Hugging Face, pour pouvoir le nommer dans un message d'erreur. Renvoie "" si
// le lien pointe ailleurs.
func hfRepoFromURL(raw string) string {
u, err := url.Parse(raw)
if err != nil || !strings.Contains(u.Host, "huggingface.co") {
return ""
}
segs := strings.Split(strings.Trim(u.Path, "/"), "/")
if len(segs) < 4 || (segs[2] != "resolve" && segs[2] != "blob") {
return ""
}
repo := segs[0] + "/" + segs[1]
if !hfRepoRe.MatchString(repo) {
return ""
}
return repo
}
// 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})
}
out := hfClassify(repo, files)
out.Gated = hfRepoGated(ctx, repo)
return out, 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
}