Modèles : les supprimer depuis l'interface, et ne charger que des presets

Un .gguf ne pouvait s'effacer qu'en cochant « supprimer aussi le fichier »
au moment de supprimer son preset. Tout ce qui avait été téléchargé sans
preset — une quantization essayée puis abandonnée, un projecteur du mauvais
dépôt — restait donc sur le disque pour des dizaines de gigaoctets, sans
aucun moyen de partir depuis Loki.

L'éditeur de preset porte désormais une ligne « Modèles installés » (repliée,
sous le sélecteur de fichier) : chaque .gguf des dossiers déclarés avec sa
taille, ses tranches, qui le réclame, et un bouton « supprimer ».

Rien n'est réversible ici, d'où deux garde-fous côté serveur, partagés par
/api/models/delete ET par la case de l'éditeur de preset :

  - le modèle CHARGÉ est intouchable — l'effacer sous le moteur ne rend pas
    l'espace tant qu'il tient le descripteur, et le démarrage suivant meurt
    sur un fichier introuvable ;
  - un modèle réclamé par des presets ne part qu'avec `force`, le refus 409
    nommant les presets pour que la seconde confirmation dise ce qu'on casse.
    Supprimer un preset ne peut donc plus emporter le modèle de son voisin.

La suppression emporte toute la famille de tranches et annonce la place
réellement libérée. /api/models marque au passage le modèle chargé et les
presets qui référencent chaque fichier.

Le sélecteur de l'en-tête ne liste plus que des presets. Il proposait aussi
les .gguf du disque, chargés en n'écrivant que MODEL= : le contexte, le NGL
et l'échantillonnage du modèle PRÉCÉDENT restaient en place, et un 27B chargé
par-dessus les réglages d'un 4B partait sur 100k de contexte pour mourir en
OOM sans que rien ne dise pourquoi. Charger un modèle, c'est appliquer TOUS
ses réglages : ça s'appelle un preset, et c'est le seul chemin. /api/models/use
disparaît avec le groupe qu'il servait.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RoKfYRvVHTf8GyKhgY5WxT
This commit is contained in:
Claude committed 2026-09-09 08:53:36 +00:00
1 parent 54468b3336
commit 06f0e74c77
10 files changed
+637 -152

No files matched your search

+107 -4
View File
@@ -219,20 +219,123 @@ func deleteModelFile(name string) error {
return nil
}
// handleModelDelete deletes a single .gguf from LOKI_HOME.
// modelSizeOnDisk additionne les octets qu'un modèle occupe RÉELLEMENT — la
// famille de tranches entière, pas le seul fichier nommé. C'est ce chiffre
// qu'on annonce après une suppression : « 42 Go libérés » pour un modèle
// découpé, et non les 15 Go de sa première tranche.
func modelSizeOnDisk(p string) int64 {
return shardFamilySize(filepath.Dir(p), filepath.Base(p))
}
// modelIsLoaded dit si p est le modèle de la configuration ACTIVE, celui que
// llama-server a ouvert. L'effacer sous ses pieds ne libère rien tant que le
// processus tient le descripteur, et le prochain démarrage meurt sur un fichier
// introuvable : on refuse, et on renvoie vers un changement de preset.
func modelIsLoaded(p string) bool {
cur := strings.TrimSpace(ReadConfig()["MODEL"])
if cur == "" {
return false
}
q, err := resolveServeModelPath(cur)
if err != nil {
return false
}
return samePath(p, q)
}
// modelPresetRefs mappe chaque .gguf référencé par un preset (MODEL= ou
// MMPROJ=, chemin normalisé) vers les noms des presets qui le réclament. Calculé
// UNE fois par listage : sans ça, chercher les références d'un modèle à la fois
// relisait tous les presets pour chaque fichier du disque.
func modelPresetRefs() map[string][]string {
refs := map[string][]string{}
list, err := ListPresets()
if err != nil {
return refs
}
for _, pr := range list {
content, err := os.ReadFile(pr.Path)
if err != nil {
continue
}
env := parseEnv(string(content))
for _, ref := range []string{env["MODEL"], env["MMPROJ"]} {
if strings.TrimSpace(ref) == "" {
continue
}
q, err := resolveServeModelPath(ref)
if err != nil {
continue
}
k := normDir(q)
// Un preset qui pointe le même fichier en MODEL et en MMPROJ ne compte
// qu'une fois.
if n := len(refs[k]); n > 0 && refs[k][n-1] == pr.Name {
continue
}
refs[k] = append(refs[k], pr.Name)
}
}
return refs
}
// presetsUsingModel liste les noms d'affichage des presets dont MODEL= ou
// MMPROJ= désigne p. Supprimer un .gguf ne casse pas qu'un fichier : chaque
// preset qui le référence devient un moteur qui meurt au démarrage. On les
// nomme AVANT, dans la confirmation, plutôt que de laisser la découverte au
// prochain switch.
func presetsUsingModel(p string) []string {
return modelPresetRefs()[normDir(p)]
}
// handleModelDelete supprime un .gguf (et toutes ses tranches) d'un dossier de
// modèles déclaré.
//
// Deux garde-fous, parce que rien n'est réversible ici et qu'un modèle pèse des
// dizaines de gigaoctets :
// - le modèle CHARGÉ est intouchable (modelIsLoaded) ;
// - un modèle référencé par des presets ne part qu'avec `force`, l'appelant
// ayant reçu leur liste dans la réponse 409 pour la poser à l'utilisateur.
func handleModelDelete(w http.ResponseWriter, r *http.Request) {
var req struct {
Name string `json:"name"`
Name string `json:"name"`
Force bool `json:"force"`
}
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
sendJSON(w, 400, map[string]any{"ok": false, "error": err.Error()})
return
}
if err := deleteModelFile(req.Name); err != nil {
p, err := resolveModelPath(req.Name)
if err != nil {
sendJSON(w, 400, map[string]any{"ok": false, "error": err.Error()})
return
}
sendJSON(w, 200, map[string]any{"ok": true})
if st, err := os.Stat(p); err != nil || st.IsDir() {
sendJSON(w, 404, map[string]any{"ok": false, "error": "modèle introuvable : " + filepath.Base(p)})
return
}
if modelIsLoaded(p) {
sendJSON(w, 409, map[string]any{"ok": false, "loaded": true,
"error": "modèle chargé par le moteur — bascule d'abord sur un autre preset"})
return
}
used := presetsUsingModel(p)
if len(used) > 0 && !req.Force {
sendJSON(w, 409, map[string]any{"ok": false, "presets": used, "needsForce": true,
"error": "modèle utilisé par un preset"})
return
}
freed := modelSizeOnDisk(p)
if err := deleteModelFile(p); err != nil {
sendJSON(w, 400, map[string]any{"ok": false, "error": err.Error()})
return
}
fmt.Printf("%s modèle supprimé : %s\n", green("[ok]"), p)
resp := map[string]any{"ok": true, "freed": freed}
if len(used) > 0 {
resp["presets"] = used // presets cassés par la suppression forcée
}
sendJSON(w, 200, resp)
}
// ---- Hugging Face downloads -------------------------------------------------