diff --git a/README.md b/README.md
index 97cd8ab..8c8edfa 100644
--- a/README.md
+++ b/README.md
@@ -84,6 +84,15 @@ Loki les marque « accès restreint » dès la liste des résultats et rappelle
geste à faire, plutôt que de laisser choisir une quantification pour échouer au
lancement du transfert.
+Le jeton se règle **dans l'interface** : éditeur de preset → *Modèle* → **Jeton
+Hugging Face**. Il est vérifié auprès de Hugging Face avant d'être enregistré
+(le compte associé s'affiche), rangé avec les autres secrets sous `/data` — donc
+il survit aux redémarrages et aux changements de preset — et il sert aussi bien à
+la recherche qu'au téléchargement. À défaut, la variable d'environnement
+`HF_TOKEN` reste lue comme avant ; le jeton enregistré dans l'interface a la
+priorité. Un jeton en **lecture** suffit (huggingface.co/settings/tokens), et il
+n'est envoyé qu'aux adresses Hugging Face.
+
## Installation sur Unraid
L'image est construite et publiée par GitHub Actions sur GHCR
@@ -112,7 +121,7 @@ redémarrages (volume `/data`). Variables d'environnement du conteneur :
| `LOKI_NGL` | couches GPU initiales | `999` (tout) |
| `LOKI_HOME` | données (volume) | `/data` |
| `LOKI_MODEL_DIRS` | dossiers .gguf additionnels | `/models` |
-| `HF_TOKEN` | jeton Hugging Face, pour les dépôts à accès restreint | — |
+| `HF_TOKEN` | jeton Hugging Face, pour les dépôts à accès restreint (repli : le jeton réglé dans l'UI prime) | — |
En CLI dans le conteneur : `docker exec -it loki loki status` (aussi :
`logs`, `restart`, `config`, `bench`, `test`…).
diff --git a/internal/loki/backend_hf.go b/internal/loki/backend_hf.go
index 60f22cf..0567415 100644
--- a/internal/loki/backend_hf.go
+++ b/internal/loki/backend_hf.go
@@ -101,13 +101,115 @@ type hfListing struct {
// transfert : sans lui un dépôt gated répond 401 et Loki afficherait « aucun
// résultat » là où il faudrait dire « dépôt à accès restreint ».
func hfAuth(req *http.Request) {
- if k := os.Getenv("HF_TOKEN"); k != "" {
+ if k := hfToken(); k != "" {
req.Header.Set("Authorization", "Bearer "+k)
}
req.Header.Set("User-Agent", "loki/"+Version)
req.Header.Set("Accept", "application/json")
}
+// ---- Jeton Hugging Face -----------------------------------------------------
+//
+// Le jeton vivait UNIQUEMENT dans la variable d'environnement HF_TOKEN. En
+// conteneur, c'était l'exiger au mauvais moment : on découvre qu'un dépôt est
+// verrouillé depuis l'interface, et il faudrait éditer un docker-compose puis
+// recréer le conteneur pour y répondre. Il se règle donc aussi dans l'interface,
+// et se range là où sont déjà les secrets (base d'état) — pas dans config.env,
+// que le changement de preset réécrit en bloc.
+//
+// Priorité : le jeton enregistré d'abord, la variable d'environnement ensuite.
+// Rien d'enregistré = comportement d'avant, à l'octet près.
+
+// hfToken renvoie le jeton effectivement utilisé, ou "".
+func hfToken() string {
+ if t := strings.TrimSpace(getStr(bkState, "hf_token")); t != "" {
+ return t
+ }
+ return strings.TrimSpace(os.Getenv("HF_TOKEN"))
+}
+
+// hfTokenSource dit D'OÙ vient le jeton : « config » (enregistré ici), « env »
+// (HF_TOKEN du conteneur) ou "" (aucun). L'interface en a besoin pour ne pas
+// proposer d'effacer un jeton qu'elle ne peut pas effacer.
+func hfTokenSource() string {
+ switch {
+ case strings.TrimSpace(getStr(bkState, "hf_token")) != "":
+ return "config"
+ case strings.TrimSpace(os.Getenv("HF_TOKEN")) != "":
+ return "env"
+ }
+ return ""
+}
+
+// hfTokenSet dit si un jeton est disponible, pour que l'interface distingue
+// « il faut en poser un » de « celui qui est posé ne suffit pas ».
+func hfTokenSet() bool { return hfToken() != "" }
+
+// writeHFToken enregistre (ou efface, tok == "") le jeton. Le cache des réponses
+// Hugging Face est vidé au passage : il contient des listes obtenues SANS jeton,
+// où un dépôt verrouillé peut manquer ou paraître illisible.
+func writeHFToken(tok string) error {
+ if err := putStr(bkState, "hf_token", strings.TrimSpace(tok)); err != nil {
+ return err
+ }
+ hfCacheMu.Lock()
+ hfCache = map[string]hfCacheItem{}
+ hfCacheMu.Unlock()
+ return nil
+}
+
+// maskHFToken n'affiche qu'assez de caractères pour reconnaître le jeton posé.
+// Il n'est jamais renvoyé en clair : contrairement à la clé API du serveur,
+// personne n'a besoin de le recopier depuis Loki.
+func maskHFToken(t string) string {
+ t = strings.TrimSpace(t)
+ if t == "" {
+ return ""
+ }
+ if len(t) <= 8 {
+ return "…" + t[len(t)-2:]
+ }
+ return t[:3] + "…" + t[len(t)-4:]
+}
+
+// hfWhoAmI vérifie un jeton auprès de Hugging Face et renvoie le nom du compte.
+// Enregistrer un jeton sans le vérifier, c'est déplacer l'échec : on croirait le
+// problème réglé jusqu'au prochain 401, quinze minutes de téléchargement plus
+// tard.
+func hfWhoAmI(ctx context.Context, tok string) (string, error) {
+ ctx, cancel := context.WithTimeout(ctx, hfTimeout)
+ defer cancel()
+ req, err := http.NewRequestWithContext(ctx, "GET", hfHost+"/api/whoami-v2", nil)
+ if err != nil {
+ return "", err
+ }
+ req.Header.Set("Authorization", "Bearer "+strings.TrimSpace(tok))
+ req.Header.Set("User-Agent", "loki/"+Version)
+ req.Header.Set("Accept", "application/json")
+ resp, err := http.DefaultClient.Do(req)
+ if err != nil {
+ return "", fmt.Errorf("Hugging Face injoignable : %v", err)
+ }
+ defer resp.Body.Close()
+ if resp.StatusCode == 401 || resp.StatusCode == 403 {
+ return "", fmt.Errorf("jeton refusé par Hugging Face (expiré, révoqué ou mal copié)")
+ }
+ if resp.StatusCode != 200 {
+ return "", fmt.Errorf("Hugging Face a répondu %d", resp.StatusCode)
+ }
+ var who struct {
+ Name string `json:"name"`
+ }
+ raw, err := io.ReadAll(io.LimitReader(resp.Body, 1<<20))
+ if err != nil {
+ return "", err
+ }
+ if err := json.Unmarshal(raw, &who); err != nil {
+ return "", err
+ }
+ return who.Name, nil
+}
+
// hfCache : mémo commun à la recherche et aux arborescences, clé = l'URL
// appelée. Sans lui, chaque frappe au clavier dans le champ de recherche
// produit une requête sortante.
@@ -241,10 +343,15 @@ func hfRepoGated(ctx context.Context, repo string) bool {
return hfGatedFlag(info.Gated)
}
-// hfTokenSet dit si un jeton est configuré, pour que l'interface distingue
-// « il faut en poser un » de « celui qui est posé ne suffit pas ».
-func hfTokenSet() bool {
- return strings.TrimSpace(os.Getenv("HF_TOKEN")) != ""
+// isHFHost dit si un hôte appartient à Hugging Face. Sert à décider qui a le
+// droit de recevoir le jeton.
+func isHFHost(host string) bool {
+ h := strings.ToLower(host)
+ if i := strings.IndexByte(h, ':'); i >= 0 {
+ h = h[:i]
+ }
+ return h == "huggingface.co" || strings.HasSuffix(h, ".huggingface.co") ||
+ h == "hf.co" || strings.HasSuffix(h, ".hf.co")
}
// hfRepoFromURL retrouve « auteur/dépôt » dans un lien de téléchargement
diff --git a/internal/loki/backend_hf_test.go b/internal/loki/backend_hf_test.go
index 3b3a825..142228a 100644
--- a/internal/loki/backend_hf_test.go
+++ b/internal/loki/backend_hf_test.go
@@ -1,6 +1,10 @@
package loki
import (
+ "context"
+ "encoding/json"
+ "fmt"
+ "net/http/httptest"
"strings"
"testing"
)
@@ -243,3 +247,113 @@ func TestHFRepoFromURL(t *testing.T) {
}
}
}
+
+// Le jeton enregistré dans l'interface prime sur HF_TOKEN, et son absence
+// rend exactement le comportement d'avant : la variable d'environnement.
+func TestHFTokenPrecedence(t *testing.T) {
+ testHome(t)
+ t.Setenv("HF_TOKEN", "")
+ if hfToken() != "" || hfTokenSource() != "" || hfTokenSet() {
+ t.Fatalf("sans rien : jeton=%q source=%q", hfToken(), hfTokenSource())
+ }
+ t.Setenv("HF_TOKEN", "hf_env")
+ if hfToken() != "hf_env" || hfTokenSource() != "env" {
+ t.Fatalf("HF_TOKEN seul : jeton=%q source=%q", hfToken(), hfTokenSource())
+ }
+ if err := writeHFToken(" hf_enregistre "); err != nil {
+ t.Fatal(err)
+ }
+ if hfToken() != "hf_enregistre" || hfTokenSource() != "config" {
+ t.Fatalf("jeton enregistré : jeton=%q source=%q", hfToken(), hfTokenSource())
+ }
+ // Retiré ici, la variable du conteneur reprend la main plutôt que de laisser
+ // Loki sans jeton alors que l'environnement en fournit un.
+ if err := writeHFToken(""); err != nil {
+ t.Fatal(err)
+ }
+ if hfToken() != "hf_env" || hfTokenSource() != "env" {
+ t.Fatalf("après retrait : jeton=%q source=%q", hfToken(), hfTokenSource())
+ }
+}
+
+// Le jeton ne doit jamais ressortir en clair de l'interface.
+func TestMaskHFToken(t *testing.T) {
+ if got := maskHFToken("hf_abcdefghijklmnop"); got != "hf_…mnop" {
+ t.Errorf("masque = %q", got)
+ }
+ if got := maskHFToken(""); got != "" {
+ t.Errorf("masque d'un jeton absent = %q", got)
+ }
+ if strings.Contains(maskHFToken("hf_abcdefghijklmnop"), "efghij") {
+ t.Error("le masque laisse voir le milieu du jeton")
+ }
+}
+
+// Un secret ne part QUE vers Hugging Face : un lien collé vers un autre
+// hébergeur ne doit pas recevoir le jeton du compte.
+func TestDLRequestTokenOnlyToHuggingFace(t *testing.T) {
+ testHome(t)
+ t.Setenv("HF_TOKEN", "hf_secret")
+ for _, c := range []struct {
+ url string
+ want bool
+ }{
+ {"https://huggingface.co/a/b/resolve/main/m.gguf", true},
+ {"https://cdn-lfs.huggingface.co/a/b/m.gguf", true},
+ {"https://hf.co/a/b/resolve/main/m.gguf", true},
+ {"https://example.com/m.gguf", false},
+ {"https://huggingface.co.evil.example/m.gguf", false},
+ } {
+ req, err := dlRequest(context.Background(), c.url, "")
+ if err != nil {
+ t.Fatal(err)
+ }
+ if got := req.Header.Get("Authorization") != ""; got != c.want {
+ t.Errorf("%s : en-tête Authorization présent=%v, attendu %v", c.url, got, c.want)
+ }
+ }
+}
+
+// La route décrit le jeton sans jamais le rendre, et l'efface sans appeler
+// Hugging Face (un jeton vide n'a rien à vérifier).
+func TestHandleHFTokenGetAndClear(t *testing.T) {
+ testHome(t)
+ t.Setenv("HF_TOKEN", "")
+ if err := writeHFToken("hf_abcdefghijklmnop"); err != nil {
+ t.Fatal(err)
+ }
+
+ call := func(method, body string) map[string]any {
+ t.Helper()
+ r := httptest.NewRequest(method, "http://placeholder/api/hf/token", strings.NewReader(body))
+ w := httptest.NewRecorder()
+ handleHFToken(w, r)
+ if w.Code != 200 {
+ t.Fatalf("%s → HTTP %d : %s", method, w.Code, w.Body.String())
+ }
+ var out map[string]any
+ if err := json.Unmarshal(w.Body.Bytes(), &out); err != nil {
+ t.Fatal(err)
+ }
+ return out
+ }
+
+ got := call("GET", "")
+ if got["set"] != true || got["source"] != "config" {
+ t.Fatalf("état lu = %v", got)
+ }
+ if s, _ := got["masked"].(string); s == "" || strings.Contains(s, "efghij") {
+ t.Fatalf("masque inattendu : %q", s)
+ }
+ if strings.Contains(fmt.Sprint(got), "hf_abcdefghijklmnop") {
+ t.Fatalf("le jeton ressort en clair : %v", got)
+ }
+
+ got = call("POST", `{"token":""}`)
+ if got["set"] != false || got["source"] != "" {
+ t.Fatalf("après effacement = %v", got)
+ }
+ if hfToken() != "" {
+ t.Fatalf("jeton toujours présent : %q", hfToken())
+ }
+}
diff --git a/internal/loki/backend_models.go b/internal/loki/backend_models.go
index 4133294..b388a45 100644
--- a/internal/loki/backend_models.go
+++ b/internal/loki/backend_models.go
@@ -489,8 +489,12 @@ func dlRequest(ctx context.Context, dlURL, rng string) (*http.Request, error) {
if err != nil {
return nil, err
}
- // HF gated/private repos may need a token; reuse the same key store if set.
- if k := os.Getenv("HF_TOKEN"); k != "" {
+ // 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)
@@ -577,10 +581,10 @@ func dlAccessReason(code, repo string) string {
// 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 strings.TrimSpace(os.Getenv("HF_TOKEN")) == "" {
- return " — puis renseigne la variable d'environnement HF_TOKEN (jeton Hugging Face)"
+ if !hfTokenSet() {
+ return " — puis renseigne le jeton Hugging Face (réglage « Jeton Hugging Face », ou variable d'environnement HF_TOKEN)"
}
- return " — le jeton HF_TOKEN utilisé n'y donne pas accès (expiré, ou conditions non acceptées avec ce compte)"
+ 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
diff --git a/internal/loki/backend_models_dl_test.go b/internal/loki/backend_models_dl_test.go
index 2107508..9223356 100644
--- a/internal/loki/backend_models_dl_test.go
+++ b/internal/loki/backend_models_dl_test.go
@@ -164,7 +164,7 @@ func TestDLSourceErrorExplainsGatedRepo(t *testing.T) {
{
name: "gated avec jeton refusé", token: "hf_xxx", resp: resp(401, "GatedRepo"), url: gated,
want: []string{"accès restreint", "n'y donne pas accès"},
- absent: []string{"renseigne la variable"},
+ absent: []string{"renseigne le jeton"},
},
{
name: "dépôt privé ou absent", resp: resp(401, "RepoNotFound"), url: gated,
diff --git a/internal/loki/ui/index.html b/internal/loki/ui/index.html
index 41c67fd..732a233 100644
--- a/internal/loki/ui/index.html
+++ b/internal/loki/ui/index.html
@@ -2940,6 +2940,23 @@ html[data-files="1"] #files-btn{color:var(--accent)}
Ces dossiers servent aussi de destination de téléchargement.
+
+
+ Jeton Hugging Facedépôts à accès restreint (« gated ») ou privés
+
+
+
+
+
+
+
+
+
+
@@ -4304,6 +4321,10 @@ async function openItem(kind, key){
// Dossiers de modèles : replié par défaut (réglage rare), chargé à l'ouverture.
document.getElementById('m-dir-path').value = '';
setModelDirsOpen(false);
+ // Jeton Hugging Face : même traitement, mais son ÉTAT est lu tout de suite —
+ // c'est lui qui s'affiche sur la ligne repliée (« aucun », « enregistré »).
+ setHFTokenOpen(false);
+ loadHFToken();
} else {
modelRow.style.display = 'none';
settingsRow.style.display = 'none';
@@ -4444,6 +4465,70 @@ function setModelDirsOpen(open){
function toggleModelDirs(){
setModelDirsOpen(document.getElementById('m-dirs-body').style.display === 'none');
}
+
+// ---- Jeton Hugging Face ----------------------------------------------------
+// Un dépôt « gated » se lit sans jeton mais ne se télécharge pas. Le jeton se
+// posait uniquement par la variable d'environnement HF_TOKEN : en conteneur, ça
+// voulait dire éditer un docker-compose et recréer le conteneur pour répondre à
+// un 401 découvert ici. Il se règle donc à l'endroit où le problème apparaît.
+function setHFTokenOpen(open){
+ const b = document.getElementById('m-hft-body');
+ const c = document.getElementById('m-hft-caret');
+ if(!b) return;
+ b.style.display = open ? '' : 'none';
+ if(c) c.classList.toggle('open', open);
+}
+function toggleHFToken(){
+ setHFTokenOpen(document.getElementById('m-hft-body').style.display === 'none');
+}
+async function loadHFToken(){
+ let d = {};
+ try{ d = await jget('/api/hf/token'); }catch(_){ return; }
+ renderHFToken(d);
+}
+// Le jeton n'est jamais renvoyé en clair par le serveur : on n'affiche que sa
+// forme masquée et sa provenance. « env » se signale à part — l'interface ne
+// peut pas effacer une variable du conteneur, promettre le contraire par un
+// bouton « Retirer » serait mentir.
+function renderHFToken(d){
+ const st = document.getElementById('m-hft-state');
+ const note = document.getElementById('m-hft-note');
+ const clear = document.getElementById('m-hft-clear');
+ const inp = document.getElementById('m-hft-input');
+ if(!st || !note || !clear || !inp) return;
+ inp.value = '';
+ const src = (d && d.source) || '';
+ st.textContent = src ? (d.masked || 'enregistré') : 'aucun';
+ clear.style.display = src === 'config' ? '' : 'none';
+ if(src === 'env'){
+ note.textContent = 'Jeton fourni par la variable d’environnement HF_TOKEN. En enregistrer un ici le remplacera.';
+ } else if(src === 'config'){
+ note.textContent = 'Jeton enregistré' + (d.account ? ' — compte ' + d.account : '')
+ + '. Un dépôt à accès restreint exige EN PLUS d’avoir accepté ses conditions sur huggingface.co avec ce compte.';
+ } else {
+ note.textContent = 'Sans jeton, les dépôts à accès restreint répondent 401 au téléchargement. Crée un jeton en lecture sur huggingface.co/settings/tokens, accepte les conditions du dépôt, puis colle-le ici.';
+ }
+}
+// Vérifié côté serveur AVANT d'être enregistré : un jeton mal collé accepté en
+// silence rendrait exactement le 401 qu'on cherche à expliquer.
+async function saveHFToken(){
+ const inp = document.getElementById('m-hft-input');
+ const tok = (inp.value || '').trim();
+ if(!tok){ toast('colle un jeton (hf_…)'); return; }
+ toast('vérification du jeton…');
+ const r = await jpost('/api/hf/token', {token: tok});
+ if(!r || !r.ok){ toast('jeton refusé : ' + ((r && r.error) || '')); return; }
+ renderHFToken(r);
+ toast(r.account ? 'jeton enregistré — compte ' + r.account : 'jeton enregistré');
+}
+async function clearHFToken(){
+ if(!await askConfirm('Les dépôts à accès restreint répondront de nouveau 401 au téléchargement.',
+ {title:'Retirer le jeton Hugging Face ?', okText:'Retirer'})) return;
+ const r = await jpost('/api/hf/token', {token: ''});
+ if(!r || !r.ok){ toast('erreur : ' + ((r && r.error) || '')); return; }
+ renderHFToken(r);
+ toast('jeton retiré');
+}
async function populateModelDirs(){
const box = document.getElementById('m-dirs-list');
if(!box) return;
@@ -5295,8 +5380,8 @@ async function hfSearch(){
// sans jeton il en faut un, avec jeton c'est l'accès au dépôt qui manque.
function hfGatedHint(hasToken){
return hasToken
- ? 'Dépôt à accès restreint : le jeton HF_TOKEN sera utilisé. Si le téléchargement échoue en 401, accepte les conditions du dépôt sur huggingface.co avec le compte de ce jeton.'
- : 'Dépôt à accès restreint : accepte ses conditions sur huggingface.co, puis renseigne la variable d’environnement HF_TOKEN. Sans jeton, le téléchargement échouera en 401.';
+ ? 'Dépôt à accès restreint : le jeton enregistré sera utilisé. Si le téléchargement échoue en 401, accepte les conditions du dépôt sur huggingface.co avec le compte de ce jeton.'
+ : 'Dépôt à accès restreint : accepte ses conditions sur huggingface.co, puis enregistre un jeton dans « Jeton Hugging Face » plus bas. Sans jeton, le téléchargement échouera en 401.';
}
// Millions/milliers abrégés : un dépôt à 1 945 635 téléchargements dit surtout
@@ -5341,6 +5426,19 @@ async function hfPickRepo(repo){
warn.style.color = r.hf_token ? 'var(--warn)' : 'var(--err)';
warn.textContent = hfGatedHint(r.hf_token);
o.appendChild(warn);
+ // Le réglage est plus bas dans la même fenêtre : y mener directement évite
+ // de le chercher, et c'est le seul geste qui débloque ce dépôt.
+ if(!r.hf_token){
+ const go = document.createElement('button');
+ go.className = 'pe-link'; go.textContent = 'Renseigner un jeton Hugging Face';
+ go.onclick = ()=>{
+ setHFTokenOpen(true);
+ const inp = document.getElementById('m-hft-input');
+ document.getElementById('m-hft-toggle').scrollIntoView({block:'center', behavior:'smooth'});
+ if(inp) inp.focus();
+ };
+ o.appendChild(go);
+ }
}
// Projecteur vision : proposé UNIQUEMENT s'il vient de ce dépôt. Un mmproj
diff --git a/internal/loki/ui/src/index.tmpl.html b/internal/loki/ui/src/index.tmpl.html
index 6aa230d..872debd 100644
--- a/internal/loki/ui/src/index.tmpl.html
+++ b/internal/loki/ui/src/index.tmpl.html
@@ -864,6 +864,23 @@ document.documentElement.setAttribute('data-side',localStorage.getItem('loki-sid
Ces dossiers servent aussi de destination de téléchargement.
+
+
+ Jeton Hugging Facedépôts à accès restreint (« gated ») ou privés
+
+
+
+
+
+
+
+
+
+
diff --git a/internal/loki/ui/src/js/07-models.js b/internal/loki/ui/src/js/07-models.js
index ec58729..0009d8e 100644
--- a/internal/loki/ui/src/js/07-models.js
+++ b/internal/loki/ui/src/js/07-models.js
@@ -121,6 +121,10 @@ async function openItem(kind, key){
// Dossiers de modèles : replié par défaut (réglage rare), chargé à l'ouverture.
document.getElementById('m-dir-path').value = '';
setModelDirsOpen(false);
+ // Jeton Hugging Face : même traitement, mais son ÉTAT est lu tout de suite —
+ // c'est lui qui s'affiche sur la ligne repliée (« aucun », « enregistré »).
+ setHFTokenOpen(false);
+ loadHFToken();
} else {
modelRow.style.display = 'none';
settingsRow.style.display = 'none';
@@ -261,6 +265,70 @@ function setModelDirsOpen(open){
function toggleModelDirs(){
setModelDirsOpen(document.getElementById('m-dirs-body').style.display === 'none');
}
+
+// ---- Jeton Hugging Face ----------------------------------------------------
+// Un dépôt « gated » se lit sans jeton mais ne se télécharge pas. Le jeton se
+// posait uniquement par la variable d'environnement HF_TOKEN : en conteneur, ça
+// voulait dire éditer un docker-compose et recréer le conteneur pour répondre à
+// un 401 découvert ici. Il se règle donc à l'endroit où le problème apparaît.
+function setHFTokenOpen(open){
+ const b = document.getElementById('m-hft-body');
+ const c = document.getElementById('m-hft-caret');
+ if(!b) return;
+ b.style.display = open ? '' : 'none';
+ if(c) c.classList.toggle('open', open);
+}
+function toggleHFToken(){
+ setHFTokenOpen(document.getElementById('m-hft-body').style.display === 'none');
+}
+async function loadHFToken(){
+ let d = {};
+ try{ d = await jget('/api/hf/token'); }catch(_){ return; }
+ renderHFToken(d);
+}
+// Le jeton n'est jamais renvoyé en clair par le serveur : on n'affiche que sa
+// forme masquée et sa provenance. « env » se signale à part — l'interface ne
+// peut pas effacer une variable du conteneur, promettre le contraire par un
+// bouton « Retirer » serait mentir.
+function renderHFToken(d){
+ const st = document.getElementById('m-hft-state');
+ const note = document.getElementById('m-hft-note');
+ const clear = document.getElementById('m-hft-clear');
+ const inp = document.getElementById('m-hft-input');
+ if(!st || !note || !clear || !inp) return;
+ inp.value = '';
+ const src = (d && d.source) || '';
+ st.textContent = src ? (d.masked || 'enregistré') : 'aucun';
+ clear.style.display = src === 'config' ? '' : 'none';
+ if(src === 'env'){
+ note.textContent = 'Jeton fourni par la variable d’environnement HF_TOKEN. En enregistrer un ici le remplacera.';
+ } else if(src === 'config'){
+ note.textContent = 'Jeton enregistré' + (d.account ? ' — compte ' + d.account : '')
+ + '. Un dépôt à accès restreint exige EN PLUS d’avoir accepté ses conditions sur huggingface.co avec ce compte.';
+ } else {
+ note.textContent = 'Sans jeton, les dépôts à accès restreint répondent 401 au téléchargement. Crée un jeton en lecture sur huggingface.co/settings/tokens, accepte les conditions du dépôt, puis colle-le ici.';
+ }
+}
+// Vérifié côté serveur AVANT d'être enregistré : un jeton mal collé accepté en
+// silence rendrait exactement le 401 qu'on cherche à expliquer.
+async function saveHFToken(){
+ const inp = document.getElementById('m-hft-input');
+ const tok = (inp.value || '').trim();
+ if(!tok){ toast('colle un jeton (hf_…)'); return; }
+ toast('vérification du jeton…');
+ const r = await jpost('/api/hf/token', {token: tok});
+ if(!r || !r.ok){ toast('jeton refusé : ' + ((r && r.error) || '')); return; }
+ renderHFToken(r);
+ toast(r.account ? 'jeton enregistré — compte ' + r.account : 'jeton enregistré');
+}
+async function clearHFToken(){
+ if(!await askConfirm('Les dépôts à accès restreint répondront de nouveau 401 au téléchargement.',
+ {title:'Retirer le jeton Hugging Face ?', okText:'Retirer'})) return;
+ const r = await jpost('/api/hf/token', {token: ''});
+ if(!r || !r.ok){ toast('erreur : ' + ((r && r.error) || '')); return; }
+ renderHFToken(r);
+ toast('jeton retiré');
+}
async function populateModelDirs(){
const box = document.getElementById('m-dirs-list');
if(!box) return;
@@ -1112,8 +1180,8 @@ async function hfSearch(){
// sans jeton il en faut un, avec jeton c'est l'accès au dépôt qui manque.
function hfGatedHint(hasToken){
return hasToken
- ? 'Dépôt à accès restreint : le jeton HF_TOKEN sera utilisé. Si le téléchargement échoue en 401, accepte les conditions du dépôt sur huggingface.co avec le compte de ce jeton.'
- : 'Dépôt à accès restreint : accepte ses conditions sur huggingface.co, puis renseigne la variable d’environnement HF_TOKEN. Sans jeton, le téléchargement échouera en 401.';
+ ? 'Dépôt à accès restreint : le jeton enregistré sera utilisé. Si le téléchargement échoue en 401, accepte les conditions du dépôt sur huggingface.co avec le compte de ce jeton.'
+ : 'Dépôt à accès restreint : accepte ses conditions sur huggingface.co, puis enregistre un jeton dans « Jeton Hugging Face » plus bas. Sans jeton, le téléchargement échouera en 401.';
}
// Millions/milliers abrégés : un dépôt à 1 945 635 téléchargements dit surtout
@@ -1158,6 +1226,19 @@ async function hfPickRepo(repo){
warn.style.color = r.hf_token ? 'var(--warn)' : 'var(--err)';
warn.textContent = hfGatedHint(r.hf_token);
o.appendChild(warn);
+ // Le réglage est plus bas dans la même fenêtre : y mener directement évite
+ // de le chercher, et c'est le seul geste qui débloque ce dépôt.
+ if(!r.hf_token){
+ const go = document.createElement('button');
+ go.className = 'pe-link'; go.textContent = 'Renseigner un jeton Hugging Face';
+ go.onclick = ()=>{
+ setHFTokenOpen(true);
+ const inp = document.getElementById('m-hft-input');
+ document.getElementById('m-hft-toggle').scrollIntoView({block:'center', behavior:'smooth'});
+ if(inp) inp.focus();
+ };
+ o.appendChild(go);
+ }
}
// Projecteur vision : proposé UNIQUEMENT s'il vient de ce dépôt. Un mmproj
diff --git a/internal/loki/web_hf.go b/internal/loki/web_hf.go
index 2fa2112..e3ac3f2 100644
--- a/internal/loki/web_hf.go
+++ b/internal/loki/web_hf.go
@@ -4,12 +4,14 @@ package loki
//
// GET /api/hf/search?q=… dépôts GGUF correspondants
// GET /api/hf/files?repo=auteur/dépôt modèles, projecteurs et drafts du dépôt
+// GET|POST /api/hf/token jeton Hugging Face (dépôts verrouillés)
//
// Les deux renvoient aussi le matériel local, pour que l'interface n'ait pas à
// le demander séparément et n'affiche jamais une liste de tailles sans le
// budget en face.
import (
+ "encoding/json"
"net/http"
"strconv"
"strings"
@@ -80,3 +82,52 @@ func handleHFFiles(w http.ResponseWriter, r *http.Request) {
"hf_token": hfTokenSet(),
})
}
+
+// handleHFToken lit et enregistre le jeton Hugging Face.
+//
+// GET → {ok, set, source:"config"|"env"|"", masked, account}
+// POST {token:"hf_…"} → vérifie le jeton PUIS l'enregistre
+// POST {token:""} → efface le jeton enregistré
+//
+// Le jeton n'est jamais renvoyé en clair : il ne sert qu'ici, personne n'a
+// besoin de le recopier depuis Loki. Et il est vérifié AVANT d'être enregistré —
+// un jeton mal collé qu'on accepte silencieusement redonne exactement le 401
+// qu'on cherchait à expliquer.
+func handleHFToken(w http.ResponseWriter, r *http.Request) {
+ if r.Method == http.MethodPost {
+ var req struct {
+ Token string `json:"token"`
+ }
+ if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
+ sendJSON(w, 400, map[string]any{"ok": false, "error": "requête illisible"})
+ return
+ }
+ tok := strings.TrimSpace(req.Token)
+ account := ""
+ if tok != "" {
+ var err error
+ if account, err = hfWhoAmI(r.Context(), tok); err != nil {
+ sendJSON(w, 400, map[string]any{"ok": false, "error": err.Error()})
+ return
+ }
+ }
+ if err := writeHFToken(tok); err != nil {
+ sendJSON(w, 500, map[string]any{"ok": false, "error": err.Error()})
+ return
+ }
+ sendJSON(w, 200, hfTokenState(account))
+ return
+ }
+ sendJSON(w, 200, hfTokenState(""))
+}
+
+// hfTokenState décrit le jeton pour l'interface. account n'est renseigné qu'au
+// retour d'un enregistrement : le vérifier à chaque affichage ferait un appel
+// sortant à l'ouverture de la fenêtre des réglages.
+func hfTokenState(account string) map[string]any {
+ src := hfTokenSource()
+ return map[string]any{
+ "ok": true, "set": src != "", "source": src,
+ "masked": maskHFToken(hfToken()), "account": account,
+ }
+}
diff --git a/internal/loki/web_server.go b/internal/loki/web_server.go
index d835d29..a8e0044 100644
--- a/internal/loki/web_server.go
+++ b/internal/loki/web_server.go
@@ -184,6 +184,7 @@ func newWebMux() *http.ServeMux {
api("/api/models/download/cancel", handleModelDownloadCancel)
api("/api/hf/search", handleHFSearch) // chercher un modèle GGUF sur Hugging Face
api("/api/hf/files", handleHFFiles) // quants et projecteurs d'un dépôt
+ api("/api/hf/token", handleHFToken) // jeton pour les dépôts à accès restreint
api("/api/backends", handleBackends)
api("/api/backends/custom", handleBackendsCustom) // backends custom uniquement (hors ⚡/🔧)
api("/api/backends/devices", handleBackendDevices) // GPU vus par CE moteur (noms/ordre propres au backend)