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//blob// → /resolve// 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) }