Files
Loki/internal/loki/backend_catalog.go
T
Claude 26e93f43ef Chercher et installer un modèle depuis Loki
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
2026-08-16 11:02:35 +00:00

121 lines
4.1 KiB
Go

package loki
// backend_catalog.go — ce que la machine peut avaler, et si un modèle y tient.
//
// Ce fichier servait autrefois un catalogue de modèles téléchargé depuis
// ajean.link/models.json — le serveur de l'auteur du projet dont Loki est un
// fork. Deux raisons de l'avoir retiré : sa route n'avait AUCUN consommateur
// (l'« écran d'accueil » qu'elle attendait n'a jamais existé), et un fork qui
// laisse un tiers décider de ce qu'il propose n'est pas vraiment un fork. Les
// modèles se cherchent maintenant directement sur Hugging Face
// (backend_hf.go).
//
// Reste ici la seule question qui vaille avant de lancer un téléchargement de
// 20 Go : est-ce que ça tiendra ?
import (
"fmt"
"runtime"
"strconv"
"strings"
)
type gpuBrief struct {
Name string `json:"name"`
VRAMGB float64 `json:"vram_gb"`
}
type hardwareInfo struct {
OS string `json:"os"`
Arch string `json:"arch"`
RAMGB float64 `json:"ram_gb"`
// VRAMGB : somme de la mémoire des GPU détectés. 0 = pas de GPU visible
// (ni nvidia-smi, ni carte) — le verdict retombe alors sur la RAM système,
// ce qui est le bon repère pour une inférence CPU.
VRAMGB float64 `json:"vram_gb"`
GPUs []gpuBrief `json:"gpus"`
}
// detectHardware décrit la machine. La VRAM vient de detectGPUs
// (backend_gpu.go, via nvidia-smi) : sans elle, le verdict d'un serveur à GPU
// se prononçait sur la RAM système, donc à côté de la plaque — c'est le GPU
// qui porte le modèle quand NGL l'y envoie.
func detectHardware() hardwareInfo {
h := hardwareInfo{OS: runtime.GOOS, Arch: runtime.GOARCH, RAMGB: totalRAMGB()}
gpus, err := detectGPUs()
if err != nil {
return h // pas de GPU visible : VRAMGB reste à 0, et c'est une info
}
for _, g := range gpus {
// nvidia-smi rend des MiB en --format=nounits.
mib, convErr := strconv.ParseFloat(strings.TrimSpace(g.MemTotal), 64)
if convErr != nil {
continue
}
gb := mib / 1024
h.VRAMGB += gb
h.GPUs = append(h.GPUs, gpuBrief{Name: g.Name, VRAMGB: gb})
}
return h
}
// Verdicts rendus par fitVerdict.
const (
fitOK = "ok" // confortable
fitTight = "juste" // ça passe, sans marge
fitOver = "trop" // ça ne rentre pas
)
// ctxOverheadGB estime ce que le cache KV va prendre en plus des poids.
//
// ⚠️ C'est une RÈGLE GROSSIÈRE, et l'interface doit le dire. Le coût exact d'un
// cache KV dépend de l'architecture du modèle (nombre de couches, têtes KV,
// dimension des têtes), qu'une simple liste de fichiers ne révèle pas. Le
// connaître demanderait de lire l'en-tête GGUF par requête Range et d'en parser
// les métadonnées — faisable, mais ce n'est pas ce que fait ce code.
//
// L'ordre de grandeur retenu : ~1 Go par tranche de 32k de contexte pour un
// modèle de 20 Go, proportionnel à la taille des poids. Mieux vaut une
// fourchette annoncée comme telle qu'un chiffre faussement précis.
func ctxOverheadGB(weightsGB float64, ctxTokens int) float64 {
if ctxTokens <= 0 {
ctxTokens = 32768
}
return weightsGB / 20 * (float64(ctxTokens) / 32768)
}
// fitVerdict dit si un modèle tient, et POURQUOI. La phrase compte autant que
// le verdict : « trop » sans explication laisse l'utilisateur deviner s'il doit
// changer de quantification, baisser le contexte ou renoncer au projecteur.
func fitVerdict(h hardwareInfo, weights, mmproj int64, ctxTokens int) (string, string) {
const gb = float64(1 << 30)
wGB := float64(weights) / gb
mGB := float64(mmproj) / gb
kvGB := ctxOverheadGB(wGB, ctxTokens)
need := wGB + mGB + kvGB
budget, where := h.VRAMGB, "VRAM"
if budget <= 0 {
budget, where = h.RAMGB, "RAM"
}
if budget <= 0 {
return "", "" // machine non mesurable : pas de verdict inventé
}
detail := fmt.Sprintf("%.1f Go de poids", wGB)
if mGB > 0 {
detail += fmt.Sprintf(" + %.1f Go de projecteur", mGB)
}
detail += fmt.Sprintf(" + ~%.1f Go de contexte (estimation) = ~%.1f Go pour %.1f Go de %s",
kvGB, need, budget, where)
switch {
case need > budget:
return fitOver, "ne tient pas : " + detail
case need > budget*0.9:
return fitTight, "ça passe de justesse : " + detail
default:
return fitOK, detail
}
}