mirror of
https://github.com/R0m1k3/Loki.git
synced 2026-10-11 17:26:57 +02:00
Dépôts Hugging Face verrouillés : dit lesquels, et pourquoi le 401
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 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Hz1QWmvZqW3t53YC5SJBKC
This commit is contained in:
9 files changed
+362
-6
No files matched your search
@@ -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
|
(dépôt privé, fichier hors des conventions). Un dépôt à accès restreint demande
|
||||||
la variable d'environnement `HF_TOKEN`.
|
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
|
## Installation sur Unraid
|
||||||
|
|
||||||
L'image est construite et publiée par GitHub Actions sur GHCR
|
L'image est construite et publiée par GitHub Actions sur GHCR
|
||||||
|
|||||||
@@ -67,6 +67,11 @@ type hfRepo struct {
|
|||||||
ID string `json:"id"`
|
ID string `json:"id"`
|
||||||
Downloads int `json:"downloads"`
|
Downloads int `json:"downloads"`
|
||||||
Likes int `json:"likes"`
|
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 :
|
// 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é.
|
// hfListing — le contenu utile d'un dépôt, trié.
|
||||||
type hfListing struct {
|
type hfListing struct {
|
||||||
Repo string `json:"repo"`
|
Repo string `json:"repo"`
|
||||||
|
Gated bool `json:"gated"`
|
||||||
Models []hfEntry `json:"models"`
|
Models []hfEntry `json:"models"`
|
||||||
Projectors []hfEntry `json:"projectors"`
|
Projectors []hfEntry `json:"projectors"`
|
||||||
Drafts []hfEntry `json:"drafts"`
|
Drafts []hfEntry `json:"drafts"`
|
||||||
@@ -172,29 +178,94 @@ func hfSearch(ctx context.Context, q string) ([]hfRepo, error) {
|
|||||||
if len(q) > hfMaxQuery {
|
if len(q) > hfMaxQuery {
|
||||||
q = 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{
|
endpoint := hfHost + "/api/models?" + url.Values{
|
||||||
"search": {q},
|
"search": {q},
|
||||||
"filter": {"gguf"},
|
"filter": {"gguf"},
|
||||||
"limit": {fmt.Sprint(hfMaxRepos)},
|
"limit": {fmt.Sprint(hfMaxRepos)},
|
||||||
"sort": {"downloads"},
|
"sort": {"downloads"},
|
||||||
"direction": {"-1"},
|
"direction": {"-1"},
|
||||||
|
"expand[]": {"gated", "downloads", "likes"},
|
||||||
}.Encode()
|
}.Encode()
|
||||||
|
|
||||||
var raw []struct {
|
var raw []struct {
|
||||||
ID string `json:"id"`
|
ID string `json:"id"`
|
||||||
Downloads int `json:"downloads"`
|
Downloads int `json:"downloads"`
|
||||||
Likes int `json:"likes"`
|
Likes int `json:"likes"`
|
||||||
|
Gated any `json:"gated"`
|
||||||
}
|
}
|
||||||
if err := hfGetJSON(ctx, endpoint, hfSearchTTL, &raw); err != nil {
|
if err := hfGetJSON(ctx, endpoint, hfSearchTTL, &raw); err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
out := make([]hfRepo, 0, len(raw))
|
out := make([]hfRepo, 0, len(raw))
|
||||||
for _, r := range 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
|
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.
|
// 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
|
// `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})
|
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.
|
// hfFile — une entrée d'arborescence, réduite à ce dont le classement a besoin.
|
||||||
|
|||||||
@@ -205,3 +205,41 @@ func TestFitVerdict(t *testing.T) {
|
|||||||
t.Errorf("le contexte ne change rien au verdict (%q dans les deux cas)", court)
|
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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -514,6 +514,75 @@ func contentRangeTotal(v string) int64 {
|
|||||||
return n
|
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
|
// dlProbe asks the server for the first byte to learn the total size and
|
||||||
// whether ranges are supported (206 + Content-Range).
|
// whether ranges are supported (206 + Content-Range).
|
||||||
func dlProbe(ctx context.Context, dlURL string) (total int64, ranged bool, err error) {
|
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.
|
// Server ignored the Range: single stream, ContentLength is the size.
|
||||||
return resp.ContentLength, false, nil
|
return resp.ContentLength, false, nil
|
||||||
default:
|
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
|
continue
|
||||||
}
|
}
|
||||||
if resp.StatusCode != 200 && resp.StatusCode != 206 {
|
if resp.StatusCode != 200 && resp.StatusCode != 206 {
|
||||||
|
err := dlSourceError(resp, dlURL)
|
||||||
resp.Body.Close()
|
resp.Body.Close()
|
||||||
return fmt.Errorf("HTTP %d depuis la source", resp.StatusCode)
|
return err
|
||||||
}
|
}
|
||||||
if resp.StatusCode == 200 && pos > start {
|
if resp.StatusCode == 200 && pos > start {
|
||||||
// Resume refused: the body restarts from 0, rewind our bookkeeping.
|
// Resume refused: the body restarts from 0, rewind our bookkeeping.
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ import (
|
|||||||
"os"
|
"os"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
"strconv"
|
"strconv"
|
||||||
|
"strings"
|
||||||
"testing"
|
"testing"
|
||||||
"time"
|
"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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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.ok{color:var(--ok);border-color:var(--ok)}
|
||||||
.hf-fit.juste{color:var(--warn);border-color:var(--warn)}
|
.hf-fit.juste{color:var(--warn);border-color:var(--warn)}
|
||||||
.hf-fit.trop{color:var(--err);border-color:var(--err)}
|
.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-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;
|
.hf-mm{display:flex;align-items:center;gap:6px;margin-top:8px;font-size:11.5px;
|
||||||
color:var(--dim);cursor:pointer}
|
color:var(--dim);cursor:pointer}
|
||||||
@@ -5272,11 +5276,29 @@ async function hfSearch(){
|
|||||||
const m = document.createElement('span'); m.className = 'hf-meta';
|
const m = document.createElement('span'); m.className = 'hf-meta';
|
||||||
m.textContent = fmtCount(rep.downloads)+' ↓';
|
m.textContent = fmtCount(rep.downloads)+' ↓';
|
||||||
row.append(n, m);
|
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);
|
list.appendChild(row);
|
||||||
}
|
}
|
||||||
o.appendChild(list);
|
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
|
// 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.
|
// « celui-là est le plus utilisé », pas son compte exact.
|
||||||
function fmtCount(n){
|
function fmtCount(n){
|
||||||
@@ -5304,8 +5326,23 @@ async function hfPickRepo(repo){
|
|||||||
back.onclick = hfSearch;
|
back.onclick = hfSearch;
|
||||||
const title = document.createElement('span'); title.className = 'hf-name'; title.textContent = repo;
|
const title = document.createElement('span'); title.className = 'hf-name'; title.textContent = repo;
|
||||||
head.append(back, title);
|
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);
|
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
|
// 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
|
// 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
|
// moteur qui démarre et ne voit rien. Quand le dépôt n'en publie pas, on le
|
||||||
|
|||||||
@@ -1093,11 +1093,29 @@ async function hfSearch(){
|
|||||||
const m = document.createElement('span'); m.className = 'hf-meta';
|
const m = document.createElement('span'); m.className = 'hf-meta';
|
||||||
m.textContent = fmtCount(rep.downloads)+' ↓';
|
m.textContent = fmtCount(rep.downloads)+' ↓';
|
||||||
row.append(n, m);
|
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);
|
list.appendChild(row);
|
||||||
}
|
}
|
||||||
o.appendChild(list);
|
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
|
// 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.
|
// « celui-là est le plus utilisé », pas son compte exact.
|
||||||
function fmtCount(n){
|
function fmtCount(n){
|
||||||
@@ -1125,8 +1143,23 @@ async function hfPickRepo(repo){
|
|||||||
back.onclick = hfSearch;
|
back.onclick = hfSearch;
|
||||||
const title = document.createElement('span'); title.className = 'hf-name'; title.textContent = repo;
|
const title = document.createElement('span'); title.className = 'hf-name'; title.textContent = repo;
|
||||||
head.append(back, title);
|
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);
|
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
|
// 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
|
// 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
|
// moteur qui démarre et ne voit rien. Quand le dépôt n'en publie pas, on le
|
||||||
|
|||||||
@@ -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.ok{color:var(--ok);border-color:var(--ok)}
|
||||||
.hf-fit.juste{color:var(--warn);border-color:var(--warn)}
|
.hf-fit.juste{color:var(--warn);border-color:var(--warn)}
|
||||||
.hf-fit.trop{color:var(--err);border-color:var(--err)}
|
.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-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;
|
.hf-mm{display:flex;align-items:center;gap:6px;margin-top:8px;font-size:11.5px;
|
||||||
color:var(--dim);cursor:pointer}
|
color:var(--dim);cursor:pointer}
|
||||||
|
|||||||
@@ -26,7 +26,7 @@ func handleHFSearch(w http.ResponseWriter, r *http.Request) {
|
|||||||
sendJSON(w, 502, map[string]any{"ok": false, "error": err.Error()})
|
sendJSON(w, 502, map[string]any{"ok": false, "error": err.Error()})
|
||||||
return
|
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
|
// 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
|
// mmproj dans CE dépôt, pas un tag Hugging Face — lequel manque sur des
|
||||||
// dépôts qui en publient pourtant un.
|
// dépôts qui en publient pourtant un.
|
||||||
// Un projecteur ne reçoit pas de verdict : il ne se charge jamais seul.
|
// 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{
|
sendJSON(w, 200, map[string]any{
|
||||||
"ok": true, "hardware": hw, "ctx": ctxTokens,
|
"ok": true, "hardware": hw, "ctx": ctxTokens,
|
||||||
"repo": list.Repo, "models": list.Models,
|
"repo": list.Repo, "models": list.Models,
|
||||||
"projectors": list.Projectors, "drafts": list.Drafts,
|
"projectors": list.Projectors, "drafts": list.Drafts,
|
||||||
"vision": len(list.Projectors) > 0,
|
"vision": len(list.Projectors) > 0,
|
||||||
|
"gated": list.Gated,
|
||||||
|
"hf_token": hfTokenSet(),
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
Reference in new issue
Block a user