mirror of
https://github.com/R0m1k3/Loki.git
synced 2026-10-23 06:58:14 +02:00
Merge pull request #14 from R0m1k3/claude/new-session-hrmmyc
Support Hugging Face gated repositories with configurable tokens
This commit is contained in:
11 files changed
+848
-10
No files matched your search
@@ -77,6 +77,22 @@ 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.
|
||||||
|
|
||||||
|
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
|
## 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
|
||||||
@@ -105,7 +121,7 @@ redémarrages (volume `/data`). Variables d'environnement du conteneur :
|
|||||||
| `LOKI_NGL` | couches GPU initiales | `999` (tout) |
|
| `LOKI_NGL` | couches GPU initiales | `999` (tout) |
|
||||||
| `LOKI_HOME` | données (volume) | `/data` |
|
| `LOKI_HOME` | données (volume) | `/data` |
|
||||||
| `LOKI_MODEL_DIRS` | dossiers .gguf additionnels | `/models` |
|
| `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 :
|
En CLI dans le conteneur : `docker exec -it loki loki status` (aussi :
|
||||||
`logs`, `restart`, `config`, `bench`, `test`…).
|
`logs`, `restart`, `config`, `bench`, `test`…).
|
||||||
|
|||||||
+183
-3
@@ -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"`
|
||||||
@@ -95,13 +101,115 @@ type hfListing struct {
|
|||||||
// transfert : sans lui un dépôt gated répond 401 et Loki afficherait « aucun
|
// 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 ».
|
// résultat » là où il faudrait dire « dépôt à accès restreint ».
|
||||||
func hfAuth(req *http.Request) {
|
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("Authorization", "Bearer "+k)
|
||||||
}
|
}
|
||||||
req.Header.Set("User-Agent", "loki/"+Version)
|
req.Header.Set("User-Agent", "loki/"+Version)
|
||||||
req.Header.Set("Accept", "application/json")
|
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
|
// 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
|
// appelée. Sans lui, chaque frappe au clavier dans le champ de recherche
|
||||||
// produit une requête sortante.
|
// produit une requête sortante.
|
||||||
@@ -172,29 +280,99 @@ 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)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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
|
||||||
|
// 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 +407,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.
|
||||||
|
|||||||
@@ -1,6 +1,10 @@
|
|||||||
package loki
|
package loki
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"net/http/httptest"
|
||||||
"strings"
|
"strings"
|
||||||
"testing"
|
"testing"
|
||||||
)
|
)
|
||||||
@@ -205,3 +209,151 @@ 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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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())
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -489,8 +489,12 @@ func dlRequest(ctx context.Context, dlURL, rng string) (*http.Request, error) {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
// HF gated/private repos may need a token; reuse the same key store if set.
|
// Dépôt gated/privé : le jeton (réglage de l'interface, sinon HF_TOKEN) est
|
||||||
if k := os.Getenv("HF_TOKEN"); k != "" {
|
// 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("Authorization", "Bearer "+k)
|
||||||
}
|
}
|
||||||
req.Header.Set("User-Agent", "loki/"+Version)
|
req.Header.Set("User-Agent", "loki/"+Version)
|
||||||
@@ -514,6 +518,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 !hfTokenSet() {
|
||||||
|
return " — puis renseigne le jeton Hugging Face (réglage « Jeton Hugging Face », ou variable d'environnement HF_TOKEN)"
|
||||||
|
}
|
||||||
|
return " — le jeton Hugging Face enregistré n'y donne pas accès (expiré, ou conditions non acceptées avec ce compte)"
|
||||||
|
}
|
||||||
|
|
||||||
// dlProbe asks the server for the first byte to learn the total size and
|
// 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 +610,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 +836,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 le jeton"},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
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}
|
||||||
@@ -2936,6 +2940,23 @@ html[data-files="1"] #files-btn{color:var(--accent)}
|
|||||||
</div>
|
</div>
|
||||||
<div class="pe-note">Ces dossiers servent aussi de destination de téléchargement.</div>
|
<div class="pe-note">Ces dossiers servent aussi de destination de téléchargement.</div>
|
||||||
</div>
|
</div>
|
||||||
|
<!-- Jeton Hugging Face. Replié comme « Dossiers de modèles » : on n'y
|
||||||
|
touche qu'une fois, le jour où un dépôt à accès restreint refuse
|
||||||
|
le téléchargement. Il vaut aussi pour la recherche, d'où sa place
|
||||||
|
dans ce groupe plutôt qu'à côté de la clé API du serveur, qui ne
|
||||||
|
parle pas du tout de la même chose. -->
|
||||||
|
<div id="m-hft-toggle" class="pe-row" style="cursor:pointer" onclick="toggleHFToken()">
|
||||||
|
<span class="pe-row-l">Jeton Hugging Face<span class="pe-sub">dépôts à accès restreint (« gated ») ou privés</span></span>
|
||||||
|
<span class="pe-row-c"><span id="m-hft-state" class="pe-sub"></span><span id="m-hft-caret" class="pe-caret"></span></span>
|
||||||
|
</div>
|
||||||
|
<div id="m-hft-body" class="pe-row stack" style="display:none">
|
||||||
|
<div class="pe-inpbtn">
|
||||||
|
<input id="m-hft-input" class="pe-inp" type="password" autocomplete="off" spellcheck="false" placeholder="hf_…" onkeydown="if(event.key==='Enter'){event.preventDefault();saveHFToken();}">
|
||||||
|
<button id="m-hft-save" onclick="saveHFToken()" title="Vérifier et enregistrer" aria-label="Enregistrer">✓</button>
|
||||||
|
</div>
|
||||||
|
<div id="m-hft-note" class="pe-note"></div>
|
||||||
|
<button id="m-hft-clear" class="pe-link" onclick="clearHFToken()" style="display:none">Retirer le jeton</button>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -4300,6 +4321,10 @@ async function openItem(kind, key){
|
|||||||
// Dossiers de modèles : replié par défaut (réglage rare), chargé à l'ouverture.
|
// Dossiers de modèles : replié par défaut (réglage rare), chargé à l'ouverture.
|
||||||
document.getElementById('m-dir-path').value = '';
|
document.getElementById('m-dir-path').value = '';
|
||||||
setModelDirsOpen(false);
|
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 {
|
} else {
|
||||||
modelRow.style.display = 'none';
|
modelRow.style.display = 'none';
|
||||||
settingsRow.style.display = 'none';
|
settingsRow.style.display = 'none';
|
||||||
@@ -4440,6 +4465,70 @@ function setModelDirsOpen(open){
|
|||||||
function toggleModelDirs(){
|
function toggleModelDirs(){
|
||||||
setModelDirsOpen(document.getElementById('m-dirs-body').style.display === 'none');
|
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(){
|
async function populateModelDirs(){
|
||||||
const box = document.getElementById('m-dirs-list');
|
const box = document.getElementById('m-dirs-list');
|
||||||
if(!box) return;
|
if(!box) return;
|
||||||
@@ -5272,11 +5361,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 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
|
// 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 +5411,36 @@ 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);
|
||||||
|
// 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
|
// 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
|
||||||
|
|||||||
@@ -864,6 +864,23 @@ document.documentElement.setAttribute('data-side',localStorage.getItem('loki-sid
|
|||||||
</div>
|
</div>
|
||||||
<div class="pe-note">Ces dossiers servent aussi de destination de téléchargement.</div>
|
<div class="pe-note">Ces dossiers servent aussi de destination de téléchargement.</div>
|
||||||
</div>
|
</div>
|
||||||
|
<!-- Jeton Hugging Face. Replié comme « Dossiers de modèles » : on n'y
|
||||||
|
touche qu'une fois, le jour où un dépôt à accès restreint refuse
|
||||||
|
le téléchargement. Il vaut aussi pour la recherche, d'où sa place
|
||||||
|
dans ce groupe plutôt qu'à côté de la clé API du serveur, qui ne
|
||||||
|
parle pas du tout de la même chose. -->
|
||||||
|
<div id="m-hft-toggle" class="pe-row" style="cursor:pointer" onclick="toggleHFToken()">
|
||||||
|
<span class="pe-row-l">Jeton Hugging Face<span class="pe-sub">dépôts à accès restreint (« gated ») ou privés</span></span>
|
||||||
|
<span class="pe-row-c"><span id="m-hft-state" class="pe-sub"></span><span id="m-hft-caret" class="pe-caret"></span></span>
|
||||||
|
</div>
|
||||||
|
<div id="m-hft-body" class="pe-row stack" style="display:none">
|
||||||
|
<div class="pe-inpbtn">
|
||||||
|
<input id="m-hft-input" class="pe-inp" type="password" autocomplete="off" spellcheck="false" placeholder="hf_…" onkeydown="if(event.key==='Enter'){event.preventDefault();saveHFToken();}">
|
||||||
|
<button id="m-hft-save" onclick="saveHFToken()" title="Vérifier et enregistrer" aria-label="Enregistrer">✓</button>
|
||||||
|
</div>
|
||||||
|
<div id="m-hft-note" class="pe-note"></div>
|
||||||
|
<button id="m-hft-clear" class="pe-link" onclick="clearHFToken()" style="display:none">Retirer le jeton</button>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@@ -121,6 +121,10 @@ async function openItem(kind, key){
|
|||||||
// Dossiers de modèles : replié par défaut (réglage rare), chargé à l'ouverture.
|
// Dossiers de modèles : replié par défaut (réglage rare), chargé à l'ouverture.
|
||||||
document.getElementById('m-dir-path').value = '';
|
document.getElementById('m-dir-path').value = '';
|
||||||
setModelDirsOpen(false);
|
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 {
|
} else {
|
||||||
modelRow.style.display = 'none';
|
modelRow.style.display = 'none';
|
||||||
settingsRow.style.display = 'none';
|
settingsRow.style.display = 'none';
|
||||||
@@ -261,6 +265,70 @@ function setModelDirsOpen(open){
|
|||||||
function toggleModelDirs(){
|
function toggleModelDirs(){
|
||||||
setModelDirsOpen(document.getElementById('m-dirs-body').style.display === 'none');
|
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(){
|
async function populateModelDirs(){
|
||||||
const box = document.getElementById('m-dirs-list');
|
const box = document.getElementById('m-dirs-list');
|
||||||
if(!box) return;
|
if(!box) return;
|
||||||
@@ -1093,11 +1161,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 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
|
// 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 +1211,36 @@ 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);
|
||||||
|
// 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
|
// 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}
|
||||||
|
|||||||
+59
-2
@@ -4,12 +4,14 @@ package loki
|
|||||||
//
|
//
|
||||||
// GET /api/hf/search?q=… dépôts GGUF correspondants
|
// 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 /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 à
|
// 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
|
// le demander séparément et n'affiche jamais une liste de tailles sans le
|
||||||
// budget en face.
|
// budget en face.
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"encoding/json"
|
||||||
"net/http"
|
"net/http"
|
||||||
"strconv"
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
@@ -26,7 +28,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 +69,65 @@ 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(),
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 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,
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -184,6 +184,7 @@ func newWebMux() *http.ServeMux {
|
|||||||
api("/api/models/download/cancel", handleModelDownloadCancel)
|
api("/api/models/download/cancel", handleModelDownloadCancel)
|
||||||
api("/api/hf/search", handleHFSearch) // chercher un modèle GGUF sur Hugging Face
|
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/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", handleBackends)
|
||||||
api("/api/backends/custom", handleBackendsCustom) // backends custom uniquement (hors ⚡/🔧)
|
api("/api/backends/custom", handleBackendsCustom) // backends custom uniquement (hors ⚡/🔧)
|
||||||
api("/api/backends/devices", handleBackendDevices) // GPU vus par CE moteur (noms/ordre propres au backend)
|
api("/api/backends/devices", handleBackendDevices) // GPU vus par CE moteur (noms/ordre propres au backend)
|
||||||
|
|||||||
Reference in new issue
Block a user