Files
Loki/internal/loki/backend_models.go
T
Claude 9bdaf00ca4 Modèles : un .gguf gardé se réutilise et s'efface
Un modèle téléchargé depuis l'éditeur d'un preset survit à la suppression
de ce preset (c'est voulu : on le réutilise ailleurs). Il devenait pourtant
inatteignable — impossible de refaire un preset dessus, impossible de
l'effacer.

Trois causes, trois correctifs.

1. « Le modèle existe déjà » n'est plus une erreur. La recherche Hugging
   Face proposait le quant, le clic lançait la sonde, et le téléchargement
   répondait en rouge « le modèle existe déjà » — fin du parcours. La sonde
   constate maintenant la présence du fichier (sans même sortir sur le
   réseau) et renvoie la valeur à écrire dans MODEL= : l'interface le
   SÉLECTIONNE, et la file continue (le projecteur vision, par exemple).
   Les quants déjà présents portent une pastille « déjà installé » dans la
   liste du dépôt, avant le clic.

2. Le sélecteur de modèle reconnaissait mal ce qu'il avait sous les yeux.
   /api/models comparait le dossier de chaque .gguf à LokiHome() alors que
   les téléchargements atterrissent dans $LOKI_HOME/models : la comparaison
   ne pouvait jamais être vraie. Conséquences : l'étiquette « dossier loki »
   ne s'affichait nulle part, et un preset écrit MODEL=modele.gguf
   s'affichait « introuvable ; ajoute son dossier ci-dessous » — le fichier
   étant juste à côté. Un nom simple est désormais résolu comme le fait le
   moteur : dossier de loki d'abord, puis les autres.

3. Une liste « Modèles installés », dans le groupe Modèle de l'éditeur.
   La route /api/models/delete existait depuis longtemps ; aucun bouton ne
   l'appelait. Le seul moment où un .gguf pouvait disparaître, c'était en
   cochant « supprimer aussi le fichier » à la suppression de son preset.
   La liste montre taille, dossier, tranches manquantes, et qui s'en sert :
   le modèle en service ne s'efface pas (le moteur l'a ouvert, la place ne
   serait même pas rendue), celui que des presets nomment prévient en les
   nommant puis obéit. La suppression dit ce qu'elle a libéré.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0129sffVC43rezAXUMQuzUog
2026-09-12 20:20:33 +00:00

1012 lines
34 KiB
Go

package loki
import (
"context"
"encoding/json"
"fmt"
"io"
"net/http"
"net/url"
"os"
"path"
"path/filepath"
"regexp"
"strconv"
"strings"
"sync"
"sync/atomic"
"time"
)
// quantSegRe matches a single name segment that looks like a GGUF quantization
// token: Q8_0, Q6_K, Q5_K_M, Q4_K_XL, IQ4_XS, IQ3_XXS, Q4, 4bpw, BF16, F16…
var quantSegRe = regexp.MustCompile(`(?i)^(I?Q\d+(_[A-Za-z0-9]+)*|\d+BPW|BF16|FP16|F16|FP32|F32)$`)
// quantFromName extracts a quantization tag from a model filename by splitting
// on '-' and '.' and keeping the longest segment that looks like a quant token.
// Returns "" when nothing matches.
func quantFromName(name string) string {
base := name
if dot := strings.LastIndexByte(base, '.'); dot >= 0 && strings.EqualFold(base[dot:], ".gguf") {
base = base[:dot]
}
segs := strings.FieldsFunc(base, func(r rune) bool { return r == '-' || r == '.' })
best := ""
for _, seg := range segs {
if quantSegRe.MatchString(seg) && len(seg) > len(best) {
best = seg
}
}
return strings.ToUpper(best)
}
// presetReasoning returns the raw REASONING= value from a preset's config.env
// body, or "" if absent.
func presetReasoning(content string) string {
for _, line := range strings.Split(content, "\n") {
s := strings.TrimSpace(line)
if s == "" || strings.HasPrefix(s, "#") {
continue
}
i := strings.IndexByte(s, '=')
if i < 0 {
continue
}
if strings.EqualFold(strings.TrimSpace(s[:i]), "REASONING") {
return unquoteValue(strings.TrimSpace(s[i+1:]))
}
}
return ""
}
// reasoningActive reports whether a REASONING= value enables reasoning. backend_serve.go
// passes the flag whenever the value is non-empty, but an explicit off/none is
// treated here as disabled so the UI badge isn't misleading.
func reasoningActive(v string) bool {
switch strings.ToLower(strings.TrimSpace(v)) {
case "", "off", "none", "false", "0", "no", "disable", "disabled":
return false
}
return true
}
// ReasoningEfforts liste les valeurs proposées par l'UI pour REASONING_EFFORT.
// "" (auto) n'y figure pas : c'est l'absence de réglage.
//
// Aucun gabarit ne les connaît toutes — gpt-oss lit low/medium/high, Qwen3.8 lit
// low/medium/xhigh — et certains REFUSENT celles qu'ils ne connaissent pas (voir
// llm_effort.go, qui traduit alors vers le niveau accepté le plus proche). La
// liste reste donc l'union des niveaux courants plutôt que le plus petit
// dénominateur commun, sinon le maximum d'un modèle est hors d'atteinte.
var ReasoningEfforts = []string{"none", "low", "medium", "high", "xhigh"}
// reasoningEffortValue normalise REASONING_EFFORT en une valeur envoyable telle
// quelle à llama-server, ou "" pour ne rien envoyer du tout.
//
// llama-server accepte `reasoning_effort` dans /v1/chat/completions : la valeur
// `none` coupe le raisonnement, toute autre valeur est simplement passée au
// gabarit jinja du modèle. Un gabarit qui ne la lit pas l'ignore sans erreur ;
// un gabarit qui la VALIDE, lui, peut lever une exception jinja et faire
// répondre 500 à llama-server (Qwen3.8 sur « high »). Le repli n'est pas ici —
// on ne sait pas d'avance ce que le gabarit accepte — mais dans llm_effort.go,
// qui apprend la réponse du refus lui-même et rejoue le tour.
func reasoningEffortValue(v string) string {
switch s := strings.ToLower(strings.TrimSpace(v)); s {
case "", "auto", "default":
return "" // rien envoyé : le gabarit garde son comportement
case "off", "false", "0", "no", "disable", "disabled", "none":
return "none"
case "low", "medium", "high":
return s
default:
// Valeur inconnue (éditée à la main) : on ne l'invente pas, on la laisse
// passer au gabarit, qui l'ignorera s'il ne la connaît pas.
return s
}
}
// reasoningTemplateKwargs construit les `chat_template_kwargs` à joindre à une
// requête de chat pour que « aucune » coupe VRAIMENT le raisonnement.
//
// Pourquoi ce doublon avec `reasoning_effort` : ce champ n'agit que sur les
// gabarits qui le LISENT (gpt-oss et apparentés). Un modèle hybride à la Qwen3
// ne connaît que `enable_thinking` — on lui envoie `reasoning_effort: none`, son
// gabarit l'ignore, et il continue de réfléchir alors que l'interface affiche
// « aucune ». Les llama-server récents traduisent eux-mêmes `none` en
// `enable_thinking=false`, mais pas les binaires plus anciens, qui laissent
// simplement tomber le champ.
//
// D'où la règle : quand le raisonnement doit être coupé, on le dit dans la
// langue que TOUS les gabarits comprennent. `chat_template_kwargs` est passé tel
// quel au gabarit jinja ; une clé qu'il n'utilise pas est ignorée sans erreur.
// Même procédé que la compaction (chat_compact.go), qui coupe déjà la réflexion
// de cette façon.
//
// off = REASONING explicitement à off. Clé absente ≠ off : sans consigne on ne
// touche à rien, le gabarit garde son défaut.
// Renvoie nil quand il n'y a rien à imposer.
func reasoningTemplateKwargs(off bool, effort string) map[string]any {
if off || effort == "none" {
return map[string]any{"enable_thinking": false}
}
if effort != "" {
// Niveau explicite : les vieux binaires ne relaient pas le champ de haut
// niveau au gabarit, celui-ci passe toujours.
return map[string]any{"reasoning_effort": effort}
}
return nil
}
// reasoningExplicitlyOff dit si REASONING interdit le raisonnement. Une clé
// absente ou vide n'est PAS une interdiction : c'est l'absence de consigne.
func reasoningExplicitlyOff(v string) bool {
return strings.TrimSpace(v) != "" && !reasoningActive(v)
}
// detectQuant returns the quantization tag for a preset: an explicit QUANT= line
// (manual override, with or without a leading '#') wins; otherwise it is
// auto-detected from the MODEL= filename. Returns "" when unknown.
func detectQuant(content string) string {
for _, line := range strings.Split(content, "\n") {
s := strings.TrimSpace(strings.TrimPrefix(strings.TrimSpace(line), "#"))
i := strings.IndexByte(s, '=')
if i >= 0 && strings.EqualFold(strings.TrimSpace(s[:i]), "QUANT") {
if v := unquoteValue(strings.TrimSpace(s[i+1:])); v != "" {
return strings.ToUpper(v)
}
}
}
return quantFromName(baseName(modelFromPresetContent(content)))
}
// downloadDestPath resolves the destination of a downloaded model : le dossier
// demandé (vide = LOKI_HOME) parmi les dossiers de modèles déclarés, en
// refusant tout ce qui en sortirait (path traversal).
func downloadDestPath(name, dir string) (string, error) {
base := filepath.Base(strings.TrimSpace(name))
if base == "" || base == "." || base == string(filepath.Separator) {
return "", fmt.Errorf("nom de modèle invalide")
}
if !strings.HasSuffix(strings.ToLower(base), ".gguf") {
return "", fmt.Errorf("seuls les fichiers .gguf sont acceptés")
}
d, err := resolveDownloadDir(dir)
if err != nil {
return "", err
}
return filepath.Join(d, base), nil
}
// modelFromPresetContent extracts the MODEL= value from a preset's config.env
// body (nom de fichier ou chemin absolu, tel quel), or "" if absent.
func modelFromPresetContent(content string) string {
for _, line := range strings.Split(content, "\n") {
s := strings.TrimSpace(line)
if s == "" || strings.HasPrefix(s, "#") {
continue
}
i := strings.IndexByte(s, '=')
if i < 0 {
continue
}
if strings.TrimSpace(s[:i]) == "MODEL" {
return unquoteValue(strings.TrimSpace(s[i+1:]))
}
}
return ""
}
// deleteModelFile removes a .gguf file from one of the declared model folders
// after validating the name. Un modèle découpé emporte TOUTES ses tranches :
// n'effacer que la première laissait des dizaines de Go de fichiers que plus
// rien ne référence, et que rien ne sait plus supprimer depuis l'interface (les
// tranches suivantes n'y apparaissent pas).
//
// Renvoie le nombre d'octets réellement libérés — l'interface le dit, et
// « 41,2 Go libérés » est la seule confirmation qui vaille quelque chose après
// un ménage de disque.
//
// Un modèle EN SERVICE n'est jamais effacé : le fichier reste mappé par
// llama-server (la place n'est donc même pas rendue) et le prochain démarrage
// meurt sur un .gguf absent, sans que rien n'ait prévenu.
func deleteModelFile(name string) (int64, error) {
p, err := resolveModelPath(name)
if err != nil {
return 0, err
}
if _, active := modelUsers(name); active {
return 0, fmt.Errorf("%s", modelInServiceMsg)
}
freed := modelFamilySize(p)
if err := os.Remove(p); err != nil {
if os.IsNotExist(err) {
return 0, fmt.Errorf("modèle introuvable: %s", filepath.Base(p))
}
return 0, err
}
dir := filepath.Dir(p)
for _, n := range shardFamily(filepath.Base(p)) {
_ = os.Remove(filepath.Join(dir, n)) // déjà supprimée ou absente = rien à faire
}
return freed, nil
}
// handleModelDelete efface un .gguf d'un des dossiers de modèles déclarés.
//
// Deux garde-fous, et un seul est un refus définitif :
//
// - modèle en service → refus net (deleteModelFile), il faut basculer d'abord ;
// - modèle nommé par un ou plusieurs presets → 409 avec la LISTE des presets
// concernés. L'interface la montre, l'utilisateur tranche, et renvoie la
// même requête avec force:true. Refuser tout court transformerait le
// nettoyage en impasse — c'est exactement le travers qu'on corrige ici.
func handleModelDelete(w http.ResponseWriter, r *http.Request) {
var req struct {
Name string `json:"name"`
Force bool `json:"force"`
}
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
sendJSON(w, 400, map[string]any{"ok": false, "error": err.Error()})
return
}
users, active := modelUsers(req.Name)
if active {
sendJSON(w, 409, map[string]any{"ok": false, "error": modelInServiceMsg, "active": true})
return
}
if len(users) > 0 && !req.Force {
sendJSON(w, 409, map[string]any{"ok": false, "used": users,
"error": "modèle utilisé par : " + strings.Join(users, ", ")})
return
}
freed, err := deleteModelFile(req.Name)
if err != nil {
sendJSON(w, 400, map[string]any{"ok": false, "error": err.Error()})
return
}
sendJSON(w, 200, map[string]any{"ok": true, "freed": freed, "used": users})
}
// ---- Hugging Face downloads -------------------------------------------------
// dlState tracks a single in-flight (or finished) model download.
type dlState struct {
Filename string `json:"filename"`
URL string `json:"url"`
Dir string `json:"dir"` // dossier de destination
Total int64 `json:"total"`
Done int64 `json:"done"`
Speed int64 `json:"speed"` // bytes/s, smoothed over the last samples
Conns int `json:"conns"` // parallel connections actually used
Parts int `json:"parts"` // nombre de tranches (1 pour un modèle en un seul fichier)
Part int `json:"part"` // tranche en cours (1-based)
Finished bool `json:"finished"`
Canceled bool `json:"canceled"`
Err string `json:"error"`
StartedAt int64 `json:"started_at"`
cancel context.CancelFunc `json:"-"` // set while in flight, cleared on finish
}
var (
dlMu sync.Mutex
dlDownloads = map[string]*dlState{} // keyed by filename
)
// dlClient is shared by all download workers so connections to the HF CDN are
// pooled and reused across chunks instead of re-handshaking TLS each time.
var dlClient = &http.Client{
Timeout: 0, // large files: no overall timeout
Transport: &http.Transport{
Proxy: http.ProxyFromEnvironment,
MaxIdleConns: 64,
MaxIdleConnsPerHost: 64,
MaxConnsPerHost: 0,
IdleConnTimeout: 90 * time.Second,
TLSHandshakeTimeout: 20 * time.Second,
ExpectContinueTimeout: 1 * time.Second,
// HTTP/1.1: truly parallel sockets, no shared h2 flow-control window.
ForceAttemptHTTP2: false,
WriteBufferSize: 64 << 10,
ReadBufferSize: 256 << 10,
},
}
// dlConns is the number of parallel range requests used per download.
// Overridable with LOKI_DL_CONNS (1 disables parallelism).
func dlConns() int {
n := 8
if v := os.Getenv("LOKI_DL_CONNS"); v != "" {
if p, err := strconv.Atoi(v); err == nil && p > 0 {
n = p
}
}
if n > 16 {
n = 16
}
return n
}
// dlMinChunk is the smallest slice worth a dedicated connection (16 MiB), so a
// small file doesn't get split into a swarm of tiny requests.
const dlMinChunk = 16 << 20
// normalizeHFURL turns a Hugging Face "blob" page URL into a direct "resolve"
// download URL, and leaves already-direct URLs untouched. Returns the URL to
// fetch and the target filename.
func normalizeHFURL(raw string) (string, string, error) {
raw = strings.TrimSpace(raw)
if raw == "" {
return "", "", fmt.Errorf("lien vide")
}
u, err := url.Parse(raw)
if err != nil {
return "", "", fmt.Errorf("lien invalide: %v", err)
}
if u.Scheme != "http" && u.Scheme != "https" {
return "", "", fmt.Errorf("lien invalide (http/https attendu)")
}
// huggingface.co/<repo>/blob/<rev>/<file> → /resolve/<rev>/<file>
if strings.Contains(u.Host, "huggingface.co") {
u.Path = strings.Replace(u.Path, "/blob/", "/resolve/", 1)
}
name := path.Base(u.Path)
if name == "" || name == "/" || name == "." {
return "", "", fmt.Errorf("impossible de déduire le nom du fichier depuis le lien")
}
if !strings.HasSuffix(strings.ToLower(name), ".gguf") {
return "", "", fmt.Errorf("le lien doit pointer vers un fichier .gguf")
}
return u.String(), name, nil
}
// handleModelDownload kicks off a background download of a .gguf from a URL
// (typically Hugging Face) into LOKI_HOME. Progress is polled via
// /api/models/download/status.
func handleModelDownload(w http.ResponseWriter, r *http.Request) {
var req struct {
URL string `json:"url"`
Dir string `json:"dir"`
}
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
sendJSON(w, 400, map[string]any{"ok": false, "error": err.Error()})
return
}
dlURL, name, err := normalizeHFURL(req.URL)
if err != nil {
sendJSON(w, 400, map[string]any{"ok": false, "error": err.Error()})
return
}
// Modèle découpé : le lien ne désigne qu'une tranche, on rapatrie la famille.
// Le modèle porte le nom de sa PREMIÈRE tranche — c'est elle qu'on passe à
// llama-server, et c'est donc elle qui identifie le téléchargement.
urls, names := shardURLSet(dlURL, name)
name = names[0]
dests := make([]string, len(names))
for i, n := range names {
if dests[i], err = downloadDestPath(n, req.Dir); err != nil {
sendJSON(w, 400, map[string]any{"ok": false, "error": err.Error()})
return
}
}
if err := os.MkdirAll(filepath.Dir(dests[0]), 0o755); err != nil {
sendJSON(w, 400, map[string]any{"ok": false, "error": "dossier de destination inaccessible : " + err.Error()})
return
}
// Tranches déjà là : on ne les retélécharge pas. C'est aussi ce qui permet de
// relancer un téléchargement en plusieurs fichiers interrompu à la deuxième
// tranche sans repayer les 15 Go de la première.
var todoURLs, todoDests []string
for i := range names {
if st, err := os.Stat(dests[i]); err == nil && !st.IsDir() {
continue
}
todoURLs = append(todoURLs, urls[i])
todoDests = append(todoDests, dests[i])
}
dlMu.Lock()
if st, ok := dlDownloads[name]; ok && !st.Finished {
dlMu.Unlock()
sendJSON(w, 409, map[string]any{"ok": false, "error": "téléchargement déjà en cours pour " + name})
return
}
if len(todoURLs) == 0 {
dlMu.Unlock()
// Rien à faire : toutes les tranches sont là. Ce n'était pas une erreur,
// c'était le résultat voulu — le modèle est installé. On répond ok avec la
// valeur à écrire dans MODEL=, l'interface le sélectionne et enchaîne la
// file (le projecteur vision, par exemple) au lieu de s'arrêter en rouge.
value, _, _ := modelPresence(name, req.Dir)
if value == "" {
value = modelPickerValue(filepath.Dir(dests[0]), name)
}
sendJSON(w, 200, map[string]any{"ok": true, "filename": name, "exists": true,
"value": value, "parts": 0})
return
}
ctx, cancel := context.WithCancel(context.Background())
st := &dlState{
Filename: name, URL: urls[0], Dir: filepath.Dir(dests[0]),
Parts: len(todoURLs), StartedAt: time.Now().Unix(), cancel: cancel,
}
dlDownloads[name] = st
dlMu.Unlock()
go runDownloadSet(ctx, st, todoURLs, todoDests)
sendJSON(w, 200, map[string]any{"ok": true, "filename": name, "parts": len(todoURLs)})
}
// dlSpaceMargin est la marge laissée libre après le téléchargement : un disque
// rempli à ras bord met en danger tout le reste (logs, .part d'un autre
// modèle, swap).
const dlSpaceMargin = 256 << 20
// checkDiskSpace refuse le téléchargement si le fichier ne tient pas dans le
// dossier visé. free < 0 = mesure impossible : on laisse passer plutôt que de
// bloquer sur un système de fichiers exotique.
func checkDiskSpace(dir string, size int64) error {
free := diskFree(dir)
if size <= 0 || free < 0 {
return nil
}
// Système de fichiers dont statfs approxime (FUSE/shfs d'Unraid, overlay,
// réseau) : le chiffre reste affiché, mais il ne peut plus REFUSER un
// téléchargement. Vécu : « 11,9 Go libres » annoncés sur un partage Unraid
// qui en avait des centaines, et l'installation du modèle bloquée.
if !diskFreeReliable(dir) {
return nil
}
if free < size+dlSpaceMargin {
return fmt.Errorf("espace insuffisant sur %s : %s libres, %s nécessaires", dir, humanBytes(free), humanBytes(size+dlSpaceMargin))
}
return nil
}
// humanBytes formate une taille en Go/Mo pour les messages d'erreur.
func humanBytes(n int64) string {
switch {
case n >= 1<<30:
return fmt.Sprintf("%.1f Go", float64(n)/float64(1<<30))
case n >= 1<<20:
return fmt.Sprintf("%.0f Mo", float64(n)/float64(1<<20))
default:
return fmt.Sprintf("%d o", n)
}
}
// handleModelDownloadProbe renseigne l'UI avant de lancer quoi que ce soit :
// taille du fichier distant, espace libre du dossier visé, et si ça tient. Une
// seule requête d'un octet côté CDN, donc c'est gratuit.
func handleModelDownloadProbe(w http.ResponseWriter, r *http.Request) {
var req struct {
URL string `json:"url"`
Dir string `json:"dir"`
}
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
sendJSON(w, 400, map[string]any{"ok": false, "error": err.Error()})
return
}
dlURL, name, err := normalizeHFURL(req.URL)
if err != nil {
sendJSON(w, 400, map[string]any{"ok": false, "error": err.Error()})
return
}
dir, err := resolveDownloadDir(req.Dir)
if err != nil {
sendJSON(w, 400, map[string]any{"ok": false, "error": err.Error()})
return
}
// Un modèle découpé se sonde EN ENTIER : annoncer les 15 Go de la première
// tranche alors qu'il en faut 45 sur le disque, c'est promettre que ça tient
// puis échouer au deux tiers du transfert.
urls, names := shardURLSet(dlURL, name)
name = names[0]
// Déjà sur le disque : on ne sonde même pas Hugging Face. C'est le cas qui
// mettait l'interface dans une impasse — un modèle gardé après la suppression
// de son preset ne pouvait plus être re-choisi qu'en le retéléchargeant, et
// le téléchargement refusait d'écraser. Il n'y a rien à télécharger : il y a
// un modèle à SÉLECTIONNER, et l'UI a besoin de sa valeur MODEL= pour ça.
// Cherché dans TOUS les dossiers déclarés, pas seulement la destination
// choisie : re-télécharger 40 Go sur le SSD parce que le fichier est sur le
// disque externe déjà déclaré n'aide personne — le sélecteur, lui, le trouve
// où qu'il soit.
if value, path, ok := modelPresence(name, ""); ok {
sendJSON(w, 200, map[string]any{"ok": true, "filename": name, "dir": filepath.Dir(path),
"exists": true, "value": value, "size": modelFamilySize(path),
"free": diskFree(dir), "free_exact": diskFreeReliable(dir), "enough": true, "parts": len(urls)})
return
}
ctx, cancel := context.WithTimeout(r.Context(), 30*time.Second)
defer cancel()
var total int64
for _, u := range urls {
n, _, err := dlProbe(ctx, u)
if err != nil {
sendJSON(w, 400, map[string]any{"ok": false, "error": err.Error()})
return
}
total += n
}
out := map[string]any{"ok": true, "filename": name, "dir": dir, "size": total,
"free": diskFree(dir), "free_exact": diskFreeReliable(dir), "enough": true, "parts": len(urls)}
if err := checkDiskSpace(dir, total); err != nil {
out["enough"] = false
out["error"] = err.Error()
}
sendJSON(w, 200, out)
}
// dlRequest builds a GET for the download URL, carrying the HF token when set
// (gated/private repos) and an optional Range header.
func dlRequest(ctx context.Context, dlURL, rng string) (*http.Request, error) {
req, err := http.NewRequestWithContext(ctx, "GET", dlURL, nil)
if err != nil {
return nil, err
}
// Dépôt gated/privé : le jeton (réglage de l'interface, sinon HF_TOKEN) est
// ce qui débloque le transfert. Il n'accompagne QUE les liens Hugging Face :
// un lien collé vers un autre hébergeur n'a aucune raison de recevoir un
// secret, et le CDN vers lequel HF redirige est signé, donc n'en a pas besoin
// non plus (Go retire l'en-tête au changement de domaine).
if k := hfToken(); k != "" && isHFHost(req.URL.Host) {
req.Header.Set("Authorization", "Bearer "+k)
}
req.Header.Set("User-Agent", "loki/"+Version)
req.Header.Set("Accept-Encoding", "identity") // never gzip a .gguf: it breaks ranges
if rng != "" {
req.Header.Set("Range", rng)
}
return req, nil
}
// contentRangeTotal parses the total size out of a "bytes 0-0/12345" header.
func contentRangeTotal(v string) int64 {
i := strings.LastIndexByte(v, '/')
if i < 0 {
return 0
}
n, err := strconv.ParseInt(strings.TrimSpace(v[i+1:]), 10, 64)
if err != nil || n <= 0 {
return 0
}
return n
}
// dlSourceError traduit un refus HTTP en phrase qui dit quoi faire.
//
// Vécu : un dépôt Hugging Face « gated » (conditions à accepter avant de
// télécharger) répond 200 sur son arborescence — Loki liste donc tous ses
// quants, avec leur verdict mémoire — puis 401 sur CHAQUE .gguf. « HTTP 401
// depuis la source » ne dit alors ni que le dépôt est verrouillé, ni qu'il faut
// un jeton, ni où l'accepter : l'utilisateur voit un modèle proposé comme
// installable qui échoue sans raison.
//
// Hugging Face, lui, le dit — dans l'en-tête X-Error-Code (GatedRepo,
// RepoNotFound, EntryNotFound…). On le traduit plutôt que de le recopier :
// l'interface est en français, et le message d'origine (« Please log in »)
// parle d'une session de navigateur qui n'existe pas ici.
func dlSourceError(resp *http.Response, dlURL string) error {
code := resp.Header.Get("X-Error-Code")
switch resp.StatusCode {
case 401, 403:
return fmt.Errorf("%s%s", dlAccessReason(code, hfRepoFromURL(dlURL)), dlTokenHint())
case 404:
switch code {
case "EntryNotFound":
// Nommer le fichier : sur un modèle en fragments, c'est souvent UN
// fragment qui manque — certains dépôts n'en publient que six sur
// sept et livrent la table PLE à part, à lier soi-même — et sans
// le nom, on cherche une révision réécrite qui n'existe pas.
return fmt.Errorf("fichier %s absent du dépôt (HTTP 404) — dépôt réorganisé depuis la copie du lien, ou fragment jamais publié (table PLE livrée à part ?)", dlFileLabel(dlURL))
case "RevisionNotFound":
return fmt.Errorf("révision introuvable dans le dépôt (HTTP 404)")
}
return fmt.Errorf("lien introuvable (HTTP 404)")
case 416:
return fmt.Errorf("la source refuse la plage d'octets demandée (HTTP 416) — fichier modifié pendant le transfert ?")
case 429:
return fmt.Errorf("trop de requêtes vers la source (HTTP 429) — réessaie dans quelques minutes")
}
if resp.StatusCode >= 500 {
return fmt.Errorf("la source est en panne (HTTP %d) — réessaie plus tard", resp.StatusCode)
}
return fmt.Errorf("HTTP %d depuis la source", resp.StatusCode)
}
// dlFileLabel : « nom.gguf » entre guillemets pour un message, ou vide si le
// lien n'a pas de nom de fichier lisible.
func dlFileLabel(dlURL string) string {
u, err := url.Parse(dlURL)
if err != nil {
return ""
}
name := path.Base(u.Path)
if name == "" || name == "/" || name == "." {
return ""
}
return "« " + name + " »"
}
// dlAccessReason nomme la raison du refus. repo vide = source hors Hugging Face
// (lien direct vers un autre hébergeur) : on ne parle alors pas de conditions à
// accepter, qui n'existent que là-bas.
func dlAccessReason(code, repo string) string {
switch code {
case "GatedRepo":
if repo == "" {
return "dépôt à accès restreint : ses conditions doivent être acceptées sur huggingface.co"
}
return "dépôt à accès restreint : accepte ses conditions sur huggingface.co/" + repo
case "RepoNotFound":
if repo == "" {
return "dépôt privé ou inexistant"
}
return "dépôt privé ou inexistant : " + repo
}
if repo != "" {
return "accès refusé par Hugging Face sur " + repo
}
return "accès refusé par la source"
}
// dlTokenHint complète la raison par l'état du jeton. Deux situations opposées
// se cachent derrière le même 401 : pas de jeton du tout, ou un jeton qui n'a
// pas accès à CE dépôt — et le geste à faire n'est pas le même.
func dlTokenHint() string {
if !hfTokenSet() {
return " — puis renseigne le jeton Hugging Face (réglage « Jeton Hugging Face », ou variable d'environnement HF_TOKEN)"
}
return " — le jeton Hugging Face enregistré n'y donne pas accès (expiré, ou conditions non acceptées avec ce compte)"
}
// dlProbe asks the server for the first byte to learn the total size and
// whether ranges are supported (206 + Content-Range).
func dlProbe(ctx context.Context, dlURL string) (total int64, ranged bool, err error) {
req, err := dlRequest(ctx, dlURL, "bytes=0-0")
if err != nil {
return 0, false, err
}
resp, err := dlClient.Do(req)
if err != nil {
return 0, false, err
}
defer resp.Body.Close()
_, _ = io.Copy(io.Discard, resp.Body)
switch resp.StatusCode {
case 206:
if t := contentRangeTotal(resp.Header.Get("Content-Range")); t > 0 {
return t, true, nil
}
return 0, false, nil
case 200:
// Server ignored the Range: single stream, ContentLength is the size.
return resp.ContentLength, false, nil
default:
return 0, false, dlSourceError(resp, dlURL)
}
}
// runDownloadSet fetches one model, qui peut tenir en plusieurs fichiers (un
// GGUF découpé en tranches). Les tranches se suivent SÉQUENTIELLEMENT — chacune
// sature déjà le lien à elle seule grâce aux connexions parallèles, les mener de
// front ne ferait que multiplier les fichiers à jeter en cas d'annulation.
// La progression publiée (Done / Total) couvre l'ENSEMBLE : une barre unique du
// début à la fin, pas trois barres qui repartent de zéro.
//
// Annulation ou échec : toutes les tranches déjà écrites par CE téléchargement
// sont supprimées. Un modèle amputé d'une tranche ne démarre pas ; le laisser
// sur le disque n'offrirait qu'un modèle mort et des dizaines de Go occupés.
func runDownloadSet(ctx context.Context, st *dlState, urls, dests []string) {
var done int64 // atomique : octets écrits, toutes tranches confondues
finish := func(e error) {
dlMu.Lock()
switch {
case ctx.Err() != nil:
st.Canceled = true
st.Speed = 0
case e != nil:
st.Err = e.Error()
st.Speed = 0
default:
st.Done = atomic.LoadInt64(&done)
}
st.Finished = true
st.cancel = nil
dlMu.Unlock()
}
// Sonde de TOUTES les tranches avant d'écrire quoi que ce soit : la place
// disque se vérifie sur le total, pas tranche par tranche.
totals := make([]int64, len(urls))
rangeds := make([]bool, len(urls))
var grand int64
for i, u := range urls {
n, ranged, err := dlProbe(ctx, u)
if err != nil {
finish(err)
return
}
totals[i], rangeds[i] = n, ranged
grand += n
}
// Vérification serveur : l'UI a déjà prévenu, mais rien ne garantit qu'elle
// l'ait fait (autre client, disque rempli entre-temps).
if err := checkDiskSpace(filepath.Dir(dests[0]), grand); err != nil {
finish(err)
return
}
dlMu.Lock()
st.Total = grand
st.Parts = len(urls)
dlMu.Unlock()
// Publication de la progression + vitesse lissée, une fois par seconde.
stop := make(chan struct{})
go func() {
t := time.NewTicker(time.Second)
defer t.Stop()
last, lastAt := int64(0), time.Now()
for {
select {
case <-stop:
return
case now := <-t.C:
cur := atomic.LoadInt64(&done)
dt := now.Sub(lastAt).Seconds()
dlMu.Lock()
st.Done = cur
if dt > 0 {
inst := int64(float64(cur-last) / dt)
if st.Speed == 0 {
st.Speed = inst
} else {
st.Speed = (st.Speed*2 + inst) / 3 // EMA, lisse les à-coups du CDN
}
}
dlMu.Unlock()
last, lastAt = cur, now
}
}
}()
var written []string // tranches menées à bien par CE téléchargement
for i := range urls {
dlMu.Lock()
st.Part = i + 1
dlMu.Unlock()
if err := dlOnePart(ctx, st, urls[i], dests[i], totals[i], rangeds[i], &done); err != nil {
close(stop)
for _, p := range written {
_ = os.Remove(p)
}
finish(err) // annulation comprise : le .part est supprimé dans tous les cas
return
}
written = append(written, dests[i])
}
close(stop)
finish(nil)
}
// dlOnePart télécharge UN fichier dans un .part puis le renomme au succès.
// Quand la source honore les plages d'octets (le CDN de Hugging Face le fait),
// le fichier est réparti sur plusieurs connexions écrites en place via WriteAt :
// c'est ce qui fait qu'un .gguf de plusieurs Go sature le lien au lieu de se
// traîner sur un seul flux TCP. done est incrémenté globalement (il compte pour
// tout le modèle, pas seulement pour cette tranche).
func dlOnePart(ctx context.Context, st *dlState, dlURL, dest string, total int64, ranged bool, done *int64) error {
tmp := dest + ".part"
conns := 1
if ranged && total > 0 {
conns = dlConns()
if max := int((total + dlMinChunk - 1) / dlMinChunk); conns > max {
conns = max
}
if conns < 1 {
conns = 1
}
}
dlMu.Lock()
st.Conns = conns
dlMu.Unlock()
f, err := os.Create(tmp)
if err != nil {
return err
}
if conns > 1 {
// Préallocation : le système de fichiers pose le fichier d'un bloc et les
// WriteAt concurrents n'ont jamais à l'étendre en même temps.
if err := f.Truncate(total); err != nil {
f.Close()
_ = os.Remove(tmp)
return err
}
}
if err := dlFetch(ctx, f, dlURL, total, conns, done); err != nil {
f.Close()
_ = os.Remove(tmp)
return err
}
if err := f.Close(); err != nil {
_ = os.Remove(tmp)
return err
}
if err := os.Rename(tmp, dest); err != nil {
_ = os.Remove(tmp)
return err
}
return nil
}
// dlFetch writes the whole body into f, either as one stream or as `conns`
// parallel byte ranges. done is incremented atomically as bytes land on disk.
func dlFetch(ctx context.Context, f *os.File, dlURL string, total int64, conns int, done *int64) error {
if conns <= 1 {
return dlChunk(ctx, f, dlURL, 0, total-1, total <= 0, done)
}
size := total / int64(conns)
var wg sync.WaitGroup
errs := make([]error, conns)
for i := 0; i < conns; i++ {
start := int64(i) * size
end := start + size - 1
if i == conns-1 {
end = total - 1
}
wg.Add(1)
go func(i int, start, end int64) {
defer wg.Done()
errs[i] = dlChunk(ctx, f, dlURL, start, end, false, done)
}(i, start, end)
}
wg.Wait()
for _, e := range errs {
if e != nil {
return e
}
}
return nil
}
// dlChunk downloads [start,end] into f at the right offset, retrying from where
// it stopped if the connection drops mid-chunk. With whole=true it streams the
// entire body sequentially (server without range support, unknown size).
func dlChunk(ctx context.Context, f *os.File, dlURL string, start, end int64, whole bool, done *int64) error {
const attempts = 4
pos := start
var lastErr error
for try := 0; try < attempts; try++ {
if err := ctx.Err(); err != nil {
return err // cancelled: never retry
}
if try > 0 {
select {
case <-ctx.Done():
return ctx.Err()
case <-time.After(time.Duration(try) * time.Second):
}
}
rng := ""
if !whole {
if pos > end {
return nil
}
rng = fmt.Sprintf("bytes=%d-%d", pos, end)
} else if pos > start {
rng = fmt.Sprintf("bytes=%d-", pos) // best-effort resume
}
req, err := dlRequest(ctx, dlURL, rng)
if err != nil {
return err
}
resp, err := dlClient.Do(req)
if err != nil {
lastErr = err
continue
}
if resp.StatusCode != 200 && resp.StatusCode != 206 {
err := dlSourceError(resp, dlURL)
resp.Body.Close()
return err
}
if resp.StatusCode == 200 && pos > start {
// Resume refused: the body restarts from 0, rewind our bookkeeping.
atomic.AddInt64(done, start-pos)
pos = start
}
n, cerr := dlCopy(f, resp.Body, pos, done)
resp.Body.Close()
pos += n
if cerr == nil {
return nil
}
lastErr = cerr
}
return lastErr
}
// dlCopy streams src into f starting at off, reporting bytes written. It
// returns the byte count even on error so the caller can resume.
func dlCopy(f *os.File, src io.Reader, off int64, done *int64) (int64, error) {
buf := make([]byte, 1<<20) // 1 MiB
var written int64
for {
n, rerr := src.Read(buf)
if n > 0 {
if _, werr := f.WriteAt(buf[:n], off+written); werr != nil {
return written, werr
}
written += int64(n)
atomic.AddInt64(done, int64(n))
}
if rerr == io.EOF {
return written, nil
}
if rerr != nil {
return written, rerr
}
}
}
// handleModelDownloadCancel aborts an in-flight download; runDownload then
// deletes its .part file, so nothing partial survives.
func handleModelDownloadCancel(w http.ResponseWriter, r *http.Request) {
var req struct {
Filename string `json:"filename"`
}
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
sendJSON(w, 400, map[string]any{"ok": false, "error": err.Error()})
return
}
name := filepath.Base(strings.TrimSpace(req.Filename))
dlMu.Lock()
st, ok := dlDownloads[name]
var cancel context.CancelFunc
if ok {
cancel = st.cancel
}
dlMu.Unlock()
if !ok {
sendJSON(w, 404, map[string]any{"ok": false, "error": "aucun téléchargement pour " + name})
return
}
if cancel != nil {
cancel()
}
sendJSON(w, 200, map[string]any{"ok": true})
}
// cleanStalePartFiles removes leftover *.gguf.part files in every model dir at
// startup. A download killed by a crash or a service restart can't be resumed
// (its state lived in memory), so the partial file would otherwise sit there
// forever eating disk.
func cleanStalePartFiles() {
for _, dir := range modelDirs() {
matches, err := filepath.Glob(filepath.Join(dir, "*.gguf.part"))
if err != nil {
continue
}
for _, p := range matches {
if err := os.Remove(p); err == nil {
fmt.Printf("[models] téléchargement incomplet supprimé : %s\n", p)
}
}
}
}
// handleModelDownloadStatus returns the state of all known downloads this run.
func handleModelDownloadStatus(w http.ResponseWriter, r *http.Request) {
dlMu.Lock()
out := make([]dlState, 0, len(dlDownloads))
for _, st := range dlDownloads {
out = append(out, *st)
}
dlMu.Unlock()
sendJSON(w, 200, out)
}