Files
Loki/internal/loki/parity_models_test.go
T
Claude a33e5ccffe Lot 8 : toutes les routes des modèles tenues par la façade (LOKI_MODELS=rust)
La variable LOKI_DOWNLOADS devient LOKI_MODELS et couvre, en plus des
téléchargements, la liste du sélecteur (avec qui référence chaque .gguf),
la suppression (refus si en service, 409 avec les presets sans force,
tranches emportées), les dossiers de modèles et le catalogue Hugging Face
(recherche, fichiers d'un dépôt avec verdict mémoire, jeton vérifié puis
enregistré, cache vidé à son changement).

Côté Go, facade_models.go remet ces routes à la façade quand elles
arrivent par le tunnel du relais.

Parité : parity_models_test.go tire du Go les fonctions pures du catalogue
et un scénario sur disque de 20 appels (liste, dossiers, suppressions),
rejoués à l'identique par tests/models.rs ; catalogue et routes testés de
bout en bout contre un faux Hugging Face.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016tcGfJWefW5kUGoC2wL8cv
2026-10-15 13:25:45 +00:00

352 lines
11 KiB
Go

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)
}
}