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

+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)