diff --git a/README.md b/README.md index 98a1a9e..adfa692 100644 --- a/README.md +++ b/README.md @@ -228,7 +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 | +| `LOKI_MODELS` | `rust` : les routes des modèles — liste, dossiers, suppression, téléchargements, catalogue Hugging Face — sont servies par la façade Rust (expérimental, migration en cours) | 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 diff --git a/docs/superpowers/specs/2026-10-15-rust-migration-lot8-modeles-design.md b/docs/superpowers/specs/2026-10-15-rust-migration-lot8-modeles-design.md new file mode 100644 index 0000000..d7a2504 --- /dev/null +++ b/docs/superpowers/specs/2026-10-15-rust-migration-lot8-modeles-design.md @@ -0,0 +1,101 @@ +# Lot 8 — routes des modèles tenues par la façade + +## Contexte + +Les modèles `.gguf` vivaient dans le Go : + +- la liste du sélecteur (`handleModels`, `backend_models_refs.go`) ; +- les dossiers de modèles (`backend_model_dirs.go`) ; +- la suppression (`deleteModelFile`) ; +- les téléchargements (`backend_models.go`, `backend_models_shards.go`) ; +- le catalogue Hugging Face (`backend_hf.go`, `web_hf.go`, `backend_catalog.go`). + +Lot 8 : la façade tient tout cela quand `LOKI_MODELS=rust` (expérimental ; +sans la variable rien ne change, tout part au Go). + +## Ce que sert la façade + +| Route | Module | Rôle | +|---|---|---| +| `GET /api/models` | `models.rs` | `.gguf` des dossiers déclarés (tranches 2..N exclues), taille de la famille, tranches manquantes, qui les référence (presets, configuration en service) | +| `POST /api/models/delete` | `models.rs` | refus si en service ; 409 avec la liste des presets sans `force` ; le modèle part avec toutes ses tranches, octets libérés rendus | +| `GET\|POST /api/models/dirs` | `models.rs` | dossiers de modèles, nombre de modèles, espace libre ; ajout (dossier existant) et retrait | +| `POST /api/models/download/probe` | `downloads.rs` | taille du modèle (toutes ses tranches), espace libre, « ça tient ? », ou « déjà là » avec la valeur de `MODEL=` | +| `POST /api/models/download` | `downloads.rs` | 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` | `downloads.rs` | état de chaque téléchargement de ce lancement | +| `POST /api/models/download/cancel` | `downloads.rs` | annule ; ce que le téléchargement a écrit est effacé | +| `GET /api/hf/search` | `hf.rs` | dépôts GGUF, matériel local joint | +| `GET /api/hf/files` | `hf.rs` | modèles, projecteurs et poids MTP d'un dépôt, verdict mémoire de chaque modèle (contexte du preset actif, projecteur du dépôt) | +| `GET\|POST /api/hf/token` | `hf.rs` | jeton vérifié auprès de Hugging Face avant d'être enregistré, jamais rendu en clair | + +Mêmes contrats (corps, codes, messages) et même mécanique que le Go, entre +autres : + +- téléchargement : + - lien de page Hugging Face (`/blob/`) converti en lien direct ; + - modèle en tranches rapatrié en entier ; + - place disque vérifiée sur le total, avec 256 Mio de marge ; jamais de + refus sur FUSE, overlayfs, NFS, SMB, Ceph ou GlusterFS ; + - plages d'octets en parallèle (`LOKI_DL_CONNS`) et reprise d'une + connexion coupée ; + - jeton envoyé aux seuls hôtes Hugging Face ; +- catalogue : réponses mises en cache (60 s pour une recherche, 10 min pour un + dépôt), cache vidé à l'enregistrement d'un jeton ; +- `.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_models.go`) + +Le tunnel du relais et la redistribution E2E arrivent au Go sans passer par +la façade : `withFacadeChat` lui remet ces routes, authentifiées par le +jeton interne, comme celles du chat. Façade injoignable : 502 « modèles +indisponibles : … ». + +La façade transmet `LOKI_MODELS=rust` et son adresse au Go qu'elle lance. + +## Écarts assumés + +- Une source qui ignore les plages n'est plus lue en entier pendant la sonde. +- Erreurs de transport (réseau, TLS) au format de reqwest. +- Message d'un corps JSON illisible : celui de serde (l'interface n'en envoie + jamais). +- Hôte d'un lien collé en majuscules converti comme les autres. +- Espace libre et mémoire vive inconnus hors Unix/Linux (la façade n'est + livrée que pour Linux). +- `/api/models/download/status` trié par date de début. +- Nombres flottants du matériel écrits `24.0` (le Go écrit `24`) : même + valeur JSON. + +## Parité + +Journaux tirés du Go et rejoués en Rust (`LOKI_PARITY_UPDATE=1` pour +régénérer) : + +- `internal/loki/parity_dl_test.go` → `tests/parity_models/download.json` → + `tests/downloads.rs` : lien normalisé, famille de tranches, + `Content-Range`, messages de refus, hôtes et dépôts Hugging Face, dossier + de destination ; +- `internal/loki/parity_models_test.go` → `tests/parity_models/models.json` → + `tests/models.rs` : quantification déduite du nom, découpage + d'`EXTRA_ARGS`, jeton masqué, `gated`, classement d'un dépôt, choix du + projecteur, verdicts mémoire, puis un scénario sur disque de 20 appels + (liste, dossiers, suppressions avec et sans `force`, modèle en service, + tranches, chemins refusés) rejoué à l'identique, fichiers restants et + dossiers enregistrés compris. + +Deux mutations (`replacen` → `replace` sur `/blob/`, `--mmproj` ignoré) +font échouer les rejeux. + +De bout en bout : + +- téléchargements contre un faux Hugging Face : trois connexions sur des + plages disjointes, source sans plages, tranches dont une présente, dépôt + verrouillé, panne pendant la deuxième tranche, annulation et 409 ; +- catalogue contre un faux Hugging Face : recherche, cache, taille LFS, + dépôt verrouillé ou absent, jeton refusé ou accepté ; +- toutes les routes par la façade (clé de pilotage, remise du Go, base du Go + écrite), et laissées au Go sans la variable. + +## Reste + +- Essai réel sur une installation avant d'en faire le défaut. diff --git a/docs/superpowers/specs/2026-10-15-rust-migration-lot8-telechargements-design.md b/docs/superpowers/specs/2026-10-15-rust-migration-lot8-telechargements-design.md deleted file mode 100644 index 4d74c70..0000000 --- a/docs/superpowers/specs/2026-10-15-rust-migration-lot8-telechargements-design.md +++ /dev/null @@ -1,76 +0,0 @@ -# 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. diff --git a/internal/loki/facade_chat.go b/internal/loki/facade_chat.go index 225c809..453d58f 100644 --- a/internal/loki/facade_chat.go +++ b/internal/loki/facade_chat.go @@ -141,15 +141,15 @@ var facadeChatRoutes = map[string]bool{ } // withFacadeChat remet la requête à la façade quand elle tient la -// conversation — ou les téléchargements de modèles (facade_downloads.go). +// conversation — ou les routes des modèles (facade_models.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 { held, unavailable := facadeChat, "conversation indisponible : " switch { case facadeChatRoutes[path]: - case facadeDownloadRoutes[path]: - held, unavailable = facadeDownloads, "téléchargements indisponibles : " + case facadeModelRoutes[path]: + held, unavailable = facadeModels, "modèles indisponibles : " default: return h } diff --git a/internal/loki/facade_chat_test.go b/internal/loki/facade_chat_test.go index 9b8ee5d..048dcfb 100644 --- a/internal/loki/facade_chat_test.go +++ b/internal/loki/facade_chat_test.go @@ -306,30 +306,31 @@ func TestFacadeReglageQuiGardeLaConversation(t *testing.T) { } } -// 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) { +// LOKI_MODELS=rust : les routes des modèles partent à la façade (avec ou sans +// LOKI_CHAT), les autres restent ici ; sans la variable, rien ne change. +// Façade muette : erreur qui le dit. +func TestFacadeRelaieLesModeles(t *testing.T) { testHome(t) f := demarrerFacade(t) t.Setenv(facadeChatEnv, "") - t.Setenv(facadeDownloadsEnv, "rust") + t.Setenv(facadeModelsEnv, "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"} { + routes := []string{"/api/models/download", "/api/models/download/probe", + "/api/models/download/status", "/api/models/download/cancel", "/api/models", + "/api/models/delete", "/api/models/dirs", "/api/hf/search", "/api/hf/files", "/api/hf/token"} + for _, p := range routes { 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"}` { + if got := f.recu(); len(got) != len(routes) || 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"} { + // Le chat, lui, reste ici (LOKI_CHAT absent), et les routes voisines aussi. + for _, p := range []string{"/api/chat/send", "/api/model/layers", "/api/presets"} { w := httptest.NewRecorder() withFacadeChat(p, local)(w, httptest.NewRequest("POST", p, nil)) if w.Body.String() != "go" { @@ -340,11 +341,11 @@ func TestFacadeRelaieLesTelechargements(t *testing.T) { 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 : ") { + if w.Code != http.StatusBadGateway || !strings.Contains(w.Body.String(), "modèles indisponibles : ") { t.Fatalf("façade muette : %d %s", w.Code, w.Body.String()) } - // Sans LOKI_DOWNLOADS=rust : tout reste ici. - t.Setenv(facadeDownloadsEnv, "") + // Sans LOKI_MODELS=rust : tout reste ici. + t.Setenv(facadeModelsEnv, "") w = httptest.NewRecorder() withFacadeChat("/api/models/download", local)(w, httptest.NewRequest("POST", "/api/models/download", nil)) if w.Body.String() != "go" { @@ -353,7 +354,7 @@ func TestFacadeRelaieLesTelechargements(t *testing.T) { } // 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 +// modèles : 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) @@ -367,12 +368,12 @@ func TestFacadeTelechargementsGardeLesPart(t *testing.T) { } t.Setenv(internalTokenEnv, jetonTest) t.Setenv(facadeAddrEnv, "127.0.0.1:1") - t.Setenv(facadeDownloadsEnv, "rust") + t.Setenv(facadeModelsEnv, "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, "") + t.Setenv(facadeModelsEnv, "") cleanStartupPartFiles() if _, err := os.Stat(part); !os.IsNotExist(err) { t.Fatalf(".part orphelin gardé : %v", err) diff --git a/internal/loki/facade_downloads.go b/internal/loki/facade_downloads.go deleted file mode 100644 index c35e074..0000000 --- a/internal/loki/facade_downloads.go +++ /dev/null @@ -1,45 +0,0 @@ -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, -} diff --git a/internal/loki/facade_models.go b/internal/loki/facade_models.go new file mode 100644 index 0000000..8c0f776 --- /dev/null +++ b/internal/loki/facade_models.go @@ -0,0 +1,52 @@ +package loki + +import ( + "os" + "strings" +) + +// facade_models.go — routes des modèles tenues par la façade Rust +// (LOKI_MODELS=rust, lot 8 de la migration ; expérimental, le Go reste le +// défaut) : liste du sélecteur, dossiers, suppression, téléchargements et +// catalogue Hugging Face. +// +// La façade sert elle-même ces routes sur le port public. Le tunnel du relais +// et sa redistribution E2E arrivent pourtant ICI : elles 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 facadeModelsEnv = "LOKI_MODELS" + +// facadeModels : la façade tient-elle les routes des modèles ? Il faut aussi +// son adresse et le jeton interne, sans lesquels rien ne peut lui être remis. +func facadeModels() bool { + return strings.EqualFold(strings.TrimSpace(os.Getenv(facadeModelsEnv)), "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 !facadeModels() { + cleanStalePartFiles() + } +} + +// facadeModelRoutes : les routes que la façade sert alors (models.rs, +// downloads.rs, hf.rs). +var facadeModelRoutes = map[string]bool{ + "/api/models": true, + "/api/models/delete": true, + "/api/models/dirs": true, + "/api/hf/search": true, + "/api/hf/files": true, + "/api/hf/token": true, + "/api/models/download": true, + "/api/models/download/probe": true, + "/api/models/download/status": true, + "/api/models/download/cancel": true, +} diff --git a/internal/loki/parity_models_test.go b/internal/loki/parity_models_test.go new file mode 100644 index 0000000..08edf26 --- /dev/null +++ b/internal/loki/parity_models_test.go @@ -0,0 +1,351 @@ +package loki + +import ( + "encoding/json" + "net/http/httptest" + "os" + "path/filepath" + "runtime" + "sort" + "strings" + "testing" +) + +// parity_models_test.go — journal de référence des autres routes des modèles +// (lot 8 de la migration Rust) : liste du sélecteur, dossiers de modèles, +// suppression, et les fonctions pures du catalogue Hugging Face (classement +// d'un dépôt, verdict mémoire, jeton masqué…). Rangé dans +// rust/loki-rs/tests/parity_models/models.json ; tests/models.rs le rejoue. +// LOKI_HOME réécrit en {{HOME}} ; l'espace libre, propre à la machine, masqué. +// +// LOKI_PARITY_UPDATE=1 go test ./internal/loki -run TestPariteModeles + +const parityModelsFile = "../../rust/loki-rs/tests/parity_models/models.json" + +type parityHFFile struct { + Path string `json:"path"` + Size int64 `json:"size"` +} + +type parityVerdict struct { + Hardware hardwareInfo `json:"hardware"` + Weights int64 `json:"weights"` + MMProj int64 `json:"mmproj"` + Ctx int `json:"ctx"` + Verdict string `json:"verdict"` + Why string `json:"why"` +} + +type parityModelsCall struct { + Method string `json:"method"` + Path string `json:"path"` + Body string `json:"body,omitempty"` + Status int `json:"status"` + Out any `json:"out"` + // Après l'appel : fichiers restants (suppression) ou dossiers enregistrés. + Files []string `json:"files,omitempty"` + Stored []string `json:"stored,omitempty"` +} + +type parityModelsScenario struct { + // Fichiers sous LOKI_HOME (taille ; -1 = dossier). + Files map[string]int64 `json:"files"` + Extra []string `json:"extra"` + Presets map[string]string `json:"presets"` + Config map[string]string `json:"config"` + Calls []parityModelsCall `json:"calls"` +} + +type parityModelsFixture struct { + Quant map[string]string `json:"quant"` + SplitArgs []struct { + In string `json:"in"` + Out []string `json:"out"` + } `json:"split_args"` + Mask map[string]string `json:"mask"` + Gated []struct { + In json.RawMessage `json:"in"` + Out bool `json:"out"` + } `json:"gated"` + Classify struct { + Repo string `json:"repo"` + Files []parityHFFile `json:"files"` + Out hfListing `json:"out"` + } `json:"classify"` + Projector []struct { + In []hfEntry `json:"in"` + Name string `json:"name"` + } `json:"projector"` + Verdicts []parityVerdict `json:"verdicts"` + Scenario parityModelsScenario `json:"scenario"` +} + +// parityMaskFree remplace l'espace libre (propre à la machine) par « … ». +func parityMaskFree(v any) any { + switch x := v.(type) { + case map[string]any: + for k, e := range x { + if k == "free" || k == "free_exact" { + x[k] = "…" + continue + } + x[k] = parityMaskFree(e) + } + case []any: + for i, e := range x { + x[i] = parityMaskFree(e) + } + } + return v +} + +func parityTreeFiles(t *testing.T, home string) []string { + t.Helper() + var out []string + _ = filepath.Walk(home, func(p string, info os.FileInfo, err error) error { + if err != nil || info.IsDir() { + return nil + } + rel, _ := filepath.Rel(home, p) + rel = filepath.ToSlash(rel) + if strings.HasPrefix(rel, "models/") || strings.HasPrefix(rel, "disque/") { + out = append(out, rel) + } + return nil + }) + sort.Strings(out) + return out +} + +func TestPariteModeles(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 parityModelsFixture + + fx.Quant = map[string]string{} + for _, n := range []string{"Qwen3-8B-Q4_K_M.gguf", "m-IQ4_XS-00001-of-00002.gguf", "x.Q8_0.gguf", + "model-bf16.gguf", "a-4bpw.gguf", "plain.gguf", "m-Q4.gguf", "m-Q4_K_XL-Q5_K_M.gguf", "mmproj-F16.GGUF", "noext"} { + fx.Quant[n] = quantFromName(n) + } + for _, in := range []string{"", "--mmproj /a/b.gguf -ngl 99", `--chat-template-file "/etc/x y.jinja" --x ''`, + "a\tb\nc", `'un "deux"' trois`, `--mmproj`} { + fx.SplitArgs = append(fx.SplitArgs, struct { + In string `json:"in"` + Out []string `json:"out"` + }{in, splitArgs(in)}) + } + fx.Mask = map[string]string{} + for _, tk := range []string{"", " ", "abc", "hf_12345678", "hf_abcdefghijklmnop", " hf_padded_token "} { + fx.Mask[tk] = maskHFToken(tk) + } + for _, raw := range []string{`true`, `false`, `"auto"`, `"manual"`, `"false"`, `""`, `"None"`, `null`, `1`} { + var v any + _ = json.Unmarshal([]byte(raw), &v) + fx.Gated = append(fx.Gated, struct { + In json.RawMessage `json:"in"` + Out bool `json:"out"` + }{json.RawMessage(raw), hfGatedFlag(v)}) + } + + fx.Classify.Repo = "unsloth/Qwen3-8B-GGUF" + fx.Classify.Files = []parityHFFile{ + {"README.md", 1000}, + {"Qwen3-8B-Q8_0.gguf", 8 << 30}, + {"Qwen3-8B-Q4_K_M.gguf", 5 << 30}, + {"mmproj-F16.gguf", 900 << 20}, + {"mmproj-Q8_0.gguf", 600 << 20}, + {"mtp-Qwen3-8B-Q8_0.gguf", 300 << 20}, + {"BF16/Qwen3-8B-BF16-00001-of-00002.gguf", 9 << 30}, + {"BF16/Qwen3-8B-BF16-00002-of-00002.gguf", 7 << 30}, + {"Q2/Qwen3-8B-BF16-00001-of-00002.gguf", 1 << 30}, + {"dossier avec espace/Qwen3 8B Q3.gguf", 4 << 30}, + } + var files []hfFile + for _, f := range fx.Classify.Files { + files = append(files, hfFile{Path: f.Path, Size: f.Size}) + } + fx.Classify.Out = hfClassify(fx.Classify.Repo, files) + + for _, in := range [][]hfEntry{ + nil, + {{Name: "mmproj-F16.gguf", Quant: "F16", Size: 900}, {Name: "mmproj-BF16.gguf", Quant: "BF16", Size: 800}}, + {{Name: "mmproj-F16.gguf", Quant: "F16", Size: 900}, {Name: "mmproj-q8_0.gguf", Quant: "q8_0", Size: 950}}, + } { + p, ok := hfPickProjector(in) + name := "" + if ok { + name = p.Name + } + fx.Projector = append(fx.Projector, struct { + In []hfEntry `json:"in"` + Name string `json:"name"` + }{in, name}) + } + + gpu := hardwareInfo{OS: "linux", Arch: "amd64", RAMGB: 64, VRAMGB: 24, GPUs: []gpuBrief{{Name: "RTX", VRAMGB: 24}}} + cpu := hardwareInfo{OS: "linux", Arch: "amd64", RAMGB: 16} + none := hardwareInfo{OS: "linux", Arch: "amd64"} + for _, c := range []parityVerdict{ + {Hardware: gpu, Weights: 5 << 30, Ctx: 32768}, + {Hardware: gpu, Weights: 20 << 30, MMProj: 600 << 20, Ctx: 32768}, + {Hardware: gpu, Weights: 22 << 30, Ctx: 32768}, + {Hardware: gpu, Weights: 30 << 30, Ctx: 0}, + {Hardware: cpu, Weights: 8 << 30, Ctx: 131072}, + {Hardware: none, Weights: 1 << 30, Ctx: 4096}, + } { + c.Verdict, c.Why = fitVerdict(c.Hardware, c.Weights, c.MMProj, c.Ctx) + fx.Verdicts = append(fx.Verdicts, c) + } + + // Scénario sur disque : liste, dossiers, suppressions. + sc := parityModelsScenario{ + Files: map[string]int64{ + "models/a-Q4_K_M.gguf": 100, + "models/b.gguf": 200, + "models/actif.gguf": 50, + "models/vision-mmproj.gguf": 30, + "models/m-00001-of-00002.gguf": 10, + "models/m-00002-of-00002.gguf": 20, + "models/t-00001-of-00003.gguf": 5, + "models/t-00003-of-00003.gguf": 7, + "models/notes.txt": 3, + "models/sous-dossier.gguf": -1, + "disque/ext.GGUF": 400, + "disque/a-Q4_K_M.gguf": 100, + }, + Extra: []string{"{{HOME}}/disque", "{{HOME}}/absent"}, + Presets: map[string]string{ + "alpha": "NAME=Alpha\nMODEL=a-Q4_K_M.gguf\nMMPROJ=vision-mmproj.gguf\n", + "beta": "NAME=Beta\nMODEL={{HOME}}/disque/ext.GGUF\n", + "gamma": "NAME=Gamma\nMODEL=b.gguf\nEXTRA_ARGS=--mmproj \"vision-mmproj.gguf\" -ngl 99 --mmproj b.gguf\n", + "delta": "NAME=Delta\nMODEL=m-00001-of-00002.gguf\n", + }, + Config: map[string]string{"MODEL": "actif.gguf", "MODEL_DRAFT": "{{HOME}}/disque/a-Q4_K_M.gguf"}, + } + for rel, size := range sc.Files { + p := filepath.Join(home, rel) + if size < 0 { + if err := os.MkdirAll(p, 0o755); err != nil { + t.Fatal(err) + } + continue + } + if err := os.MkdirAll(filepath.Dir(p), 0o755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(p, make([]byte, size), 0o644); err != nil { + t.Fatal(err) + } + } + unhome := func(s string) string { return strings.ReplaceAll(s, "{{HOME}}", home) } + var extra []string + for _, d := range sc.Extra { + extra = append(extra, unhome(d)) + } + if err := saveExtraModelDirs(extra); err != nil { + t.Fatal(err) + } + for id, content := range sc.Presets { + if err := os.MkdirAll(presetsDir(), 0o755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(presetsDir(), id+".env"), []byte(unhome(content)), 0o644); err != nil { + t.Fatal(err) + } + } + cfg := map[string]string{} + for k, v := range sc.Config { + cfg[k] = unhome(v) + } + if err := WriteConfig(cfg); err != nil { + t.Fatal(err) + } + + call := func(method, path, body string) parityModelsCall { + w := httptest.NewRecorder() + r := httptest.NewRequest(method, path, strings.NewReader(unhome(body))) + switch { + case path == "/api/models": + handleModels(w, r) + case path == "/api/models/delete": + handleModelDelete(w, r) + case path == "/api/models/dirs": + handleModelDirs(w, r) + default: + t.Fatalf("route inattendue %s", path) + } + var out any + if err := json.Unmarshal([]byte(norm(w.Body.String())), &out); err != nil { + t.Fatalf("%s %s : %v (%s)", method, path, err, w.Body.String()) + } + c := parityModelsCall{Method: method, Path: path, Body: body, Status: w.Code, Out: parityMaskFree(out)} + if path == "/api/models/delete" { + c.Files = parityTreeFiles(t, home) + } + if path == "/api/models/dirs" && method == "POST" { + var stored []string + getJSON(bkState, "model_dirs", &stored) + for _, s := range stored { + c.Stored = append(c.Stored, norm(s)) + } + } + return c + } + for _, c := range []struct{ m, p, b string }{ + {"GET", "/api/models", ""}, + {"GET", "/api/models/dirs", ""}, + {"POST", "/api/models/delete", `{"name":"actif.gguf"}`}, + {"POST", "/api/models/delete", `{"name":"a-Q4_K_M.gguf"}`}, + {"POST", "/api/models/delete", `{"name":"{{HOME}}/disque/a-Q4_K_M.gguf"}`}, + {"POST", "/api/models/delete", `{"name":"b.gguf","force":true}`}, + {"POST", "/api/models/delete", `{"name":"m-00001-of-00002.gguf","force":true}`}, + {"POST", "/api/models/delete", `{"name":"t-00001-of-00003.gguf"}`}, + {"POST", "/api/models/delete", `{"name":"inconnu.gguf"}`}, + {"POST", "/api/models/delete", `{"name":"/etc/passwd.gguf"}`}, + {"POST", "/api/models/delete", `{"name":"notes.txt"}`}, + {"POST", "/api/models/delete", `{"name":""}`}, + {"POST", "/api/models/dirs", `{"path":""}`}, + {"POST", "/api/models/dirs", `{"path":"{{HOME}}/nulle-part"}`}, + {"POST", "/api/models/dirs", `{"path":"{{HOME}}/models/sous-dossier.gguf"}`}, + {"POST", "/api/models/dirs", `{"path":"{{HOME}}/disque/","action":"remove"}`}, + {"POST", "/api/models/dirs", `{"path":"{{HOME}}/disque/../disque","action":"add"}`}, + {"POST", "/api/models/dirs", `{"path":"{{HOME}}/models","action":"add"}`}, + {"GET", "/api/models", ""}, + {"GET", "/api/models/dirs", ""}, + } { + sc.Calls = append(sc.Calls, call(c.m, c.p, c.b)) + } + fx.Scenario = sc + + got, err := parityJSON(fx) + if err != nil { + t.Fatal(err) + } + if os.Getenv("LOKI_PARITY_UPDATE") == "1" { + if err := os.MkdirAll(filepath.Dir(parityModelsFile), 0o755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(parityModelsFile, got, 0o644); err != nil { + t.Fatal(err) + } + return + } + want, err := os.ReadFile(parityModelsFile) + 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 routes des modèles ne produisent plus le journal rangé dans %s.\n"+ + "Si c'est voulu : LOKI_PARITY_UPDATE=1 go test ./internal/loki -run TestPariteModeles,\n"+ + "puis cargo test --test models dit si le port Rust suit.\n--- obtenu ---\n%s", parityModelsFile, got) + } +} diff --git a/internal/loki/web_server.go b/internal/loki/web_server.go index 99d3ffc..a1bd310 100644 --- a/internal/loki/web_server.go +++ b/internal/loki/web_server.go @@ -131,7 +131,7 @@ func newWebMux() *http.ServeMux { 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 (sauf quand la - // façade tient les téléchargements, facade_downloads.go). + // façade tient les modèles, facade_models.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). @@ -219,9 +219,8 @@ 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), celles des téléchargements - // de modèles quand elle les tient (LOKI_DOWNLOADS=rust, - // facade_downloads.go) ; sans effet sinon. + // conversation (LOKI_CHAT=rust, facade_chat.go), celles des modèles quand + // elle les tient (LOKI_MODELS=rust, facade_models.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) diff --git a/rust/loki-rs/src/api.rs b/rust/loki-rs/src/api.rs index 5caf66c..870e36c 100644 --- a/rust/loki-rs/src/api.rs +++ b/rust/loki-rs/src/api.rs @@ -26,9 +26,10 @@ pub struct Ctx { pub engine: engine::Paths, /// Conversation tenue par la façade (`LOKI_CHAT=rust`) ; `None` : au Go. pub chat: Option>, - /// Téléchargements de modèles tenus par la façade (`LOKI_DOWNLOADS=rust`) ; + /// Routes des modèles (liste, dossiers, suppression, téléchargements, + /// catalogue Hugging Face) tenues par la façade (`LOKI_MODELS=rust`) ; /// `None` : au Go. - pub downloads: Option>, + pub models: Option>, /// Jeton de l'API interne : une requête qui le porte vient du Go. pub internal_token: String, } diff --git a/rust/loki-rs/src/downloads.rs b/rust/loki-rs/src/downloads.rs index 08b84a5..44fe294 100644 --- a/rust/loki-rs/src/downloads.rs +++ b/rust/loki-rs/src/downloads.rs @@ -2,7 +2,7 @@ //! des téléchargements de `backend_models.go`, `backend_models_shards.go`, //! `backend_model_dirs.go` et `backend_hf.go` (lot 8 de la migration). //! -//! Tenus par la façade quand `LOKI_DOWNLOADS=rust` (expérimental ; le Go +//! Tenus par la façade quand `LOKI_MODELS=rust` (expérimental ; le Go //! par défaut) : `POST /api/models/download`, `/probe`, `/cancel`, //! `GET /api/models/download/status`. Le Go remet alors à la façade ces //! routes quand elles lui arrivent par le tunnel du relais, et ne vide plus diff --git a/rust/loki-rs/src/hf.rs b/rust/loki-rs/src/hf.rs new file mode 100644 index 0000000..ec59b80 --- /dev/null +++ b/rust/loki-rs/src/hf.rs @@ -0,0 +1,735 @@ +//! Catalogue Hugging Face — port de `backend_hf.go`, `web_hf.go` et +//! `backend_catalog.go` (lot 8 de la migration), servi par la façade quand +//! `LOKI_MODELS=rust` : +//! +//! - `GET /api/hf/search?q=…` : dépôts GGUF ; +//! - `GET /api/hf/files?repo=auteur/dépôt` : modèles, projecteurs et poids +//! MTP du dépôt, chaque modèle avec son verdict mémoire ; +//! - `GET|POST /api/hf/token` : le jeton (dépôts verrouillés), vérifié avant +//! d'être enregistré, jamais rendu en clair. +//! +//! Rien n'est téléchargé ici : les liens produits passent par +//! [`crate::downloads`]. Réponses de l'API mises en cache (60 s pour une +//! recherche, 10 min pour un dépôt) ; un nouveau jeton vide le cache. +//! +//! Écarts assumés : erreur de transport au format de reqwest ; mémoire vive +//! inconnue hors Linux (la façade n'est livrée que pour Linux). + +use std::collections::HashMap; +use std::sync::Mutex; +use std::time::{Duration, Instant}; + +use bytes::Bytes; +use hyper::body::Incoming; +use hyper::{Method, Request, Response, StatusCode}; +use serde::{Deserialize, Serialize}; +use serde_json::{Value, json}; + +use crate::api::Ctx; +use crate::auth::json as send_json; +use crate::gguf; +use crate::models::quant_from_name; +use crate::paths::base_name; +use crate::proxy::Body; +use crate::store::BK_STATE; + +pub const HF_HOST: &str = "https://huggingface.co"; +const SEARCH_TTL: Duration = Duration::from_secs(60); +const FILES_TTL: Duration = Duration::from_secs(600); +const MAX_REPOS: usize = 25; +const MAX_QUERY: usize = 100; +const TIMEOUT: Duration = Duration::from_secs(10); + +/// Routes servies ici. +pub fn handles(path: &str) -> bool { + matches!(path, "/api/hf/search" | "/api/hf/files" | "/api/hf/token") +} + +// --------------------------------------------------------------------------- +// Fonctions pures +// --------------------------------------------------------------------------- + +/// `hfRepoRe`. +pub fn repo_ok(repo: &str) -> bool { + let part = |s: &str| { + let mut c = s.chars(); + c.next().is_some_and(|f| f.is_ascii_alphanumeric()) + && c.all(|ch| ch.is_ascii_alphanumeric() || matches!(ch, '.' | '_' | '-')) + }; + match repo.split_once('/') { + Some((a, b)) => part(a) && part(b), + None => false, + } +} + +/// `maskHFToken` : assez de caractères pour reconnaître le jeton posé. +pub fn mask_hf_token(t: &str) -> String { + let t = t.trim(); + if t.is_empty() { + return String::new(); + } + let b = t.as_bytes(); + let s = |r: std::ops::Range| String::from_utf8_lossy(&b[r]).into_owned(); + if b.len() <= 8 { + return format!("…{}", s(b.len().saturating_sub(2)..b.len())); + } + format!("{}…{}", s(0..3), s(b.len() - 4..b.len())) +} + +/// `hfGatedFlag` : `false`, `"auto"` ou `"manual"` — seul compte le verrou. +pub fn gated_flag(v: &Value) -> bool { + match v { + Value::Bool(b) => *b, + Value::String(s) => !matches!( + s.trim().to_lowercase().as_str(), + "" | "false" | "none" | "no" + ), + _ => false, + } +} + +/// `url.PathEscape`. +pub fn go_path_escape(s: &str) -> String { + let mut out = String::new(); + for &c in s.as_bytes() { + if c.is_ascii_alphanumeric() || b"-_.~$&+:=@".contains(&c) { + out.push(c as char); + } else { + out.push_str(&format!("%{c:02X}")); + } + } + out +} + +/// `url.QueryEscape` (sur des octets : la recherche est tronquée à l'octet +/// comme dans le Go). +pub fn go_query_escape(s: &[u8]) -> String { + let mut out = String::new(); + for &c in s { + if c.is_ascii_alphanumeric() || b"-_.~".contains(&c) { + out.push(c as char); + } else if c == b' ' { + out.push('+'); + } else { + out.push_str(&format!("%{c:02X}")); + } + } + out +} + +/// Un `.gguf` installable (`hfEntry`). +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] +pub struct HfEntry { + pub name: String, + pub url: String, + pub size: i64, + pub quant: String, + pub shards: i64, + #[serde(default, skip_serializing_if = "String::is_empty")] + pub verdict: String, + #[serde(default, skip_serializing_if = "String::is_empty")] + pub why: String, +} + +/// `null` quand vide, comme une tranche nil du Go. +mod nil_vec { + use serde::{Deserialize, Deserializer, Serialize, Serializer}; + pub fn serialize(v: &[T], s: S) -> Result { + if v.is_empty() { + s.serialize_none() + } else { + v.serialize(s) + } + } + pub fn deserialize<'de, D: Deserializer<'de>, T: Deserialize<'de>>( + d: D, + ) -> Result, D::Error> { + Ok(Option::>::deserialize(d)?.unwrap_or_default()) + } +} + +/// Le contenu utile d'un dépôt (`hfListing`). +#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq)] +pub struct HfListing { + pub repo: String, + pub gated: bool, + #[serde(with = "nil_vec")] + pub models: Vec, + #[serde(with = "nil_vec")] + pub projectors: Vec, + #[serde(with = "nil_vec")] + pub drafts: Vec, +} + +/// `hfResolveURL` : chaque segment échappé à part. +pub fn resolve_url(base: &str, repo: &str, file_path: &str) -> String { + let segs: Vec = file_path.split('/').map(go_path_escape).collect(); + format!("{base}/{repo}/resolve/main/{}", segs.join("/")) +} + +/// `hfClassify` : modèles, projecteurs (`mmproj*`) et poids MTP (`mtp-*`), +/// une famille de tranches comptée en entier, triés par taille. +pub fn classify(base: &str, repo: &str, files: &[(String, i64)]) -> HfListing { + let mut size: HashMap<&str, i64> = HashMap::new(); + let mut paths = Vec::new(); + for (p, n) in files { + if !p.to_lowercase().ends_with(".gguf") { + continue; + } + size.insert(p, *n); + paths.push(p.as_str()); + } + let mut out = HfListing { + repo: repo.into(), + ..Default::default() + }; + for p in paths { + let name = base_name(p); + if gguf::is_follower_shard(&name) { + continue; + } + let fam = gguf::shard_family(&name); + let dir = p.rfind('/').map_or("", |i| &p[..=i]); + let total = fam + .iter() + .map(|n| size.get(format!("{dir}{n}").as_str()).copied().unwrap_or(0)) + .sum(); + let e = HfEntry { + url: resolve_url(base, repo, p), + size: total, + quant: quant_from_name(&name), + shards: fam.len() as i64, + name: name.clone(), + verdict: String::new(), + why: String::new(), + }; + let low = name.to_lowercase(); + if low.starts_with("mmproj") { + out.projectors.push(e); + } else if low.starts_with("mtp-") { + out.drafts.push(e); + } else { + out.models.push(e); + } + } + // sort.Slice n'est pas stable ; à tailles égales l'ordre du Go n'est pas + // garanti non plus. + for v in [&mut out.models, &mut out.projectors, &mut out.drafts] { + v.sort_by_key(|e| e.size); + } + out +} + +/// `hfPickProjector` : le Q8_0 d'abord, sinon le plus léger. +pub fn pick_projector(list: &[HfEntry]) -> Option<&HfEntry> { + if let Some(e) = list.iter().find(|e| e.quant.eq_ignore_ascii_case("Q8_0")) { + return Some(e); + } + let mut best = list.first()?; + for e in &list[1..] { + if e.size < best.size { + best = e; + } + } + Some(best) +} + +/// Une carte (`gpuBrief`). +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] +pub struct GpuBrief { + pub name: String, + pub vram_gb: f64, +} + +/// La machine (`hardwareInfo`). +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] +pub struct Hardware { + pub os: String, + pub arch: String, + pub ram_gb: f64, + pub vram_gb: f64, + #[serde(with = "nil_vec")] + pub gpus: Vec, +} + +/// `runtime.GOOS` / `runtime.GOARCH`. +fn go_os_arch() -> (String, String) { + let arch = match std::env::consts::ARCH { + "x86_64" => "amd64", + "aarch64" => "arm64", + "x86" => "386", + a => a, + }; + (std::env::consts::OS.into(), arch.into()) +} + +/// `totalRAMGB` (Linux : MemTotal de /proc/meminfo). +fn total_ram_gb() -> f64 { + #[cfg(target_os = "linux")] + { + let Ok(b) = std::fs::read_to_string("/proc/meminfo") else { + return 0.0; + }; + for line in b.lines() { + if line.starts_with("MemTotal:") { + return line + .split_whitespace() + .nth(1) + .and_then(|v| v.parse::().ok()) + .map_or(0.0, |kb| kb / (1024.0 * 1024.0)); + } + } + 0.0 + } + #[cfg(not(target_os = "linux"))] + { + 0.0 + } +} + +/// `detectHardware` : la VRAM vient de nvidia-smi (cache partagé des jauges). +pub async fn detect_hardware() -> Hardware { + let (os, arch) = go_os_arch(); + let mut h = Hardware { + os, + arch, + ram_gb: total_ram_gb(), + vram_gb: 0.0, + gpus: Vec::new(), + }; + for g in crate::sysinfo::gpu_stats_cached().await { + let gb = g.total as f64 / 1024.0; + h.vram_gb += gb; + h.gpus.push(GpuBrief { + name: g.name, + vram_gb: gb, + }); + } + h +} + +/// `fitVerdict` : « ok », « juste » ou « trop », et pourquoi. +pub fn fit_verdict(h: &Hardware, weights: i64, mmproj: i64, ctx_tokens: i64) -> (String, String) { + const GB: f64 = (1u64 << 30) as f64; + let w = weights as f64 / GB; + let m = mmproj as f64 / GB; + let ctx = if ctx_tokens <= 0 { 32768 } else { ctx_tokens }; + let kv = w / 20.0 * (ctx as f64 / 32768.0); + let need = w + m + kv; + let (budget, place) = if h.vram_gb > 0.0 { + (h.vram_gb, "VRAM") + } else { + (h.ram_gb, "RAM") + }; + if budget <= 0.0 { + return (String::new(), String::new()); + } + let mut detail = format!("{w:.1} Go de poids"); + if m > 0.0 { + detail += &format!(" + {m:.1} Go de projecteur"); + } + detail += &format!( + " + ~{kv:.1} Go de contexte (estimation) = ~{need:.1} Go pour {budget:.1} Go de {place}" + ); + if need > budget { + ("trop".into(), format!("ne tient pas : {detail}")) + } else if need > budget * 0.9 { + ("juste".into(), format!("ça passe de justesse : {detail}")) + } else { + ("ok".into(), detail) + } +} + +/// `hfSearch` : l'URL appelée (clés triées comme `url.Values.Encode`). +pub fn search_endpoint(base: &str, q: &str) -> String { + let q = q.trim().as_bytes(); + let q = &q[..q.len().min(MAX_QUERY)]; + format!( + "{base}/api/models?direction=-1&expand%5B%5D=gated&expand%5B%5D=downloads&expand%5B%5D=likes&filter=gguf&limit={MAX_REPOS}&search={}&sort=downloads", + go_query_escape(q) + ) +} + +// --------------------------------------------------------------------------- +// Client +// --------------------------------------------------------------------------- + +/// Appels à l'API Hugging Face, avec cache (`hfGetJSON`). +pub struct HfClient { + base: String, + client: reqwest::Client, + cache: Mutex>, +} + +/// Ce dont un appel a besoin : jeton et version (User-Agent). +pub struct HfEnv { + pub token: String, + pub version: String, +} + +#[derive(Deserialize)] +struct RawRepo { + #[serde(default)] + id: String, + #[serde(default)] + downloads: i64, + #[serde(default)] + likes: i64, + #[serde(default)] + gated: Value, +} + +#[derive(Deserialize)] +struct RawFile { + #[serde(default, rename = "type")] + kind: String, + #[serde(default)] + path: String, + #[serde(default)] + size: i64, + #[serde(default)] + lfs: Option, +} + +#[derive(Deserialize)] +struct RawLfs { + #[serde(default)] + size: i64, +} + +impl HfClient { + /// `base` : `https://huggingface.co` (un faux serveur dans les tests). + pub fn new(base: &str) -> Self { + crate::chat::engine::install_crypto(); + HfClient { + base: base.trim_end_matches('/').to_string(), + client: reqwest::Client::builder() + .timeout(TIMEOUT) + .build() + .unwrap_or_default(), + cache: Mutex::new(HashMap::new()), + } + } + + pub fn base(&self) -> &str { + &self.base + } + + fn cache(&self) -> std::sync::MutexGuard<'_, HashMap> { + self.cache.lock().unwrap_or_else(|e| e.into_inner()) + } + + /// Un nouveau jeton : les réponses obtenues sans lui ne valent plus. + pub fn clear_cache(&self) { + self.cache().clear(); + } + + async fn get_json( + &self, + env: &HfEnv, + endpoint: &str, + ttl: Duration, + ) -> Result { + let hit = self + .cache() + .get(endpoint) + .filter(|(at, _)| at.elapsed() < ttl) + .map(|(_, b)| b.clone()); + if let Some(raw) = hit { + return serde_json::from_slice(&raw).map_err(|e| e.to_string()); + } + let mut rb = self + .client + .get(endpoint) + .header("User-Agent", format!("loki/{}", env.version)) + .header("Accept", "application/json"); + if !env.token.is_empty() { + rb = rb.header("Authorization", format!("Bearer {}", env.token)); + } + let resp = rb + .send() + .await + .map_err(|e| format!("Hugging Face injoignable : {e}"))?; + match resp.status().as_u16() { + 200 => {} + 401 | 403 => { + return Err("dépôt à accès restreint — renseigne HF_TOKEN pour y accéder".into()); + } + 404 => return Err("dépôt introuvable sur Hugging Face".into()), + s => return Err(format!("Hugging Face a répondu {s}")), + } + let raw = read_capped(resp, 4 << 20).await?; + self.cache() + .insert(endpoint.to_string(), (Instant::now(), raw.clone())); + serde_json::from_slice(&raw).map_err(|e| e.to_string()) + } + + /// `hfSearch`. + pub async fn search(&self, env: &HfEnv, q: &str) -> Result { + if q.trim().is_empty() { + return Err("recherche vide".into()); + } + let raw: Vec = self + .get_json(env, &search_endpoint(&self.base, q), SEARCH_TTL) + .await?; + Ok(Value::Array( + raw.into_iter() + .map(|r| { + json!({ "id": r.id, "downloads": r.downloads, "likes": r.likes, "gated": gated_flag(&r.gated) }) + }) + .collect(), + )) + } + + /// `hfFiles`. + pub async fn files(&self, env: &HfEnv, repo: &str) -> Result { + let repo = repo.trim().trim_matches('/'); + if !repo_ok(repo) { + return Err("nom de dépôt invalide (attendu « auteur/dépôt »)".into()); + } + let endpoint = format!("{}/api/models/{repo}/tree/main?recursive=1", self.base); + let raw: Vec = self.get_json(env, &endpoint, FILES_TTL).await?; + let files: Vec<(String, i64)> = raw + .into_iter() + .filter(|f| f.kind != "directory") + .map(|f| { + let n = f.lfs.map_or(0, |l| l.size); + (f.path, if n == 0 { f.size } else { n }) + }) + .collect(); + let mut out = classify(&self.base, repo, &files); + out.gated = self.repo_gated(env, repo).await; + Ok(out) + } + + /// `hfRepoGated` : une erreur ne casse rien (pas d'avertissement, c'est + /// tout). + async fn repo_gated(&self, env: &HfEnv, repo: &str) -> bool { + #[derive(Deserialize)] + struct Info { + #[serde(default)] + gated: Value, + } + let endpoint = format!("{}/api/models/{repo}?expand%5B%5D=gated", self.base); + self.get_json::(env, &endpoint, FILES_TTL) + .await + .is_ok_and(|i| gated_flag(&i.gated)) + } + + /// `hfWhoAmI` : le compte du jeton, ou pourquoi il est refusé. + pub async fn whoami(&self, version: &str, tok: &str) -> Result { + let resp = self + .client + .get(format!("{}/api/whoami-v2", self.base)) + .header("Authorization", format!("Bearer {}", tok.trim())) + .header("User-Agent", format!("loki/{version}")) + .header("Accept", "application/json") + .send() + .await + .map_err(|e| format!("Hugging Face injoignable : {e}"))?; + match resp.status().as_u16() { + 200 => {} + 401 | 403 => { + return Err("jeton refusé par Hugging Face (expiré, révoqué ou mal copié)".into()); + } + s => return Err(format!("Hugging Face a répondu {s}")), + } + #[derive(Deserialize)] + struct Who { + #[serde(default)] + name: String, + } + let raw = read_capped(resp, 1 << 20).await?; + serde_json::from_slice::(&raw) + .map(|w| w.name) + .map_err(|e| e.to_string()) + } +} + +/// Lit au plus `max` octets du corps (réponse d'un tiers). +async fn read_capped(mut resp: reqwest::Response, max: usize) -> Result { + let mut buf = Vec::new(); + while let Some(c) = resp.chunk().await.map_err(|e| e.to_string())? { + let room = max - buf.len(); + buf.extend_from_slice(&c[..c.len().min(room)]); + if buf.len() >= max { + break; + } + } + Ok(Bytes::from(buf)) +} + +// --------------------------------------------------------------------------- +// Routes +// --------------------------------------------------------------------------- + +/// `hfTokenSource` : « config », « env » ou "". +async fn token_source(ctx: &Ctx) -> (&'static str, String) { + let stored = ctx.store.get_str(BK_STATE, "hf_token").await; + if !stored.trim().is_empty() { + return ("config", stored.trim().to_string()); + } + let env = std::env::var("HF_TOKEN").unwrap_or_default(); + if !env.trim().is_empty() { + return ("env", env.trim().to_string()); + } + ("", String::new()) +} + +fn token_state(src: &str, tok: &str, account: &str) -> Value { + json!({ "ok": true, "set": !src.is_empty(), "source": src, "masked": mask_hf_token(tok), "account": account }) +} + +fn query_param(uri: &hyper::Uri, key: &str) -> String { + let q = uri.query().unwrap_or(""); + for kv in q.split('&') { + let (k, v) = kv.split_once('=').unwrap_or((kv, "")); + if form_decode(k) == key { + return form_decode(v); + } + } + String::new() +} + +/// Décodage `application/x-www-form-urlencoded` (`+` → espace, `%XX`). +fn form_decode(s: &str) -> String { + let b = s.as_bytes(); + let mut out = Vec::with_capacity(b.len()); + let mut i = 0; + while i < b.len() { + match b[i] { + b'+' => out.push(b' '), + b'%' if i + 2 < b.len() => { + match u8::from_str_radix(std::str::from_utf8(&b[i + 1..i + 3]).unwrap_or("zz"), 16) + { + Ok(v) => { + out.push(v); + i += 3; + continue; + } + Err(_) => out.push(b'%'), + } + } + c => out.push(c), + } + i += 1; + } + String::from_utf8_lossy(&out).into_owned() +} + +pub async fn serve( + ctx: &Ctx, + hf: &HfClient, + req: Request, + from_go: bool, +) -> Response { + let (parts, body) = req.into_parts(); + let version = if from_go { + ctx.internal + .auth() + .await + .map(|i| i.version) + .unwrap_or_default() + } else { + match ctx.guard(&parts.headers).await { + Ok(v) => v, + Err(refus) => return *refus, + } + }; + let (src, token) = token_source(ctx).await; + let env = HfEnv { + token: token.clone(), + version: version.clone(), + }; + let err = |code: StatusCode, e: String| send_json(code, &json!({ "ok": false, "error": e })); + match parts.uri.path() { + "/api/hf/search" => { + let q = query_param(&parts.uri, "q"); + if q.trim().is_empty() { + return err(StatusCode::BAD_REQUEST, "précise ce que tu cherches".into()); + } + match hf.search(&env, &q).await { + Ok(repos) => send_json( + StatusCode::OK, + &json!({ "ok": true, "hardware": detect_hardware().await, "repos": repos, "hf_token": !token.is_empty() }), + ), + Err(e) => err(StatusCode::BAD_GATEWAY, e), + } + } + "/api/hf/files" => { + let repo = query_param(&parts.uri, "repo"); + if repo.trim().is_empty() { + return err(StatusCode::BAD_REQUEST, "dépôt manquant".into()); + } + let mut list = match hf.files(&env, &repo).await { + Ok(l) => l, + Err(e) => return err(StatusCode::BAD_GATEWAY, e), + }; + let hw = detect_hardware().await; + // Contexte du preset actif, défaut de serve. + let ctx_tokens = ctx + .store + .config() + .await + .get("CTX") + .and_then(|v| v.trim().parse::().ok()) + .filter(|v| *v > 0) + .unwrap_or(32768); + let mm = pick_projector(&list.projectors).map_or(0, |p| p.size); + for m in &mut list.models { + (m.verdict, m.why) = fit_verdict(&hw, m.size, mm, ctx_tokens); + } + let vision = !list.projectors.is_empty(); + let nil = |v: &Vec| { + if v.is_empty() { Value::Null } else { json!(v) } + }; + send_json( + StatusCode::OK, + &json!({ + "ok": true, "hardware": hw, "ctx": ctx_tokens, + "repo": list.repo, "models": nil(&list.models), + "projectors": nil(&list.projectors), "drafts": nil(&list.drafts), + "vision": vision, "gated": list.gated, "hf_token": !token.is_empty(), + }), + ) + } + "/api/hf/token" if parts.method == Method::POST => { + let v = match crate::models::body_json(body).await { + Ok(v) => v, + Err(_) => return err(StatusCode::BAD_REQUEST, "requête illisible".into()), + }; + let tok = v + .get("token") + .and_then(Value::as_str) + .unwrap_or("") + .trim() + .to_string(); + let mut account = String::new(); + if !tok.is_empty() { + match hf.whoami(&version, &tok).await { + Ok(a) => account = a, + Err(e) => return err(StatusCode::BAD_REQUEST, e), + } + } + // putStr : une valeur vide efface la clé. + let res = if tok.is_empty() { + ctx.store.del(BK_STATE, "hf_token").await + } else { + ctx.store + .put(BK_STATE, "hf_token", tok.clone().into_bytes()) + .await + }; + if let Err(e) = res { + return err(StatusCode::INTERNAL_SERVER_ERROR, e); + } + hf.clear_cache(); + let (src, token) = token_source(ctx).await; + send_json(StatusCode::OK, &token_state(src, &token, &account)) + } + "/api/hf/token" => send_json(StatusCode::OK, &token_state(src, &token, "")), + _ => send_json( + StatusCode::NOT_FOUND, + &json!({ "error": "404 page not found" }), + ), + } +} diff --git a/rust/loki-rs/src/lib.rs b/rust/loki-rs/src/lib.rs index e7ab18a..c0f8b84 100644 --- a/rust/loki-rs/src/lib.rs +++ b/rust/loki-rs/src/lib.rs @@ -19,6 +19,8 @@ pub mod downloads; pub mod engine; pub mod engstatus; pub mod gguf; +pub mod hf; +pub mod models; pub mod oai; pub mod paths; pub mod presets; diff --git a/rust/loki-rs/src/main.rs b/rust/loki-rs/src/main.rs index 24b75d6..7260207 100644 --- a/rust/loki-rs/src/main.rs +++ b/rust/loki-rs/src/main.rs @@ -118,17 +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(); + // LOKI_MODELS=rust : les routes des modèles aussi (le Go remet alors + // celles du tunnel et ne vide plus les .part). + let models_rust = models_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 models_rust { + env.push(("LOKI_MODELS".into(), "rust".into())); } - if chat_rust || downloads_rust { + if chat_rust || models_rust { env.push(("LOKI_FACADE_ADDR".into(), format!("127.0.0.1:{web_port}"))); } let sup = child::Supervisor::spawn(go_bin, go_port, env); @@ -161,8 +161,8 @@ fn serve() -> ExitCode { } else { None }; - let downloads = if downloads_rust { - Some(start_downloads(backend, &token, &engine_paths.home).await) + let models = if models_rust { + Some(start_models(backend, &token, &engine_paths.home).await) } else { None }; @@ -183,7 +183,7 @@ fn serve() -> ExitCode { host_env: auth::HostEnv::from_env(), engine: engine_paths, chat, - downloads, + models, }; let res = proxy::serve(listener, cfg, shutdown_signal()).await; eprintln!("[loki-rs] arrêt demandé — arrêt du serveur Go"); @@ -203,18 +203,18 @@ 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")) +/// `LOKI_MODELS=rust` : routes des modèles tenues par la façade. +fn models_held_by_facade() -> bool { + std::env::var("LOKI_MODELS").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( +async fn start_models( backend: SocketAddr, token: &str, home: &std::path::Path, -) -> std::sync::Arc { +) -> std::sync::Arc { use loki_rs::{downloads, gguf, store}; let st = store::Store::new(backend, token.to_string()); let stored = gguf::parse_stored_model_dirs( @@ -230,8 +230,8 @@ async fn start_downloads( 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()) + eprintln!("[loki-rs] modèles tenus par la façade (LOKI_MODELS=rust)"); + std::sync::Arc::new(loki_rs::models::Models::new()) } /// Le Go garde-t-il la conversation ? (quelques essais : il vient de démarrer) diff --git a/rust/loki-rs/src/models.rs b/rust/loki-rs/src/models.rs new file mode 100644 index 0000000..338402f --- /dev/null +++ b/rust/loki-rs/src/models.rs @@ -0,0 +1,591 @@ +//! Les routes des modèles tenues par la façade quand `LOKI_MODELS=rust` (lot +//! 8 de la migration ; le Go par défaut) : +//! +//! - `GET /api/models` : les `.gguf` du sélecteur, avec qui les référence +//! (`handleModels`, `backend_models_refs.go`) ; +//! - `POST /api/models/delete` : suppression, garde-fous compris +//! (`handleModelDelete`, `deleteModelFile`) ; +//! - `GET|POST /api/models/dirs` : dossiers de modèles (`handleModelDirs`) ; +//! - les téléchargements ([`crate::downloads`]) ; +//! - le catalogue Hugging Face ([`crate::hf`]). +//! +//! Le Go remet ces routes à la façade quand elles lui arrivent par le tunnel +//! du relais (`facade_models.go`). +//! +//! Écart assumé : le message d'un corps JSON illisible est celui de serde, +//! pas celui d'`encoding/json` (l'interface n'en envoie jamais). + +use std::collections::{HashMap, HashSet}; +use std::path::Path; +use std::sync::Arc; + +use http_body_util::{BodyExt, Limited}; +use hyper::body::Incoming; +use hyper::{Method, Request, Response, StatusCode}; +use serde_json::{Map, Value, json}; + +use crate::api::Ctx; +use crate::auth::json as send_json; +use crate::downloads::{self, Downloads, ModelDirs, disk_free, disk_free_reliable}; +use crate::gguf; +use crate::hf; +use crate::paths::{base_name, go_abs, go_dir, go_join}; +use crate::presets; +use crate::proxy::Body; +use crate::store::BK_STATE; + +/// Ce que la façade tient quand `LOKI_MODELS=rust`. +pub struct Models { + pub downloads: Arc, + pub hf: hf::HfClient, +} + +impl Default for Models { + fn default() -> Self { + Self::new() + } +} + +impl Models { + pub fn new() -> Self { + Models { + downloads: Arc::new(Downloads::new()), + hf: hf::HfClient::new(hf::HF_HOST), + } + } +} + +/// Routes servies ici quand la façade tient les modèles. +pub fn handles(path: &str) -> bool { + matches!( + path, + "/api/models" | "/api/models/delete" | "/api/models/dirs" + ) || downloads::handles(path) + || hf::handles(path) +} + +// --------------------------------------------------------------------------- +// Fonctions pures +// --------------------------------------------------------------------------- + +/// `quantFromName` : l'étiquette de quantification la plus longue d'un nom +/// (segments séparés par `-` et `.`), en majuscules ; "" sinon. +pub fn quant_from_name(name: &str) -> String { + let mut base = name; + if let Some(dot) = base.rfind('.') + && base[dot..].eq_ignore_ascii_case(".gguf") + { + base = &base[..dot]; + } + let mut best = ""; + for seg in base.split(['-', '.']).filter(|s| !s.is_empty()) { + if is_quant_seg(seg) && seg.len() > best.len() { + best = seg; + } + } + best.to_uppercase() +} + +/// `quantSegRe` : `(?i)^(I?Q\d+(_[A-Za-z0-9]+)*|\d+BPW|BF16|FP16|F16|FP32|F32)$`. +fn is_quant_seg(seg: &str) -> bool { + let u = seg.to_ascii_uppercase(); + if matches!(u.as_str(), "BF16" | "FP16" | "F16" | "FP32" | "F32") { + return true; + } + if let Some(d) = u.strip_suffix("BPW") { + return !d.is_empty() && d.bytes().all(|c| c.is_ascii_digit()); + } + let rest = u.strip_prefix("IQ").or_else(|| u.strip_prefix('Q')); + let Some(rest) = rest else { + return false; + }; + let digits = rest.bytes().take_while(u8::is_ascii_digit).count(); + if digits == 0 { + return false; + } + let tail = &rest[digits..]; + if tail.is_empty() { + return true; + } + // (_[A-Za-z0-9]+)* + tail.strip_prefix('_').is_some_and(|t| { + t.split('_') + .all(|p| !p.is_empty() && p.bytes().all(|c| c.is_ascii_alphanumeric())) + }) +} + +/// `splitArgs` : découpe EXTRA_ARGS comme un shell simple (guillemets " et '). +pub fn split_args(s: &str) -> Vec { + let mut out = Vec::new(); + let mut cur = String::new(); + let mut quote: Option = None; + for r in s.chars() { + match quote { + Some(q) => { + if r == q { + quote = None; + // Guillemets vides : argument vide explicite. + if cur.is_empty() { + out.push(String::new()); + } + } else { + cur.push(r); + } + } + None if r == '"' || r == '\'' => quote = Some(r), + None if matches!(r, ' ' | '\t' | '\n' | '\r') => { + if !cur.is_empty() { + out.push(std::mem::take(&mut cur)); + } + } + None => cur.push(r), + } + } + if !cur.is_empty() { + out.push(cur); + } + out +} + +/// `os.Stat` sans erreur et pas un dossier. +fn present(p: &str) -> bool { + std::fs::metadata(p).is_ok_and(|m| !m.is_dir()) +} + +/// `shardFamilySize` : les tranches présentes dans `dir`. +fn family_size(dir: &str, name: &str) -> i64 { + gguf::shard_family(name) + .iter() + .filter_map(|n| std::fs::metadata(go_join(&[dir, n])).ok()) + .filter(|m| !m.is_dir()) + .map(|m| m.len() as i64) + .sum() +} + +/// `shardFamilyMissing` : les tranches absentes de `dir`. +fn family_missing(dir: &str, name: &str) -> Vec { + gguf::shard_family(name) + .into_iter() + .filter(|n| !present(&go_join(&[dir, n]))) + .collect() +} + +// --------------------------------------------------------------------------- +// Qui référence un .gguf (backend_models_refs.go) +// --------------------------------------------------------------------------- + +/// `modelRefIndex` : chemins normalisés → presets qui les nomment, et ceux +/// de la configuration en service. +#[derive(Debug, Default)] +pub struct ModelRefs { + active: HashSet, + presets: HashMap>, +} + +impl ModelDirs { + /// `modelRefKey` : valeur MODEL=/MMPROJ= → clé d'index ("" : pas un .gguf). + fn ref_key(&self, v: &str) -> String { + let v = v.trim(); + if v.is_empty() { + return String::new(); + } + let p = gguf::resolve_serve_model_path(v, &self.home, &self.stored, self.env.as_deref()) + .map(|p| p.to_string_lossy().into_owned()) + // Introuvable : une clé comparable quand même. + .unwrap_or_else(|_| go_join(&[&self.models_dir(), &base_name(v)])); + gguf::norm_dir(&p) + } + + /// `modelKeysOf` : modèle, projecteur, brouillon, `--mmproj` d'EXTRA_ARGS. + fn keys_of(&self, env: &HashMap) -> Vec { + let get = |k: &str| env.get(k).cloned().unwrap_or_default(); + let mut vals = vec![get("MODEL"), get("MMPROJ"), get("MODEL_DRAFT")]; + let args = split_args(&get("EXTRA_ARGS")); + for (i, a) in args.iter().enumerate() { + if a == "--mmproj" && i + 1 < args.len() { + vals.push(args[i + 1].clone()); + } + } + vals.iter() + .map(|v| self.ref_key(v)) + .filter(|k| !k.is_empty()) + .collect() + } +} + +impl ModelRefs { + /// `buildModelRefIndex` : la configuration active et les presets + /// (`(nom d'affichage, contenu)`, dans l'ordre de la liste). + pub fn build( + dirs: &ModelDirs, + cfg: &HashMap, + presets: &[(String, String)], + ) -> Self { + let mut ix = ModelRefs::default(); + ix.active.extend(dirs.keys_of(cfg)); + for (name, content) in presets { + let mut seen = HashSet::new(); + for k in dirs.keys_of(&presets::parse_env(content)) { + if seen.insert(k.clone()) { + ix.presets.entry(k).or_default().push(name.clone()); + } + } + } + ix + } + + /// `lookup` : presets qui référencent `path`, et s'il est en service. + pub fn lookup(&self, path: &str) -> (Vec, bool) { + let k = gguf::norm_dir(path); + ( + self.presets.get(&k).cloned().unwrap_or_default(), + self.active.contains(&k), + ) + } + + /// `modelUsers` : pour UN modèle (nom de fichier ou chemin). + fn users(&self, dirs: &ModelDirs, name: &str) -> (Vec, bool) { + let k = dirs.ref_key(name); + if k.is_empty() { + return (Vec::new(), false); + } + self.lookup(&k) + } +} + +const MODEL_IN_SERVICE: &str = + "modèle en service — bascule sur un autre preset avant de le supprimer"; + +/// `handleModels` : les `.gguf` des dossiers de modèles (tranches 2..N +/// exclues), avec leurs références. +pub fn list_models(dirs: &ModelDirs, refs: &ModelRefs) -> Value { + let home = dirs.models_dir(); + let mut out = Vec::new(); + let mut seen = HashSet::new(); + for dir in dirs.all() { + let Ok(rd) = std::fs::read_dir(&dir) else { + continue; + }; + // os.ReadDir : trié par nom. + let mut entries: Vec<_> = rd.filter_map(Result::ok).collect(); + entries.sort_by_key(|e| e.file_name()); + let is_home = gguf::norm_dir(&dir) == gguf::norm_dir(&home); + for e in entries { + let name = e.file_name().to_string_lossy().into_owned(); + let is_dir = e.file_type().map(|t| t.is_dir()).unwrap_or(false); + if is_dir || !name.to_lowercase().ends_with(".gguf") || gguf::is_follower_shard(&name) { + continue; + } + let full = go_join(&[&dir, &name]); + if !seen.insert(gguf::norm_dir(&full)) { + continue; + } + let size = e.metadata().map(|m| m.len() as i64).unwrap_or(0); + let value = if is_home { name.clone() } else { full.clone() }; + let mut m = Map::new(); + m.insert("name".into(), json!(name)); + m.insert("size".into(), json!(size)); + m.insert("value".into(), json!(value)); + m.insert("path".into(), json!(full)); + m.insert("dir".into(), json!(dir)); + m.insert("home".into(), json!(is_home)); + let (users, active) = refs.lookup(&full); + if !users.is_empty() || active { + if !users.is_empty() { + m.insert("used".into(), json!(users)); + } + m.insert("active".into(), json!(active)); + } + if let Some((_, total)) = gguf::shard_info(&name) { + m.insert("shards".into(), json!(total)); + m.insert("size".into(), json!(family_size(&dir, &name))); + let missing = family_missing(&dir, &name); + if !missing.is_empty() { + m.insert("missing".into(), json!(missing)); + } + } + out.push(Value::Object(m)); + } + } + Value::Array(out) +} + +/// `modelDirCount` : les `.gguf` lisibles d'un dossier (-1 : illisible). +pub fn dir_count(dir: &str) -> i64 { + let Ok(rd) = std::fs::read_dir(dir) else { + return -1; + }; + rd.filter_map(Result::ok) + .filter(|e| { + let n = e.file_name().to_string_lossy().into_owned(); + !e.file_type().map(|t| t.is_dir()).unwrap_or(false) + && n.to_lowercase().ends_with(".gguf") + && !gguf::is_follower_shard(&n) + }) + .count() as i64 +} + +/// `GET /api/models/dirs`. +pub fn dirs_json(dirs: &ModelDirs) -> Value { + let home = dirs.models_dir(); + let mut out = vec![json!({ + "path": home, "home": true, "count": dir_count(&home), "exists": true, + "free": disk_free(&home), "free_exact": disk_free_reliable(&home), + })]; + for d in gguf::extra_model_dirs(&dirs.home, &dirs.stored, dirs.env.as_deref()) { + let n = dir_count(&d); + out.push(json!({ + "path": d, "home": false, "count": n, "exists": n >= 0, + "free": disk_free(&d), "free_exact": disk_free_reliable(&d), + })); + } + json!({ "ok": true, "dirs": out, "env": dirs.env.as_deref().is_some_and(|v| !v.is_empty()) }) +} + +/// `POST /api/models/dirs` : la liste à enregistrer, ou le refus. +pub fn dirs_update(dirs: &ModelDirs, path: &str, action: &str) -> Result, String> { + let p = path.trim(); + if p.is_empty() { + return Err("chemin vide".into()); + } + let mut cur = gguf::extra_model_dirs(&dirs.home, &dirs.stored, dirs.env.as_deref()); + if action == "remove" { + cur.retain(|d| gguf::norm_dir(d) != gguf::norm_dir(p)); + } else { + let abs = go_abs(p).ok_or_else(|| "chemin invalide".to_string())?; + if !std::fs::metadata(&abs).is_ok_and(|m| m.is_dir()) { + return Err(format!("dossier introuvable : {abs}")); + } + cur.push(abs); + } + // saveExtraModelDirs : nettoyée, dédoublonnée, models/ exclu. + Ok(gguf::dedup_dirs(&cur, &dirs.models_dir())) +} + +/// `handleModelDelete` : refus si en service, 409 avec la liste des presets +/// sans `force`, sinon le modèle part avec toutes ses tranches. +pub fn delete_model( + dirs: &ModelDirs, + refs: &ModelRefs, + name: &str, + force: bool, +) -> (StatusCode, Value) { + let (users, active) = refs.users(dirs, name); + if active { + return ( + StatusCode::CONFLICT, + json!({ "ok": false, "error": MODEL_IN_SERVICE, "active": true }), + ); + } + if !users.is_empty() && !force { + return ( + StatusCode::CONFLICT, + json!({ "ok": false, "used": users, "error": format!("modèle utilisé par : {}", users.join(", ")) }), + ); + } + match delete_model_file(dirs, refs, name) { + Ok(freed) => { + let used = if users.is_empty() { + Value::Null + } else { + json!(users) + }; + ( + StatusCode::OK, + json!({ "ok": true, "freed": freed, "used": used }), + ) + } + Err(e) => (StatusCode::BAD_REQUEST, json!({ "ok": false, "error": e })), + } +} + +/// `deleteModelFile` : rend les octets libérés. +fn delete_model_file(dirs: &ModelDirs, refs: &ModelRefs, name: &str) -> Result { + let p = gguf::resolve_model_path(name, &dirs.home, &dirs.stored, dirs.env.as_deref())? + .to_string_lossy() + .into_owned(); + if refs.users(dirs, name).1 { + return Err(MODEL_IN_SERVICE.into()); + } + let dir = go_dir(&p); + let base = base_name(&p); + let freed = family_size(&dir, &base); + if let Err(e) = std::fs::remove_file(&p) { + if e.kind() == std::io::ErrorKind::NotFound { + return Err(format!("modèle introuvable: {base}")); + } + return Err(crate::paths::os_err("remove", &p, &e)); + } + for n in gguf::shard_family(&base) { + let _ = std::fs::remove_file(Path::new(&dir).join(n)); + } + Ok(freed) +} + +// --------------------------------------------------------------------------- +// Routes +// --------------------------------------------------------------------------- + +/// Les dossiers de modèles du moment, lus en base. +pub async fn model_dirs(ctx: &Ctx) -> ModelDirs { + let stored = gguf::parse_stored_model_dirs( + ctx.store + .get(BK_STATE, gguf::STATE_MODEL_DIRS_KEY) + .await + .ok() + .flatten() + .as_deref(), + ); + ModelDirs { + home: ctx.engine.home.clone(), + stored, + env: std::env::var(gguf::MODEL_DIRS_ENV).ok(), + } +} + +/// La configuration en service et les presets (nom, contenu), dans l'ordre +/// de la liste. +async fn refs_inputs(ctx: &Ctx) -> (HashMap, Vec) { + let cfg = ctx.store.config().await; + let order = presets::parse_preset_order( + ctx.store + .get(BK_STATE, presets::PRESET_ORDER_KEY) + .await + .ok() + .flatten() + .as_deref(), + ); + (cfg, order) +} + +/// L'index des références : configuration et presets lus sur le disque. +pub fn build_refs(dirs: &ModelDirs, cfg: &HashMap, order: &[String]) -> ModelRefs { + let list = presets::list_presets(&dirs.home, cfg, order).unwrap_or_default(); + let presets: Vec<(String, String)> = list + .into_iter() + .filter_map(|p| { + std::fs::read(&p.path) + .ok() + .map(|b| (p.name, String::from_utf8_lossy(&b).into_owned())) + }) + .collect(); + ModelRefs::build(dirs, cfg, &presets) +} + +async fn blocking(f: impl FnOnce() -> T + Send + 'static) -> T { + tokio::task::spawn_blocking(f) + .await + .expect("tâche bloquante interrompue") +} + +pub(crate) async fn body_json(body: Incoming) -> Result { + let b = Limited::new(body, 1 << 20) + .collect() + .await + .map_err(|e| e.to_string())? + .to_bytes(); + if b.iter().all(u8::is_ascii_whitespace) { + return Err("EOF".into()); + } + serde_json::from_slice(&b).map_err(|e| e.to_string()) +} + +fn bad(e: impl Into) -> Response { + send_json( + StatusCode::BAD_REQUEST, + &json!({ "ok": false, "error": e.into() }), + ) +} + +/// `from_go` : remise par le Go (tunnel), déjà authentifiée par lui. +pub async fn serve( + ctx: &Ctx, + models: &Arc, + req: Request, + from_go: bool, +) -> Response { + let path = req.uri().path().to_string(); + if downloads::handles(&path) { + return downloads::serve(ctx, &models.downloads, req, from_go).await; + } + if hf::handles(&path) { + return hf::serve(ctx, &models.hf, req, from_go).await; + } + let (parts, body) = req.into_parts(); + if !from_go && let Err(refus) = ctx.guard(&parts.headers).await { + return *refus; + } + let dirs = model_dirs(ctx).await; + match path.as_str() { + "/api/models" => { + let (cfg, order) = refs_inputs(ctx).await; + let out = blocking(move || { + let refs = build_refs(&dirs, &cfg, &order); + list_models(&dirs, &refs) + }) + .await; + send_json(StatusCode::OK, &out) + } + "/api/models/delete" => { + let v = match body_json(body).await { + Ok(v) => v, + Err(e) => return bad(e), + }; + let name = v + .get("name") + .and_then(Value::as_str) + .unwrap_or("") + .to_string(); + let force = v.get("force").and_then(Value::as_bool).unwrap_or(false); + let (cfg, order) = refs_inputs(ctx).await; + let (code, out) = blocking(move || { + let refs = build_refs(&dirs, &cfg, &order); + delete_model(&dirs, &refs, &name, force) + }) + .await; + send_json(code, &out) + } + "/api/models/dirs" if parts.method == Method::POST => { + let v = match body_json(body).await { + Ok(v) => v, + Err(e) => return bad(e), + }; + let p = v + .get("path") + .and_then(Value::as_str) + .unwrap_or("") + .to_string(); + let action = v + .get("action") + .and_then(Value::as_str) + .unwrap_or("") + .to_string(); + let res = blocking(move || dirs_update(&dirs, &p, &action)).await; + match res { + Ok(list) => { + let raw = serde_json::to_vec(&list).unwrap_or_default(); + match ctx + .store + .put(BK_STATE, gguf::STATE_MODEL_DIRS_KEY, raw) + .await + { + Ok(()) => send_json(StatusCode::OK, &json!({ "ok": true })), + Err(e) => send_json( + StatusCode::INTERNAL_SERVER_ERROR, + &json!({ "ok": false, "error": e }), + ), + } + } + Err(e) => bad(e), + } + } + "/api/models/dirs" => { + let out = blocking(move || dirs_json(&dirs)).await; + send_json(StatusCode::OK, &out) + } + _ => send_json( + StatusCode::NOT_FOUND, + &json!({ "error": "404 page not found" }), + ), + } +} diff --git a/rust/loki-rs/src/proxy.rs b/rust/loki-rs/src/proxy.rs index 930b561..71c1076 100644 --- a/rust/loki-rs/src/proxy.rs +++ b/rust/loki-rs/src/proxy.rs @@ -63,9 +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>, - /// 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>, + /// Routes des modèles tenues par la façade (`LOKI_MODELS=rust`) : servies + /// ici ; `None` : relayées au Go. + pub models: Option>, } struct Proxy { @@ -93,7 +93,7 @@ pub async fn serve( host_env: cfg.host_env, engine: cfg.engine, chat: cfg.chat, - downloads: cfg.downloads, + models: cfg.models, internal_token: cfg.internal_token.clone(), }, }); @@ -133,9 +133,9 @@ pub async fn serve( impl Proxy { async fn handle(&self, mut req: Request) -> Response { // Le Go (et lui seul : jeton tiré à chaque démarrage) remet à la - // 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()) + // façade le trafic du chat et des modèles qui lui arrive sans passer + // par elle. + let from_go = (self.ctx.chat.is_some() || self.ctx.models.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 ». @@ -169,10 +169,10 @@ impl Proxy { { return chatapi::serve(&self.ctx, chat, req, from_go).await; } - if let Some(dl) = &self.ctx.downloads - && crate::downloads::handles(path) + if let Some(m) = &self.ctx.models + && crate::models::handles(path) { - return crate::downloads::serve(&self.ctx, dl, req, from_go).await; + return crate::models::serve(&self.ctx, m, req, from_go).await; } self.forward(req).await } diff --git a/rust/loki-rs/tests/common/fakego.rs b/rust/loki-rs/tests/common/fakego.rs index 4ef5359..222bec9 100644 --- a/rust/loki-rs/tests/common/fakego.rs +++ b/rust/loki-rs/tests/common/fakego.rs @@ -427,16 +427,17 @@ async fn handle(st: Arc, req: Request) -> Response /// 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, oneshot::Sender<()>) { - let (f, st, tx, _) = start_opts(home, false, None, false).await; + let (f, st, tx, _) = start_opts(home, false, None, None).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( +/// Comme [`start`], avec les routes des modèles tenues par la façade +/// (`LOKI_MODELS=rust`) ; `hf` : adresse du faux Hugging Face. +pub async fn start_models( home: std::path::PathBuf, + hf: &str, ) -> (SocketAddr, Arc, oneshot::Sender<()>) { - let (f, st, tx, _) = start_opts(home, false, None, true).await; + let (f, st, tx, _) = start_opts(home, false, None, Some(hf.to_string())).await; (f, st, tx) } @@ -451,7 +452,7 @@ pub async fn start_chat( oneshot::Sender<()>, Arc, ) { - let (f, st, tx, chat) = start_opts(home, true, Some(engine_port), false).await; + let (f, st, tx, chat) = start_opts(home, true, Some(engine_port), None).await; (f, st, tx, chat.unwrap()) } @@ -459,7 +460,7 @@ async fn start_opts( home: std::path::PathBuf, with_chat: bool, engine_port: Option, - with_downloads: bool, + models_hf: Option, ) -> ( SocketAddr, Arc, @@ -516,7 +517,12 @@ async fn start_opts( service: "loki-engine".into(), }, chat: chat.clone(), - downloads: with_downloads.then(|| Arc::new(loki_rs::downloads::Downloads::new())), + models: models_hf.map(|hf| { + Arc::new(loki_rs::models::Models { + downloads: Arc::new(loki_rs::downloads::Downloads::new()), + hf: loki_rs::hf::HfClient::new(&hf), + }) + }), }; tokio::spawn(loki_rs::proxy::serve(fl, cfg, async move { let _ = rx.await; diff --git a/rust/loki-rs/tests/downloads.rs b/rust/loki-rs/tests/downloads.rs index 17e4aad..bf1f7cd 100644 --- a/rust/loki-rs/tests/downloads.rs +++ b/rust/loki-rs/tests/downloads.rs @@ -1,4 +1,4 @@ -//! Lot « modèles » : téléchargements tenus par la façade (`LOKI_DOWNLOADS=rust`). +//! Lot 8 : téléchargements de modèles tenus par la façade (`LOKI_MODELS=rust`). //! //! - les fonctions pures rejouent le journal tiré du Go //! (`internal/loki/parity_dl_test.go` → `tests/parity_models/download.json`) ; @@ -572,7 +572,7 @@ async fn routes_servies_par_la_facade() { ); let tmp = tempfile::tempdir().unwrap(); let home = tmp.path().canonicalize().unwrap(); - let (facade, go, _stop) = fakego::start_downloads(home.clone()).await; + let (facade, go, _stop) = fakego::start_models(home.clone(), "http://127.0.0.1:1").await; let url = format!("http://{addr}/u/r/resolve/main/m.gguf"); let body = format!(r#"{{"url":"{url}","dir":""}}"#); diff --git a/rust/loki-rs/tests/lot2.rs b/rust/loki-rs/tests/lot2.rs index 0453b79..349c12c 100644 --- a/rust/loki-rs/tests/lot2.rs +++ b/rust/loki-rs/tests/lot2.rs @@ -94,7 +94,7 @@ async fn banc(cle: Option<&str>) -> Banc { service: "loki-engine".into(), }, chat: None, - downloads: None, + models: None, }; tokio::spawn(loki_rs::proxy::serve(l, cfg, async move { let _ = rx.await; diff --git a/rust/loki-rs/tests/models.rs b/rust/loki-rs/tests/models.rs new file mode 100644 index 0000000..3f61182 --- /dev/null +++ b/rust/loki-rs/tests/models.rs @@ -0,0 +1,557 @@ +//! Lot 8 : routes des modèles tenues par la façade (`LOKI_MODELS=rust`). +//! +//! - le journal tiré du Go (`internal/loki/parity_models_test.go` → +//! `tests/parity_models/models.json`) : fonctions pures du catalogue, puis +//! un scénario sur disque (liste, dossiers, suppressions) rejoué à +//! l'identique ; +//! - le catalogue Hugging Face contre un faux serveur (recherche, dépôt, +//! jeton, cache) ; +//! - les routes par la façade. + +mod common; + +use std::collections::HashMap; +use std::convert::Infallible; +use std::net::SocketAddr; +use std::path::Path; +use std::sync::{Arc, Mutex}; + +use bytes::Bytes; +use common::fakego; +use http_body_util::Full; +use hyper::body::Incoming; +use hyper::{Request, Response, StatusCode, service::service_fn}; +use hyper_util::rt::TokioIo; +use loki_rs::downloads::ModelDirs; +use loki_rs::hf::{self, Hardware, HfEntry, HfEnv, HfListing}; +use loki_rs::models; +use serde_json::Value; + +fn fixture() -> Value { + serde_json::from_str(include_str!("parity_models/models.json")).expect("journal") +} + +fn mask_free(v: &mut Value) { + match v { + Value::Object(m) => { + for (k, e) in m.iter_mut() { + if k == "free" || k == "free_exact" { + *e = Value::String("…".into()); + } else { + mask_free(e); + } + } + } + Value::Array(a) => a.iter_mut().for_each(mask_free), + _ => {} + } +} + +#[test] +fn parite_des_fonctions_pures() { + let fx = fixture(); + for (n, want) in fx["quant"].as_object().unwrap() { + assert_eq!( + models::quant_from_name(n), + want.as_str().unwrap(), + "quant({n})" + ); + } + for c in fx["split_args"].as_array().unwrap() { + let want: Vec = serde_json::from_value(c["out"].clone()).unwrap(); + assert_eq!(models::split_args(c["in"].as_str().unwrap()), want, "{c}"); + } + for (t, want) in fx["mask"].as_object().unwrap() { + assert_eq!(hf::mask_hf_token(t), want.as_str().unwrap(), "mask({t:?})"); + } + for c in fx["gated"].as_array().unwrap() { + assert_eq!(hf::gated_flag(&c["in"]), c["out"].as_bool().unwrap(), "{c}"); + } + let cl = &fx["classify"]; + let files: Vec<(String, i64)> = cl["files"] + .as_array() + .unwrap() + .iter() + .map(|f| { + ( + f["path"].as_str().unwrap().to_string(), + f["size"].as_i64().unwrap(), + ) + }) + .collect(); + let got = hf::classify(hf::HF_HOST, cl["repo"].as_str().unwrap(), &files); + let want: HfListing = serde_json::from_value(cl["out"].clone()).unwrap(); + assert_eq!(got, want); + // Sérialisé comme le Go (listes vides → null). + assert_eq!(serde_json::to_value(&got).unwrap(), cl["out"]); + for c in fx["projector"].as_array().unwrap() { + let list: Vec = serde_json::from_value(c["in"].clone()).unwrap_or_default(); + let got = hf::pick_projector(&list) + .map(|e| e.name.clone()) + .unwrap_or_default(); + assert_eq!(got, c["name"].as_str().unwrap(), "{c}"); + } + for c in fx["verdicts"].as_array().unwrap() { + let hw: Hardware = serde_json::from_value(c["hardware"].clone()).unwrap(); + // Sans GPU : null, comme une tranche nil du Go (24.0 contre 24 : même + // nombre en JSON). + let gpus = &serde_json::to_value(&hw).unwrap()["gpus"]; + assert_eq!(gpus.is_null(), c["hardware"]["gpus"].is_null()); + let (v, why) = hf::fit_verdict( + &hw, + c["weights"].as_i64().unwrap(), + c["mmproj"].as_i64().unwrap(), + c["ctx"].as_i64().unwrap(), + ); + assert_eq!( + (v.as_str(), why.as_str()), + (c["verdict"].as_str().unwrap(), c["why"].as_str().unwrap()) + ); + } +} + +fn tree_files(home: &Path) -> Vec { + let mut out = Vec::new(); + for top in ["disque", "models"] { + let mut stack = vec![home.join(top)]; + while let Some(d) = stack.pop() { + let Ok(rd) = std::fs::read_dir(&d) else { + continue; + }; + for e in rd.filter_map(Result::ok) { + let p = e.path(); + if p.is_dir() { + stack.push(p); + } else { + out.push(p.strip_prefix(home).unwrap().to_string_lossy().into_owned()); + } + } + } + } + out.sort(); + out +} + +/// Monte l'arborescence du scénario sous `home`. +fn setup_tree(sc: &Value, home: &Path) -> (Vec, HashMap) { + let h = home.to_string_lossy().into_owned(); + let unhome = |s: &str| s.replace("{{HOME}}", &h); + for (rel, size) in sc["files"].as_object().unwrap() { + let p = home.join(rel); + let size = size.as_i64().unwrap(); + if size < 0 { + std::fs::create_dir_all(&p).unwrap(); + continue; + } + std::fs::create_dir_all(p.parent().unwrap()).unwrap(); + std::fs::write(&p, vec![0u8; size as usize]).unwrap(); + } + std::fs::create_dir_all(home.join("presets")).unwrap(); + for (id, content) in sc["presets"].as_object().unwrap() { + std::fs::write( + home.join("presets").join(format!("{id}.env")), + unhome(content.as_str().unwrap()), + ) + .unwrap(); + } + let extra = sc["extra"] + .as_array() + .unwrap() + .iter() + .map(|d| unhome(d.as_str().unwrap())) + .collect::>(); + let cfg = sc["config"] + .as_object() + .unwrap() + .iter() + .map(|(k, v)| (k.clone(), unhome(v.as_str().unwrap()))) + .collect(); + (extra, cfg) +} + +#[test] +fn parite_du_scenario_sur_disque() { + let fx = fixture(); + let sc = &fx["scenario"]; + let tmp = tempfile::tempdir().unwrap(); + let home = tmp.path().canonicalize().unwrap(); + let h = home.to_string_lossy().into_owned(); + let (extra, cfg) = setup_tree(sc, &home); + let mut dirs = ModelDirs { + home: home.clone(), + // saveExtraModelDirs : nettoyée, dédoublonnée. + stored: loki_rs::gguf::dedup_dirs(&extra, &format!("{h}/models")), + env: None, + }; + let norm = |v: Value| -> Value { + let s = serde_json::to_string(&v).unwrap().replace(&h, "{{HOME}}"); + let mut v: Value = serde_json::from_str(&s).unwrap(); + mask_free(&mut v); + v + }; + for (i, c) in sc["calls"].as_array().unwrap().iter().enumerate() { + let (method, path) = (c["method"].as_str().unwrap(), c["path"].as_str().unwrap()); + let body: Value = c + .get("body") + .and_then(Value::as_str) + .map(|b| serde_json::from_str(&b.replace("{{HOME}}", &h)).unwrap()) + .unwrap_or(Value::Null); + let s = |k: &str| { + body.get(k) + .and_then(Value::as_str) + .unwrap_or("") + .to_string() + }; + let (status, out) = match (method, path) { + ("GET", "/api/models") => { + let refs = models::build_refs(&dirs, &cfg, &[]); + (200, models::list_models(&dirs, &refs)) + } + ("GET", "/api/models/dirs") => (200, models::dirs_json(&dirs)), + ("POST", "/api/models/delete") => { + let refs = models::build_refs(&dirs, &cfg, &[]); + let force = body.get("force").and_then(Value::as_bool).unwrap_or(false); + let (code, out) = models::delete_model(&dirs, &refs, &s("name"), force); + (code.as_u16(), out) + } + ("POST", "/api/models/dirs") => { + match models::dirs_update(&dirs, &s("path"), &s("action")) { + Ok(list) => { + dirs.stored = list; + (200, serde_json::json!({ "ok": true })) + } + Err(e) => (400, serde_json::json!({ "ok": false, "error": e })), + } + } + _ => panic!("appel inattendu {c}"), + }; + let ctx = format!( + "appel {i} : {method} {path} {}", + c.get("body").unwrap_or(&Value::Null) + ); + assert_eq!(status as u64, c["status"].as_u64().unwrap(), "{ctx}"); + assert_eq!(norm(out), c["out"], "{ctx}"); + if let Some(want) = c.get("files") { + let want: Vec = serde_json::from_value(want.clone()).unwrap(); + assert_eq!(tree_files(&home), want, "{ctx}"); + } + if let Some(want) = c.get("stored") { + let want: Vec = serde_json::from_value(want.clone()).unwrap(); + let got: Vec = dirs + .stored + .iter() + .map(|d| d.replace(&h, "{{HOME}}")) + .collect(); + assert_eq!(got, want, "{ctx}"); + } + } +} + +#[test] +fn adresses_et_echappements_comme_le_go() { + assert_eq!( + hf::search_endpoint("https://huggingface.co", " qwen 3 & co "), + "https://huggingface.co/api/models?direction=-1&expand%5B%5D=gated&expand%5B%5D=downloads&expand%5B%5D=likes&filter=gguf&limit=25&search=qwen+3+%26+co&sort=downloads" + ); + // Tronquée à 100 octets, même au milieu d'un caractère (q[:100] du Go). + let long = format!("{}é", "a".repeat(99)); + assert!(hf::search_endpoint("h", &long).contains(&format!("search={}%C3&", "a".repeat(99)))); + assert_eq!( + hf::go_path_escape("a b+c&(d)/é"), + "a%20b+c&%28d%29%2F%C3%A9" + ); + assert!( + hf::repo_ok("a/b.c-d_e") + && !hf::repo_ok("../x") + && !hf::repo_ok("a/b/c") + && !hf::repo_ok("-a/b") + ); +} + +// --------------------------------------------------------------------------- +// Faux Hugging Face +// --------------------------------------------------------------------------- + +#[derive(Default)] +struct FakeHf { + log: Mutex>, + auth: Mutex>, + gated: Mutex, +} + +async fn hf_handle(st: Arc, req: Request) -> Response> { + let pq = req + .uri() + .path_and_query() + .map(|p| p.as_str().to_string()) + .unwrap_or_default(); + st.log.lock().unwrap().push(pq.clone()); + let auth = req + .headers() + .get("authorization") + .map(|v| v.to_str().unwrap().to_string()) + .unwrap_or_default(); + st.auth.lock().unwrap().push(auth.clone()); + let path = req.uri().path(); + let (code, body) = if path == "/api/whoami-v2" { + if auth == "Bearer hf_bon_jeton" { + (200, r#"{"name":"moi","type":"user"}"#.to_string()) + } else { + (401, r#"{"error":"Invalid credentials"}"#.to_string()) + } + } else if path == "/api/models" { + (200, r#"[{"id":"u/a-GGUF","downloads":10,"likes":2,"gated":false},{"id":"u/b-GGUF","downloads":5,"likes":1,"gated":"manual"}]"#.to_string()) + } else if path == "/api/models/u/a-GGUF/tree/main" { + (200, r#"[{"type":"directory","path":"Q4"},{"type":"file","path":"Q4/a-Q4_K_M.gguf","size":134,"lfs":{"size":5368709120}},{"type":"file","path":"mmproj-Q8_0.gguf","size":629145600},{"type":"file","path":"README.md","size":10}]"#.to_string()) + } else if path == "/api/models/u/a-GGUF" { + ( + 200, + format!( + r#"{{"id":"u/a-GGUF","gated":{}}}"#, + st.gated.lock().unwrap() + ), + ) + } else if path == "/api/models/u/verrou/tree/main" { + (401, "{}".to_string()) + } else { + (404, "{}".to_string()) + }; + let mut r = Response::new(Full::new(Bytes::from(body))); + *r.status_mut() = StatusCode::from_u16(code).unwrap(); + r +} + +async fn fake_hf() -> (SocketAddr, Arc) { + let st = Arc::new(FakeHf::default()); + *st.gated.lock().unwrap() = Value::String("auto".into()); + let l = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap(); + let addr = l.local_addr().unwrap(); + let s2 = st.clone(); + tokio::spawn(async move { + while let Ok((s, _)) = l.accept().await { + let st = s2.clone(); + tokio::spawn(async move { + let svc = service_fn(move |req| { + let st = st.clone(); + async move { Ok::<_, Infallible>(hf_handle(st, req).await) } + }); + let _ = hyper::server::conn::http1::Builder::new() + .serve_connection(TokioIo::new(s), svc) + .await; + }); + } + }); + (addr, st) +} + +#[tokio::test] +async fn catalogue_contre_un_faux_hugging_face() { + let (addr, st) = fake_hf().await; + let base = format!("http://{addr}"); + let c = hf::HfClient::new(&base); + let env = HfEnv { + token: "hf_x".into(), + version: "1.2.3".into(), + }; + let repos = c.search(&env, "qwen").await.unwrap(); + assert_eq!(repos[1]["gated"], true); + assert_eq!(repos[0]["id"], "u/a-GGUF"); + // Deuxième recherche identique : servie par le cache. + c.search(&env, "qwen").await.unwrap(); + assert_eq!(st.log.lock().unwrap().len(), 1); + + let l = c.files(&env, " /u/a-GGUF/ ").await.unwrap(); + assert!(l.gated); + assert_eq!(l.models.len(), 1); + assert_eq!(l.models[0].size, 5368709120, "taille LFS prise"); + assert_eq!( + l.models[0].url, + format!("{base}/u/a-GGUF/resolve/main/Q4/a-Q4_K_M.gguf") + ); + assert_eq!(l.projectors[0].quant, "Q8_0"); + assert!(l.drafts.is_empty()); + assert!(st.auth.lock().unwrap().iter().all(|a| a == "Bearer hf_x")); + + assert_eq!( + c.files(&env, "u/verrou").await.unwrap_err(), + "dépôt à accès restreint — renseigne HF_TOKEN pour y accéder" + ); + assert_eq!( + c.files(&env, "u/absent").await.unwrap_err(), + "dépôt introuvable sur Hugging Face" + ); + assert_eq!( + c.files(&env, "../x").await.unwrap_err(), + "nom de dépôt invalide (attendu « auteur/dépôt »)" + ); + assert_eq!(c.whoami("1", "hf_bon_jeton").await.unwrap(), "moi"); + assert_eq!( + c.whoami("1", "mauvais").await.unwrap_err(), + "jeton refusé par Hugging Face (expiré, révoqué ou mal copié)" + ); + // Vider le cache : la recherche repart vers Hugging Face. + let n = st.log.lock().unwrap().len(); + c.clear_cache(); + c.search(&env, "qwen").await.unwrap(); + assert_eq!(st.log.lock().unwrap().len(), n + 1); +} + +#[tokio::test] +async fn routes_des_modeles_par_la_facade() { + let (addr, st) = fake_hf().await; + let tmp = tempfile::tempdir().unwrap(); + let home = tmp.path().canonicalize().unwrap(); + std::fs::create_dir_all(home.join("models")).unwrap(); + std::fs::write(home.join("models/a.gguf"), b"0123").unwrap(); + std::fs::create_dir_all(home.join("presets")).unwrap(); + std::fs::write(home.join("presets/p.env"), "NAME=P\nMODEL=a.gguf\n").unwrap(); + let ext = home.join("ext"); + std::fs::create_dir_all(&ext).unwrap(); + let (facade, go, _stop) = fakego::start_models(home.clone(), &format!("http://{addr}")).await; + + let (code, _, b) = fakego::get(facade, "/api/models", &[]).await; + assert_eq!(code, StatusCode::OK); + let v: Value = serde_json::from_slice(&b).unwrap(); + assert_eq!(v[0]["name"], "a.gguf"); + assert_eq!(v[0]["used"], serde_json::json!(["P"])); + + // Dossier ajouté : rangé dans la base du Go. + let body = format!(r#"{{"path":"{}"}}"#, ext.display()); + let (code, _) = fakego::send(facade, "POST", "/api/models/dirs", &[], &body).await; + assert_eq!(code, StatusCode::OK); + let stored: Vec = + serde_json::from_str(&go.get("state", "model_dirs").unwrap()).unwrap(); + assert_eq!(stored, [ext.to_string_lossy().into_owned()]); + let (_, _, b) = fakego::get(facade, "/api/models/dirs", &[]).await; + let v: Value = serde_json::from_slice(&b).unwrap(); + assert_eq!(v["dirs"][1]["path"], ext.to_string_lossy().as_ref()); + assert_eq!(v["dirs"][1]["count"], 0); + + // Utilisé par un preset : 409, puis force. + let (code, b) = fakego::send( + facade, + "POST", + "/api/models/delete", + &[], + r#"{"name":"a.gguf"}"#, + ) + .await; + assert_eq!(code, StatusCode::CONFLICT, "{b:?}"); + let (code, b) = fakego::send( + facade, + "POST", + "/api/models/delete", + &[], + r#"{"name":"a.gguf","force":true}"#, + ) + .await; + assert_eq!(code, StatusCode::OK); + assert_eq!(serde_json::from_slice::(&b).unwrap()["freed"], 4); + assert!(!home.join("models/a.gguf").exists()); + + // Catalogue : matériel joint, jeton absent. + let (code, _, b) = fakego::get(facade, "/api/hf/search?q=qwen+3", &[]).await; + assert_eq!(code, StatusCode::OK); + let v: Value = serde_json::from_slice(&b).unwrap(); + assert_eq!(v["hf_token"], false); + assert!(v["hardware"]["os"].is_string()); + assert!(st.log.lock().unwrap()[0].contains("search=qwen+3&")); + let (code, _, b) = fakego::get(facade, "/api/hf/search?q=%20", &[]).await; + assert_eq!(code, StatusCode::BAD_REQUEST); + assert_eq!( + serde_json::from_slice::(&b).unwrap()["error"], + "précise ce que tu cherches" + ); + go.set("config", "CTX", "65536"); + let (code, _, b) = fakego::get(facade, "/api/hf/files?repo=u/a-GGUF", &[]).await; + assert_eq!(code, StatusCode::OK); + let v: Value = serde_json::from_slice(&b).unwrap(); + assert_eq!( + ( + v["ctx"].as_i64(), + v["vision"].as_bool(), + v["gated"].as_bool() + ), + (Some(65536), Some(true), Some(true)) + ); + assert_eq!(v["drafts"], Value::Null); + assert!(v["models"][0].get("verdict").is_none() || v["models"][0]["verdict"].is_string()); + let (code, _, _) = fakego::get(facade, "/api/hf/files?repo=u/absent", &[]).await; + assert_eq!(code, StatusCode::BAD_GATEWAY); + + // Jeton : refusé, accepté (vérifié, enregistré, masqué), effacé. + let (code, b) = fakego::send( + facade, + "POST", + "/api/hf/token", + &[], + r#"{"token":"mauvais"}"#, + ) + .await; + assert_eq!(code, StatusCode::BAD_REQUEST, "{b:?}"); + let (code, b) = fakego::send( + facade, + "POST", + "/api/hf/token", + &[], + r#"{"token":" hf_bon_jeton "}"#, + ) + .await; + assert_eq!(code, StatusCode::OK); + let v: Value = serde_json::from_slice(&b).unwrap(); + assert_eq!( + ( + v["set"].as_bool(), + v["source"].as_str(), + v["masked"].as_str(), + v["account"].as_str() + ), + (Some(true), Some("config"), Some("hf_…eton"), Some("moi")) + ); + assert_eq!(go.get("state", "hf_token").as_deref(), Some("hf_bon_jeton")); + let (_, _, b) = fakego::get(facade, "/api/hf/search?q=autre", &[]).await; + assert_eq!( + serde_json::from_slice::(&b).unwrap()["hf_token"], + true + ); + assert_eq!( + st.auth.lock().unwrap().last().unwrap(), + "Bearer hf_bon_jeton" + ); + let (code, b) = fakego::send(facade, "POST", "/api/hf/token", &[], r#"{"token":""}"#).await; + assert_eq!(code, StatusCode::OK); + assert_eq!(serde_json::from_slice::(&b).unwrap()["set"], false); + assert_eq!(go.get("state", "hf_token"), None); + let (code, b) = fakego::send(facade, "POST", "/api/hf/token", &[], "x").await; + assert_eq!( + ( + code, + serde_json::from_slice::(&b).unwrap()["error"].as_str() + ), + (StatusCode::BAD_REQUEST, Some("requête illisible")) + ); + + // Rien n'est parti au Go. + let relayed = go.relayed.lock().unwrap().clone(); + assert!( + !relayed + .iter() + .any(|p| p.starts_with("/api/models") || p.starts_with("/api/hf")), + "{relayed:?}" + ); +} + +#[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; + for p in [ + "/api/models", + "/api/models/dirs", + "/api/hf/token", + "/api/hf/search?q=x", + ] { + let (_, _, b) = fakego::get(facade, p, &[]).await; + assert!(b.starts_with(b"go:/api/"), "{p} : {b:?}"); + } +} diff --git a/rust/loki-rs/tests/parity_models/models.json b/rust/loki-rs/tests/parity_models/models.json new file mode 100644 index 0000000..c5b5aa3 --- /dev/null +++ b/rust/loki-rs/tests/parity_models/models.json @@ -0,0 +1,933 @@ +{ + "quant": { + "Qwen3-8B-Q4_K_M.gguf": "Q4_K_M", + "a-4bpw.gguf": "4BPW", + "m-IQ4_XS-00001-of-00002.gguf": "IQ4_XS", + "m-Q4.gguf": "Q4", + "m-Q4_K_XL-Q5_K_M.gguf": "Q4_K_XL", + "mmproj-F16.GGUF": "F16", + "model-bf16.gguf": "BF16", + "noext": "", + "plain.gguf": "", + "x.Q8_0.gguf": "Q8_0" + }, + "split_args": [ + { + "in": "", + "out": [] + }, + { + "in": "--mmproj /a/b.gguf -ngl 99", + "out": [ + "--mmproj", + "/a/b.gguf", + "-ngl", + "99" + ] + }, + { + "in": "--chat-template-file \"/etc/x y.jinja\" --x ''", + "out": [ + "--chat-template-file", + "/etc/x y.jinja", + "--x", + "" + ] + }, + { + "in": "a\tb\nc", + "out": [ + "a", + "b", + "c" + ] + }, + { + "in": "'un \"deux\"' trois", + "out": [ + "un \"deux\"", + "trois" + ] + }, + { + "in": "--mmproj", + "out": [ + "--mmproj" + ] + } + ], + "mask": { + "": "", + " ": "", + " hf_padded_token ": "hf_…oken", + "abc": "…bc", + "hf_12345678": "hf_…5678", + "hf_abcdefghijklmnop": "hf_…mnop" + }, + "gated": [ + { + "in": true, + "out": true + }, + { + "in": false, + "out": false + }, + { + "in": "auto", + "out": true + }, + { + "in": "manual", + "out": true + }, + { + "in": "false", + "out": false + }, + { + "in": "", + "out": false + }, + { + "in": "None", + "out": false + }, + { + "in": null, + "out": false + }, + { + "in": 1, + "out": false + } + ], + "classify": { + "repo": "unsloth/Qwen3-8B-GGUF", + "files": [ + { + "path": "README.md", + "size": 1000 + }, + { + "path": "Qwen3-8B-Q8_0.gguf", + "size": 8589934592 + }, + { + "path": "Qwen3-8B-Q4_K_M.gguf", + "size": 5368709120 + }, + { + "path": "mmproj-F16.gguf", + "size": 943718400 + }, + { + "path": "mmproj-Q8_0.gguf", + "size": 629145600 + }, + { + "path": "mtp-Qwen3-8B-Q8_0.gguf", + "size": 314572800 + }, + { + "path": "BF16/Qwen3-8B-BF16-00001-of-00002.gguf", + "size": 9663676416 + }, + { + "path": "BF16/Qwen3-8B-BF16-00002-of-00002.gguf", + "size": 7516192768 + }, + { + "path": "Q2/Qwen3-8B-BF16-00001-of-00002.gguf", + "size": 1073741824 + }, + { + "path": "dossier avec espace/Qwen3 8B Q3.gguf", + "size": 4294967296 + } + ], + "out": { + "repo": "unsloth/Qwen3-8B-GGUF", + "gated": false, + "models": [ + { + "name": "Qwen3-8B-BF16-00001-of-00002.gguf", + "url": "https://huggingface.co/unsloth/Qwen3-8B-GGUF/resolve/main/Q2/Qwen3-8B-BF16-00001-of-00002.gguf", + "size": 1073741824, + "quant": "BF16", + "shards": 2 + }, + { + "name": "Qwen3 8B Q3.gguf", + "url": "https://huggingface.co/unsloth/Qwen3-8B-GGUF/resolve/main/dossier%20avec%20espace/Qwen3%208B%20Q3.gguf", + "size": 4294967296, + "quant": "", + "shards": 1 + }, + { + "name": "Qwen3-8B-Q4_K_M.gguf", + "url": "https://huggingface.co/unsloth/Qwen3-8B-GGUF/resolve/main/Qwen3-8B-Q4_K_M.gguf", + "size": 5368709120, + "quant": "Q4_K_M", + "shards": 1 + }, + { + "name": "Qwen3-8B-Q8_0.gguf", + "url": "https://huggingface.co/unsloth/Qwen3-8B-GGUF/resolve/main/Qwen3-8B-Q8_0.gguf", + "size": 8589934592, + "quant": "Q8_0", + "shards": 1 + }, + { + "name": "Qwen3-8B-BF16-00001-of-00002.gguf", + "url": "https://huggingface.co/unsloth/Qwen3-8B-GGUF/resolve/main/BF16/Qwen3-8B-BF16-00001-of-00002.gguf", + "size": 17179869184, + "quant": "BF16", + "shards": 2 + } + ], + "projectors": [ + { + "name": "mmproj-Q8_0.gguf", + "url": "https://huggingface.co/unsloth/Qwen3-8B-GGUF/resolve/main/mmproj-Q8_0.gguf", + "size": 629145600, + "quant": "Q8_0", + "shards": 1 + }, + { + "name": "mmproj-F16.gguf", + "url": "https://huggingface.co/unsloth/Qwen3-8B-GGUF/resolve/main/mmproj-F16.gguf", + "size": 943718400, + "quant": "F16", + "shards": 1 + } + ], + "drafts": [ + { + "name": "mtp-Qwen3-8B-Q8_0.gguf", + "url": "https://huggingface.co/unsloth/Qwen3-8B-GGUF/resolve/main/mtp-Qwen3-8B-Q8_0.gguf", + "size": 314572800, + "quant": "Q8_0", + "shards": 1 + } + ] + } + }, + "projector": [ + { + "in": null, + "name": "" + }, + { + "in": [ + { + "name": "mmproj-F16.gguf", + "url": "", + "size": 900, + "quant": "F16", + "shards": 0 + }, + { + "name": "mmproj-BF16.gguf", + "url": "", + "size": 800, + "quant": "BF16", + "shards": 0 + } + ], + "name": "mmproj-BF16.gguf" + }, + { + "in": [ + { + "name": "mmproj-F16.gguf", + "url": "", + "size": 900, + "quant": "F16", + "shards": 0 + }, + { + "name": "mmproj-q8_0.gguf", + "url": "", + "size": 950, + "quant": "q8_0", + "shards": 0 + } + ], + "name": "mmproj-q8_0.gguf" + } + ], + "verdicts": [ + { + "hardware": { + "os": "linux", + "arch": "amd64", + "ram_gb": 64, + "vram_gb": 24, + "gpus": [ + { + "name": "RTX", + "vram_gb": 24 + } + ] + }, + "weights": 5368709120, + "mmproj": 0, + "ctx": 32768, + "verdict": "ok", + "why": "5.0 Go de poids + ~0.2 Go de contexte (estimation) = ~5.2 Go pour 24.0 Go de VRAM" + }, + { + "hardware": { + "os": "linux", + "arch": "amd64", + "ram_gb": 64, + "vram_gb": 24, + "gpus": [ + { + "name": "RTX", + "vram_gb": 24 + } + ] + }, + "weights": 21474836480, + "mmproj": 629145600, + "ctx": 32768, + "verdict": "ok", + "why": "20.0 Go de poids + 0.6 Go de projecteur + ~1.0 Go de contexte (estimation) = ~21.6 Go pour 24.0 Go de VRAM" + }, + { + "hardware": { + "os": "linux", + "arch": "amd64", + "ram_gb": 64, + "vram_gb": 24, + "gpus": [ + { + "name": "RTX", + "vram_gb": 24 + } + ] + }, + "weights": 23622320128, + "mmproj": 0, + "ctx": 32768, + "verdict": "juste", + "why": "ça passe de justesse : 22.0 Go de poids + ~1.1 Go de contexte (estimation) = ~23.1 Go pour 24.0 Go de VRAM" + }, + { + "hardware": { + "os": "linux", + "arch": "amd64", + "ram_gb": 64, + "vram_gb": 24, + "gpus": [ + { + "name": "RTX", + "vram_gb": 24 + } + ] + }, + "weights": 32212254720, + "mmproj": 0, + "ctx": 0, + "verdict": "trop", + "why": "ne tient pas : 30.0 Go de poids + ~1.5 Go de contexte (estimation) = ~31.5 Go pour 24.0 Go de VRAM" + }, + { + "hardware": { + "os": "linux", + "arch": "amd64", + "ram_gb": 16, + "vram_gb": 0, + "gpus": null + }, + "weights": 8589934592, + "mmproj": 0, + "ctx": 131072, + "verdict": "ok", + "why": "8.0 Go de poids + ~1.6 Go de contexte (estimation) = ~9.6 Go pour 16.0 Go de RAM" + }, + { + "hardware": { + "os": "linux", + "arch": "amd64", + "ram_gb": 0, + "vram_gb": 0, + "gpus": null + }, + "weights": 1073741824, + "mmproj": 0, + "ctx": 4096, + "verdict": "", + "why": "" + } + ], + "scenario": { + "files": { + "disque/a-Q4_K_M.gguf": 100, + "disque/ext.GGUF": 400, + "models/a-Q4_K_M.gguf": 100, + "models/actif.gguf": 50, + "models/b.gguf": 200, + "models/m-00001-of-00002.gguf": 10, + "models/m-00002-of-00002.gguf": 20, + "models/notes.txt": 3, + "models/sous-dossier.gguf": -1, + "models/t-00001-of-00003.gguf": 5, + "models/t-00003-of-00003.gguf": 7, + "models/vision-mmproj.gguf": 30 + }, + "extra": [ + "{{HOME}}/disque", + "{{HOME}}/absent" + ], + "presets": { + "alpha": "NAME=Alpha\nMODEL=a-Q4_K_M.gguf\nMMPROJ=vision-mmproj.gguf\n", + "beta": "NAME=Beta\nMODEL={{HOME}}/disque/ext.GGUF\n", + "delta": "NAME=Delta\nMODEL=m-00001-of-00002.gguf\n", + "gamma": "NAME=Gamma\nMODEL=b.gguf\nEXTRA_ARGS=--mmproj \"vision-mmproj.gguf\" -ngl 99 --mmproj b.gguf\n" + }, + "config": { + "MODEL": "actif.gguf", + "MODEL_DRAFT": "{{HOME}}/disque/a-Q4_K_M.gguf" + }, + "calls": [ + { + "method": "GET", + "path": "/api/models", + "status": 200, + "out": [ + { + "active": false, + "dir": "{{HOME}}/models", + "home": true, + "name": "a-Q4_K_M.gguf", + "path": "{{HOME}}/models/a-Q4_K_M.gguf", + "size": 100, + "used": [ + "Alpha" + ], + "value": "a-Q4_K_M.gguf" + }, + { + "active": true, + "dir": "{{HOME}}/models", + "home": true, + "name": "actif.gguf", + "path": "{{HOME}}/models/actif.gguf", + "size": 50, + "value": "actif.gguf" + }, + { + "active": false, + "dir": "{{HOME}}/models", + "home": true, + "name": "b.gguf", + "path": "{{HOME}}/models/b.gguf", + "size": 200, + "used": [ + "Gamma" + ], + "value": "b.gguf" + }, + { + "active": false, + "dir": "{{HOME}}/models", + "home": true, + "name": "m-00001-of-00002.gguf", + "path": "{{HOME}}/models/m-00001-of-00002.gguf", + "shards": 2, + "size": 30, + "used": [ + "Delta" + ], + "value": "m-00001-of-00002.gguf" + }, + { + "dir": "{{HOME}}/models", + "home": true, + "missing": [ + "t-00002-of-00003.gguf" + ], + "name": "t-00001-of-00003.gguf", + "path": "{{HOME}}/models/t-00001-of-00003.gguf", + "shards": 3, + "size": 12, + "value": "t-00001-of-00003.gguf" + }, + { + "active": false, + "dir": "{{HOME}}/models", + "home": true, + "name": "vision-mmproj.gguf", + "path": "{{HOME}}/models/vision-mmproj.gguf", + "size": 30, + "used": [ + "Alpha", + "Gamma" + ], + "value": "vision-mmproj.gguf" + }, + { + "active": true, + "dir": "{{HOME}}/disque", + "home": false, + "name": "a-Q4_K_M.gguf", + "path": "{{HOME}}/disque/a-Q4_K_M.gguf", + "size": 100, + "value": "{{HOME}}/disque/a-Q4_K_M.gguf" + }, + { + "active": false, + "dir": "{{HOME}}/disque", + "home": false, + "name": "ext.GGUF", + "path": "{{HOME}}/disque/ext.GGUF", + "size": 400, + "used": [ + "Beta" + ], + "value": "{{HOME}}/disque/ext.GGUF" + } + ] + }, + { + "method": "GET", + "path": "/api/models/dirs", + "status": 200, + "out": { + "dirs": [ + { + "count": 6, + "exists": true, + "free": "…", + "free_exact": "…", + "home": true, + "path": "{{HOME}}/models" + }, + { + "count": 2, + "exists": true, + "free": "…", + "free_exact": "…", + "home": false, + "path": "{{HOME}}/disque" + }, + { + "count": -1, + "exists": false, + "free": "…", + "free_exact": "…", + "home": false, + "path": "{{HOME}}/absent" + } + ], + "env": false, + "ok": true + } + }, + { + "method": "POST", + "path": "/api/models/delete", + "body": "{\"name\":\"actif.gguf\"}", + "status": 409, + "out": { + "active": true, + "error": "modèle en service — bascule sur un autre preset avant de le supprimer", + "ok": false + }, + "files": [ + "disque/a-Q4_K_M.gguf", + "disque/ext.GGUF", + "models/a-Q4_K_M.gguf", + "models/actif.gguf", + "models/b.gguf", + "models/m-00001-of-00002.gguf", + "models/m-00002-of-00002.gguf", + "models/notes.txt", + "models/t-00001-of-00003.gguf", + "models/t-00003-of-00003.gguf", + "models/vision-mmproj.gguf" + ] + }, + { + "method": "POST", + "path": "/api/models/delete", + "body": "{\"name\":\"a-Q4_K_M.gguf\"}", + "status": 409, + "out": { + "error": "modèle utilisé par : Alpha", + "ok": false, + "used": [ + "Alpha" + ] + }, + "files": [ + "disque/a-Q4_K_M.gguf", + "disque/ext.GGUF", + "models/a-Q4_K_M.gguf", + "models/actif.gguf", + "models/b.gguf", + "models/m-00001-of-00002.gguf", + "models/m-00002-of-00002.gguf", + "models/notes.txt", + "models/t-00001-of-00003.gguf", + "models/t-00003-of-00003.gguf", + "models/vision-mmproj.gguf" + ] + }, + { + "method": "POST", + "path": "/api/models/delete", + "body": "{\"name\":\"{{HOME}}/disque/a-Q4_K_M.gguf\"}", + "status": 409, + "out": { + "active": true, + "error": "modèle en service — bascule sur un autre preset avant de le supprimer", + "ok": false + }, + "files": [ + "disque/a-Q4_K_M.gguf", + "disque/ext.GGUF", + "models/a-Q4_K_M.gguf", + "models/actif.gguf", + "models/b.gguf", + "models/m-00001-of-00002.gguf", + "models/m-00002-of-00002.gguf", + "models/notes.txt", + "models/t-00001-of-00003.gguf", + "models/t-00003-of-00003.gguf", + "models/vision-mmproj.gguf" + ] + }, + { + "method": "POST", + "path": "/api/models/delete", + "body": "{\"name\":\"b.gguf\",\"force\":true}", + "status": 200, + "out": { + "freed": 200, + "ok": true, + "used": [ + "Gamma" + ] + }, + "files": [ + "disque/a-Q4_K_M.gguf", + "disque/ext.GGUF", + "models/a-Q4_K_M.gguf", + "models/actif.gguf", + "models/m-00001-of-00002.gguf", + "models/m-00002-of-00002.gguf", + "models/notes.txt", + "models/t-00001-of-00003.gguf", + "models/t-00003-of-00003.gguf", + "models/vision-mmproj.gguf" + ] + }, + { + "method": "POST", + "path": "/api/models/delete", + "body": "{\"name\":\"m-00001-of-00002.gguf\",\"force\":true}", + "status": 200, + "out": { + "freed": 30, + "ok": true, + "used": [ + "Delta" + ] + }, + "files": [ + "disque/a-Q4_K_M.gguf", + "disque/ext.GGUF", + "models/a-Q4_K_M.gguf", + "models/actif.gguf", + "models/notes.txt", + "models/t-00001-of-00003.gguf", + "models/t-00003-of-00003.gguf", + "models/vision-mmproj.gguf" + ] + }, + { + "method": "POST", + "path": "/api/models/delete", + "body": "{\"name\":\"t-00001-of-00003.gguf\"}", + "status": 200, + "out": { + "freed": 12, + "ok": true, + "used": null + }, + "files": [ + "disque/a-Q4_K_M.gguf", + "disque/ext.GGUF", + "models/a-Q4_K_M.gguf", + "models/actif.gguf", + "models/notes.txt", + "models/vision-mmproj.gguf" + ] + }, + { + "method": "POST", + "path": "/api/models/delete", + "body": "{\"name\":\"inconnu.gguf\"}", + "status": 400, + "out": { + "error": "modèle introuvable: inconnu.gguf", + "ok": false + }, + "files": [ + "disque/a-Q4_K_M.gguf", + "disque/ext.GGUF", + "models/a-Q4_K_M.gguf", + "models/actif.gguf", + "models/notes.txt", + "models/vision-mmproj.gguf" + ] + }, + { + "method": "POST", + "path": "/api/models/delete", + "body": "{\"name\":\"/etc/passwd.gguf\"}", + "status": 400, + "out": { + "error": "dossier non autorisé : /etc — ajoute-le dans « Dossiers de modèles »", + "ok": false + }, + "files": [ + "disque/a-Q4_K_M.gguf", + "disque/ext.GGUF", + "models/a-Q4_K_M.gguf", + "models/actif.gguf", + "models/notes.txt", + "models/vision-mmproj.gguf" + ] + }, + { + "method": "POST", + "path": "/api/models/delete", + "body": "{\"name\":\"notes.txt\"}", + "status": 400, + "out": { + "error": "le modèle doit être un fichier .gguf", + "ok": false + }, + "files": [ + "disque/a-Q4_K_M.gguf", + "disque/ext.GGUF", + "models/a-Q4_K_M.gguf", + "models/actif.gguf", + "models/notes.txt", + "models/vision-mmproj.gguf" + ] + }, + { + "method": "POST", + "path": "/api/models/delete", + "body": "{\"name\":\"\"}", + "status": 400, + "out": { + "error": "nom de modèle invalide", + "ok": false + }, + "files": [ + "disque/a-Q4_K_M.gguf", + "disque/ext.GGUF", + "models/a-Q4_K_M.gguf", + "models/actif.gguf", + "models/notes.txt", + "models/vision-mmproj.gguf" + ] + }, + { + "method": "POST", + "path": "/api/models/dirs", + "body": "{\"path\":\"\"}", + "status": 400, + "out": { + "error": "chemin vide", + "ok": false + }, + "stored": [ + "{{HOME}}/disque", + "{{HOME}}/absent" + ] + }, + { + "method": "POST", + "path": "/api/models/dirs", + "body": "{\"path\":\"{{HOME}}/nulle-part\"}", + "status": 400, + "out": { + "error": "dossier introuvable : {{HOME}}/nulle-part", + "ok": false + }, + "stored": [ + "{{HOME}}/disque", + "{{HOME}}/absent" + ] + }, + { + "method": "POST", + "path": "/api/models/dirs", + "body": "{\"path\":\"{{HOME}}/models/sous-dossier.gguf\"}", + "status": 200, + "out": { + "ok": true + }, + "stored": [ + "{{HOME}}/disque", + "{{HOME}}/absent", + "{{HOME}}/models/sous-dossier.gguf" + ] + }, + { + "method": "POST", + "path": "/api/models/dirs", + "body": "{\"path\":\"{{HOME}}/disque/\",\"action\":\"remove\"}", + "status": 200, + "out": { + "ok": true + }, + "stored": [ + "{{HOME}}/absent", + "{{HOME}}/models/sous-dossier.gguf" + ] + }, + { + "method": "POST", + "path": "/api/models/dirs", + "body": "{\"path\":\"{{HOME}}/disque/../disque\",\"action\":\"add\"}", + "status": 200, + "out": { + "ok": true + }, + "stored": [ + "{{HOME}}/absent", + "{{HOME}}/models/sous-dossier.gguf", + "{{HOME}}/disque" + ] + }, + { + "method": "POST", + "path": "/api/models/dirs", + "body": "{\"path\":\"{{HOME}}/models\",\"action\":\"add\"}", + "status": 200, + "out": { + "ok": true + }, + "stored": [ + "{{HOME}}/absent", + "{{HOME}}/models/sous-dossier.gguf", + "{{HOME}}/disque" + ] + }, + { + "method": "GET", + "path": "/api/models", + "status": 200, + "out": [ + { + "active": false, + "dir": "{{HOME}}/models", + "home": true, + "name": "a-Q4_K_M.gguf", + "path": "{{HOME}}/models/a-Q4_K_M.gguf", + "size": 100, + "used": [ + "Alpha" + ], + "value": "a-Q4_K_M.gguf" + }, + { + "active": true, + "dir": "{{HOME}}/models", + "home": true, + "name": "actif.gguf", + "path": "{{HOME}}/models/actif.gguf", + "size": 50, + "value": "actif.gguf" + }, + { + "active": false, + "dir": "{{HOME}}/models", + "home": true, + "name": "vision-mmproj.gguf", + "path": "{{HOME}}/models/vision-mmproj.gguf", + "size": 30, + "used": [ + "Alpha", + "Gamma" + ], + "value": "vision-mmproj.gguf" + }, + { + "active": true, + "dir": "{{HOME}}/disque", + "home": false, + "name": "a-Q4_K_M.gguf", + "path": "{{HOME}}/disque/a-Q4_K_M.gguf", + "size": 100, + "value": "{{HOME}}/disque/a-Q4_K_M.gguf" + }, + { + "active": false, + "dir": "{{HOME}}/disque", + "home": false, + "name": "ext.GGUF", + "path": "{{HOME}}/disque/ext.GGUF", + "size": 400, + "used": [ + "Beta" + ], + "value": "{{HOME}}/disque/ext.GGUF" + } + ] + }, + { + "method": "GET", + "path": "/api/models/dirs", + "status": 200, + "out": { + "dirs": [ + { + "count": 3, + "exists": true, + "free": "…", + "free_exact": "…", + "home": true, + "path": "{{HOME}}/models" + }, + { + "count": -1, + "exists": false, + "free": "…", + "free_exact": "…", + "home": false, + "path": "{{HOME}}/absent" + }, + { + "count": 0, + "exists": true, + "free": "…", + "free_exact": "…", + "home": false, + "path": "{{HOME}}/models/sous-dossier.gguf" + }, + { + "count": 2, + "exists": true, + "free": "…", + "free_exact": "…", + "home": false, + "path": "{{HOME}}/disque" + } + ], + "env": false, + "ok": true + } + } + ] + } +} diff --git a/rust/loki-rs/tests/proxy.rs b/rust/loki-rs/tests/proxy.rs index 2f5da3c..bda1229 100644 --- a/rust/loki-rs/tests/proxy.rs +++ b/rust/loki-rs/tests/proxy.rs @@ -100,7 +100,7 @@ async fn facade(backend: SocketAddr) -> (SocketAddr, oneshot::Sender<()>) { service: "loki-engine".into(), }, chat: None, - downloads: None, + models: None, }; tokio::spawn(loki_rs::proxy::serve(l, cfg, async move { let _ = rx.await;