mirror of
https://github.com/R0m1k3/Loki.git
synced 2026-10-11 17:26:57 +02:00
Installer un modèle demandait d'aller sur huggingface.co, de naviguer dans l'arborescence d'un dépôt et de coller un lien à la main. Rien ne disait si le fichier tiendrait en mémoire, et surtout rien ne reliait un modèle à SON projecteur vision : c'est ainsi qu'un mmproj-Qwen3VL-8B s'est retrouvé configuré pour un Qwen3.8-27B — deux modèles sans rapport, moteur qui démarre et ne voit rien. La recherche Hugging Face arrive dans l'éditeur de preset. Elle ne remonte que les dépôts GGUF ; déplier un dépôt montre ses quantifications avec leur taille et un verdict mémoire, et propose le projecteur vision DU MÊME DÉPÔT — le seul qui corresponde. Quand le dépôt n'en publie pas, Loki le dit au lieu d'aller en chercher un ailleurs. Le nouveau code ne télécharge rien : il produit des URL que le chemin existant consomme tel quel (normalizeHFURL, shardURLSet, sonde d'espace disque, reprise et annulation). Une file d'attente enchaîne modèle puis projecteur — le serveur ne mène qu'un transfert à la fois, et le champ Vision ne se remplit que si le modèle est déjà sélectionné. Trois familles de .gguf cohabitent dans un dépôt et ne veulent pas dire la même chose : mmproj-* (projecteur), mtp-* (poids de décodage spéculatif) et le modèle. Les deux premières ressemblent à un modèle ; les proposer en vrac, ce serait offrir de lancer llama-server sur un encodeur d'images. Les tranches d'une famille sont repliées en une entrée de taille TOTALE : annoncer 15 Go pour un modèle qui en occupe 45 promet une place qui n'existe pas. Le verdict mémoire s'appuie enfin sur le GPU. detectHardware() ne renvoyait que la RAM système alors que detectGPUs() existait déjà : sur un serveur à carte NVIDIA, le verdict se prononçait sur la mauvaise grandeur. Le coût du cache KV reste une estimation assumée — l'exact demanderait de parser l'en-tête GGUF — et l'interface l'annonce comme telle plutôt que d'afficher un chiffre faussement sûr. Le catalogue ajean.link disparaît. Sa route n'avait aucun consommateur (l'écran d'accueil qu'elle attendait n'a jamais existé), son repli embarqué proposait du Qwen2.5 de 2024, et un fork qui laisse le serveur de l'amont décider de ce qu'il propose n'est pas vraiment un fork. Une piste écartée en cours de route : marquer les dépôts « vision » d'après les tags Hugging Face. Ils mentent — des deux dépôts GGUF de Qwen3.8-27B qui publient tous deux un mmproj, seul ggml-org est taggé image-text-to-text. Une pastille sur l'un et pas sur l'autre aurait été pire que rien. La vision est donc déduite de la seule source qui ne se trompe pas : la présence d'un mmproj-*.gguf dans l'arborescence. Vérifié : 8 tests unitaires sur les arborescences réelles des deux dépôts, puis au navigateur contre le vrai Hugging Face — 25 dépôts trouvés, 3 quants listés sans aucun mtp ni mmproj, projecteur Q8_0 proposé et coché, verdicts affichés avec leur explication, modale dans l'écran. L'ordre de la file (modèle puis projecteur) est testé en interceptant les appels, sans transfert. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01VueWA9xcYadaYq65tBisix
208 lines
7.6 KiB
Go
208 lines
7.6 KiB
Go
package loki
|
|
|
|
import (
|
|
"strings"
|
|
"testing"
|
|
)
|
|
|
|
const gb = int64(1) << 30
|
|
|
|
// Arborescence réelle de ggml-org/Qwen3.8-27B-GGUF, relevée sur l'API Hugging
|
|
// Face. Trois familles de .gguf y cohabitent, et deux d'entre elles ne sont PAS
|
|
// des modèles : c'est tout l'enjeu du classement.
|
|
func ggmlOrgTree() []hfFile {
|
|
return []hfFile{
|
|
{Path: ".gitattributes", Size: 2011},
|
|
{Path: "README.md", Size: 397},
|
|
{Path: "convert.log", Size: 485915},
|
|
{Path: "Qwen3.8-27B-BF16.gguf", Size: 53808281952},
|
|
{Path: "Qwen3.8-27B-Q4_K_M.gguf", Size: 18973870432},
|
|
{Path: "Qwen3.8-27B-Q8_0.gguf", Size: 28595763552},
|
|
{Path: "mmproj-Qwen3.8-27B-BF16.gguf", Size: 931145888},
|
|
{Path: "mmproj-Qwen3.8-27B-Q8_0.gguf", Size: 629247008},
|
|
{Path: "mtp-Qwen3.8-27B-BF16.gguf", Size: 5946009888},
|
|
{Path: "mtp-Qwen3.8-27B-Q4_0.gguf", Size: 1680271648},
|
|
{Path: "mtp-Qwen3.8-27B-Q8_0.gguf", Size: 3164006688},
|
|
}
|
|
}
|
|
|
|
func names(list []hfEntry) []string {
|
|
out := make([]string, len(list))
|
|
for i, e := range list {
|
|
out[i] = e.Name
|
|
}
|
|
return out
|
|
}
|
|
|
|
func TestHFClassifySeparatesProjectorsAndDrafts(t *testing.T) {
|
|
got := hfClassify("ggml-org/Qwen3.8-27B-GGUF", ggmlOrgTree())
|
|
|
|
if len(got.Models) != 3 {
|
|
t.Fatalf("modèles = %v, attendu 3 entrées", names(got.Models))
|
|
}
|
|
// Un mmproj proposé comme modèle, c'est un llama-server lancé sur un
|
|
// encodeur d'images : il démarre et ne répond rien de sensé.
|
|
for _, m := range got.Models {
|
|
if strings.HasPrefix(m.Name, "mmproj") || strings.HasPrefix(m.Name, "mtp-") {
|
|
t.Errorf("%q classé comme modèle", m.Name)
|
|
}
|
|
}
|
|
if len(got.Projectors) != 2 {
|
|
t.Errorf("projecteurs = %v, attendu 2", names(got.Projectors))
|
|
}
|
|
if len(got.Drafts) != 3 {
|
|
t.Errorf("drafts = %v, attendu 3", names(got.Drafts))
|
|
}
|
|
// Les fichiers non-.gguf n'ont rien à faire dans une liste installable.
|
|
for _, e := range append(append(got.Models, got.Projectors...), got.Drafts...) {
|
|
if !strings.HasSuffix(e.Name, ".gguf") {
|
|
t.Errorf("%q n'est pas un .gguf", e.Name)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestHFClassifyBuildsUsableURLs(t *testing.T) {
|
|
got := hfClassify("ggml-org/Qwen3.8-27B-GGUF", ggmlOrgTree())
|
|
want := "https://huggingface.co/ggml-org/Qwen3.8-27B-GGUF/resolve/main/Qwen3.8-27B-Q4_K_M.gguf"
|
|
found := false
|
|
for _, m := range got.Models {
|
|
if m.URL == want {
|
|
found = true
|
|
}
|
|
}
|
|
if !found {
|
|
t.Fatalf("URL attendue absente ; obtenu %v", got.Models)
|
|
}
|
|
// L'URL doit traverser normalizeHFURL sans retouche : c'est elle que
|
|
// /api/models/download/probe recevra.
|
|
for _, m := range got.Models {
|
|
if _, name, err := normalizeHFURL(m.URL); err != nil {
|
|
t.Errorf("normalizeHFURL(%q) : %v", m.URL, err)
|
|
} else if name != m.Name {
|
|
t.Errorf("normalizeHFURL(%q) → %q, attendu %q", m.URL, name, m.Name)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestHFClassifyQuantAndSort(t *testing.T) {
|
|
got := hfClassify("unsloth/Qwen3.8-27B-GGUF", []hfFile{
|
|
{Path: "Qwen3.8-27B-UD-Q4_K_XL.gguf", Size: 17923394624},
|
|
{Path: "Qwen3.8-27B-Q8_0.gguf", Size: 29047086048},
|
|
{Path: "Qwen3.8-27B-UD-IQ2_XXS.gguf", Size: 9010048064},
|
|
})
|
|
// Du plus léger au plus lourd : la liste sert à choisir ce qui tient.
|
|
if n := names(got.Models); n[0] != "Qwen3.8-27B-UD-IQ2_XXS.gguf" || n[2] != "Qwen3.8-27B-Q8_0.gguf" {
|
|
t.Fatalf("tri par taille cassé : %v", n)
|
|
}
|
|
want := map[string]string{
|
|
"Qwen3.8-27B-UD-Q4_K_XL.gguf": "Q4_K_XL",
|
|
"Qwen3.8-27B-Q8_0.gguf": "Q8_0",
|
|
"Qwen3.8-27B-UD-IQ2_XXS.gguf": "IQ2_XXS",
|
|
}
|
|
for _, m := range got.Models {
|
|
if want[m.Name] != m.Quant {
|
|
t.Errorf("quant de %q = %q, attendu %q", m.Name, m.Quant, want[m.Name])
|
|
}
|
|
}
|
|
}
|
|
|
|
// Une famille de tranches doit donner UNE entrée, de taille totale. Annoncer le
|
|
// poids de la première tranche promet 15 Go là où le disque en verra 45.
|
|
func TestHFClassifyFoldsShards(t *testing.T) {
|
|
got := hfClassify("unsloth/Big-GGUF", []hfFile{
|
|
{Path: "UD-IQ4_XS/Big-UD-IQ4_XS-00001-of-00003.gguf", Size: 15 * gb},
|
|
{Path: "UD-IQ4_XS/Big-UD-IQ4_XS-00002-of-00003.gguf", Size: 15 * gb},
|
|
{Path: "UD-IQ4_XS/Big-UD-IQ4_XS-00003-of-00003.gguf", Size: 12 * gb},
|
|
})
|
|
if len(got.Models) != 1 {
|
|
t.Fatalf("modèles = %v, attendu 1 famille repliée", names(got.Models))
|
|
}
|
|
m := got.Models[0]
|
|
if m.Shards != 3 {
|
|
t.Errorf("shards = %d, attendu 3", m.Shards)
|
|
}
|
|
if m.Size != 42*gb {
|
|
t.Errorf("taille = %d, attendu %d (total de la famille)", m.Size, 42*gb)
|
|
}
|
|
if !strings.HasSuffix(m.Name, "-00001-of-00003.gguf") {
|
|
t.Errorf("l'entrée doit désigner la PREMIÈRE tranche, obtenu %q", m.Name)
|
|
}
|
|
}
|
|
|
|
// Deux dossiers de quantification portant des tranches distinctes ne doivent pas
|
|
// mélanger leurs tailles : le repli se fait par dossier, pas par nom de base.
|
|
func TestHFClassifyShardsStayInTheirDirectory(t *testing.T) {
|
|
got := hfClassify("x/y", []hfFile{
|
|
{Path: "Q4/M-00001-of-00002.gguf", Size: 4 * gb},
|
|
{Path: "Q4/M-00002-of-00002.gguf", Size: 4 * gb},
|
|
{Path: "Q8/M-00001-of-00002.gguf", Size: 9 * gb},
|
|
{Path: "Q8/M-00002-of-00002.gguf", Size: 9 * gb},
|
|
})
|
|
if len(got.Models) != 2 {
|
|
t.Fatalf("attendu 2 familles, obtenu %d", len(got.Models))
|
|
}
|
|
if got.Models[0].Size != 8*gb || got.Models[1].Size != 18*gb {
|
|
t.Errorf("tailles = %d et %d, attendu %d et %d",
|
|
got.Models[0].Size, got.Models[1].Size, 8*gb, 18*gb)
|
|
}
|
|
}
|
|
|
|
func TestHFPickProjectorPrefersQ8(t *testing.T) {
|
|
list := hfClassify("ggml-org/Qwen3.8-27B-GGUF", ggmlOrgTree()).Projectors
|
|
p, ok := hfPickProjector(list)
|
|
if !ok {
|
|
t.Fatal("aucun projecteur trouvé alors que le dépôt en publie deux")
|
|
}
|
|
if p.Name != "mmproj-Qwen3.8-27B-Q8_0.gguf" {
|
|
t.Errorf("projecteur choisi = %q, attendu le Q8_0", p.Name)
|
|
}
|
|
// Dépôt sans vision : il faut le DIRE, pas aller chercher ailleurs. Un
|
|
// projecteur d'un autre modèle ne correspond jamais.
|
|
if _, ok := hfPickProjector(nil); ok {
|
|
t.Error("un dépôt sans projecteur ne doit rien proposer")
|
|
}
|
|
}
|
|
|
|
func TestHFRepoValidation(t *testing.T) {
|
|
for _, bad := range []string{"", "pasdeslash", "../../api/whoami", "/leading", "a//b", "x/y/z"} {
|
|
if _, err := hfFiles(t.Context(), bad); err == nil {
|
|
t.Errorf("dépôt %q accepté alors qu'il est invalide", bad)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestFitVerdict(t *testing.T) {
|
|
gpu24 := hardwareInfo{VRAMGB: 24, RAMGB: 64}
|
|
|
|
// Q8_0 de 29 Go : ne rentre pas dans 24 Go, quoi qu'il arrive.
|
|
if v, why := fitVerdict(gpu24, 29*gb, 0, 32768); v != fitOver {
|
|
t.Errorf("Q8_0 29 Go sur 24 Go de VRAM → %q (%s), attendu %q", v, why, fitOver)
|
|
}
|
|
// UD-Q4_K_XL de 17 Go + projecteur + contexte : ça tient.
|
|
if v, why := fitVerdict(gpu24, 17*gb, 629<<20, 32768); v != fitOK {
|
|
t.Errorf("Q4 17 Go sur 24 Go de VRAM → %q (%s), attendu %q", v, why, fitOK)
|
|
}
|
|
// Le projecteur doit peser dans la balance : à la limite, l'ajouter fait
|
|
// basculer le verdict. C'est exactement le cas qu'on veut voir venir avant
|
|
// de lancer 20 Go de téléchargement.
|
|
sans, _ := fitVerdict(hardwareInfo{VRAMGB: 20}, 18*gb, 0, 32768)
|
|
avec, _ := fitVerdict(hardwareInfo{VRAMGB: 20}, 18*gb, 3*gb, 32768)
|
|
if sans == avec {
|
|
t.Errorf("le projecteur ne change rien au verdict (%q dans les deux cas)", sans)
|
|
}
|
|
// Sans GPU, le verdict se prononce sur la RAM et le dit.
|
|
if v, why := fitVerdict(hardwareInfo{RAMGB: 64}, 17*gb, 0, 32768); v != fitOK || !strings.Contains(why, "RAM") {
|
|
t.Errorf("sans GPU → %q (%s), attendu %q mesuré en RAM", v, why, fitOK)
|
|
}
|
|
// Machine non mesurable : pas de verdict inventé.
|
|
if v, _ := fitVerdict(hardwareInfo{}, 17*gb, 0, 32768); v != "" {
|
|
t.Errorf("sans mesure mémoire → %q, attendu aucun verdict", v)
|
|
}
|
|
// Un contexte plus large coûte plus cher, et doit pouvoir faire basculer.
|
|
court, _ := fitVerdict(hardwareInfo{VRAMGB: 24}, 21*gb, 0, 8192)
|
|
long, _ := fitVerdict(hardwareInfo{VRAMGB: 24}, 21*gb, 0, 262144)
|
|
if court == long {
|
|
t.Errorf("le contexte ne change rien au verdict (%q dans les deux cas)", court)
|
|
}
|
|
}
|