From b339cced0a2d4c574c502abf7d3cd303ab4664f5 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 20 Aug 2026 10:18:55 +0000 Subject: [PATCH 1/2] =?UTF-8?q?D=C3=A9p=C3=B4ts=20Hugging=20Face=20verroui?= =?UTF-8?q?ll=C3=A9s=20:=20dit=20lesquels,=20et=20pourquoi=20le=20401?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Un dépôt « gated » (orcarouter/Qwen3.8-27B-Uncensored-GGUF, vécu) laisse lire son arborescence sans rien : Loki listait donc ses seize quantifications avec leur verdict mémoire, puis échouait sur « HTTP 401 depuis la source » au premier octet. Le message ne disait ni que le dépôt était verrouillé, ni qu'il fallait accepter ses conditions, ni où poser un jeton. - Le refus est maintenant traduit à partir de X-Error-Code (GatedRepo, RepoNotFound, EntryNotFound…) et nomme le dépôt, l'action à faire et l'état du jeton : absent (il en faut un) ou présent mais sans accès. 404, 416, 429 et les pannes de la source y gagnent aussi une phrase utile. - Le verrou se voit AVANT de choisir une quantification : la recherche demande `expand[]=gated` et la fiche du dépôt est lue à l'ouverture, d'où une pastille « accès restreint » dans la liste et un avertissement en toutes lettres au-dessus des fichiers. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01Hz1QWmvZqW3t53YC5SJBKC --- README.md | 7 ++ internal/loki/backend_hf.go | 77 +++++++++++++++++++++- internal/loki/backend_hf_test.go | 38 +++++++++++ internal/loki/backend_models.go | 74 ++++++++++++++++++++- internal/loki/backend_models_dl_test.go | 88 +++++++++++++++++++++++++ internal/loki/ui/index.html | 37 +++++++++++ internal/loki/ui/src/js/07-models.js | 33 ++++++++++ internal/loki/ui/src/styles.css | 4 ++ internal/loki/web_hf.go | 10 ++- 9 files changed, 362 insertions(+), 6 deletions(-) diff --git a/README.md b/README.md index b0c207d..97cd8ab 100644 --- a/README.md +++ b/README.md @@ -77,6 +77,13 @@ Le champ **Télécharger un modèle** reste disponible pour coller un lien direc (dépôt privé, fichier hors des conventions). Un dépôt à accès restreint demande la variable d'environnement `HF_TOKEN`. +Certains dépôts sont **à accès restreint** (« gated ») : leur arborescence se lit +sans rien, mais chaque `.gguf` répond `401` tant que les conditions du dépôt +n'ont pas été acceptées sur huggingface.co **et** qu'un jeton n'est pas fourni. +Loki les marque « accès restreint » dès la liste des résultats et rappelle le +geste à faire, plutôt que de laisser choisir une quantification pour échouer au +lancement du transfert. + ## Installation sur Unraid L'image est construite et publiée par GitHub Actions sur GHCR diff --git a/internal/loki/backend_hf.go b/internal/loki/backend_hf.go index fd23ae7..60f22cf 100644 --- a/internal/loki/backend_hf.go +++ b/internal/loki/backend_hf.go @@ -67,6 +67,11 @@ type hfRepo struct { ID string `json:"id"` Downloads int `json:"downloads"` Likes int `json:"likes"` + // Gated : le dépôt exige d'avoir accepté ses conditions ET un jeton. Il se + // LIT pourtant sans rien (arborescence en 200), et ne refuse qu'au moment du + // transfert : sans cette pastille, l'interface propose des quants avec leur + // verdict mémoire et le téléchargement échoue en 401 sans prévenir. + Gated bool `json:"gated"` } // hfEntry — un .gguf installable. Shards > 1 signale une famille de tranches : @@ -85,6 +90,7 @@ type hfEntry struct { // hfListing — le contenu utile d'un dépôt, trié. type hfListing struct { Repo string `json:"repo"` + Gated bool `json:"gated"` Models []hfEntry `json:"models"` Projectors []hfEntry `json:"projectors"` Drafts []hfEntry `json:"drafts"` @@ -172,29 +178,94 @@ func hfSearch(ctx context.Context, q string) ([]hfRepo, error) { if len(q) > hfMaxQuery { q = q[:hfMaxQuery] } + // `expand[]` remplace les champs par défaut de la réponse : `gated` s'y + // ajoute, mais downloads et likes doivent alors être redemandés + // explicitement, sinon ils disparaissent et la liste perd son classement + // lisible. endpoint := hfHost + "/api/models?" + url.Values{ "search": {q}, "filter": {"gguf"}, "limit": {fmt.Sprint(hfMaxRepos)}, "sort": {"downloads"}, "direction": {"-1"}, + "expand[]": {"gated", "downloads", "likes"}, }.Encode() var raw []struct { ID string `json:"id"` Downloads int `json:"downloads"` Likes int `json:"likes"` + Gated any `json:"gated"` } if err := hfGetJSON(ctx, endpoint, hfSearchTTL, &raw); err != nil { return nil, err } out := make([]hfRepo, 0, len(raw)) for _, r := range raw { - out = append(out, hfRepo{ID: r.ID, Downloads: r.Downloads, Likes: r.Likes}) + out = append(out, hfRepo{ID: r.ID, Downloads: r.Downloads, Likes: r.Likes, Gated: hfGatedFlag(r.Gated)}) } return out, nil } +// hfGatedFlag lit le champ `gated` de l'API, qui n'est PAS un booléen : il vaut +// false, "auto" (accepter les conditions suffit) ou "manual" (l'auteur valide +// chaque demande). Les deux dernières valeurs verrouillent le téléchargement de +// la même façon ; seule la présence d'un verrou nous intéresse ici. +func hfGatedFlag(v any) bool { + switch g := v.(type) { + case bool: + return g + case string: + switch strings.ToLower(strings.TrimSpace(g)) { + case "", "false", "none", "no": + return false + } + return true + } + return false +} + +// hfRepoGated dit si un dépôt est verrouillé. L'arborescence (hfFiles) ne porte +// pas l'information : elle se lit sur la fiche du dépôt, en un appel séparé et +// mis en cache comme elle. Une erreur ici ne doit RIEN casser — au pire on +// n'affiche pas l'avertissement, et le téléchargement dira lui-même pourquoi il +// a été refusé. +func hfRepoGated(ctx context.Context, repo string) bool { + var info struct { + Gated any `json:"gated"` + } + endpoint := hfHost + "/api/models/" + repo + "?" + url.Values{"expand[]": {"gated"}}.Encode() + if err := hfGetJSON(ctx, endpoint, hfFilesTTL, &info); err != nil { + return false + } + return hfGatedFlag(info.Gated) +} + +// 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")) != "" +} + +// hfRepoFromURL retrouve « auteur/dépôt » dans un lien de téléchargement +// Hugging Face, pour pouvoir le nommer dans un message d'erreur. Renvoie "" si +// le lien pointe ailleurs. +func hfRepoFromURL(raw string) string { + u, err := url.Parse(raw) + if err != nil || !strings.Contains(u.Host, "huggingface.co") { + return "" + } + segs := strings.Split(strings.Trim(u.Path, "/"), "/") + if len(segs) < 4 || (segs[2] != "resolve" && segs[2] != "blob") { + return "" + } + repo := segs[0] + "/" + segs[1] + if !hfRepoRe.MatchString(repo) { + return "" + } + return repo +} + // hfFiles liste les .gguf d'un dépôt et les range par famille. // // `recursive=1` n'est pas un confort : les quants volumineux vivent dans des @@ -229,7 +300,9 @@ func hfFiles(ctx context.Context, repo string) (hfListing, error) { } files = append(files, hfFile{Path: f.Path, Size: n}) } - return hfClassify(repo, files), nil + out := hfClassify(repo, files) + out.Gated = hfRepoGated(ctx, repo) + return out, nil } // hfFile — une entrée d'arborescence, réduite à ce dont le classement a besoin. diff --git a/internal/loki/backend_hf_test.go b/internal/loki/backend_hf_test.go index 456763e..3b3a825 100644 --- a/internal/loki/backend_hf_test.go +++ b/internal/loki/backend_hf_test.go @@ -205,3 +205,41 @@ func TestFitVerdict(t *testing.T) { t.Errorf("le contexte ne change rien au verdict (%q dans les deux cas)", court) } } + +// Le champ `gated` de l'API n'est pas un booléen : false, "auto" ou "manual". +// Le lire comme un bool laissait passer les deux valeurs qui verrouillent +// vraiment le téléchargement. +func TestHFGatedFlag(t *testing.T) { + for _, c := range []struct { + in any + want bool + }{ + {nil, false}, + {false, false}, + {true, true}, + {"auto", true}, + {"manual", true}, + {"false", false}, + {"", false}, + } { + if got := hfGatedFlag(c.in); got != c.want { + t.Errorf("hfGatedFlag(%#v) = %v, attendu %v", c.in, got, c.want) + } + } +} + +// hfRepoFromURL sert à NOMMER le dépôt dans le message d'erreur : un lien qui +// ne vient pas de Hugging Face ne doit rien produire plutôt qu'un nom inventé. +func TestHFRepoFromURL(t *testing.T) { + for _, c := range []struct{ in, want string }{ + {"https://huggingface.co/orcarouter/Qwen3.8-27B-Uncensored-GGUF/resolve/main/m.gguf", "orcarouter/Qwen3.8-27B-Uncensored-GGUF"}, + {"https://huggingface.co/ggml-org/Qwen3.8-27B-GGUF/blob/main/sub/dir/m.gguf", "ggml-org/Qwen3.8-27B-GGUF"}, + {"https://example.com/ggml-org/Qwen3.8-27B-GGUF/resolve/main/m.gguf", ""}, + {"https://huggingface.co/ggml-org/Qwen3.8-27B-GGUF", ""}, + {"pas une url", ""}, + } { + if got := hfRepoFromURL(c.in); got != c.want { + t.Errorf("hfRepoFromURL(%q) = %q, attendu %q", c.in, got, c.want) + } + } +} diff --git a/internal/loki/backend_models.go b/internal/loki/backend_models.go index 8b7ac6a..4133294 100644 --- a/internal/loki/backend_models.go +++ b/internal/loki/backend_models.go @@ -514,6 +514,75 @@ func contentRangeTotal(v string) int64 { 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": + return fmt.Errorf("fichier absent du dépôt (HTTP 404) — la révision a pu être réécrite depuis que le lien a été copié") + 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) +} + +// 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 strings.TrimSpace(os.Getenv("HF_TOKEN")) == "" { + return " — puis renseigne la variable d'environnement HF_TOKEN (jeton Hugging Face)" + } + return " — le jeton HF_TOKEN utilisé 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) { @@ -537,7 +606,7 @@ func dlProbe(ctx context.Context, dlURL string) (total int64, ranged bool, err e // Server ignored the Range: single stream, ContentLength is the size. return resp.ContentLength, false, nil default: - return 0, false, fmt.Errorf("HTTP %d depuis la source", resp.StatusCode) + return 0, false, dlSourceError(resp, dlURL) } } @@ -763,8 +832,9 @@ func dlChunk(ctx context.Context, f *os.File, dlURL string, start, end int64, wh continue } if resp.StatusCode != 200 && resp.StatusCode != 206 { + err := dlSourceError(resp, dlURL) resp.Body.Close() - return fmt.Errorf("HTTP %d depuis la source", resp.StatusCode) + return err } if resp.StatusCode == 200 && pos > start { // Resume refused: the body restarts from 0, rewind our bookkeeping. diff --git a/internal/loki/backend_models_dl_test.go b/internal/loki/backend_models_dl_test.go index 11fc8e3..2107508 100644 --- a/internal/loki/backend_models_dl_test.go +++ b/internal/loki/backend_models_dl_test.go @@ -9,6 +9,7 @@ import ( "os" "path/filepath" "strconv" + "strings" "testing" "time" ) @@ -135,3 +136,90 @@ func TestRunDownloadParallelAndFallback(t *testing.T) { } } } + +// Un dépôt Hugging Face verrouillé répond 401 sur le .gguf alors que son +// arborescence se lit sans jeton. « HTTP 401 depuis la source » n'apprenait +// rien : le message doit nommer le verrou, le dépôt, et le geste à faire. +func TestDLSourceErrorExplainsGatedRepo(t *testing.T) { + const gated = "https://huggingface.co/orcarouter/Qwen3.8-27B-Uncensored-GGUF/resolve/main/m.gguf" + resp := func(status int, code string) *http.Response { + r := &http.Response{StatusCode: status, Header: http.Header{}} + if code != "" { + r.Header.Set("X-Error-Code", code) + } + return r + } + for _, c := range []struct { + name string + token string + resp *http.Response + url string + want []string + absent []string + }{ + { + name: "gated sans jeton", resp: resp(401, "GatedRepo"), url: gated, + want: []string{"accès restreint", "orcarouter/Qwen3.8-27B-Uncensored-GGUF", "HF_TOKEN"}, + }, + { + 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"}, + }, + { + name: "dépôt privé ou absent", resp: resp(401, "RepoNotFound"), url: gated, + want: []string{"privé ou inexistant", "HF_TOKEN"}, + }, + { + name: "fichier absent", resp: resp(404, "EntryNotFound"), url: gated, + want: []string{"absent du dépôt", "404"}, + }, + { + name: "source hors Hugging Face", resp: resp(403, ""), url: "https://example.com/m.gguf", + want: []string{"accès refusé par la source"}, + absent: []string{"huggingface.co"}, + }, + { + name: "panne de la source", resp: resp(503, ""), url: gated, + want: []string{"en panne", "503"}, + }, + { + name: "code inconnu", resp: resp(418, ""), url: gated, + want: []string{"HTTP 418 depuis la source"}, + }, + } { + t.Run(c.name, func(t *testing.T) { + t.Setenv("HF_TOKEN", c.token) + got := dlSourceError(c.resp, c.url).Error() + for _, w := range c.want { + if !strings.Contains(got, w) { + t.Errorf("message %q ne contient pas %q", got, w) + } + } + for _, a := range c.absent { + if strings.Contains(got, a) { + t.Errorf("message %q contient %q alors qu'il ne devrait pas", got, a) + } + } + }) + } +} + +// La sonde est le premier appel réseau d'une installation : c'est elle qui doit +// remonter le refus expliqué, pas un code HTTP nu. +func TestDLProbeSurfacesExplainedError(t *testing.T) { + t.Setenv("HF_TOKEN", "") + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("X-Error-Code", "GatedRepo") + w.WriteHeader(401) + })) + defer srv.Close() + + _, _, err := dlProbe(context.Background(), srv.URL+"/m.gguf") + if err == nil { + t.Fatal("un 401 doit faire échouer la sonde") + } + if !strings.Contains(err.Error(), "HF_TOKEN") { + t.Errorf("message %q sans indication sur le jeton", err) + } +} diff --git a/internal/loki/ui/index.html b/internal/loki/ui/index.html index 81af19d..41c67fd 100644 --- a/internal/loki/ui/index.html +++ b/internal/loki/ui/index.html @@ -1480,6 +1480,10 @@ button:hover{border-color:var(--dim);color:var(--text)} .hf-fit.ok{color:var(--ok);border-color:var(--ok)} .hf-fit.juste{color:var(--warn);border-color:var(--warn)} .hf-fit.trop{color:var(--err);border-color:var(--err)} +/* Verrou d'accès : ce n'est pas un verdict mémoire, mais ça se lit au même + endroit et ça décide autant de la suite — un dépôt gated ne se télécharge + pas sans jeton. */ +.hf-fit.gated{color:var(--warn);border-color:var(--warn);text-transform:none} .hf-head{display:flex;align-items:center;gap:10px;margin-top:6px;font-size:12px} .hf-mm{display:flex;align-items:center;gap:6px;margin-top:8px;font-size:11.5px; color:var(--dim);cursor:pointer} @@ -5272,11 +5276,29 @@ async function hfSearch(){ const m = document.createElement('span'); m.className = 'hf-meta'; m.textContent = fmtCount(rep.downloads)+' ↓'; row.append(n, m); + // Dépôt verrouillé : il se LIT sans rien, mais chaque .gguf répond 401 au + // transfert. La pastille le dit dès la liste — sinon on choisit un quant, + // on lance 15 Go et l'erreur tombe à la fin de la sonde. + if(rep.gated){ + const g = document.createElement('span'); + g.className = 'hf-fit gated'; + g.textContent = 'accès restreint'; + g.title = hfGatedHint(r.hf_token); + row.appendChild(g); + } list.appendChild(row); } o.appendChild(list); } +// Le geste à faire n'est pas le même selon qu'un jeton est configuré ou non : +// 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.'; +} + // Millions/milliers abrégés : un dépôt à 1 945 635 téléchargements dit surtout // « celui-là est le plus utilisé », pas son compte exact. function fmtCount(n){ @@ -5304,8 +5326,23 @@ async function hfPickRepo(repo){ back.onclick = hfSearch; const title = document.createElement('span'); title.className = 'hf-name'; title.textContent = repo; head.append(back, title); + if(r.gated){ + const g = document.createElement('span'); + g.className = 'hf-fit gated'; g.textContent = 'accès restreint'; + head.appendChild(g); + } o.appendChild(head); + // Avertissement en toutes lettres : la pastille seule ne dit pas quoi faire, + // et c'est ici que le téléchargement se déclenche. + if(r.gated){ + const warn = document.createElement('div'); + warn.className = 'pe-note'; + warn.style.color = r.hf_token ? 'var(--warn)' : 'var(--err)'; + warn.textContent = hfGatedHint(r.hf_token); + o.appendChild(warn); + } + // Projecteur vision : proposé UNIQUEMENT s'il vient de ce dépôt. Un mmproj // encode dans l'espace latent de SON modèle ; en prendre un ailleurs donne un // moteur qui démarre et ne voit rien. Quand le dépôt n'en publie pas, on le diff --git a/internal/loki/ui/src/js/07-models.js b/internal/loki/ui/src/js/07-models.js index 593370a..ec58729 100644 --- a/internal/loki/ui/src/js/07-models.js +++ b/internal/loki/ui/src/js/07-models.js @@ -1093,11 +1093,29 @@ async function hfSearch(){ const m = document.createElement('span'); m.className = 'hf-meta'; m.textContent = fmtCount(rep.downloads)+' ↓'; row.append(n, m); + // Dépôt verrouillé : il se LIT sans rien, mais chaque .gguf répond 401 au + // transfert. La pastille le dit dès la liste — sinon on choisit un quant, + // on lance 15 Go et l'erreur tombe à la fin de la sonde. + if(rep.gated){ + const g = document.createElement('span'); + g.className = 'hf-fit gated'; + g.textContent = 'accès restreint'; + g.title = hfGatedHint(r.hf_token); + row.appendChild(g); + } list.appendChild(row); } o.appendChild(list); } +// Le geste à faire n'est pas le même selon qu'un jeton est configuré ou non : +// 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.'; +} + // Millions/milliers abrégés : un dépôt à 1 945 635 téléchargements dit surtout // « celui-là est le plus utilisé », pas son compte exact. function fmtCount(n){ @@ -1125,8 +1143,23 @@ async function hfPickRepo(repo){ back.onclick = hfSearch; const title = document.createElement('span'); title.className = 'hf-name'; title.textContent = repo; head.append(back, title); + if(r.gated){ + const g = document.createElement('span'); + g.className = 'hf-fit gated'; g.textContent = 'accès restreint'; + head.appendChild(g); + } o.appendChild(head); + // Avertissement en toutes lettres : la pastille seule ne dit pas quoi faire, + // et c'est ici que le téléchargement se déclenche. + if(r.gated){ + const warn = document.createElement('div'); + warn.className = 'pe-note'; + warn.style.color = r.hf_token ? 'var(--warn)' : 'var(--err)'; + warn.textContent = hfGatedHint(r.hf_token); + o.appendChild(warn); + } + // Projecteur vision : proposé UNIQUEMENT s'il vient de ce dépôt. Un mmproj // encode dans l'espace latent de SON modèle ; en prendre un ailleurs donne un // moteur qui démarre et ne voit rien. Quand le dépôt n'en publie pas, on le diff --git a/internal/loki/ui/src/styles.css b/internal/loki/ui/src/styles.css index 52b1c4c..efd72b1 100644 --- a/internal/loki/ui/src/styles.css +++ b/internal/loki/ui/src/styles.css @@ -1449,6 +1449,10 @@ button:hover{border-color:var(--dim);color:var(--text)} .hf-fit.ok{color:var(--ok);border-color:var(--ok)} .hf-fit.juste{color:var(--warn);border-color:var(--warn)} .hf-fit.trop{color:var(--err);border-color:var(--err)} +/* Verrou d'accès : ce n'est pas un verdict mémoire, mais ça se lit au même + endroit et ça décide autant de la suite — un dépôt gated ne se télécharge + pas sans jeton. */ +.hf-fit.gated{color:var(--warn);border-color:var(--warn);text-transform:none} .hf-head{display:flex;align-items:center;gap:10px;margin-top:6px;font-size:12px} .hf-mm{display:flex;align-items:center;gap:6px;margin-top:8px;font-size:11.5px; color:var(--dim);cursor:pointer} diff --git a/internal/loki/web_hf.go b/internal/loki/web_hf.go index 0ca5474..2fa2112 100644 --- a/internal/loki/web_hf.go +++ b/internal/loki/web_hf.go @@ -26,7 +26,7 @@ func handleHFSearch(w http.ResponseWriter, r *http.Request) { sendJSON(w, 502, map[string]any{"ok": false, "error": err.Error()}) return } - sendJSON(w, 200, map[string]any{"ok": true, "hardware": detectHardware(), "repos": repos}) + sendJSON(w, 200, map[string]any{"ok": true, "hardware": detectHardware(), "repos": repos, "hf_token": hfTokenSet()}) } // handleHFFiles liste les .gguf d'un dépôt, chaque modèle portant son verdict @@ -67,10 +67,16 @@ func handleHFFiles(w http.ResponseWriter, r *http.Request) { // mmproj dans CE dépôt, pas un tag Hugging Face — lequel manque sur des // dépôts qui en publient pourtant un. // Un projecteur ne reçoit pas de verdict : il ne se charge jamais seul. + // `gated` et `hf_token` servent le même but que `vision` : dire AVANT le + // téléchargement ce qui va se passer. Un dépôt verrouillé se lit sans jeton + // mais ne se télécharge pas — l'avertir ici évite de choisir un quant, de + // lancer 15 Go et de récolter un 401. sendJSON(w, 200, map[string]any{ "ok": true, "hardware": hw, "ctx": ctxTokens, "repo": list.Repo, "models": list.Models, "projectors": list.Projectors, "drafts": list.Drafts, - "vision": len(list.Projectors) > 0, + "vision": len(list.Projectors) > 0, + "gated": list.Gated, + "hf_token": hfTokenSet(), }) } From 7f5765160da013abd349b4000d3d33302696c25c Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 20 Aug 2026 10:32:44 +0000 Subject: [PATCH 2/2] =?UTF-8?q?Jeton=20Hugging=20Face=20r=C3=A9glable=20da?= =?UTF-8?q?ns=20l'interface?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Le jeton ne vivait que dans la variable d'environnement HF_TOKEN : découvrir depuis l'interface qu'un dépôt est verrouillé, c'était devoir éditer un docker-compose et recréer le conteneur pour y répondre. - Éditeur de preset → Modèle → « Jeton Hugging Face » : ligne repliée comme « Dossiers de modèles », qui affiche l'état (aucun / masqué / fourni par l'environnement). Le bandeau d'un dépôt verrouillé y mène d'un clic. - Le jeton est VÉRIFIÉ auprès de /api/whoami-v2 avant d'être enregistré (le compte s'affiche) : un jeton mal collé accepté en silence rendrait le 401 qu'on cherchait à expliquer. Il est rangé avec les secrets en base d'état, pas dans config.env que le changement de preset réécrit en bloc, et n'est jamais renvoyé en clair — seulement masqué. - Priorité : jeton enregistré, puis HF_TOKEN. Rien d'enregistré = comportement d'avant à l'identique. L'enregistrement vide le cache des réponses obtenues sans jeton. - Le jeton n'est envoyé qu'aux adresses Hugging Face : un lien collé vers un autre hébergeur n'a aucune raison de recevoir un secret. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01Hz1QWmvZqW3t53YC5SJBKC --- README.md | 11 ++- internal/loki/backend_hf.go | 117 +++++++++++++++++++++++- internal/loki/backend_hf_test.go | 114 +++++++++++++++++++++++ internal/loki/backend_models.go | 14 ++- internal/loki/backend_models_dl_test.go | 2 +- internal/loki/ui/index.html | 102 ++++++++++++++++++++- internal/loki/ui/src/index.tmpl.html | 17 ++++ internal/loki/ui/src/js/07-models.js | 85 ++++++++++++++++- internal/loki/web_hf.go | 51 +++++++++++ internal/loki/web_server.go | 1 + 10 files changed, 498 insertions(+), 16 deletions(-) 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)