Lot 8 : téléchargements de modèles tenus par la façade (LOKI_DOWNLOADS=rust)

La façade Rust mène les téléchargements de .gguf quand LOKI_DOWNLOADS=rust
(expérimental ; le Go reste le défaut) : sonde, lancement, suivi,
annulation, avec les mêmes contrats et la même mécanique que le Go
(tranches rapatriées en entier, plages d'octets parallèles, reprise, place
disque vérifiée sur le total, jeton réservé aux hôtes Hugging Face, refus
traduits, .part vidés au démarrage).

Côté Go : facade_downloads.go remet ces routes à la façade quand elles
arrivent par le tunnel du relais, et le Go ne vide plus les .part qu'elle
mène.

Parité : journal des fonctions pures tiré du Go (parity_dl_test.go) et
rejoué en Rust ; téléchargements de bout en bout contre un faux Hugging
Face (tests/downloads.rs).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016tcGfJWefW5kUGoC2wL8cv
This commit is contained in:
Claude committed 2026-10-15 13:11:07 +00:00
1 parent 98196e1730
commit bbea5fcd3f
20 files changed
+2820 -17

No files matched your search

+1
View File
@@ -228,6 +228,7 @@ redémarrages (volume `/data`). Variables d'environnement du conteneur :
| `LOKI_FACADE` | `0` : sans façade Rust, le Go sert directement le port public (porte de sortie) | `1` |
| `LOKI_GO_PORT` | port local (127.0.0.1) du serveur Go derrière la façade | `8091` |
| `LOKI_CHAT` | `rust` : la conversation est tenue par la façade Rust (expérimental, migration en cours), outils fichiers et shell compris. Le Go garde les autres outils, les pièces jointes, les fichiers et l'export. Avec `PROJ_SNAPSHOT`, `KEEP_TURN_IMAGES`, `NUDGE_IN_TOOL`, `SIDE_SLOT`, `SLOT_PERSIST`, `PREWARM` ou des motifs `retry_patterns`, le Go garde la conversation (décidé au démarrage) | Go |
| `LOKI_DOWNLOADS` | `rust` : les téléchargements de modèles (`/api/models/download*`) sont menés par la façade Rust (expérimental, migration en cours). Le Go garde les autres routes des modèles | Go |
**Accès par nom de domaine.** Sans clé de pilotage, l'API n'accepte que les
hôtes locaux (IP, `localhost`, nom sans point, `.local`/`.lan`…) et refuse
@@ -0,0 +1,76 @@
# Lot 8 — téléchargements de modèles tenus par la façade
## Contexte
Les téléchargements de `.gguf` (Hugging Face ou lien direct) vivaient dans le
Go : `backend_models.go`, `backend_models_shards.go`, `backend_model_dirs.go`,
`backend_hf.go`. Lot 8 : la façade les tient quand `LOKI_DOWNLOADS=rust`
(expérimental ; sans la variable rien ne change, tout part au Go).
## Ce que sert la façade (`rust/loki-rs/src/downloads.rs`)
| Route | Rôle |
|---|---|
| `POST /api/models/download/probe` | taille du modèle (toutes ses tranches), espace libre, « ça tient ? », ou « déjà là » avec la valeur de `MODEL=` |
| `POST /api/models/download` | lance en arrière-plan ; 409 si déjà en cours ; « déjà là » si toutes les tranches sont présentes |
| `GET /api/models/download/status` | état de chaque téléchargement de ce lancement |
| `POST /api/models/download/cancel` | annule ; ce que le téléchargement a écrit est effacé |
Mêmes contrats (corps, codes, messages) et même mécanique que le Go :
- lien de page Hugging Face (`/blob/`) converti en lien direct ;
- modèle en tranches rapatrié en entier (la première le désigne) ;
- tranches déjà là non retéléchargées ;
- place disque vérifiée sur le total, avec 256 Mio de marge ; jamais de refus
sur FUSE, overlayfs, NFS, SMB, Ceph ou GlusterFS, où l'espace annoncé
approxime ;
- jusqu'à `LOKI_DL_CONNS` connexions (8 par défaut, 16 au plus) par tranche
quand la source honore les plages, 16 Mio par connexion au moins ;
- connexion coupée reprise où elle en était (4 essais) ;
- progression sur l'ensemble, vitesse lissée ;
- jeton Hugging Face (réglage, sinon `HF_TOKEN`) envoyé aux seuls hôtes
Hugging Face ;
- refus de la source traduits en phrase qui dit quoi faire (dépôt verrouillé,
jeton absent ou insuffisant, fichier absent, quota…) ;
- `.part` orphelins vidés au démarrage — par la façade : le Go ne le fait
plus, il effacerait un téléchargement qu'elle mène.
## Côté Go (`facade_downloads.go`)
Le tunnel du relais et la redistribution E2E arrivent au Go sans passer par
la façade : `withFacadeChat` lui remet les quatre routes, authentifiées par le
jeton interne, comme celles du chat. Façade injoignable : 502
« téléchargements indisponibles : … ».
La façade transmet `LOKI_DOWNLOADS=rust` et son adresse au Go qu'elle lance.
## Écarts assumés
- Une source qui ignore les plages (200 à la sonde) n'est plus lue en entier
pendant la sonde.
- Erreur de transport (réseau, TLS) au format de reqwest.
- Hôte d'un lien collé en majuscules converti comme les autres.
- Espace libre inconnu hors Unix : rien n'est refusé (comme une mesure ratée
côté Go).
- `/status` trié par date de début (le Go rendait l'ordre d'une map).
## Parité
`internal/loki/parity_dl_test.go` tire du Go le journal des fonctions pures
(lien normalisé, famille de tranches, `Content-Range`, messages de refus,
hôtes et dépôts Hugging Face, dossier de destination), rangé dans
`rust/loki-rs/tests/parity_models/download.json` ; `tests/downloads.rs` le
rejoue (`LOKI_PARITY_UPDATE=1` pour régénérer). Une mutation
(`replacen` → `replace` sur `/blob/`) fait échouer le rejeu.
`tests/downloads.rs` mène aussi le téléchargement de bout en bout contre un
faux Hugging Face : trois connexions sur des plages disjointes, source sans
plages, tranches avec une déjà présente, dépôt verrouillé refusé dès la sonde,
panne en cours de deuxième tranche, annulation, routes par la façade (clé de
pilotage, remise du Go), routes laissées au Go sans la variable.
## Reste
- Les autres routes des modèles (`/api/models`, suppression, dossiers, liste
Hugging Face, vision) restent au Go.
- Essai réel sur une installation avant d'en faire le défaut.
+15 -8
View File
@@ -141,23 +141,30 @@ var facadeChatRoutes = map[string]bool{
}
// withFacadeChat remet la requête à la façade quand elle tient la
// conversation. Posé APRÈS requireWebAuth : seule une requête authentifiée
// ici part, avec le jeton interne qui en tient lieu là-bas.
// conversation — ou les téléchargements de modèles (facade_downloads.go).
// Posé APRÈS requireWebAuth : seule une requête authentifiée ici part, avec le
// jeton interne qui en tient lieu là-bas.
func withFacadeChat(path string, h http.HandlerFunc) http.HandlerFunc {
if !facadeChatRoutes[path] {
held, unavailable := facadeChat, "conversation indisponible : "
switch {
case facadeChatRoutes[path]:
case facadeDownloadRoutes[path]:
held, unavailable = facadeDownloads, "téléchargements indisponibles : "
default:
return h
}
return func(w http.ResponseWriter, r *http.Request) {
if !facadeChat() {
if !held() {
h(w, r)
return
}
facadeRelay(w, r)
facadeRelayAs(w, r, unavailable)
}
}
// facadeRelay : relais transparent (flux compris) vers la façade.
func facadeRelay(w http.ResponseWriter, r *http.Request) {
// facadeRelayAs : relais transparent (flux compris) vers la façade ;
// unavailable préfixe l'erreur si elle ne répond pas.
func facadeRelayAs(w http.ResponseWriter, r *http.Request, unavailable string) {
rp := &httputil.ReverseProxy{
Rewrite: func(pr *httputil.ProxyRequest) {
pr.SetURL(&url.URL{Scheme: "http", Host: facadeAddr()})
@@ -165,7 +172,7 @@ func facadeRelay(w http.ResponseWriter, r *http.Request) {
},
FlushInterval: -1, // SSE : chaque trame part aussitôt
ErrorHandler: func(w http.ResponseWriter, _ *http.Request, err error) {
sendJSON(w, http.StatusBadGateway, map[string]any{"ok": false, "error": "conversation indisponible : " + err.Error()})
sendJSON(w, http.StatusBadGateway, map[string]any{"ok": false, "error": unavailable + err.Error()})
},
}
rp.ServeHTTP(w, r)
+75
View File
@@ -5,6 +5,8 @@ import (
"io"
"net/http"
"net/http/httptest"
"os"
"path/filepath"
"strings"
"sync"
"testing"
@@ -303,3 +305,76 @@ func TestFacadeReglageQuiGardeLaConversation(t *testing.T) {
t.Fatalf("sans la variable : %s", w.Body.String())
}
}
// LOKI_DOWNLOADS=rust : les routes des téléchargements de modèles partent à la
// façade (avec ou sans LOKI_CHAT), les autres routes des modèles restent ici ;
// sans la variable, rien ne change. Façade muette : erreur qui le dit.
func TestFacadeRelaieLesTelechargements(t *testing.T) {
testHome(t)
f := demarrerFacade(t)
t.Setenv(facadeChatEnv, "")
t.Setenv(facadeDownloadsEnv, "rust")
local := func(w http.ResponseWriter, r *http.Request) { io.WriteString(w, "go") }
for _, p := range []string{"/api/models/download", "/api/models/download/probe",
"/api/models/download/status", "/api/models/download/cancel"} {
w := httptest.NewRecorder()
withFacadeChat(p, local)(w, httptest.NewRequest("POST", p, strings.NewReader(`{"url":"u"}`)))
if w.Body.String() != f.reply {
t.Fatalf("%s : réponse %q", p, w.Body.String())
}
}
if got := f.recu(); len(got) != 4 || got[0] != `POST /api/models/download jeton {"url":"u"}` {
t.Fatalf("reçu par la façade : %v", got)
}
// Le chat, lui, reste ici (LOKI_CHAT absent), et les autres routes des
// modèles aussi.
for _, p := range []string{"/api/chat/send", "/api/models", "/api/models/delete"} {
w := httptest.NewRecorder()
withFacadeChat(p, local)(w, httptest.NewRequest("POST", p, nil))
if w.Body.String() != "go" {
t.Fatalf("%s relayé : %q", p, w.Body.String())
}
}
// Façade injoignable : 502 qui nomme ce qui manque.
f.srv.Close()
w := httptest.NewRecorder()
withFacadeChat("/api/models/download/status", local)(w, httptest.NewRequest("GET", "/api/models/download/status", nil))
if w.Code != http.StatusBadGateway || !strings.Contains(w.Body.String(), "téléchargements indisponibles : ") {
t.Fatalf("façade muette : %d %s", w.Code, w.Body.String())
}
// Sans LOKI_DOWNLOADS=rust : tout reste ici.
t.Setenv(facadeDownloadsEnv, "")
w = httptest.NewRecorder()
withFacadeChat("/api/models/download", local)(w, httptest.NewRequest("POST", "/api/models/download", nil))
if w.Body.String() != "go" {
t.Fatalf("relayé sans la variable : %q", w.Body.String())
}
}
// Les .part orphelins ne sont plus vidés par ce Go quand la façade tient les
// téléchargements : elle les vide à son démarrage, et un Go relancé seul
// effacerait celui qu'elle mène.
func TestFacadeTelechargementsGardeLesPart(t *testing.T) {
home := testHome(t)
dir := filepath.Join(home, "models")
if err := os.MkdirAll(dir, 0o755); err != nil {
t.Fatal(err)
}
part := filepath.Join(dir, "m.gguf.part")
if err := os.WriteFile(part, []byte("x"), 0o644); err != nil {
t.Fatal(err)
}
t.Setenv(internalTokenEnv, jetonTest)
t.Setenv(facadeAddrEnv, "127.0.0.1:1")
t.Setenv(facadeDownloadsEnv, "rust")
cleanStartupPartFiles()
if _, err := os.Stat(part); err != nil {
t.Fatalf(".part effacé alors que la façade tient les téléchargements : %v", err)
}
t.Setenv(facadeDownloadsEnv, "")
cleanStartupPartFiles()
if _, err := os.Stat(part); !os.IsNotExist(err) {
t.Fatalf(".part orphelin gardé : %v", err)
}
}
+45
View File
@@ -0,0 +1,45 @@
package loki
import (
"os"
"strings"
)
// facade_downloads.go — téléchargements de modèles tenus par la façade Rust
// (LOKI_DOWNLOADS=rust, lot « modèles » de la migration ; expérimental, le Go
// reste le défaut).
//
// La façade sert elle-même /api/models/download* sur le port public. Le tunnel
// du relais et sa redistribution E2E arrivent pourtant ICI : ces routes lui
// sont remises, sans quoi un téléchargement lancé depuis le téléphone vivrait
// dans ce process, que le poste — servi par la façade — ne verrait pas.
//
// Les .part orphelins sont vidés par la façade à son démarrage : les vider ici
// (un Go relancé seul après un plantage) effacerait sous ses pieds un
// téléchargement qu'elle mène.
const facadeDownloadsEnv = "LOKI_DOWNLOADS"
// facadeDownloads : la façade tient-elle les téléchargements de modèles ? Il
// faut aussi son adresse et le jeton interne, sans lesquels rien ne peut lui
// être remis.
func facadeDownloads() bool {
return strings.EqualFold(strings.TrimSpace(os.Getenv(facadeDownloadsEnv)), "rust") &&
facadeAddr() != "" && internalToken() != ""
}
// cleanStartupPartFiles vide les .part orphelins au démarrage — sauf quand la
// façade tient les téléchargements : elle le fait à SON démarrage.
func cleanStartupPartFiles() {
if !facadeDownloads() {
cleanStalePartFiles()
}
}
// facadeDownloadRoutes : les routes que la façade sert alors (downloads.rs).
var facadeDownloadRoutes = map[string]bool{
"/api/models/download": true,
"/api/models/download/probe": true,
"/api/models/download/status": true,
"/api/models/download/cancel": true,
}
+182
View File
@@ -0,0 +1,182 @@
package loki
import (
"net/http"
"os"
"path/filepath"
"runtime"
"strings"
"testing"
)
// parity_dl_test.go — journal de référence des fonctions pures des
// téléchargements de modèles (lot « modèles » de la migration Rust) : lien
// collé normalisé, famille de tranches, messages d'erreur de la source,
// dossier de destination. Rangé dans
// rust/loki-rs/tests/parity_models/download.json ; le test Rust
// (tests/downloads.rs) doit retrouver chaque sortie. LOKI_HOME réécrit en
// {{HOME}}.
//
// LOKI_PARITY_UPDATE=1 go test ./internal/loki -run TestPariteTelechargements
const parityDLFile = "../../rust/loki-rs/tests/parity_models/download.json"
type parityDLNorm struct {
In string `json:"in"`
URL string `json:"url,omitempty"`
Name string `json:"name,omitempty"`
Err string `json:"err,omitempty"`
URLs []string `json:"urls,omitempty"`
// Names : noms de la famille de tranches (shardURLSet).
Names []string `json:"names,omitempty"`
}
type parityDLSource struct {
Status int `json:"status"`
Code string `json:"code"`
URL string `json:"url"`
TokenSet bool `json:"token_set"`
Err string `json:"err"`
}
type parityDLDest struct {
Name string `json:"name"`
Dir string `json:"dir"`
Path string `json:"path,omitempty"`
Err string `json:"err,omitempty"`
}
type parityDLFixture struct {
Normalize []parityDLNorm `json:"normalize"`
ContentRange map[string]int64 `json:"content_range"`
Source []parityDLSource `json:"source"`
HFHost map[string]bool `json:"hf_host"`
HFRepo map[string]string `json:"hf_repo"`
Dest []parityDLDest `json:"dest"`
}
func TestPariteTelechargements(t *testing.T) {
if runtime.GOOS == "windows" {
t.Skip("journal tiré sous Unix")
}
home := testHome(t)
if r, err := filepath.EvalSymlinks(home); err == nil {
home = r
}
t.Setenv("LOKI_HOME", home)
t.Setenv("LOKI_MODEL_DIRS", "")
t.Setenv("HF_TOKEN", "")
norm := func(s string) string { return strings.ReplaceAll(s, home, "{{HOME}}") }
var fx parityDLFixture
for _, in := range []string{
"", " ", "ftp://h/a.gguf", "pas un lien", "https://exemple.org/dossier/",
"https://huggingface.co/u/r/blob/main/m.gguf",
"https://huggingface.co/u/r/blob/main/blob/m.gguf",
"https://huggingface.co/u/r/resolve/main/m-Q4_K_M.gguf?download=true",
" https://huggingface.co/u/r/blob/main/m.bin ",
"http://exemple.org/modeles/M.GGUF",
"https://huggingface.co/u/r/blob/main/Q4/m-Q4-00002-of-00003.gguf",
"https://huggingface.co/u/r/resolve/main/m-00001-of-00012.gguf",
"https://exemple.org/m-1-of-2.gguf",
"https://exemple.org/m-02-of-01.gguf",
} {
c := parityDLNorm{In: in}
u, name, err := normalizeHFURL(in)
if err != nil {
c.Err = err.Error()
} else {
c.URL, c.Name = u, name
c.URLs, c.Names = shardURLSet(u, name)
}
fx.Normalize = append(fx.Normalize, c)
}
fx.ContentRange = map[string]int64{}
for _, v := range []string{"bytes 0-0/12345", "bytes 0-0/*", "", "bytes 0-0/0", "bytes 0-0/ 99 "} {
fx.ContentRange[v] = contentRangeTotal(v)
}
hf := "https://huggingface.co/auteur/depot/resolve/main/m.gguf"
for _, c := range []parityDLSource{
{Status: 401, Code: "GatedRepo", URL: hf},
{Status: 401, Code: "GatedRepo", URL: hf, TokenSet: true},
{Status: 403, Code: "RepoNotFound", URL: hf},
{Status: 401, Code: "GatedRepo", URL: "https://exemple.org/m.gguf"},
{Status: 403, Code: "", URL: hf, TokenSet: true},
{Status: 403, Code: "", URL: "https://exemple.org/m.gguf"},
{Status: 404, Code: "EntryNotFound", URL: hf},
{Status: 404, Code: "EntryNotFound", URL: "https://exemple.org/"},
{Status: 404, Code: "RevisionNotFound", URL: hf},
{Status: 404, Code: "", URL: hf},
{Status: 416, URL: hf}, {Status: 429, URL: hf}, {Status: 502, URL: hf}, {Status: 418, URL: hf},
} {
tok := ""
if c.TokenSet {
tok = "hf_x"
}
t.Setenv("HF_TOKEN", tok)
resp := &http.Response{StatusCode: c.Status, Header: http.Header{}}
if c.Code != "" {
resp.Header.Set("X-Error-Code", c.Code)
}
c.Err = dlSourceError(resp, c.URL).Error()
fx.Source = append(fx.Source, c)
}
t.Setenv("HF_TOKEN", "")
fx.HFHost = map[string]bool{}
for _, h := range []string{"huggingface.co", "HUGGINGFACE.CO:443", "cdn-lfs.huggingface.co", "hf.co", "x.hf.co",
"evilhuggingface.co", "huggingface.co.evil.org", "exemple.org"} {
fx.HFHost[h] = isHFHost(h)
}
fx.HFRepo = map[string]string{}
for _, u := range []string{hf, "https://huggingface.co/a/b/blob/main/x.gguf", "https://huggingface.co/a/b/tree/main",
"https://huggingface.co/a/b", "https://exemple.org/a/b/resolve/main/x.gguf", "https://huggingface.co/-a/b/resolve/main/x.gguf"} {
fx.HFRepo[u] = hfRepoFromURL(u)
}
extra := filepath.Join(home, "disque")
if err := os.MkdirAll(extra, 0o755); err != nil {
t.Fatal(err)
}
if err := saveExtraModelDirs([]string{extra}); err != nil {
t.Fatal(err)
}
for _, c := range []parityDLDest{
{Name: "a.gguf"}, {Name: " ../../x.gguf "}, {Name: "x.bin"}, {Name: ""}, {Name: "/"},
{Name: "a.gguf", Dir: "/etc"}, {Name: "a.gguf", Dir: "{{HOME}}/disque"}, {Name: "a.gguf", Dir: "{{HOME}}/disque/"},
{Name: "a.gguf", Dir: "{{HOME}}/models"},
} {
p, err := downloadDestPath(c.Name, strings.ReplaceAll(c.Dir, "{{HOME}}", home))
if err != nil {
c.Err = norm(err.Error())
} else {
c.Path = norm(p)
}
fx.Dest = append(fx.Dest, c)
}
got, err := parityJSON(fx)
if err != nil {
t.Fatal(err)
}
if os.Getenv("LOKI_PARITY_UPDATE") == "1" {
if err := os.MkdirAll(filepath.Dir(parityDLFile), 0o755); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(parityDLFile, got, 0o644); err != nil {
t.Fatal(err)
}
return
}
want, err := os.ReadFile(parityDLFile)
if err != nil {
t.Fatalf("journal de référence absent (%v) : LOKI_PARITY_UPDATE=1 pour le créer", err)
}
if string(got) != string(want) {
t.Errorf("les téléchargements ne produisent plus le journal rangé dans %s.\n"+
"Si c'est voulu : LOKI_PARITY_UPDATE=1 go test ./internal/loki -run TestPariteTelechargements,\n"+
"puis cargo test --test downloads dit si le port Rust suit.\n--- obtenu ---\n%s", parityDLFile, got)
}
}
+6 -3
View File
@@ -130,8 +130,9 @@ func newWebMux() *http.ServeMux {
// secondes pour un serveur lancé via npx) est payé par le premier message.
MCPPrewarm()
// Un téléchargement de modèle coupé net (crash, restart du service) laisse un
// .part orphelin non reprenable : on nettoie au démarrage.
cleanStalePartFiles()
// .part orphelin non reprenable : on nettoie au démarrage (sauf quand la
// façade tient les téléchargements, facade_downloads.go).
cleanStartupPartFiles()
// Les fichiers des versions précédentes vivaient dans un pot commun : on les
// range dans le dossier de leur discussion (une fois par process).
migrateConvFiles()
@@ -218,7 +219,9 @@ func newWebMux() *http.ServeMux {
mountOAI(mux)
// api enregistre une route /api/* protégée par la clé de pilotage (web_auth.go).
// withFacadeChat : routes du chat remises à la façade quand elle tient la
// conversation (LOKI_CHAT=rust, facade_chat.go) ; sans effet sinon.
// conversation (LOKI_CHAT=rust, facade_chat.go), celles des téléchargements
// de modèles quand elle les tient (LOKI_DOWNLOADS=rust,
// facade_downloads.go) ; sans effet sinon.
api := func(path string, h http.HandlerFunc) { mux.HandleFunc(path, requireWebAuth(withFacadeChat(path, h))) }
api("/api/ping", handlePing)
api("/api/status", handleStatus)
+1
View File
@@ -686,6 +686,7 @@ dependencies = [
"tempfile",
"tokio",
"tokio-tungstenite",
"url",
]
[[package]]
+1
View File
@@ -13,6 +13,7 @@ http-body-util = "0.1.5"
hyper = { version = "1.12.0", features = ["http1", "server", "client"] }
hyper-util = { version = "0.1.21", features = ["tokio", "server", "client-legacy", "http1"] }
regex = "1.13.1"
url = "2.5.8"
reqwest = { version = "0.13.5", default-features = false, features = ["rustls-no-provider", "stream", "http2"] }
rustls = { version = "0.23.46", default-features = false, features = ["ring", "std", "tls12"], optional = true }
serde = { version = "1.0.229", features = ["derive"] }
+3
View File
@@ -26,6 +26,9 @@ pub struct Ctx {
pub engine: engine::Paths,
/// Conversation tenue par la façade (`LOKI_CHAT=rust`) ; `None` : au Go.
pub chat: Option<std::sync::Arc<crate::chat::generate::ChatService>>,
/// Téléchargements de modèles tenus par la façade (`LOKI_DOWNLOADS=rust`) ;
/// `None` : au Go.
pub downloads: Option<std::sync::Arc<crate::downloads::Downloads>>,
/// Jeton de l'API interne : une requête qui le porte vient du Go.
pub internal_token: String,
}
+3 -2
View File
@@ -208,8 +208,9 @@ impl EngineHttp {
}
}
/// Fournisseur cryptographique de rustls (ring), posé une fois.
fn install_crypto() {
/// Fournisseur cryptographique de rustls (ring), posé une fois (tout client
/// reqwest de la façade le demande avant d'être construit).
pub fn install_crypto() {
#[cfg(feature = "tls")]
let _ = rustls::crypto::ring::default_provider().install_default();
}
File diff suppressed because it is too large. Load diff
+1
View File
@@ -15,6 +15,7 @@ pub mod auth;
pub mod chat;
pub mod chatapi;
pub mod child;
pub mod downloads;
pub mod engine;
pub mod engstatus;
pub mod gguf;
+45
View File
@@ -118,9 +118,17 @@ fn serve() -> ExitCode {
// sait (il remet alors à la façade le trafic de chat du tunnel) et
// connaît son adresse locale.
let chat_rust = chat_held_by_facade();
// LOKI_DOWNLOADS=rust : les téléchargements de modèles aussi (le Go
// remet alors leurs routes du tunnel et ne vide plus les .part).
let downloads_rust = downloads_held_by_facade();
let mut env = vec![(auth::INTERNAL_ENV.to_string(), token.clone())];
if chat_rust {
env.push(("LOKI_CHAT".into(), "rust".into()));
}
if downloads_rust {
env.push(("LOKI_DOWNLOADS".into(), "rust".into()));
}
if chat_rust || downloads_rust {
env.push(("LOKI_FACADE_ADDR".into(), format!("127.0.0.1:{web_port}")));
}
let sup = child::Supervisor::spawn(go_bin, go_port, env);
@@ -153,6 +161,11 @@ fn serve() -> ExitCode {
} else {
None
};
let downloads = if downloads_rust {
Some(start_downloads(backend, &token, &engine_paths.home).await)
} else {
None
};
let addr = SocketAddr::from(([0, 0, 0, 0], web_port));
let listener = match tokio::net::TcpListener::bind(addr).await {
@@ -170,6 +183,7 @@ fn serve() -> ExitCode {
host_env: auth::HostEnv::from_env(),
engine: engine_paths,
chat,
downloads,
};
let res = proxy::serve(listener, cfg, shutdown_signal()).await;
eprintln!("[loki-rs] arrêt demandé — arrêt du serveur Go");
@@ -189,6 +203,37 @@ fn chat_held_by_facade() -> bool {
std::env::var("LOKI_CHAT").is_ok_and(|v| v.trim().eq_ignore_ascii_case("rust"))
}
/// `LOKI_DOWNLOADS=rust` : téléchargements de modèles tenus par la façade.
fn downloads_held_by_facade() -> bool {
std::env::var("LOKI_DOWNLOADS").is_ok_and(|v| v.trim().eq_ignore_ascii_case("rust"))
}
/// Les `.part` d'un téléchargement tué ne reprendront jamais : vidés au
/// démarrage (le Go ne le fait plus quand la façade les tient).
async fn start_downloads(
backend: SocketAddr,
token: &str,
home: &std::path::Path,
) -> std::sync::Arc<loki_rs::downloads::Downloads> {
use loki_rs::{downloads, gguf, store};
let st = store::Store::new(backend, token.to_string());
let stored = gguf::parse_stored_model_dirs(
st.get(store::BK_STATE, gguf::STATE_MODEL_DIRS_KEY)
.await
.ok()
.flatten()
.as_deref(),
);
let dirs = gguf::model_dirs(
home,
&stored,
std::env::var(gguf::MODEL_DIRS_ENV).ok().as_deref(),
);
let _ = tokio::task::spawn_blocking(move || downloads::clean_stale_part_files(&dirs)).await;
eprintln!("[loki-rs] téléchargements de modèles tenus par la façade (LOKI_DOWNLOADS=rust)");
std::sync::Arc::new(downloads::Downloads::new())
}
/// Le Go garde-t-il la conversation ? (quelques essais : il vient de démarrer)
async fn go_keeps_chat(backend: SocketAddr, token: &str) -> Result<Option<String>, String> {
let store = loki_rs::store::Store::new(backend, token.to_string());
+13 -2
View File
@@ -63,6 +63,9 @@ pub struct Config {
/// Conversation tenue par la façade (`LOKI_CHAT=rust`) : ses routes sont
/// servies ici ; `None` : relayées au Go.
pub chat: Option<Arc<crate::chat::generate::ChatService>>,
/// Téléchargements de modèles tenus par la façade (`LOKI_DOWNLOADS=rust`) :
/// leurs routes sont servies ici ; `None` : relayées au Go.
pub downloads: Option<Arc<crate::downloads::Downloads>>,
}
struct Proxy {
@@ -90,6 +93,7 @@ pub async fn serve(
host_env: cfg.host_env,
engine: cfg.engine,
chat: cfg.chat,
downloads: cfg.downloads,
internal_token: cfg.internal_token.clone(),
},
});
@@ -129,8 +133,10 @@ pub async fn serve(
impl Proxy {
async fn handle(&self, mut req: Request<Incoming>) -> Response<Body> {
// Le Go (et lui seul : jeton tiré à chaque démarrage) remet à la
// façade le trafic de chat qui lui arrive sans passer par elle.
let from_go = self.ctx.chat.is_some() && self.ctx.from_go(req.headers());
// façade le trafic de chat et de téléchargements qui lui arrive sans
// passer par elle.
let from_go = (self.ctx.chat.is_some() || self.ctx.downloads.is_some())
&& self.ctx.from_go(req.headers());
// Jamais de confiance accordée sur la foi d'un en-tête du client :
// ni « je viens du tunnel », ni « je suis la façade ».
req.headers_mut().remove(VIA_TUNNEL);
@@ -163,6 +169,11 @@ impl Proxy {
{
return chatapi::serve(&self.ctx, chat, req, from_go).await;
}
if let Some(dl) = &self.ctx.downloads
&& crate::downloads::handles(path)
{
return crate::downloads::serve(&self.ctx, dl, req, from_go).await;
}
self.forward(req).await
}
+39 -2
View File
@@ -427,7 +427,16 @@ async fn handle(st: Arc<FakeGo>, req: Request<Incoming>) -> Response<Full<Bytes>
/// Lance le faux Go puis la façade devant lui ; rend (adresse de la façade,
/// état du faux Go, garde d'arrêt).
pub async fn start(home: std::path::PathBuf) -> (SocketAddr, Arc<FakeGo>, oneshot::Sender<()>) {
let (f, st, tx, _) = start_opts(home, false, None).await;
let (f, st, tx, _) = start_opts(home, false, None, false).await;
(f, st, tx)
}
/// Comme [`start`], avec les téléchargements de modèles tenus par la façade
/// (`LOKI_DOWNLOADS=rust`).
pub async fn start_downloads(
home: std::path::PathBuf,
) -> (SocketAddr, Arc<FakeGo>, oneshot::Sender<()>) {
let (f, st, tx, _) = start_opts(home, false, None, true).await;
(f, st, tx)
}
@@ -442,7 +451,7 @@ pub async fn start_chat(
oneshot::Sender<()>,
Arc<loki_rs::chat::generate::ChatService>,
) {
let (f, st, tx, chat) = start_opts(home, true, Some(engine_port)).await;
let (f, st, tx, chat) = start_opts(home, true, Some(engine_port), false).await;
(f, st, tx, chat.unwrap())
}
@@ -450,6 +459,7 @@ async fn start_opts(
home: std::path::PathBuf,
with_chat: bool,
engine_port: Option<u16>,
with_downloads: bool,
) -> (
SocketAddr,
Arc<FakeGo>,
@@ -506,6 +516,7 @@ async fn start_opts(
service: "loki-engine".into(),
},
chat: chat.clone(),
downloads: with_downloads.then(|| Arc::new(loki_rs::downloads::Downloads::new())),
};
tokio::spawn(loki_rs::proxy::serve(fl, cfg, async move {
let _ = rx.await;
@@ -513,6 +524,32 @@ async fn start_opts(
(facade, st, tx, chat)
}
/// Requête via la façade (corps JSON) : (code, corps).
pub async fn send(
facade: SocketAddr,
method: &str,
path: &str,
headers: &[(&str, &str)],
body: &str,
) -> (StatusCode, Bytes) {
use hyper_util::client::legacy::Client;
use hyper_util::rt::TokioExecutor;
let mut req = Request::builder()
.method(method)
.uri(format!("http://{facade}{path}"))
.header("content-type", "application/json");
for (k, v) in headers {
req = req.header(*k, *v);
}
let client: Client<_, Full<Bytes>> = Client::builder(TokioExecutor::new()).build_http();
let r = client
.request(req.body(Full::new(Bytes::from(body.to_string()))).unwrap())
.await
.unwrap();
let (parts, body) = r.into_parts();
(parts.status, body.collect().await.unwrap().to_bytes())
}
/// GET via la façade : (code, en-têtes, corps).
pub async fn get(
facade: SocketAddr,
+692
View File
@@ -0,0 +1,692 @@
//! Lot « modèles » : téléchargements tenus par la façade (`LOKI_DOWNLOADS=rust`).
//!
//! - les fonctions pures rejouent le journal tiré du Go
//! (`internal/loki/parity_dl_test.go` → `tests/parity_models/download.json`) ;
//! - le téléchargement de bout en bout tourne contre un faux Hugging Face
//! (plages d'octets, tranches, dépôt verrouillé, annulation).
mod common;
use std::collections::HashMap;
use std::convert::Infallible;
use std::net::SocketAddr;
use std::path::{Path, PathBuf};
use std::sync::{Arc, Mutex};
use std::time::Duration;
use bytes::Bytes;
use common::fakego;
use http_body_util::{BodyExt, Full, StreamBody, combinators::BoxBody};
use hyper::body::{Frame, Incoming};
use hyper::{Request, Response, StatusCode, service::service_fn};
use hyper_util::rt::TokioIo;
use loki_rs::downloads::{self, DlEnv, DlState, Downloads, ModelDirs};
use serde_json::Value;
// ---------------------------------------------------------------------------
// Journal de référence du Go
// ---------------------------------------------------------------------------
#[test]
fn parite_des_fonctions_pures() {
let fx: Value =
serde_json::from_str(include_str!("parity_models/download.json")).expect("journal");
let s = |v: &Value, k: &str| v.get(k).and_then(Value::as_str).unwrap_or("").to_string();
let list = |v: &Value, k: &str| -> Vec<String> {
v.get(k)
.and_then(Value::as_array)
.map(|a| a.iter().map(|x| x.as_str().unwrap().to_string()).collect())
.unwrap_or_default()
};
for c in fx["normalize"].as_array().unwrap() {
let input = s(c, "in");
match downloads::normalize_hf_url(&input) {
Err(e) => assert_eq!(e, s(c, "err"), "normalize({input:?})"),
Ok((url, name)) => {
assert_eq!(s(c, "err"), "", "normalize({input:?}) aurait dû échouer");
assert_eq!(url, s(c, "url"), "url de {input:?}");
assert_eq!(name, s(c, "name"), "nom de {input:?}");
let (urls, names) = downloads::shard_url_set(&url, &name);
assert_eq!(urls, list(c, "urls"), "tranches de {input:?}");
assert_eq!(names, list(c, "names"), "noms de {input:?}");
}
}
}
for (v, want) in fx["content_range"].as_object().unwrap() {
assert_eq!(
downloads::content_range_total(v),
want.as_i64().unwrap(),
"{v:?}"
);
}
for c in fx["source"].as_array().unwrap() {
let got = downloads::dl_source_error(
c["status"].as_u64().unwrap() as u16,
&s(c, "code"),
&s(c, "url"),
c["token_set"].as_bool().unwrap(),
);
assert_eq!(got, s(c, "err"), "{c}");
}
for (h, want) in fx["hf_host"].as_object().unwrap() {
assert_eq!(downloads::is_hf_host(h), want.as_bool().unwrap(), "{h}");
}
for (u, want) in fx["hf_repo"].as_object().unwrap() {
assert_eq!(
downloads::hf_repo_from_url(u),
want.as_str().unwrap(),
"{u}"
);
}
let tmp = tempfile::tempdir().unwrap();
let home = tmp.path().canonicalize().unwrap();
let home_s = home.to_string_lossy().into_owned();
std::fs::create_dir_all(home.join("disque")).unwrap();
let dirs = ModelDirs {
home: home.clone(),
stored: vec![format!("{home_s}/disque")],
env: None,
};
for c in fx["dest"].as_array().unwrap() {
let dir = s(c, "dir").replace("{{HOME}}", &home_s);
let got = match dirs.download_dest_path(&s(c, "name"), &dir) {
Ok(p) => ("path", p.replace(&home_s, "{{HOME}}")),
Err(e) => ("err", e.replace(&home_s, "{{HOME}}")),
};
let want = if c.get("path").is_some() {
("path", s(c, "path"))
} else {
("err", s(c, "err"))
};
assert_eq!(got, want, "{c}");
}
}
#[test]
fn tailles_lisibles_comme_le_go() {
assert_eq!(downloads::human_bytes(0), "0 o");
assert_eq!(downloads::human_bytes((1 << 20) - 1), "1048575 o");
assert_eq!(downloads::human_bytes(1 << 20), "1 Mo");
assert_eq!(downloads::human_bytes(1536 << 20), "1.5 Go");
}
// ---------------------------------------------------------------------------
// Faux Hugging Face
// ---------------------------------------------------------------------------
type Body = BoxBody<Bytes, Infallible>;
#[derive(Default)]
struct FakeHf {
files: Mutex<HashMap<String, Arc<Vec<u8>>>>,
/// Chemin → (statut, X-Error-Code) imposés.
refuse: Mutex<HashMap<String, (u16, String)>>,
/// Ignore les plages (200, corps entier).
no_ranges: Mutex<bool>,
/// Corps envoyé au goutte-à-goutte (annulation).
slow: Mutex<bool>,
/// « chemin Range » de chaque requête, et en-têtes Authorization vus.
log: Mutex<Vec<String>>,
auth: Mutex<Vec<String>>,
}
fn full(status: u16, body: impl Into<Bytes>) -> Response<Body> {
let mut r = Response::new(Full::new(body.into()).map_err(|e| match e {}).boxed());
*r.status_mut() = StatusCode::from_u16(status).unwrap();
r
}
async fn hf_handle(hf: Arc<FakeHf>, req: Request<Incoming>) -> Response<Body> {
let path = req.uri().path().to_string();
let range = req
.headers()
.get("range")
.and_then(|v| v.to_str().ok())
.unwrap_or("")
.to_string();
if let Some(a) = req.headers().get("authorization") {
hf.auth
.lock()
.unwrap()
.push(a.to_str().unwrap().to_string());
}
hf.log.lock().unwrap().push(format!("{path} {range}"));
if let Some((st, code)) = hf.refuse.lock().unwrap().get(&path).cloned() {
let mut r = full(st, "refus");
if !code.is_empty() {
r.headers_mut()
.insert("x-error-code", code.parse().unwrap());
}
return r;
}
let Some(data) = hf.files.lock().unwrap().get(&path).cloned() else {
return full(404, "absent");
};
let total = data.len();
let (status, start, end) = match range.strip_prefix("bytes=") {
Some(r) if !*hf.no_ranges.lock().unwrap() => {
let (a, b) = r.split_once('-').unwrap();
let a: usize = a.parse().unwrap();
let b: usize = if b.is_empty() {
total - 1
} else {
b.parse::<usize>().unwrap().min(total - 1)
};
(206, a, b)
}
_ => (200, 0, total - 1),
};
let chunk = Bytes::copy_from_slice(&data[start..=end]);
let mut r = if *hf.slow.lock().unwrap() && chunk.len() > 1 {
// 64 Kio toutes les 20 ms : le téléchargement dure, on peut l'annuler.
let s = futures_util::stream::unfold(chunk, |mut rest| async move {
if rest.is_empty() {
return None;
}
tokio::time::sleep(Duration::from_millis(20)).await;
let part = rest.split_to(rest.len().min(64 << 10));
Some((Ok::<_, Infallible>(Frame::data(part)), rest))
});
let mut r = Response::new(StreamBody::new(s).boxed());
*r.status_mut() = StatusCode::from_u16(status).unwrap();
r
} else {
full(status, chunk)
};
if status == 206 {
r.headers_mut().insert(
"content-range",
format!("bytes {start}-{end}/{total}").parse().unwrap(),
);
}
r
}
async fn fake_hf() -> (SocketAddr, Arc<FakeHf>) {
let hf = Arc::new(FakeHf::default());
let l = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
let addr = l.local_addr().unwrap();
let h = hf.clone();
tokio::spawn(async move {
while let Ok((s, _)) = l.accept().await {
let h = h.clone();
tokio::spawn(async move {
let svc = service_fn(move |req| {
let h = h.clone();
async move { Ok::<_, Infallible>(hf_handle(h, req).await) }
});
let _ = hyper::server::conn::http1::Builder::new()
.serve_connection(TokioIo::new(s), svc)
.await;
});
}
});
(addr, hf)
}
/// Contenu reconnaissable : un décalage d'octets se verrait.
fn payload(n: usize, seed: u8) -> Vec<u8> {
(0..n)
.map(|i| (i as u32).wrapping_mul(2_654_435_761).to_le_bytes()[1] ^ seed)
.collect()
}
fn env() -> DlEnv {
DlEnv {
hf_token: "hf_secret".into(),
version: "9.9.9".into(),
}
}
fn dirs_at(home: &Path) -> ModelDirs {
ModelDirs {
home: home.to_path_buf(),
stored: vec![],
env: None,
}
}
async fn wait_finished(dl: &Downloads, name: &str) -> DlState {
for _ in 0..1000 {
if let Some(s) = dl.status().into_iter().find(|s| s.filename == name)
&& s.finished
{
return s;
}
tokio::time::sleep(Duration::from_millis(10)).await;
}
panic!("téléchargement {name} jamais terminé : {:?}", dl.status());
}
fn models(home: &Path) -> PathBuf {
home.join("models")
}
fn entries(dir: &Path) -> Vec<String> {
let mut v: Vec<String> = std::fs::read_dir(dir)
.map(|rd| {
rd.filter_map(Result::ok)
.map(|e| e.file_name().to_string_lossy().into_owned())
.collect()
})
.unwrap_or_default();
v.sort();
v
}
// ---------------------------------------------------------------------------
// De bout en bout
// ---------------------------------------------------------------------------
#[tokio::test]
async fn un_fichier_sur_plusieurs_connexions() {
let (addr, hf) = fake_hf().await;
let data = payload(40 << 20, 7); // 40 Mio → 3 parts de 16 Mio au plus
hf.files
.lock()
.unwrap()
.insert("/u/r/resolve/main/m.gguf".into(), Arc::new(data.clone()));
let tmp = tempfile::tempdir().unwrap();
let dl = Arc::new(Downloads::new());
let (code, out) = dl.start(
env(),
&dirs_at(tmp.path()),
&format!("http://{addr}/u/r/resolve/main/m.gguf"),
"",
);
assert_eq!(code, StatusCode::OK, "{out}");
assert_eq!(out["filename"], "m.gguf");
assert_eq!(out["parts"], 1);
let st = wait_finished(&dl, "m.gguf").await;
assert_eq!(st.err, "");
assert!(!st.canceled);
assert_eq!(
(st.total, st.done, st.conns, st.parts, st.part),
(40 << 20, 40 << 20, 3, 1, 1)
);
assert_eq!(st.dir, models(tmp.path()).to_string_lossy());
let got = std::fs::read(models(tmp.path()).join("m.gguf")).unwrap();
assert!(got == data, "contenu différent");
assert_eq!(entries(&models(tmp.path())), ["m.gguf"]);
// Sonde d'un octet, puis trois plages disjointes qui couvrent le fichier.
let log = hf.log.lock().unwrap().clone();
assert_eq!(log[0], "/u/r/resolve/main/m.gguf bytes=0-0");
let mut ranges: Vec<&str> = log[1..]
.iter()
.map(|l| l.split(' ').nth(1).unwrap())
.collect();
ranges.sort();
assert_eq!(
ranges,
[
"bytes=0-13981012",
"bytes=13981013-27962025",
"bytes=27962026-41943039"
]
);
// Hôte hors Hugging Face : le jeton ne part pas.
assert!(hf.auth.lock().unwrap().is_empty());
}
#[tokio::test]
async fn source_sans_plages_un_seul_flux() {
let (addr, hf) = fake_hf().await;
let data = payload(3 << 20, 1);
hf.files
.lock()
.unwrap()
.insert("/m.gguf".into(), Arc::new(data.clone()));
*hf.no_ranges.lock().unwrap() = true;
let tmp = tempfile::tempdir().unwrap();
let dl = Arc::new(Downloads::new());
let (code, _) = dl.start(
env(),
&dirs_at(tmp.path()),
&format!("http://{addr}/m.gguf"),
"",
);
assert_eq!(code, StatusCode::OK);
let st = wait_finished(&dl, "m.gguf").await;
assert_eq!(
(st.err.as_str(), st.total, st.done, st.conns),
("", 3 << 20, 3 << 20, 1)
);
assert!(std::fs::read(models(tmp.path()).join("m.gguf")).unwrap() == data);
}
#[tokio::test]
async fn modele_en_tranches_rapatrie_en_entier() {
let (addr, hf) = fake_hf().await;
let parts: Vec<Vec<u8>> = (0..3).map(|i| payload(1 << 20, i as u8)).collect();
for (i, p) in parts.iter().enumerate() {
hf.files.lock().unwrap().insert(
format!("/u/r/resolve/main/Q4/m-0000{}-of-00003.gguf", i + 1),
Arc::new(p.clone()),
);
}
let tmp = tempfile::tempdir().unwrap();
let m = models(tmp.path());
std::fs::create_dir_all(&m).unwrap();
// La première est déjà là : pas retéléchargée.
std::fs::write(m.join("m-00001-of-00003.gguf"), &parts[0]).unwrap();
let dl = Arc::new(Downloads::new());
let (code, out) = dl.start(
env(),
&dirs_at(tmp.path()),
&format!("http://{addr}/u/r/resolve/main/Q4/m-00002-of-00003.gguf"),
"",
);
assert_eq!(code, StatusCode::OK, "{out}");
assert_eq!(
(out["filename"].as_str(), out["parts"].as_i64()),
(Some("m-00001-of-00003.gguf"), Some(2))
);
let st = wait_finished(&dl, "m-00001-of-00003.gguf").await;
assert_eq!(
(st.err.as_str(), st.total, st.done, st.parts, st.part),
("", 2 << 20, 2 << 20, 2, 2)
);
for (i, p) in parts.iter().enumerate() {
let got = std::fs::read(m.join(format!("m-0000{}-of-00003.gguf", i + 1))).unwrap();
assert!(&got == p, "tranche {}", i + 1);
}
assert!(
!hf.log
.lock()
.unwrap()
.iter()
.any(|l| l.contains("00001-of")),
"tranche présente retéléchargée"
);
// Tout est là : rien à faire, le modèle est à sélectionner.
let (code, out) = dl.start(
env(),
&dirs_at(tmp.path()),
&format!("http://{addr}/u/r/resolve/main/Q4/m-00003-of-00003.gguf"),
"",
);
assert_eq!(code, StatusCode::OK);
assert_eq!(out["exists"], true);
assert_eq!(out["value"], "m-00001-of-00003.gguf");
assert_eq!(out["parts"], 0);
}
#[tokio::test]
async fn depot_verrouille_rien_ne_reste() {
let (addr, hf) = fake_hf().await;
for i in 1..=2 {
hf.files.lock().unwrap().insert(
format!("/g/d/resolve/main/m-0000{i}-of-00002.gguf"),
Arc::new(payload(1 << 20, i)),
);
}
hf.refuse.lock().unwrap().insert(
"/g/d/resolve/main/m-00002-of-00002.gguf".into(),
(401, "GatedRepo".into()),
);
let tmp = tempfile::tempdir().unwrap();
let dl = Arc::new(Downloads::new());
let (code, _) = dl.start(
env(),
&dirs_at(tmp.path()),
&format!("http://{addr}/g/d/resolve/main/m-00001-of-00002.gguf"),
"",
);
assert_eq!(code, StatusCode::OK);
let st = wait_finished(&dl, "m-00001-of-00002.gguf").await;
// Refusé dès la sonde : rien n'a été écrit.
assert_eq!(
st.err,
"dépôt à accès restreint : ses conditions doivent être acceptées sur huggingface.co — le jeton Hugging Face enregistré n'y donne pas accès (expiré, ou conditions non acceptées avec ce compte)"
);
assert_eq!(entries(&models(tmp.path())), Vec::<String>::new());
assert!(
hf.log
.lock()
.unwrap()
.iter()
.all(|l| l.ends_with("bytes=0-0"))
);
}
#[tokio::test]
async fn panne_pendant_la_deuxieme_tranche_efface_la_premiere() {
let (addr, hf) = fake_hf().await;
let tmp = tempfile::tempdir().unwrap();
let dl = Arc::new(Downloads::new());
for i in 1..=2 {
hf.files.lock().unwrap().insert(
format!("/m-0000{i}-of-00002.gguf"),
Arc::new(payload(1 << 20, i)),
);
}
// La sonde passe, le transfert de la deuxième échoue (fichier retiré).
let (code, _) = dl.start(
env(),
&dirs_at(tmp.path()),
&format!("http://{addr}/m-00001-of-00002.gguf"),
"",
);
assert_eq!(code, StatusCode::OK);
// Retire la deuxième dès que la première est écrite.
let m = models(tmp.path());
for _ in 0..500 {
if m.join("m-00001-of-00002.gguf").exists() {
break;
}
if dl.status()[0].finished {
break;
}
tokio::time::sleep(Duration::from_millis(2)).await;
}
let st0 = dl.status()[0].clone();
if st0.finished {
// Trop rapide pour la machine : le scénario ne se joue pas.
return;
}
hf.files.lock().unwrap().remove("/m-00002-of-00002.gguf");
let st = wait_finished(&dl, "m-00001-of-00002.gguf").await;
if st.err.is_empty() {
return; // deuxième tranche déjà partie avant le retrait
}
assert_eq!(st.err, "lien introuvable (HTTP 404)");
assert_eq!(entries(&m), Vec::<String>::new(), "tranches laissées");
}
#[tokio::test]
async fn annulation_efface_le_part() {
let (addr, hf) = fake_hf().await;
hf.files
.lock()
.unwrap()
.insert("/m.gguf".into(), Arc::new(payload(8 << 20, 3)));
*hf.slow.lock().unwrap() = true;
let tmp = tempfile::tempdir().unwrap();
let dl = Arc::new(Downloads::new());
let (code, _) = dl.start(
env(),
&dirs_at(tmp.path()),
&format!("http://{addr}/m.gguf"),
"",
);
assert_eq!(code, StatusCode::OK);
// Deuxième lancement pendant le premier : refusé.
let (code, out) = dl.start(
env(),
&dirs_at(tmp.path()),
&format!("http://{addr}/m.gguf"),
"",
);
assert_eq!(code, StatusCode::CONFLICT);
assert_eq!(out["error"], "téléchargement déjà en cours pour m.gguf");
let part = models(tmp.path()).join("m.gguf.part");
for _ in 0..500 {
if part.exists() {
break;
}
tokio::time::sleep(Duration::from_millis(5)).await;
}
assert!(part.exists(), "pas de .part en cours");
assert!(dl.cancel("m.gguf"));
assert!(!dl.cancel("autre.gguf"));
let st = wait_finished(&dl, "m.gguf").await;
assert!(st.canceled && st.err.is_empty(), "{st:?}");
assert_eq!(st.speed, 0);
assert_eq!(entries(&models(tmp.path())), Vec::<String>::new());
}
#[tokio::test]
async fn refus_avant_de_lancer() {
let tmp = tempfile::tempdir().unwrap();
let dl = Arc::new(Downloads::new());
let d = dirs_at(tmp.path());
let (code, out) = dl.start(env(), &d, "pas un lien", "");
assert_eq!(
(code, out["error"].as_str()),
(
StatusCode::BAD_REQUEST,
Some("lien invalide (http/https attendu)")
)
);
let (code, out) = dl.start(env(), &d, "http://h/m.gguf", "/etc");
assert_eq!(code, StatusCode::BAD_REQUEST);
assert_eq!(
out["error"],
"dossier non autorisé : /etc — ajoute-le dans « Dossiers de modèles »"
);
assert!(dl.status().is_empty());
}
// ---------------------------------------------------------------------------
// Par la façade
// ---------------------------------------------------------------------------
#[tokio::test]
async fn routes_servies_par_la_facade() {
let (addr, hf) = fake_hf().await;
hf.files.lock().unwrap().insert(
"/u/r/resolve/main/m.gguf".into(),
Arc::new(payload(1 << 20, 9)),
);
let tmp = tempfile::tempdir().unwrap();
let home = tmp.path().canonicalize().unwrap();
let (facade, go, _stop) = fakego::start_downloads(home.clone()).await;
let url = format!("http://{addr}/u/r/resolve/main/m.gguf");
let body = format!(r#"{{"url":"{url}","dir":""}}"#);
// Sonde : taille, dossier, espace.
let (code, b) = fakego::send(facade, "POST", "/api/models/download/probe", &[], &body).await;
assert_eq!(code, StatusCode::OK, "{b:?}");
let v: Value = serde_json::from_slice(&b).unwrap();
assert_eq!(v["ok"], true);
assert_eq!(v["size"], 1 << 20);
assert_eq!(v["parts"], 1);
assert_eq!(v["enough"], true);
assert_eq!(v["dir"], models(&home).to_string_lossy().as_ref());
// Lancement, suivi, puis la sonde dit « déjà là ».
let (code, b) = fakego::send(facade, "POST", "/api/models/download", &[], &body).await;
assert_eq!(code, StatusCode::OK, "{b:?}");
let mut done = false;
for _ in 0..500 {
let (_, _, b) = fakego::get(facade, "/api/models/download/status", &[]).await;
let v: Value = serde_json::from_slice(&b).unwrap();
if v[0]["finished"] == true {
assert_eq!(v[0]["error"], "");
assert_eq!(v[0]["done"], 1 << 20);
done = true;
break;
}
tokio::time::sleep(Duration::from_millis(10)).await;
}
assert!(done);
let (_, b) = fakego::send(facade, "POST", "/api/models/download/probe", &[], &body).await;
let v: Value = serde_json::from_slice(&b).unwrap();
assert_eq!(
(v["exists"].as_bool(), v["value"].as_str()),
(Some(true), Some("m.gguf"))
);
assert_eq!(v["size"], 1 << 20);
// Annuler l'inconnu ; corps vide.
let (code, b) = fakego::send(
facade,
"POST",
"/api/models/download/cancel",
&[],
r#"{"filename":"../x.gguf"}"#,
)
.await;
assert_eq!(code, StatusCode::NOT_FOUND);
assert_eq!(
serde_json::from_slice::<Value>(&b).unwrap()["error"],
"aucun téléchargement pour x.gguf"
);
let (code, b) = fakego::send(facade, "POST", "/api/models/download", &[], "").await;
assert_eq!(
(
code,
serde_json::from_slice::<Value>(&b).unwrap()["error"].as_str()
),
(StatusCode::BAD_REQUEST, Some("EOF"))
);
// Clé de pilotage posée : refus sans elle, la remise du Go passe.
go.set("state", "web_key", "cle");
let (code, _, _) = fakego::get(facade, "/api/models/download/status", &[]).await;
assert_eq!(code, StatusCode::UNAUTHORIZED);
let (code, _, _) = fakego::get(
facade,
"/api/models/download/status",
&[("x-loki-internal", fakego::JETON)],
)
.await;
assert_eq!(code, StatusCode::OK);
// Rien n'est parti au Go.
assert!(
!go.relayed
.lock()
.unwrap()
.iter()
.any(|p| p.contains("download")),
"{:?}",
go.relayed.lock().unwrap()
);
}
#[tokio::test]
async fn sans_la_variable_les_routes_restent_au_go() {
let tmp = tempfile::tempdir().unwrap();
let (facade, go, _stop) = fakego::start(tmp.path().to_path_buf()).await;
let (_, _, b) = fakego::get(facade, "/api/models/download/status", &[]).await;
assert_eq!(&b[..], b"go:/api/models/download/status");
assert!(
go.relayed
.lock()
.unwrap()
.iter()
.any(|p| p == "/api/models/download/status")
);
}
#[test]
fn part_perimes_vides_dans_chaque_dossier() {
let a = tempfile::tempdir().unwrap();
let b = tempfile::tempdir().unwrap();
for d in [a.path(), b.path()] {
std::fs::write(d.join("x.gguf.part"), b"x").unwrap();
std::fs::write(d.join("X.GGUF.PART"), b"x").unwrap();
std::fs::write(d.join("y.gguf"), b"y").unwrap();
}
downloads::clean_stale_part_files(&[
a.path().to_string_lossy().into_owned(),
b.path().to_string_lossy().into_owned(),
"/nexiste/pas".into(),
]);
for d in [a.path(), b.path()] {
// Le motif du Go est sensible à la casse.
assert_eq!(entries(d), ["X.GGUF.PART", "y.gguf"]);
}
}
+1
View File
@@ -94,6 +94,7 @@ async fn banc(cle: Option<&str>) -> Banc {
service: "loki-engine".into(),
},
chat: None,
downloads: None,
};
tokio::spawn(loki_rs::proxy::serve(l, cfg, async move {
let _ = rx.await;
@@ -0,0 +1,314 @@
{
"normalize": [
{
"in": "",
"err": "lien vide"
},
{
"in": " ",
"err": "lien vide"
},
{
"in": "ftp://h/a.gguf",
"err": "lien invalide (http/https attendu)"
},
{
"in": "pas un lien",
"err": "lien invalide (http/https attendu)"
},
{
"in": "https://exemple.org/dossier/",
"err": "le lien doit pointer vers un fichier .gguf"
},
{
"in": "https://huggingface.co/u/r/blob/main/m.gguf",
"url": "https://huggingface.co/u/r/resolve/main/m.gguf",
"name": "m.gguf",
"urls": [
"https://huggingface.co/u/r/resolve/main/m.gguf"
],
"names": [
"m.gguf"
]
},
{
"in": "https://huggingface.co/u/r/blob/main/blob/m.gguf",
"url": "https://huggingface.co/u/r/resolve/main/blob/m.gguf",
"name": "m.gguf",
"urls": [
"https://huggingface.co/u/r/resolve/main/blob/m.gguf"
],
"names": [
"m.gguf"
]
},
{
"in": "https://huggingface.co/u/r/resolve/main/m-Q4_K_M.gguf?download=true",
"url": "https://huggingface.co/u/r/resolve/main/m-Q4_K_M.gguf?download=true",
"name": "m-Q4_K_M.gguf",
"urls": [
"https://huggingface.co/u/r/resolve/main/m-Q4_K_M.gguf?download=true"
],
"names": [
"m-Q4_K_M.gguf"
]
},
{
"in": " https://huggingface.co/u/r/blob/main/m.bin ",
"err": "le lien doit pointer vers un fichier .gguf"
},
{
"in": "http://exemple.org/modeles/M.GGUF",
"url": "http://exemple.org/modeles/M.GGUF",
"name": "M.GGUF",
"urls": [
"http://exemple.org/modeles/M.GGUF"
],
"names": [
"M.GGUF"
]
},
{
"in": "https://huggingface.co/u/r/blob/main/Q4/m-Q4-00002-of-00003.gguf",
"url": "https://huggingface.co/u/r/resolve/main/Q4/m-Q4-00002-of-00003.gguf",
"name": "m-Q4-00002-of-00003.gguf",
"urls": [
"https://huggingface.co/u/r/resolve/main/Q4/m-Q4-00001-of-00003.gguf",
"https://huggingface.co/u/r/resolve/main/Q4/m-Q4-00002-of-00003.gguf",
"https://huggingface.co/u/r/resolve/main/Q4/m-Q4-00003-of-00003.gguf"
],
"names": [
"m-Q4-00001-of-00003.gguf",
"m-Q4-00002-of-00003.gguf",
"m-Q4-00003-of-00003.gguf"
]
},
{
"in": "https://huggingface.co/u/r/resolve/main/m-00001-of-00012.gguf",
"url": "https://huggingface.co/u/r/resolve/main/m-00001-of-00012.gguf",
"name": "m-00001-of-00012.gguf",
"urls": [
"https://huggingface.co/u/r/resolve/main/m-00001-of-00012.gguf",
"https://huggingface.co/u/r/resolve/main/m-00002-of-00012.gguf",
"https://huggingface.co/u/r/resolve/main/m-00003-of-00012.gguf",
"https://huggingface.co/u/r/resolve/main/m-00004-of-00012.gguf",
"https://huggingface.co/u/r/resolve/main/m-00005-of-00012.gguf",
"https://huggingface.co/u/r/resolve/main/m-00006-of-00012.gguf",
"https://huggingface.co/u/r/resolve/main/m-00007-of-00012.gguf",
"https://huggingface.co/u/r/resolve/main/m-00008-of-00012.gguf",
"https://huggingface.co/u/r/resolve/main/m-00009-of-00012.gguf",
"https://huggingface.co/u/r/resolve/main/m-00010-of-00012.gguf",
"https://huggingface.co/u/r/resolve/main/m-00011-of-00012.gguf",
"https://huggingface.co/u/r/resolve/main/m-00012-of-00012.gguf"
],
"names": [
"m-00001-of-00012.gguf",
"m-00002-of-00012.gguf",
"m-00003-of-00012.gguf",
"m-00004-of-00012.gguf",
"m-00005-of-00012.gguf",
"m-00006-of-00012.gguf",
"m-00007-of-00012.gguf",
"m-00008-of-00012.gguf",
"m-00009-of-00012.gguf",
"m-00010-of-00012.gguf",
"m-00011-of-00012.gguf",
"m-00012-of-00012.gguf"
]
},
{
"in": "https://exemple.org/m-1-of-2.gguf",
"url": "https://exemple.org/m-1-of-2.gguf",
"name": "m-1-of-2.gguf",
"urls": [
"https://exemple.org/m-1-of-2.gguf"
],
"names": [
"m-1-of-2.gguf"
]
},
{
"in": "https://exemple.org/m-02-of-01.gguf",
"url": "https://exemple.org/m-02-of-01.gguf",
"name": "m-02-of-01.gguf",
"urls": [
"https://exemple.org/m-02-of-01.gguf"
],
"names": [
"m-02-of-01.gguf"
]
}
],
"content_range": {
"": 0,
"bytes 0-0/ 99 ": 99,
"bytes 0-0/*": 0,
"bytes 0-0/0": 0,
"bytes 0-0/12345": 12345
},
"source": [
{
"status": 401,
"code": "GatedRepo",
"url": "https://huggingface.co/auteur/depot/resolve/main/m.gguf",
"token_set": false,
"err": "dépôt à accès restreint : accepte ses conditions sur huggingface.co/auteur/depot — puis renseigne le jeton Hugging Face (réglage « Jeton Hugging Face », ou variable d'environnement HF_TOKEN)"
},
{
"status": 401,
"code": "GatedRepo",
"url": "https://huggingface.co/auteur/depot/resolve/main/m.gguf",
"token_set": true,
"err": "dépôt à accès restreint : accepte ses conditions sur huggingface.co/auteur/depot — le jeton Hugging Face enregistré n'y donne pas accès (expiré, ou conditions non acceptées avec ce compte)"
},
{
"status": 403,
"code": "RepoNotFound",
"url": "https://huggingface.co/auteur/depot/resolve/main/m.gguf",
"token_set": false,
"err": "dépôt privé ou inexistant : auteur/depot — puis renseigne le jeton Hugging Face (réglage « Jeton Hugging Face », ou variable d'environnement HF_TOKEN)"
},
{
"status": 401,
"code": "GatedRepo",
"url": "https://exemple.org/m.gguf",
"token_set": false,
"err": "dépôt à accès restreint : ses conditions doivent être acceptées sur huggingface.co — puis renseigne le jeton Hugging Face (réglage « Jeton Hugging Face », ou variable d'environnement HF_TOKEN)"
},
{
"status": 403,
"code": "",
"url": "https://huggingface.co/auteur/depot/resolve/main/m.gguf",
"token_set": true,
"err": "accès refusé par Hugging Face sur auteur/depot — le jeton Hugging Face enregistré n'y donne pas accès (expiré, ou conditions non acceptées avec ce compte)"
},
{
"status": 403,
"code": "",
"url": "https://exemple.org/m.gguf",
"token_set": false,
"err": "accès refusé par la source — puis renseigne le jeton Hugging Face (réglage « Jeton Hugging Face », ou variable d'environnement HF_TOKEN)"
},
{
"status": 404,
"code": "EntryNotFound",
"url": "https://huggingface.co/auteur/depot/resolve/main/m.gguf",
"token_set": false,
"err": "fichier « m.gguf » absent du dépôt (HTTP 404) — dépôt réorganisé depuis la copie du lien, ou fragment jamais publié (table PLE livrée à part ?)"
},
{
"status": 404,
"code": "EntryNotFound",
"url": "https://exemple.org/",
"token_set": false,
"err": "fichier absent du dépôt (HTTP 404) — dépôt réorganisé depuis la copie du lien, ou fragment jamais publié (table PLE livrée à part ?)"
},
{
"status": 404,
"code": "RevisionNotFound",
"url": "https://huggingface.co/auteur/depot/resolve/main/m.gguf",
"token_set": false,
"err": "révision introuvable dans le dépôt (HTTP 404)"
},
{
"status": 404,
"code": "",
"url": "https://huggingface.co/auteur/depot/resolve/main/m.gguf",
"token_set": false,
"err": "lien introuvable (HTTP 404)"
},
{
"status": 416,
"code": "",
"url": "https://huggingface.co/auteur/depot/resolve/main/m.gguf",
"token_set": false,
"err": "la source refuse la plage d'octets demandée (HTTP 416) — fichier modifié pendant le transfert ?"
},
{
"status": 429,
"code": "",
"url": "https://huggingface.co/auteur/depot/resolve/main/m.gguf",
"token_set": false,
"err": "trop de requêtes vers la source (HTTP 429) — réessaie dans quelques minutes"
},
{
"status": 502,
"code": "",
"url": "https://huggingface.co/auteur/depot/resolve/main/m.gguf",
"token_set": false,
"err": "la source est en panne (HTTP 502) — réessaie plus tard"
},
{
"status": 418,
"code": "",
"url": "https://huggingface.co/auteur/depot/resolve/main/m.gguf",
"token_set": false,
"err": "HTTP 418 depuis la source"
}
],
"hf_host": {
"HUGGINGFACE.CO:443": true,
"cdn-lfs.huggingface.co": true,
"evilhuggingface.co": false,
"exemple.org": false,
"hf.co": true,
"huggingface.co": true,
"huggingface.co.evil.org": false,
"x.hf.co": true
},
"hf_repo": {
"https://exemple.org/a/b/resolve/main/x.gguf": "",
"https://huggingface.co/-a/b/resolve/main/x.gguf": "",
"https://huggingface.co/a/b": "",
"https://huggingface.co/a/b/blob/main/x.gguf": "a/b",
"https://huggingface.co/a/b/tree/main": "",
"https://huggingface.co/auteur/depot/resolve/main/m.gguf": "auteur/depot"
},
"dest": [
{
"name": "a.gguf",
"dir": "",
"path": "{{HOME}}/models/a.gguf"
},
{
"name": " ../../x.gguf ",
"dir": "",
"path": "{{HOME}}/models/x.gguf"
},
{
"name": "x.bin",
"dir": "",
"err": "seuls les fichiers .gguf sont acceptés"
},
{
"name": "",
"dir": "",
"err": "nom de modèle invalide"
},
{
"name": "/",
"dir": "",
"err": "nom de modèle invalide"
},
{
"name": "a.gguf",
"dir": "/etc",
"err": "dossier non autorisé : /etc — ajoute-le dans « Dossiers de modèles »"
},
{
"name": "a.gguf",
"dir": "{{HOME}}/disque",
"path": "{{HOME}}/disque/a.gguf"
},
{
"name": "a.gguf",
"dir": "{{HOME}}/disque/",
"path": "{{HOME}}/disque/a.gguf"
},
{
"name": "a.gguf",
"dir": "{{HOME}}/models",
"path": "{{HOME}}/models/a.gguf"
}
]
}
+1
View File
@@ -100,6 +100,7 @@ async fn facade(backend: SocketAddr) -> (SocketAddr, oneshot::Sender<()>) {
service: "loki-engine".into(),
},
chat: None,
downloads: None,
};
tokio::spawn(loki_rs::proxy::serve(l, cfg, async move {
let _ = rx.await;