Files
Loki/internal/loki/backend_models.go
T
Claude 06f0e74c77 Modèles : les supprimer depuis l'interface, et ne charger que des presets
Un .gguf ne pouvait s'effacer qu'en cochant « supprimer aussi le fichier »
au moment de supprimer son preset. Tout ce qui avait été téléchargé sans
preset — une quantization essayée puis abandonnée, un projecteur du mauvais
dépôt — restait donc sur le disque pour des dizaines de gigaoctets, sans
aucun moyen de partir depuis Loki.

L'éditeur de preset porte désormais une ligne « Modèles installés » (repliée,
sous le sélecteur de fichier) : chaque .gguf des dossiers déclarés avec sa
taille, ses tranches, qui le réclame, et un bouton « supprimer ».

Rien n'est réversible ici, d'où deux garde-fous côté serveur, partagés par
/api/models/delete ET par la case de l'éditeur de preset :

  - le modèle CHARGÉ est intouchable — l'effacer sous le moteur ne rend pas
    l'espace tant qu'il tient le descripteur, et le démarrage suivant meurt
    sur un fichier introuvable ;
  - un modèle réclamé par des presets ne part qu'avec `force`, le refus 409
    nommant les presets pour que la seconde confirmation dise ce qu'on casse.
    Supprimer un preset ne peut donc plus emporter le modèle de son voisin.

La suppression emporte toute la famille de tranches et annonce la place
réellement libérée. /api/models marque au passage le modèle chargé et les
presets qui référencent chaque fichier.

Le sélecteur de l'en-tête ne liste plus que des presets. Il proposait aussi
les .gguf du disque, chargés en n'écrivant que MODEL= : le contexte, le NGL
et l'échantillonnage du modèle PRÉCÉDENT restaient en place, et un 27B chargé
par-dessus les réglages d'un 4B partait sur 100k de contexte pour mourir en
OOM sans que rien ne dise pourquoi. Charger un modèle, c'est appliquer TOUS
ses réglages : ça s'appelle un preset, et c'est le seul chemin. /api/models/use
disparaît avec le groupe qu'il servait.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RoKfYRvVHTf8GyKhgY5WxT
2026-09-09 08:53:36 +00:00

1059 lines
35 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).
func deleteModelFile(name string) error {
p, err := resolveModelPath(name)
if err != nil {
return err
}
if err := os.Remove(p); err != nil {
if os.IsNotExist(err) {
return fmt.Errorf("modèle introuvable: %s", filepath.Base(p))
}
return 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 nil
}
// modelSizeOnDisk additionne les octets qu'un modèle occupe RÉELLEMENT — la
// famille de tranches entière, pas le seul fichier nommé. C'est ce chiffre
// qu'on annonce après une suppression : « 42 Go libérés » pour un modèle
// découpé, et non les 15 Go de sa première tranche.
func modelSizeOnDisk(p string) int64 {
return shardFamilySize(filepath.Dir(p), filepath.Base(p))
}
// modelIsLoaded dit si p est le modèle de la configuration ACTIVE, celui que
// llama-server a ouvert. L'effacer sous ses pieds ne libère rien tant que le
// processus tient le descripteur, et le prochain démarrage meurt sur un fichier
// introuvable : on refuse, et on renvoie vers un changement de preset.
func modelIsLoaded(p string) bool {
cur := strings.TrimSpace(ReadConfig()["MODEL"])
if cur == "" {
return false
}
q, err := resolveServeModelPath(cur)
if err != nil {
return false
}
return samePath(p, q)
}
// modelPresetRefs mappe chaque .gguf référencé par un preset (MODEL= ou
// MMPROJ=, chemin normalisé) vers les noms des presets qui le réclament. Calculé
// UNE fois par listage : sans ça, chercher les références d'un modèle à la fois
// relisait tous les presets pour chaque fichier du disque.
func modelPresetRefs() map[string][]string {
refs := map[string][]string{}
list, err := ListPresets()
if err != nil {
return refs
}
for _, pr := range list {
content, err := os.ReadFile(pr.Path)
if err != nil {
continue
}
env := parseEnv(string(content))
for _, ref := range []string{env["MODEL"], env["MMPROJ"]} {
if strings.TrimSpace(ref) == "" {
continue
}
q, err := resolveServeModelPath(ref)
if err != nil {
continue
}
k := normDir(q)
// Un preset qui pointe le même fichier en MODEL et en MMPROJ ne compte
// qu'une fois.
if n := len(refs[k]); n > 0 && refs[k][n-1] == pr.Name {
continue
}
refs[k] = append(refs[k], pr.Name)
}
}
return refs
}
// presetsUsingModel liste les noms d'affichage des presets dont MODEL= ou
// MMPROJ= désigne p. Supprimer un .gguf ne casse pas qu'un fichier : chaque
// preset qui le référence devient un moteur qui meurt au démarrage. On les
// nomme AVANT, dans la confirmation, plutôt que de laisser la découverte au
// prochain switch.
func presetsUsingModel(p string) []string {
return modelPresetRefs()[normDir(p)]
}
// handleModelDelete supprime un .gguf (et toutes ses tranches) d'un dossier de
// modèles déclaré.
//
// Deux garde-fous, parce que rien n'est réversible ici et qu'un modèle pèse des
// dizaines de gigaoctets :
// - le modèle CHARGÉ est intouchable (modelIsLoaded) ;
// - un modèle référencé par des presets ne part qu'avec `force`, l'appelant
// ayant reçu leur liste dans la réponse 409 pour la poser à l'utilisateur.
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
}
p, err := resolveModelPath(req.Name)
if err != nil {
sendJSON(w, 400, map[string]any{"ok": false, "error": err.Error()})
return
}
if st, err := os.Stat(p); err != nil || st.IsDir() {
sendJSON(w, 404, map[string]any{"ok": false, "error": "modèle introuvable : " + filepath.Base(p)})
return
}
if modelIsLoaded(p) {
sendJSON(w, 409, map[string]any{"ok": false, "loaded": true,
"error": "modèle chargé par le moteur — bascule d'abord sur un autre preset"})
return
}
used := presetsUsingModel(p)
if len(used) > 0 && !req.Force {
sendJSON(w, 409, map[string]any{"ok": false, "presets": used, "needsForce": true,
"error": "modèle utilisé par un preset"})
return
}
freed := modelSizeOnDisk(p)
if err := deleteModelFile(p); err != nil {
sendJSON(w, 400, map[string]any{"ok": false, "error": err.Error()})
return
}
fmt.Printf("%s modèle supprimé : %s\n", green("[ok]"), p)
resp := map[string]any{"ok": true, "freed": freed}
if len(used) > 0 {
resp["presets"] = used // presets cassés par la suppression forcée
}
sendJSON(w, 200, resp)
}
// ---- 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()
sendJSON(w, 409, map[string]any{"ok": false, "error": "le modèle existe déjà: " + name})
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]
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)
}