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:
Claude committed 2026-08-20 10:18:55 +00:00
1 parent d7214d97eb
commit b339cced0a
9 files changed
+361 -5

No files matched your search

+7
View File
@@ -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
+75 -2
View File
@@ -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.
+38
View File
@@ -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)
}
}
}
+72 -2
View File
@@ -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.
+88
View File
@@ -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)
}
}
+37
View File
@@ -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
+33
View File
@@ -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
+4
View File
@@ -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}
+7 -1
View File
@@ -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,
"gated": list.Gated,
"hf_token": hfTokenSet(),
})
}