Files
Loki/internal/loki/backend_models_refs_test.go
T
MichaelandClaude Opus 5.5 3ff56e01db Moteur : corrections de relecture du MTP opt-in — le contrôle après chargement voit enfin le journal, et un second lancement ne prend plus le jeton du premier
La relecture de 3a6ad6f a trouvé quatre trous, aucun ne touche la sortie du
modèle ; tous font que le garde-fou se trompe de verdict.

- Couches en RAM : lastOffload repartait de la dernière ligne contenant
  « load_model » — or llama-server écrit « load_model: initializing… » APRÈS
  le chargement, et « loading model tensors » aussi pour le brouillon. Le
  contrôle ne trouvait jamais rien, ou ne regardait que le brouillon. Repère
  désormais la ligne « loading model '<chemin>' » du serveur, et la pire des
  lignes offloaded compte (modèle ou brouillon).
- Jeton de tentative : il était consommé (et l'échec inscrit) AVANT le test du
  port. Un second « loki serve » refusé pendant que le premier chargeait
  marquait la configuration comme ratée. Lecture d'abord (specAutoPeek), puis
  rangement une fois le port libre (specAutoSettle).
- Process web : il effaçait le jeton sans le relire sous verrou, donc parfois
  celui d'un lancement plus récent. specAttemptClear ne touche qu'au sien.
- MODEL_DRAFT vers une tête EAGLE-3 ou dFlash : Loki imposait draft-simple, que
  le moteur ne peut pas charger. La note renvoie maintenant à -md et
  --spec-type dans EXTRA_ARGS.
- Une tête MTP choisie comme MODEL_DRAFT compte comme modèle utilisé (« utilisé
  par », garde à la suppression).
- Éditeur : choisir un brouillon sur un ancien preset dont le MTP est en
  --spec-type dans EXTRA_ARGS fait passer le preset à SPEC=mtp. Sans ça, le
  brouillon était ignoré.
- Tests : un journal réel (initializing après chargement, brouillon chargé à
  part), un second lancement qui ne fait que lire, le jeton d'un autre
  lancement gardé, les têtes eagle3/dflash, et MODEL_DRAFT dans les références.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-04 00:16:41 +02:00

255 lines
9.2 KiB
Go

package loki
import (
"encoding/json"
"net/http"
"net/http/httptest"
"os"
"path/filepath"
"strings"
"testing"
)
// writeModel pose un .gguf factice dans dir et renvoie son chemin.
func writeModel(t *testing.T, dir, name string, size int) string {
t.Helper()
if err := os.MkdirAll(dir, 0o755); err != nil {
t.Fatal(err)
}
p := filepath.Join(dir, name)
if err := os.WriteFile(p, make([]byte, size), 0o644); err != nil {
t.Fatal(err)
}
return p
}
// Le scénario complet du rapport : un modèle téléchargé depuis un preset, le
// preset supprimé sans son .gguf, et plus rien pour reprendre le fichier.
// Après ce correctif il doit être A) retrouvé par le sélecteur, B) effaçable.
func TestModeleOrphelinResteUtilisableEtEffaçable(t *testing.T) {
testHome(t)
writeModel(t, modelsDir(), "modele.gguf", 1024)
// A) Présent sur le disque, sous la valeur qu'on écrira dans MODEL=.
value, path, ok := modelPresence("modele.gguf", "")
if !ok {
t.Fatal("modèle présent non détecté — le téléchargement resterait un cul-de-sac")
}
if value != "modele.gguf" {
t.Fatalf("valeur MODEL= = %q, attendu le simple nom de fichier", value)
}
if path != filepath.Join(modelsDir(), "modele.gguf") {
t.Fatalf("chemin = %q", path)
}
// B) Plus personne ne le référence : il s'efface sans force.
users, active := modelUsers("modele.gguf")
if active || len(users) != 0 {
t.Fatalf("modèle orphelin annoncé utilisé : actif=%v presets=%v", active, users)
}
freed, err := deleteModelFile("modele.gguf")
if err != nil {
t.Fatalf("suppression refusée : %v", err)
}
if freed != 1024 {
t.Fatalf("octets libérés = %d, attendu 1024", freed)
}
if _, err := os.Stat(path); !os.IsNotExist(err) {
t.Fatal("le fichier est toujours là")
}
}
// Le modèle que le moteur a ouvert ne s'efface pas : la place ne serait même pas
// rendue, et le prochain démarrage mourrait sur un .gguf absent.
func TestSuppressionRefuseeSurLeModeleEnService(t *testing.T) {
testHome(t)
writeModel(t, modelsDir(), "servi.gguf", 8)
if err := WriteConfig(map[string]string{"MODEL": "servi.gguf"}); err != nil {
t.Fatal(err)
}
if _, active := modelUsers("servi.gguf"); !active {
t.Fatal("modèle de la configuration active non reconnu")
}
if _, err := deleteModelFile("servi.gguf"); err == nil {
t.Fatal("le modèle en service a été supprimé")
}
if _, err := os.Stat(filepath.Join(modelsDir(), "servi.gguf")); err != nil {
t.Fatal("fichier supprimé malgré le refus")
}
}
// Un modèle nommé par des presets n'est pas bloqué : il est SIGNALÉ, et
// force:true tranche. Refuser tout court recréerait l'impasse qu'on corrige.
func TestSuppressionSignaleLesPresetsPuisObeitAForce(t *testing.T) {
testHome(t)
p := writeModel(t, modelsDir(), "partage.gguf", 16)
if _, err := SavePreset("", "Rapide", "MODEL=\"partage.gguf\"\nCTX=4096\n"); err != nil {
t.Fatal(err)
}
if _, err := SavePreset("", "Long", "MODEL=\"partage.gguf\"\nCTX=131072\n"); err != nil {
t.Fatal(err)
}
users, active := modelUsers("partage.gguf")
if active {
t.Fatal("aucune configuration active ne devrait référencer ce modèle")
}
if len(users) != 2 {
t.Fatalf("presets référents = %v, attendu les deux", users)
}
post := func(body string) (int, map[string]any) {
r := httptest.NewRequest("POST", "/api/models/delete", strings.NewReader(body))
w := httptest.NewRecorder()
handleModelDelete(w, r)
var out map[string]any
_ = json.Unmarshal(w.Body.Bytes(), &out)
return w.Code, out
}
code, out := post(`{"name":"partage.gguf"}`)
if code != 409 {
t.Fatalf("code = %d, attendu 409 (presets référents)", code)
}
if len(out["used"].([]any)) != 2 {
t.Fatalf("la réponse doit NOMMER les presets concernés : %v", out)
}
if _, err := os.Stat(p); err != nil {
t.Fatal("fichier supprimé alors que la confirmation n'a pas eu lieu")
}
if code, out = post(`{"name":"partage.gguf","force":true}`); code != 200 {
t.Fatalf("code = %d avec force:true — %v", code, out)
}
if _, err := os.Stat(p); !os.IsNotExist(err) {
t.Fatal("force:true n'a pas supprimé le fichier")
}
}
// MMPROJ et un --mmproj resté dans EXTRA_ARGS comptent aussi : effacer le
// projecteur du modèle en service casse la vision sans rien dire.
func TestProjecteurCompteCommeReference(t *testing.T) {
testHome(t)
writeModel(t, modelsDir(), "mmproj-F16.gguf", 4)
writeModel(t, modelsDir(), "vieux-mmproj.gguf", 4)
if err := WriteConfig(map[string]string{"MODEL": "m.gguf", "MMPROJ": "mmproj-F16.gguf"}); err != nil {
t.Fatal(err)
}
if _, active := modelUsers("mmproj-F16.gguf"); !active {
t.Fatal("MMPROJ= non compté comme référence")
}
if _, err := SavePreset("", "Vision", `MODEL="m.gguf"`+"\n"+`EXTRA_ARGS="--mmproj vieux-mmproj.gguf --flash-attn"`+"\n"); err != nil {
t.Fatal(err)
}
users, _ := modelUsers("vieux-mmproj.gguf")
if len(users) != 1 || users[0] != "Vision" {
t.Fatalf("--mmproj d'EXTRA_ARGS non compté : %v", users)
}
// La tête MTP publiée à part aussi (MODEL_DRAFT).
writeModel(t, modelsDir(), "mtp-Qwen3.6-27B.gguf", 4)
if _, err := SavePreset("", "MTP", `MODEL="m.gguf"`+"\n"+`SPEC=auto`+"\n"+`MODEL_DRAFT="mtp-Qwen3.6-27B.gguf"`+"\n"); err != nil {
t.Fatal(err)
}
if users, _ := modelUsers("mtp-Qwen3.6-27B.gguf"); len(users) != 1 || users[0] != "MTP" {
t.Fatalf("MODEL_DRAFT= non compté : %v", users)
}
}
// Une famille de tranches incomplète n'est pas « déjà là » : il reste à
// télécharger, et prétendre le contraire donnerait un moteur qui meurt sur un
// tenseur introuvable.
func TestFamilleIncompleteNestPasPresente(t *testing.T) {
testHome(t)
writeModel(t, modelsDir(), "gros-00001-of-00002.gguf", 4)
if _, _, ok := modelPresence("gros-00001-of-00002.gguf", ""); ok {
t.Fatal("famille incomplète annoncée présente")
}
writeModel(t, modelsDir(), "gros-00002-of-00002.gguf", 4)
if _, _, ok := modelPresence("gros-00001-of-00002.gguf", ""); !ok {
t.Fatal("famille complète non détectée")
}
}
// Un modèle rangé dans un dossier déclaré ailleurs (disque externe) est trouvé
// lui aussi, et sous son chemin complet — un simple nom ne le désignerait pas.
func TestPresenceDansUnDossierDeclare(t *testing.T) {
home := testHome(t)
ext := filepath.Join(home, "externe")
writeModel(t, ext, "ailleurs.gguf", 32)
if _, _, ok := modelPresence("ailleurs.gguf", ""); ok {
t.Fatal("dossier non déclaré : le modèle ne doit pas être visible")
}
if err := saveExtraModelDirs([]string{ext}); err != nil {
t.Fatal(err)
}
value, _, ok := modelPresence("ailleurs.gguf", "")
if !ok {
t.Fatal("modèle d'un dossier déclaré non détecté")
}
if value != filepath.Join(ext, "ailleurs.gguf") {
t.Fatalf("valeur MODEL= = %q, attendu le chemin complet", value)
}
}
// /api/models doit marquer « dossier loki » le dossier de téléchargement et en
// donner le SIMPLE NOM : c'est ce que resolveModelPath retrouve, et c'est ce qui
// permet au sélecteur de reconnaître un MODEL=modele.gguf écrit à la main.
func TestListeModelesNommeLeDossierDeLoki(t *testing.T) {
testHome(t)
writeModel(t, modelsDir(), "local.gguf", 64)
if err := WriteConfig(map[string]string{"MODEL": "local.gguf"}); err != nil {
t.Fatal(err)
}
w := httptest.NewRecorder()
handleModels(w, httptest.NewRequest("GET", "/api/models", nil))
var out []map[string]any
if err := json.Unmarshal(w.Body.Bytes(), &out); err != nil {
t.Fatal(err)
}
if len(out) != 1 {
t.Fatalf("liste = %v", out)
}
if out[0]["home"] != true {
t.Fatal("le dossier de téléchargement n'est pas marqué « dossier loki »")
}
if out[0]["value"] != "local.gguf" {
t.Fatalf("value = %v, attendu le simple nom de fichier", out[0]["value"])
}
if out[0]["active"] != true {
t.Fatal("le modèle en service doit être signalé comme tel")
}
}
// Télécharger un modèle DÉJÀ sur le disque n'est pas une erreur : c'est le
// résultat voulu. Les deux routes doivent répondre « ok, exists » avec la valeur
// à écrire dans MODEL= — sinon l'interface s'arrête en rouge sur « le modèle
// existe déjà » et il devient impossible de refaire un preset dessus.
func TestTelechargerUnModelePresentLeSelectionne(t *testing.T) {
testHome(t)
writeModel(t, modelsDir(), "deja.gguf", 2048)
const url = "https://huggingface.co/auteur/depot/resolve/main/deja.gguf"
call := func(h func(w http.ResponseWriter, r *http.Request), path string) (int, map[string]any) {
body := `{"url":"` + url + `","dir":""}`
w := httptest.NewRecorder()
h(w, httptest.NewRequest("POST", path, strings.NewReader(body)))
var out map[string]any
_ = json.Unmarshal(w.Body.Bytes(), &out)
return w.Code, out
}
// La sonde ne doit même pas sortir sur le réseau : elle répond depuis le disque.
code, out := call(handleModelDownloadProbe, "/api/models/download/probe")
if code != 200 || out["ok"] != true || out["exists"] != true {
t.Fatalf("sonde : code=%d out=%v", code, out)
}
if out["value"] != "deja.gguf" {
t.Fatalf("sonde : value = %v, attendu le nom à mettre dans MODEL=", out["value"])
}
code, out = call(handleModelDownload, "/api/models/download")
if code != 200 || out["ok"] != true || out["exists"] != true {
t.Fatalf("téléchargement : code=%d out=%v", code, out)
}
if out["value"] != "deja.gguf" {
t.Fatalf("téléchargement : value = %v", out["value"])
}
}