From bbea5fcd3f0bd8f8f0be2049f42070d9e32b5861 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 15 Oct 2026 13:11:07 +0000 Subject: [PATCH 01/52] =?UTF-8?q?Lot=208=20:=20t=C3=A9l=C3=A9chargements?= =?UTF-8?q?=20de=20mod=C3=A8les=20tenus=20par=20la=20fa=C3=A7ade=20(LOKI?= =?UTF-8?q?=5FDOWNLOADS=3Drust)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit La façade Rust mène les téléchargements de .gguf quand LOKI_DOWNLOADS=rust (expérimental ; le Go reste le défaut) : sonde, lancement, suivi, annulation, avec les mêmes contrats et la même mécanique que le Go (tranches rapatriées en entier, plages d'octets parallèles, reprise, place disque vérifiée sur le total, jeton réservé aux hôtes Hugging Face, refus traduits, .part vidés au démarrage). Côté Go : facade_downloads.go remet ces routes à la façade quand elles arrivent par le tunnel du relais, et le Go ne vide plus les .part qu'elle mène. Parité : journal des fonctions pures tiré du Go (parity_dl_test.go) et rejoué en Rust ; téléchargements de bout en bout contre un faux Hugging Face (tests/downloads.rs). Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_016tcGfJWefW5kUGoC2wL8cv --- README.md | 1 + ...t-migration-lot8-telechargements-design.md | 76 + internal/loki/facade_chat.go | 23 +- internal/loki/facade_chat_test.go | 75 + internal/loki/facade_downloads.go | 45 + internal/loki/parity_dl_test.go | 182 +++ internal/loki/web_server.go | 9 +- rust/Cargo.lock | 1 + rust/loki-rs/Cargo.toml | 1 + rust/loki-rs/src/api.rs | 3 + rust/loki-rs/src/chat/engine.rs | 5 +- rust/loki-rs/src/downloads.rs | 1306 +++++++++++++++++ rust/loki-rs/src/lib.rs | 1 + rust/loki-rs/src/main.rs | 45 + rust/loki-rs/src/proxy.rs | 15 +- rust/loki-rs/tests/common/fakego.rs | 41 +- rust/loki-rs/tests/downloads.rs | 692 +++++++++ rust/loki-rs/tests/lot2.rs | 1 + .../loki-rs/tests/parity_models/download.json | 314 ++++ rust/loki-rs/tests/proxy.rs | 1 + 20 files changed, 2820 insertions(+), 17 deletions(-) create mode 100644 docs/superpowers/specs/2026-10-15-rust-migration-lot8-telechargements-design.md create mode 100644 internal/loki/facade_downloads.go create mode 100644 internal/loki/parity_dl_test.go create mode 100644 rust/loki-rs/src/downloads.rs create mode 100644 rust/loki-rs/tests/downloads.rs create mode 100644 rust/loki-rs/tests/parity_models/download.json diff --git a/README.md b/README.md index 9b4fec9..98a1a9e 100644 --- a/README.md +++ b/README.md @@ -228,6 +228,7 @@ redémarrages (volume `/data`). Variables d'environnement du conteneur : | `LOKI_FACADE` | `0` : sans façade Rust, le Go sert directement le port public (porte de sortie) | `1` | | `LOKI_GO_PORT` | port local (127.0.0.1) du serveur Go derrière la façade | `8091` | | `LOKI_CHAT` | `rust` : la conversation est tenue par la façade Rust (expérimental, migration en cours), outils fichiers et shell compris. Le Go garde les autres outils, les pièces jointes, les fichiers et l'export. Avec `PROJ_SNAPSHOT`, `KEEP_TURN_IMAGES`, `NUDGE_IN_TOOL`, `SIDE_SLOT`, `SLOT_PERSIST`, `PREWARM` ou des motifs `retry_patterns`, le Go garde la conversation (décidé au démarrage) | Go | +| `LOKI_DOWNLOADS` | `rust` : les téléchargements de modèles (`/api/models/download*`) sont menés par la façade Rust (expérimental, migration en cours). Le Go garde les autres routes des modèles | Go | **Accès par nom de domaine.** Sans clé de pilotage, l'API n'accepte que les hôtes locaux (IP, `localhost`, nom sans point, `.local`/`.lan`…) et refuse diff --git a/docs/superpowers/specs/2026-10-15-rust-migration-lot8-telechargements-design.md b/docs/superpowers/specs/2026-10-15-rust-migration-lot8-telechargements-design.md new file mode 100644 index 0000000..4d74c70 --- /dev/null +++ b/docs/superpowers/specs/2026-10-15-rust-migration-lot8-telechargements-design.md @@ -0,0 +1,76 @@ +# Lot 8 — téléchargements de modèles tenus par la façade + +## Contexte + +Les téléchargements de `.gguf` (Hugging Face ou lien direct) vivaient dans le +Go : `backend_models.go`, `backend_models_shards.go`, `backend_model_dirs.go`, +`backend_hf.go`. Lot 8 : la façade les tient quand `LOKI_DOWNLOADS=rust` +(expérimental ; sans la variable rien ne change, tout part au Go). + +## Ce que sert la façade (`rust/loki-rs/src/downloads.rs`) + +| Route | Rôle | +|---|---| +| `POST /api/models/download/probe` | taille du modèle (toutes ses tranches), espace libre, « ça tient ? », ou « déjà là » avec la valeur de `MODEL=` | +| `POST /api/models/download` | lance en arrière-plan ; 409 si déjà en cours ; « déjà là » si toutes les tranches sont présentes | +| `GET /api/models/download/status` | état de chaque téléchargement de ce lancement | +| `POST /api/models/download/cancel` | annule ; ce que le téléchargement a écrit est effacé | + +Mêmes contrats (corps, codes, messages) et même mécanique que le Go : + +- lien de page Hugging Face (`/blob/`) converti en lien direct ; +- modèle en tranches rapatrié en entier (la première le désigne) ; +- tranches déjà là non retéléchargées ; +- place disque vérifiée sur le total, avec 256 Mio de marge ; jamais de refus + sur FUSE, overlayfs, NFS, SMB, Ceph ou GlusterFS, où l'espace annoncé + approxime ; +- jusqu'à `LOKI_DL_CONNS` connexions (8 par défaut, 16 au plus) par tranche + quand la source honore les plages, 16 Mio par connexion au moins ; +- connexion coupée reprise où elle en était (4 essais) ; +- progression sur l'ensemble, vitesse lissée ; +- jeton Hugging Face (réglage, sinon `HF_TOKEN`) envoyé aux seuls hôtes + Hugging Face ; +- refus de la source traduits en phrase qui dit quoi faire (dépôt verrouillé, + jeton absent ou insuffisant, fichier absent, quota…) ; +- `.part` orphelins vidés au démarrage — par la façade : le Go ne le fait + plus, il effacerait un téléchargement qu'elle mène. + +## Côté Go (`facade_downloads.go`) + +Le tunnel du relais et la redistribution E2E arrivent au Go sans passer par +la façade : `withFacadeChat` lui remet les quatre routes, authentifiées par le +jeton interne, comme celles du chat. Façade injoignable : 502 +« téléchargements indisponibles : … ». + +La façade transmet `LOKI_DOWNLOADS=rust` et son adresse au Go qu'elle lance. + +## Écarts assumés + +- Une source qui ignore les plages (200 à la sonde) n'est plus lue en entier + pendant la sonde. +- Erreur de transport (réseau, TLS) au format de reqwest. +- Hôte d'un lien collé en majuscules converti comme les autres. +- Espace libre inconnu hors Unix : rien n'est refusé (comme une mesure ratée + côté Go). +- `/status` trié par date de début (le Go rendait l'ordre d'une map). + +## Parité + +`internal/loki/parity_dl_test.go` tire du Go le journal des fonctions pures +(lien normalisé, famille de tranches, `Content-Range`, messages de refus, +hôtes et dépôts Hugging Face, dossier de destination), rangé dans +`rust/loki-rs/tests/parity_models/download.json` ; `tests/downloads.rs` le +rejoue (`LOKI_PARITY_UPDATE=1` pour régénérer). Une mutation +(`replacen` → `replace` sur `/blob/`) fait échouer le rejeu. + +`tests/downloads.rs` mène aussi le téléchargement de bout en bout contre un +faux Hugging Face : trois connexions sur des plages disjointes, source sans +plages, tranches avec une déjà présente, dépôt verrouillé refusé dès la sonde, +panne en cours de deuxième tranche, annulation, routes par la façade (clé de +pilotage, remise du Go), routes laissées au Go sans la variable. + +## Reste + +- Les autres routes des modèles (`/api/models`, suppression, dossiers, liste + Hugging Face, vision) restent au Go. +- Essai réel sur une installation avant d'en faire le défaut. diff --git a/internal/loki/facade_chat.go b/internal/loki/facade_chat.go index 1bd95de..225c809 100644 --- a/internal/loki/facade_chat.go +++ b/internal/loki/facade_chat.go @@ -141,23 +141,30 @@ var facadeChatRoutes = map[string]bool{ } // withFacadeChat remet la requête à la façade quand elle tient la -// conversation. Posé APRÈS requireWebAuth : seule une requête authentifiée -// ici part, avec le jeton interne qui en tient lieu là-bas. +// conversation — ou les téléchargements de modèles (facade_downloads.go). +// Posé APRÈS requireWebAuth : seule une requête authentifiée ici part, avec le +// jeton interne qui en tient lieu là-bas. func withFacadeChat(path string, h http.HandlerFunc) http.HandlerFunc { - if !facadeChatRoutes[path] { + held, unavailable := facadeChat, "conversation indisponible : " + switch { + case facadeChatRoutes[path]: + case facadeDownloadRoutes[path]: + held, unavailable = facadeDownloads, "téléchargements indisponibles : " + default: return h } return func(w http.ResponseWriter, r *http.Request) { - if !facadeChat() { + if !held() { h(w, r) return } - facadeRelay(w, r) + facadeRelayAs(w, r, unavailable) } } -// facadeRelay : relais transparent (flux compris) vers la façade. -func facadeRelay(w http.ResponseWriter, r *http.Request) { +// facadeRelayAs : relais transparent (flux compris) vers la façade ; +// unavailable préfixe l'erreur si elle ne répond pas. +func facadeRelayAs(w http.ResponseWriter, r *http.Request, unavailable string) { rp := &httputil.ReverseProxy{ Rewrite: func(pr *httputil.ProxyRequest) { pr.SetURL(&url.URL{Scheme: "http", Host: facadeAddr()}) @@ -165,7 +172,7 @@ func facadeRelay(w http.ResponseWriter, r *http.Request) { }, FlushInterval: -1, // SSE : chaque trame part aussitôt ErrorHandler: func(w http.ResponseWriter, _ *http.Request, err error) { - sendJSON(w, http.StatusBadGateway, map[string]any{"ok": false, "error": "conversation indisponible : " + err.Error()}) + sendJSON(w, http.StatusBadGateway, map[string]any{"ok": false, "error": unavailable + err.Error()}) }, } rp.ServeHTTP(w, r) diff --git a/internal/loki/facade_chat_test.go b/internal/loki/facade_chat_test.go index 70282f1..9b8ee5d 100644 --- a/internal/loki/facade_chat_test.go +++ b/internal/loki/facade_chat_test.go @@ -5,6 +5,8 @@ import ( "io" "net/http" "net/http/httptest" + "os" + "path/filepath" "strings" "sync" "testing" @@ -303,3 +305,76 @@ func TestFacadeReglageQuiGardeLaConversation(t *testing.T) { t.Fatalf("sans la variable : %s", w.Body.String()) } } + +// LOKI_DOWNLOADS=rust : les routes des téléchargements de modèles partent à la +// façade (avec ou sans LOKI_CHAT), les autres routes des modèles restent ici ; +// sans la variable, rien ne change. Façade muette : erreur qui le dit. +func TestFacadeRelaieLesTelechargements(t *testing.T) { + testHome(t) + f := demarrerFacade(t) + t.Setenv(facadeChatEnv, "") + t.Setenv(facadeDownloadsEnv, "rust") + local := func(w http.ResponseWriter, r *http.Request) { io.WriteString(w, "go") } + + for _, p := range []string{"/api/models/download", "/api/models/download/probe", + "/api/models/download/status", "/api/models/download/cancel"} { + w := httptest.NewRecorder() + withFacadeChat(p, local)(w, httptest.NewRequest("POST", p, strings.NewReader(`{"url":"u"}`))) + if w.Body.String() != f.reply { + t.Fatalf("%s : réponse %q", p, w.Body.String()) + } + } + if got := f.recu(); len(got) != 4 || got[0] != `POST /api/models/download jeton {"url":"u"}` { + t.Fatalf("reçu par la façade : %v", got) + } + // Le chat, lui, reste ici (LOKI_CHAT absent), et les autres routes des + // modèles aussi. + for _, p := range []string{"/api/chat/send", "/api/models", "/api/models/delete"} { + w := httptest.NewRecorder() + withFacadeChat(p, local)(w, httptest.NewRequest("POST", p, nil)) + if w.Body.String() != "go" { + t.Fatalf("%s relayé : %q", p, w.Body.String()) + } + } + // Façade injoignable : 502 qui nomme ce qui manque. + f.srv.Close() + w := httptest.NewRecorder() + withFacadeChat("/api/models/download/status", local)(w, httptest.NewRequest("GET", "/api/models/download/status", nil)) + if w.Code != http.StatusBadGateway || !strings.Contains(w.Body.String(), "téléchargements indisponibles : ") { + t.Fatalf("façade muette : %d %s", w.Code, w.Body.String()) + } + // Sans LOKI_DOWNLOADS=rust : tout reste ici. + t.Setenv(facadeDownloadsEnv, "") + w = httptest.NewRecorder() + withFacadeChat("/api/models/download", local)(w, httptest.NewRequest("POST", "/api/models/download", nil)) + if w.Body.String() != "go" { + t.Fatalf("relayé sans la variable : %q", w.Body.String()) + } +} + +// Les .part orphelins ne sont plus vidés par ce Go quand la façade tient les +// téléchargements : elle les vide à son démarrage, et un Go relancé seul +// effacerait celui qu'elle mène. +func TestFacadeTelechargementsGardeLesPart(t *testing.T) { + home := testHome(t) + dir := filepath.Join(home, "models") + if err := os.MkdirAll(dir, 0o755); err != nil { + t.Fatal(err) + } + part := filepath.Join(dir, "m.gguf.part") + if err := os.WriteFile(part, []byte("x"), 0o644); err != nil { + t.Fatal(err) + } + t.Setenv(internalTokenEnv, jetonTest) + t.Setenv(facadeAddrEnv, "127.0.0.1:1") + t.Setenv(facadeDownloadsEnv, "rust") + cleanStartupPartFiles() + if _, err := os.Stat(part); err != nil { + t.Fatalf(".part effacé alors que la façade tient les téléchargements : %v", err) + } + t.Setenv(facadeDownloadsEnv, "") + cleanStartupPartFiles() + if _, err := os.Stat(part); !os.IsNotExist(err) { + t.Fatalf(".part orphelin gardé : %v", err) + } +} diff --git a/internal/loki/facade_downloads.go b/internal/loki/facade_downloads.go new file mode 100644 index 0000000..c35e074 --- /dev/null +++ b/internal/loki/facade_downloads.go @@ -0,0 +1,45 @@ +package loki + +import ( + "os" + "strings" +) + +// facade_downloads.go — téléchargements de modèles tenus par la façade Rust +// (LOKI_DOWNLOADS=rust, lot « modèles » de la migration ; expérimental, le Go +// reste le défaut). +// +// La façade sert elle-même /api/models/download* sur le port public. Le tunnel +// du relais et sa redistribution E2E arrivent pourtant ICI : ces routes lui +// sont remises, sans quoi un téléchargement lancé depuis le téléphone vivrait +// dans ce process, que le poste — servi par la façade — ne verrait pas. +// +// Les .part orphelins sont vidés par la façade à son démarrage : les vider ici +// (un Go relancé seul après un plantage) effacerait sous ses pieds un +// téléchargement qu'elle mène. + +const facadeDownloadsEnv = "LOKI_DOWNLOADS" + +// facadeDownloads : la façade tient-elle les téléchargements de modèles ? Il +// faut aussi son adresse et le jeton interne, sans lesquels rien ne peut lui +// être remis. +func facadeDownloads() bool { + return strings.EqualFold(strings.TrimSpace(os.Getenv(facadeDownloadsEnv)), "rust") && + facadeAddr() != "" && internalToken() != "" +} + +// cleanStartupPartFiles vide les .part orphelins au démarrage — sauf quand la +// façade tient les téléchargements : elle le fait à SON démarrage. +func cleanStartupPartFiles() { + if !facadeDownloads() { + cleanStalePartFiles() + } +} + +// facadeDownloadRoutes : les routes que la façade sert alors (downloads.rs). +var facadeDownloadRoutes = map[string]bool{ + "/api/models/download": true, + "/api/models/download/probe": true, + "/api/models/download/status": true, + "/api/models/download/cancel": true, +} diff --git a/internal/loki/parity_dl_test.go b/internal/loki/parity_dl_test.go new file mode 100644 index 0000000..784c577 --- /dev/null +++ b/internal/loki/parity_dl_test.go @@ -0,0 +1,182 @@ +package loki + +import ( + "net/http" + "os" + "path/filepath" + "runtime" + "strings" + "testing" +) + +// parity_dl_test.go — journal de référence des fonctions pures des +// téléchargements de modèles (lot « modèles » de la migration Rust) : lien +// collé normalisé, famille de tranches, messages d'erreur de la source, +// dossier de destination. Rangé dans +// rust/loki-rs/tests/parity_models/download.json ; le test Rust +// (tests/downloads.rs) doit retrouver chaque sortie. LOKI_HOME réécrit en +// {{HOME}}. +// +// LOKI_PARITY_UPDATE=1 go test ./internal/loki -run TestPariteTelechargements + +const parityDLFile = "../../rust/loki-rs/tests/parity_models/download.json" + +type parityDLNorm struct { + In string `json:"in"` + URL string `json:"url,omitempty"` + Name string `json:"name,omitempty"` + Err string `json:"err,omitempty"` + URLs []string `json:"urls,omitempty"` + // Names : noms de la famille de tranches (shardURLSet). + Names []string `json:"names,omitempty"` +} + +type parityDLSource struct { + Status int `json:"status"` + Code string `json:"code"` + URL string `json:"url"` + TokenSet bool `json:"token_set"` + Err string `json:"err"` +} + +type parityDLDest struct { + Name string `json:"name"` + Dir string `json:"dir"` + Path string `json:"path,omitempty"` + Err string `json:"err,omitempty"` +} + +type parityDLFixture struct { + Normalize []parityDLNorm `json:"normalize"` + ContentRange map[string]int64 `json:"content_range"` + Source []parityDLSource `json:"source"` + HFHost map[string]bool `json:"hf_host"` + HFRepo map[string]string `json:"hf_repo"` + Dest []parityDLDest `json:"dest"` +} + +func TestPariteTelechargements(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 parityDLFixture + for _, in := range []string{ + "", " ", "ftp://h/a.gguf", "pas un lien", "https://exemple.org/dossier/", + "https://huggingface.co/u/r/blob/main/m.gguf", + "https://huggingface.co/u/r/blob/main/blob/m.gguf", + "https://huggingface.co/u/r/resolve/main/m-Q4_K_M.gguf?download=true", + " https://huggingface.co/u/r/blob/main/m.bin ", + "http://exemple.org/modeles/M.GGUF", + "https://huggingface.co/u/r/blob/main/Q4/m-Q4-00002-of-00003.gguf", + "https://huggingface.co/u/r/resolve/main/m-00001-of-00012.gguf", + "https://exemple.org/m-1-of-2.gguf", + "https://exemple.org/m-02-of-01.gguf", + } { + c := parityDLNorm{In: in} + u, name, err := normalizeHFURL(in) + if err != nil { + c.Err = err.Error() + } else { + c.URL, c.Name = u, name + c.URLs, c.Names = shardURLSet(u, name) + } + fx.Normalize = append(fx.Normalize, c) + } + + fx.ContentRange = map[string]int64{} + for _, v := range []string{"bytes 0-0/12345", "bytes 0-0/*", "", "bytes 0-0/0", "bytes 0-0/ 99 "} { + fx.ContentRange[v] = contentRangeTotal(v) + } + + hf := "https://huggingface.co/auteur/depot/resolve/main/m.gguf" + for _, c := range []parityDLSource{ + {Status: 401, Code: "GatedRepo", URL: hf}, + {Status: 401, Code: "GatedRepo", URL: hf, TokenSet: true}, + {Status: 403, Code: "RepoNotFound", URL: hf}, + {Status: 401, Code: "GatedRepo", URL: "https://exemple.org/m.gguf"}, + {Status: 403, Code: "", URL: hf, TokenSet: true}, + {Status: 403, Code: "", URL: "https://exemple.org/m.gguf"}, + {Status: 404, Code: "EntryNotFound", URL: hf}, + {Status: 404, Code: "EntryNotFound", URL: "https://exemple.org/"}, + {Status: 404, Code: "RevisionNotFound", URL: hf}, + {Status: 404, Code: "", URL: hf}, + {Status: 416, URL: hf}, {Status: 429, URL: hf}, {Status: 502, URL: hf}, {Status: 418, URL: hf}, + } { + tok := "" + if c.TokenSet { + tok = "hf_x" + } + t.Setenv("HF_TOKEN", tok) + resp := &http.Response{StatusCode: c.Status, Header: http.Header{}} + if c.Code != "" { + resp.Header.Set("X-Error-Code", c.Code) + } + c.Err = dlSourceError(resp, c.URL).Error() + fx.Source = append(fx.Source, c) + } + t.Setenv("HF_TOKEN", "") + + fx.HFHost = map[string]bool{} + for _, h := range []string{"huggingface.co", "HUGGINGFACE.CO:443", "cdn-lfs.huggingface.co", "hf.co", "x.hf.co", + "evilhuggingface.co", "huggingface.co.evil.org", "exemple.org"} { + fx.HFHost[h] = isHFHost(h) + } + fx.HFRepo = map[string]string{} + for _, u := range []string{hf, "https://huggingface.co/a/b/blob/main/x.gguf", "https://huggingface.co/a/b/tree/main", + "https://huggingface.co/a/b", "https://exemple.org/a/b/resolve/main/x.gguf", "https://huggingface.co/-a/b/resolve/main/x.gguf"} { + fx.HFRepo[u] = hfRepoFromURL(u) + } + + extra := filepath.Join(home, "disque") + if err := os.MkdirAll(extra, 0o755); err != nil { + t.Fatal(err) + } + if err := saveExtraModelDirs([]string{extra}); err != nil { + t.Fatal(err) + } + for _, c := range []parityDLDest{ + {Name: "a.gguf"}, {Name: " ../../x.gguf "}, {Name: "x.bin"}, {Name: ""}, {Name: "/"}, + {Name: "a.gguf", Dir: "/etc"}, {Name: "a.gguf", Dir: "{{HOME}}/disque"}, {Name: "a.gguf", Dir: "{{HOME}}/disque/"}, + {Name: "a.gguf", Dir: "{{HOME}}/models"}, + } { + p, err := downloadDestPath(c.Name, strings.ReplaceAll(c.Dir, "{{HOME}}", home)) + if err != nil { + c.Err = norm(err.Error()) + } else { + c.Path = norm(p) + } + fx.Dest = append(fx.Dest, c) + } + + got, err := parityJSON(fx) + if err != nil { + t.Fatal(err) + } + if os.Getenv("LOKI_PARITY_UPDATE") == "1" { + if err := os.MkdirAll(filepath.Dir(parityDLFile), 0o755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(parityDLFile, got, 0o644); err != nil { + t.Fatal(err) + } + return + } + want, err := os.ReadFile(parityDLFile) + 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 téléchargements ne produisent plus le journal rangé dans %s.\n"+ + "Si c'est voulu : LOKI_PARITY_UPDATE=1 go test ./internal/loki -run TestPariteTelechargements,\n"+ + "puis cargo test --test downloads dit si le port Rust suit.\n--- obtenu ---\n%s", parityDLFile, got) + } +} diff --git a/internal/loki/web_server.go b/internal/loki/web_server.go index 4609ff9..99d3ffc 100644 --- a/internal/loki/web_server.go +++ b/internal/loki/web_server.go @@ -130,8 +130,9 @@ func newWebMux() *http.ServeMux { // secondes pour un serveur lancé via npx) est payé par le premier message. MCPPrewarm() // Un téléchargement de modèle coupé net (crash, restart du service) laisse un - // .part orphelin non reprenable : on nettoie au démarrage. - cleanStalePartFiles() + // .part orphelin non reprenable : on nettoie au démarrage (sauf quand la + // façade tient les téléchargements, facade_downloads.go). + cleanStartupPartFiles() // Les fichiers des versions précédentes vivaient dans un pot commun : on les // range dans le dossier de leur discussion (une fois par process). migrateConvFiles() @@ -218,7 +219,9 @@ func newWebMux() *http.ServeMux { mountOAI(mux) // api enregistre une route /api/* protégée par la clé de pilotage (web_auth.go). // withFacadeChat : routes du chat remises à la façade quand elle tient la - // conversation (LOKI_CHAT=rust, facade_chat.go) ; sans effet sinon. + // conversation (LOKI_CHAT=rust, facade_chat.go), celles des téléchargements + // de modèles quand elle les tient (LOKI_DOWNLOADS=rust, + // facade_downloads.go) ; sans effet sinon. api := func(path string, h http.HandlerFunc) { mux.HandleFunc(path, requireWebAuth(withFacadeChat(path, h))) } api("/api/ping", handlePing) api("/api/status", handleStatus) diff --git a/rust/Cargo.lock b/rust/Cargo.lock index 9df7f87..c003d08 100644 --- a/rust/Cargo.lock +++ b/rust/Cargo.lock @@ -686,6 +686,7 @@ dependencies = [ "tempfile", "tokio", "tokio-tungstenite", + "url", ] [[package]] diff --git a/rust/loki-rs/Cargo.toml b/rust/loki-rs/Cargo.toml index fcf2da2..191251e 100644 --- a/rust/loki-rs/Cargo.toml +++ b/rust/loki-rs/Cargo.toml @@ -13,6 +13,7 @@ http-body-util = "0.1.5" hyper = { version = "1.12.0", features = ["http1", "server", "client"] } hyper-util = { version = "0.1.21", features = ["tokio", "server", "client-legacy", "http1"] } regex = "1.13.1" +url = "2.5.8" reqwest = { version = "0.13.5", default-features = false, features = ["rustls-no-provider", "stream", "http2"] } rustls = { version = "0.23.46", default-features = false, features = ["ring", "std", "tls12"], optional = true } serde = { version = "1.0.229", features = ["derive"] } diff --git a/rust/loki-rs/src/api.rs b/rust/loki-rs/src/api.rs index 020a999..5caf66c 100644 --- a/rust/loki-rs/src/api.rs +++ b/rust/loki-rs/src/api.rs @@ -26,6 +26,9 @@ pub struct Ctx { pub engine: engine::Paths, /// Conversation tenue par la façade (`LOKI_CHAT=rust`) ; `None` : au Go. pub chat: Option>, + /// Téléchargements de modèles tenus par la façade (`LOKI_DOWNLOADS=rust`) ; + /// `None` : au Go. + pub downloads: Option>, /// Jeton de l'API interne : une requête qui le porte vient du Go. pub internal_token: String, } diff --git a/rust/loki-rs/src/chat/engine.rs b/rust/loki-rs/src/chat/engine.rs index fec61b5..205a9a2 100644 --- a/rust/loki-rs/src/chat/engine.rs +++ b/rust/loki-rs/src/chat/engine.rs @@ -208,8 +208,9 @@ impl EngineHttp { } } -/// Fournisseur cryptographique de rustls (ring), posé une fois. -fn install_crypto() { +/// Fournisseur cryptographique de rustls (ring), posé une fois (tout client +/// reqwest de la façade le demande avant d'être construit). +pub fn install_crypto() { #[cfg(feature = "tls")] let _ = rustls::crypto::ring::default_provider().install_default(); } diff --git a/rust/loki-rs/src/downloads.rs b/rust/loki-rs/src/downloads.rs new file mode 100644 index 0000000..08b84a5 --- /dev/null +++ b/rust/loki-rs/src/downloads.rs @@ -0,0 +1,1306 @@ +//! Téléchargements de modèles depuis Hugging Face (ou un lien direct) — port +//! des téléchargements de `backend_models.go`, `backend_models_shards.go`, +//! `backend_model_dirs.go` et `backend_hf.go` (lot 8 de la migration). +//! +//! Tenus par la façade quand `LOKI_DOWNLOADS=rust` (expérimental ; le Go +//! par défaut) : `POST /api/models/download`, `/probe`, `/cancel`, +//! `GET /api/models/download/status`. Le Go remet alors à la façade ces +//! routes quand elles lui arrivent par le tunnel du relais, et ne vide plus +//! les `.part` au démarrage (c'est la façade qui le fait). +//! +//! Comme le Go : un modèle découpé en tranches se rapatrie EN ENTIER (la +//! première désigne le modèle), les tranches déjà là ne se retéléchargent +//! pas, la place disque se vérifie sur le total, chaque tranche se répartit +//! sur plusieurs connexions quand la source honore les plages d'octets, une +//! connexion coupée reprend où elle en était, et une annulation ou un échec +//! efface ce que CE téléchargement avait écrit. +//! +//! Écarts assumés : une source qui ignore les plages (200 à la sonde) n'est +//! plus lue en entier pour rien pendant la sonde ; message d'une erreur de +//! transport (réseau, TLS) au format de reqwest ; hôte d'un lien collé en +//! majuscules converti comme les autres (`/blob/` → `/resolve/`) ; espace +//! libre inconnu hors Unix (rien n'est alors refusé, comme pour une mesure +//! ratée côté Go) ; `/status` trié par date de début (le Go rendait l'ordre +//! d'une map). + +use std::collections::HashMap; +use std::path::{Path, PathBuf}; +use std::sync::atomic::{AtomicI64, Ordering}; +use std::sync::{Arc, Mutex}; +use std::time::Duration; + +use futures_util::StreamExt; +use http_body_util::{BodyExt, Limited}; +use hyper::body::Incoming; +use hyper::{Request, Response, StatusCode}; +use serde::Serialize; +use serde_json::{Value, json}; +use tokio::sync::watch; + +use crate::api::Ctx; +use crate::auth::json as send_json; +use crate::gguf; +use crate::paths::{base_name, go_abs, go_clean, go_join}; +use crate::proxy::Body; +use crate::store::{BK_STATE, Store}; + +/// Connexions parallèles par tranche (`LOKI_DL_CONNS`, 16 au plus). +pub fn dl_conns() -> usize { + std::env::var("LOKI_DL_CONNS") + .ok() + .and_then(|v| v.trim().parse::().ok()) + .filter(|n| *n > 0) + .unwrap_or(8) + .min(16) +} + +/// Plus petite part qui mérite sa propre connexion (16 Mio). +const DL_MIN_CHUNK: i64 = 16 << 20; +/// Marge laissée libre après le téléchargement. +const DL_SPACE_MARGIN: i64 = 256 << 20; +/// Essais d'une part avant d'abandonner. +const DL_ATTEMPTS: u64 = 4; +/// Écrit par blocs de 1 Mio. +const DL_WRITE_BUF: usize = 1 << 20; + +/// Routes servies ici quand la façade tient les téléchargements. +pub fn handles(path: &str) -> bool { + matches!( + path, + "/api/models/download" + | "/api/models/download/probe" + | "/api/models/download/status" + | "/api/models/download/cancel" + ) +} + +// --------------------------------------------------------------------------- +// Fonctions pures (journal de référence : tests/parity_models/download.json) +// --------------------------------------------------------------------------- + +/// `path.Base` sur un chemin d'URL (séparateur `/` seul). +fn url_path_base(p: &str) -> String { + if p.is_empty() { + return ".".into(); + } + let t = p.trim_end_matches('/'); + if t.is_empty() { + return "/".into(); + } + t.rsplit('/').next().unwrap_or(t).to_string() +} + +/// Décodage `%XX` d'un segment d'URL (le `u.Path` du Go est décodé). +fn percent_decode(s: &str) -> String { + let b = s.as_bytes(); + let mut out = Vec::with_capacity(b.len()); + let mut i = 0; + while i < b.len() { + if b[i] == b'%' + && i + 2 < b.len() + && let (Some(h), Some(l)) = (hex(b[i + 1]), hex(b[i + 2])) + { + out.push(h << 4 | l); + i += 3; + continue; + } + out.push(b[i]); + i += 1; + } + String::from_utf8_lossy(&out).into_owned() +} + +fn hex(c: u8) -> Option { + match c { + b'0'..=b'9' => Some(c - b'0'), + b'a'..=b'f' => Some(c - b'a' + 10), + b'A'..=b'F' => Some(c - b'A' + 10), + _ => None, + } +} + +/// `normalizeHFURL` : lien de page Hugging Face (`/blob/`) → lien direct +/// (`/resolve/`) ; rend l'URL à télécharger et le nom du fichier. +pub fn normalize_hf_url(raw: &str) -> Result<(String, String), String> { + let raw = raw.trim(); + if raw.is_empty() { + return Err("lien vide".into()); + } + let mut u = match reqwest::Url::parse(raw) { + Ok(u) => u, + // Pas de schéma : le Go l'accepte et refuse ensuite le schéma vide. + Err(url::ParseError::RelativeUrlWithoutBase) => { + return Err("lien invalide (http/https attendu)".into()); + } + Err(e) => return Err(format!("lien invalide: {e}")), + }; + if u.scheme() != "http" && u.scheme() != "https" { + return Err("lien invalide (http/https attendu)".into()); + } + if u.host_str().is_some_and(|h| h.contains("huggingface.co")) { + let p = u.path().replacen("/blob/", "/resolve/", 1); + u.set_path(&p); + } + let name = percent_decode(&url_path_base(u.path())); + if name.is_empty() || name == "/" || name == "." { + return Err("impossible de déduire le nom du fichier depuis le lien".into()); + } + if !name.to_lowercase().ends_with(".gguf") { + return Err("le lien doit pointer vers un fichier .gguf".into()); + } + Ok((u.to_string(), name)) +} + +/// `shardURLSet` : l'URL d'UNE tranche → les URLs de toute la famille, dans +/// l'ordre, avec leurs noms. +pub fn shard_url_set(dl_url: &str, name: &str) -> (Vec, Vec) { + let fam = gguf::shard_family(name); + if fam.len() == 1 { + return (vec![dl_url.to_string()], fam); + } + let base = base_name(name); + let Some(i) = dl_url.rfind(&base) else { + return (vec![dl_url.to_string()], vec![base]); + }; + let urls = fam + .iter() + .map(|n| format!("{}{n}{}", &dl_url[..i], &dl_url[i + base.len()..])) + .collect(); + (urls, fam) +} + +/// `contentRangeTotal` : la taille totale d'un « bytes 0-0/12345 ». +pub fn content_range_total(v: &str) -> i64 { + match v.rfind('/') { + Some(i) => v[i + 1..] + .trim() + .parse::() + .ok() + .filter(|n| *n > 0) + .unwrap_or(0), + None => 0, + } +} + +/// `isHFHost` : l'hôte appartient à Hugging Face (seul à recevoir le jeton). +pub fn is_hf_host(host: &str) -> bool { + let h = host.to_lowercase(); + let h = h.split(':').next().unwrap_or(""); + h == "huggingface.co" || h.ends_with(".huggingface.co") || h == "hf.co" || h.ends_with(".hf.co") +} + +fn hf_repo_ok(repo: &str) -> bool { + let part = |s: &str| { + let mut c = s.chars(); + c.next().is_some_and(|f| f.is_ascii_alphanumeric()) + && c.all(|ch| ch.is_ascii_alphanumeric() || matches!(ch, '.' | '_' | '-')) + }; + match repo.split_once('/') { + Some((a, b)) => part(a) && part(b), + None => false, + } +} + +/// `hfRepoFromURL` : « auteur/dépôt » d'un lien de téléchargement, "" ailleurs. +pub fn hf_repo_from_url(raw: &str) -> String { + let Ok(u) = reqwest::Url::parse(raw) else { + return String::new(); + }; + if !u.host_str().unwrap_or("").contains("huggingface.co") { + return String::new(); + } + let segs: Vec<&str> = u.path().trim_matches('/').split('/').collect(); + if segs.len() < 4 || (segs[2] != "resolve" && segs[2] != "blob") { + return String::new(); + } + let repo = format!("{}/{}", segs[0], segs[1]); + if hf_repo_ok(&repo) { + repo + } else { + String::new() + } +} + +/// `dlFileLabel` : « nom.gguf » pour un message, "" sans nom lisible. +fn dl_file_label(dl_url: &str) -> String { + let Ok(u) = reqwest::Url::parse(dl_url) else { + return String::new(); + }; + let name = percent_decode(&url_path_base(u.path())); + if name.is_empty() || name == "/" || name == "." { + return String::new(); + } + format!("« {name} »") +} + +fn dl_access_reason(code: &str, repo: &str) -> String { + match (code, repo.is_empty()) { + ("GatedRepo", true) => { + "dépôt à accès restreint : ses conditions doivent être acceptées sur huggingface.co" + .into() + } + ("GatedRepo", false) => { + format!("dépôt à accès restreint : accepte ses conditions sur huggingface.co/{repo}") + } + ("RepoNotFound", true) => "dépôt privé ou inexistant".into(), + ("RepoNotFound", false) => format!("dépôt privé ou inexistant : {repo}"), + (_, false) => format!("accès refusé par Hugging Face sur {repo}"), + (_, true) => "accès refusé par la source".into(), + } +} + +fn dl_token_hint(token_set: bool) -> &'static str { + if token_set { + " — le jeton Hugging Face enregistré n'y donne pas accès (expiré, ou conditions non acceptées avec ce compte)" + } else { + " — puis renseigne le jeton Hugging Face (réglage « Jeton Hugging Face », ou variable d'environnement HF_TOKEN)" + } +} + +/// `dlSourceError` : un refus HTTP en phrase qui dit quoi faire. +pub fn dl_source_error(status: u16, error_code: &str, dl_url: &str, token_set: bool) -> String { + match status { + 401 | 403 => format!( + "{}{}", + dl_access_reason(error_code, &hf_repo_from_url(dl_url)), + dl_token_hint(token_set) + ), + 404 => match error_code { + "EntryNotFound" => format!( + "fichier {} absent du dépôt (HTTP 404) — dépôt réorganisé depuis la copie du lien, ou fragment jamais publié (table PLE livrée à part ?)", + dl_file_label(dl_url) + ), + "RevisionNotFound" => "révision introuvable dans le dépôt (HTTP 404)".into(), + _ => "lien introuvable (HTTP 404)".into(), + }, + 416 => "la source refuse la plage d'octets demandée (HTTP 416) — fichier modifié pendant le transfert ?".into(), + 429 => "trop de requêtes vers la source (HTTP 429) — réessaie dans quelques minutes".into(), + s if s >= 500 => format!("la source est en panne (HTTP {s}) — réessaie plus tard"), + s => format!("HTTP {s} depuis la source"), + } +} + +/// `humanBytes`. +pub fn human_bytes(n: i64) -> String { + if n >= 1 << 30 { + format!("{:.1} Go", n as f64 / (1u64 << 30) as f64) + } else if n >= 1 << 20 { + format!("{:.0} Mo", n as f64 / (1u64 << 20) as f64) + } else { + format!("{n} o") + } +} + +/// `filepath.Base` (séparateur de la plateforme). +fn filepath_base(p: &str) -> String { + if p.is_empty() { + return ".".into(); + } + let seps: &[char] = if cfg!(windows) { &['/', '\\'] } else { &['/'] }; + let t = p.trim_end_matches(seps); + if t.is_empty() { + return std::path::MAIN_SEPARATOR.to_string(); + } + t.rsplit(seps).next().unwrap_or(t).to_string() +} + +/// Les dossiers de modèles du moment (`modelsDir` + déclarés + variable). +#[derive(Debug, Clone)] +pub struct ModelDirs { + pub home: PathBuf, + pub stored: Vec, + pub env: Option, +} + +impl ModelDirs { + pub fn models_dir(&self) -> String { + gguf::models_dir(&self.home) + } + + pub fn all(&self) -> Vec { + gguf::model_dirs(&self.home, &self.stored, self.env.as_deref()) + } + + /// `resolveDownloadDir` : vide = models/, sinon un des dossiers déclarés. + pub fn resolve_download_dir(&self, dir: &str) -> Result { + let dir = dir.trim(); + if dir.is_empty() { + return Ok(self.models_dir()); + } + let abs = go_abs(dir).ok_or_else(|| "chemin invalide".to_string())?; + self.all() + .into_iter() + .find(|d| gguf::norm_dir(d) == gguf::norm_dir(&abs)) + .ok_or_else(|| { + format!("dossier non autorisé : {abs} — ajoute-le dans « Dossiers de modèles »") + }) + } + + /// `downloadDestPath`. + pub fn download_dest_path(&self, name: &str, dir: &str) -> Result { + let base = filepath_base(name.trim()); + if base.is_empty() || base == "." || base == std::path::MAIN_SEPARATOR_STR { + return Err("nom de modèle invalide".into()); + } + if !base.to_lowercase().ends_with(".gguf") { + return Err("seuls les fichiers .gguf sont acceptés".into()); + } + let d = self.resolve_download_dir(dir)?; + Ok(go_join(&[&d, &base])) + } + + /// `modelPickerValue` : le nom seul dans models/ (preset portable), le + /// chemin complet ailleurs. + pub fn model_picker_value(&self, dir: &str, base: &str) -> String { + if gguf::norm_dir(dir) == gguf::norm_dir(&self.models_dir()) { + base.to_string() + } else { + go_join(&[dir, base]) + } + } + + /// `modelPresence` : déjà sur le disque, famille complète ? Rend la + /// valeur de MODEL= et le chemin. + pub fn model_presence(&self, name: &str, dir: &str) -> Option<(String, String)> { + let base = base_name(name.trim()); + if base.is_empty() || !base.to_lowercase().ends_with(".gguf") { + return None; + } + let dirs = match self.resolve_download_dir(dir) { + Ok(d) if !dir.trim().is_empty() => vec![d], + _ => self.all(), + }; + for d in dirs { + let p = go_join(&[&d, &base]); + if !present(&p) { + continue; + } + // Famille incomplète : le téléchargement doit pouvoir la compléter. + if gguf::shard_family(&base) + .iter() + .any(|n| !present(&go_join(&[&d, n]))) + { + continue; + } + return Some((self.model_picker_value(&d, &base), p)); + } + None + } +} + +/// `os.Stat` sans erreur et pas un dossier. +fn present(p: &str) -> bool { + std::fs::metadata(p).is_ok_and(|m| !m.is_dir()) +} + +/// `modelFamilySize` : toutes les tranches présentes. +pub fn model_family_size(p: &str) -> i64 { + let path = Path::new(p); + let dir = path.parent().unwrap_or(Path::new(".")); + gguf::shard_family(&base_name(p)) + .iter() + .filter_map(|n| std::fs::metadata(dir.join(n)).ok()) + .filter(|m| !m.is_dir()) + .map(|m| m.len() as i64) + .sum() +} + +// --------------------------------------------------------------------------- +// Espace disque +// --------------------------------------------------------------------------- + +#[cfg(unix)] +fn statfs(dir: &str) -> Option { + let c = std::ffi::CString::new(dir).ok()?; + let mut st: libc::statfs = unsafe { std::mem::zeroed() }; + // SAFETY : chemin terminé par NUL, structure de sortie initialisée. + if unsafe { libc::statfs(c.as_ptr(), &mut st) } != 0 { + return None; + } + Some(st) +} + +/// `diskFreeAt` : octets libres pour l'utilisateur, -1 si inconnu. +fn disk_free_at(dir: &str) -> i64 { + #[cfg(unix)] + { + match statfs(dir) { + Some(st) => { + // Types selon la plateforme (u32 ou u64) : les conversions ne + // sont pas toutes superflues. + #[allow(clippy::unnecessary_cast)] + let free = (st.f_bavail as u64).saturating_mul(st.f_bsize as u64); + if free > 1 << 62 { -1 } else { free as i64 } + } + None => -1, + } + } + #[cfg(not(unix))] + { + let _ = dir; + -1 + } +} + +/// `diskFree` : remonte jusqu'au premier parent qui existe. +pub fn disk_free(dir: &str) -> i64 { + let Some(mut d) = go_abs(dir.trim()) else { + return -1; + }; + loop { + if Path::new(&d).is_dir() { + return disk_free_at(&d); + } + let parent = crate::paths::go_dir(&d); + if parent == d { + return -1; + } + d = parent; + } +} + +/// `diskFreeReliable` : l'espace annoncé peut-il servir à REFUSER ? Non sur +/// FUSE (shfs d'Unraid…), overlayfs, NFS, SMB, Ceph, GlusterFS. +pub fn disk_free_reliable(dir: &str) -> bool { + #[cfg(target_os = "linux")] + { + const UNRELIABLE: [u32; 7] = [ + 0x6573_5546, // FUSE + 0x794c_7630, // overlayfs + 0x6969, // NFS + 0x517b, // SMB1 + 0xfe53_4d42, // SMB2+ + 0x00c3_6400, // Ceph + 0x5846_5342, // GlusterFS + ]; + match statfs(dir) { + Some(st) => !UNRELIABLE.contains(&(st.f_type as u32)), + None => false, + } + } + #[cfg(not(target_os = "linux"))] + { + let _ = dir; + true + } +} + +/// `checkDiskSpace` : refus si ça ne tient pas (mesure fiable seulement). +pub fn check_disk_space(dir: &str, size: i64) -> Result<(), String> { + let free = disk_free(dir); + if size <= 0 || free < 0 || !disk_free_reliable(dir) { + return Ok(()); + } + if free < size + DL_SPACE_MARGIN { + return Err(format!( + "espace insuffisant sur {dir} : {} libres, {} nécessaires", + human_bytes(free), + human_bytes(size + DL_SPACE_MARGIN) + )); + } + Ok(()) +} + +// --------------------------------------------------------------------------- +// Les téléchargements +// --------------------------------------------------------------------------- + +/// Un téléchargement (`dlState`), tel que `/status` le rend. +#[derive(Debug, Clone, Default, Serialize, PartialEq)] +pub struct DlState { + pub filename: String, + pub url: String, + pub dir: String, + pub total: i64, + pub done: i64, + pub speed: i64, + pub conns: i64, + pub parts: i64, + pub part: i64, + pub finished: bool, + pub canceled: bool, + #[serde(rename = "error")] + pub err: String, + pub started_at: i64, +} + +struct Entry { + state: Mutex, + cancel: watch::Sender, +} + +impl Entry { + fn with(&self, f: impl FnOnce(&mut DlState) -> R) -> R { + f(&mut self.state.lock().unwrap_or_else(|e| e.into_inner())) + } +} + +/// Une annulation, attendue ou constatée. +#[derive(Clone)] +struct Stop(watch::Receiver); + +impl Stop { + fn is_set(&self) -> bool { + *self.0.borrow() + } + async fn wait(&self) { + let mut rx = self.0.clone(); + let _ = rx.wait_for(|v| *v).await; + } +} + +/// Ce qu'un téléchargement doit savoir de la machine. +#[derive(Clone)] +pub struct DlEnv { + /// Jeton Hugging Face ("" : aucun). + pub hf_token: String, + /// Version de Loki (User-Agent). + pub version: String, +} + +/// Les téléchargements de ce lancement, par nom de fichier. +pub struct Downloads { + map: Mutex>>, + client: reqwest::Client, +} + +impl Default for Downloads { + fn default() -> Self { + Self::new() + } +} + +/// Fin anticipée d'une part : annulation, ou erreur (message). +enum Halt { + Canceled, + Err(String), +} + +impl Downloads { + pub fn new() -> Self { + // HTTP/1.1 : de vraies connexions parallèles, sans fenêtre de flux h2 + // partagée ; pas de délai global (gros fichiers). + crate::chat::engine::install_crypto(); + let client = reqwest::Client::builder() + .http1_only() + .pool_max_idle_per_host(64) + .connect_timeout(Duration::from_secs(20)) + .build() + .unwrap_or_default(); + Downloads { + map: Mutex::new(HashMap::new()), + client, + } + } + + fn map(&self) -> std::sync::MutexGuard<'_, HashMap>> { + self.map.lock().unwrap_or_else(|e| e.into_inner()) + } + + /// `handleModelDownloadStatus` : tous les téléchargements connus. + pub fn status(&self) -> Vec { + let mut v: Vec = self.map().values().map(|e| e.with(|s| s.clone())).collect(); + v.sort_by_key(|s| (s.started_at, s.filename.clone())); + v + } + + /// Annule ; `false` : inconnu. + pub fn cancel(&self, name: &str) -> bool { + match self.map().get(name) { + Some(e) => { + let _ = e.cancel.send(true); + true + } + None => false, + } + } + + fn request(&self, env: &DlEnv, url: &str, range: Option<&str>) -> reqwest::RequestBuilder { + let mut rb = self + .client + .get(url) + .header("User-Agent", format!("loki/{}", env.version)) + // Jamais gzip sur un .gguf : ça casse les plages d'octets. + .header("Accept-Encoding", "identity"); + // Le jeton n'accompagne QUE les liens Hugging Face (reqwest le retire + // quand une redirection change d'hôte). + if !env.hf_token.is_empty() + && reqwest::Url::parse(url) + .ok() + .and_then(|u| u.host_str().map(is_hf_host)) + .unwrap_or(false) + { + rb = rb.header("Authorization", format!("Bearer {}", env.hf_token)); + } + if let Some(r) = range { + rb = rb.header("Range", r); + } + rb + } + + fn source_error(env: &DlEnv, resp: &reqwest::Response, url: &str) -> String { + let code = resp + .headers() + .get("x-error-code") + .and_then(|v| v.to_str().ok()) + .unwrap_or(""); + dl_source_error(resp.status().as_u16(), code, url, !env.hf_token.is_empty()) + } + + /// `dlProbe` : un octet demandé, pour la taille et le support des plages. + pub async fn probe(&self, env: &DlEnv, url: &str) -> Result<(i64, bool), String> { + let resp = self + .request(env, url, Some("bytes=0-0")) + .send() + .await + .map_err(|e| e.to_string())?; + match resp.status().as_u16() { + 206 => Ok(resp + .headers() + .get("content-range") + .and_then(|v| v.to_str().ok()) + .map(content_range_total) + .filter(|t| *t > 0) + .map_or((0, false), |t| (t, true))), + 200 => Ok((resp.content_length().map_or(-1, |n| n as i64), false)), + _ => Err(Self::source_error(env, &resp, url)), + } + } + + /// `handleModelDownload` : lance en arrière-plan ; rend la réponse. + pub fn start( + self: &Arc, + env: DlEnv, + dirs: &ModelDirs, + url: &str, + dir: &str, + ) -> (StatusCode, Value) { + let bad = |e: String| (StatusCode::BAD_REQUEST, json!({ "ok": false, "error": e })); + let (dl_url, name) = match normalize_hf_url(url) { + Ok(v) => v, + Err(e) => return bad(e), + }; + let (urls, names) = shard_url_set(&dl_url, &name); + let name = names[0].clone(); + let mut dests = Vec::new(); + for n in &names { + match dirs.download_dest_path(n, dir) { + Ok(d) => dests.push(d), + Err(e) => return bad(e), + } + } + let dest_dir = crate::paths::go_dir(&dests[0]); + if let Err(e) = std::fs::create_dir_all(&dest_dir) { + return bad(format!( + "dossier de destination inaccessible : {}", + crate::paths::os_err("mkdir", &dest_dir, &e) + )); + } + // Tranches déjà là : pas retéléchargées. + let todo: Vec<(String, String)> = urls + .into_iter() + .zip(dests.iter().cloned()) + .filter(|(_, d)| !present(d)) + .collect(); + let mut map = self.map(); + if map.get(&name).is_some_and(|e| !e.with(|s| s.finished)) { + return ( + StatusCode::CONFLICT, + json!({ "ok": false, "error": format!("téléchargement déjà en cours pour {name}") }), + ); + } + if todo.is_empty() { + drop(map); + // Rien à faire : le modèle est installé ; l'UI le sélectionne. + let value = dirs + .model_presence(&name, dir) + .map(|(v, _)| v) + .unwrap_or_else(|| dirs.model_picker_value(&dest_dir, &name)); + return ( + StatusCode::OK, + json!({ "ok": true, "filename": name, "exists": true, "value": value, "parts": 0 }), + ); + } + let (tx, rx) = watch::channel(false); + let entry = Arc::new(Entry { + state: Mutex::new(DlState { + filename: name.clone(), + url: todo[0].0.clone(), + dir: dest_dir, + parts: todo.len() as i64, + started_at: crate::chat::persist::now_unix(), + ..Default::default() + }), + cancel: tx, + }); + map.insert(name.clone(), entry.clone()); + drop(map); + let parts = todo.len(); + let me = self.clone(); + tokio::spawn(async move { me.run_set(env, entry, Stop(rx), todo).await }); + ( + StatusCode::OK, + json!({ "ok": true, "filename": name, "parts": parts }), + ) + } + + /// `runDownloadSet` : les tranches l'une après l'autre ; progression sur + /// l'ensemble ; annulation ou échec : tout ce que CE téléchargement a + /// écrit est effacé. + async fn run_set( + self: Arc, + env: DlEnv, + entry: Arc, + stop: Stop, + todo: Vec<(String, String)>, + ) { + let done = Arc::new(AtomicI64::new(0)); + let finish = |res: Result<(), Halt>| { + entry.with(|s| { + match &res { + Err(Halt::Canceled) => { + s.canceled = true; + s.speed = 0; + } + _ if stop.is_set() => { + s.canceled = true; + s.speed = 0; + } + Err(Halt::Err(e)) => { + s.err = e.clone(); + s.speed = 0; + } + Ok(()) => s.done = done.load(Ordering::Relaxed), + } + s.finished = true; + }); + }; + // Sonde de TOUTES les tranches avant d'écrire quoi que ce soit. + let mut sizes = Vec::new(); + let mut grand = 0i64; + for (u, _) in &todo { + let r = tokio::select! { + r = self.probe(&env, u) => r.map_err(Halt::Err), + _ = stop.wait() => Err(Halt::Canceled), + }; + match r { + Ok((n, ranged)) => { + grand += n; + sizes.push((n, ranged)); + } + Err(h) => return finish(Err(h)), + } + } + let dir = entry.with(|s| s.dir.clone()); + if let Err(e) = check_disk_space(&dir, grand) { + return finish(Err(Halt::Err(e))); + } + entry.with(|s| { + s.total = grand; + s.parts = todo.len() as i64; + }); + // Progression et vitesse lissée, une fois par seconde. + let ticker = { + let (entry, done) = (entry.clone(), done.clone()); + tokio::spawn(async move { + let mut t = tokio::time::interval(Duration::from_secs(1)); + t.tick().await; + let (mut last, mut last_at) = (0i64, tokio::time::Instant::now()); + loop { + let now = t.tick().await; + let cur = done.load(Ordering::Relaxed); + let dt = now.duration_since(last_at).as_secs_f64(); + entry.with(|s| { + s.done = cur; + if dt > 0.0 { + let inst = ((cur - last) as f64 / dt) as i64; + s.speed = if s.speed == 0 { + inst + } else { + (s.speed * 2 + inst) / 3 + }; + } + }); + (last, last_at) = (cur, now); + } + }) + }; + let mut written: Vec = Vec::new(); + for (i, ((u, dest), (total, ranged))) in todo.iter().zip(sizes).enumerate() { + entry.with(|s| s.part = i as i64 + 1); + if let Err(h) = self + .one_part(&env, &entry, &stop, u, dest, total, ranged, &done) + .await + { + ticker.abort(); + for p in &written { + let _ = std::fs::remove_file(p); + } + return finish(Err(h)); + } + written.push(dest.clone()); + } + ticker.abort(); + finish(Ok(())); + } + + /// `dlOnePart` : un fichier dans un `.part`, renommé au succès. + #[allow(clippy::too_many_arguments)] + async fn one_part( + &self, + env: &DlEnv, + entry: &Entry, + stop: &Stop, + url: &str, + dest: &str, + total: i64, + ranged: bool, + done: &Arc, + ) -> Result<(), Halt> { + let tmp = format!("{dest}.part"); + let mut conns = 1usize; + if ranged && total > 0 { + let max = ((total + DL_MIN_CHUNK - 1) / DL_MIN_CHUNK) as usize; + conns = dl_conns().min(max).max(1); + } + entry.with(|s| s.conns = conns as i64); + let file = std::fs::File::create(&tmp) + .map_err(|e| Halt::Err(crate::paths::os_err("open", &tmp, &e)))?; + if conns > 1 + && let Err(e) = file.set_len(total as u64) + { + let _ = std::fs::remove_file(&tmp); + return Err(Halt::Err(crate::paths::os_err("truncate", &tmp, &e))); + } + let file = Arc::new(PartFile { + f: file, + path: tmp.clone(), + }); + let res = if conns <= 1 { + self.chunk(env, stop, &file, url, 0, total - 1, total <= 0, done) + .await + } else { + let size = total / conns as i64; + let parts = (0..conns).map(|i| { + let start = i as i64 * size; + let end = if i == conns - 1 { + total - 1 + } else { + start + size - 1 + }; + self.chunk(env, stop, &file, url, start, end, false, done) + }); + futures_util::future::join_all(parts) + .await + .into_iter() + .find(Result::is_err) + .unwrap_or(Ok(())) + }; + if let Err(h) = res { + drop(file); + let _ = std::fs::remove_file(&tmp); + return Err(h); + } + drop(file); + std::fs::rename(&tmp, dest).map_err(|e| { + let _ = std::fs::remove_file(&tmp); + Halt::Err(format!( + "rename {tmp} {dest}: {}", + crate::tools::files::os_err_text(&e) + )) + }) + } + + /// `dlChunk` : [start, end] écrit en place, reprise où la connexion a + /// lâché ; `whole` : tout le corps d'un flux (source sans plages). + #[allow(clippy::too_many_arguments)] + async fn chunk( + &self, + env: &DlEnv, + stop: &Stop, + file: &Arc, + url: &str, + start: i64, + end: i64, + whole: bool, + done: &Arc, + ) -> Result<(), Halt> { + let mut pos = start; + let mut last_err = String::new(); + for attempt in 0..DL_ATTEMPTS { + if stop.is_set() { + return Err(Halt::Canceled); + } + if attempt > 0 { + tokio::select! { + _ = tokio::time::sleep(Duration::from_secs(attempt)) => {} + _ = stop.wait() => return Err(Halt::Canceled), + } + } + let range = if !whole { + if pos > end { + return Ok(()); + } + Some(format!("bytes={pos}-{end}")) + } else if pos > start { + Some(format!("bytes={pos}-")) // reprise au mieux + } else { + None + }; + let resp = tokio::select! { + r = self.request(env, url, range.as_deref()).send() => r, + _ = stop.wait() => return Err(Halt::Canceled), + }; + let resp = match resp { + Ok(r) => r, + Err(e) => { + last_err = e.to_string(); + continue; + } + }; + let status = resp.status().as_u16(); + if status != 200 && status != 206 { + return Err(Halt::Err(Self::source_error(env, &resp, url))); + } + if status == 200 && pos > start { + // Reprise refusée : le corps repart de zéro. + done.fetch_add(start - pos, Ordering::Relaxed); + pos = start; + } + match copy_at(stop, file, resp, pos, done).await { + (n, Ok(())) => { + let _ = n; + return Ok(()); + } + (_, Err(Halt::Canceled)) => return Err(Halt::Canceled), + (n, Err(Halt::Err(e))) => { + pos += n; + last_err = e; + } + } + } + Err(Halt::Err(last_err)) + } +} + +/// `dlCopy` : le corps écrit à partir de `off` ; rend les octets écrits, +/// même en cas d'erreur (pour reprendre). +async fn copy_at( + stop: &Stop, + file: &Arc, + resp: reqwest::Response, + off: i64, + done: &Arc, +) -> (i64, Result<(), Halt>) { + let mut stream = resp.bytes_stream(); + let mut written = 0i64; + let mut buf: Vec = Vec::with_capacity(DL_WRITE_BUF); + loop { + let next = tokio::select! { + n = stream.next() => n, + _ = stop.wait() => return (written, Err(Halt::Canceled)), + }; + let eof = next.is_none(); + match next { + Some(Ok(b)) => buf.extend_from_slice(&b), + Some(Err(e)) => { + let n = flush(file, &mut buf, off + written).await; + return match n { + Ok(k) => { + written += k; + done.fetch_add(k, Ordering::Relaxed); + (written, Err(Halt::Err(e.to_string()))) + } + Err(we) => (written, Err(Halt::Err(we))), + }; + } + None => {} + } + if buf.len() >= DL_WRITE_BUF || (eof && !buf.is_empty()) { + match flush(file, &mut buf, off + written).await { + Ok(k) => { + written += k; + done.fetch_add(k, Ordering::Relaxed); + } + Err(e) => return (written, Err(Halt::Err(e))), + } + } + if eof { + return (written, Ok(())); + } + } +} + +/// Écrit le tampon à sa place, hors du runtime ; le vide. +async fn flush(file: &Arc, buf: &mut Vec, at: i64) -> Result { + let data = std::mem::take(buf); + let n = data.len() as i64; + let f = file.clone(); + tokio::task::spawn_blocking(move || { + write_all_at(&f.f, &data, at as u64).map_err(|e| crate::paths::os_err("write", &f.path, &e)) + }) + .await + .map_err(|e| e.to_string())??; + Ok(n) +} + +/// Le `.part` en cours d'écriture, et son chemin (messages d'erreur). +struct PartFile { + f: std::fs::File, + path: String, +} + +#[cfg(unix)] +fn write_all_at(f: &std::fs::File, data: &[u8], at: u64) -> std::io::Result<()> { + use std::os::unix::fs::FileExt; + f.write_all_at(data, at) +} + +#[cfg(windows)] +fn write_all_at(f: &std::fs::File, mut data: &[u8], mut at: u64) -> std::io::Result<()> { + use std::os::windows::fs::FileExt; + while !data.is_empty() { + let n = f.seek_write(data, at)?; + data = &data[n..]; + at += n as u64; + } + Ok(()) +} + +/// `cleanStalePartFiles` : au démarrage, les `.part` d'un téléchargement tué +/// (crash, redémarrage) ne reprendront jamais : effacés. +pub fn clean_stale_part_files(dirs: &[String]) { + for d in dirs { + let Ok(rd) = std::fs::read_dir(d) else { + continue; + }; + for e in rd.filter_map(Result::ok) { + let name = e.file_name().to_string_lossy().into_owned(); + // `filepath.Glob("*.gguf.part")` : sensible à la casse. + if name.ends_with(".gguf.part") && std::fs::remove_file(e.path()).is_ok() { + println!( + "[models] téléchargement incomplet supprimé : {}", + go_clean(&e.path().to_string_lossy()) + ); + } + } + } +} + +// --------------------------------------------------------------------------- +// Routes +// --------------------------------------------------------------------------- + +/// Les dossiers de modèles et l'environnement du téléchargement, lus en base. +async fn dirs_and_env(ctx: &Ctx, version: String) -> (ModelDirs, DlEnv) { + let stored = gguf::parse_stored_model_dirs( + ctx.store + .get(BK_STATE, gguf::STATE_MODEL_DIRS_KEY) + .await + .ok() + .flatten() + .as_deref(), + ); + let dirs = ModelDirs { + home: ctx.engine.home.clone(), + stored, + env: std::env::var(gguf::MODEL_DIRS_ENV).ok(), + }; + ( + dirs, + DlEnv { + hf_token: hf_token(&ctx.store).await, + version, + }, + ) +} + +/// `hfToken` : le jeton enregistré d'abord, `HF_TOKEN` ensuite. +pub async fn hf_token(store: &Store) -> String { + let t = store.get_str(BK_STATE, "hf_token").await; + if !t.trim().is_empty() { + return t.trim().to_string(); + } + std::env::var("HF_TOKEN") + .unwrap_or_default() + .trim() + .to_string() +} + +async fn body_json(body: Incoming) -> Result { + let b = Limited::new(body, 1 << 20) + .collect() + .await + .map_err(|e| e.to_string())? + .to_bytes(); + if b.iter().all(u8::is_ascii_whitespace) { + return Err("EOF".into()); + } + serde_json::from_slice(&b).map_err(|e| e.to_string()) +} + +fn str_of<'a>(v: &'a Value, k: &str) -> &'a str { + v.get(k).and_then(Value::as_str).unwrap_or("") +} + +/// `from_go` : remise par le Go (tunnel), déjà authentifiée par lui. +pub async fn serve( + ctx: &Ctx, + dl: &Arc, + req: Request, + from_go: bool, +) -> Response { + let (parts, body) = req.into_parts(); + // La version du Go (User-Agent) vient avec l'autorisation ; une remise du + // Go la redemande. + let version = if from_go { + ctx.internal + .auth() + .await + .map(|i| i.version) + .unwrap_or_default() + } else { + match ctx.guard(&parts.headers).await { + Ok(v) => v, + Err(refus) => return *refus, + } + }; + let bad = |e: String| send_json(StatusCode::BAD_REQUEST, &json!({ "ok": false, "error": e })); + match parts.uri.path() { + "/api/models/download/status" => send_json( + StatusCode::OK, + &serde_json::to_value(dl.status()).unwrap_or_default(), + ), + "/api/models/download/cancel" => { + let v = match body_json(body).await { + Ok(v) => v, + Err(e) => return bad(e), + }; + let name = filepath_base(str_of(&v, "filename").trim()); + if dl.cancel(&name) { + send_json(StatusCode::OK, &json!({ "ok": true })) + } else { + send_json( + StatusCode::NOT_FOUND, + &json!({ "ok": false, "error": format!("aucun téléchargement pour {name}") }), + ) + } + } + "/api/models/download" => { + let v = match body_json(body).await { + Ok(v) => v, + Err(e) => return bad(e), + }; + let (dirs, env) = dirs_and_env(ctx, version).await; + let (url, dir) = (str_of(&v, "url").to_string(), str_of(&v, "dir").to_string()); + let dl = dl.clone(); + // Disque (dossiers, présence, mkdir) hors du runtime ; le + // téléchargement lui-même y repart (tokio::spawn). + let (code, out) = tokio::task::spawn_blocking(move || dl.start(env, &dirs, &url, &dir)) + .await + .unwrap_or_else(|e| { + ( + StatusCode::INTERNAL_SERVER_ERROR, + json!({ "ok": false, "error": e.to_string() }), + ) + }); + send_json(code, &out) + } + "/api/models/download/probe" => { + let v = match body_json(body).await { + Ok(v) => v, + Err(e) => return bad(e), + }; + let (dirs, env) = dirs_and_env(ctx, version).await; + let (dl_url, name) = match normalize_hf_url(str_of(&v, "url")) { + Ok(x) => x, + Err(e) => return bad(e), + }; + let dir = match dirs.resolve_download_dir(str_of(&v, "dir")) { + Ok(d) => d, + Err(e) => return bad(e), + }; + let (urls, names) = shard_url_set(&dl_url, &name); + let name = names[0].clone(); + // Déjà là (dans N'IMPORTE quel dossier déclaré) : rien à sonder, + // un modèle à sélectionner. + if let Some((value, path)) = dirs.model_presence(&name, "") { + return send_json( + StatusCode::OK, + &json!({ + "ok": true, "filename": name, "dir": crate::paths::go_dir(&path), + "exists": true, "value": value, "size": model_family_size(&path), + "free": disk_free(&dir), "free_exact": disk_free_reliable(&dir), + "enough": true, "parts": urls.len(), + }), + ); + } + let mut total = 0i64; + let probe_all = async { + for u in &urls { + let (n, _) = dl.probe(&env, u).await?; + total += n; + } + Ok::<(), String>(()) + }; + match tokio::time::timeout(Duration::from_secs(30), probe_all).await { + Ok(Ok(())) => {} + Ok(Err(e)) => return bad(e), + Err(_) => return bad("context deadline exceeded".into()), + } + let mut out = json!({ + "ok": true, "filename": name, "dir": dir, "size": total, + "free": disk_free(&dir), "free_exact": disk_free_reliable(&dir), + "enough": true, "parts": urls.len(), + }); + if let Err(e) = check_disk_space(&dir, total) { + out["enough"] = json!(false); + out["error"] = json!(e); + } + send_json(StatusCode::OK, &out) + } + _ => send_json( + StatusCode::NOT_FOUND, + &json!({ "error": "404 page not found" }), + ), + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn noms_et_tailles() { + assert_eq!(url_path_base("/a/b/"), "b"); + assert_eq!(url_path_base(""), "."); + assert_eq!(url_path_base("///"), "/"); + assert_eq!(percent_decode("a%20b%zz%4"), "a b%zz%4"); + assert_eq!(human_bytes(3 << 30), "3.0 Go"); + assert_eq!(human_bytes(5 << 20), "5 Mo"); + assert_eq!(human_bytes(12), "12 o"); + assert_eq!(filepath_base(""), "."); + } + + #[test] + fn espace_disque() { + let d = tempfile::tempdir().unwrap(); + let p = d.path().join("pas/encore/la"); + let free = disk_free(&p.to_string_lossy()); + assert!(free != 0, "{free}"); + assert!(check_disk_space(&d.path().to_string_lossy(), 1).is_ok()); + if free > 0 && disk_free_reliable(&d.path().to_string_lossy()) { + let e = check_disk_space(&d.path().to_string_lossy(), free).unwrap_err(); + assert!(e.starts_with("espace insuffisant sur "), "{e}"); + } + } + + #[test] + fn part_perimes_effaces() { + let d = tempfile::tempdir().unwrap(); + std::fs::write(d.path().join("m.gguf.part"), b"x").unwrap(); + std::fs::write(d.path().join("m.gguf"), b"x").unwrap(); + clean_stale_part_files(&[d.path().to_string_lossy().into_owned()]); + assert!(!d.path().join("m.gguf.part").exists() && d.path().join("m.gguf").exists()); + } +} diff --git a/rust/loki-rs/src/lib.rs b/rust/loki-rs/src/lib.rs index be2668b..e7ab18a 100644 --- a/rust/loki-rs/src/lib.rs +++ b/rust/loki-rs/src/lib.rs @@ -15,6 +15,7 @@ pub mod auth; pub mod chat; pub mod chatapi; pub mod child; +pub mod downloads; pub mod engine; pub mod engstatus; pub mod gguf; diff --git a/rust/loki-rs/src/main.rs b/rust/loki-rs/src/main.rs index 1d735ca..24b75d6 100644 --- a/rust/loki-rs/src/main.rs +++ b/rust/loki-rs/src/main.rs @@ -118,9 +118,17 @@ fn serve() -> ExitCode { // sait (il remet alors à la façade le trafic de chat du tunnel) et // connaît son adresse locale. let chat_rust = chat_held_by_facade(); + // LOKI_DOWNLOADS=rust : les téléchargements de modèles aussi (le Go + // remet alors leurs routes du tunnel et ne vide plus les .part). + let downloads_rust = downloads_held_by_facade(); let mut env = vec![(auth::INTERNAL_ENV.to_string(), token.clone())]; if chat_rust { env.push(("LOKI_CHAT".into(), "rust".into())); + } + if downloads_rust { + env.push(("LOKI_DOWNLOADS".into(), "rust".into())); + } + if chat_rust || downloads_rust { env.push(("LOKI_FACADE_ADDR".into(), format!("127.0.0.1:{web_port}"))); } let sup = child::Supervisor::spawn(go_bin, go_port, env); @@ -153,6 +161,11 @@ fn serve() -> ExitCode { } else { None }; + let downloads = if downloads_rust { + Some(start_downloads(backend, &token, &engine_paths.home).await) + } else { + None + }; let addr = SocketAddr::from(([0, 0, 0, 0], web_port)); let listener = match tokio::net::TcpListener::bind(addr).await { @@ -170,6 +183,7 @@ fn serve() -> ExitCode { host_env: auth::HostEnv::from_env(), engine: engine_paths, chat, + downloads, }; let res = proxy::serve(listener, cfg, shutdown_signal()).await; eprintln!("[loki-rs] arrêt demandé — arrêt du serveur Go"); @@ -189,6 +203,37 @@ fn chat_held_by_facade() -> bool { std::env::var("LOKI_CHAT").is_ok_and(|v| v.trim().eq_ignore_ascii_case("rust")) } +/// `LOKI_DOWNLOADS=rust` : téléchargements de modèles tenus par la façade. +fn downloads_held_by_facade() -> bool { + std::env::var("LOKI_DOWNLOADS").is_ok_and(|v| v.trim().eq_ignore_ascii_case("rust")) +} + +/// Les `.part` d'un téléchargement tué ne reprendront jamais : vidés au +/// démarrage (le Go ne le fait plus quand la façade les tient). +async fn start_downloads( + backend: SocketAddr, + token: &str, + home: &std::path::Path, +) -> std::sync::Arc { + use loki_rs::{downloads, gguf, store}; + let st = store::Store::new(backend, token.to_string()); + let stored = gguf::parse_stored_model_dirs( + st.get(store::BK_STATE, gguf::STATE_MODEL_DIRS_KEY) + .await + .ok() + .flatten() + .as_deref(), + ); + let dirs = gguf::model_dirs( + home, + &stored, + std::env::var(gguf::MODEL_DIRS_ENV).ok().as_deref(), + ); + let _ = tokio::task::spawn_blocking(move || downloads::clean_stale_part_files(&dirs)).await; + eprintln!("[loki-rs] téléchargements de modèles tenus par la façade (LOKI_DOWNLOADS=rust)"); + std::sync::Arc::new(downloads::Downloads::new()) +} + /// Le Go garde-t-il la conversation ? (quelques essais : il vient de démarrer) async fn go_keeps_chat(backend: SocketAddr, token: &str) -> Result, String> { let store = loki_rs::store::Store::new(backend, token.to_string()); diff --git a/rust/loki-rs/src/proxy.rs b/rust/loki-rs/src/proxy.rs index b2b55da..930b561 100644 --- a/rust/loki-rs/src/proxy.rs +++ b/rust/loki-rs/src/proxy.rs @@ -63,6 +63,9 @@ pub struct Config { /// Conversation tenue par la façade (`LOKI_CHAT=rust`) : ses routes sont /// servies ici ; `None` : relayées au Go. pub chat: Option>, + /// Téléchargements de modèles tenus par la façade (`LOKI_DOWNLOADS=rust`) : + /// leurs routes sont servies ici ; `None` : relayées au Go. + pub downloads: Option>, } struct Proxy { @@ -90,6 +93,7 @@ pub async fn serve( host_env: cfg.host_env, engine: cfg.engine, chat: cfg.chat, + downloads: cfg.downloads, internal_token: cfg.internal_token.clone(), }, }); @@ -129,8 +133,10 @@ pub async fn serve( impl Proxy { async fn handle(&self, mut req: Request) -> Response { // Le Go (et lui seul : jeton tiré à chaque démarrage) remet à la - // façade le trafic de chat qui lui arrive sans passer par elle. - let from_go = self.ctx.chat.is_some() && self.ctx.from_go(req.headers()); + // façade le trafic de chat et de téléchargements qui lui arrive sans + // passer par elle. + let from_go = (self.ctx.chat.is_some() || self.ctx.downloads.is_some()) + && self.ctx.from_go(req.headers()); // Jamais de confiance accordée sur la foi d'un en-tête du client : // ni « je viens du tunnel », ni « je suis la façade ». req.headers_mut().remove(VIA_TUNNEL); @@ -163,6 +169,11 @@ impl Proxy { { return chatapi::serve(&self.ctx, chat, req, from_go).await; } + if let Some(dl) = &self.ctx.downloads + && crate::downloads::handles(path) + { + return crate::downloads::serve(&self.ctx, dl, req, from_go).await; + } self.forward(req).await } diff --git a/rust/loki-rs/tests/common/fakego.rs b/rust/loki-rs/tests/common/fakego.rs index 19b92dd..4ef5359 100644 --- a/rust/loki-rs/tests/common/fakego.rs +++ b/rust/loki-rs/tests/common/fakego.rs @@ -427,7 +427,16 @@ async fn handle(st: Arc, req: Request) -> Response /// Lance le faux Go puis la façade devant lui ; rend (adresse de la façade, /// état du faux Go, garde d'arrêt). pub async fn start(home: std::path::PathBuf) -> (SocketAddr, Arc, oneshot::Sender<()>) { - let (f, st, tx, _) = start_opts(home, false, None).await; + let (f, st, tx, _) = start_opts(home, false, None, false).await; + (f, st, tx) +} + +/// Comme [`start`], avec les téléchargements de modèles tenus par la façade +/// (`LOKI_DOWNLOADS=rust`). +pub async fn start_downloads( + home: std::path::PathBuf, +) -> (SocketAddr, Arc, oneshot::Sender<()>) { + let (f, st, tx, _) = start_opts(home, false, None, true).await; (f, st, tx) } @@ -442,7 +451,7 @@ pub async fn start_chat( oneshot::Sender<()>, Arc, ) { - let (f, st, tx, chat) = start_opts(home, true, Some(engine_port)).await; + let (f, st, tx, chat) = start_opts(home, true, Some(engine_port), false).await; (f, st, tx, chat.unwrap()) } @@ -450,6 +459,7 @@ async fn start_opts( home: std::path::PathBuf, with_chat: bool, engine_port: Option, + with_downloads: bool, ) -> ( SocketAddr, Arc, @@ -506,6 +516,7 @@ async fn start_opts( service: "loki-engine".into(), }, chat: chat.clone(), + downloads: with_downloads.then(|| Arc::new(loki_rs::downloads::Downloads::new())), }; tokio::spawn(loki_rs::proxy::serve(fl, cfg, async move { let _ = rx.await; @@ -513,6 +524,32 @@ async fn start_opts( (facade, st, tx, chat) } +/// Requête via la façade (corps JSON) : (code, corps). +pub async fn send( + facade: SocketAddr, + method: &str, + path: &str, + headers: &[(&str, &str)], + body: &str, +) -> (StatusCode, Bytes) { + use hyper_util::client::legacy::Client; + use hyper_util::rt::TokioExecutor; + let mut req = Request::builder() + .method(method) + .uri(format!("http://{facade}{path}")) + .header("content-type", "application/json"); + for (k, v) in headers { + req = req.header(*k, *v); + } + let client: Client<_, Full> = Client::builder(TokioExecutor::new()).build_http(); + let r = client + .request(req.body(Full::new(Bytes::from(body.to_string()))).unwrap()) + .await + .unwrap(); + let (parts, body) = r.into_parts(); + (parts.status, body.collect().await.unwrap().to_bytes()) +} + /// GET via la façade : (code, en-têtes, corps). pub async fn get( facade: SocketAddr, diff --git a/rust/loki-rs/tests/downloads.rs b/rust/loki-rs/tests/downloads.rs new file mode 100644 index 0000000..17e4aad --- /dev/null +++ b/rust/loki-rs/tests/downloads.rs @@ -0,0 +1,692 @@ +//! Lot « modèles » : téléchargements tenus par la façade (`LOKI_DOWNLOADS=rust`). +//! +//! - les fonctions pures rejouent le journal tiré du Go +//! (`internal/loki/parity_dl_test.go` → `tests/parity_models/download.json`) ; +//! - le téléchargement de bout en bout tourne contre un faux Hugging Face +//! (plages d'octets, tranches, dépôt verrouillé, annulation). + +mod common; + +use std::collections::HashMap; +use std::convert::Infallible; +use std::net::SocketAddr; +use std::path::{Path, PathBuf}; +use std::sync::{Arc, Mutex}; +use std::time::Duration; + +use bytes::Bytes; +use common::fakego; +use http_body_util::{BodyExt, Full, StreamBody, combinators::BoxBody}; +use hyper::body::{Frame, Incoming}; +use hyper::{Request, Response, StatusCode, service::service_fn}; +use hyper_util::rt::TokioIo; +use loki_rs::downloads::{self, DlEnv, DlState, Downloads, ModelDirs}; +use serde_json::Value; + +// --------------------------------------------------------------------------- +// Journal de référence du Go +// --------------------------------------------------------------------------- + +#[test] +fn parite_des_fonctions_pures() { + let fx: Value = + serde_json::from_str(include_str!("parity_models/download.json")).expect("journal"); + let s = |v: &Value, k: &str| v.get(k).and_then(Value::as_str).unwrap_or("").to_string(); + let list = |v: &Value, k: &str| -> Vec { + v.get(k) + .and_then(Value::as_array) + .map(|a| a.iter().map(|x| x.as_str().unwrap().to_string()).collect()) + .unwrap_or_default() + }; + + for c in fx["normalize"].as_array().unwrap() { + let input = s(c, "in"); + match downloads::normalize_hf_url(&input) { + Err(e) => assert_eq!(e, s(c, "err"), "normalize({input:?})"), + Ok((url, name)) => { + assert_eq!(s(c, "err"), "", "normalize({input:?}) aurait dû échouer"); + assert_eq!(url, s(c, "url"), "url de {input:?}"); + assert_eq!(name, s(c, "name"), "nom de {input:?}"); + let (urls, names) = downloads::shard_url_set(&url, &name); + assert_eq!(urls, list(c, "urls"), "tranches de {input:?}"); + assert_eq!(names, list(c, "names"), "noms de {input:?}"); + } + } + } + for (v, want) in fx["content_range"].as_object().unwrap() { + assert_eq!( + downloads::content_range_total(v), + want.as_i64().unwrap(), + "{v:?}" + ); + } + for c in fx["source"].as_array().unwrap() { + let got = downloads::dl_source_error( + c["status"].as_u64().unwrap() as u16, + &s(c, "code"), + &s(c, "url"), + c["token_set"].as_bool().unwrap(), + ); + assert_eq!(got, s(c, "err"), "{c}"); + } + for (h, want) in fx["hf_host"].as_object().unwrap() { + assert_eq!(downloads::is_hf_host(h), want.as_bool().unwrap(), "{h}"); + } + for (u, want) in fx["hf_repo"].as_object().unwrap() { + assert_eq!( + downloads::hf_repo_from_url(u), + want.as_str().unwrap(), + "{u}" + ); + } + + let tmp = tempfile::tempdir().unwrap(); + let home = tmp.path().canonicalize().unwrap(); + let home_s = home.to_string_lossy().into_owned(); + std::fs::create_dir_all(home.join("disque")).unwrap(); + let dirs = ModelDirs { + home: home.clone(), + stored: vec![format!("{home_s}/disque")], + env: None, + }; + for c in fx["dest"].as_array().unwrap() { + let dir = s(c, "dir").replace("{{HOME}}", &home_s); + let got = match dirs.download_dest_path(&s(c, "name"), &dir) { + Ok(p) => ("path", p.replace(&home_s, "{{HOME}}")), + Err(e) => ("err", e.replace(&home_s, "{{HOME}}")), + }; + let want = if c.get("path").is_some() { + ("path", s(c, "path")) + } else { + ("err", s(c, "err")) + }; + assert_eq!(got, want, "{c}"); + } +} + +#[test] +fn tailles_lisibles_comme_le_go() { + assert_eq!(downloads::human_bytes(0), "0 o"); + assert_eq!(downloads::human_bytes((1 << 20) - 1), "1048575 o"); + assert_eq!(downloads::human_bytes(1 << 20), "1 Mo"); + assert_eq!(downloads::human_bytes(1536 << 20), "1.5 Go"); +} + +// --------------------------------------------------------------------------- +// Faux Hugging Face +// --------------------------------------------------------------------------- + +type Body = BoxBody; + +#[derive(Default)] +struct FakeHf { + files: Mutex>>>, + /// Chemin → (statut, X-Error-Code) imposés. + refuse: Mutex>, + /// Ignore les plages (200, corps entier). + no_ranges: Mutex, + /// Corps envoyé au goutte-à-goutte (annulation). + slow: Mutex, + /// « chemin Range » de chaque requête, et en-têtes Authorization vus. + log: Mutex>, + auth: Mutex>, +} + +fn full(status: u16, body: impl Into) -> Response { + let mut r = Response::new(Full::new(body.into()).map_err(|e| match e {}).boxed()); + *r.status_mut() = StatusCode::from_u16(status).unwrap(); + r +} + +async fn hf_handle(hf: Arc, req: Request) -> Response { + let path = req.uri().path().to_string(); + let range = req + .headers() + .get("range") + .and_then(|v| v.to_str().ok()) + .unwrap_or("") + .to_string(); + if let Some(a) = req.headers().get("authorization") { + hf.auth + .lock() + .unwrap() + .push(a.to_str().unwrap().to_string()); + } + hf.log.lock().unwrap().push(format!("{path} {range}")); + if let Some((st, code)) = hf.refuse.lock().unwrap().get(&path).cloned() { + let mut r = full(st, "refus"); + if !code.is_empty() { + r.headers_mut() + .insert("x-error-code", code.parse().unwrap()); + } + return r; + } + let Some(data) = hf.files.lock().unwrap().get(&path).cloned() else { + return full(404, "absent"); + }; + let total = data.len(); + let (status, start, end) = match range.strip_prefix("bytes=") { + Some(r) if !*hf.no_ranges.lock().unwrap() => { + let (a, b) = r.split_once('-').unwrap(); + let a: usize = a.parse().unwrap(); + let b: usize = if b.is_empty() { + total - 1 + } else { + b.parse::().unwrap().min(total - 1) + }; + (206, a, b) + } + _ => (200, 0, total - 1), + }; + let chunk = Bytes::copy_from_slice(&data[start..=end]); + let mut r = if *hf.slow.lock().unwrap() && chunk.len() > 1 { + // 64 Kio toutes les 20 ms : le téléchargement dure, on peut l'annuler. + let s = futures_util::stream::unfold(chunk, |mut rest| async move { + if rest.is_empty() { + return None; + } + tokio::time::sleep(Duration::from_millis(20)).await; + let part = rest.split_to(rest.len().min(64 << 10)); + Some((Ok::<_, Infallible>(Frame::data(part)), rest)) + }); + let mut r = Response::new(StreamBody::new(s).boxed()); + *r.status_mut() = StatusCode::from_u16(status).unwrap(); + r + } else { + full(status, chunk) + }; + if status == 206 { + r.headers_mut().insert( + "content-range", + format!("bytes {start}-{end}/{total}").parse().unwrap(), + ); + } + r +} + +async fn fake_hf() -> (SocketAddr, Arc) { + let hf = Arc::new(FakeHf::default()); + let l = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap(); + let addr = l.local_addr().unwrap(); + let h = hf.clone(); + tokio::spawn(async move { + while let Ok((s, _)) = l.accept().await { + let h = h.clone(); + tokio::spawn(async move { + let svc = service_fn(move |req| { + let h = h.clone(); + async move { Ok::<_, Infallible>(hf_handle(h, req).await) } + }); + let _ = hyper::server::conn::http1::Builder::new() + .serve_connection(TokioIo::new(s), svc) + .await; + }); + } + }); + (addr, hf) +} + +/// Contenu reconnaissable : un décalage d'octets se verrait. +fn payload(n: usize, seed: u8) -> Vec { + (0..n) + .map(|i| (i as u32).wrapping_mul(2_654_435_761).to_le_bytes()[1] ^ seed) + .collect() +} + +fn env() -> DlEnv { + DlEnv { + hf_token: "hf_secret".into(), + version: "9.9.9".into(), + } +} + +fn dirs_at(home: &Path) -> ModelDirs { + ModelDirs { + home: home.to_path_buf(), + stored: vec![], + env: None, + } +} + +async fn wait_finished(dl: &Downloads, name: &str) -> DlState { + for _ in 0..1000 { + if let Some(s) = dl.status().into_iter().find(|s| s.filename == name) + && s.finished + { + return s; + } + tokio::time::sleep(Duration::from_millis(10)).await; + } + panic!("téléchargement {name} jamais terminé : {:?}", dl.status()); +} + +fn models(home: &Path) -> PathBuf { + home.join("models") +} + +fn entries(dir: &Path) -> Vec { + let mut v: Vec = std::fs::read_dir(dir) + .map(|rd| { + rd.filter_map(Result::ok) + .map(|e| e.file_name().to_string_lossy().into_owned()) + .collect() + }) + .unwrap_or_default(); + v.sort(); + v +} + +// --------------------------------------------------------------------------- +// De bout en bout +// --------------------------------------------------------------------------- + +#[tokio::test] +async fn un_fichier_sur_plusieurs_connexions() { + let (addr, hf) = fake_hf().await; + let data = payload(40 << 20, 7); // 40 Mio → 3 parts de 16 Mio au plus + hf.files + .lock() + .unwrap() + .insert("/u/r/resolve/main/m.gguf".into(), Arc::new(data.clone())); + let tmp = tempfile::tempdir().unwrap(); + let dl = Arc::new(Downloads::new()); + let (code, out) = dl.start( + env(), + &dirs_at(tmp.path()), + &format!("http://{addr}/u/r/resolve/main/m.gguf"), + "", + ); + assert_eq!(code, StatusCode::OK, "{out}"); + assert_eq!(out["filename"], "m.gguf"); + assert_eq!(out["parts"], 1); + let st = wait_finished(&dl, "m.gguf").await; + assert_eq!(st.err, ""); + assert!(!st.canceled); + assert_eq!( + (st.total, st.done, st.conns, st.parts, st.part), + (40 << 20, 40 << 20, 3, 1, 1) + ); + assert_eq!(st.dir, models(tmp.path()).to_string_lossy()); + let got = std::fs::read(models(tmp.path()).join("m.gguf")).unwrap(); + assert!(got == data, "contenu différent"); + assert_eq!(entries(&models(tmp.path())), ["m.gguf"]); + // Sonde d'un octet, puis trois plages disjointes qui couvrent le fichier. + let log = hf.log.lock().unwrap().clone(); + assert_eq!(log[0], "/u/r/resolve/main/m.gguf bytes=0-0"); + let mut ranges: Vec<&str> = log[1..] + .iter() + .map(|l| l.split(' ').nth(1).unwrap()) + .collect(); + ranges.sort(); + assert_eq!( + ranges, + [ + "bytes=0-13981012", + "bytes=13981013-27962025", + "bytes=27962026-41943039" + ] + ); + // Hôte hors Hugging Face : le jeton ne part pas. + assert!(hf.auth.lock().unwrap().is_empty()); +} + +#[tokio::test] +async fn source_sans_plages_un_seul_flux() { + let (addr, hf) = fake_hf().await; + let data = payload(3 << 20, 1); + hf.files + .lock() + .unwrap() + .insert("/m.gguf".into(), Arc::new(data.clone())); + *hf.no_ranges.lock().unwrap() = true; + let tmp = tempfile::tempdir().unwrap(); + let dl = Arc::new(Downloads::new()); + let (code, _) = dl.start( + env(), + &dirs_at(tmp.path()), + &format!("http://{addr}/m.gguf"), + "", + ); + assert_eq!(code, StatusCode::OK); + let st = wait_finished(&dl, "m.gguf").await; + assert_eq!( + (st.err.as_str(), st.total, st.done, st.conns), + ("", 3 << 20, 3 << 20, 1) + ); + assert!(std::fs::read(models(tmp.path()).join("m.gguf")).unwrap() == data); +} + +#[tokio::test] +async fn modele_en_tranches_rapatrie_en_entier() { + let (addr, hf) = fake_hf().await; + let parts: Vec> = (0..3).map(|i| payload(1 << 20, i as u8)).collect(); + for (i, p) in parts.iter().enumerate() { + hf.files.lock().unwrap().insert( + format!("/u/r/resolve/main/Q4/m-0000{}-of-00003.gguf", i + 1), + Arc::new(p.clone()), + ); + } + let tmp = tempfile::tempdir().unwrap(); + let m = models(tmp.path()); + std::fs::create_dir_all(&m).unwrap(); + // La première est déjà là : pas retéléchargée. + std::fs::write(m.join("m-00001-of-00003.gguf"), &parts[0]).unwrap(); + let dl = Arc::new(Downloads::new()); + let (code, out) = dl.start( + env(), + &dirs_at(tmp.path()), + &format!("http://{addr}/u/r/resolve/main/Q4/m-00002-of-00003.gguf"), + "", + ); + assert_eq!(code, StatusCode::OK, "{out}"); + assert_eq!( + (out["filename"].as_str(), out["parts"].as_i64()), + (Some("m-00001-of-00003.gguf"), Some(2)) + ); + let st = wait_finished(&dl, "m-00001-of-00003.gguf").await; + assert_eq!( + (st.err.as_str(), st.total, st.done, st.parts, st.part), + ("", 2 << 20, 2 << 20, 2, 2) + ); + for (i, p) in parts.iter().enumerate() { + let got = std::fs::read(m.join(format!("m-0000{}-of-00003.gguf", i + 1))).unwrap(); + assert!(&got == p, "tranche {}", i + 1); + } + assert!( + !hf.log + .lock() + .unwrap() + .iter() + .any(|l| l.contains("00001-of")), + "tranche présente retéléchargée" + ); + // Tout est là : rien à faire, le modèle est à sélectionner. + let (code, out) = dl.start( + env(), + &dirs_at(tmp.path()), + &format!("http://{addr}/u/r/resolve/main/Q4/m-00003-of-00003.gguf"), + "", + ); + assert_eq!(code, StatusCode::OK); + assert_eq!(out["exists"], true); + assert_eq!(out["value"], "m-00001-of-00003.gguf"); + assert_eq!(out["parts"], 0); +} + +#[tokio::test] +async fn depot_verrouille_rien_ne_reste() { + let (addr, hf) = fake_hf().await; + for i in 1..=2 { + hf.files.lock().unwrap().insert( + format!("/g/d/resolve/main/m-0000{i}-of-00002.gguf"), + Arc::new(payload(1 << 20, i)), + ); + } + hf.refuse.lock().unwrap().insert( + "/g/d/resolve/main/m-00002-of-00002.gguf".into(), + (401, "GatedRepo".into()), + ); + let tmp = tempfile::tempdir().unwrap(); + let dl = Arc::new(Downloads::new()); + let (code, _) = dl.start( + env(), + &dirs_at(tmp.path()), + &format!("http://{addr}/g/d/resolve/main/m-00001-of-00002.gguf"), + "", + ); + assert_eq!(code, StatusCode::OK); + let st = wait_finished(&dl, "m-00001-of-00002.gguf").await; + // Refusé dès la sonde : rien n'a été écrit. + assert_eq!( + st.err, + "dépôt à accès restreint : ses conditions doivent être acceptées sur huggingface.co — le jeton Hugging Face enregistré n'y donne pas accès (expiré, ou conditions non acceptées avec ce compte)" + ); + assert_eq!(entries(&models(tmp.path())), Vec::::new()); + assert!( + hf.log + .lock() + .unwrap() + .iter() + .all(|l| l.ends_with("bytes=0-0")) + ); +} + +#[tokio::test] +async fn panne_pendant_la_deuxieme_tranche_efface_la_premiere() { + let (addr, hf) = fake_hf().await; + let tmp = tempfile::tempdir().unwrap(); + let dl = Arc::new(Downloads::new()); + for i in 1..=2 { + hf.files.lock().unwrap().insert( + format!("/m-0000{i}-of-00002.gguf"), + Arc::new(payload(1 << 20, i)), + ); + } + // La sonde passe, le transfert de la deuxième échoue (fichier retiré). + let (code, _) = dl.start( + env(), + &dirs_at(tmp.path()), + &format!("http://{addr}/m-00001-of-00002.gguf"), + "", + ); + assert_eq!(code, StatusCode::OK); + // Retire la deuxième dès que la première est écrite. + let m = models(tmp.path()); + for _ in 0..500 { + if m.join("m-00001-of-00002.gguf").exists() { + break; + } + if dl.status()[0].finished { + break; + } + tokio::time::sleep(Duration::from_millis(2)).await; + } + let st0 = dl.status()[0].clone(); + if st0.finished { + // Trop rapide pour la machine : le scénario ne se joue pas. + return; + } + hf.files.lock().unwrap().remove("/m-00002-of-00002.gguf"); + let st = wait_finished(&dl, "m-00001-of-00002.gguf").await; + if st.err.is_empty() { + return; // deuxième tranche déjà partie avant le retrait + } + assert_eq!(st.err, "lien introuvable (HTTP 404)"); + assert_eq!(entries(&m), Vec::::new(), "tranches laissées"); +} + +#[tokio::test] +async fn annulation_efface_le_part() { + let (addr, hf) = fake_hf().await; + hf.files + .lock() + .unwrap() + .insert("/m.gguf".into(), Arc::new(payload(8 << 20, 3))); + *hf.slow.lock().unwrap() = true; + let tmp = tempfile::tempdir().unwrap(); + let dl = Arc::new(Downloads::new()); + let (code, _) = dl.start( + env(), + &dirs_at(tmp.path()), + &format!("http://{addr}/m.gguf"), + "", + ); + assert_eq!(code, StatusCode::OK); + // Deuxième lancement pendant le premier : refusé. + let (code, out) = dl.start( + env(), + &dirs_at(tmp.path()), + &format!("http://{addr}/m.gguf"), + "", + ); + assert_eq!(code, StatusCode::CONFLICT); + assert_eq!(out["error"], "téléchargement déjà en cours pour m.gguf"); + let part = models(tmp.path()).join("m.gguf.part"); + for _ in 0..500 { + if part.exists() { + break; + } + tokio::time::sleep(Duration::from_millis(5)).await; + } + assert!(part.exists(), "pas de .part en cours"); + assert!(dl.cancel("m.gguf")); + assert!(!dl.cancel("autre.gguf")); + let st = wait_finished(&dl, "m.gguf").await; + assert!(st.canceled && st.err.is_empty(), "{st:?}"); + assert_eq!(st.speed, 0); + assert_eq!(entries(&models(tmp.path())), Vec::::new()); +} + +#[tokio::test] +async fn refus_avant_de_lancer() { + let tmp = tempfile::tempdir().unwrap(); + let dl = Arc::new(Downloads::new()); + let d = dirs_at(tmp.path()); + let (code, out) = dl.start(env(), &d, "pas un lien", ""); + assert_eq!( + (code, out["error"].as_str()), + ( + StatusCode::BAD_REQUEST, + Some("lien invalide (http/https attendu)") + ) + ); + let (code, out) = dl.start(env(), &d, "http://h/m.gguf", "/etc"); + assert_eq!(code, StatusCode::BAD_REQUEST); + assert_eq!( + out["error"], + "dossier non autorisé : /etc — ajoute-le dans « Dossiers de modèles »" + ); + assert!(dl.status().is_empty()); +} + +// --------------------------------------------------------------------------- +// Par la façade +// --------------------------------------------------------------------------- + +#[tokio::test] +async fn routes_servies_par_la_facade() { + let (addr, hf) = fake_hf().await; + hf.files.lock().unwrap().insert( + "/u/r/resolve/main/m.gguf".into(), + Arc::new(payload(1 << 20, 9)), + ); + let tmp = tempfile::tempdir().unwrap(); + let home = tmp.path().canonicalize().unwrap(); + let (facade, go, _stop) = fakego::start_downloads(home.clone()).await; + let url = format!("http://{addr}/u/r/resolve/main/m.gguf"); + let body = format!(r#"{{"url":"{url}","dir":""}}"#); + + // Sonde : taille, dossier, espace. + let (code, b) = fakego::send(facade, "POST", "/api/models/download/probe", &[], &body).await; + assert_eq!(code, StatusCode::OK, "{b:?}"); + let v: Value = serde_json::from_slice(&b).unwrap(); + assert_eq!(v["ok"], true); + assert_eq!(v["size"], 1 << 20); + assert_eq!(v["parts"], 1); + assert_eq!(v["enough"], true); + assert_eq!(v["dir"], models(&home).to_string_lossy().as_ref()); + + // Lancement, suivi, puis la sonde dit « déjà là ». + let (code, b) = fakego::send(facade, "POST", "/api/models/download", &[], &body).await; + assert_eq!(code, StatusCode::OK, "{b:?}"); + let mut done = false; + for _ in 0..500 { + let (_, _, b) = fakego::get(facade, "/api/models/download/status", &[]).await; + let v: Value = serde_json::from_slice(&b).unwrap(); + if v[0]["finished"] == true { + assert_eq!(v[0]["error"], ""); + assert_eq!(v[0]["done"], 1 << 20); + done = true; + break; + } + tokio::time::sleep(Duration::from_millis(10)).await; + } + assert!(done); + let (_, b) = fakego::send(facade, "POST", "/api/models/download/probe", &[], &body).await; + let v: Value = serde_json::from_slice(&b).unwrap(); + assert_eq!( + (v["exists"].as_bool(), v["value"].as_str()), + (Some(true), Some("m.gguf")) + ); + assert_eq!(v["size"], 1 << 20); + + // Annuler l'inconnu ; corps vide. + let (code, b) = fakego::send( + facade, + "POST", + "/api/models/download/cancel", + &[], + r#"{"filename":"../x.gguf"}"#, + ) + .await; + assert_eq!(code, StatusCode::NOT_FOUND); + assert_eq!( + serde_json::from_slice::(&b).unwrap()["error"], + "aucun téléchargement pour x.gguf" + ); + let (code, b) = fakego::send(facade, "POST", "/api/models/download", &[], "").await; + assert_eq!( + ( + code, + serde_json::from_slice::(&b).unwrap()["error"].as_str() + ), + (StatusCode::BAD_REQUEST, Some("EOF")) + ); + + // Clé de pilotage posée : refus sans elle, la remise du Go passe. + go.set("state", "web_key", "cle"); + let (code, _, _) = fakego::get(facade, "/api/models/download/status", &[]).await; + assert_eq!(code, StatusCode::UNAUTHORIZED); + let (code, _, _) = fakego::get( + facade, + "/api/models/download/status", + &[("x-loki-internal", fakego::JETON)], + ) + .await; + assert_eq!(code, StatusCode::OK); + // Rien n'est parti au Go. + assert!( + !go.relayed + .lock() + .unwrap() + .iter() + .any(|p| p.contains("download")), + "{:?}", + go.relayed.lock().unwrap() + ); +} + +#[tokio::test] +async fn sans_la_variable_les_routes_restent_au_go() { + let tmp = tempfile::tempdir().unwrap(); + let (facade, go, _stop) = fakego::start(tmp.path().to_path_buf()).await; + let (_, _, b) = fakego::get(facade, "/api/models/download/status", &[]).await; + assert_eq!(&b[..], b"go:/api/models/download/status"); + assert!( + go.relayed + .lock() + .unwrap() + .iter() + .any(|p| p == "/api/models/download/status") + ); +} + +#[test] +fn part_perimes_vides_dans_chaque_dossier() { + let a = tempfile::tempdir().unwrap(); + let b = tempfile::tempdir().unwrap(); + for d in [a.path(), b.path()] { + std::fs::write(d.join("x.gguf.part"), b"x").unwrap(); + std::fs::write(d.join("X.GGUF.PART"), b"x").unwrap(); + std::fs::write(d.join("y.gguf"), b"y").unwrap(); + } + downloads::clean_stale_part_files(&[ + a.path().to_string_lossy().into_owned(), + b.path().to_string_lossy().into_owned(), + "/nexiste/pas".into(), + ]); + for d in [a.path(), b.path()] { + // Le motif du Go est sensible à la casse. + assert_eq!(entries(d), ["X.GGUF.PART", "y.gguf"]); + } +} diff --git a/rust/loki-rs/tests/lot2.rs b/rust/loki-rs/tests/lot2.rs index 5c789fa..0453b79 100644 --- a/rust/loki-rs/tests/lot2.rs +++ b/rust/loki-rs/tests/lot2.rs @@ -94,6 +94,7 @@ async fn banc(cle: Option<&str>) -> Banc { service: "loki-engine".into(), }, chat: None, + downloads: None, }; tokio::spawn(loki_rs::proxy::serve(l, cfg, async move { let _ = rx.await; diff --git a/rust/loki-rs/tests/parity_models/download.json b/rust/loki-rs/tests/parity_models/download.json new file mode 100644 index 0000000..ef9c430 --- /dev/null +++ b/rust/loki-rs/tests/parity_models/download.json @@ -0,0 +1,314 @@ +{ + "normalize": [ + { + "in": "", + "err": "lien vide" + }, + { + "in": " ", + "err": "lien vide" + }, + { + "in": "ftp://h/a.gguf", + "err": "lien invalide (http/https attendu)" + }, + { + "in": "pas un lien", + "err": "lien invalide (http/https attendu)" + }, + { + "in": "https://exemple.org/dossier/", + "err": "le lien doit pointer vers un fichier .gguf" + }, + { + "in": "https://huggingface.co/u/r/blob/main/m.gguf", + "url": "https://huggingface.co/u/r/resolve/main/m.gguf", + "name": "m.gguf", + "urls": [ + "https://huggingface.co/u/r/resolve/main/m.gguf" + ], + "names": [ + "m.gguf" + ] + }, + { + "in": "https://huggingface.co/u/r/blob/main/blob/m.gguf", + "url": "https://huggingface.co/u/r/resolve/main/blob/m.gguf", + "name": "m.gguf", + "urls": [ + "https://huggingface.co/u/r/resolve/main/blob/m.gguf" + ], + "names": [ + "m.gguf" + ] + }, + { + "in": "https://huggingface.co/u/r/resolve/main/m-Q4_K_M.gguf?download=true", + "url": "https://huggingface.co/u/r/resolve/main/m-Q4_K_M.gguf?download=true", + "name": "m-Q4_K_M.gguf", + "urls": [ + "https://huggingface.co/u/r/resolve/main/m-Q4_K_M.gguf?download=true" + ], + "names": [ + "m-Q4_K_M.gguf" + ] + }, + { + "in": " https://huggingface.co/u/r/blob/main/m.bin ", + "err": "le lien doit pointer vers un fichier .gguf" + }, + { + "in": "http://exemple.org/modeles/M.GGUF", + "url": "http://exemple.org/modeles/M.GGUF", + "name": "M.GGUF", + "urls": [ + "http://exemple.org/modeles/M.GGUF" + ], + "names": [ + "M.GGUF" + ] + }, + { + "in": "https://huggingface.co/u/r/blob/main/Q4/m-Q4-00002-of-00003.gguf", + "url": "https://huggingface.co/u/r/resolve/main/Q4/m-Q4-00002-of-00003.gguf", + "name": "m-Q4-00002-of-00003.gguf", + "urls": [ + "https://huggingface.co/u/r/resolve/main/Q4/m-Q4-00001-of-00003.gguf", + "https://huggingface.co/u/r/resolve/main/Q4/m-Q4-00002-of-00003.gguf", + "https://huggingface.co/u/r/resolve/main/Q4/m-Q4-00003-of-00003.gguf" + ], + "names": [ + "m-Q4-00001-of-00003.gguf", + "m-Q4-00002-of-00003.gguf", + "m-Q4-00003-of-00003.gguf" + ] + }, + { + "in": "https://huggingface.co/u/r/resolve/main/m-00001-of-00012.gguf", + "url": "https://huggingface.co/u/r/resolve/main/m-00001-of-00012.gguf", + "name": "m-00001-of-00012.gguf", + "urls": [ + "https://huggingface.co/u/r/resolve/main/m-00001-of-00012.gguf", + "https://huggingface.co/u/r/resolve/main/m-00002-of-00012.gguf", + "https://huggingface.co/u/r/resolve/main/m-00003-of-00012.gguf", + "https://huggingface.co/u/r/resolve/main/m-00004-of-00012.gguf", + "https://huggingface.co/u/r/resolve/main/m-00005-of-00012.gguf", + "https://huggingface.co/u/r/resolve/main/m-00006-of-00012.gguf", + "https://huggingface.co/u/r/resolve/main/m-00007-of-00012.gguf", + "https://huggingface.co/u/r/resolve/main/m-00008-of-00012.gguf", + "https://huggingface.co/u/r/resolve/main/m-00009-of-00012.gguf", + "https://huggingface.co/u/r/resolve/main/m-00010-of-00012.gguf", + "https://huggingface.co/u/r/resolve/main/m-00011-of-00012.gguf", + "https://huggingface.co/u/r/resolve/main/m-00012-of-00012.gguf" + ], + "names": [ + "m-00001-of-00012.gguf", + "m-00002-of-00012.gguf", + "m-00003-of-00012.gguf", + "m-00004-of-00012.gguf", + "m-00005-of-00012.gguf", + "m-00006-of-00012.gguf", + "m-00007-of-00012.gguf", + "m-00008-of-00012.gguf", + "m-00009-of-00012.gguf", + "m-00010-of-00012.gguf", + "m-00011-of-00012.gguf", + "m-00012-of-00012.gguf" + ] + }, + { + "in": "https://exemple.org/m-1-of-2.gguf", + "url": "https://exemple.org/m-1-of-2.gguf", + "name": "m-1-of-2.gguf", + "urls": [ + "https://exemple.org/m-1-of-2.gguf" + ], + "names": [ + "m-1-of-2.gguf" + ] + }, + { + "in": "https://exemple.org/m-02-of-01.gguf", + "url": "https://exemple.org/m-02-of-01.gguf", + "name": "m-02-of-01.gguf", + "urls": [ + "https://exemple.org/m-02-of-01.gguf" + ], + "names": [ + "m-02-of-01.gguf" + ] + } + ], + "content_range": { + "": 0, + "bytes 0-0/ 99 ": 99, + "bytes 0-0/*": 0, + "bytes 0-0/0": 0, + "bytes 0-0/12345": 12345 + }, + "source": [ + { + "status": 401, + "code": "GatedRepo", + "url": "https://huggingface.co/auteur/depot/resolve/main/m.gguf", + "token_set": false, + "err": "dépôt à accès restreint : accepte ses conditions sur huggingface.co/auteur/depot — puis renseigne le jeton Hugging Face (réglage « Jeton Hugging Face », ou variable d'environnement HF_TOKEN)" + }, + { + "status": 401, + "code": "GatedRepo", + "url": "https://huggingface.co/auteur/depot/resolve/main/m.gguf", + "token_set": true, + "err": "dépôt à accès restreint : accepte ses conditions sur huggingface.co/auteur/depot — le jeton Hugging Face enregistré n'y donne pas accès (expiré, ou conditions non acceptées avec ce compte)" + }, + { + "status": 403, + "code": "RepoNotFound", + "url": "https://huggingface.co/auteur/depot/resolve/main/m.gguf", + "token_set": false, + "err": "dépôt privé ou inexistant : auteur/depot — puis renseigne le jeton Hugging Face (réglage « Jeton Hugging Face », ou variable d'environnement HF_TOKEN)" + }, + { + "status": 401, + "code": "GatedRepo", + "url": "https://exemple.org/m.gguf", + "token_set": false, + "err": "dépôt à accès restreint : ses conditions doivent être acceptées sur huggingface.co — puis renseigne le jeton Hugging Face (réglage « Jeton Hugging Face », ou variable d'environnement HF_TOKEN)" + }, + { + "status": 403, + "code": "", + "url": "https://huggingface.co/auteur/depot/resolve/main/m.gguf", + "token_set": true, + "err": "accès refusé par Hugging Face sur auteur/depot — le jeton Hugging Face enregistré n'y donne pas accès (expiré, ou conditions non acceptées avec ce compte)" + }, + { + "status": 403, + "code": "", + "url": "https://exemple.org/m.gguf", + "token_set": false, + "err": "accès refusé par la source — puis renseigne le jeton Hugging Face (réglage « Jeton Hugging Face », ou variable d'environnement HF_TOKEN)" + }, + { + "status": 404, + "code": "EntryNotFound", + "url": "https://huggingface.co/auteur/depot/resolve/main/m.gguf", + "token_set": false, + "err": "fichier « m.gguf » absent du dépôt (HTTP 404) — dépôt réorganisé depuis la copie du lien, ou fragment jamais publié (table PLE livrée à part ?)" + }, + { + "status": 404, + "code": "EntryNotFound", + "url": "https://exemple.org/", + "token_set": false, + "err": "fichier absent du dépôt (HTTP 404) — dépôt réorganisé depuis la copie du lien, ou fragment jamais publié (table PLE livrée à part ?)" + }, + { + "status": 404, + "code": "RevisionNotFound", + "url": "https://huggingface.co/auteur/depot/resolve/main/m.gguf", + "token_set": false, + "err": "révision introuvable dans le dépôt (HTTP 404)" + }, + { + "status": 404, + "code": "", + "url": "https://huggingface.co/auteur/depot/resolve/main/m.gguf", + "token_set": false, + "err": "lien introuvable (HTTP 404)" + }, + { + "status": 416, + "code": "", + "url": "https://huggingface.co/auteur/depot/resolve/main/m.gguf", + "token_set": false, + "err": "la source refuse la plage d'octets demandée (HTTP 416) — fichier modifié pendant le transfert ?" + }, + { + "status": 429, + "code": "", + "url": "https://huggingface.co/auteur/depot/resolve/main/m.gguf", + "token_set": false, + "err": "trop de requêtes vers la source (HTTP 429) — réessaie dans quelques minutes" + }, + { + "status": 502, + "code": "", + "url": "https://huggingface.co/auteur/depot/resolve/main/m.gguf", + "token_set": false, + "err": "la source est en panne (HTTP 502) — réessaie plus tard" + }, + { + "status": 418, + "code": "", + "url": "https://huggingface.co/auteur/depot/resolve/main/m.gguf", + "token_set": false, + "err": "HTTP 418 depuis la source" + } + ], + "hf_host": { + "HUGGINGFACE.CO:443": true, + "cdn-lfs.huggingface.co": true, + "evilhuggingface.co": false, + "exemple.org": false, + "hf.co": true, + "huggingface.co": true, + "huggingface.co.evil.org": false, + "x.hf.co": true + }, + "hf_repo": { + "https://exemple.org/a/b/resolve/main/x.gguf": "", + "https://huggingface.co/-a/b/resolve/main/x.gguf": "", + "https://huggingface.co/a/b": "", + "https://huggingface.co/a/b/blob/main/x.gguf": "a/b", + "https://huggingface.co/a/b/tree/main": "", + "https://huggingface.co/auteur/depot/resolve/main/m.gguf": "auteur/depot" + }, + "dest": [ + { + "name": "a.gguf", + "dir": "", + "path": "{{HOME}}/models/a.gguf" + }, + { + "name": " ../../x.gguf ", + "dir": "", + "path": "{{HOME}}/models/x.gguf" + }, + { + "name": "x.bin", + "dir": "", + "err": "seuls les fichiers .gguf sont acceptés" + }, + { + "name": "", + "dir": "", + "err": "nom de modèle invalide" + }, + { + "name": "/", + "dir": "", + "err": "nom de modèle invalide" + }, + { + "name": "a.gguf", + "dir": "/etc", + "err": "dossier non autorisé : /etc — ajoute-le dans « Dossiers de modèles »" + }, + { + "name": "a.gguf", + "dir": "{{HOME}}/disque", + "path": "{{HOME}}/disque/a.gguf" + }, + { + "name": "a.gguf", + "dir": "{{HOME}}/disque/", + "path": "{{HOME}}/disque/a.gguf" + }, + { + "name": "a.gguf", + "dir": "{{HOME}}/models", + "path": "{{HOME}}/models/a.gguf" + } + ] +} diff --git a/rust/loki-rs/tests/proxy.rs b/rust/loki-rs/tests/proxy.rs index 575f6ca..2f5da3c 100644 --- a/rust/loki-rs/tests/proxy.rs +++ b/rust/loki-rs/tests/proxy.rs @@ -100,6 +100,7 @@ async fn facade(backend: SocketAddr) -> (SocketAddr, oneshot::Sender<()>) { service: "loki-engine".into(), }, chat: None, + downloads: None, }; tokio::spawn(loki_rs::proxy::serve(l, cfg, async move { let _ = rx.await; From a33e5ccffe8ffbd29fefda14c6c48ab55808ae6c Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 15 Oct 2026 13:25:45 +0000 Subject: [PATCH 02/52] =?UTF-8?q?Lot=208=20:=20toutes=20les=20routes=20des?= =?UTF-8?q?=20mod=C3=A8les=20tenues=20par=20la=20fa=C3=A7ade=20(LOKI=5FMOD?= =?UTF-8?q?ELS=3Drust)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 Claude-Session: https://claude.ai/code/session_016tcGfJWefW5kUGoC2wL8cv --- README.md | 2 +- ...0-15-rust-migration-lot8-modeles-design.md | 101 ++ ...t-migration-lot8-telechargements-design.md | 76 -- internal/loki/facade_chat.go | 6 +- internal/loki/facade_chat_test.go | 35 +- internal/loki/facade_downloads.go | 45 - internal/loki/facade_models.go | 52 + internal/loki/parity_models_test.go | 351 +++++++ internal/loki/web_server.go | 7 +- rust/loki-rs/src/api.rs | 5 +- rust/loki-rs/src/downloads.rs | 2 +- rust/loki-rs/src/hf.rs | 735 ++++++++++++++ rust/loki-rs/src/lib.rs | 2 + rust/loki-rs/src/main.rs | 32 +- rust/loki-rs/src/models.rs | 591 +++++++++++ rust/loki-rs/src/proxy.rs | 20 +- rust/loki-rs/tests/common/fakego.rs | 22 +- rust/loki-rs/tests/downloads.rs | 4 +- rust/loki-rs/tests/lot2.rs | 2 +- rust/loki-rs/tests/models.rs | 557 +++++++++++ rust/loki-rs/tests/parity_models/models.json | 933 ++++++++++++++++++ rust/loki-rs/tests/proxy.rs | 2 +- 22 files changed, 3395 insertions(+), 187 deletions(-) create mode 100644 docs/superpowers/specs/2026-10-15-rust-migration-lot8-modeles-design.md delete mode 100644 docs/superpowers/specs/2026-10-15-rust-migration-lot8-telechargements-design.md delete mode 100644 internal/loki/facade_downloads.go create mode 100644 internal/loki/facade_models.go create mode 100644 internal/loki/parity_models_test.go create mode 100644 rust/loki-rs/src/hf.rs create mode 100644 rust/loki-rs/src/models.rs create mode 100644 rust/loki-rs/tests/models.rs create mode 100644 rust/loki-rs/tests/parity_models/models.json diff --git a/README.md b/README.md index 98a1a9e..adfa692 100644 --- a/README.md +++ b/README.md @@ -228,7 +228,7 @@ redémarrages (volume `/data`). Variables d'environnement du conteneur : | `LOKI_FACADE` | `0` : sans façade Rust, le Go sert directement le port public (porte de sortie) | `1` | | `LOKI_GO_PORT` | port local (127.0.0.1) du serveur Go derrière la façade | `8091` | | `LOKI_CHAT` | `rust` : la conversation est tenue par la façade Rust (expérimental, migration en cours), outils fichiers et shell compris. Le Go garde les autres outils, les pièces jointes, les fichiers et l'export. Avec `PROJ_SNAPSHOT`, `KEEP_TURN_IMAGES`, `NUDGE_IN_TOOL`, `SIDE_SLOT`, `SLOT_PERSIST`, `PREWARM` ou des motifs `retry_patterns`, le Go garde la conversation (décidé au démarrage) | Go | -| `LOKI_DOWNLOADS` | `rust` : les téléchargements de modèles (`/api/models/download*`) sont menés par la façade Rust (expérimental, migration en cours). Le Go garde les autres routes des modèles | Go | +| `LOKI_MODELS` | `rust` : les routes des modèles — liste, dossiers, suppression, téléchargements, catalogue Hugging Face — sont servies par la façade Rust (expérimental, migration en cours) | Go | **Accès par nom de domaine.** Sans clé de pilotage, l'API n'accepte que les hôtes locaux (IP, `localhost`, nom sans point, `.local`/`.lan`…) et refuse diff --git a/docs/superpowers/specs/2026-10-15-rust-migration-lot8-modeles-design.md b/docs/superpowers/specs/2026-10-15-rust-migration-lot8-modeles-design.md new file mode 100644 index 0000000..d7a2504 --- /dev/null +++ b/docs/superpowers/specs/2026-10-15-rust-migration-lot8-modeles-design.md @@ -0,0 +1,101 @@ +# Lot 8 — routes des modèles tenues par la façade + +## Contexte + +Les modèles `.gguf` vivaient dans le Go : + +- la liste du sélecteur (`handleModels`, `backend_models_refs.go`) ; +- les dossiers de modèles (`backend_model_dirs.go`) ; +- la suppression (`deleteModelFile`) ; +- les téléchargements (`backend_models.go`, `backend_models_shards.go`) ; +- le catalogue Hugging Face (`backend_hf.go`, `web_hf.go`, `backend_catalog.go`). + +Lot 8 : la façade tient tout cela quand `LOKI_MODELS=rust` (expérimental ; +sans la variable rien ne change, tout part au Go). + +## Ce que sert la façade + +| Route | Module | Rôle | +|---|---|---| +| `GET /api/models` | `models.rs` | `.gguf` des dossiers déclarés (tranches 2..N exclues), taille de la famille, tranches manquantes, qui les référence (presets, configuration en service) | +| `POST /api/models/delete` | `models.rs` | refus si en service ; 409 avec la liste des presets sans `force` ; le modèle part avec toutes ses tranches, octets libérés rendus | +| `GET\|POST /api/models/dirs` | `models.rs` | dossiers de modèles, nombre de modèles, espace libre ; ajout (dossier existant) et retrait | +| `POST /api/models/download/probe` | `downloads.rs` | taille du modèle (toutes ses tranches), espace libre, « ça tient ? », ou « déjà là » avec la valeur de `MODEL=` | +| `POST /api/models/download` | `downloads.rs` | lance en arrière-plan ; 409 si déjà en cours ; « déjà là » si toutes les tranches sont présentes | +| `GET /api/models/download/status` | `downloads.rs` | état de chaque téléchargement de ce lancement | +| `POST /api/models/download/cancel` | `downloads.rs` | annule ; ce que le téléchargement a écrit est effacé | +| `GET /api/hf/search` | `hf.rs` | dépôts GGUF, matériel local joint | +| `GET /api/hf/files` | `hf.rs` | modèles, projecteurs et poids MTP d'un dépôt, verdict mémoire de chaque modèle (contexte du preset actif, projecteur du dépôt) | +| `GET\|POST /api/hf/token` | `hf.rs` | jeton vérifié auprès de Hugging Face avant d'être enregistré, jamais rendu en clair | + +Mêmes contrats (corps, codes, messages) et même mécanique que le Go, entre +autres : + +- téléchargement : + - lien de page Hugging Face (`/blob/`) converti en lien direct ; + - modèle en tranches rapatrié en entier ; + - place disque vérifiée sur le total, avec 256 Mio de marge ; jamais de + refus sur FUSE, overlayfs, NFS, SMB, Ceph ou GlusterFS ; + - plages d'octets en parallèle (`LOKI_DL_CONNS`) et reprise d'une + connexion coupée ; + - jeton envoyé aux seuls hôtes Hugging Face ; +- catalogue : réponses mises en cache (60 s pour une recherche, 10 min pour un + dépôt), cache vidé à l'enregistrement d'un jeton ; +- `.part` orphelins vidés au démarrage — par la façade : le Go ne le fait + plus, il effacerait un téléchargement qu'elle mène. + +## Côté Go (`facade_models.go`) + +Le tunnel du relais et la redistribution E2E arrivent au Go sans passer par +la façade : `withFacadeChat` lui remet ces routes, authentifiées par le +jeton interne, comme celles du chat. Façade injoignable : 502 « modèles +indisponibles : … ». + +La façade transmet `LOKI_MODELS=rust` et son adresse au Go qu'elle lance. + +## Écarts assumés + +- Une source qui ignore les plages n'est plus lue en entier pendant la sonde. +- Erreurs de transport (réseau, TLS) au format de reqwest. +- Message d'un corps JSON illisible : celui de serde (l'interface n'en envoie + jamais). +- Hôte d'un lien collé en majuscules converti comme les autres. +- Espace libre et mémoire vive inconnus hors Unix/Linux (la façade n'est + livrée que pour Linux). +- `/api/models/download/status` trié par date de début. +- Nombres flottants du matériel écrits `24.0` (le Go écrit `24`) : même + valeur JSON. + +## Parité + +Journaux tirés du Go et rejoués en Rust (`LOKI_PARITY_UPDATE=1` pour +régénérer) : + +- `internal/loki/parity_dl_test.go` → `tests/parity_models/download.json` → + `tests/downloads.rs` : lien normalisé, famille de tranches, + `Content-Range`, messages de refus, hôtes et dépôts Hugging Face, dossier + de destination ; +- `internal/loki/parity_models_test.go` → `tests/parity_models/models.json` → + `tests/models.rs` : quantification déduite du nom, découpage + d'`EXTRA_ARGS`, jeton masqué, `gated`, classement d'un dépôt, choix du + projecteur, verdicts mémoire, puis un scénario sur disque de 20 appels + (liste, dossiers, suppressions avec et sans `force`, modèle en service, + tranches, chemins refusés) rejoué à l'identique, fichiers restants et + dossiers enregistrés compris. + +Deux mutations (`replacen` → `replace` sur `/blob/`, `--mmproj` ignoré) +font échouer les rejeux. + +De bout en bout : + +- téléchargements contre un faux Hugging Face : trois connexions sur des + plages disjointes, source sans plages, tranches dont une présente, dépôt + verrouillé, panne pendant la deuxième tranche, annulation et 409 ; +- catalogue contre un faux Hugging Face : recherche, cache, taille LFS, + dépôt verrouillé ou absent, jeton refusé ou accepté ; +- toutes les routes par la façade (clé de pilotage, remise du Go, base du Go + écrite), et laissées au Go sans la variable. + +## Reste + +- Essai réel sur une installation avant d'en faire le défaut. diff --git a/docs/superpowers/specs/2026-10-15-rust-migration-lot8-telechargements-design.md b/docs/superpowers/specs/2026-10-15-rust-migration-lot8-telechargements-design.md deleted file mode 100644 index 4d74c70..0000000 --- a/docs/superpowers/specs/2026-10-15-rust-migration-lot8-telechargements-design.md +++ /dev/null @@ -1,76 +0,0 @@ -# Lot 8 — téléchargements de modèles tenus par la façade - -## Contexte - -Les téléchargements de `.gguf` (Hugging Face ou lien direct) vivaient dans le -Go : `backend_models.go`, `backend_models_shards.go`, `backend_model_dirs.go`, -`backend_hf.go`. Lot 8 : la façade les tient quand `LOKI_DOWNLOADS=rust` -(expérimental ; sans la variable rien ne change, tout part au Go). - -## Ce que sert la façade (`rust/loki-rs/src/downloads.rs`) - -| Route | Rôle | -|---|---| -| `POST /api/models/download/probe` | taille du modèle (toutes ses tranches), espace libre, « ça tient ? », ou « déjà là » avec la valeur de `MODEL=` | -| `POST /api/models/download` | lance en arrière-plan ; 409 si déjà en cours ; « déjà là » si toutes les tranches sont présentes | -| `GET /api/models/download/status` | état de chaque téléchargement de ce lancement | -| `POST /api/models/download/cancel` | annule ; ce que le téléchargement a écrit est effacé | - -Mêmes contrats (corps, codes, messages) et même mécanique que le Go : - -- lien de page Hugging Face (`/blob/`) converti en lien direct ; -- modèle en tranches rapatrié en entier (la première le désigne) ; -- tranches déjà là non retéléchargées ; -- place disque vérifiée sur le total, avec 256 Mio de marge ; jamais de refus - sur FUSE, overlayfs, NFS, SMB, Ceph ou GlusterFS, où l'espace annoncé - approxime ; -- jusqu'à `LOKI_DL_CONNS` connexions (8 par défaut, 16 au plus) par tranche - quand la source honore les plages, 16 Mio par connexion au moins ; -- connexion coupée reprise où elle en était (4 essais) ; -- progression sur l'ensemble, vitesse lissée ; -- jeton Hugging Face (réglage, sinon `HF_TOKEN`) envoyé aux seuls hôtes - Hugging Face ; -- refus de la source traduits en phrase qui dit quoi faire (dépôt verrouillé, - jeton absent ou insuffisant, fichier absent, quota…) ; -- `.part` orphelins vidés au démarrage — par la façade : le Go ne le fait - plus, il effacerait un téléchargement qu'elle mène. - -## Côté Go (`facade_downloads.go`) - -Le tunnel du relais et la redistribution E2E arrivent au Go sans passer par -la façade : `withFacadeChat` lui remet les quatre routes, authentifiées par le -jeton interne, comme celles du chat. Façade injoignable : 502 -« téléchargements indisponibles : … ». - -La façade transmet `LOKI_DOWNLOADS=rust` et son adresse au Go qu'elle lance. - -## Écarts assumés - -- Une source qui ignore les plages (200 à la sonde) n'est plus lue en entier - pendant la sonde. -- Erreur de transport (réseau, TLS) au format de reqwest. -- Hôte d'un lien collé en majuscules converti comme les autres. -- Espace libre inconnu hors Unix : rien n'est refusé (comme une mesure ratée - côté Go). -- `/status` trié par date de début (le Go rendait l'ordre d'une map). - -## Parité - -`internal/loki/parity_dl_test.go` tire du Go le journal des fonctions pures -(lien normalisé, famille de tranches, `Content-Range`, messages de refus, -hôtes et dépôts Hugging Face, dossier de destination), rangé dans -`rust/loki-rs/tests/parity_models/download.json` ; `tests/downloads.rs` le -rejoue (`LOKI_PARITY_UPDATE=1` pour régénérer). Une mutation -(`replacen` → `replace` sur `/blob/`) fait échouer le rejeu. - -`tests/downloads.rs` mène aussi le téléchargement de bout en bout contre un -faux Hugging Face : trois connexions sur des plages disjointes, source sans -plages, tranches avec une déjà présente, dépôt verrouillé refusé dès la sonde, -panne en cours de deuxième tranche, annulation, routes par la façade (clé de -pilotage, remise du Go), routes laissées au Go sans la variable. - -## Reste - -- Les autres routes des modèles (`/api/models`, suppression, dossiers, liste - Hugging Face, vision) restent au Go. -- Essai réel sur une installation avant d'en faire le défaut. diff --git a/internal/loki/facade_chat.go b/internal/loki/facade_chat.go index 225c809..453d58f 100644 --- a/internal/loki/facade_chat.go +++ b/internal/loki/facade_chat.go @@ -141,15 +141,15 @@ var facadeChatRoutes = map[string]bool{ } // withFacadeChat remet la requête à la façade quand elle tient la -// conversation — ou les téléchargements de modèles (facade_downloads.go). +// conversation — ou les routes des modèles (facade_models.go). // Posé APRÈS requireWebAuth : seule une requête authentifiée ici part, avec le // jeton interne qui en tient lieu là-bas. func withFacadeChat(path string, h http.HandlerFunc) http.HandlerFunc { held, unavailable := facadeChat, "conversation indisponible : " switch { case facadeChatRoutes[path]: - case facadeDownloadRoutes[path]: - held, unavailable = facadeDownloads, "téléchargements indisponibles : " + case facadeModelRoutes[path]: + held, unavailable = facadeModels, "modèles indisponibles : " default: return h } diff --git a/internal/loki/facade_chat_test.go b/internal/loki/facade_chat_test.go index 9b8ee5d..048dcfb 100644 --- a/internal/loki/facade_chat_test.go +++ b/internal/loki/facade_chat_test.go @@ -306,30 +306,31 @@ func TestFacadeReglageQuiGardeLaConversation(t *testing.T) { } } -// LOKI_DOWNLOADS=rust : les routes des téléchargements de modèles partent à la -// façade (avec ou sans LOKI_CHAT), les autres routes des modèles restent ici ; -// sans la variable, rien ne change. Façade muette : erreur qui le dit. -func TestFacadeRelaieLesTelechargements(t *testing.T) { +// LOKI_MODELS=rust : les routes des modèles partent à la façade (avec ou sans +// LOKI_CHAT), les autres restent ici ; sans la variable, rien ne change. +// Façade muette : erreur qui le dit. +func TestFacadeRelaieLesModeles(t *testing.T) { testHome(t) f := demarrerFacade(t) t.Setenv(facadeChatEnv, "") - t.Setenv(facadeDownloadsEnv, "rust") + t.Setenv(facadeModelsEnv, "rust") local := func(w http.ResponseWriter, r *http.Request) { io.WriteString(w, "go") } - for _, p := range []string{"/api/models/download", "/api/models/download/probe", - "/api/models/download/status", "/api/models/download/cancel"} { + routes := []string{"/api/models/download", "/api/models/download/probe", + "/api/models/download/status", "/api/models/download/cancel", "/api/models", + "/api/models/delete", "/api/models/dirs", "/api/hf/search", "/api/hf/files", "/api/hf/token"} + for _, p := range routes { w := httptest.NewRecorder() withFacadeChat(p, local)(w, httptest.NewRequest("POST", p, strings.NewReader(`{"url":"u"}`))) if w.Body.String() != f.reply { t.Fatalf("%s : réponse %q", p, w.Body.String()) } } - if got := f.recu(); len(got) != 4 || got[0] != `POST /api/models/download jeton {"url":"u"}` { + if got := f.recu(); len(got) != len(routes) || got[0] != `POST /api/models/download jeton {"url":"u"}` { t.Fatalf("reçu par la façade : %v", got) } - // Le chat, lui, reste ici (LOKI_CHAT absent), et les autres routes des - // modèles aussi. - for _, p := range []string{"/api/chat/send", "/api/models", "/api/models/delete"} { + // Le chat, lui, reste ici (LOKI_CHAT absent), et les routes voisines aussi. + for _, p := range []string{"/api/chat/send", "/api/model/layers", "/api/presets"} { w := httptest.NewRecorder() withFacadeChat(p, local)(w, httptest.NewRequest("POST", p, nil)) if w.Body.String() != "go" { @@ -340,11 +341,11 @@ func TestFacadeRelaieLesTelechargements(t *testing.T) { f.srv.Close() w := httptest.NewRecorder() withFacadeChat("/api/models/download/status", local)(w, httptest.NewRequest("GET", "/api/models/download/status", nil)) - if w.Code != http.StatusBadGateway || !strings.Contains(w.Body.String(), "téléchargements indisponibles : ") { + if w.Code != http.StatusBadGateway || !strings.Contains(w.Body.String(), "modèles indisponibles : ") { t.Fatalf("façade muette : %d %s", w.Code, w.Body.String()) } - // Sans LOKI_DOWNLOADS=rust : tout reste ici. - t.Setenv(facadeDownloadsEnv, "") + // Sans LOKI_MODELS=rust : tout reste ici. + t.Setenv(facadeModelsEnv, "") w = httptest.NewRecorder() withFacadeChat("/api/models/download", local)(w, httptest.NewRequest("POST", "/api/models/download", nil)) if w.Body.String() != "go" { @@ -353,7 +354,7 @@ func TestFacadeRelaieLesTelechargements(t *testing.T) { } // Les .part orphelins ne sont plus vidés par ce Go quand la façade tient les -// téléchargements : elle les vide à son démarrage, et un Go relancé seul +// modèles : elle les vide à son démarrage, et un Go relancé seul // effacerait celui qu'elle mène. func TestFacadeTelechargementsGardeLesPart(t *testing.T) { home := testHome(t) @@ -367,12 +368,12 @@ func TestFacadeTelechargementsGardeLesPart(t *testing.T) { } t.Setenv(internalTokenEnv, jetonTest) t.Setenv(facadeAddrEnv, "127.0.0.1:1") - t.Setenv(facadeDownloadsEnv, "rust") + t.Setenv(facadeModelsEnv, "rust") cleanStartupPartFiles() if _, err := os.Stat(part); err != nil { t.Fatalf(".part effacé alors que la façade tient les téléchargements : %v", err) } - t.Setenv(facadeDownloadsEnv, "") + t.Setenv(facadeModelsEnv, "") cleanStartupPartFiles() if _, err := os.Stat(part); !os.IsNotExist(err) { t.Fatalf(".part orphelin gardé : %v", err) diff --git a/internal/loki/facade_downloads.go b/internal/loki/facade_downloads.go deleted file mode 100644 index c35e074..0000000 --- a/internal/loki/facade_downloads.go +++ /dev/null @@ -1,45 +0,0 @@ -package loki - -import ( - "os" - "strings" -) - -// facade_downloads.go — téléchargements de modèles tenus par la façade Rust -// (LOKI_DOWNLOADS=rust, lot « modèles » de la migration ; expérimental, le Go -// reste le défaut). -// -// La façade sert elle-même /api/models/download* sur le port public. Le tunnel -// du relais et sa redistribution E2E arrivent pourtant ICI : ces routes lui -// sont remises, sans quoi un téléchargement lancé depuis le téléphone vivrait -// dans ce process, que le poste — servi par la façade — ne verrait pas. -// -// Les .part orphelins sont vidés par la façade à son démarrage : les vider ici -// (un Go relancé seul après un plantage) effacerait sous ses pieds un -// téléchargement qu'elle mène. - -const facadeDownloadsEnv = "LOKI_DOWNLOADS" - -// facadeDownloads : la façade tient-elle les téléchargements de modèles ? Il -// faut aussi son adresse et le jeton interne, sans lesquels rien ne peut lui -// être remis. -func facadeDownloads() bool { - return strings.EqualFold(strings.TrimSpace(os.Getenv(facadeDownloadsEnv)), "rust") && - facadeAddr() != "" && internalToken() != "" -} - -// cleanStartupPartFiles vide les .part orphelins au démarrage — sauf quand la -// façade tient les téléchargements : elle le fait à SON démarrage. -func cleanStartupPartFiles() { - if !facadeDownloads() { - cleanStalePartFiles() - } -} - -// facadeDownloadRoutes : les routes que la façade sert alors (downloads.rs). -var facadeDownloadRoutes = map[string]bool{ - "/api/models/download": true, - "/api/models/download/probe": true, - "/api/models/download/status": true, - "/api/models/download/cancel": true, -} diff --git a/internal/loki/facade_models.go b/internal/loki/facade_models.go new file mode 100644 index 0000000..8c0f776 --- /dev/null +++ b/internal/loki/facade_models.go @@ -0,0 +1,52 @@ +package loki + +import ( + "os" + "strings" +) + +// facade_models.go — routes des modèles tenues par la façade Rust +// (LOKI_MODELS=rust, lot 8 de la migration ; expérimental, le Go reste le +// défaut) : liste du sélecteur, dossiers, suppression, téléchargements et +// catalogue Hugging Face. +// +// La façade sert elle-même ces routes sur le port public. Le tunnel du relais +// et sa redistribution E2E arrivent pourtant ICI : elles lui sont remises, sans +// quoi un téléchargement lancé depuis le téléphone vivrait dans ce process, que +// le poste — servi par la façade — ne verrait pas. +// +// Les .part orphelins sont vidés par la façade à son démarrage : les vider ici +// (un Go relancé seul après un plantage) effacerait sous ses pieds un +// téléchargement qu'elle mène. + +const facadeModelsEnv = "LOKI_MODELS" + +// facadeModels : la façade tient-elle les routes des modèles ? Il faut aussi +// son adresse et le jeton interne, sans lesquels rien ne peut lui être remis. +func facadeModels() bool { + return strings.EqualFold(strings.TrimSpace(os.Getenv(facadeModelsEnv)), "rust") && + facadeAddr() != "" && internalToken() != "" +} + +// cleanStartupPartFiles vide les .part orphelins au démarrage — sauf quand la +// façade tient les téléchargements : elle le fait à SON démarrage. +func cleanStartupPartFiles() { + if !facadeModels() { + cleanStalePartFiles() + } +} + +// facadeModelRoutes : les routes que la façade sert alors (models.rs, +// downloads.rs, hf.rs). +var facadeModelRoutes = map[string]bool{ + "/api/models": true, + "/api/models/delete": true, + "/api/models/dirs": true, + "/api/hf/search": true, + "/api/hf/files": true, + "/api/hf/token": true, + "/api/models/download": true, + "/api/models/download/probe": true, + "/api/models/download/status": true, + "/api/models/download/cancel": true, +} diff --git a/internal/loki/parity_models_test.go b/internal/loki/parity_models_test.go new file mode 100644 index 0000000..08edf26 --- /dev/null +++ b/internal/loki/parity_models_test.go @@ -0,0 +1,351 @@ +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) + } +} diff --git a/internal/loki/web_server.go b/internal/loki/web_server.go index 99d3ffc..a1bd310 100644 --- a/internal/loki/web_server.go +++ b/internal/loki/web_server.go @@ -131,7 +131,7 @@ func newWebMux() *http.ServeMux { MCPPrewarm() // Un téléchargement de modèle coupé net (crash, restart du service) laisse un // .part orphelin non reprenable : on nettoie au démarrage (sauf quand la - // façade tient les téléchargements, facade_downloads.go). + // façade tient les modèles, facade_models.go). cleanStartupPartFiles() // Les fichiers des versions précédentes vivaient dans un pot commun : on les // range dans le dossier de leur discussion (une fois par process). @@ -219,9 +219,8 @@ func newWebMux() *http.ServeMux { mountOAI(mux) // api enregistre une route /api/* protégée par la clé de pilotage (web_auth.go). // withFacadeChat : routes du chat remises à la façade quand elle tient la - // conversation (LOKI_CHAT=rust, facade_chat.go), celles des téléchargements - // de modèles quand elle les tient (LOKI_DOWNLOADS=rust, - // facade_downloads.go) ; sans effet sinon. + // conversation (LOKI_CHAT=rust, facade_chat.go), celles des modèles quand + // elle les tient (LOKI_MODELS=rust, facade_models.go) ; sans effet sinon. api := func(path string, h http.HandlerFunc) { mux.HandleFunc(path, requireWebAuth(withFacadeChat(path, h))) } api("/api/ping", handlePing) api("/api/status", handleStatus) diff --git a/rust/loki-rs/src/api.rs b/rust/loki-rs/src/api.rs index 5caf66c..870e36c 100644 --- a/rust/loki-rs/src/api.rs +++ b/rust/loki-rs/src/api.rs @@ -26,9 +26,10 @@ pub struct Ctx { pub engine: engine::Paths, /// Conversation tenue par la façade (`LOKI_CHAT=rust`) ; `None` : au Go. pub chat: Option>, - /// Téléchargements de modèles tenus par la façade (`LOKI_DOWNLOADS=rust`) ; + /// Routes des modèles (liste, dossiers, suppression, téléchargements, + /// catalogue Hugging Face) tenues par la façade (`LOKI_MODELS=rust`) ; /// `None` : au Go. - pub downloads: Option>, + pub models: Option>, /// Jeton de l'API interne : une requête qui le porte vient du Go. pub internal_token: String, } diff --git a/rust/loki-rs/src/downloads.rs b/rust/loki-rs/src/downloads.rs index 08b84a5..44fe294 100644 --- a/rust/loki-rs/src/downloads.rs +++ b/rust/loki-rs/src/downloads.rs @@ -2,7 +2,7 @@ //! des téléchargements de `backend_models.go`, `backend_models_shards.go`, //! `backend_model_dirs.go` et `backend_hf.go` (lot 8 de la migration). //! -//! Tenus par la façade quand `LOKI_DOWNLOADS=rust` (expérimental ; le Go +//! Tenus par la façade quand `LOKI_MODELS=rust` (expérimental ; le Go //! par défaut) : `POST /api/models/download`, `/probe`, `/cancel`, //! `GET /api/models/download/status`. Le Go remet alors à la façade ces //! routes quand elles lui arrivent par le tunnel du relais, et ne vide plus diff --git a/rust/loki-rs/src/hf.rs b/rust/loki-rs/src/hf.rs new file mode 100644 index 0000000..ec59b80 --- /dev/null +++ b/rust/loki-rs/src/hf.rs @@ -0,0 +1,735 @@ +//! Catalogue Hugging Face — port de `backend_hf.go`, `web_hf.go` et +//! `backend_catalog.go` (lot 8 de la migration), servi par la façade quand +//! `LOKI_MODELS=rust` : +//! +//! - `GET /api/hf/search?q=…` : dépôts GGUF ; +//! - `GET /api/hf/files?repo=auteur/dépôt` : modèles, projecteurs et poids +//! MTP du dépôt, chaque modèle avec son verdict mémoire ; +//! - `GET|POST /api/hf/token` : le jeton (dépôts verrouillés), vérifié avant +//! d'être enregistré, jamais rendu en clair. +//! +//! Rien n'est téléchargé ici : les liens produits passent par +//! [`crate::downloads`]. Réponses de l'API mises en cache (60 s pour une +//! recherche, 10 min pour un dépôt) ; un nouveau jeton vide le cache. +//! +//! Écarts assumés : erreur de transport au format de reqwest ; mémoire vive +//! inconnue hors Linux (la façade n'est livrée que pour Linux). + +use std::collections::HashMap; +use std::sync::Mutex; +use std::time::{Duration, Instant}; + +use bytes::Bytes; +use hyper::body::Incoming; +use hyper::{Method, Request, Response, StatusCode}; +use serde::{Deserialize, Serialize}; +use serde_json::{Value, json}; + +use crate::api::Ctx; +use crate::auth::json as send_json; +use crate::gguf; +use crate::models::quant_from_name; +use crate::paths::base_name; +use crate::proxy::Body; +use crate::store::BK_STATE; + +pub const HF_HOST: &str = "https://huggingface.co"; +const SEARCH_TTL: Duration = Duration::from_secs(60); +const FILES_TTL: Duration = Duration::from_secs(600); +const MAX_REPOS: usize = 25; +const MAX_QUERY: usize = 100; +const TIMEOUT: Duration = Duration::from_secs(10); + +/// Routes servies ici. +pub fn handles(path: &str) -> bool { + matches!(path, "/api/hf/search" | "/api/hf/files" | "/api/hf/token") +} + +// --------------------------------------------------------------------------- +// Fonctions pures +// --------------------------------------------------------------------------- + +/// `hfRepoRe`. +pub fn repo_ok(repo: &str) -> bool { + let part = |s: &str| { + let mut c = s.chars(); + c.next().is_some_and(|f| f.is_ascii_alphanumeric()) + && c.all(|ch| ch.is_ascii_alphanumeric() || matches!(ch, '.' | '_' | '-')) + }; + match repo.split_once('/') { + Some((a, b)) => part(a) && part(b), + None => false, + } +} + +/// `maskHFToken` : assez de caractères pour reconnaître le jeton posé. +pub fn mask_hf_token(t: &str) -> String { + let t = t.trim(); + if t.is_empty() { + return String::new(); + } + let b = t.as_bytes(); + let s = |r: std::ops::Range| String::from_utf8_lossy(&b[r]).into_owned(); + if b.len() <= 8 { + return format!("…{}", s(b.len().saturating_sub(2)..b.len())); + } + format!("{}…{}", s(0..3), s(b.len() - 4..b.len())) +} + +/// `hfGatedFlag` : `false`, `"auto"` ou `"manual"` — seul compte le verrou. +pub fn gated_flag(v: &Value) -> bool { + match v { + Value::Bool(b) => *b, + Value::String(s) => !matches!( + s.trim().to_lowercase().as_str(), + "" | "false" | "none" | "no" + ), + _ => false, + } +} + +/// `url.PathEscape`. +pub fn go_path_escape(s: &str) -> String { + let mut out = String::new(); + for &c in s.as_bytes() { + if c.is_ascii_alphanumeric() || b"-_.~$&+:=@".contains(&c) { + out.push(c as char); + } else { + out.push_str(&format!("%{c:02X}")); + } + } + out +} + +/// `url.QueryEscape` (sur des octets : la recherche est tronquée à l'octet +/// comme dans le Go). +pub fn go_query_escape(s: &[u8]) -> String { + let mut out = String::new(); + for &c in s { + if c.is_ascii_alphanumeric() || b"-_.~".contains(&c) { + out.push(c as char); + } else if c == b' ' { + out.push('+'); + } else { + out.push_str(&format!("%{c:02X}")); + } + } + out +} + +/// Un `.gguf` installable (`hfEntry`). +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] +pub struct HfEntry { + pub name: String, + pub url: String, + pub size: i64, + pub quant: String, + pub shards: i64, + #[serde(default, skip_serializing_if = "String::is_empty")] + pub verdict: String, + #[serde(default, skip_serializing_if = "String::is_empty")] + pub why: String, +} + +/// `null` quand vide, comme une tranche nil du Go. +mod nil_vec { + use serde::{Deserialize, Deserializer, Serialize, Serializer}; + pub fn serialize(v: &[T], s: S) -> Result { + if v.is_empty() { + s.serialize_none() + } else { + v.serialize(s) + } + } + pub fn deserialize<'de, D: Deserializer<'de>, T: Deserialize<'de>>( + d: D, + ) -> Result, D::Error> { + Ok(Option::>::deserialize(d)?.unwrap_or_default()) + } +} + +/// Le contenu utile d'un dépôt (`hfListing`). +#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq)] +pub struct HfListing { + pub repo: String, + pub gated: bool, + #[serde(with = "nil_vec")] + pub models: Vec, + #[serde(with = "nil_vec")] + pub projectors: Vec, + #[serde(with = "nil_vec")] + pub drafts: Vec, +} + +/// `hfResolveURL` : chaque segment échappé à part. +pub fn resolve_url(base: &str, repo: &str, file_path: &str) -> String { + let segs: Vec = file_path.split('/').map(go_path_escape).collect(); + format!("{base}/{repo}/resolve/main/{}", segs.join("/")) +} + +/// `hfClassify` : modèles, projecteurs (`mmproj*`) et poids MTP (`mtp-*`), +/// une famille de tranches comptée en entier, triés par taille. +pub fn classify(base: &str, repo: &str, files: &[(String, i64)]) -> HfListing { + let mut size: HashMap<&str, i64> = HashMap::new(); + let mut paths = Vec::new(); + for (p, n) in files { + if !p.to_lowercase().ends_with(".gguf") { + continue; + } + size.insert(p, *n); + paths.push(p.as_str()); + } + let mut out = HfListing { + repo: repo.into(), + ..Default::default() + }; + for p in paths { + let name = base_name(p); + if gguf::is_follower_shard(&name) { + continue; + } + let fam = gguf::shard_family(&name); + let dir = p.rfind('/').map_or("", |i| &p[..=i]); + let total = fam + .iter() + .map(|n| size.get(format!("{dir}{n}").as_str()).copied().unwrap_or(0)) + .sum(); + let e = HfEntry { + url: resolve_url(base, repo, p), + size: total, + quant: quant_from_name(&name), + shards: fam.len() as i64, + name: name.clone(), + verdict: String::new(), + why: String::new(), + }; + let low = name.to_lowercase(); + if low.starts_with("mmproj") { + out.projectors.push(e); + } else if low.starts_with("mtp-") { + out.drafts.push(e); + } else { + out.models.push(e); + } + } + // sort.Slice n'est pas stable ; à tailles égales l'ordre du Go n'est pas + // garanti non plus. + for v in [&mut out.models, &mut out.projectors, &mut out.drafts] { + v.sort_by_key(|e| e.size); + } + out +} + +/// `hfPickProjector` : le Q8_0 d'abord, sinon le plus léger. +pub fn pick_projector(list: &[HfEntry]) -> Option<&HfEntry> { + if let Some(e) = list.iter().find(|e| e.quant.eq_ignore_ascii_case("Q8_0")) { + return Some(e); + } + let mut best = list.first()?; + for e in &list[1..] { + if e.size < best.size { + best = e; + } + } + Some(best) +} + +/// Une carte (`gpuBrief`). +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] +pub struct GpuBrief { + pub name: String, + pub vram_gb: f64, +} + +/// La machine (`hardwareInfo`). +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] +pub struct Hardware { + pub os: String, + pub arch: String, + pub ram_gb: f64, + pub vram_gb: f64, + #[serde(with = "nil_vec")] + pub gpus: Vec, +} + +/// `runtime.GOOS` / `runtime.GOARCH`. +fn go_os_arch() -> (String, String) { + let arch = match std::env::consts::ARCH { + "x86_64" => "amd64", + "aarch64" => "arm64", + "x86" => "386", + a => a, + }; + (std::env::consts::OS.into(), arch.into()) +} + +/// `totalRAMGB` (Linux : MemTotal de /proc/meminfo). +fn total_ram_gb() -> f64 { + #[cfg(target_os = "linux")] + { + let Ok(b) = std::fs::read_to_string("/proc/meminfo") else { + return 0.0; + }; + for line in b.lines() { + if line.starts_with("MemTotal:") { + return line + .split_whitespace() + .nth(1) + .and_then(|v| v.parse::().ok()) + .map_or(0.0, |kb| kb / (1024.0 * 1024.0)); + } + } + 0.0 + } + #[cfg(not(target_os = "linux"))] + { + 0.0 + } +} + +/// `detectHardware` : la VRAM vient de nvidia-smi (cache partagé des jauges). +pub async fn detect_hardware() -> Hardware { + let (os, arch) = go_os_arch(); + let mut h = Hardware { + os, + arch, + ram_gb: total_ram_gb(), + vram_gb: 0.0, + gpus: Vec::new(), + }; + for g in crate::sysinfo::gpu_stats_cached().await { + let gb = g.total as f64 / 1024.0; + h.vram_gb += gb; + h.gpus.push(GpuBrief { + name: g.name, + vram_gb: gb, + }); + } + h +} + +/// `fitVerdict` : « ok », « juste » ou « trop », et pourquoi. +pub fn fit_verdict(h: &Hardware, weights: i64, mmproj: i64, ctx_tokens: i64) -> (String, String) { + const GB: f64 = (1u64 << 30) as f64; + let w = weights as f64 / GB; + let m = mmproj as f64 / GB; + let ctx = if ctx_tokens <= 0 { 32768 } else { ctx_tokens }; + let kv = w / 20.0 * (ctx as f64 / 32768.0); + let need = w + m + kv; + let (budget, place) = if h.vram_gb > 0.0 { + (h.vram_gb, "VRAM") + } else { + (h.ram_gb, "RAM") + }; + if budget <= 0.0 { + return (String::new(), String::new()); + } + let mut detail = format!("{w:.1} Go de poids"); + if m > 0.0 { + detail += &format!(" + {m:.1} Go de projecteur"); + } + detail += &format!( + " + ~{kv:.1} Go de contexte (estimation) = ~{need:.1} Go pour {budget:.1} Go de {place}" + ); + if need > budget { + ("trop".into(), format!("ne tient pas : {detail}")) + } else if need > budget * 0.9 { + ("juste".into(), format!("ça passe de justesse : {detail}")) + } else { + ("ok".into(), detail) + } +} + +/// `hfSearch` : l'URL appelée (clés triées comme `url.Values.Encode`). +pub fn search_endpoint(base: &str, q: &str) -> String { + let q = q.trim().as_bytes(); + let q = &q[..q.len().min(MAX_QUERY)]; + format!( + "{base}/api/models?direction=-1&expand%5B%5D=gated&expand%5B%5D=downloads&expand%5B%5D=likes&filter=gguf&limit={MAX_REPOS}&search={}&sort=downloads", + go_query_escape(q) + ) +} + +// --------------------------------------------------------------------------- +// Client +// --------------------------------------------------------------------------- + +/// Appels à l'API Hugging Face, avec cache (`hfGetJSON`). +pub struct HfClient { + base: String, + client: reqwest::Client, + cache: Mutex>, +} + +/// Ce dont un appel a besoin : jeton et version (User-Agent). +pub struct HfEnv { + pub token: String, + pub version: String, +} + +#[derive(Deserialize)] +struct RawRepo { + #[serde(default)] + id: String, + #[serde(default)] + downloads: i64, + #[serde(default)] + likes: i64, + #[serde(default)] + gated: Value, +} + +#[derive(Deserialize)] +struct RawFile { + #[serde(default, rename = "type")] + kind: String, + #[serde(default)] + path: String, + #[serde(default)] + size: i64, + #[serde(default)] + lfs: Option, +} + +#[derive(Deserialize)] +struct RawLfs { + #[serde(default)] + size: i64, +} + +impl HfClient { + /// `base` : `https://huggingface.co` (un faux serveur dans les tests). + pub fn new(base: &str) -> Self { + crate::chat::engine::install_crypto(); + HfClient { + base: base.trim_end_matches('/').to_string(), + client: reqwest::Client::builder() + .timeout(TIMEOUT) + .build() + .unwrap_or_default(), + cache: Mutex::new(HashMap::new()), + } + } + + pub fn base(&self) -> &str { + &self.base + } + + fn cache(&self) -> std::sync::MutexGuard<'_, HashMap> { + self.cache.lock().unwrap_or_else(|e| e.into_inner()) + } + + /// Un nouveau jeton : les réponses obtenues sans lui ne valent plus. + pub fn clear_cache(&self) { + self.cache().clear(); + } + + async fn get_json( + &self, + env: &HfEnv, + endpoint: &str, + ttl: Duration, + ) -> Result { + let hit = self + .cache() + .get(endpoint) + .filter(|(at, _)| at.elapsed() < ttl) + .map(|(_, b)| b.clone()); + if let Some(raw) = hit { + return serde_json::from_slice(&raw).map_err(|e| e.to_string()); + } + let mut rb = self + .client + .get(endpoint) + .header("User-Agent", format!("loki/{}", env.version)) + .header("Accept", "application/json"); + if !env.token.is_empty() { + rb = rb.header("Authorization", format!("Bearer {}", env.token)); + } + let resp = rb + .send() + .await + .map_err(|e| format!("Hugging Face injoignable : {e}"))?; + match resp.status().as_u16() { + 200 => {} + 401 | 403 => { + return Err("dépôt à accès restreint — renseigne HF_TOKEN pour y accéder".into()); + } + 404 => return Err("dépôt introuvable sur Hugging Face".into()), + s => return Err(format!("Hugging Face a répondu {s}")), + } + let raw = read_capped(resp, 4 << 20).await?; + self.cache() + .insert(endpoint.to_string(), (Instant::now(), raw.clone())); + serde_json::from_slice(&raw).map_err(|e| e.to_string()) + } + + /// `hfSearch`. + pub async fn search(&self, env: &HfEnv, q: &str) -> Result { + if q.trim().is_empty() { + return Err("recherche vide".into()); + } + let raw: Vec = self + .get_json(env, &search_endpoint(&self.base, q), SEARCH_TTL) + .await?; + Ok(Value::Array( + raw.into_iter() + .map(|r| { + json!({ "id": r.id, "downloads": r.downloads, "likes": r.likes, "gated": gated_flag(&r.gated) }) + }) + .collect(), + )) + } + + /// `hfFiles`. + pub async fn files(&self, env: &HfEnv, repo: &str) -> Result { + let repo = repo.trim().trim_matches('/'); + if !repo_ok(repo) { + return Err("nom de dépôt invalide (attendu « auteur/dépôt »)".into()); + } + let endpoint = format!("{}/api/models/{repo}/tree/main?recursive=1", self.base); + let raw: Vec = self.get_json(env, &endpoint, FILES_TTL).await?; + let files: Vec<(String, i64)> = raw + .into_iter() + .filter(|f| f.kind != "directory") + .map(|f| { + let n = f.lfs.map_or(0, |l| l.size); + (f.path, if n == 0 { f.size } else { n }) + }) + .collect(); + let mut out = classify(&self.base, repo, &files); + out.gated = self.repo_gated(env, repo).await; + Ok(out) + } + + /// `hfRepoGated` : une erreur ne casse rien (pas d'avertissement, c'est + /// tout). + async fn repo_gated(&self, env: &HfEnv, repo: &str) -> bool { + #[derive(Deserialize)] + struct Info { + #[serde(default)] + gated: Value, + } + let endpoint = format!("{}/api/models/{repo}?expand%5B%5D=gated", self.base); + self.get_json::(env, &endpoint, FILES_TTL) + .await + .is_ok_and(|i| gated_flag(&i.gated)) + } + + /// `hfWhoAmI` : le compte du jeton, ou pourquoi il est refusé. + pub async fn whoami(&self, version: &str, tok: &str) -> Result { + let resp = self + .client + .get(format!("{}/api/whoami-v2", self.base)) + .header("Authorization", format!("Bearer {}", tok.trim())) + .header("User-Agent", format!("loki/{version}")) + .header("Accept", "application/json") + .send() + .await + .map_err(|e| format!("Hugging Face injoignable : {e}"))?; + match resp.status().as_u16() { + 200 => {} + 401 | 403 => { + return Err("jeton refusé par Hugging Face (expiré, révoqué ou mal copié)".into()); + } + s => return Err(format!("Hugging Face a répondu {s}")), + } + #[derive(Deserialize)] + struct Who { + #[serde(default)] + name: String, + } + let raw = read_capped(resp, 1 << 20).await?; + serde_json::from_slice::(&raw) + .map(|w| w.name) + .map_err(|e| e.to_string()) + } +} + +/// Lit au plus `max` octets du corps (réponse d'un tiers). +async fn read_capped(mut resp: reqwest::Response, max: usize) -> Result { + let mut buf = Vec::new(); + while let Some(c) = resp.chunk().await.map_err(|e| e.to_string())? { + let room = max - buf.len(); + buf.extend_from_slice(&c[..c.len().min(room)]); + if buf.len() >= max { + break; + } + } + Ok(Bytes::from(buf)) +} + +// --------------------------------------------------------------------------- +// Routes +// --------------------------------------------------------------------------- + +/// `hfTokenSource` : « config », « env » ou "". +async fn token_source(ctx: &Ctx) -> (&'static str, String) { + let stored = ctx.store.get_str(BK_STATE, "hf_token").await; + if !stored.trim().is_empty() { + return ("config", stored.trim().to_string()); + } + let env = std::env::var("HF_TOKEN").unwrap_or_default(); + if !env.trim().is_empty() { + return ("env", env.trim().to_string()); + } + ("", String::new()) +} + +fn token_state(src: &str, tok: &str, account: &str) -> Value { + json!({ "ok": true, "set": !src.is_empty(), "source": src, "masked": mask_hf_token(tok), "account": account }) +} + +fn query_param(uri: &hyper::Uri, key: &str) -> String { + let q = uri.query().unwrap_or(""); + for kv in q.split('&') { + let (k, v) = kv.split_once('=').unwrap_or((kv, "")); + if form_decode(k) == key { + return form_decode(v); + } + } + String::new() +} + +/// Décodage `application/x-www-form-urlencoded` (`+` → espace, `%XX`). +fn form_decode(s: &str) -> String { + let b = s.as_bytes(); + let mut out = Vec::with_capacity(b.len()); + let mut i = 0; + while i < b.len() { + match b[i] { + b'+' => out.push(b' '), + b'%' if i + 2 < b.len() => { + match u8::from_str_radix(std::str::from_utf8(&b[i + 1..i + 3]).unwrap_or("zz"), 16) + { + Ok(v) => { + out.push(v); + i += 3; + continue; + } + Err(_) => out.push(b'%'), + } + } + c => out.push(c), + } + i += 1; + } + String::from_utf8_lossy(&out).into_owned() +} + +pub async fn serve( + ctx: &Ctx, + hf: &HfClient, + req: Request, + from_go: bool, +) -> Response { + let (parts, body) = req.into_parts(); + let version = if from_go { + ctx.internal + .auth() + .await + .map(|i| i.version) + .unwrap_or_default() + } else { + match ctx.guard(&parts.headers).await { + Ok(v) => v, + Err(refus) => return *refus, + } + }; + let (src, token) = token_source(ctx).await; + let env = HfEnv { + token: token.clone(), + version: version.clone(), + }; + let err = |code: StatusCode, e: String| send_json(code, &json!({ "ok": false, "error": e })); + match parts.uri.path() { + "/api/hf/search" => { + let q = query_param(&parts.uri, "q"); + if q.trim().is_empty() { + return err(StatusCode::BAD_REQUEST, "précise ce que tu cherches".into()); + } + match hf.search(&env, &q).await { + Ok(repos) => send_json( + StatusCode::OK, + &json!({ "ok": true, "hardware": detect_hardware().await, "repos": repos, "hf_token": !token.is_empty() }), + ), + Err(e) => err(StatusCode::BAD_GATEWAY, e), + } + } + "/api/hf/files" => { + let repo = query_param(&parts.uri, "repo"); + if repo.trim().is_empty() { + return err(StatusCode::BAD_REQUEST, "dépôt manquant".into()); + } + let mut list = match hf.files(&env, &repo).await { + Ok(l) => l, + Err(e) => return err(StatusCode::BAD_GATEWAY, e), + }; + let hw = detect_hardware().await; + // Contexte du preset actif, défaut de serve. + let ctx_tokens = ctx + .store + .config() + .await + .get("CTX") + .and_then(|v| v.trim().parse::().ok()) + .filter(|v| *v > 0) + .unwrap_or(32768); + let mm = pick_projector(&list.projectors).map_or(0, |p| p.size); + for m in &mut list.models { + (m.verdict, m.why) = fit_verdict(&hw, m.size, mm, ctx_tokens); + } + let vision = !list.projectors.is_empty(); + let nil = |v: &Vec| { + if v.is_empty() { Value::Null } else { json!(v) } + }; + send_json( + StatusCode::OK, + &json!({ + "ok": true, "hardware": hw, "ctx": ctx_tokens, + "repo": list.repo, "models": nil(&list.models), + "projectors": nil(&list.projectors), "drafts": nil(&list.drafts), + "vision": vision, "gated": list.gated, "hf_token": !token.is_empty(), + }), + ) + } + "/api/hf/token" if parts.method == Method::POST => { + let v = match crate::models::body_json(body).await { + Ok(v) => v, + Err(_) => return err(StatusCode::BAD_REQUEST, "requête illisible".into()), + }; + let tok = v + .get("token") + .and_then(Value::as_str) + .unwrap_or("") + .trim() + .to_string(); + let mut account = String::new(); + if !tok.is_empty() { + match hf.whoami(&version, &tok).await { + Ok(a) => account = a, + Err(e) => return err(StatusCode::BAD_REQUEST, e), + } + } + // putStr : une valeur vide efface la clé. + let res = if tok.is_empty() { + ctx.store.del(BK_STATE, "hf_token").await + } else { + ctx.store + .put(BK_STATE, "hf_token", tok.clone().into_bytes()) + .await + }; + if let Err(e) = res { + return err(StatusCode::INTERNAL_SERVER_ERROR, e); + } + hf.clear_cache(); + let (src, token) = token_source(ctx).await; + send_json(StatusCode::OK, &token_state(src, &token, &account)) + } + "/api/hf/token" => send_json(StatusCode::OK, &token_state(src, &token, "")), + _ => send_json( + StatusCode::NOT_FOUND, + &json!({ "error": "404 page not found" }), + ), + } +} diff --git a/rust/loki-rs/src/lib.rs b/rust/loki-rs/src/lib.rs index e7ab18a..c0f8b84 100644 --- a/rust/loki-rs/src/lib.rs +++ b/rust/loki-rs/src/lib.rs @@ -19,6 +19,8 @@ pub mod downloads; pub mod engine; pub mod engstatus; pub mod gguf; +pub mod hf; +pub mod models; pub mod oai; pub mod paths; pub mod presets; diff --git a/rust/loki-rs/src/main.rs b/rust/loki-rs/src/main.rs index 24b75d6..7260207 100644 --- a/rust/loki-rs/src/main.rs +++ b/rust/loki-rs/src/main.rs @@ -118,17 +118,17 @@ fn serve() -> ExitCode { // sait (il remet alors à la façade le trafic de chat du tunnel) et // connaît son adresse locale. let chat_rust = chat_held_by_facade(); - // LOKI_DOWNLOADS=rust : les téléchargements de modèles aussi (le Go - // remet alors leurs routes du tunnel et ne vide plus les .part). - let downloads_rust = downloads_held_by_facade(); + // LOKI_MODELS=rust : les routes des modèles aussi (le Go remet alors + // celles du tunnel et ne vide plus les .part). + let models_rust = models_held_by_facade(); let mut env = vec![(auth::INTERNAL_ENV.to_string(), token.clone())]; if chat_rust { env.push(("LOKI_CHAT".into(), "rust".into())); } - if downloads_rust { - env.push(("LOKI_DOWNLOADS".into(), "rust".into())); + if models_rust { + env.push(("LOKI_MODELS".into(), "rust".into())); } - if chat_rust || downloads_rust { + if chat_rust || models_rust { env.push(("LOKI_FACADE_ADDR".into(), format!("127.0.0.1:{web_port}"))); } let sup = child::Supervisor::spawn(go_bin, go_port, env); @@ -161,8 +161,8 @@ fn serve() -> ExitCode { } else { None }; - let downloads = if downloads_rust { - Some(start_downloads(backend, &token, &engine_paths.home).await) + let models = if models_rust { + Some(start_models(backend, &token, &engine_paths.home).await) } else { None }; @@ -183,7 +183,7 @@ fn serve() -> ExitCode { host_env: auth::HostEnv::from_env(), engine: engine_paths, chat, - downloads, + models, }; let res = proxy::serve(listener, cfg, shutdown_signal()).await; eprintln!("[loki-rs] arrêt demandé — arrêt du serveur Go"); @@ -203,18 +203,18 @@ fn chat_held_by_facade() -> bool { std::env::var("LOKI_CHAT").is_ok_and(|v| v.trim().eq_ignore_ascii_case("rust")) } -/// `LOKI_DOWNLOADS=rust` : téléchargements de modèles tenus par la façade. -fn downloads_held_by_facade() -> bool { - std::env::var("LOKI_DOWNLOADS").is_ok_and(|v| v.trim().eq_ignore_ascii_case("rust")) +/// `LOKI_MODELS=rust` : routes des modèles tenues par la façade. +fn models_held_by_facade() -> bool { + std::env::var("LOKI_MODELS").is_ok_and(|v| v.trim().eq_ignore_ascii_case("rust")) } /// Les `.part` d'un téléchargement tué ne reprendront jamais : vidés au /// démarrage (le Go ne le fait plus quand la façade les tient). -async fn start_downloads( +async fn start_models( backend: SocketAddr, token: &str, home: &std::path::Path, -) -> std::sync::Arc { +) -> std::sync::Arc { use loki_rs::{downloads, gguf, store}; let st = store::Store::new(backend, token.to_string()); let stored = gguf::parse_stored_model_dirs( @@ -230,8 +230,8 @@ async fn start_downloads( std::env::var(gguf::MODEL_DIRS_ENV).ok().as_deref(), ); let _ = tokio::task::spawn_blocking(move || downloads::clean_stale_part_files(&dirs)).await; - eprintln!("[loki-rs] téléchargements de modèles tenus par la façade (LOKI_DOWNLOADS=rust)"); - std::sync::Arc::new(downloads::Downloads::new()) + eprintln!("[loki-rs] modèles tenus par la façade (LOKI_MODELS=rust)"); + std::sync::Arc::new(loki_rs::models::Models::new()) } /// Le Go garde-t-il la conversation ? (quelques essais : il vient de démarrer) diff --git a/rust/loki-rs/src/models.rs b/rust/loki-rs/src/models.rs new file mode 100644 index 0000000..338402f --- /dev/null +++ b/rust/loki-rs/src/models.rs @@ -0,0 +1,591 @@ +//! Les routes des modèles tenues par la façade quand `LOKI_MODELS=rust` (lot +//! 8 de la migration ; le Go par défaut) : +//! +//! - `GET /api/models` : les `.gguf` du sélecteur, avec qui les référence +//! (`handleModels`, `backend_models_refs.go`) ; +//! - `POST /api/models/delete` : suppression, garde-fous compris +//! (`handleModelDelete`, `deleteModelFile`) ; +//! - `GET|POST /api/models/dirs` : dossiers de modèles (`handleModelDirs`) ; +//! - les téléchargements ([`crate::downloads`]) ; +//! - le catalogue Hugging Face ([`crate::hf`]). +//! +//! Le Go remet ces routes à la façade quand elles lui arrivent par le tunnel +//! du relais (`facade_models.go`). +//! +//! Écart assumé : le message d'un corps JSON illisible est celui de serde, +//! pas celui d'`encoding/json` (l'interface n'en envoie jamais). + +use std::collections::{HashMap, HashSet}; +use std::path::Path; +use std::sync::Arc; + +use http_body_util::{BodyExt, Limited}; +use hyper::body::Incoming; +use hyper::{Method, Request, Response, StatusCode}; +use serde_json::{Map, Value, json}; + +use crate::api::Ctx; +use crate::auth::json as send_json; +use crate::downloads::{self, Downloads, ModelDirs, disk_free, disk_free_reliable}; +use crate::gguf; +use crate::hf; +use crate::paths::{base_name, go_abs, go_dir, go_join}; +use crate::presets; +use crate::proxy::Body; +use crate::store::BK_STATE; + +/// Ce que la façade tient quand `LOKI_MODELS=rust`. +pub struct Models { + pub downloads: Arc, + pub hf: hf::HfClient, +} + +impl Default for Models { + fn default() -> Self { + Self::new() + } +} + +impl Models { + pub fn new() -> Self { + Models { + downloads: Arc::new(Downloads::new()), + hf: hf::HfClient::new(hf::HF_HOST), + } + } +} + +/// Routes servies ici quand la façade tient les modèles. +pub fn handles(path: &str) -> bool { + matches!( + path, + "/api/models" | "/api/models/delete" | "/api/models/dirs" + ) || downloads::handles(path) + || hf::handles(path) +} + +// --------------------------------------------------------------------------- +// Fonctions pures +// --------------------------------------------------------------------------- + +/// `quantFromName` : l'étiquette de quantification la plus longue d'un nom +/// (segments séparés par `-` et `.`), en majuscules ; "" sinon. +pub fn quant_from_name(name: &str) -> String { + let mut base = name; + if let Some(dot) = base.rfind('.') + && base[dot..].eq_ignore_ascii_case(".gguf") + { + base = &base[..dot]; + } + let mut best = ""; + for seg in base.split(['-', '.']).filter(|s| !s.is_empty()) { + if is_quant_seg(seg) && seg.len() > best.len() { + best = seg; + } + } + best.to_uppercase() +} + +/// `quantSegRe` : `(?i)^(I?Q\d+(_[A-Za-z0-9]+)*|\d+BPW|BF16|FP16|F16|FP32|F32)$`. +fn is_quant_seg(seg: &str) -> bool { + let u = seg.to_ascii_uppercase(); + if matches!(u.as_str(), "BF16" | "FP16" | "F16" | "FP32" | "F32") { + return true; + } + if let Some(d) = u.strip_suffix("BPW") { + return !d.is_empty() && d.bytes().all(|c| c.is_ascii_digit()); + } + let rest = u.strip_prefix("IQ").or_else(|| u.strip_prefix('Q')); + let Some(rest) = rest else { + return false; + }; + let digits = rest.bytes().take_while(u8::is_ascii_digit).count(); + if digits == 0 { + return false; + } + let tail = &rest[digits..]; + if tail.is_empty() { + return true; + } + // (_[A-Za-z0-9]+)* + tail.strip_prefix('_').is_some_and(|t| { + t.split('_') + .all(|p| !p.is_empty() && p.bytes().all(|c| c.is_ascii_alphanumeric())) + }) +} + +/// `splitArgs` : découpe EXTRA_ARGS comme un shell simple (guillemets " et '). +pub fn split_args(s: &str) -> Vec { + let mut out = Vec::new(); + let mut cur = String::new(); + let mut quote: Option = None; + for r in s.chars() { + match quote { + Some(q) => { + if r == q { + quote = None; + // Guillemets vides : argument vide explicite. + if cur.is_empty() { + out.push(String::new()); + } + } else { + cur.push(r); + } + } + None if r == '"' || r == '\'' => quote = Some(r), + None if matches!(r, ' ' | '\t' | '\n' | '\r') => { + if !cur.is_empty() { + out.push(std::mem::take(&mut cur)); + } + } + None => cur.push(r), + } + } + if !cur.is_empty() { + out.push(cur); + } + out +} + +/// `os.Stat` sans erreur et pas un dossier. +fn present(p: &str) -> bool { + std::fs::metadata(p).is_ok_and(|m| !m.is_dir()) +} + +/// `shardFamilySize` : les tranches présentes dans `dir`. +fn family_size(dir: &str, name: &str) -> i64 { + gguf::shard_family(name) + .iter() + .filter_map(|n| std::fs::metadata(go_join(&[dir, n])).ok()) + .filter(|m| !m.is_dir()) + .map(|m| m.len() as i64) + .sum() +} + +/// `shardFamilyMissing` : les tranches absentes de `dir`. +fn family_missing(dir: &str, name: &str) -> Vec { + gguf::shard_family(name) + .into_iter() + .filter(|n| !present(&go_join(&[dir, n]))) + .collect() +} + +// --------------------------------------------------------------------------- +// Qui référence un .gguf (backend_models_refs.go) +// --------------------------------------------------------------------------- + +/// `modelRefIndex` : chemins normalisés → presets qui les nomment, et ceux +/// de la configuration en service. +#[derive(Debug, Default)] +pub struct ModelRefs { + active: HashSet, + presets: HashMap>, +} + +impl ModelDirs { + /// `modelRefKey` : valeur MODEL=/MMPROJ= → clé d'index ("" : pas un .gguf). + fn ref_key(&self, v: &str) -> String { + let v = v.trim(); + if v.is_empty() { + return String::new(); + } + let p = gguf::resolve_serve_model_path(v, &self.home, &self.stored, self.env.as_deref()) + .map(|p| p.to_string_lossy().into_owned()) + // Introuvable : une clé comparable quand même. + .unwrap_or_else(|_| go_join(&[&self.models_dir(), &base_name(v)])); + gguf::norm_dir(&p) + } + + /// `modelKeysOf` : modèle, projecteur, brouillon, `--mmproj` d'EXTRA_ARGS. + fn keys_of(&self, env: &HashMap) -> Vec { + let get = |k: &str| env.get(k).cloned().unwrap_or_default(); + let mut vals = vec![get("MODEL"), get("MMPROJ"), get("MODEL_DRAFT")]; + let args = split_args(&get("EXTRA_ARGS")); + for (i, a) in args.iter().enumerate() { + if a == "--mmproj" && i + 1 < args.len() { + vals.push(args[i + 1].clone()); + } + } + vals.iter() + .map(|v| self.ref_key(v)) + .filter(|k| !k.is_empty()) + .collect() + } +} + +impl ModelRefs { + /// `buildModelRefIndex` : la configuration active et les presets + /// (`(nom d'affichage, contenu)`, dans l'ordre de la liste). + pub fn build( + dirs: &ModelDirs, + cfg: &HashMap, + presets: &[(String, String)], + ) -> Self { + let mut ix = ModelRefs::default(); + ix.active.extend(dirs.keys_of(cfg)); + for (name, content) in presets { + let mut seen = HashSet::new(); + for k in dirs.keys_of(&presets::parse_env(content)) { + if seen.insert(k.clone()) { + ix.presets.entry(k).or_default().push(name.clone()); + } + } + } + ix + } + + /// `lookup` : presets qui référencent `path`, et s'il est en service. + pub fn lookup(&self, path: &str) -> (Vec, bool) { + let k = gguf::norm_dir(path); + ( + self.presets.get(&k).cloned().unwrap_or_default(), + self.active.contains(&k), + ) + } + + /// `modelUsers` : pour UN modèle (nom de fichier ou chemin). + fn users(&self, dirs: &ModelDirs, name: &str) -> (Vec, bool) { + let k = dirs.ref_key(name); + if k.is_empty() { + return (Vec::new(), false); + } + self.lookup(&k) + } +} + +const MODEL_IN_SERVICE: &str = + "modèle en service — bascule sur un autre preset avant de le supprimer"; + +/// `handleModels` : les `.gguf` des dossiers de modèles (tranches 2..N +/// exclues), avec leurs références. +pub fn list_models(dirs: &ModelDirs, refs: &ModelRefs) -> Value { + let home = dirs.models_dir(); + let mut out = Vec::new(); + let mut seen = HashSet::new(); + for dir in dirs.all() { + let Ok(rd) = std::fs::read_dir(&dir) else { + continue; + }; + // os.ReadDir : trié par nom. + let mut entries: Vec<_> = rd.filter_map(Result::ok).collect(); + entries.sort_by_key(|e| e.file_name()); + let is_home = gguf::norm_dir(&dir) == gguf::norm_dir(&home); + for e in entries { + let name = e.file_name().to_string_lossy().into_owned(); + let is_dir = e.file_type().map(|t| t.is_dir()).unwrap_or(false); + if is_dir || !name.to_lowercase().ends_with(".gguf") || gguf::is_follower_shard(&name) { + continue; + } + let full = go_join(&[&dir, &name]); + if !seen.insert(gguf::norm_dir(&full)) { + continue; + } + let size = e.metadata().map(|m| m.len() as i64).unwrap_or(0); + let value = if is_home { name.clone() } else { full.clone() }; + let mut m = Map::new(); + m.insert("name".into(), json!(name)); + m.insert("size".into(), json!(size)); + m.insert("value".into(), json!(value)); + m.insert("path".into(), json!(full)); + m.insert("dir".into(), json!(dir)); + m.insert("home".into(), json!(is_home)); + let (users, active) = refs.lookup(&full); + if !users.is_empty() || active { + if !users.is_empty() { + m.insert("used".into(), json!(users)); + } + m.insert("active".into(), json!(active)); + } + if let Some((_, total)) = gguf::shard_info(&name) { + m.insert("shards".into(), json!(total)); + m.insert("size".into(), json!(family_size(&dir, &name))); + let missing = family_missing(&dir, &name); + if !missing.is_empty() { + m.insert("missing".into(), json!(missing)); + } + } + out.push(Value::Object(m)); + } + } + Value::Array(out) +} + +/// `modelDirCount` : les `.gguf` lisibles d'un dossier (-1 : illisible). +pub fn dir_count(dir: &str) -> i64 { + let Ok(rd) = std::fs::read_dir(dir) else { + return -1; + }; + rd.filter_map(Result::ok) + .filter(|e| { + let n = e.file_name().to_string_lossy().into_owned(); + !e.file_type().map(|t| t.is_dir()).unwrap_or(false) + && n.to_lowercase().ends_with(".gguf") + && !gguf::is_follower_shard(&n) + }) + .count() as i64 +} + +/// `GET /api/models/dirs`. +pub fn dirs_json(dirs: &ModelDirs) -> Value { + let home = dirs.models_dir(); + let mut out = vec![json!({ + "path": home, "home": true, "count": dir_count(&home), "exists": true, + "free": disk_free(&home), "free_exact": disk_free_reliable(&home), + })]; + for d in gguf::extra_model_dirs(&dirs.home, &dirs.stored, dirs.env.as_deref()) { + let n = dir_count(&d); + out.push(json!({ + "path": d, "home": false, "count": n, "exists": n >= 0, + "free": disk_free(&d), "free_exact": disk_free_reliable(&d), + })); + } + json!({ "ok": true, "dirs": out, "env": dirs.env.as_deref().is_some_and(|v| !v.is_empty()) }) +} + +/// `POST /api/models/dirs` : la liste à enregistrer, ou le refus. +pub fn dirs_update(dirs: &ModelDirs, path: &str, action: &str) -> Result, String> { + let p = path.trim(); + if p.is_empty() { + return Err("chemin vide".into()); + } + let mut cur = gguf::extra_model_dirs(&dirs.home, &dirs.stored, dirs.env.as_deref()); + if action == "remove" { + cur.retain(|d| gguf::norm_dir(d) != gguf::norm_dir(p)); + } else { + let abs = go_abs(p).ok_or_else(|| "chemin invalide".to_string())?; + if !std::fs::metadata(&abs).is_ok_and(|m| m.is_dir()) { + return Err(format!("dossier introuvable : {abs}")); + } + cur.push(abs); + } + // saveExtraModelDirs : nettoyée, dédoublonnée, models/ exclu. + Ok(gguf::dedup_dirs(&cur, &dirs.models_dir())) +} + +/// `handleModelDelete` : refus si en service, 409 avec la liste des presets +/// sans `force`, sinon le modèle part avec toutes ses tranches. +pub fn delete_model( + dirs: &ModelDirs, + refs: &ModelRefs, + name: &str, + force: bool, +) -> (StatusCode, Value) { + let (users, active) = refs.users(dirs, name); + if active { + return ( + StatusCode::CONFLICT, + json!({ "ok": false, "error": MODEL_IN_SERVICE, "active": true }), + ); + } + if !users.is_empty() && !force { + return ( + StatusCode::CONFLICT, + json!({ "ok": false, "used": users, "error": format!("modèle utilisé par : {}", users.join(", ")) }), + ); + } + match delete_model_file(dirs, refs, name) { + Ok(freed) => { + let used = if users.is_empty() { + Value::Null + } else { + json!(users) + }; + ( + StatusCode::OK, + json!({ "ok": true, "freed": freed, "used": used }), + ) + } + Err(e) => (StatusCode::BAD_REQUEST, json!({ "ok": false, "error": e })), + } +} + +/// `deleteModelFile` : rend les octets libérés. +fn delete_model_file(dirs: &ModelDirs, refs: &ModelRefs, name: &str) -> Result { + let p = gguf::resolve_model_path(name, &dirs.home, &dirs.stored, dirs.env.as_deref())? + .to_string_lossy() + .into_owned(); + if refs.users(dirs, name).1 { + return Err(MODEL_IN_SERVICE.into()); + } + let dir = go_dir(&p); + let base = base_name(&p); + let freed = family_size(&dir, &base); + if let Err(e) = std::fs::remove_file(&p) { + if e.kind() == std::io::ErrorKind::NotFound { + return Err(format!("modèle introuvable: {base}")); + } + return Err(crate::paths::os_err("remove", &p, &e)); + } + for n in gguf::shard_family(&base) { + let _ = std::fs::remove_file(Path::new(&dir).join(n)); + } + Ok(freed) +} + +// --------------------------------------------------------------------------- +// Routes +// --------------------------------------------------------------------------- + +/// Les dossiers de modèles du moment, lus en base. +pub async fn model_dirs(ctx: &Ctx) -> ModelDirs { + let stored = gguf::parse_stored_model_dirs( + ctx.store + .get(BK_STATE, gguf::STATE_MODEL_DIRS_KEY) + .await + .ok() + .flatten() + .as_deref(), + ); + ModelDirs { + home: ctx.engine.home.clone(), + stored, + env: std::env::var(gguf::MODEL_DIRS_ENV).ok(), + } +} + +/// La configuration en service et les presets (nom, contenu), dans l'ordre +/// de la liste. +async fn refs_inputs(ctx: &Ctx) -> (HashMap, Vec) { + let cfg = ctx.store.config().await; + let order = presets::parse_preset_order( + ctx.store + .get(BK_STATE, presets::PRESET_ORDER_KEY) + .await + .ok() + .flatten() + .as_deref(), + ); + (cfg, order) +} + +/// L'index des références : configuration et presets lus sur le disque. +pub fn build_refs(dirs: &ModelDirs, cfg: &HashMap, order: &[String]) -> ModelRefs { + let list = presets::list_presets(&dirs.home, cfg, order).unwrap_or_default(); + let presets: Vec<(String, String)> = list + .into_iter() + .filter_map(|p| { + std::fs::read(&p.path) + .ok() + .map(|b| (p.name, String::from_utf8_lossy(&b).into_owned())) + }) + .collect(); + ModelRefs::build(dirs, cfg, &presets) +} + +async fn blocking(f: impl FnOnce() -> T + Send + 'static) -> T { + tokio::task::spawn_blocking(f) + .await + .expect("tâche bloquante interrompue") +} + +pub(crate) async fn body_json(body: Incoming) -> Result { + let b = Limited::new(body, 1 << 20) + .collect() + .await + .map_err(|e| e.to_string())? + .to_bytes(); + if b.iter().all(u8::is_ascii_whitespace) { + return Err("EOF".into()); + } + serde_json::from_slice(&b).map_err(|e| e.to_string()) +} + +fn bad(e: impl Into) -> Response { + send_json( + StatusCode::BAD_REQUEST, + &json!({ "ok": false, "error": e.into() }), + ) +} + +/// `from_go` : remise par le Go (tunnel), déjà authentifiée par lui. +pub async fn serve( + ctx: &Ctx, + models: &Arc, + req: Request, + from_go: bool, +) -> Response { + let path = req.uri().path().to_string(); + if downloads::handles(&path) { + return downloads::serve(ctx, &models.downloads, req, from_go).await; + } + if hf::handles(&path) { + return hf::serve(ctx, &models.hf, req, from_go).await; + } + let (parts, body) = req.into_parts(); + if !from_go && let Err(refus) = ctx.guard(&parts.headers).await { + return *refus; + } + let dirs = model_dirs(ctx).await; + match path.as_str() { + "/api/models" => { + let (cfg, order) = refs_inputs(ctx).await; + let out = blocking(move || { + let refs = build_refs(&dirs, &cfg, &order); + list_models(&dirs, &refs) + }) + .await; + send_json(StatusCode::OK, &out) + } + "/api/models/delete" => { + let v = match body_json(body).await { + Ok(v) => v, + Err(e) => return bad(e), + }; + let name = v + .get("name") + .and_then(Value::as_str) + .unwrap_or("") + .to_string(); + let force = v.get("force").and_then(Value::as_bool).unwrap_or(false); + let (cfg, order) = refs_inputs(ctx).await; + let (code, out) = blocking(move || { + let refs = build_refs(&dirs, &cfg, &order); + delete_model(&dirs, &refs, &name, force) + }) + .await; + send_json(code, &out) + } + "/api/models/dirs" if parts.method == Method::POST => { + let v = match body_json(body).await { + Ok(v) => v, + Err(e) => return bad(e), + }; + let p = v + .get("path") + .and_then(Value::as_str) + .unwrap_or("") + .to_string(); + let action = v + .get("action") + .and_then(Value::as_str) + .unwrap_or("") + .to_string(); + let res = blocking(move || dirs_update(&dirs, &p, &action)).await; + match res { + Ok(list) => { + let raw = serde_json::to_vec(&list).unwrap_or_default(); + match ctx + .store + .put(BK_STATE, gguf::STATE_MODEL_DIRS_KEY, raw) + .await + { + Ok(()) => send_json(StatusCode::OK, &json!({ "ok": true })), + Err(e) => send_json( + StatusCode::INTERNAL_SERVER_ERROR, + &json!({ "ok": false, "error": e }), + ), + } + } + Err(e) => bad(e), + } + } + "/api/models/dirs" => { + let out = blocking(move || dirs_json(&dirs)).await; + send_json(StatusCode::OK, &out) + } + _ => send_json( + StatusCode::NOT_FOUND, + &json!({ "error": "404 page not found" }), + ), + } +} diff --git a/rust/loki-rs/src/proxy.rs b/rust/loki-rs/src/proxy.rs index 930b561..71c1076 100644 --- a/rust/loki-rs/src/proxy.rs +++ b/rust/loki-rs/src/proxy.rs @@ -63,9 +63,9 @@ pub struct Config { /// Conversation tenue par la façade (`LOKI_CHAT=rust`) : ses routes sont /// servies ici ; `None` : relayées au Go. pub chat: Option>, - /// Téléchargements de modèles tenus par la façade (`LOKI_DOWNLOADS=rust`) : - /// leurs routes sont servies ici ; `None` : relayées au Go. - pub downloads: Option>, + /// Routes des modèles tenues par la façade (`LOKI_MODELS=rust`) : servies + /// ici ; `None` : relayées au Go. + pub models: Option>, } struct Proxy { @@ -93,7 +93,7 @@ pub async fn serve( host_env: cfg.host_env, engine: cfg.engine, chat: cfg.chat, - downloads: cfg.downloads, + models: cfg.models, internal_token: cfg.internal_token.clone(), }, }); @@ -133,9 +133,9 @@ pub async fn serve( impl Proxy { async fn handle(&self, mut req: Request) -> Response { // Le Go (et lui seul : jeton tiré à chaque démarrage) remet à la - // façade le trafic de chat et de téléchargements qui lui arrive sans - // passer par elle. - let from_go = (self.ctx.chat.is_some() || self.ctx.downloads.is_some()) + // façade le trafic du chat et des modèles qui lui arrive sans passer + // par elle. + let from_go = (self.ctx.chat.is_some() || self.ctx.models.is_some()) && self.ctx.from_go(req.headers()); // Jamais de confiance accordée sur la foi d'un en-tête du client : // ni « je viens du tunnel », ni « je suis la façade ». @@ -169,10 +169,10 @@ impl Proxy { { return chatapi::serve(&self.ctx, chat, req, from_go).await; } - if let Some(dl) = &self.ctx.downloads - && crate::downloads::handles(path) + if let Some(m) = &self.ctx.models + && crate::models::handles(path) { - return crate::downloads::serve(&self.ctx, dl, req, from_go).await; + return crate::models::serve(&self.ctx, m, req, from_go).await; } self.forward(req).await } diff --git a/rust/loki-rs/tests/common/fakego.rs b/rust/loki-rs/tests/common/fakego.rs index 4ef5359..222bec9 100644 --- a/rust/loki-rs/tests/common/fakego.rs +++ b/rust/loki-rs/tests/common/fakego.rs @@ -427,16 +427,17 @@ async fn handle(st: Arc, req: Request) -> Response /// Lance le faux Go puis la façade devant lui ; rend (adresse de la façade, /// état du faux Go, garde d'arrêt). pub async fn start(home: std::path::PathBuf) -> (SocketAddr, Arc, oneshot::Sender<()>) { - let (f, st, tx, _) = start_opts(home, false, None, false).await; + let (f, st, tx, _) = start_opts(home, false, None, None).await; (f, st, tx) } -/// Comme [`start`], avec les téléchargements de modèles tenus par la façade -/// (`LOKI_DOWNLOADS=rust`). -pub async fn start_downloads( +/// Comme [`start`], avec les routes des modèles tenues par la façade +/// (`LOKI_MODELS=rust`) ; `hf` : adresse du faux Hugging Face. +pub async fn start_models( home: std::path::PathBuf, + hf: &str, ) -> (SocketAddr, Arc, oneshot::Sender<()>) { - let (f, st, tx, _) = start_opts(home, false, None, true).await; + let (f, st, tx, _) = start_opts(home, false, None, Some(hf.to_string())).await; (f, st, tx) } @@ -451,7 +452,7 @@ pub async fn start_chat( oneshot::Sender<()>, Arc, ) { - let (f, st, tx, chat) = start_opts(home, true, Some(engine_port), false).await; + let (f, st, tx, chat) = start_opts(home, true, Some(engine_port), None).await; (f, st, tx, chat.unwrap()) } @@ -459,7 +460,7 @@ async fn start_opts( home: std::path::PathBuf, with_chat: bool, engine_port: Option, - with_downloads: bool, + models_hf: Option, ) -> ( SocketAddr, Arc, @@ -516,7 +517,12 @@ async fn start_opts( service: "loki-engine".into(), }, chat: chat.clone(), - downloads: with_downloads.then(|| Arc::new(loki_rs::downloads::Downloads::new())), + models: models_hf.map(|hf| { + Arc::new(loki_rs::models::Models { + downloads: Arc::new(loki_rs::downloads::Downloads::new()), + hf: loki_rs::hf::HfClient::new(&hf), + }) + }), }; tokio::spawn(loki_rs::proxy::serve(fl, cfg, async move { let _ = rx.await; diff --git a/rust/loki-rs/tests/downloads.rs b/rust/loki-rs/tests/downloads.rs index 17e4aad..bf1f7cd 100644 --- a/rust/loki-rs/tests/downloads.rs +++ b/rust/loki-rs/tests/downloads.rs @@ -1,4 +1,4 @@ -//! Lot « modèles » : téléchargements tenus par la façade (`LOKI_DOWNLOADS=rust`). +//! Lot 8 : téléchargements de modèles tenus par la façade (`LOKI_MODELS=rust`). //! //! - les fonctions pures rejouent le journal tiré du Go //! (`internal/loki/parity_dl_test.go` → `tests/parity_models/download.json`) ; @@ -572,7 +572,7 @@ async fn routes_servies_par_la_facade() { ); let tmp = tempfile::tempdir().unwrap(); let home = tmp.path().canonicalize().unwrap(); - let (facade, go, _stop) = fakego::start_downloads(home.clone()).await; + let (facade, go, _stop) = fakego::start_models(home.clone(), "http://127.0.0.1:1").await; let url = format!("http://{addr}/u/r/resolve/main/m.gguf"); let body = format!(r#"{{"url":"{url}","dir":""}}"#); diff --git a/rust/loki-rs/tests/lot2.rs b/rust/loki-rs/tests/lot2.rs index 0453b79..349c12c 100644 --- a/rust/loki-rs/tests/lot2.rs +++ b/rust/loki-rs/tests/lot2.rs @@ -94,7 +94,7 @@ async fn banc(cle: Option<&str>) -> Banc { service: "loki-engine".into(), }, chat: None, - downloads: None, + models: None, }; tokio::spawn(loki_rs::proxy::serve(l, cfg, async move { let _ = rx.await; diff --git a/rust/loki-rs/tests/models.rs b/rust/loki-rs/tests/models.rs new file mode 100644 index 0000000..3f61182 --- /dev/null +++ b/rust/loki-rs/tests/models.rs @@ -0,0 +1,557 @@ +//! Lot 8 : routes des modèles tenues par la façade (`LOKI_MODELS=rust`). +//! +//! - le journal tiré du Go (`internal/loki/parity_models_test.go` → +//! `tests/parity_models/models.json`) : fonctions pures du catalogue, puis +//! un scénario sur disque (liste, dossiers, suppressions) rejoué à +//! l'identique ; +//! - le catalogue Hugging Face contre un faux serveur (recherche, dépôt, +//! jeton, cache) ; +//! - les routes par la façade. + +mod common; + +use std::collections::HashMap; +use std::convert::Infallible; +use std::net::SocketAddr; +use std::path::Path; +use std::sync::{Arc, Mutex}; + +use bytes::Bytes; +use common::fakego; +use http_body_util::Full; +use hyper::body::Incoming; +use hyper::{Request, Response, StatusCode, service::service_fn}; +use hyper_util::rt::TokioIo; +use loki_rs::downloads::ModelDirs; +use loki_rs::hf::{self, Hardware, HfEntry, HfEnv, HfListing}; +use loki_rs::models; +use serde_json::Value; + +fn fixture() -> Value { + serde_json::from_str(include_str!("parity_models/models.json")).expect("journal") +} + +fn mask_free(v: &mut Value) { + match v { + Value::Object(m) => { + for (k, e) in m.iter_mut() { + if k == "free" || k == "free_exact" { + *e = Value::String("…".into()); + } else { + mask_free(e); + } + } + } + Value::Array(a) => a.iter_mut().for_each(mask_free), + _ => {} + } +} + +#[test] +fn parite_des_fonctions_pures() { + let fx = fixture(); + for (n, want) in fx["quant"].as_object().unwrap() { + assert_eq!( + models::quant_from_name(n), + want.as_str().unwrap(), + "quant({n})" + ); + } + for c in fx["split_args"].as_array().unwrap() { + let want: Vec = serde_json::from_value(c["out"].clone()).unwrap(); + assert_eq!(models::split_args(c["in"].as_str().unwrap()), want, "{c}"); + } + for (t, want) in fx["mask"].as_object().unwrap() { + assert_eq!(hf::mask_hf_token(t), want.as_str().unwrap(), "mask({t:?})"); + } + for c in fx["gated"].as_array().unwrap() { + assert_eq!(hf::gated_flag(&c["in"]), c["out"].as_bool().unwrap(), "{c}"); + } + let cl = &fx["classify"]; + let files: Vec<(String, i64)> = cl["files"] + .as_array() + .unwrap() + .iter() + .map(|f| { + ( + f["path"].as_str().unwrap().to_string(), + f["size"].as_i64().unwrap(), + ) + }) + .collect(); + let got = hf::classify(hf::HF_HOST, cl["repo"].as_str().unwrap(), &files); + let want: HfListing = serde_json::from_value(cl["out"].clone()).unwrap(); + assert_eq!(got, want); + // Sérialisé comme le Go (listes vides → null). + assert_eq!(serde_json::to_value(&got).unwrap(), cl["out"]); + for c in fx["projector"].as_array().unwrap() { + let list: Vec = serde_json::from_value(c["in"].clone()).unwrap_or_default(); + let got = hf::pick_projector(&list) + .map(|e| e.name.clone()) + .unwrap_or_default(); + assert_eq!(got, c["name"].as_str().unwrap(), "{c}"); + } + for c in fx["verdicts"].as_array().unwrap() { + let hw: Hardware = serde_json::from_value(c["hardware"].clone()).unwrap(); + // Sans GPU : null, comme une tranche nil du Go (24.0 contre 24 : même + // nombre en JSON). + let gpus = &serde_json::to_value(&hw).unwrap()["gpus"]; + assert_eq!(gpus.is_null(), c["hardware"]["gpus"].is_null()); + let (v, why) = hf::fit_verdict( + &hw, + c["weights"].as_i64().unwrap(), + c["mmproj"].as_i64().unwrap(), + c["ctx"].as_i64().unwrap(), + ); + assert_eq!( + (v.as_str(), why.as_str()), + (c["verdict"].as_str().unwrap(), c["why"].as_str().unwrap()) + ); + } +} + +fn tree_files(home: &Path) -> Vec { + let mut out = Vec::new(); + for top in ["disque", "models"] { + let mut stack = vec![home.join(top)]; + while let Some(d) = stack.pop() { + let Ok(rd) = std::fs::read_dir(&d) else { + continue; + }; + for e in rd.filter_map(Result::ok) { + let p = e.path(); + if p.is_dir() { + stack.push(p); + } else { + out.push(p.strip_prefix(home).unwrap().to_string_lossy().into_owned()); + } + } + } + } + out.sort(); + out +} + +/// Monte l'arborescence du scénario sous `home`. +fn setup_tree(sc: &Value, home: &Path) -> (Vec, HashMap) { + let h = home.to_string_lossy().into_owned(); + let unhome = |s: &str| s.replace("{{HOME}}", &h); + for (rel, size) in sc["files"].as_object().unwrap() { + let p = home.join(rel); + let size = size.as_i64().unwrap(); + if size < 0 { + std::fs::create_dir_all(&p).unwrap(); + continue; + } + std::fs::create_dir_all(p.parent().unwrap()).unwrap(); + std::fs::write(&p, vec![0u8; size as usize]).unwrap(); + } + std::fs::create_dir_all(home.join("presets")).unwrap(); + for (id, content) in sc["presets"].as_object().unwrap() { + std::fs::write( + home.join("presets").join(format!("{id}.env")), + unhome(content.as_str().unwrap()), + ) + .unwrap(); + } + let extra = sc["extra"] + .as_array() + .unwrap() + .iter() + .map(|d| unhome(d.as_str().unwrap())) + .collect::>(); + let cfg = sc["config"] + .as_object() + .unwrap() + .iter() + .map(|(k, v)| (k.clone(), unhome(v.as_str().unwrap()))) + .collect(); + (extra, cfg) +} + +#[test] +fn parite_du_scenario_sur_disque() { + let fx = fixture(); + let sc = &fx["scenario"]; + let tmp = tempfile::tempdir().unwrap(); + let home = tmp.path().canonicalize().unwrap(); + let h = home.to_string_lossy().into_owned(); + let (extra, cfg) = setup_tree(sc, &home); + let mut dirs = ModelDirs { + home: home.clone(), + // saveExtraModelDirs : nettoyée, dédoublonnée. + stored: loki_rs::gguf::dedup_dirs(&extra, &format!("{h}/models")), + env: None, + }; + let norm = |v: Value| -> Value { + let s = serde_json::to_string(&v).unwrap().replace(&h, "{{HOME}}"); + let mut v: Value = serde_json::from_str(&s).unwrap(); + mask_free(&mut v); + v + }; + for (i, c) in sc["calls"].as_array().unwrap().iter().enumerate() { + let (method, path) = (c["method"].as_str().unwrap(), c["path"].as_str().unwrap()); + let body: Value = c + .get("body") + .and_then(Value::as_str) + .map(|b| serde_json::from_str(&b.replace("{{HOME}}", &h)).unwrap()) + .unwrap_or(Value::Null); + let s = |k: &str| { + body.get(k) + .and_then(Value::as_str) + .unwrap_or("") + .to_string() + }; + let (status, out) = match (method, path) { + ("GET", "/api/models") => { + let refs = models::build_refs(&dirs, &cfg, &[]); + (200, models::list_models(&dirs, &refs)) + } + ("GET", "/api/models/dirs") => (200, models::dirs_json(&dirs)), + ("POST", "/api/models/delete") => { + let refs = models::build_refs(&dirs, &cfg, &[]); + let force = body.get("force").and_then(Value::as_bool).unwrap_or(false); + let (code, out) = models::delete_model(&dirs, &refs, &s("name"), force); + (code.as_u16(), out) + } + ("POST", "/api/models/dirs") => { + match models::dirs_update(&dirs, &s("path"), &s("action")) { + Ok(list) => { + dirs.stored = list; + (200, serde_json::json!({ "ok": true })) + } + Err(e) => (400, serde_json::json!({ "ok": false, "error": e })), + } + } + _ => panic!("appel inattendu {c}"), + }; + let ctx = format!( + "appel {i} : {method} {path} {}", + c.get("body").unwrap_or(&Value::Null) + ); + assert_eq!(status as u64, c["status"].as_u64().unwrap(), "{ctx}"); + assert_eq!(norm(out), c["out"], "{ctx}"); + if let Some(want) = c.get("files") { + let want: Vec = serde_json::from_value(want.clone()).unwrap(); + assert_eq!(tree_files(&home), want, "{ctx}"); + } + if let Some(want) = c.get("stored") { + let want: Vec = serde_json::from_value(want.clone()).unwrap(); + let got: Vec = dirs + .stored + .iter() + .map(|d| d.replace(&h, "{{HOME}}")) + .collect(); + assert_eq!(got, want, "{ctx}"); + } + } +} + +#[test] +fn adresses_et_echappements_comme_le_go() { + assert_eq!( + hf::search_endpoint("https://huggingface.co", " qwen 3 & co "), + "https://huggingface.co/api/models?direction=-1&expand%5B%5D=gated&expand%5B%5D=downloads&expand%5B%5D=likes&filter=gguf&limit=25&search=qwen+3+%26+co&sort=downloads" + ); + // Tronquée à 100 octets, même au milieu d'un caractère (q[:100] du Go). + let long = format!("{}é", "a".repeat(99)); + assert!(hf::search_endpoint("h", &long).contains(&format!("search={}%C3&", "a".repeat(99)))); + assert_eq!( + hf::go_path_escape("a b+c&(d)/é"), + "a%20b+c&%28d%29%2F%C3%A9" + ); + assert!( + hf::repo_ok("a/b.c-d_e") + && !hf::repo_ok("../x") + && !hf::repo_ok("a/b/c") + && !hf::repo_ok("-a/b") + ); +} + +// --------------------------------------------------------------------------- +// Faux Hugging Face +// --------------------------------------------------------------------------- + +#[derive(Default)] +struct FakeHf { + log: Mutex>, + auth: Mutex>, + gated: Mutex, +} + +async fn hf_handle(st: Arc, req: Request) -> Response> { + let pq = req + .uri() + .path_and_query() + .map(|p| p.as_str().to_string()) + .unwrap_or_default(); + st.log.lock().unwrap().push(pq.clone()); + let auth = req + .headers() + .get("authorization") + .map(|v| v.to_str().unwrap().to_string()) + .unwrap_or_default(); + st.auth.lock().unwrap().push(auth.clone()); + let path = req.uri().path(); + let (code, body) = if path == "/api/whoami-v2" { + if auth == "Bearer hf_bon_jeton" { + (200, r#"{"name":"moi","type":"user"}"#.to_string()) + } else { + (401, r#"{"error":"Invalid credentials"}"#.to_string()) + } + } else if path == "/api/models" { + (200, r#"[{"id":"u/a-GGUF","downloads":10,"likes":2,"gated":false},{"id":"u/b-GGUF","downloads":5,"likes":1,"gated":"manual"}]"#.to_string()) + } else if path == "/api/models/u/a-GGUF/tree/main" { + (200, r#"[{"type":"directory","path":"Q4"},{"type":"file","path":"Q4/a-Q4_K_M.gguf","size":134,"lfs":{"size":5368709120}},{"type":"file","path":"mmproj-Q8_0.gguf","size":629145600},{"type":"file","path":"README.md","size":10}]"#.to_string()) + } else if path == "/api/models/u/a-GGUF" { + ( + 200, + format!( + r#"{{"id":"u/a-GGUF","gated":{}}}"#, + st.gated.lock().unwrap() + ), + ) + } else if path == "/api/models/u/verrou/tree/main" { + (401, "{}".to_string()) + } else { + (404, "{}".to_string()) + }; + let mut r = Response::new(Full::new(Bytes::from(body))); + *r.status_mut() = StatusCode::from_u16(code).unwrap(); + r +} + +async fn fake_hf() -> (SocketAddr, Arc) { + let st = Arc::new(FakeHf::default()); + *st.gated.lock().unwrap() = Value::String("auto".into()); + let l = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap(); + let addr = l.local_addr().unwrap(); + let s2 = st.clone(); + tokio::spawn(async move { + while let Ok((s, _)) = l.accept().await { + let st = s2.clone(); + tokio::spawn(async move { + let svc = service_fn(move |req| { + let st = st.clone(); + async move { Ok::<_, Infallible>(hf_handle(st, req).await) } + }); + let _ = hyper::server::conn::http1::Builder::new() + .serve_connection(TokioIo::new(s), svc) + .await; + }); + } + }); + (addr, st) +} + +#[tokio::test] +async fn catalogue_contre_un_faux_hugging_face() { + let (addr, st) = fake_hf().await; + let base = format!("http://{addr}"); + let c = hf::HfClient::new(&base); + let env = HfEnv { + token: "hf_x".into(), + version: "1.2.3".into(), + }; + let repos = c.search(&env, "qwen").await.unwrap(); + assert_eq!(repos[1]["gated"], true); + assert_eq!(repos[0]["id"], "u/a-GGUF"); + // Deuxième recherche identique : servie par le cache. + c.search(&env, "qwen").await.unwrap(); + assert_eq!(st.log.lock().unwrap().len(), 1); + + let l = c.files(&env, " /u/a-GGUF/ ").await.unwrap(); + assert!(l.gated); + assert_eq!(l.models.len(), 1); + assert_eq!(l.models[0].size, 5368709120, "taille LFS prise"); + assert_eq!( + l.models[0].url, + format!("{base}/u/a-GGUF/resolve/main/Q4/a-Q4_K_M.gguf") + ); + assert_eq!(l.projectors[0].quant, "Q8_0"); + assert!(l.drafts.is_empty()); + assert!(st.auth.lock().unwrap().iter().all(|a| a == "Bearer hf_x")); + + assert_eq!( + c.files(&env, "u/verrou").await.unwrap_err(), + "dépôt à accès restreint — renseigne HF_TOKEN pour y accéder" + ); + assert_eq!( + c.files(&env, "u/absent").await.unwrap_err(), + "dépôt introuvable sur Hugging Face" + ); + assert_eq!( + c.files(&env, "../x").await.unwrap_err(), + "nom de dépôt invalide (attendu « auteur/dépôt »)" + ); + assert_eq!(c.whoami("1", "hf_bon_jeton").await.unwrap(), "moi"); + assert_eq!( + c.whoami("1", "mauvais").await.unwrap_err(), + "jeton refusé par Hugging Face (expiré, révoqué ou mal copié)" + ); + // Vider le cache : la recherche repart vers Hugging Face. + let n = st.log.lock().unwrap().len(); + c.clear_cache(); + c.search(&env, "qwen").await.unwrap(); + assert_eq!(st.log.lock().unwrap().len(), n + 1); +} + +#[tokio::test] +async fn routes_des_modeles_par_la_facade() { + let (addr, st) = fake_hf().await; + let tmp = tempfile::tempdir().unwrap(); + let home = tmp.path().canonicalize().unwrap(); + std::fs::create_dir_all(home.join("models")).unwrap(); + std::fs::write(home.join("models/a.gguf"), b"0123").unwrap(); + std::fs::create_dir_all(home.join("presets")).unwrap(); + std::fs::write(home.join("presets/p.env"), "NAME=P\nMODEL=a.gguf\n").unwrap(); + let ext = home.join("ext"); + std::fs::create_dir_all(&ext).unwrap(); + let (facade, go, _stop) = fakego::start_models(home.clone(), &format!("http://{addr}")).await; + + let (code, _, b) = fakego::get(facade, "/api/models", &[]).await; + assert_eq!(code, StatusCode::OK); + let v: Value = serde_json::from_slice(&b).unwrap(); + assert_eq!(v[0]["name"], "a.gguf"); + assert_eq!(v[0]["used"], serde_json::json!(["P"])); + + // Dossier ajouté : rangé dans la base du Go. + let body = format!(r#"{{"path":"{}"}}"#, ext.display()); + let (code, _) = fakego::send(facade, "POST", "/api/models/dirs", &[], &body).await; + assert_eq!(code, StatusCode::OK); + let stored: Vec = + serde_json::from_str(&go.get("state", "model_dirs").unwrap()).unwrap(); + assert_eq!(stored, [ext.to_string_lossy().into_owned()]); + let (_, _, b) = fakego::get(facade, "/api/models/dirs", &[]).await; + let v: Value = serde_json::from_slice(&b).unwrap(); + assert_eq!(v["dirs"][1]["path"], ext.to_string_lossy().as_ref()); + assert_eq!(v["dirs"][1]["count"], 0); + + // Utilisé par un preset : 409, puis force. + let (code, b) = fakego::send( + facade, + "POST", + "/api/models/delete", + &[], + r#"{"name":"a.gguf"}"#, + ) + .await; + assert_eq!(code, StatusCode::CONFLICT, "{b:?}"); + let (code, b) = fakego::send( + facade, + "POST", + "/api/models/delete", + &[], + r#"{"name":"a.gguf","force":true}"#, + ) + .await; + assert_eq!(code, StatusCode::OK); + assert_eq!(serde_json::from_slice::(&b).unwrap()["freed"], 4); + assert!(!home.join("models/a.gguf").exists()); + + // Catalogue : matériel joint, jeton absent. + let (code, _, b) = fakego::get(facade, "/api/hf/search?q=qwen+3", &[]).await; + assert_eq!(code, StatusCode::OK); + let v: Value = serde_json::from_slice(&b).unwrap(); + assert_eq!(v["hf_token"], false); + assert!(v["hardware"]["os"].is_string()); + assert!(st.log.lock().unwrap()[0].contains("search=qwen+3&")); + let (code, _, b) = fakego::get(facade, "/api/hf/search?q=%20", &[]).await; + assert_eq!(code, StatusCode::BAD_REQUEST); + assert_eq!( + serde_json::from_slice::(&b).unwrap()["error"], + "précise ce que tu cherches" + ); + go.set("config", "CTX", "65536"); + let (code, _, b) = fakego::get(facade, "/api/hf/files?repo=u/a-GGUF", &[]).await; + assert_eq!(code, StatusCode::OK); + let v: Value = serde_json::from_slice(&b).unwrap(); + assert_eq!( + ( + v["ctx"].as_i64(), + v["vision"].as_bool(), + v["gated"].as_bool() + ), + (Some(65536), Some(true), Some(true)) + ); + assert_eq!(v["drafts"], Value::Null); + assert!(v["models"][0].get("verdict").is_none() || v["models"][0]["verdict"].is_string()); + let (code, _, _) = fakego::get(facade, "/api/hf/files?repo=u/absent", &[]).await; + assert_eq!(code, StatusCode::BAD_GATEWAY); + + // Jeton : refusé, accepté (vérifié, enregistré, masqué), effacé. + let (code, b) = fakego::send( + facade, + "POST", + "/api/hf/token", + &[], + r#"{"token":"mauvais"}"#, + ) + .await; + assert_eq!(code, StatusCode::BAD_REQUEST, "{b:?}"); + let (code, b) = fakego::send( + facade, + "POST", + "/api/hf/token", + &[], + r#"{"token":" hf_bon_jeton "}"#, + ) + .await; + assert_eq!(code, StatusCode::OK); + let v: Value = serde_json::from_slice(&b).unwrap(); + assert_eq!( + ( + v["set"].as_bool(), + v["source"].as_str(), + v["masked"].as_str(), + v["account"].as_str() + ), + (Some(true), Some("config"), Some("hf_…eton"), Some("moi")) + ); + assert_eq!(go.get("state", "hf_token").as_deref(), Some("hf_bon_jeton")); + let (_, _, b) = fakego::get(facade, "/api/hf/search?q=autre", &[]).await; + assert_eq!( + serde_json::from_slice::(&b).unwrap()["hf_token"], + true + ); + assert_eq!( + st.auth.lock().unwrap().last().unwrap(), + "Bearer hf_bon_jeton" + ); + let (code, b) = fakego::send(facade, "POST", "/api/hf/token", &[], r#"{"token":""}"#).await; + assert_eq!(code, StatusCode::OK); + assert_eq!(serde_json::from_slice::(&b).unwrap()["set"], false); + assert_eq!(go.get("state", "hf_token"), None); + let (code, b) = fakego::send(facade, "POST", "/api/hf/token", &[], "x").await; + assert_eq!( + ( + code, + serde_json::from_slice::(&b).unwrap()["error"].as_str() + ), + (StatusCode::BAD_REQUEST, Some("requête illisible")) + ); + + // Rien n'est parti au Go. + let relayed = go.relayed.lock().unwrap().clone(); + assert!( + !relayed + .iter() + .any(|p| p.starts_with("/api/models") || p.starts_with("/api/hf")), + "{relayed:?}" + ); +} + +#[tokio::test] +async fn sans_la_variable_les_routes_restent_au_go() { + let tmp = tempfile::tempdir().unwrap(); + let (facade, _go, _stop) = fakego::start(tmp.path().to_path_buf()).await; + for p in [ + "/api/models", + "/api/models/dirs", + "/api/hf/token", + "/api/hf/search?q=x", + ] { + let (_, _, b) = fakego::get(facade, p, &[]).await; + assert!(b.starts_with(b"go:/api/"), "{p} : {b:?}"); + } +} diff --git a/rust/loki-rs/tests/parity_models/models.json b/rust/loki-rs/tests/parity_models/models.json new file mode 100644 index 0000000..c5b5aa3 --- /dev/null +++ b/rust/loki-rs/tests/parity_models/models.json @@ -0,0 +1,933 @@ +{ + "quant": { + "Qwen3-8B-Q4_K_M.gguf": "Q4_K_M", + "a-4bpw.gguf": "4BPW", + "m-IQ4_XS-00001-of-00002.gguf": "IQ4_XS", + "m-Q4.gguf": "Q4", + "m-Q4_K_XL-Q5_K_M.gguf": "Q4_K_XL", + "mmproj-F16.GGUF": "F16", + "model-bf16.gguf": "BF16", + "noext": "", + "plain.gguf": "", + "x.Q8_0.gguf": "Q8_0" + }, + "split_args": [ + { + "in": "", + "out": [] + }, + { + "in": "--mmproj /a/b.gguf -ngl 99", + "out": [ + "--mmproj", + "/a/b.gguf", + "-ngl", + "99" + ] + }, + { + "in": "--chat-template-file \"/etc/x y.jinja\" --x ''", + "out": [ + "--chat-template-file", + "/etc/x y.jinja", + "--x", + "" + ] + }, + { + "in": "a\tb\nc", + "out": [ + "a", + "b", + "c" + ] + }, + { + "in": "'un \"deux\"' trois", + "out": [ + "un \"deux\"", + "trois" + ] + }, + { + "in": "--mmproj", + "out": [ + "--mmproj" + ] + } + ], + "mask": { + "": "", + " ": "", + " hf_padded_token ": "hf_…oken", + "abc": "…bc", + "hf_12345678": "hf_…5678", + "hf_abcdefghijklmnop": "hf_…mnop" + }, + "gated": [ + { + "in": true, + "out": true + }, + { + "in": false, + "out": false + }, + { + "in": "auto", + "out": true + }, + { + "in": "manual", + "out": true + }, + { + "in": "false", + "out": false + }, + { + "in": "", + "out": false + }, + { + "in": "None", + "out": false + }, + { + "in": null, + "out": false + }, + { + "in": 1, + "out": false + } + ], + "classify": { + "repo": "unsloth/Qwen3-8B-GGUF", + "files": [ + { + "path": "README.md", + "size": 1000 + }, + { + "path": "Qwen3-8B-Q8_0.gguf", + "size": 8589934592 + }, + { + "path": "Qwen3-8B-Q4_K_M.gguf", + "size": 5368709120 + }, + { + "path": "mmproj-F16.gguf", + "size": 943718400 + }, + { + "path": "mmproj-Q8_0.gguf", + "size": 629145600 + }, + { + "path": "mtp-Qwen3-8B-Q8_0.gguf", + "size": 314572800 + }, + { + "path": "BF16/Qwen3-8B-BF16-00001-of-00002.gguf", + "size": 9663676416 + }, + { + "path": "BF16/Qwen3-8B-BF16-00002-of-00002.gguf", + "size": 7516192768 + }, + { + "path": "Q2/Qwen3-8B-BF16-00001-of-00002.gguf", + "size": 1073741824 + }, + { + "path": "dossier avec espace/Qwen3 8B Q3.gguf", + "size": 4294967296 + } + ], + "out": { + "repo": "unsloth/Qwen3-8B-GGUF", + "gated": false, + "models": [ + { + "name": "Qwen3-8B-BF16-00001-of-00002.gguf", + "url": "https://huggingface.co/unsloth/Qwen3-8B-GGUF/resolve/main/Q2/Qwen3-8B-BF16-00001-of-00002.gguf", + "size": 1073741824, + "quant": "BF16", + "shards": 2 + }, + { + "name": "Qwen3 8B Q3.gguf", + "url": "https://huggingface.co/unsloth/Qwen3-8B-GGUF/resolve/main/dossier%20avec%20espace/Qwen3%208B%20Q3.gguf", + "size": 4294967296, + "quant": "", + "shards": 1 + }, + { + "name": "Qwen3-8B-Q4_K_M.gguf", + "url": "https://huggingface.co/unsloth/Qwen3-8B-GGUF/resolve/main/Qwen3-8B-Q4_K_M.gguf", + "size": 5368709120, + "quant": "Q4_K_M", + "shards": 1 + }, + { + "name": "Qwen3-8B-Q8_0.gguf", + "url": "https://huggingface.co/unsloth/Qwen3-8B-GGUF/resolve/main/Qwen3-8B-Q8_0.gguf", + "size": 8589934592, + "quant": "Q8_0", + "shards": 1 + }, + { + "name": "Qwen3-8B-BF16-00001-of-00002.gguf", + "url": "https://huggingface.co/unsloth/Qwen3-8B-GGUF/resolve/main/BF16/Qwen3-8B-BF16-00001-of-00002.gguf", + "size": 17179869184, + "quant": "BF16", + "shards": 2 + } + ], + "projectors": [ + { + "name": "mmproj-Q8_0.gguf", + "url": "https://huggingface.co/unsloth/Qwen3-8B-GGUF/resolve/main/mmproj-Q8_0.gguf", + "size": 629145600, + "quant": "Q8_0", + "shards": 1 + }, + { + "name": "mmproj-F16.gguf", + "url": "https://huggingface.co/unsloth/Qwen3-8B-GGUF/resolve/main/mmproj-F16.gguf", + "size": 943718400, + "quant": "F16", + "shards": 1 + } + ], + "drafts": [ + { + "name": "mtp-Qwen3-8B-Q8_0.gguf", + "url": "https://huggingface.co/unsloth/Qwen3-8B-GGUF/resolve/main/mtp-Qwen3-8B-Q8_0.gguf", + "size": 314572800, + "quant": "Q8_0", + "shards": 1 + } + ] + } + }, + "projector": [ + { + "in": null, + "name": "" + }, + { + "in": [ + { + "name": "mmproj-F16.gguf", + "url": "", + "size": 900, + "quant": "F16", + "shards": 0 + }, + { + "name": "mmproj-BF16.gguf", + "url": "", + "size": 800, + "quant": "BF16", + "shards": 0 + } + ], + "name": "mmproj-BF16.gguf" + }, + { + "in": [ + { + "name": "mmproj-F16.gguf", + "url": "", + "size": 900, + "quant": "F16", + "shards": 0 + }, + { + "name": "mmproj-q8_0.gguf", + "url": "", + "size": 950, + "quant": "q8_0", + "shards": 0 + } + ], + "name": "mmproj-q8_0.gguf" + } + ], + "verdicts": [ + { + "hardware": { + "os": "linux", + "arch": "amd64", + "ram_gb": 64, + "vram_gb": 24, + "gpus": [ + { + "name": "RTX", + "vram_gb": 24 + } + ] + }, + "weights": 5368709120, + "mmproj": 0, + "ctx": 32768, + "verdict": "ok", + "why": "5.0 Go de poids + ~0.2 Go de contexte (estimation) = ~5.2 Go pour 24.0 Go de VRAM" + }, + { + "hardware": { + "os": "linux", + "arch": "amd64", + "ram_gb": 64, + "vram_gb": 24, + "gpus": [ + { + "name": "RTX", + "vram_gb": 24 + } + ] + }, + "weights": 21474836480, + "mmproj": 629145600, + "ctx": 32768, + "verdict": "ok", + "why": "20.0 Go de poids + 0.6 Go de projecteur + ~1.0 Go de contexte (estimation) = ~21.6 Go pour 24.0 Go de VRAM" + }, + { + "hardware": { + "os": "linux", + "arch": "amd64", + "ram_gb": 64, + "vram_gb": 24, + "gpus": [ + { + "name": "RTX", + "vram_gb": 24 + } + ] + }, + "weights": 23622320128, + "mmproj": 0, + "ctx": 32768, + "verdict": "juste", + "why": "ça passe de justesse : 22.0 Go de poids + ~1.1 Go de contexte (estimation) = ~23.1 Go pour 24.0 Go de VRAM" + }, + { + "hardware": { + "os": "linux", + "arch": "amd64", + "ram_gb": 64, + "vram_gb": 24, + "gpus": [ + { + "name": "RTX", + "vram_gb": 24 + } + ] + }, + "weights": 32212254720, + "mmproj": 0, + "ctx": 0, + "verdict": "trop", + "why": "ne tient pas : 30.0 Go de poids + ~1.5 Go de contexte (estimation) = ~31.5 Go pour 24.0 Go de VRAM" + }, + { + "hardware": { + "os": "linux", + "arch": "amd64", + "ram_gb": 16, + "vram_gb": 0, + "gpus": null + }, + "weights": 8589934592, + "mmproj": 0, + "ctx": 131072, + "verdict": "ok", + "why": "8.0 Go de poids + ~1.6 Go de contexte (estimation) = ~9.6 Go pour 16.0 Go de RAM" + }, + { + "hardware": { + "os": "linux", + "arch": "amd64", + "ram_gb": 0, + "vram_gb": 0, + "gpus": null + }, + "weights": 1073741824, + "mmproj": 0, + "ctx": 4096, + "verdict": "", + "why": "" + } + ], + "scenario": { + "files": { + "disque/a-Q4_K_M.gguf": 100, + "disque/ext.GGUF": 400, + "models/a-Q4_K_M.gguf": 100, + "models/actif.gguf": 50, + "models/b.gguf": 200, + "models/m-00001-of-00002.gguf": 10, + "models/m-00002-of-00002.gguf": 20, + "models/notes.txt": 3, + "models/sous-dossier.gguf": -1, + "models/t-00001-of-00003.gguf": 5, + "models/t-00003-of-00003.gguf": 7, + "models/vision-mmproj.gguf": 30 + }, + "extra": [ + "{{HOME}}/disque", + "{{HOME}}/absent" + ], + "presets": { + "alpha": "NAME=Alpha\nMODEL=a-Q4_K_M.gguf\nMMPROJ=vision-mmproj.gguf\n", + "beta": "NAME=Beta\nMODEL={{HOME}}/disque/ext.GGUF\n", + "delta": "NAME=Delta\nMODEL=m-00001-of-00002.gguf\n", + "gamma": "NAME=Gamma\nMODEL=b.gguf\nEXTRA_ARGS=--mmproj \"vision-mmproj.gguf\" -ngl 99 --mmproj b.gguf\n" + }, + "config": { + "MODEL": "actif.gguf", + "MODEL_DRAFT": "{{HOME}}/disque/a-Q4_K_M.gguf" + }, + "calls": [ + { + "method": "GET", + "path": "/api/models", + "status": 200, + "out": [ + { + "active": false, + "dir": "{{HOME}}/models", + "home": true, + "name": "a-Q4_K_M.gguf", + "path": "{{HOME}}/models/a-Q4_K_M.gguf", + "size": 100, + "used": [ + "Alpha" + ], + "value": "a-Q4_K_M.gguf" + }, + { + "active": true, + "dir": "{{HOME}}/models", + "home": true, + "name": "actif.gguf", + "path": "{{HOME}}/models/actif.gguf", + "size": 50, + "value": "actif.gguf" + }, + { + "active": false, + "dir": "{{HOME}}/models", + "home": true, + "name": "b.gguf", + "path": "{{HOME}}/models/b.gguf", + "size": 200, + "used": [ + "Gamma" + ], + "value": "b.gguf" + }, + { + "active": false, + "dir": "{{HOME}}/models", + "home": true, + "name": "m-00001-of-00002.gguf", + "path": "{{HOME}}/models/m-00001-of-00002.gguf", + "shards": 2, + "size": 30, + "used": [ + "Delta" + ], + "value": "m-00001-of-00002.gguf" + }, + { + "dir": "{{HOME}}/models", + "home": true, + "missing": [ + "t-00002-of-00003.gguf" + ], + "name": "t-00001-of-00003.gguf", + "path": "{{HOME}}/models/t-00001-of-00003.gguf", + "shards": 3, + "size": 12, + "value": "t-00001-of-00003.gguf" + }, + { + "active": false, + "dir": "{{HOME}}/models", + "home": true, + "name": "vision-mmproj.gguf", + "path": "{{HOME}}/models/vision-mmproj.gguf", + "size": 30, + "used": [ + "Alpha", + "Gamma" + ], + "value": "vision-mmproj.gguf" + }, + { + "active": true, + "dir": "{{HOME}}/disque", + "home": false, + "name": "a-Q4_K_M.gguf", + "path": "{{HOME}}/disque/a-Q4_K_M.gguf", + "size": 100, + "value": "{{HOME}}/disque/a-Q4_K_M.gguf" + }, + { + "active": false, + "dir": "{{HOME}}/disque", + "home": false, + "name": "ext.GGUF", + "path": "{{HOME}}/disque/ext.GGUF", + "size": 400, + "used": [ + "Beta" + ], + "value": "{{HOME}}/disque/ext.GGUF" + } + ] + }, + { + "method": "GET", + "path": "/api/models/dirs", + "status": 200, + "out": { + "dirs": [ + { + "count": 6, + "exists": true, + "free": "…", + "free_exact": "…", + "home": true, + "path": "{{HOME}}/models" + }, + { + "count": 2, + "exists": true, + "free": "…", + "free_exact": "…", + "home": false, + "path": "{{HOME}}/disque" + }, + { + "count": -1, + "exists": false, + "free": "…", + "free_exact": "…", + "home": false, + "path": "{{HOME}}/absent" + } + ], + "env": false, + "ok": true + } + }, + { + "method": "POST", + "path": "/api/models/delete", + "body": "{\"name\":\"actif.gguf\"}", + "status": 409, + "out": { + "active": true, + "error": "modèle en service — bascule sur un autre preset avant de le supprimer", + "ok": false + }, + "files": [ + "disque/a-Q4_K_M.gguf", + "disque/ext.GGUF", + "models/a-Q4_K_M.gguf", + "models/actif.gguf", + "models/b.gguf", + "models/m-00001-of-00002.gguf", + "models/m-00002-of-00002.gguf", + "models/notes.txt", + "models/t-00001-of-00003.gguf", + "models/t-00003-of-00003.gguf", + "models/vision-mmproj.gguf" + ] + }, + { + "method": "POST", + "path": "/api/models/delete", + "body": "{\"name\":\"a-Q4_K_M.gguf\"}", + "status": 409, + "out": { + "error": "modèle utilisé par : Alpha", + "ok": false, + "used": [ + "Alpha" + ] + }, + "files": [ + "disque/a-Q4_K_M.gguf", + "disque/ext.GGUF", + "models/a-Q4_K_M.gguf", + "models/actif.gguf", + "models/b.gguf", + "models/m-00001-of-00002.gguf", + "models/m-00002-of-00002.gguf", + "models/notes.txt", + "models/t-00001-of-00003.gguf", + "models/t-00003-of-00003.gguf", + "models/vision-mmproj.gguf" + ] + }, + { + "method": "POST", + "path": "/api/models/delete", + "body": "{\"name\":\"{{HOME}}/disque/a-Q4_K_M.gguf\"}", + "status": 409, + "out": { + "active": true, + "error": "modèle en service — bascule sur un autre preset avant de le supprimer", + "ok": false + }, + "files": [ + "disque/a-Q4_K_M.gguf", + "disque/ext.GGUF", + "models/a-Q4_K_M.gguf", + "models/actif.gguf", + "models/b.gguf", + "models/m-00001-of-00002.gguf", + "models/m-00002-of-00002.gguf", + "models/notes.txt", + "models/t-00001-of-00003.gguf", + "models/t-00003-of-00003.gguf", + "models/vision-mmproj.gguf" + ] + }, + { + "method": "POST", + "path": "/api/models/delete", + "body": "{\"name\":\"b.gguf\",\"force\":true}", + "status": 200, + "out": { + "freed": 200, + "ok": true, + "used": [ + "Gamma" + ] + }, + "files": [ + "disque/a-Q4_K_M.gguf", + "disque/ext.GGUF", + "models/a-Q4_K_M.gguf", + "models/actif.gguf", + "models/m-00001-of-00002.gguf", + "models/m-00002-of-00002.gguf", + "models/notes.txt", + "models/t-00001-of-00003.gguf", + "models/t-00003-of-00003.gguf", + "models/vision-mmproj.gguf" + ] + }, + { + "method": "POST", + "path": "/api/models/delete", + "body": "{\"name\":\"m-00001-of-00002.gguf\",\"force\":true}", + "status": 200, + "out": { + "freed": 30, + "ok": true, + "used": [ + "Delta" + ] + }, + "files": [ + "disque/a-Q4_K_M.gguf", + "disque/ext.GGUF", + "models/a-Q4_K_M.gguf", + "models/actif.gguf", + "models/notes.txt", + "models/t-00001-of-00003.gguf", + "models/t-00003-of-00003.gguf", + "models/vision-mmproj.gguf" + ] + }, + { + "method": "POST", + "path": "/api/models/delete", + "body": "{\"name\":\"t-00001-of-00003.gguf\"}", + "status": 200, + "out": { + "freed": 12, + "ok": true, + "used": null + }, + "files": [ + "disque/a-Q4_K_M.gguf", + "disque/ext.GGUF", + "models/a-Q4_K_M.gguf", + "models/actif.gguf", + "models/notes.txt", + "models/vision-mmproj.gguf" + ] + }, + { + "method": "POST", + "path": "/api/models/delete", + "body": "{\"name\":\"inconnu.gguf\"}", + "status": 400, + "out": { + "error": "modèle introuvable: inconnu.gguf", + "ok": false + }, + "files": [ + "disque/a-Q4_K_M.gguf", + "disque/ext.GGUF", + "models/a-Q4_K_M.gguf", + "models/actif.gguf", + "models/notes.txt", + "models/vision-mmproj.gguf" + ] + }, + { + "method": "POST", + "path": "/api/models/delete", + "body": "{\"name\":\"/etc/passwd.gguf\"}", + "status": 400, + "out": { + "error": "dossier non autorisé : /etc — ajoute-le dans « Dossiers de modèles »", + "ok": false + }, + "files": [ + "disque/a-Q4_K_M.gguf", + "disque/ext.GGUF", + "models/a-Q4_K_M.gguf", + "models/actif.gguf", + "models/notes.txt", + "models/vision-mmproj.gguf" + ] + }, + { + "method": "POST", + "path": "/api/models/delete", + "body": "{\"name\":\"notes.txt\"}", + "status": 400, + "out": { + "error": "le modèle doit être un fichier .gguf", + "ok": false + }, + "files": [ + "disque/a-Q4_K_M.gguf", + "disque/ext.GGUF", + "models/a-Q4_K_M.gguf", + "models/actif.gguf", + "models/notes.txt", + "models/vision-mmproj.gguf" + ] + }, + { + "method": "POST", + "path": "/api/models/delete", + "body": "{\"name\":\"\"}", + "status": 400, + "out": { + "error": "nom de modèle invalide", + "ok": false + }, + "files": [ + "disque/a-Q4_K_M.gguf", + "disque/ext.GGUF", + "models/a-Q4_K_M.gguf", + "models/actif.gguf", + "models/notes.txt", + "models/vision-mmproj.gguf" + ] + }, + { + "method": "POST", + "path": "/api/models/dirs", + "body": "{\"path\":\"\"}", + "status": 400, + "out": { + "error": "chemin vide", + "ok": false + }, + "stored": [ + "{{HOME}}/disque", + "{{HOME}}/absent" + ] + }, + { + "method": "POST", + "path": "/api/models/dirs", + "body": "{\"path\":\"{{HOME}}/nulle-part\"}", + "status": 400, + "out": { + "error": "dossier introuvable : {{HOME}}/nulle-part", + "ok": false + }, + "stored": [ + "{{HOME}}/disque", + "{{HOME}}/absent" + ] + }, + { + "method": "POST", + "path": "/api/models/dirs", + "body": "{\"path\":\"{{HOME}}/models/sous-dossier.gguf\"}", + "status": 200, + "out": { + "ok": true + }, + "stored": [ + "{{HOME}}/disque", + "{{HOME}}/absent", + "{{HOME}}/models/sous-dossier.gguf" + ] + }, + { + "method": "POST", + "path": "/api/models/dirs", + "body": "{\"path\":\"{{HOME}}/disque/\",\"action\":\"remove\"}", + "status": 200, + "out": { + "ok": true + }, + "stored": [ + "{{HOME}}/absent", + "{{HOME}}/models/sous-dossier.gguf" + ] + }, + { + "method": "POST", + "path": "/api/models/dirs", + "body": "{\"path\":\"{{HOME}}/disque/../disque\",\"action\":\"add\"}", + "status": 200, + "out": { + "ok": true + }, + "stored": [ + "{{HOME}}/absent", + "{{HOME}}/models/sous-dossier.gguf", + "{{HOME}}/disque" + ] + }, + { + "method": "POST", + "path": "/api/models/dirs", + "body": "{\"path\":\"{{HOME}}/models\",\"action\":\"add\"}", + "status": 200, + "out": { + "ok": true + }, + "stored": [ + "{{HOME}}/absent", + "{{HOME}}/models/sous-dossier.gguf", + "{{HOME}}/disque" + ] + }, + { + "method": "GET", + "path": "/api/models", + "status": 200, + "out": [ + { + "active": false, + "dir": "{{HOME}}/models", + "home": true, + "name": "a-Q4_K_M.gguf", + "path": "{{HOME}}/models/a-Q4_K_M.gguf", + "size": 100, + "used": [ + "Alpha" + ], + "value": "a-Q4_K_M.gguf" + }, + { + "active": true, + "dir": "{{HOME}}/models", + "home": true, + "name": "actif.gguf", + "path": "{{HOME}}/models/actif.gguf", + "size": 50, + "value": "actif.gguf" + }, + { + "active": false, + "dir": "{{HOME}}/models", + "home": true, + "name": "vision-mmproj.gguf", + "path": "{{HOME}}/models/vision-mmproj.gguf", + "size": 30, + "used": [ + "Alpha", + "Gamma" + ], + "value": "vision-mmproj.gguf" + }, + { + "active": true, + "dir": "{{HOME}}/disque", + "home": false, + "name": "a-Q4_K_M.gguf", + "path": "{{HOME}}/disque/a-Q4_K_M.gguf", + "size": 100, + "value": "{{HOME}}/disque/a-Q4_K_M.gguf" + }, + { + "active": false, + "dir": "{{HOME}}/disque", + "home": false, + "name": "ext.GGUF", + "path": "{{HOME}}/disque/ext.GGUF", + "size": 400, + "used": [ + "Beta" + ], + "value": "{{HOME}}/disque/ext.GGUF" + } + ] + }, + { + "method": "GET", + "path": "/api/models/dirs", + "status": 200, + "out": { + "dirs": [ + { + "count": 3, + "exists": true, + "free": "…", + "free_exact": "…", + "home": true, + "path": "{{HOME}}/models" + }, + { + "count": -1, + "exists": false, + "free": "…", + "free_exact": "…", + "home": false, + "path": "{{HOME}}/absent" + }, + { + "count": 0, + "exists": true, + "free": "…", + "free_exact": "…", + "home": false, + "path": "{{HOME}}/models/sous-dossier.gguf" + }, + { + "count": 2, + "exists": true, + "free": "…", + "free_exact": "…", + "home": false, + "path": "{{HOME}}/disque" + } + ], + "env": false, + "ok": true + } + } + ] + } +} diff --git a/rust/loki-rs/tests/proxy.rs b/rust/loki-rs/tests/proxy.rs index 2f5da3c..bda1229 100644 --- a/rust/loki-rs/tests/proxy.rs +++ b/rust/loki-rs/tests/proxy.rs @@ -100,7 +100,7 @@ async fn facade(backend: SocketAddr) -> (SocketAddr, oneshot::Sender<()>) { service: "loki-engine".into(), }, chat: None, - downloads: None, + models: None, }; tokio::spawn(loki_rs::proxy::serve(l, cfg, async move { let _ = rx.await; From 17ec2291d4532c6077f7ed8d552a53d667b090e8 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 15 Oct 2026 16:09:48 +0000 Subject: [PATCH 03/52] =?UTF-8?q?Parit=C3=A9=20des=20mod=C3=A8les=20:=20co?= =?UTF-8?q?nversion=20directe=20vers=20hfFile=20(staticcheck=20S1016)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_016tcGfJWefW5kUGoC2wL8cv --- internal/loki/parity_models_test.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/internal/loki/parity_models_test.go b/internal/loki/parity_models_test.go index 08edf26..5223bed 100644 --- a/internal/loki/parity_models_test.go +++ b/internal/loki/parity_models_test.go @@ -171,7 +171,7 @@ func TestPariteModeles(t *testing.T) { } var files []hfFile for _, f := range fx.Classify.Files { - files = append(files, hfFile{Path: f.Path, Size: f.Size}) + files = append(files, hfFile(f)) } fx.Classify.Out = hfClassify(fx.Classify.Repo, files) From 46bfab33432b8210882c1790d181cd10b95ea74e Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 15 Oct 2026 16:17:29 +0000 Subject: [PATCH 04/52] =?UTF-8?q?Lot=209=20:=20dict=C3=A9e=20vocale=20tenu?= =?UTF-8?q?e=20par=20la=20fa=C3=A7ade=20(LOKI=5FDICTATE=3Drust)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit La façade sert /api/transcribe et /api/dictate/* quand LOKI_DICTATE=rust (expérimental ; le Go reste le défaut) et supervise elle-même le serveur de transcription sherpa-onnx : lancé à la demande sur la boucle locale, GPU masqués et sans le jeton interne, prêt dès que sa socket répond, mort au démarrage expliquée, éteint après dix minutes d'inactivité ou quand les réglages changent. Modèles Parakeet téléchargés et décompressés au fil de l'eau (tar.bz2, entrées hors du dossier refusées, liens ignorés), protocole WebSocket identique (en-tête de 8 octets, trames de 10 Kio). Côté Go, facade_dictate.go remet ces routes à la façade quand elles arrivent par le tunnel du relais, et asrShutdownVite (libération de la VRAM, optimiseur) lui demande l'arrêt par l'API interne. Parité : parity_dictate_test.go tire du Go la lecture de treize WAV, les chemins d'archive, réglages, catalogue, arguments et treize appels de route, rejoués par tests/dictate.rs ; supervision testée contre un faux sherpa-onnx qui parle le protocole. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_016tcGfJWefW5kUGoC2wL8cv --- README.md | 1 + ...10-15-rust-migration-lot9-dictee-design.md | 96 ++ internal/loki/dictate_server.go | 4 + internal/loki/facade_chat.go | 5 +- internal/loki/facade_chat_test.go | 33 + internal/loki/facade_dictate.go | 52 + internal/loki/parity_dictate_test.go | 266 +++++ internal/loki/web_server.go | 3 +- rust/Cargo.lock | 37 + rust/loki-rs/Cargo.toml | 4 +- rust/loki-rs/src/api.rs | 2 + rust/loki-rs/src/dictate.rs | 1050 +++++++++++++++++ rust/loki-rs/src/lib.rs | 1 + rust/loki-rs/src/main.rs | 21 +- rust/loki-rs/src/paths.rs | 21 + rust/loki-rs/src/proxy.rs | 25 +- rust/loki-rs/tests/common/fakego.rs | 53 +- rust/loki-rs/tests/dictate.rs | 705 +++++++++++ rust/loki-rs/tests/lot2.rs | 1 + .../loki-rs/tests/parity_dictate/dictate.json | 337 ++++++ rust/loki-rs/tests/proxy.rs | 1 + 21 files changed, 2699 insertions(+), 19 deletions(-) create mode 100644 docs/superpowers/specs/2026-10-15-rust-migration-lot9-dictee-design.md create mode 100644 internal/loki/facade_dictate.go create mode 100644 internal/loki/parity_dictate_test.go create mode 100644 rust/loki-rs/src/dictate.rs create mode 100644 rust/loki-rs/tests/dictate.rs create mode 100644 rust/loki-rs/tests/parity_dictate/dictate.json diff --git a/README.md b/README.md index adfa692..3954cc0 100644 --- a/README.md +++ b/README.md @@ -229,6 +229,7 @@ redémarrages (volume `/data`). Variables d'environnement du conteneur : | `LOKI_GO_PORT` | port local (127.0.0.1) du serveur Go derrière la façade | `8091` | | `LOKI_CHAT` | `rust` : la conversation est tenue par la façade Rust (expérimental, migration en cours), outils fichiers et shell compris. Le Go garde les autres outils, les pièces jointes, les fichiers et l'export. Avec `PROJ_SNAPSHOT`, `KEEP_TURN_IMAGES`, `NUDGE_IN_TOOL`, `SIDE_SLOT`, `SLOT_PERSIST`, `PREWARM` ou des motifs `retry_patterns`, le Go garde la conversation (décidé au démarrage) | Go | | `LOKI_MODELS` | `rust` : les routes des modèles — liste, dossiers, suppression, téléchargements, catalogue Hugging Face — sont servies par la façade Rust (expérimental, migration en cours) | Go | +| `LOKI_DICTATE` | `rust` : la dictée vocale (`/api/transcribe`, `/api/dictate/*`) et son serveur de transcription sont tenus par la façade Rust (expérimental, migration en cours) | Go | **Accès par nom de domaine.** Sans clé de pilotage, l'API n'accepte que les hôtes locaux (IP, `localhost`, nom sans point, `.local`/`.lan`…) et refuse diff --git a/docs/superpowers/specs/2026-10-15-rust-migration-lot9-dictee-design.md b/docs/superpowers/specs/2026-10-15-rust-migration-lot9-dictee-design.md new file mode 100644 index 0000000..167450c --- /dev/null +++ b/docs/superpowers/specs/2026-10-15-rust-migration-lot9-dictee-design.md @@ -0,0 +1,96 @@ +# Lot 9 — dictée vocale tenue par la façade + +## Contexte + +La dictée vocale vivait dans le Go : + +- `dictate_config.go` : réglages ; +- `dictate_models.go` : catalogue Parakeet, téléchargement et extraction ; +- `dictate_server.go` : supervision de `sherpa-onnx-offline-websocket-server` + et protocole WebSocket ; +- `web_transcribe.go` : routes. + +Lot 9 : la façade la tient quand `LOKI_DICTATE=rust` (expérimental ; sans la +variable rien ne change). + +## Ce que sert la façade (`rust/loki-rs/src/dictate.rs`) + +| Route | Rôle | +|---|---| +| `POST /api/transcribe` | un WAV (≥ 1000 octets, ≤ 32 Mio) entre, le texte sort ; modèle absent : 503 `{downloading, pct}` et téléchargement lancé (relancé après un échec, qui est dit) | +| `GET\|POST /api/dictate/config` | modèle (validé contre le catalogue) et réactivité ; enregistrer arrête le serveur, qui repart au prochain mot avec le nouveau modèle | +| `GET /api/dictate/models` | catalogue trié par taille, présence des quatre fichiers | +| `GET\|POST /api/dictate/models/download` | un téléchargement à la fois, progression sur les octets reçus | +| `GET /api/dictate/state` | serveur allumé (et depuis quand), modèle, présence, téléchargement | +| `POST /internal/facade/v1/dictate/stop` | (le Go, jeton interne) arrêt sans attendre derrière un démarrage | + +Même mécanique que le Go : + +**Archive `.tar.bz2`** +- Décompressée au fil du téléchargement, dans un dossier `.part` renommé à la fin. +- Entrées absolues ou qui remontent refusées, liens ignorés. + +**Serveur de transcription** +- Lancé à la demande sur un port libre de la boucle locale, GPU masqués, sans + le jeton interne dans son environnement. +- Prêt dès que sa socket répond. +- Une mort au démarrage est rapportée avec sa cause et la dernière ligne du + journal ; un processus qui ne répond pas fait échouer le démarrage au bout + de 2 minutes. +- Éteint après dix minutes d'inactivité. +- `shutdown_fast` tue le processus qui démarre au lieu d'attendre le verrou. + +**Protocole** +- En-tête de 8 octets (fréquence, nombre d'octets), échantillons float32 en + trames de 10 Kio, une trame texte en retour. +- Annotations (`[musique]`, `(rires)`) écartées. + +## Côté Go (`facade_dictate.go`) + +- `withFacadeChat` remet les routes du tunnel et de la redistribution E2E à la + façade. Si elle ne répond pas : 502 « dictée indisponible : … ». +- `asrShutdownVite` passe par la façade quand elle tient la dictée : son + serveur n'est pas un enfant de ce process. Cela couvre la libération de la + VRAM et l'arrêt du moteur principal par l'optimiseur. + +## Écarts assumés + +- Messages d'erreur de transport (WebSocket, HTTP) et de décompression au + format des crates Rust. +- Fin de connexion sans réponse : « lecture du résultat : connexion fermée ». + +## Parité + +`internal/loki/parity_dictate_test.go` tire du Go ce journal : + +- treize WAV : mono, bloc `LIST` intercalé, stéréo moyenné, flottant 32 bits, + données tronquées, impaires, vides, et les refus ; +- les chemins d'archive ; +- les marqueurs ; +- les réglages par défaut ; +- les arguments du serveur ; +- les adresses des modèles ; +- la présence ; +- treize appels de route. + +Le tout est rangé dans `tests/parity_dictate/dictate.json` et rejoué par +`tests/dictate.rs`, les routes par la façade. Une mutation (premier canal au +lieu de la moyenne) fait échouer le rejeu. + +`tests/dictate.rs` couvre aussi : + +- le protocole contre un faux serveur WebSocket (en-tête, découpage + 10240 + 10240 + 3528, marqueurs, réponse illisible, serveur injoignable) ; +- l'extraction (lien ignoré, entrée qui remonte refusée sans rien laisser, + archive corrompue) ; +- le téléchargement depuis un faux serveur (100 %, HTTP 404, modèle inconnu, + un seul à la fois) ; +- la supervision d'un faux sherpa-onnx (script Python qui parle le + protocole) : + - transcription de bout en bout par la façade ; + - même serveur réutilisé ; + - arrêt demandé par le Go, et refusé sans le jeton ; + - réglage changé ; +- les démarrages ratés : sortie en code 3 avec sa dernière ligne, serveur + muet, arrêt « vite » qui tue le démarrage, binaire introuvable, modèle + absent. diff --git a/internal/loki/dictate_server.go b/internal/loki/dictate_server.go index 18a209d..9f727de 100644 --- a/internal/loki/dictate_server.go +++ b/internal/loki/dictate_server.go @@ -414,6 +414,10 @@ func asrShutdown() { // normale. La dictée qui attendait reçoit une erreur de démarrage — c'est // l'utilisateur qui vient de demander la place, pas une panne. func asrShutdownVite() { + // Dictée tenue par la façade (LOKI_DICTATE=rust) : son serveur est le sien. + if facadeDictateStop() { + return + } for i := 0; i < 20; i++ { if wsrvMu.TryLock() { asrStopLocked() diff --git a/internal/loki/facade_chat.go b/internal/loki/facade_chat.go index 453d58f..a264a15 100644 --- a/internal/loki/facade_chat.go +++ b/internal/loki/facade_chat.go @@ -141,7 +141,8 @@ var facadeChatRoutes = map[string]bool{ } // withFacadeChat remet la requête à la façade quand elle tient la -// conversation — ou les routes des modèles (facade_models.go). +// conversation — ou les routes des modèles (facade_models.go), ou la dictée +// (facade_dictate.go). // Posé APRÈS requireWebAuth : seule une requête authentifiée ici part, avec le // jeton interne qui en tient lieu là-bas. func withFacadeChat(path string, h http.HandlerFunc) http.HandlerFunc { @@ -150,6 +151,8 @@ func withFacadeChat(path string, h http.HandlerFunc) http.HandlerFunc { case facadeChatRoutes[path]: case facadeModelRoutes[path]: held, unavailable = facadeModels, "modèles indisponibles : " + case facadeDictateRoutes[path]: + held, unavailable = facadeDictate, "dictée indisponible : " default: return h } diff --git a/internal/loki/facade_chat_test.go b/internal/loki/facade_chat_test.go index 048dcfb..ef1e3d6 100644 --- a/internal/loki/facade_chat_test.go +++ b/internal/loki/facade_chat_test.go @@ -379,3 +379,36 @@ func TestFacadeTelechargementsGardeLesPart(t *testing.T) { t.Fatalf(".part orphelin gardé : %v", err) } } + +// LOKI_DICTATE=rust : les routes de la dictée partent à la façade, et arrêter +// la dictée (libération de la VRAM, optimiseur) le lui demande au lieu de +// toucher au serveur de ce process. Sans la variable, rien ne change. +func TestFacadeDictee(t *testing.T) { + testHome(t) + f := demarrerFacade(t) + t.Setenv(facadeChatEnv, "") + t.Setenv(facadeDictateEnv, "rust") + local := func(w http.ResponseWriter, r *http.Request) { io.WriteString(w, "go") } + for p := range facadeDictateRoutes { + w := httptest.NewRecorder() + withFacadeChat(p, local)(w, httptest.NewRequest("POST", p, strings.NewReader("{}"))) + if w.Body.String() != f.reply { + t.Fatalf("%s : réponse %q", p, w.Body.String()) + } + } + asrShutdownVite() + got := f.recu() + if len(got) != len(facadeDictateRoutes)+1 || got[len(got)-1] != "POST /internal/facade/v1/dictate/stop jeton {}" { + t.Fatalf("reçu par la façade : %v", got) + } + t.Setenv(facadeDictateEnv, "") + w := httptest.NewRecorder() + withFacadeChat("/api/transcribe", local)(w, httptest.NewRequest("POST", "/api/transcribe", nil)) + if w.Body.String() != "go" { + t.Fatalf("relayé sans la variable : %q", w.Body.String()) + } + asrShutdownVite() + if n := len(f.recu()); n != len(facadeDictateRoutes)+1 { + t.Fatalf("arrêt demandé à la façade sans la variable (%d appels)", n) + } +} diff --git a/internal/loki/facade_dictate.go b/internal/loki/facade_dictate.go new file mode 100644 index 0000000..a0f8afa --- /dev/null +++ b/internal/loki/facade_dictate.go @@ -0,0 +1,52 @@ +package loki + +import ( + "fmt" + "os" + "strings" +) + +// facade_dictate.go — dictée vocale tenue par la façade Rust (LOKI_DICTATE=rust, +// lot 9 de la migration ; expérimental, le Go reste le défaut). +// +// La façade sert elle-même /api/transcribe et /api/dictate/* sur le port +// public, et supervise le serveur de transcription. Deux choses restent à +// régler ici : +// +// - le tunnel du relais et sa redistribution E2E arrivent dans ce process : +// ces routes sont remises à la façade (withFacadeChat) ; +// - libérer la VRAM (web_vram.go) et l'optimiseur (backend_tune_run.go) +// arrêtent la dictée : c'est alors à la façade qu'on le demande, son +// serveur n'étant pas un enfant de ce process. + +const facadeDictateEnv = "LOKI_DICTATE" + +// facadeDictate : la façade tient-elle la dictée ? Il faut aussi son adresse +// et le jeton interne, sans lesquels rien ne peut lui être remis. +func facadeDictate() bool { + return strings.EqualFold(strings.TrimSpace(os.Getenv(facadeDictateEnv)), "rust") && + facadeAddr() != "" && internalToken() != "" +} + +// facadeDictateRoutes : les routes que la façade sert alors (dictate.rs). +var facadeDictateRoutes = map[string]bool{ + "/api/transcribe": true, + "/api/dictate/config": true, + "/api/dictate/models": true, + "/api/dictate/models/download": true, + "/api/dictate/state": true, +} + +// facadeDictateStop demande à la façade d'arrêter son serveur de dictée, sans +// attendre derrière un démarrage (elle tue le processus qui démarre). Rend +// false quand la façade ne tient pas la dictée : l'appelant arrête alors le +// serveur de ce process. +func facadeDictateStop() bool { + if !facadeDictate() { + return false + } + if err := facadePost("/internal/facade/v1/dictate/stop", map[string]any{}); err != nil { + fmt.Printf("[dictée] arrêt demandé à la façade : %v\n", err) + } + return true +} diff --git a/internal/loki/parity_dictate_test.go b/internal/loki/parity_dictate_test.go new file mode 100644 index 0000000..1649d06 --- /dev/null +++ b/internal/loki/parity_dictate_test.go @@ -0,0 +1,266 @@ +package loki + +import ( + "encoding/binary" + "encoding/json" + "math" + "net/http/httptest" + "os" + "path/filepath" + "runtime" + "strings" + "testing" +) + +// parity_dictate_test.go — journal de référence de la dictée vocale (lot 9 de +// la migration Rust) : lecture des WAV, chemins d'archive, marqueurs, +// réglages, catalogue, arguments du serveur, et les routes qui ne lancent +// rien (réglages, catalogue, état, refus). Rangé dans +// rust/loki-rs/tests/parity_dictate/dictate.json ; tests/dictate.rs le rejoue. +// LOKI_HOME réécrit en {{HOME}}. +// +// LOKI_PARITY_UPDATE=1 go test ./internal/loki -run TestPariteDictee + +const parityDictateFile = "../../rust/loki-rs/tests/parity_dictate/dictate.json" + +type parityWav struct { + Name string `json:"name"` + // WAV en octets (JSON : base64). + In []byte `json:"in"` + Rate int `json:"rate,omitempty"` + Ech []float32 `json:"ech,omitempty"` + Err string `json:"err,omitempty"` +} + +type parityDictateCall struct { + Method string `json:"method"` + Path string `json:"path"` + Body string `json:"body,omitempty"` + Status int `json:"status"` + Out any `json:"out"` +} + +type parityDictateFixture struct { + Wav []parityWav `json:"wav"` + SafeRel []struct { + In string `json:"in"` + Out string `json:"out,omitempty"` + Err string `json:"err,omitempty"` + } `json:"safe_rel"` + Marker map[string]bool `json:"marker"` + Defaults []map[string]string `json:"defaults"` + LastLine map[string]string `json:"last_line"` + Args []string `json:"args"` + ArgsErr string `json:"args_err"` + URLs map[string]string `json:"urls"` + Calls []parityDictateCall `json:"calls"` + Present map[string]bool `json:"present"` +} + +// parityWavOf : un WAV avec les blocs donnés après l'en-tête RIFF/WAVE. +func parityWavOf(chunks ...[]byte) []byte { + b := []byte("RIFF\x00\x00\x00\x00WAVE") + for _, c := range chunks { + b = append(b, c...) + } + binary.LittleEndian.PutUint32(b[4:8], uint32(len(b)-8)) + return b +} + +func parityChunk(id string, body []byte) []byte { + h := make([]byte, 8) + copy(h, id) + binary.LittleEndian.PutUint32(h[4:], uint32(len(body))) + out := append(h, body...) + if len(body)%2 == 1 { + out = append(out, 0) + } + return out +} + +func parityFmt(format, canaux, taux, bits int) []byte { + b := make([]byte, 16) + binary.LittleEndian.PutUint16(b[0:], uint16(format)) + binary.LittleEndian.PutUint16(b[2:], uint16(canaux)) + binary.LittleEndian.PutUint32(b[4:], uint32(taux)) + binary.LittleEndian.PutUint32(b[8:], uint32(taux*canaux*bits/8)) + binary.LittleEndian.PutUint16(b[12:], uint16(canaux*bits/8)) + binary.LittleEndian.PutUint16(b[14:], uint16(bits)) + return parityChunk("fmt ", b) +} + +func parityPCM16(v ...int16) []byte { + b := make([]byte, 2*len(v)) + for i, x := range v { + binary.LittleEndian.PutUint16(b[2*i:], uint16(x)) + } + return b +} + +func TestPariteDictee(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) + norm := func(s string) string { return strings.ReplaceAll(s, home, "{{HOME}}") } + var fx parityDictateFixture + + f32 := make([]byte, 12) + for i, v := range []float32{0.25, -1, 0.5} { + binary.LittleEndian.PutUint32(f32[4*i:], math.Float32bits(v)) + } + cases := []struct { + name string + in []byte + }{ + {"mono 16 bits", parityWavOf(parityFmt(1, 1, 16000, 16), parityChunk("data", parityPCM16(0, 16384, -16384, 32767, -32768)))}, + {"bloc LIST intercalé", parityWavOf(parityChunk("LIST", []byte("INFOxyz")), parityFmt(1, 1, 16000, 16), parityChunk("data", parityPCM16(100, -100)))}, + {"stéréo moyenné", parityWavOf(parityFmt(1, 2, 48000, 16), parityChunk("data", parityPCM16(0, 32767, -32768, 32767)))}, + {"flottant 32 bits", parityWavOf(parityFmt(3, 1, 16000, 32), parityChunk("data", f32))}, + {"data tronqué", parityWavOf(parityFmt(1, 1, 16000, 16), parityChunk("data", parityPCM16(1, 2, 3)))[:12+24+8+4]}, + {"data impair", parityWavOf(parityFmt(1, 1, 8000, 16), parityChunk("data", []byte{1, 2, 3}))}, + {"data vide", parityWavOf(parityFmt(1, 1, 16000, 16), parityChunk("data", nil))}, + {"24 bits", parityWavOf(parityFmt(1, 1, 16000, 24), parityChunk("data", make([]byte, 6)))}, + {"data avant fmt", parityWavOf(parityChunk("data", parityPCM16(1)), parityFmt(1, 1, 16000, 16))}, + {"fmt tronqué", parityWavOf(parityChunk("fmt ", make([]byte, 8)))}, + {"sans data", parityWavOf(parityFmt(1, 1, 16000, 16))}, + {"pas un WAV", []byte("ceci n'est pas un fichier audio du tout")}, + {"vide", []byte{}}, + } + for _, c := range cases { + w := parityWav{Name: c.name, In: c.in} + taux, ech, err := wavVersFloat32(c.in) + if err != nil { + w.Err = err.Error() + } else { + w.Rate, w.Ech = taux, ech + if w.Ech == nil { + w.Ech = []float32{} + } + } + fx.Wav = append(fx.Wav, w) + } + + for _, in := range []string{"", "racine", "racine/", "racine/encoder.onnx", "racine/sous/tokens.txt", + "/abs/x", "racine/../x", `racine\a\b.txt`, " racine/a ", "..", "a/b/../c"} { + out, err := asrSafeRel(in) + c := struct { + In string `json:"in"` + Out string `json:"out,omitempty"` + Err string `json:"err,omitempty"` + }{In: in, Out: out} + if err != nil { + c.Err = err.Error() + } + fx.SafeRel = append(fx.SafeRel, c) + } + fx.Marker = map[string]bool{} + for _, s := range []string{"", " ", "[musique]", "(silence)", " (rires) ", "bonjour", "[a] b", "(x"} { + fx.Marker[s] = asrMarqueur(s) + } + for _, c := range []DictateCfg{{}, {Model: "ggml-base.bin"}, {Model: "parakeet-tdt-0.6b-v2", Reactivity: "court"}} { + d := c.withDefauts() + fx.Defaults = append(fx.Defaults, map[string]string{"model": c.Model, "reactivity": c.Reactivity, + "out_model": d.Model, "out_reactivity": d.Reactivity}) + } + fx.LastLine = map[string]string{} + for _, s := range []string{"", "a\nb\n\n ", "un seul", " \n x \n"} { + fx.LastLine[s] = lastLine(s) + } + args, _ := asrServerArgs(DictateCfg{Model: "parakeet-tdt-0.6b-v3"}, 4567) + for _, a := range args { + fx.Args = append(fx.Args, norm(a)) + } + if _, err := asrServerArgs(DictateCfg{Model: "x"}, 1); err != nil { + fx.ArgsErr = err.Error() + } + fx.URLs = map[string]string{} + for id := range asrCatalogue { + fx.URLs[id] = asrModelURLFor(id) + } + fx.URLs["inconnu"] = asrModelURLFor("inconnu") + + // v2 installé (quatre fichiers non vides), v3 à moitié. + for _, f := range asrFichiers { + p := asrModelFile("parakeet-tdt-0.6b-v2", f) + _ = os.MkdirAll(filepath.Dir(p), 0o755) + _ = os.WriteFile(p, []byte("x"), 0o644) + } + p := asrModelFile("parakeet-tdt-0.6b-v3", "encoder.int8.onnx") + _ = os.MkdirAll(filepath.Dir(p), 0o755) + _ = os.WriteFile(p, []byte("x"), 0o644) + _ = os.WriteFile(asrModelFile("parakeet-tdt-0.6b-v3", "tokens.txt"), nil, 0o644) + fx.Present = map[string]bool{} + for id := range asrCatalogue { + fx.Present[id] = asrModelPresent(id) + } + fx.Present["inconnu"] = asrModelPresent("inconnu") + + call := func(method, path, body string) parityDictateCall { + w := httptest.NewRecorder() + r := httptest.NewRequest(method, path, strings.NewReader(body)) + switch path { + case "/api/dictate/config": + handleDictateConfig(w, r) + case "/api/dictate/models": + handleDictateModels(w, r) + case "/api/dictate/models/download": + handleDictateDownload(w, r) + case "/api/dictate/state": + handleDictateState(w, r) + case "/api/transcribe": + handleTranscribe(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()) + } + return parityDictateCall{Method: method, Path: path, Body: body, Status: w.Code, Out: out} + } + for _, c := range []struct{ m, p, b string }{ + {"GET", "/api/dictate/config", ""}, + {"POST", "/api/dictate/config", `pas du json`}, + {"POST", "/api/dictate/config", `{"model":"ggml-base.bin"}`}, + {"POST", "/api/dictate/config", `{"model":"parakeet-tdt-0.6b-v2"}`}, + {"GET", "/api/dictate/config", ""}, + {"POST", "/api/dictate/config", `{"model":"parakeet-tdt-0.6b-v3","reactivity":"long","autre":1}`}, + {"GET", "/api/dictate/config", ""}, + {"GET", "/api/dictate/models", ""}, + {"GET", "/api/dictate/models/download", ""}, + {"POST", "/api/dictate/models/download", `{"id":" inconnu "}`}, + {"POST", "/api/dictate/models/download", `x`}, + {"GET", "/api/dictate/state", ""}, + {"POST", "/api/transcribe", `trop court`}, + } { + fx.Calls = append(fx.Calls, call(c.m, c.p, c.b)) + } + + got, err := parityJSON(fx) + if err != nil { + t.Fatal(err) + } + if os.Getenv("LOKI_PARITY_UPDATE") == "1" { + if err := os.MkdirAll(filepath.Dir(parityDictateFile), 0o755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(parityDictateFile, got, 0o644); err != nil { + t.Fatal(err) + } + return + } + want, err := os.ReadFile(parityDictateFile) + 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("la dictée ne produit plus le journal rangé dans %s.\n"+ + "Si c'est voulu : LOKI_PARITY_UPDATE=1 go test ./internal/loki -run TestPariteDictee,\n"+ + "puis cargo test --test dictate dit si le port Rust suit.\n--- obtenu ---\n%s", parityDictateFile, got) + } +} diff --git a/internal/loki/web_server.go b/internal/loki/web_server.go index a1bd310..9b35c1b 100644 --- a/internal/loki/web_server.go +++ b/internal/loki/web_server.go @@ -220,7 +220,8 @@ func newWebMux() *http.ServeMux { // api enregistre une route /api/* protégée par la clé de pilotage (web_auth.go). // withFacadeChat : routes du chat remises à la façade quand elle tient la // conversation (LOKI_CHAT=rust, facade_chat.go), celles des modèles quand - // elle les tient (LOKI_MODELS=rust, facade_models.go) ; sans effet sinon. + // elle les tient (LOKI_MODELS=rust, facade_models.go), celles de la dictée + // (LOKI_DICTATE=rust, facade_dictate.go) ; sans effet sinon. api := func(path string, h http.HandlerFunc) { mux.HandleFunc(path, requireWebAuth(withFacadeChat(path, h))) } api("/api/ping", handlePing) api("/api/status", handleStatus) diff --git a/rust/Cargo.lock b/rust/Cargo.lock index c003d08..7221873 100644 --- a/rust/Cargo.lock +++ b/rust/Cargo.lock @@ -56,6 +56,15 @@ version = "1.12.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "fc652a48c352aef3ea3aed32080501cf3ef6ed5da78602a020c991775b0aff04" +[[package]] +name = "bzip2" +version = "0.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f3a53fac24f34a81bc9954b5d6cfce0c21e18ec6959f44f56e8e90e4bb7c346c" +dependencies = [ + "libbz2-rs-sys", +] + [[package]] name = "cc" version = "1.8.0" @@ -192,6 +201,16 @@ version = "2.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "da7c62ceae207dd37ea5b845da6a0696c799f85e97da1ab5b7910be3c1c80223" +[[package]] +name = "filetime" +version = "0.2.29" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c287a33c7f0a620c38e641e7f60827713987b3c0f26e8ddc9462cc69cf75759" +dependencies = [ + "cfg-if", + "libc", +] + [[package]] name = "find-msvc-tools" version = "0.1.14" @@ -639,6 +658,12 @@ dependencies = [ "wasm-bindgen", ] +[[package]] +name = "libbz2-rs-sys" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "34b357333733e8260735ba5894eb928c02ecc69c78715f01a8019e7fa7f2db4c" + [[package]] name = "libc" version = "0.2.190" @@ -668,6 +693,7 @@ name = "loki-rs" version = "0.1.0" dependencies = [ "bytes", + "bzip2", "flate2", "futures-util", "getrandom 0.4.3", @@ -683,6 +709,7 @@ dependencies = [ "serde_json", "sha1", "sha2", + "tar", "tempfile", "tokio", "tokio-tungstenite", @@ -1198,6 +1225,16 @@ dependencies = [ "syn 3.0.9", ] +[[package]] +name = "tar" +version = "0.4.46" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f6221d9a6003c78398e3b239969f352578258df48c8eb051caadae0015bc840" +dependencies = [ + "filetime", + "libc", +] + [[package]] name = "tempfile" version = "3.27.0" diff --git a/rust/loki-rs/Cargo.toml b/rust/loki-rs/Cargo.toml index 191251e..9d5e62e 100644 --- a/rust/loki-rs/Cargo.toml +++ b/rust/loki-rs/Cargo.toml @@ -5,6 +5,7 @@ edition = "2024" [dependencies] bytes = "1.12.1" +bzip2 = "0.6.1" flate2 = "1.1.10" futures-util = "0.3.34" getrandom = "0.4.3" @@ -20,7 +21,9 @@ serde = { version = "1.0.229", features = ["derive"] } serde_json = { version = "1.0.154", features = ["raw_value"] } sha1 = "0.11.0" sha2 = "0.11.1" +tar = { version = "0.4.46", default-features = false } tokio = { version = "1.53.2", features = ["rt-multi-thread", "macros", "net", "io-util", "time", "signal", "process", "sync", "fs"] } +tokio-tungstenite = "0.30.0" [features] # Crypto TLS (ring) pour les API externes en HTTPS. Activée par défaut ; le @@ -35,4 +38,3 @@ libc = "0.2" tokio = { version = "1.53.2", features = ["test-util"] } futures-util = "0.3.34" tempfile = "3.27.0" -tokio-tungstenite = "0.30.0" diff --git a/rust/loki-rs/src/api.rs b/rust/loki-rs/src/api.rs index 870e36c..6f5cde0 100644 --- a/rust/loki-rs/src/api.rs +++ b/rust/loki-rs/src/api.rs @@ -30,6 +30,8 @@ pub struct Ctx { /// catalogue Hugging Face) tenues par la façade (`LOKI_MODELS=rust`) ; /// `None` : au Go. pub models: Option>, + /// Dictée vocale tenue par la façade (`LOKI_DICTATE=rust`) ; `None` : au Go. + pub dictate: Option>, /// Jeton de l'API interne : une requête qui le porte vient du Go. pub internal_token: String, } diff --git a/rust/loki-rs/src/dictate.rs b/rust/loki-rs/src/dictate.rs new file mode 100644 index 0000000..962b0e3 --- /dev/null +++ b/rust/loki-rs/src/dictate.rs @@ -0,0 +1,1050 @@ +//! Dictée vocale — port de `dictate_config.go`, `dictate_models.go`, +//! `dictate_server.go` et `web_transcribe.go` (lot 9 de la migration), servie +//! par la façade quand `LOKI_DICTATE=rust` (expérimental ; le Go par défaut) : +//! +//! - `POST /api/transcribe` : un WAV entre, le texte sort ; +//! - `GET|POST /api/dictate/config` : modèle et réactivité ; +//! - `GET /api/dictate/models` : catalogue, présence sur le disque ; +//! - `GET|POST /api/dictate/models/download` : téléchargement d'un modèle ; +//! - `GET /api/dictate/state` : serveur allumé, modèle, téléchargement. +//! +//! La transcription est locale : Parakeet servi par +//! `sherpa-onnx-offline-websocket-server`, lancé à la demande, arrêté après +//! dix minutes d'inactivité ou quand les réglages changent. Le Go demande son +//! arrêt (libération de la VRAM, optimiseur) par +//! `POST /internal/facade/v1/dictate/stop`. +//! +//! Écarts assumés : messages d'erreur de transport (WebSocket, HTTP) et de +//! décompression au format des crates Rust ; corps JSON illisible : « requête +//! illisible » comme le Go, quelle que soit la faute. + +use std::io::Read; +use std::path::{Path, PathBuf}; +use std::process::{ExitStatus, Stdio}; +use std::sync::atomic::{AtomicU64, Ordering}; +use std::sync::{Arc, Mutex}; +use std::time::{Duration, Instant}; + +use bytes::Bytes; +use futures_util::{SinkExt, StreamExt}; +use http_body_util::{BodyExt, Limited}; +use hyper::body::Incoming; +use hyper::{Method, Request, Response, StatusCode}; +use serde::{Deserialize, Serialize}; +use serde_json::{Value, json}; +use tokio::io::AsyncReadExt; +use tokio_tungstenite::tungstenite::Message; + +use crate::api::Ctx; +use crate::auth::json as send_json; +use crate::proxy::Body; +use crate::store::BK_STATE; + +pub const DICTATE_CFG_KEY: &str = "dictate_cfg"; +/// Les archives sherpa-onnx officielles. +pub const ASR_MODEL_BASE: &str = + "https://github.com/k2-fsa/sherpa-onnx/releases/download/asr-models/"; +/// Les quatre fichiers d'un modèle utilisable. +pub const ASR_FICHIERS: [&str; 4] = [ + "encoder.int8.onnx", + "decoder.int8.onnx", + "joiner.int8.onnx", + "tokens.txt", +]; +const DEFAULT_MODEL: &str = "parakeet-tdt-0.6b-v3"; +/// En-tête du protocole : fréquence, puis nombre d'octets d'audio. +const ENTETE: usize = 8; +/// Taille d'une trame WebSocket envoyée (celle du client de référence). +const TRAME_MAX: usize = 10240; +/// Le serveur s'éteint après ce délai sans dictée. +pub const ASR_IDLE: Duration = Duration::from_secs(600); +/// Plafond d'attente au démarrage (modèle de 600 Mo sur un disque lent). +pub const ASR_READY: Duration = Duration::from_secs(120); + +/// Routes servies ici. +pub fn handles(path: &str) -> bool { + matches!( + path, + "/api/transcribe" + | "/api/dictate/config" + | "/api/dictate/models" + | "/api/dictate/models/download" + | "/api/dictate/state" + ) +} + +/// La route interne par laquelle le Go demande l'arrêt du serveur. +pub const INTERNAL_STOP: &str = "/internal/facade/v1/dictate/stop"; + +// --------------------------------------------------------------------------- +// Catalogue et réglages +// --------------------------------------------------------------------------- + +/// Un modèle du catalogue (`asrModel`). +pub struct AsrModel { + pub id: &'static str, + pub nom: &'static str, + pub dossier: &'static str, + pub octets: i64, + pub langues: &'static str, +} + +pub const CATALOGUE: [AsrModel; 2] = [ + AsrModel { + id: "parakeet-tdt-0.6b-v3", + nom: "Parakeet v3 — recommandé", + dossier: "sherpa-onnx-nemo-parakeet-tdt-0.6b-v3-int8", + octets: 487_170_055, + langues: "25 langues européennes, français inclus — détection automatique", + }, + AsrModel { + id: "parakeet-tdt-0.6b-v2", + nom: "Parakeet v2 — anglais seul", + dossier: "sherpa-onnx-nemo-parakeet-tdt-0.6b-v2-int8", + octets: 482_468_385, + langues: "anglais uniquement", + }, +]; + +fn model(id: &str) -> Option<&'static AsrModel> { + CATALOGUE.iter().find(|m| m.id == id) +} + +/// Réglages (`DictateCfg`), rangés dans `state/dictate_cfg`. +#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)] +pub struct DictateCfg { + #[serde(default)] + pub model: String, + #[serde(default)] + pub reactivity: String, +} + +impl DictateCfg { + /// `withDefauts` : modèle hors catalogue → v3 ; réactivité vide → moyen. + pub fn with_defauts(mut self) -> Self { + if model(&self.model).is_none() { + self.model = DEFAULT_MODEL.into(); + } + if self.reactivity.is_empty() { + self.reactivity = "moyen".into(); + } + self + } +} + +/// `asrRoot` : `LOKI_HOME/asr`. +pub fn asr_root(home: &Path) -> PathBuf { + home.join("asr") +} + +/// `asrModelDirFor` : `None` si l'identifiant est inconnu. +pub fn model_dir_for(home: &Path, id: &str) -> Option { + model(id).map(|m| asr_root(home).join(m.dossier)) +} + +/// `asrModelURLFor` : "" si l'identifiant est inconnu. +pub fn model_url_for(base: &str, id: &str) -> String { + model(id).map_or(String::new(), |m| format!("{base}{}.tar.bz2", m.dossier)) +} + +/// `asrModelPresent` : les quatre fichiers, non vides. +pub fn model_present(home: &Path, id: &str) -> bool { + let Some(dir) = model_dir_for(home, id) else { + return false; + }; + ASR_FICHIERS + .iter() + .all(|f| std::fs::metadata(dir.join(f)).is_ok_and(|m| m.len() > 0)) +} + +/// `asrCatalogueTrie` : du plus léger au plus lourd. +pub fn catalogue_trie(home: &Path) -> Value { + let mut v: Vec<&AsrModel> = CATALOGUE.iter().collect(); + v.sort_by_key(|m| m.octets); + Value::Array( + v.into_iter() + .map(|m| { + json!({ "id": m.id, "nom": m.nom, "octets": m.octets, "langues": m.langues, "present": model_present(home, m.id) }) + }) + .collect(), + ) +} + +/// `asrServerArgs` : `--clef=valeur`, écoute sur la boucle locale seulement. +pub fn server_args(home: &Path, cfg: &DictateCfg, port: u16) -> Result, String> { + let dir = model_dir_for(home, &cfg.model) + .ok_or_else(|| format!("modèle de dictée inconnu : {}", cfg.model))?; + let f = |n: &str| dir.join(n).to_string_lossy().into_owned(); + Ok(vec![ + format!("--encoder={}", f("encoder.int8.onnx")), + format!("--decoder={}", f("decoder.int8.onnx")), + format!("--joiner={}", f("joiner.int8.onnx")), + format!("--tokens={}", f("tokens.txt")), + "--model-type=nemo_transducer".into(), + format!("--port={port}"), + "--host=127.0.0.1".into(), + ]) +} + +/// `asrMarqueur` : vide, ou annotation entre crochets ou parenthèses. +pub fn marqueur(s: &str) -> bool { + let s = s.trim(); + s.is_empty() + || (s.starts_with('[') && s.ends_with(']')) + || (s.starts_with('(') && s.ends_with(')')) +} + +/// `lastLine` : la dernière ligne non vide. +pub fn last_line(s: &str) -> String { + s.trim() + .split('\n') + .rev() + .map(str::trim) + .find(|l| !l.is_empty()) + .unwrap_or("") + .to_string() +} + +// --------------------------------------------------------------------------- +// WAV +// --------------------------------------------------------------------------- + +fn u16le(b: &[u8], i: usize) -> u16 { + u16::from_le_bytes([b[i], b[i + 1]]) +} + +fn u32le(b: &[u8], i: usize) -> u32 { + u32::from_le_bytes([b[i], b[i + 1], b[i + 2], b[i + 3]]) +} + +/// `wavVersFloat32` : fréquence et échantillons dans [-1, 1], en parcourant +/// les blocs (`LIST` & co peuvent précéder `data`). +pub fn wav_to_f32(wav: &[u8]) -> Result<(u32, Vec), String> { + if wav.len() < 12 || &wav[0..4] != b"RIFF" || &wav[8..12] != b"WAVE" { + return Err("audio illisible : ce n'est pas un WAV".into()); + } + let (mut canaux, mut bits, mut format, mut taux) = (0usize, 0u16, 0u16, 0u32); + let mut pos = 12usize; + while pos + 8 <= wav.len() { + let id = &wav[pos..pos + 4]; + let mut taille = u32le(wav, pos + 4) as usize; + let corps = pos + 8; + if corps + taille > wav.len() { + // Bloc plus grand que le fichier : ce qui reste. + taille = wav.len() - corps; + } + match id { + b"fmt " => { + if taille < 16 { + return Err("audio illisible : bloc fmt tronqué".into()); + } + format = u16le(wav, corps); + canaux = u16le(wav, corps + 2) as usize; + taux = u32le(wav, corps + 4); + bits = u16le(wav, corps + 14); + } + b"data" => { + if canaux == 0 || taux == 0 { + return Err("audio illisible : bloc data avant fmt".into()); + } + let ech = pcm_to_f32(&wav[corps..corps + taille], format, bits, canaux)?; + return Ok((taux, ech)); + } + _ => {} + } + // Blocs alignés sur un nombre pair d'octets. + pos = corps + taille + taille % 2; + } + Err("audio illisible : aucun bloc data".into()) +} + +/// `pcmVersFloat32` : canaux moyennés (calcul en f64 comme le Go). +pub fn pcm_to_f32(data: &[u8], format: u16, bits: u16, canaux: usize) -> Result, String> { + match (format, bits) { + (1, 16) => { + let n = data.len() / 2 / canaux; + Ok((0..n) + .map(|i| { + let somme: f64 = (0..canaux) + .map(|c| { + i16::from_le_bytes([ + data[2 * (i * canaux + c)], + data[2 * (i * canaux + c) + 1], + ]) as f64 + / 32768.0 + }) + .sum(); + (somme / canaux as f64) as f32 + }) + .collect()) + } + (3, 32) => { + let n = data.len() / 4 / canaux; + Ok((0..n) + .map(|i| { + let somme: f64 = (0..canaux) + .map(|c| f32::from_bits(u32le(data, 4 * (i * canaux + c))) as f64) + .sum(); + (somme / canaux as f64) as f32 + }) + .collect()) + } + _ => Err(format!( + "format audio non géré (format {format}, {bits} bits) — attendu PCM 16 bits" + )), + } +} + +// --------------------------------------------------------------------------- +// Protocole +// --------------------------------------------------------------------------- + +/// `asrInferSur` : une trame binaire (en-tête + échantillons f32, découpée +/// en trames de 10 Kio), une trame texte en retour. +pub async fn infer_at(adresse: &str, wav: &[u8]) -> Result { + let (taux, ech) = wav_to_f32(wav)?; + if ech.is_empty() { + return Ok(String::new()); + } + let cfg = tokio_tungstenite::tungstenite::protocol::WebSocketConfig::default() + .max_message_size(None) + .max_frame_size(None); + let (mut ws, _) = tokio_tungstenite::connect_async_with_config(adresse, Some(cfg), false) + .await + .map_err(|e| format!("connexion au serveur de dictée : {e}"))?; + let mut corps = Vec::with_capacity(ENTETE + 4 * ech.len()); + corps.extend_from_slice(&taux.to_le_bytes()); + corps.extend_from_slice(&((4 * ech.len()) as u32).to_le_bytes()); + for v in &ech { + corps.extend_from_slice(&v.to_bits().to_le_bytes()); + } + for trame in corps.chunks(TRAME_MAX) { + ws.send(Message::Binary(Bytes::copy_from_slice(trame))) + .await + .map_err(|e| format!("envoi de l'audio : {e}"))?; + } + let brut = loop { + match ws.next().await { + Some(Ok(Message::Text(t))) => break t.as_bytes().to_vec(), + Some(Ok(Message::Binary(b))) => break b.to_vec(), + Some(Ok(Message::Close(_))) | None => { + return Err("lecture du résultat : connexion fermée".into()); + } + Some(Ok(_)) => continue, + Some(Err(e)) => return Err(format!("lecture du résultat : {e}")), + } + }; + let _ = ws.close(None).await; + #[derive(Deserialize)] + struct R { + #[serde(default)] + text: String, + } + let r: R = serde_json::from_slice(&brut) + .map_err(|e| format!("réponse illisible du serveur de dictée : {e}"))?; + if marqueur(&r.text) { + return Ok(String::new()); + } + Ok(r.text.trim().to_string()) +} + +// --------------------------------------------------------------------------- +// Archives +// --------------------------------------------------------------------------- + +/// `asrSafeRel` : retire le dossier de premier niveau, refuse ce qui sort +/// du dossier ; "" pour le dossier racine. +pub fn safe_rel(name: &str) -> Result { + let n = name.trim().replace('\\', "/"); + if n.is_empty() || n.starts_with('/') { + return Err(format!( + "entrée d'archive au chemin absolu : {}", + go_quote(name) + )); + } + let parts: Vec<&str> = n.trim_matches('/').split('/').collect(); + if parts.contains(&"..") { + return Err(format!( + "entrée d'archive qui remonte hors du dossier : {}", + go_quote(name) + )); + } + if parts.len() <= 1 { + return Ok(String::new()); + } + Ok(crate::paths::go_join(&parts[1..])) +} + +/// `%q` du Go pour une chaîne simple. +fn go_quote(s: &str) -> String { + serde_json::to_string(s).unwrap_or_default() +} + +/// `asrExtract` : `.tar.bz2` décompressé dans `dest.part`, renommé à la fin ; +/// seuls dossiers et fichiers ordinaires (pas de liens). +pub fn extract(r: R, dest: &Path) -> Result<(), String> { + let tmp = PathBuf::from(format!("{}.part", dest.display())); + remove_all(&tmp)?; + std::fs::create_dir_all(&tmp).map_err(|e| e.to_string())?; + let res = (|| -> Result<(), String> { + let mut ar = tar::Archive::new(bzip2::read::MultiBzDecoder::new(r)); + for entry in ar.entries().map_err(|e| e.to_string())? { + let mut e = entry.map_err(|e| e.to_string())?; + let name = String::from_utf8_lossy(&e.path_bytes()).into_owned(); + let rel = safe_rel(&name)?; + if rel.is_empty() { + continue; + } + let cible = tmp.join(&rel); + match e.header().entry_type() { + tar::EntryType::Directory => { + std::fs::create_dir_all(&cible).map_err(|e| e.to_string())?; + } + tar::EntryType::Regular => { + if let Some(p) = cible.parent() { + std::fs::create_dir_all(p).map_err(|e| e.to_string())?; + } + let mut f = std::fs::File::create(&cible).map_err(|e| e.to_string())?; + std::io::copy(&mut e, &mut f).map_err(|e| e.to_string())?; + } + _ => continue, + } + } + Ok(()) + })(); + if let Err(e) = res { + let _ = std::fs::remove_dir_all(&tmp); + return Err(e); + } + if let Err(e) = remove_all(dest) { + let _ = std::fs::remove_dir_all(&tmp); + return Err(e); + } + std::fs::rename(&tmp, dest).map_err(|e| e.to_string()) +} + +/// `os.RemoveAll` : absent n'est pas une erreur. +fn remove_all(p: &Path) -> Result<(), String> { + match std::fs::remove_dir_all(p) { + Ok(()) => Ok(()), + Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(()), + Err(e) => match std::fs::remove_file(p) { + Ok(()) => Ok(()), + Err(_) => Err(e.to_string()), + }, + } +} + +/// Lecteur bloquant alimenté par le téléchargement (canal tokio). +struct ChanReader { + rx: tokio::sync::mpsc::Receiver>, + cur: Bytes, +} + +impl Read for ChanReader { + fn read(&mut self, buf: &mut [u8]) -> std::io::Result { + while self.cur.is_empty() { + match self.rx.blocking_recv() { + Some(Ok(b)) => self.cur = b, + Some(Err(e)) => return Err(std::io::Error::other(e)), + None => return Ok(0), + } + } + let n = buf.len().min(self.cur.len()); + buf[..n].copy_from_slice(&self.cur.split_to(n)); + Ok(n) + } +} + +// --------------------------------------------------------------------------- +// Supervision +// --------------------------------------------------------------------------- + +/// `ProcessState.String()` du Go, et l'explication d'une mort par signal. +pub fn exit_reason(st: ExitStatus) -> String { + #[cfg(unix)] + { + use std::os::unix::process::ExitStatusExt; + if let Some(sig) = st.signal() { + let nom = match sig { + 1 => "hangup".to_string(), + 2 => "interrupt".into(), + 3 => "quit".into(), + 4 => "illegal instruction".into(), + 6 => "aborted".into(), + 7 => "bus error".into(), + 8 => "floating point exception".into(), + 9 => "killed".into(), + 11 => "segmentation fault".into(), + 13 => "broken pipe".into(), + 15 => "terminated".into(), + n => format!("signal {n}"), + }; + return format!( + "signal: {nom} (binaire compilé pour un autre processeur : image à reconstruire)" + ); + } + } + match st.code() { + Some(c) => format!("exit status {c}"), + None => "arrêt sans erreur".into(), + } +} + +/// `time.Duration.String()` pour des durées entières en millisecondes. +fn go_duration(d: Duration) -> String { + let ms = d.as_millis(); + if ms < 1000 { + return format!("{ms}ms"); + } + let s = d.as_secs(); + let (h, m, s) = (s / 3600, (s / 60) % 60, s % 60); + if h > 0 { + format!("{h}h{m}m{s}s") + } else if m > 0 { + format!("{m}m{s}s") + } else { + format!("{s}s") + } +} + +type SharedChild = Arc>; + +#[derive(Default)] +struct Server { + child: Option, + port: u16, + cfg: DictateCfg, + since: Option, +} + +/// État du téléchargement d'un modèle. +#[derive(Debug, Clone, Default, Serialize)] +pub struct DlState { + pub id: String, + pub running: bool, + pub pct: i64, + #[serde(rename = "error")] + pub err: String, +} + +/// La dictée de ce lancement. +pub struct Dictation { + home: PathBuf, + model_base: String, + server_bin: Option, + idle: Duration, + ready: Duration, + srv: tokio::sync::Mutex, + /// Le processus en cours de démarrage, visible sans le verrou. + starting: Mutex>, + idle_gen: AtomicU64, + dl: Mutex, + client: reqwest::Client, +} + +fn lock(m: &Mutex) -> std::sync::MutexGuard<'_, T> { + m.lock().unwrap_or_else(|e| e.into_inner()) +} + +impl Dictation { + pub fn new(home: &Path) -> Self { + Self::with(home, ASR_MODEL_BASE, None, ASR_IDLE, ASR_READY) + } + + /// Pour les tests : source des modèles, binaire du serveur, délais. + pub fn with( + home: &Path, + model_base: &str, + server_bin: Option, + idle: Duration, + ready: Duration, + ) -> Self { + crate::chat::engine::install_crypto(); + Dictation { + home: home.to_path_buf(), + model_base: model_base.into(), + server_bin, + idle, + ready, + srv: tokio::sync::Mutex::new(Server::default()), + starting: Mutex::new(None), + idle_gen: AtomicU64::new(0), + dl: Mutex::new(DlState::default()), + client: reqwest::Client::builder() + .connect_timeout(Duration::from_secs(20)) + .build() + .unwrap_or_default(), + } + } + + pub fn home(&self) -> &Path { + &self.home + } + + /// `asrServerBin`. + fn server_bin(&self) -> String { + if let Some(b) = &self.server_bin { + return b.clone(); + } + for p in [ + std::env::var("LOKI_ASR_SERVER").unwrap_or_default(), + "/usr/local/bin/sherpa-onnx-offline-websocket-server".into(), + ] { + if !p.is_empty() && std::fs::metadata(&p).is_ok() { + return p; + } + } + crate::paths::look_path("sherpa-onnx-offline-websocket-server").unwrap_or_default() + } + + /// `asrEnsure` : un serveur vivant pour ces réglages ; son port. + pub async fn ensure(self: &Arc, cfg: DictateCfg) -> Result { + let mut s = self.srv.lock().await; + if let Some(c) = &s.child + && s.cfg == cfg + && matches!(lock(c).try_wait(), Ok(None)) + { + self.touch(); + return Ok(s.port); + } + self.stop_locked(&mut s); + self.start_locked(&mut s, cfg).await + } + + async fn start_locked( + self: &Arc, + s: &mut Server, + cfg: DictateCfg, + ) -> Result { + if !model_present(&self.home, &cfg.model) { + return Err(format!( + "modèle de dictée absent : {} (à télécharger dans Paramètres → Dictée)", + cfg.model + )); + } + let bin = self.server_bin(); + if bin.is_empty() { + return Err("serveur de dictée introuvable (image à reconstruire)".into()); + } + let port = std::net::TcpListener::bind("127.0.0.1:0") + .and_then(|l| l.local_addr()) + .map(|a| a.port()) + .map_err(|e| format!("aucun port libre pour le serveur de dictée : {e}"))?; + let args = server_args(&self.home, &cfg, port)?; + let mut cmd = tokio::process::Command::new(&bin); + cmd.args(&args) + // Tous les GPU masqués (le binaire livré est CPU). + .env("CUDA_VISIBLE_DEVICES", "") + .env_remove(crate::auth::INTERNAL_ENV) + .stdin(Stdio::null()) + .stdout(Stdio::piped()) + .stderr(Stdio::piped()) + .kill_on_drop(true); + let mut child = cmd + .spawn() + .map_err(|e| format!("démarrage du serveur de dictée : {e}"))?; + let log = Arc::new(Mutex::new(Vec::::new())); + for out in [ + child + .stdout + .take() + .map(|o| Box::new(o) as Box), + child + .stderr + .take() + .map(|o| Box::new(o) as Box), + ] + .into_iter() + .flatten() + { + let log = log.clone(); + tokio::spawn(async move { + let mut out = out; + let mut buf = [0u8; 4096]; + while let Ok(n) = out.read(&mut buf).await { + if n == 0 { + break; + } + let mut l = lock(&log); + l.extend_from_slice(&buf[..n]); + // La fin seule renseigne. + let excess = l.len().saturating_sub(64 << 10); + l.drain(..excess); + } + }); + } + let child: SharedChild = Arc::new(Mutex::new(child)); + s.child = Some(child.clone()); + s.port = port; + s.cfg = cfg; + s.since = Some(Instant::now()); + *lock(&self.starting) = Some(child.clone()); + let res = self.wait_ready(&child, port, &log).await; + *lock(&self.starting) = None; + if let Err(e) = res { + self.stop_locked(s); + return Err(e); + } + self.touch(); + Ok(port) + } + + /// `asrAttendPret` : le serveur ouvre sa socket une fois le modèle chargé. + async fn wait_ready( + &self, + child: &SharedChild, + port: u16, + log: &Arc>>, + ) -> Result<(), String> { + let fin = Instant::now() + self.ready; + while Instant::now() < fin { + tokio::time::sleep(Duration::from_millis(200)).await; + let mort = lock(child).try_wait().ok().flatten(); + if let Some(st) = mort { + // Laisse les lecteurs du journal finir. + tokio::time::sleep(Duration::from_millis(50)).await; + let mut msg = exit_reason(st); + let l = last_line(&String::from_utf8_lossy(&lock(log))); + if !l.is_empty() { + msg += &format!(" — dernière sortie : {l}"); + } + return Err(format!( + "le serveur de dictée s'est arrêté au démarrage : {msg}" + )); + } + let conn = tokio::time::timeout( + Duration::from_secs(2), + tokio::net::TcpStream::connect(("127.0.0.1", port)), + ) + .await; + if matches!(conn, Ok(Ok(_))) { + return Ok(()); + } + } + Err(format!( + "le serveur de dictée n'a pas répondu en {}", + go_duration(self.ready) + )) + } + + /// `asrTouchLocked` : l'arrêt pour inactivité repart de zéro. + fn touch(self: &Arc) { + let tour = self.idle_gen.fetch_add(1, Ordering::SeqCst) + 1; + let me = Arc::downgrade(self); + let idle = self.idle; + tokio::spawn(async move { + tokio::time::sleep(idle).await; + if let Some(me) = me.upgrade() + && me.idle_gen.load(Ordering::SeqCst) == tour + { + me.shutdown().await; + } + }); + } + + fn stop_locked(&self, s: &mut Server) { + self.idle_gen.fetch_add(1, Ordering::SeqCst); + if let Some(c) = s.child.take() { + let _ = lock(&c).start_kill(); + } + *s = Server::default(); + } + + /// `asrShutdown` : inactivité, réglages changés. + pub async fn shutdown(&self) { + let mut s = self.srv.lock().await; + self.stop_locked(&mut s); + } + + /// `asrShutdownVite` : jamais derrière un démarrage (qui tient le verrou + /// jusqu'à deux minutes) — le processus qui démarre est tué. + pub async fn shutdown_fast(&self) { + for _ in 0..20 { + if let Ok(mut s) = self.srv.try_lock() { + self.stop_locked(&mut s); + return; + } + if let Some(c) = lock(&self.starting).as_ref() { + let _ = lock(c).start_kill(); + } + tokio::time::sleep(Duration::from_millis(100)).await; + } + } + + /// `asrInfer`. + pub async fn infer(self: &Arc, cfg: DictateCfg, wav: &[u8]) -> Result { + let port = self.ensure(cfg).await?; + infer_at(&format!("ws://127.0.0.1:{port}"), wav).await + } + + /// `asrEtat`. + pub async fn etat(&self, cfg: &DictateCfg) -> Value { + let s = self.srv.lock().await; + let mut e = json!({ + "actif": s.child.is_some(), + "modele": cfg.model, + "present": model_present(&self.home, &cfg.model), + "device": "cpu", + }); + if s.child.is_some() + && let Some(t) = s.since + { + e["depuis_s"] = json!(t.elapsed().as_secs()); + } + e + } + + pub fn dl_state(&self) -> DlState { + lock(&self.dl).clone() + } + + fn clear_dl_err(&self) { + lock(&self.dl).err.clear(); + } + + /// `asrStartDownload` : un seul à la fois. + pub fn start_download(self: &Arc, id: &str) { + let mut d = lock(&self.dl); + if d.running { + return; + } + if model_url_for(&self.model_base, id).is_empty() { + d.err = format!("modèle de dictée inconnu : {id}"); + return; + } + *d = DlState { + id: id.into(), + running: true, + pct: 0, + err: String::new(), + }; + drop(d); + let me = self.clone(); + let id = id.to_string(); + tokio::spawn(async move { + let res = me.download(&id).await; + let mut d = lock(&me.dl); + d.running = false; + if let Err(e) = res { + d.err = e; + } + }); + } + + /// `asrDownload` : décompressé au fil du téléchargement ; progression + /// sur les octets reçus. + async fn download(self: &Arc, id: &str) -> Result<(), String> { + let dst = model_dir_for(&self.home, id) + .ok_or_else(|| format!("modèle de dictée inconnu : {id}"))?; + if let Some(p) = dst.parent() { + std::fs::create_dir_all(p).map_err(|e| e.to_string())?; + } + let mut resp = self + .client + .get(model_url_for(&self.model_base, id)) + .send() + .await + .map_err(|e| e.to_string())?; + if resp.status().as_u16() != 200 { + return Err(format!( + "téléchargement du modèle de dictée : HTTP {}", + resp.status().as_u16() + )); + } + let total = resp.content_length().unwrap_or(0) as i64; + let (tx, rx) = tokio::sync::mpsc::channel::>(32); + let extractor = tokio::task::spawn_blocking(move || { + extract( + ChanReader { + rx, + cur: Bytes::new(), + }, + &dst, + ) + }); + let mut lus = 0i64; + loop { + match resp.chunk().await { + Ok(Some(c)) => { + lus += c.len() as i64; + if total > 0 { + lock(&self.dl).pct = lus * 100 / total; + } + if tx.send(Ok(c)).await.is_err() { + break; // l'extraction a échoué : elle dira pourquoi + } + } + Ok(None) => break, + Err(e) => { + let _ = tx.send(Err(e.to_string())).await; + break; + } + } + } + drop(tx); + extractor.await.map_err(|e| e.to_string())? + } +} + +// --------------------------------------------------------------------------- +// Routes +// --------------------------------------------------------------------------- + +/// `dictateCfgLoad`. +pub async fn cfg_load(ctx: &Ctx) -> DictateCfg { + let raw = ctx + .store + .get(BK_STATE, DICTATE_CFG_KEY) + .await + .ok() + .flatten(); + raw.and_then(|b| serde_json::from_slice::(&b).ok()) + .unwrap_or_default() + .with_defauts() +} + +async fn read_body(body: Incoming, max: usize) -> Result { + Limited::new(body, max) + .collect() + .await + .map(|c| c.to_bytes()) + .map_err(|e| e.to_string()) +} + +fn err(code: StatusCode, msg: impl Into) -> Response { + send_json(code, &json!({ "error": msg.into() })) +} + +/// `from_go` : remise par le Go (tunnel), déjà authentifiée par lui. +pub async fn serve( + ctx: &Ctx, + d: &Arc, + req: Request, + from_go: bool, +) -> Response { + let (parts, body) = req.into_parts(); + if !from_go && let Err(refus) = ctx.guard(&parts.headers).await { + return *refus; + } + let post = parts.method == Method::POST; + match parts.uri.path() { + "/api/transcribe" => { + let audio = match read_body(body, 32 << 20).await { + Ok(a) if a.len() >= 1000 => a, + _ => return err(StatusCode::BAD_REQUEST, "audio vide ou trop court"), + }; + let cfg = cfg_load(ctx).await; + if !model_present(&d.home, &cfg.model) { + let st = d.dl_state(); + if !st.err.is_empty() { + // Échec précédent : on le dit ET on relance. + d.clear_dl_err(); + d.start_download(&cfg.model); + return send_json( + StatusCode::SERVICE_UNAVAILABLE, + &json!({ "downloading": true, "pct": 0, "error": st.err }), + ); + } + d.start_download(&cfg.model); + return send_json( + StatusCode::SERVICE_UNAVAILABLE, + &json!({ "downloading": true, "pct": st.pct }), + ); + } + match tokio::time::timeout(Duration::from_secs(300), d.infer(cfg, &audio)).await { + Ok(Ok(text)) => send_json(StatusCode::OK, &json!({ "text": text })), + Ok(Err(e)) => err(StatusCode::INTERNAL_SERVER_ERROR, e), + Err(_) => err( + StatusCode::INTERNAL_SERVER_ERROR, + "context deadline exceeded", + ), + } + } + "/api/dictate/config" if post => { + let c: DictateCfg = match read_body(body, 1 << 20) + .await + .ok() + .and_then(|b| serde_json::from_slice(&b).ok()) + { + Some(c) => c, + None => return err(StatusCode::BAD_REQUEST, "requête illisible"), + }; + if model(&c.model).is_none() { + return err( + StatusCode::BAD_REQUEST, + format!("modèle de dictée inconnu : {}", c.model), + ); + } + let raw = serde_json::to_vec(&c.with_defauts()).unwrap_or_default(); + if let Err(e) = ctx.store.put(BK_STATE, DICTATE_CFG_KEY, raw).await { + return err(StatusCode::BAD_REQUEST, e); + } + // Sans ça, l'ancien modèle continuerait de servir. + d.shutdown().await; + send_json(StatusCode::OK, &json!({ "ok": true })) + } + "/api/dictate/config" => send_json(StatusCode::OK, &cfg_load(ctx).await), + "/api/dictate/models" => { + let home = d.home.clone(); + let v = tokio::task::spawn_blocking(move || catalogue_trie(&home)) + .await + .unwrap_or(Value::Null); + send_json(StatusCode::OK, &v) + } + "/api/dictate/models/download" => { + if post { + #[derive(Deserialize)] + struct Req { + #[serde(default)] + id: String, + } + let r: Req = match read_body(body, 1 << 20) + .await + .ok() + .and_then(|b| serde_json::from_slice(&b).ok()) + { + Some(r) => r, + None => return err(StatusCode::BAD_REQUEST, "requête illisible"), + }; + let id = r.id.trim().to_string(); + if model_url_for(&d.model_base, &id).is_empty() { + return err( + StatusCode::BAD_REQUEST, + format!("modèle de dictée inconnu : {id}"), + ); + } + d.start_download(&id); + } + send_json(StatusCode::OK, &d.dl_state()) + } + "/api/dictate/state" => { + let cfg = cfg_load(ctx).await; + let mut e = d.etat(&cfg).await; + e["dl"] = json!(d.dl_state()); + send_json(StatusCode::OK, &e) + } + _ => send_json( + StatusCode::NOT_FOUND, + &json!({ "error": "404 page not found" }), + ), + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn durees_comme_le_go() { + assert_eq!(go_duration(Duration::from_secs(120)), "2m0s"); + assert_eq!(go_duration(Duration::from_secs(1)), "1s"); + assert_eq!(go_duration(Duration::from_millis(500)), "500ms"); + assert_eq!(go_duration(Duration::from_secs(3725)), "1h2m5s"); + } + + #[test] + fn citations_comme_le_go() { + assert_eq!(go_quote("a\"b"), r#""a\"b""#); + } +} diff --git a/rust/loki-rs/src/lib.rs b/rust/loki-rs/src/lib.rs index c0f8b84..3da8474 100644 --- a/rust/loki-rs/src/lib.rs +++ b/rust/loki-rs/src/lib.rs @@ -15,6 +15,7 @@ pub mod auth; pub mod chat; pub mod chatapi; pub mod child; +pub mod dictate; pub mod downloads; pub mod engine; pub mod engstatus; diff --git a/rust/loki-rs/src/main.rs b/rust/loki-rs/src/main.rs index 7260207..fcd56b3 100644 --- a/rust/loki-rs/src/main.rs +++ b/rust/loki-rs/src/main.rs @@ -121,6 +121,9 @@ fn serve() -> ExitCode { // LOKI_MODELS=rust : les routes des modèles aussi (le Go remet alors // celles du tunnel et ne vide plus les .part). let models_rust = models_held_by_facade(); + // LOKI_DICTATE=rust : la dictée vocale aussi (le Go lui demande alors + // d'arrêter son serveur quand il libère la VRAM). + let dictate_rust = held_by_facade("LOKI_DICTATE"); let mut env = vec![(auth::INTERNAL_ENV.to_string(), token.clone())]; if chat_rust { env.push(("LOKI_CHAT".into(), "rust".into())); @@ -128,7 +131,10 @@ fn serve() -> ExitCode { if models_rust { env.push(("LOKI_MODELS".into(), "rust".into())); } - if chat_rust || models_rust { + if dictate_rust { + env.push(("LOKI_DICTATE".into(), "rust".into())); + } + if chat_rust || models_rust || dictate_rust { env.push(("LOKI_FACADE_ADDR".into(), format!("127.0.0.1:{web_port}"))); } let sup = child::Supervisor::spawn(go_bin, go_port, env); @@ -167,6 +173,11 @@ fn serve() -> ExitCode { None }; + let dictate = dictate_rust.then(|| { + eprintln!("[loki-rs] dictée tenue par la façade (LOKI_DICTATE=rust)"); + std::sync::Arc::new(loki_rs::dictate::Dictation::new(&engine_paths.home)) + }); + let addr = SocketAddr::from(([0, 0, 0, 0], web_port)); let listener = match tokio::net::TcpListener::bind(addr).await { Ok(l) => l, @@ -184,6 +195,7 @@ fn serve() -> ExitCode { engine: engine_paths, chat, models, + dictate, }; let res = proxy::serve(listener, cfg, shutdown_signal()).await; eprintln!("[loki-rs] arrêt demandé — arrêt du serveur Go"); @@ -205,7 +217,12 @@ fn chat_held_by_facade() -> bool { /// `LOKI_MODELS=rust` : routes des modèles tenues par la façade. fn models_held_by_facade() -> bool { - std::env::var("LOKI_MODELS").is_ok_and(|v| v.trim().eq_ignore_ascii_case("rust")) + held_by_facade("LOKI_MODELS") +} + +/// `=rust` : ce lot est tenu par la façade (le Go par défaut). +fn held_by_facade(var: &str) -> bool { + std::env::var(var).is_ok_and(|v| v.trim().eq_ignore_ascii_case("rust")) } /// Les `.part` d'un téléchargement tué ne reprendront jamais : vidés au diff --git a/rust/loki-rs/src/paths.rs b/rust/loki-rs/src/paths.rs index 6293938..b695929 100644 --- a/rust/loki-rs/src/paths.rs +++ b/rust/loki-rs/src/paths.rs @@ -396,6 +396,27 @@ pub fn base_name(p: &str) -> String { } } +/// `exec.LookPath` : le chemin du programme dans le PATH (exécutable). +pub fn look_path(name: &str) -> Option { + let path = std::env::var_os("PATH")?; + std::env::split_paths(&path) + .map(|dir| dir.join(name)) + .find(|p| { + std::fs::metadata(p).is_ok_and(|m| { + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + m.is_file() && m.permissions().mode() & 0o111 != 0 + } + #[cfg(not(unix))] + { + m.is_file() + } + }) + }) + .map(|p| p.to_string_lossy().into_owned()) +} + /// Message d'une `*os.PathError` du Go : « op chemin: raison ». La raison /// suit la forme des `syscall.Errno` du Go sous Unix (minuscule en tête, sans /// le « (os error N) » de Rust). diff --git a/rust/loki-rs/src/proxy.rs b/rust/loki-rs/src/proxy.rs index 71c1076..ebc6839 100644 --- a/rust/loki-rs/src/proxy.rs +++ b/rust/loki-rs/src/proxy.rs @@ -66,6 +66,9 @@ pub struct Config { /// Routes des modèles tenues par la façade (`LOKI_MODELS=rust`) : servies /// ici ; `None` : relayées au Go. pub models: Option>, + /// Dictée vocale tenue par la façade (`LOKI_DICTATE=rust`) : servie ici ; + /// `None` : relayée au Go. + pub dictate: Option>, } struct Proxy { @@ -94,6 +97,7 @@ pub async fn serve( engine: cfg.engine, chat: cfg.chat, models: cfg.models, + dictate: cfg.dictate, internal_token: cfg.internal_token.clone(), }, }); @@ -133,10 +137,11 @@ pub async fn serve( impl Proxy { async fn handle(&self, mut req: Request) -> Response { // Le Go (et lui seul : jeton tiré à chaque démarrage) remet à la - // façade le trafic du chat et des modèles qui lui arrive sans passer - // par elle. - let from_go = (self.ctx.chat.is_some() || self.ctx.models.is_some()) - && self.ctx.from_go(req.headers()); + // façade le trafic du chat, des modèles et de la dictée qui lui arrive + // sans passer par elle. + let from_go = + (self.ctx.chat.is_some() || self.ctx.models.is_some() || self.ctx.dictate.is_some()) + && self.ctx.from_go(req.headers()); // Jamais de confiance accordée sur la foi d'un en-tête du client : // ni « je viens du tunnel », ni « je suis la façade ». req.headers_mut().remove(VIA_TUNNEL); @@ -150,6 +155,13 @@ impl Proxy { { return chatapi::serve_internal(chat, req).await; } + if from_go + && let Some(d) = &self.ctx.dictate + && path == crate::dictate::INTERNAL_STOP + { + d.shutdown_fast().await; + return plain(StatusCode::NO_CONTENT, ""); + } // L'API interne du Go n'est jamais joignable de l'extérieur. if path.starts_with("/internal/") { return plain(StatusCode::NOT_FOUND, "404 page not found"); @@ -169,6 +181,11 @@ impl Proxy { { return chatapi::serve(&self.ctx, chat, req, from_go).await; } + if let Some(d) = &self.ctx.dictate + && crate::dictate::handles(path) + { + return crate::dictate::serve(&self.ctx, d, req, from_go).await; + } if let Some(m) = &self.ctx.models && crate::models::handles(path) { diff --git a/rust/loki-rs/tests/common/fakego.rs b/rust/loki-rs/tests/common/fakego.rs index 222bec9..bbddc8f 100644 --- a/rust/loki-rs/tests/common/fakego.rs +++ b/rust/loki-rs/tests/common/fakego.rs @@ -427,7 +427,7 @@ async fn handle(st: Arc, req: Request) -> Response /// Lance le faux Go puis la façade devant lui ; rend (adresse de la façade, /// état du faux Go, garde d'arrêt). pub async fn start(home: std::path::PathBuf) -> (SocketAddr, Arc, oneshot::Sender<()>) { - let (f, st, tx, _) = start_opts(home, false, None, None).await; + let (f, st, tx, _) = start_with(home, Opts::default()).await; (f, st, tx) } @@ -437,10 +437,36 @@ pub async fn start_models( home: std::path::PathBuf, hf: &str, ) -> (SocketAddr, Arc, oneshot::Sender<()>) { - let (f, st, tx, _) = start_opts(home, false, None, Some(hf.to_string())).await; + let opts = Opts { + models_hf: Some(hf.to_string()), + ..Opts::default() + }; + let (f, st, tx, _) = start_with(home, opts).await; (f, st, tx) } +/// Comme [`start`], avec la dictée tenue par la façade (`LOKI_DICTATE=rust`). +pub async fn start_dictate( + home: std::path::PathBuf, + d: Arc, +) -> (SocketAddr, Arc, oneshot::Sender<()>) { + let opts = Opts { + dictate: Some(d), + ..Opts::default() + }; + let (f, st, tx, _) = start_with(home, opts).await; + (f, st, tx) +} + +/// Ce que la façade tient en plus du proxy. +#[derive(Default)] +pub struct Opts { + pub chat: bool, + pub engine_port: Option, + pub models_hf: Option, + pub dictate: Option>, +} + /// Comme [`start`], avec la conversation tenue par la façade /// (`LOKI_CHAT=rust`) ; `engine_port` : port du faux moteur (config PORT). pub async fn start_chat( @@ -452,15 +478,21 @@ pub async fn start_chat( oneshot::Sender<()>, Arc, ) { - let (f, st, tx, chat) = start_opts(home, true, Some(engine_port), None).await; + let (f, st, tx, chat) = start_with( + home, + Opts { + chat: true, + engine_port: Some(engine_port), + ..Opts::default() + }, + ) + .await; (f, st, tx, chat.unwrap()) } -async fn start_opts( +pub async fn start_with( home: std::path::PathBuf, - with_chat: bool, - engine_port: Option, - models_hf: Option, + opts: Opts, ) -> ( SocketAddr, Arc, @@ -468,7 +500,7 @@ async fn start_opts( Option>, ) { let st = Arc::new(FakeGo::default()); - if let Some(p) = engine_port { + if let Some(p) = opts.engine_port { st.set("config", "PORT", &p.to_string()); } let l = TcpListener::bind("127.0.0.1:0").await.unwrap(); @@ -489,7 +521,7 @@ async fn start_opts( }); } }); - let chat = if with_chat { + let chat = if opts.chat { use loki_rs::chat::{ conv::Conversation, generate::ChatService, host::ChatShared, sessions, }; @@ -517,12 +549,13 @@ async fn start_opts( service: "loki-engine".into(), }, chat: chat.clone(), - models: models_hf.map(|hf| { + models: opts.models_hf.map(|hf| { Arc::new(loki_rs::models::Models { downloads: Arc::new(loki_rs::downloads::Downloads::new()), hf: loki_rs::hf::HfClient::new(&hf), }) }), + dictate: opts.dictate, }; tokio::spawn(loki_rs::proxy::serve(fl, cfg, async move { let _ = rx.await; diff --git a/rust/loki-rs/tests/dictate.rs b/rust/loki-rs/tests/dictate.rs new file mode 100644 index 0000000..49bfe83 --- /dev/null +++ b/rust/loki-rs/tests/dictate.rs @@ -0,0 +1,705 @@ +//! Lot 9 : dictée vocale tenue par la façade (`LOKI_DICTATE=rust`). +//! +//! - le journal tiré du Go (`internal/loki/parity_dictate_test.go` → +//! `tests/parity_dictate/dictate.json`) : lecture des WAV, chemins +//! d'archive, réglages, catalogue, arguments du serveur, routes ; +//! - le protocole WebSocket de sherpa-onnx contre un faux serveur ; +//! - l'extraction d'une archive et le téléchargement d'un modèle ; +//! - la supervision d'un faux sherpa-onnx (script Python) par la façade. + +mod common; + +use std::convert::Infallible; +use std::io::Write; +use std::net::SocketAddr; +use std::path::{Path, PathBuf}; +use std::sync::{Arc, Mutex}; +use std::time::Duration; + +use bytes::Bytes; +use common::fakego; +use futures_util::{SinkExt, StreamExt}; +use http_body_util::Full; +use hyper::body::Incoming; +use hyper::{Request, Response, StatusCode, service::service_fn}; +use hyper_util::rt::TokioIo; +use loki_rs::dictate::{self, DictateCfg, Dictation}; +use serde_json::Value; +use tokio_tungstenite::tungstenite::Message; + +fn fixture() -> Value { + serde_json::from_str(include_str!("parity_dictate/dictate.json")).expect("journal") +} + +fn b64(s: &str) -> Vec { + const T: &[u8] = b"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/"; + let mut out = Vec::new(); + let mut acc = 0u32; + let mut n = 0; + for c in s.bytes().filter(|c| *c != b'=') { + acc = acc << 6 | T.iter().position(|t| *t == c).unwrap() as u32; + n += 6; + if n >= 8 { + n -= 8; + out.push((acc >> n) as u8); + acc &= (1 << n) - 1; + } + } + out +} + +/// v2 installé (quatre fichiers non vides), v3 à moitié — comme le journal. +fn install_models(home: &Path) { + for f in dictate::ASR_FICHIERS { + let p = dictate::model_dir_for(home, "parakeet-tdt-0.6b-v2") + .unwrap() + .join(f); + std::fs::create_dir_all(p.parent().unwrap()).unwrap(); + std::fs::write(p, b"x").unwrap(); + } + let v3 = dictate::model_dir_for(home, "parakeet-tdt-0.6b-v3").unwrap(); + std::fs::create_dir_all(&v3).unwrap(); + std::fs::write(v3.join("encoder.int8.onnx"), b"x").unwrap(); + std::fs::write(v3.join("tokens.txt"), b"").unwrap(); +} + +#[test] +fn parite_des_fonctions_pures() { + let fx = fixture(); + for w in fx["wav"].as_array().unwrap() { + let input = b64(w["in"].as_str().unwrap_or("")); + let name = w["name"].as_str().unwrap(); + match dictate::wav_to_f32(&input) { + Err(e) => assert_eq!(e, w["err"].as_str().unwrap_or(""), "{name}"), + Ok((rate, ech)) => { + assert!(w.get("err").is_none(), "{name} : erreur attendue"); + assert_eq!(rate as u64, w["rate"].as_u64().unwrap(), "{name}"); + let want: Vec = w + .get("ech") + .and_then(Value::as_array) + .map(|a| a.iter().map(|x| x.as_f64().unwrap() as f32).collect()) + .unwrap_or_default(); + assert_eq!(ech, want, "{name}"); + } + } + } + for c in fx["safe_rel"].as_array().unwrap() { + let input = c["in"].as_str().unwrap(); + match dictate::safe_rel(input) { + Ok(o) => assert_eq!(o, c["out"].as_str().unwrap_or(""), "{input:?}"), + Err(e) => assert_eq!(e, c["err"].as_str().unwrap_or(""), "{input:?}"), + } + } + for (s, want) in fx["marker"].as_object().unwrap() { + assert_eq!(dictate::marqueur(s), want.as_bool().unwrap(), "{s:?}"); + } + for c in fx["defaults"].as_array().unwrap() { + let d = DictateCfg { + model: c["model"].as_str().unwrap().into(), + reactivity: c["reactivity"].as_str().unwrap().into(), + } + .with_defauts(); + assert_eq!(d.model, c["out_model"].as_str().unwrap()); + assert_eq!(d.reactivity, c["out_reactivity"].as_str().unwrap()); + } + for (s, want) in fx["last_line"].as_object().unwrap() { + assert_eq!(dictate::last_line(s), want.as_str().unwrap(), "{s:?}"); + } + let home = PathBuf::from("/maison"); + let cfg = DictateCfg { + model: "parakeet-tdt-0.6b-v3".into(), + ..Default::default() + }; + let args: Vec = dictate::server_args(&home, &cfg, 4567) + .unwrap() + .iter() + .map(|a| a.replace("/maison", "{{HOME}}")) + .collect(); + let want: Vec = serde_json::from_value(fx["args"].clone()).unwrap(); + assert_eq!(args, want); + let bad = DictateCfg { + model: "x".into(), + ..Default::default() + }; + assert_eq!( + dictate::server_args(&home, &bad, 1).unwrap_err(), + fx["args_err"].as_str().unwrap() + ); + for (id, url) in fx["urls"].as_object().unwrap() { + assert_eq!( + dictate::model_url_for(dictate::ASR_MODEL_BASE, id), + url.as_str().unwrap(), + "{id}" + ); + } + let tmp = tempfile::tempdir().unwrap(); + install_models(tmp.path()); + for (id, want) in fx["present"].as_object().unwrap() { + assert_eq!( + dictate::model_present(tmp.path(), id), + want.as_bool().unwrap(), + "{id}" + ); + } +} + +#[tokio::test] +async fn parite_des_routes() { + let fx = fixture(); + let tmp = tempfile::tempdir().unwrap(); + let home = tmp.path().canonicalize().unwrap(); + install_models(&home); + let d = Arc::new(Dictation::with( + &home, + "http://127.0.0.1:1/", + Some("/nexiste/pas".into()), + Duration::from_secs(60), + Duration::from_secs(5), + )); + let (facade, _go, _stop) = fakego::start_dictate(home.clone(), d).await; + let h = home.to_string_lossy().into_owned(); + for (i, c) in fx["calls"].as_array().unwrap().iter().enumerate() { + let (m, p) = (c["method"].as_str().unwrap(), c["path"].as_str().unwrap()); + let body = c.get("body").and_then(Value::as_str).unwrap_or(""); + let (code, b) = fakego::send(facade, m, p, &[], body).await; + let got: Value = + serde_json::from_str(&String::from_utf8_lossy(&b).replace(&h, "{{HOME}}")).unwrap(); + assert_eq!( + code.as_u16() as u64, + c["status"].as_u64().unwrap(), + "appel {i} : {m} {p} {body}" + ); + assert_eq!(got, c["out"], "appel {i} : {m} {p} {body}"); + } +} + +// --------------------------------------------------------------------------- +// Protocole +// --------------------------------------------------------------------------- + +fn wav16(n: usize) -> Vec { + let data: Vec = (0..n) + .flat_map(|i| ((i as i16).wrapping_mul(7)).to_le_bytes()) + .collect(); + let mut b = b"RIFF\0\0\0\0WAVEfmt ".to_vec(); + b.extend_from_slice(&16u32.to_le_bytes()); + b.extend_from_slice(&1u16.to_le_bytes()); + b.extend_from_slice(&1u16.to_le_bytes()); + b.extend_from_slice(&16000u32.to_le_bytes()); + b.extend_from_slice(&32000u32.to_le_bytes()); + b.extend_from_slice(&2u16.to_le_bytes()); + b.extend_from_slice(&16u16.to_le_bytes()); + b.extend_from_slice(b"data"); + b.extend_from_slice(&(data.len() as u32).to_le_bytes()); + b.extend_from_slice(&data); + let len = (b.len() - 8) as u32; + b[4..8].copy_from_slice(&len.to_le_bytes()); + b +} + +/// Faux sherpa-onnx : relit l'en-tête, attend le compte annoncé, répond. +async fn fake_ws(reply: &'static str) -> (String, Arc>>) { + let frames = Arc::new(Mutex::new(Vec::new())); + let l = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap(); + let addr = l.local_addr().unwrap(); + let f = frames.clone(); + tokio::spawn(async move { + while let Ok((s, _)) = l.accept().await { + let f = f.clone(); + tokio::spawn(async move { + let Ok(mut ws) = tokio_tungstenite::accept_async(s).await else { + return; + }; + let mut buf = Vec::new(); + while let Some(Ok(m)) = ws.next().await { + if let Message::Binary(b) = m { + f.lock().unwrap().push(b.len()); + buf.extend_from_slice(&b); + if buf.len() >= 8 { + let n = u32::from_le_bytes(buf[4..8].try_into().unwrap()) as usize; + if buf.len() - 8 >= n { + let _ = ws.send(Message::Text(reply.into())).await; + return; + } + } + } + } + }); + } + }); + (format!("ws://{addr}"), frames) +} + +#[tokio::test] +async fn protocole_websocket() { + let (adr, frames) = fake_ws(r#"{"text":" bonjour ceci est un test "}"#).await; + // 6000 échantillons : 8 + 24000 octets → trois trames de 10 Kio au plus. + let txt = dictate::infer_at(&adr, &wav16(6000)).await.unwrap(); + assert_eq!(txt, "bonjour ceci est un test"); + assert_eq!(*frames.lock().unwrap(), [10240, 10240, 3528]); + + let (adr, _) = fake_ws(r#"{"text":" [musique] "}"#).await; + assert_eq!(dictate::infer_at(&adr, &wav16(10)).await.unwrap(), ""); + let (adr, _) = fake_ws("pas du json").await; + let e = dictate::infer_at(&adr, &wav16(10)).await.unwrap_err(); + assert!( + e.starts_with("réponse illisible du serveur de dictée : "), + "{e}" + ); + // Audio vide : rien à transcrire, aucune connexion. + assert_eq!( + dictate::infer_at("ws://127.0.0.1:1", &wav16(0)) + .await + .unwrap(), + "" + ); + let e = dictate::infer_at("ws://127.0.0.1:1", &wav16(10)) + .await + .unwrap_err(); + assert!(e.starts_with("connexion au serveur de dictée : "), "{e}"); + let e = dictate::infer_at("ws://127.0.0.1:1", b"pas un wav") + .await + .unwrap_err(); + assert_eq!(e, "audio illisible : ce n'est pas un WAV"); +} + +// --------------------------------------------------------------------------- +// Archives et téléchargement +// --------------------------------------------------------------------------- + +/// Une archive `.tar.bz2` de modèle : dossier racine, quatre fichiers, un +/// sous-dossier et un lien (ignoré). +fn archive(root: &str, evil: bool) -> Vec { + let enc = bzip2::write::BzEncoder::new(Vec::new(), bzip2::Compression::fast()); + let mut tb = tar::Builder::new(enc); + let mut dir = tar::Header::new_gnu(); + dir.set_entry_type(tar::EntryType::Directory); + dir.set_mode(0o755); + dir.set_size(0); + tb.append_data(&mut dir, format!("{root}/"), std::io::empty()) + .unwrap(); + for (i, f) in dictate::ASR_FICHIERS + .iter() + .chain(["sous/notes.txt"].iter()) + .enumerate() + { + let body = format!("contenu {i}"); + let mut h = tar::Header::new_gnu(); + h.set_mode(0o644); + h.set_size(body.len() as u64); + tb.append_data(&mut h, format!("{root}/{f}"), body.as_bytes()) + .unwrap(); + } + let mut l = tar::Header::new_gnu(); + l.set_entry_type(tar::EntryType::Symlink); + l.set_size(0); + tb.append_link(&mut l, format!("{root}/lien"), "/etc/passwd") + .unwrap(); + if evil { + let mut h = tar::Header::new_old(); + let name = format!("{root}/../evade.txt"); + h.as_old_mut().name[..name.len()].copy_from_slice(name.as_bytes()); + h.set_mode(0o644); + h.set_size(1); + h.set_cksum(); + tb.append(&h, &b"x"[..]).unwrap(); + } + let enc = tb.into_inner().unwrap(); + enc.finish().unwrap() +} + +#[test] +fn extraction_d_une_archive() { + let tmp = tempfile::tempdir().unwrap(); + let dest = tmp.path().join("asr/modele"); + std::fs::create_dir_all(dest.parent().unwrap()).unwrap(); + dictate::extract(&archive("racine", false)[..], &dest).unwrap(); + assert_eq!( + std::fs::read_to_string(dest.join("tokens.txt")).unwrap(), + "contenu 3" + ); + assert_eq!( + std::fs::read_to_string(dest.join("sous/notes.txt")).unwrap(), + "contenu 4" + ); + assert!(!dest.join("lien").exists(), "lien suivi"); + assert!(!tmp.path().join("asr/modele.part").exists()); + + // Entrée qui remonte : refus, rien de partiel ni d'évadé, l'ancien gardé. + let e = dictate::extract(&archive("racine", true)[..], &dest).unwrap_err(); + assert_eq!( + e, + r#"entrée d'archive qui remonte hors du dossier : "racine/../evade.txt""# + ); + assert!(!tmp.path().join("asr/modele.part").exists()); + assert!(!tmp.path().join("asr/evade.txt").exists()); + assert!(dest.join("tokens.txt").exists()); + // Archive corrompue. + assert!(dictate::extract(&b"pas du bzip2"[..], &dest).is_err()); + assert!(!tmp.path().join("asr/modele.part").exists()); +} + +async fn file_server(files: Vec<(String, Vec)>) -> SocketAddr { + let files = Arc::new(files); + let l = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap(); + let addr = l.local_addr().unwrap(); + tokio::spawn(async move { + while let Ok((s, _)) = l.accept().await { + let files = files.clone(); + tokio::spawn(async move { + let svc = service_fn(move |req: Request| { + let files = files.clone(); + async move { + let p = req.uri().path().to_string(); + let r = match files.iter().find(|(n, _)| *n == p) { + Some((_, b)) => Response::new(Full::new(Bytes::from(b.clone()))), + None => { + let mut r = Response::new(Full::new(Bytes::from_static(b"absent"))); + *r.status_mut() = StatusCode::NOT_FOUND; + r + } + }; + Ok::<_, Infallible>(r) + } + }); + let _ = hyper::server::conn::http1::Builder::new() + .serve_connection(TokioIo::new(s), svc) + .await; + }); + } + }); + addr +} + +async fn wait_dl(d: &Dictation) -> dictate::DlState { + for _ in 0..500 { + let st = d.dl_state(); + if !st.running { + return st; + } + tokio::time::sleep(Duration::from_millis(10)).await; + } + panic!("téléchargement jamais terminé"); +} + +#[tokio::test] +async fn telechargement_d_un_modele() { + let dossier = "sherpa-onnx-nemo-parakeet-tdt-0.6b-v2-int8"; + let addr = file_server(vec![( + format!("/m/{dossier}.tar.bz2"), + archive(dossier, false), + )]) + .await; + let tmp = tempfile::tempdir().unwrap(); + let d = Arc::new(Dictation::with( + tmp.path(), + &format!("http://{addr}/m/"), + None, + Duration::from_secs(60), + Duration::from_secs(5), + )); + d.start_download("parakeet-tdt-0.6b-v2"); + // Un seul à la fois : le second est ignoré. + d.start_download("parakeet-tdt-0.6b-v3"); + let st = wait_dl(&d).await; + assert_eq!( + (st.id.as_str(), st.pct, st.err.as_str()), + ("parakeet-tdt-0.6b-v2", 100, "") + ); + assert!(dictate::model_present(tmp.path(), "parakeet-tdt-0.6b-v2")); + + d.start_download("parakeet-tdt-0.6b-v3"); + let st = wait_dl(&d).await; + assert_eq!(st.err, "téléchargement du modèle de dictée : HTTP 404"); + assert!(!dictate::model_present(tmp.path(), "parakeet-tdt-0.6b-v3")); + d.start_download("inconnu"); + assert_eq!(d.dl_state().err, "modèle de dictée inconnu : inconnu"); +} + +// --------------------------------------------------------------------------- +// Supervision d'un faux sherpa-onnx +// --------------------------------------------------------------------------- + +/// Faux `sherpa-onnx-offline-websocket-server` : poignée de main WebSocket, +/// lit l'utterance, répond ce qu'il a vu. +const FAKE_SHERPA: &str = r#"#!/usr/bin/env python3 +import base64, hashlib, json, os, socket, struct, sys +port = int([a for a in sys.argv if a.startswith('--port=')][0][7:]) +print("faux sherpa prêt", flush=True) +s = socket.socket(); s.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1) +s.bind(('127.0.0.1', port)); s.listen(8) +def lire(c, n): + b = b'' + while len(b) < n: + x = c.recv(n - len(b)) + if not x: raise EOFError + b += x + return b +while True: + c, _ = s.accept() + try: + req = b'' + while b'\r\n\r\n' not in req: + x = c.recv(4096) + if not x: raise EOFError + req += x + key = [l.split(b':', 1)[1].strip() for l in req.split(b'\r\n') if l.lower().startswith(b'sec-websocket-key')][0] + acc = base64.b64encode(hashlib.sha1(key + b'258EAFA5-E914-47DA-95CA-C5AB0DC85B11').digest()) + c.sendall(b'HTTP/1.1 101 Switching Protocols\r\nUpgrade: websocket\r\nConnection: Upgrade\r\nSec-WebSocket-Accept: ' + acc + b'\r\n\r\n') + buf = b''; trames = 0 + while True: + h = lire(c, 2); op = h[0] & 15; n = h[1] & 127 + if n == 126: n = struct.unpack('>H', lire(c, 2))[0] + elif n == 127: n = struct.unpack('>Q', lire(c, 8))[0] + m = lire(c, 4); d = bytes(b ^ m[i % 4] for i, b in enumerate(lire(c, n))) + if op == 8: break + trames += 1; buf += d + if len(buf) >= 8 and len(buf) - 8 >= struct.unpack('H', len(t))) + t) + break + except Exception: + pass + finally: + c.close() +"#; + +fn script(dir: &Path, name: &str, body: &str) -> String { + let p = dir.join(name); + let mut f = std::fs::File::create(&p).unwrap(); + f.write_all(body.as_bytes()).unwrap(); + drop(f); + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + std::fs::set_permissions(&p, std::fs::Permissions::from_mode(0o755)).unwrap(); + } + p.to_string_lossy().into_owned() +} + +fn python3() -> bool { + std::process::Command::new("python3") + .arg("-c") + .arg("pass") + .status() + .is_ok_and(|s| s.success()) +} + +fn install_v3(home: &Path) { + for f in dictate::ASR_FICHIERS { + let p = dictate::model_dir_for(home, "parakeet-tdt-0.6b-v3") + .unwrap() + .join(f); + std::fs::create_dir_all(p.parent().unwrap()).unwrap(); + std::fs::write(p, b"x").unwrap(); + } +} + +async fn transcribe(facade: SocketAddr, wav: &[u8]) -> (StatusCode, Value) { + use http_body_util::BodyExt; + use hyper_util::client::legacy::Client; + use hyper_util::rt::TokioExecutor; + let client: Client<_, Full> = Client::builder(TokioExecutor::new()).build_http(); + let r = client + .request( + Request::post(format!("http://{facade}/api/transcribe")) + .body(Full::new(Bytes::copy_from_slice(wav))) + .unwrap(), + ) + .await + .unwrap(); + let code = r.status(); + let b = r.into_body().collect().await.unwrap().to_bytes(); + (code, serde_json::from_slice(&b).unwrap()) +} + +#[tokio::test] +async fn supervision_du_serveur_de_dictee() { + if !python3() { + eprintln!("python3 absent : test sauté"); + return; + } + let tmp = tempfile::tempdir().unwrap(); + let home = tmp.path().canonicalize().unwrap(); + install_v3(&home); + let bin = script(&home, "faux-sherpa", FAKE_SHERPA); + let d = Arc::new(Dictation::with( + &home, + "http://127.0.0.1:1/", + Some(bin), + Duration::from_secs(60), + Duration::from_secs(20), + )); + let (facade, go, _stop) = fakego::start_dictate(home.clone(), d.clone()).await; + + // 3000 échantillons : 8 + 12000 octets → deux trames. GPU masqués, jeton + // interne absent de l'environnement du serveur. + let (code, v) = transcribe(facade, &wav16(3000)).await; + assert_eq!(code, StatusCode::OK, "{v}"); + assert_eq!( + v["text"], + "vu 3000 échantillons à 16000 Hz en 2 trames, cuda=[], jeton=False" + ); + let (_, _, b) = fakego::get(facade, "/api/dictate/state", &[]).await; + let st: Value = serde_json::from_slice(&b).unwrap(); + assert_eq!( + (st["actif"].as_bool(), st["present"].as_bool()), + (Some(true), Some(true)) + ); + assert!(st["depuis_s"].is_u64()); + // Même réglage : même serveur (pas de nouveau démarrage). + let port = d + .ensure(DictateCfg::default().with_defauts()) + .await + .unwrap(); + assert_eq!( + d.ensure(DictateCfg::default().with_defauts()) + .await + .unwrap(), + port + ); + + // Le Go demande l'arrêt (libération de la VRAM). + let (code, _) = fakego::send( + facade, + "POST", + dictate::INTERNAL_STOP, + &[("x-loki-internal", fakego::JETON)], + "{}", + ) + .await; + assert_eq!(code, StatusCode::NO_CONTENT); + let (_, _, b) = fakego::get(facade, "/api/dictate/state", &[]).await; + assert_eq!(serde_json::from_slice::(&b).unwrap()["actif"], false); + // Sans le jeton : la route interne n'est pas joignable. + let (code, _) = fakego::send(facade, "POST", dictate::INTERNAL_STOP, &[], "{}").await; + assert_eq!(code, StatusCode::NOT_FOUND); + + // Changer de réglage éteint le serveur. + let (code, _) = transcribe(facade, &wav16(1000)).await; + assert_eq!(code, StatusCode::OK); + let (code, _) = fakego::send( + facade, + "POST", + "/api/dictate/config", + &[], + r#"{"model":"parakeet-tdt-0.6b-v3","reactivity":"court"}"#, + ) + .await; + assert_eq!(code, StatusCode::OK); + assert_eq!( + go.get("state", "dictate_cfg").as_deref(), + Some(r#"{"model":"parakeet-tdt-0.6b-v3","reactivity":"court"}"#) + ); + let (_, _, b) = fakego::get(facade, "/api/dictate/state", &[]).await; + assert_eq!(serde_json::from_slice::(&b).unwrap()["actif"], false); +} + +#[tokio::test] +async fn demarrages_rates() { + let tmp = tempfile::tempdir().unwrap(); + let home = tmp.path().canonicalize().unwrap(); + install_v3(&home); + let cfg = DictateCfg::default().with_defauts(); + // Mort au démarrage : la raison et la dernière ligne. + let mort = script( + &home, + "mort", + "#!/bin/sh\necho chargement\necho boom >&2\nexit 3\n", + ); + let d = Arc::new(Dictation::with( + &home, + "x", + Some(mort), + Duration::from_secs(60), + Duration::from_secs(10), + )); + let e = d.ensure(cfg.clone()).await.unwrap_err(); + assert_eq!( + e, + "le serveur de dictée s'est arrêté au démarrage : exit status 3 — dernière sortie : boom" + ); + // Muet : le délai, à la Go. + let muet = script(&home, "muet", "#!/bin/sh\nsleep 30\n"); + let d = Arc::new(Dictation::with( + &home, + "x", + Some(muet), + Duration::from_secs(60), + Duration::from_secs(1), + )); + let e = d.ensure(cfg.clone()).await.unwrap_err(); + assert_eq!(e, "le serveur de dictée n'a pas répondu en 1s"); + // Un arrêt « vite » pendant un démarrage tue le processus qui démarre. + let d2 = d.clone(); + let lent = Arc::new(Dictation::with( + &home, + "x", + Some(script(&home, "lent", "#!/bin/sh\nsleep 30\n")), + Duration::from_secs(60), + Duration::from_secs(60), + )); + let l2 = lent.clone(); + let c2 = cfg.clone(); + let h = tokio::spawn(async move { l2.ensure(c2).await }); + tokio::time::sleep(Duration::from_millis(300)).await; + let t0 = std::time::Instant::now(); + lent.shutdown_fast().await; + let e = tokio::time::timeout(Duration::from_secs(5), h) + .await + .unwrap() + .unwrap() + .unwrap_err(); + assert!( + e.starts_with("le serveur de dictée s'est arrêté au démarrage : signal: killed"), + "{e}" + ); + assert!(t0.elapsed() < Duration::from_secs(4)); + drop(d2); + // Binaire introuvable, modèle absent. + let d = Arc::new(Dictation::with( + &home, + "x", + Some(String::new()), + Duration::from_secs(60), + Duration::from_secs(1), + )); + assert_eq!( + d.ensure(cfg).await.unwrap_err(), + "serveur de dictée introuvable (image à reconstruire)" + ); + let v2 = DictateCfg { + model: "parakeet-tdt-0.6b-v2".into(), + ..Default::default() + }; + assert_eq!( + d.ensure(v2).await.unwrap_err(), + "modèle de dictée absent : parakeet-tdt-0.6b-v2 (à télécharger dans Paramètres → Dictée)" + ); +} + +#[tokio::test] +async fn sans_la_variable_la_dictee_reste_au_go() { + let tmp = tempfile::tempdir().unwrap(); + let (facade, _go, _stop) = fakego::start(tmp.path().to_path_buf()).await; + for p in [ + "/api/transcribe", + "/api/dictate/config", + "/api/dictate/state", + ] { + let (_, _, b) = fakego::get(facade, p, &[]).await; + assert_eq!(&b[..], format!("go:{p}").as_bytes()); + } + let (code, _) = fakego::send( + facade, + "POST", + dictate::INTERNAL_STOP, + &[("x-loki-internal", fakego::JETON)], + "{}", + ) + .await; + assert_eq!(code, StatusCode::NOT_FOUND); +} diff --git a/rust/loki-rs/tests/lot2.rs b/rust/loki-rs/tests/lot2.rs index 349c12c..2c17ae7 100644 --- a/rust/loki-rs/tests/lot2.rs +++ b/rust/loki-rs/tests/lot2.rs @@ -95,6 +95,7 @@ async fn banc(cle: Option<&str>) -> Banc { }, chat: None, models: None, + dictate: None, }; tokio::spawn(loki_rs::proxy::serve(l, cfg, async move { let _ = rx.await; diff --git a/rust/loki-rs/tests/parity_dictate/dictate.json b/rust/loki-rs/tests/parity_dictate/dictate.json new file mode 100644 index 0000000..93a5fcb --- /dev/null +++ b/rust/loki-rs/tests/parity_dictate/dictate.json @@ -0,0 +1,337 @@ +{ + "wav": [ + { + "name": "mono 16 bits", + "in": "UklGRi4AAABXQVZFZm10IBAAAAABAAEAgD4AAAB9AAACABAAZGF0YQoAAAAAAABAAMD/fwCA", + "rate": 16000, + "ech": [ + 0, + 0.5, + -0.5, + 0.9999695, + -1 + ] + }, + { + "name": "bloc LIST intercalé", + "in": "UklGRjgAAABXQVZFTElTVAcAAABJTkZPeHl6AGZtdCAQAAAAAQABAIA+AAAAfQAAAgAQAGRhdGEEAAAAZACc/w==", + "rate": 16000, + "ech": [ + 0.0030517578, + -0.0030517578 + ] + }, + { + "name": "stéréo moyenné", + "in": "UklGRiwAAABXQVZFZm10IBAAAAABAAIAgLsAAADuAgAEABAAZGF0YQgAAAAAAP9/AID/fw==", + "rate": 48000, + "ech": [ + 0.49998474, + -0.000015258789 + ] + }, + { + "name": "flottant 32 bits", + "in": "UklGRjAAAABXQVZFZm10IBAAAAADAAEAgD4AAAD6AAAEACAAZGF0YQwAAAAAAIA+AACAvwAAAD8=", + "rate": 16000, + "ech": [ + 0.25, + -1, + 0.5 + ] + }, + { + "name": "data tronqué", + "in": "UklGRioAAABXQVZFZm10IBAAAAABAAEAgD4AAAB9AAACABAAZGF0YQYAAAABAAIA", + "rate": 16000, + "ech": [ + 0.000030517578, + 0.000061035156 + ] + }, + { + "name": "data impair", + "in": "UklGRigAAABXQVZFZm10IBAAAAABAAEAQB8AAIA+AAACABAAZGF0YQMAAAABAgMA", + "rate": 8000, + "ech": [ + 0.015655518 + ] + }, + { + "name": "data vide", + "in": "UklGRiQAAABXQVZFZm10IBAAAAABAAEAgD4AAAB9AAACABAAZGF0YQAAAAA=", + "rate": 16000 + }, + { + "name": "24 bits", + "in": "UklGRioAAABXQVZFZm10IBAAAAABAAEAgD4AAIC7AAADABgAZGF0YQYAAAAAAAAAAAA=", + "err": "format audio non géré (format 1, 24 bits) — attendu PCM 16 bits" + }, + { + "name": "data avant fmt", + "in": "UklGRiYAAABXQVZFZGF0YQIAAAABAGZtdCAQAAAAAQABAIA+AAAAfQAAAgAQAA==", + "err": "audio illisible : bloc data avant fmt" + }, + { + "name": "fmt tronqué", + "in": "UklGRhQAAABXQVZFZm10IAgAAAAAAAAAAAAAAA==", + "err": "audio illisible : bloc fmt tronqué" + }, + { + "name": "sans data", + "in": "UklGRhwAAABXQVZFZm10IBAAAAABAAEAgD4AAAB9AAACABAA", + "err": "audio illisible : aucun bloc data" + }, + { + "name": "pas un WAV", + "in": "Y2VjaSBuJ2VzdCBwYXMgdW4gZmljaGllciBhdWRpbyBkdSB0b3V0", + "err": "audio illisible : ce n'est pas un WAV" + }, + { + "name": "vide", + "in": "", + "err": "audio illisible : ce n'est pas un WAV" + } + ], + "safe_rel": [ + { + "in": "", + "err": "entrée d'archive au chemin absolu : \"\"" + }, + { + "in": "racine" + }, + { + "in": "racine/" + }, + { + "in": "racine/encoder.onnx", + "out": "encoder.onnx" + }, + { + "in": "racine/sous/tokens.txt", + "out": "sous/tokens.txt" + }, + { + "in": "/abs/x", + "err": "entrée d'archive au chemin absolu : \"/abs/x\"" + }, + { + "in": "racine/../x", + "err": "entrée d'archive qui remonte hors du dossier : \"racine/../x\"" + }, + { + "in": "racine\\a\\b.txt", + "out": "a/b.txt" + }, + { + "in": " racine/a ", + "out": "a" + }, + { + "in": "..", + "err": "entrée d'archive qui remonte hors du dossier : \"..\"" + }, + { + "in": "a/b/../c", + "err": "entrée d'archive qui remonte hors du dossier : \"a/b/../c\"" + } + ], + "marker": { + "": true, + " ": true, + " (rires) ": true, + "(silence)": true, + "(x": false, + "[a] b": false, + "[musique]": true, + "bonjour": false + }, + "defaults": [ + { + "model": "", + "out_model": "parakeet-tdt-0.6b-v3", + "out_reactivity": "moyen", + "reactivity": "" + }, + { + "model": "ggml-base.bin", + "out_model": "parakeet-tdt-0.6b-v3", + "out_reactivity": "moyen", + "reactivity": "" + }, + { + "model": "parakeet-tdt-0.6b-v2", + "out_model": "parakeet-tdt-0.6b-v2", + "out_reactivity": "court", + "reactivity": "court" + } + ], + "last_line": { + "": "", + " \n x \n": "x", + "a\nb\n\n ": "b", + "un seul": "un seul" + }, + "args": [ + "--encoder={{HOME}}/asr/sherpa-onnx-nemo-parakeet-tdt-0.6b-v3-int8/encoder.int8.onnx", + "--decoder={{HOME}}/asr/sherpa-onnx-nemo-parakeet-tdt-0.6b-v3-int8/decoder.int8.onnx", + "--joiner={{HOME}}/asr/sherpa-onnx-nemo-parakeet-tdt-0.6b-v3-int8/joiner.int8.onnx", + "--tokens={{HOME}}/asr/sherpa-onnx-nemo-parakeet-tdt-0.6b-v3-int8/tokens.txt", + "--model-type=nemo_transducer", + "--port=4567", + "--host=127.0.0.1" + ], + "args_err": "modèle de dictée inconnu : x", + "urls": { + "inconnu": "", + "parakeet-tdt-0.6b-v2": "https://github.com/k2-fsa/sherpa-onnx/releases/download/asr-models/sherpa-onnx-nemo-parakeet-tdt-0.6b-v2-int8.tar.bz2", + "parakeet-tdt-0.6b-v3": "https://github.com/k2-fsa/sherpa-onnx/releases/download/asr-models/sherpa-onnx-nemo-parakeet-tdt-0.6b-v3-int8.tar.bz2" + }, + "calls": [ + { + "method": "GET", + "path": "/api/dictate/config", + "status": 200, + "out": { + "model": "parakeet-tdt-0.6b-v3", + "reactivity": "moyen" + } + }, + { + "method": "POST", + "path": "/api/dictate/config", + "body": "pas du json", + "status": 400, + "out": { + "error": "requête illisible" + } + }, + { + "method": "POST", + "path": "/api/dictate/config", + "body": "{\"model\":\"ggml-base.bin\"}", + "status": 400, + "out": { + "error": "modèle de dictée inconnu : ggml-base.bin" + } + }, + { + "method": "POST", + "path": "/api/dictate/config", + "body": "{\"model\":\"parakeet-tdt-0.6b-v2\"}", + "status": 200, + "out": { + "ok": true + } + }, + { + "method": "GET", + "path": "/api/dictate/config", + "status": 200, + "out": { + "model": "parakeet-tdt-0.6b-v2", + "reactivity": "moyen" + } + }, + { + "method": "POST", + "path": "/api/dictate/config", + "body": "{\"model\":\"parakeet-tdt-0.6b-v3\",\"reactivity\":\"long\",\"autre\":1}", + "status": 200, + "out": { + "ok": true + } + }, + { + "method": "GET", + "path": "/api/dictate/config", + "status": 200, + "out": { + "model": "parakeet-tdt-0.6b-v3", + "reactivity": "long" + } + }, + { + "method": "GET", + "path": "/api/dictate/models", + "status": 200, + "out": [ + { + "id": "parakeet-tdt-0.6b-v2", + "langues": "anglais uniquement", + "nom": "Parakeet v2 — anglais seul", + "octets": 482468385, + "present": true + }, + { + "id": "parakeet-tdt-0.6b-v3", + "langues": "25 langues européennes, français inclus — détection automatique", + "nom": "Parakeet v3 — recommandé", + "octets": 487170055, + "present": false + } + ] + }, + { + "method": "GET", + "path": "/api/dictate/models/download", + "status": 200, + "out": { + "error": "", + "id": "", + "pct": 0, + "running": false + } + }, + { + "method": "POST", + "path": "/api/dictate/models/download", + "body": "{\"id\":\" inconnu \"}", + "status": 400, + "out": { + "error": "modèle de dictée inconnu : inconnu" + } + }, + { + "method": "POST", + "path": "/api/dictate/models/download", + "body": "x", + "status": 400, + "out": { + "error": "requête illisible" + } + }, + { + "method": "GET", + "path": "/api/dictate/state", + "status": 200, + "out": { + "actif": false, + "device": "cpu", + "dl": { + "error": "", + "id": "", + "pct": 0, + "running": false + }, + "modele": "parakeet-tdt-0.6b-v3", + "present": false + } + }, + { + "method": "POST", + "path": "/api/transcribe", + "body": "trop court", + "status": 400, + "out": { + "error": "audio vide ou trop court" + } + } + ], + "present": { + "inconnu": false, + "parakeet-tdt-0.6b-v2": true, + "parakeet-tdt-0.6b-v3": false + } +} diff --git a/rust/loki-rs/tests/proxy.rs b/rust/loki-rs/tests/proxy.rs index bda1229..58b0b98 100644 --- a/rust/loki-rs/tests/proxy.rs +++ b/rust/loki-rs/tests/proxy.rs @@ -101,6 +101,7 @@ async fn facade(backend: SocketAddr) -> (SocketAddr, oneshot::Sender<()>) { }, chat: None, models: None, + dictate: None, }; tokio::spawn(loki_rs::proxy::serve(l, cfg, async move { let _ = rx.await; From 28f90543def4872e71add315646b752b8e4a39ee Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 15 Oct 2026 16:25:51 +0000 Subject: [PATCH 05/52] =?UTF-8?q?Lot=2010=20:=20r=C3=A9glages=20partag?= =?UTF-8?q?=C3=A9s=20tenus=20par=20la=20fa=C3=A7ade=20(LOKI=5FSETTINGS=3Dr?= =?UTF-8?q?ust)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit La façade sert /api/prefs, /api/sysprompt, /api/reasoning-effort et /api/oai/public quand LOKI_SETTINGS=rust (expérimental ; le Go reste le défaut), sur les mêmes clés de la base : préférences fusionnées et nettoyées (prénom borné, avatars en liste fermée, valeurs vides effacées), prompt système du preset actif avec repli sur le global, intensité du raisonnement en liste blanche, adresse publique normalisée. Côté Go, facade_settings.go remet ces routes à la façade quand elles arrivent par le tunnel du relais. Parité : parity_settings_test.go tire du Go 19 adresses, 11 intensités et 29 appels de route avec le contenu de la base après chacun, rejoués par tests/settings.rs. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_016tcGfJWefW5kUGoC2wL8cv --- README.md | 1 + ...15-rust-migration-lot10-reglages-design.md | 44 ++ internal/loki/facade_chat.go | 6 +- internal/loki/facade_chat_test.go | 26 + internal/loki/facade_settings.go | 32 + internal/loki/parity_settings_test.go | 183 +++++ internal/loki/web_server.go | 3 +- rust/loki-rs/src/api.rs | 8 + rust/loki-rs/src/lib.rs | 1 + rust/loki-rs/src/main.rs | 9 +- rust/loki-rs/src/proxy.rs | 14 +- rust/loki-rs/src/settings.rs | 490 ++++++++++++ rust/loki-rs/tests/common/fakego.rs | 2 + rust/loki-rs/tests/lot2.rs | 1 + .../tests/parity_settings/settings.json | 731 ++++++++++++++++++ rust/loki-rs/tests/proxy.rs | 1 + rust/loki-rs/tests/settings.rs | 146 ++++ 17 files changed, 1689 insertions(+), 9 deletions(-) create mode 100644 docs/superpowers/specs/2026-10-15-rust-migration-lot10-reglages-design.md create mode 100644 internal/loki/facade_settings.go create mode 100644 internal/loki/parity_settings_test.go create mode 100644 rust/loki-rs/src/settings.rs create mode 100644 rust/loki-rs/tests/parity_settings/settings.json create mode 100644 rust/loki-rs/tests/settings.rs diff --git a/README.md b/README.md index 3954cc0..a47e4c2 100644 --- a/README.md +++ b/README.md @@ -230,6 +230,7 @@ redémarrages (volume `/data`). Variables d'environnement du conteneur : | `LOKI_CHAT` | `rust` : la conversation est tenue par la façade Rust (expérimental, migration en cours), outils fichiers et shell compris. Le Go garde les autres outils, les pièces jointes, les fichiers et l'export. Avec `PROJ_SNAPSHOT`, `KEEP_TURN_IMAGES`, `NUDGE_IN_TOOL`, `SIDE_SLOT`, `SLOT_PERSIST`, `PREWARM` ou des motifs `retry_patterns`, le Go garde la conversation (décidé au démarrage) | Go | | `LOKI_MODELS` | `rust` : les routes des modèles — liste, dossiers, suppression, téléchargements, catalogue Hugging Face — sont servies par la façade Rust (expérimental, migration en cours) | Go | | `LOKI_DICTATE` | `rust` : la dictée vocale (`/api/transcribe`, `/api/dictate/*`) et son serveur de transcription sont tenus par la façade Rust (expérimental, migration en cours) | Go | +| `LOKI_SETTINGS` | `rust` : les réglages partagés — préférences d'affichage, prompt système, intensité du raisonnement, adresse publique — sont servis par la façade Rust (expérimental, migration en cours) | Go | **Accès par nom de domaine.** Sans clé de pilotage, l'API n'accepte que les hôtes locaux (IP, `localhost`, nom sans point, `.local`/`.lan`…) et refuse diff --git a/docs/superpowers/specs/2026-10-15-rust-migration-lot10-reglages-design.md b/docs/superpowers/specs/2026-10-15-rust-migration-lot10-reglages-design.md new file mode 100644 index 0000000..52a4fb2 --- /dev/null +++ b/docs/superpowers/specs/2026-10-15-rust-migration-lot10-reglages-design.md @@ -0,0 +1,44 @@ +# Lot 10 — réglages partagés tenus par la façade + +## Contexte + +Quatre routes ne font que lire et écrire des réglages en base, partagés entre +tous les appareils : + +| Route | Port de | Ce qu'elle règle | +|---|---|---| +| `GET\|POST /api/prefs` | `web_prefs.go` | thème, affichage, prénom (24 caractères, sans caractère de contrôle), avatars (liste fermée) | +| `GET\|POST /api/sysprompt` | `web_sysprompt.go` | prompt système du preset actif (`state/sysprompt:`), repli sur le global (`state/sysprompt`) | +| `GET\|POST /api/reasoning-effort` | `handleReasoningEffort` | `REASONING_EFFORT` de la configuration vivante (liste blanche) | +| `GET\|POST /api/oai/public` | `web_public_url.go` | adresse publique normalisée en « schéma://hôte[:port] » | + +Lot 10 : la façade les sert quand `LOKI_SETTINGS=rust` (`settings.rs`, +expérimental ; sans la variable rien ne change). + +Les valeurs restent dans la base du Go, aux mêmes clés. Le Go les relit là +où il s'en sert (tour de chat, accès OpenAI) : son cache de configuration se +périme sur l'horodatage du fichier de base, qu'une écriture de la façade +modifie. `facade_settings.go` remet les routes du tunnel à la façade. + +## Écarts assumés + +- Message d'un corps JSON syntaxiquement illisible : celui de serde. Une + valeur du mauvais type (`{"text":5}`) garde le message d'`encoding/json`. +- Préférences réécrites clé par clé au lieu d'une transaction. +- Port nommé dans l'adresse publique (`:https`) refusé : le Go le résolvait + par `/etc/services`. + +## Parité + +`internal/loki/parity_settings_test.go` tire du Go : + +- 19 adresses publiques normalisées ou refusées ; +- 11 valeurs d'intensité ; +- 29 appels de route, avec le contenu de la base après chacun : + - fusion des préférences (valeurs refusées, prénom tronqué, vides effacés) ; + - prompt par preset avec repli, effacement, méthode refusée ; + - intensité acceptée ou refusée ; + - adresse publique. + +`tests/settings.rs` rejoue le tout par la façade. Une mutation (prénom +tronqué à 25 caractères) fait échouer le rejeu. diff --git a/internal/loki/facade_chat.go b/internal/loki/facade_chat.go index a264a15..4a892c6 100644 --- a/internal/loki/facade_chat.go +++ b/internal/loki/facade_chat.go @@ -141,8 +141,8 @@ var facadeChatRoutes = map[string]bool{ } // withFacadeChat remet la requête à la façade quand elle tient la -// conversation — ou les routes des modèles (facade_models.go), ou la dictée -// (facade_dictate.go). +// conversation — ou les routes des modèles (facade_models.go), la dictée +// (facade_dictate.go), les réglages partagés (facade_settings.go). // Posé APRÈS requireWebAuth : seule une requête authentifiée ici part, avec le // jeton interne qui en tient lieu là-bas. func withFacadeChat(path string, h http.HandlerFunc) http.HandlerFunc { @@ -153,6 +153,8 @@ func withFacadeChat(path string, h http.HandlerFunc) http.HandlerFunc { held, unavailable = facadeModels, "modèles indisponibles : " case facadeDictateRoutes[path]: held, unavailable = facadeDictate, "dictée indisponible : " + case facadeSettingsRoutes[path]: + held, unavailable = facadeSettings, "réglages indisponibles : " default: return h } diff --git a/internal/loki/facade_chat_test.go b/internal/loki/facade_chat_test.go index ef1e3d6..c55d9de 100644 --- a/internal/loki/facade_chat_test.go +++ b/internal/loki/facade_chat_test.go @@ -412,3 +412,29 @@ func TestFacadeDictee(t *testing.T) { t.Fatalf("arrêt demandé à la façade sans la variable (%d appels)", n) } } + +// LOKI_SETTINGS=rust : les réglages partagés partent à la façade ; sans la +// variable, ils restent ici. +func TestFacadeReglages(t *testing.T) { + testHome(t) + f := demarrerFacade(t) + t.Setenv(facadeChatEnv, "") + t.Setenv(facadeSettingsEnv, "rust") + local := func(w http.ResponseWriter, r *http.Request) { io.WriteString(w, "go") } + for p := range facadeSettingsRoutes { + w := httptest.NewRecorder() + withFacadeChat(p, local)(w, httptest.NewRequest("GET", p, nil)) + if w.Body.String() != f.reply { + t.Fatalf("%s : réponse %q", p, w.Body.String()) + } + } + if n := len(f.recu()); n != len(facadeSettingsRoutes) { + t.Fatalf("%d appels reçus par la façade", n) + } + t.Setenv(facadeSettingsEnv, "") + w := httptest.NewRecorder() + withFacadeChat("/api/prefs", local)(w, httptest.NewRequest("GET", "/api/prefs", nil)) + if w.Body.String() != "go" { + t.Fatalf("relayé sans la variable : %q", w.Body.String()) + } +} diff --git a/internal/loki/facade_settings.go b/internal/loki/facade_settings.go new file mode 100644 index 0000000..c31af2e --- /dev/null +++ b/internal/loki/facade_settings.go @@ -0,0 +1,32 @@ +package loki + +import ( + "os" + "strings" +) + +// facade_settings.go — réglages partagés tenus par la façade Rust +// (LOKI_SETTINGS=rust, lot 10 de la migration ; expérimental, le Go reste le +// défaut) : préférences d'affichage, prompt système, intensité du +// raisonnement, adresse publique. +// +// Les valeurs restent dans la base, aux mêmes clés : ce process les relit là +// où il s'en sert (tour de chat, accès OpenAI). Seules les routes changent de +// main ; celles qui arrivent ici par le tunnel du relais sont remises à la +// façade (withFacadeChat). + +const facadeSettingsEnv = "LOKI_SETTINGS" + +// facadeSettings : la façade tient-elle les réglages partagés ? +func facadeSettings() bool { + return strings.EqualFold(strings.TrimSpace(os.Getenv(facadeSettingsEnv)), "rust") && + facadeAddr() != "" && internalToken() != "" +} + +// facadeSettingsRoutes : les routes que la façade sert alors (settings.rs). +var facadeSettingsRoutes = map[string]bool{ + "/api/prefs": true, + "/api/sysprompt": true, + "/api/reasoning-effort": true, + "/api/oai/public": true, +} diff --git a/internal/loki/parity_settings_test.go b/internal/loki/parity_settings_test.go new file mode 100644 index 0000000..741f8a4 --- /dev/null +++ b/internal/loki/parity_settings_test.go @@ -0,0 +1,183 @@ +package loki + +import ( + "encoding/json" + "net/http/httptest" + "os" + "path/filepath" + "strings" + "testing" +) + +// parity_settings_test.go — journal de référence des réglages partagés entre +// appareils (lot 10 de la migration Rust) : préférences d'affichage, prompt +// système (par preset, repli global), intensité du raisonnement, adresse +// publique. Rangé dans rust/loki-rs/tests/parity_settings/settings.json ; +// tests/settings.rs le rejoue par la façade. +// +// LOKI_PARITY_UPDATE=1 go test ./internal/loki -run TestPariteReglages + +const paritySettingsFile = "../../rust/loki-rs/tests/parity_settings/settings.json" + +type paritySettingsStep struct { + // Avant l'appel : configuration remplacée, preset écrit. + Config map[string]string `json:"config,omitempty"` + Preset map[string]string `json:"preset,omitempty"` + Method string `json:"method"` + Path string `json:"path"` + Body string `json:"body,omitempty"` + Host string `json:"host,omitempty"` + Status int `json:"status"` + Out any `json:"out"` + // Après l'appel : le contenu des clés touchées en base. + Prefs map[string]string `json:"prefs,omitempty"` + State map[string]string `json:"state,omitempty"` +} + +type paritySettingsFixture struct { + PublicURL []struct { + In string `json:"in"` + Out string `json:"out,omitempty"` + Err string `json:"err,omitempty"` + } `json:"public_url"` + EffortValue map[string]string `json:"effort_value"` + Steps []paritySettingsStep `json:"steps"` +} + +func TestPariteReglages(t *testing.T) { + testHome(t) + var fx paritySettingsFixture + for _, in := range []string{"", " ", "exemple.fr", "https://ia.exemple.fr/", "http://10.0.0.5:8090/v1/", + "HTTPS://Exemple.FR:443", "ftp://x.fr", "https://", "https://moi:secret@x.fr", "https://x.fr/?a=1", + "https://x.fr/#ancre", "https://x.fr/api", "https://x.fr:0", "https://x.fr:99999", "https://x.fr:abc", + "https://[::1]:8090", "https://::1", "https://:8080", "exemple.fr:8090/v1"} { + c := struct { + In string `json:"in"` + Out string `json:"out,omitempty"` + Err string `json:"err,omitempty"` + }{In: in} + out, err := normalizePublicURL(in) + if err != nil { + c.Err = err.Error() + } else { + c.Out = out + } + fx.PublicURL = append(fx.PublicURL, c) + } + fx.EffortValue = map[string]string{} + for _, v := range []string{"", "auto", "Default", "off", "NONE", "0", "low", " High ", "medium", "xhigh", "bizarre"} { + fx.EffortValue[v] = reasoningEffortValue(v) + } + + keys := []string{"sysprompt", "sysprompt:alpha", "sysprompt:beta", "public_url"} + run := func(st paritySettingsStep) paritySettingsStep { + if st.Config != nil { + if err := WriteConfig(st.Config); err != nil { + t.Fatal(err) + } + } + for id, content := range st.Preset { + if err := os.MkdirAll(presetsDir(), 0o755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(presetsDir(), id+".env"), []byte(content), 0o644); err != nil { + t.Fatal(err) + } + } + w := httptest.NewRecorder() + r := httptest.NewRequest(st.Method, st.Path, strings.NewReader(st.Body)) + if st.Host != "" { + r.Host = st.Host + } + switch st.Path { + case "/api/prefs": + handleWebPrefs(w, r) + case "/api/sysprompt": + handleSysPrompt(w, r) + case "/api/reasoning-effort": + handleReasoningEffort(w, r) + case "/api/oai/public": + handlePublicAddress(w, r) + default: + t.Fatalf("route inattendue %s", st.Path) + } + st.Status = w.Code + if err := json.Unmarshal(w.Body.Bytes(), &st.Out); err != nil { + t.Fatalf("%s %s : %v (%s)", st.Method, st.Path, err, w.Body.String()) + } + st.Prefs = loadWebPrefs() + st.State = map[string]string{} + for _, k := range keys { + if v := getStr(bkState, k); v != "" { + st.State[k] = v + } + } + if v := ReadConfig()["REASONING_EFFORT"]; v != "" { + st.State["config:REASONING_EFFORT"] = v + } + return st + } + for _, st := range []paritySettingsStep{ + {Method: "GET", Path: "/api/prefs"}, + {Method: "POST", Path: "/api/prefs", Body: `{"theme":" DARK ","hide_tools":"1","hide_stats":"2","inconnue":"x","user_name":" Ma\u0007rie ","avatar_user":"🦊","avatar_ai":"💩"}`}, + {Method: "POST", Path: "/api/prefs", Body: `{"user_name":"Une très très longue signature éèà","fold_tools":1,"hide_side":"0"}`}, + {Method: "POST", Path: "/api/prefs", Body: `pas du json`}, + {Method: "POST", Path: "/api/prefs", Body: `{"user_name":" ","avatar_user":""}`}, + {Method: "GET", Path: "/api/prefs"}, + // Prompt système : sans preset actif, le global. + {Method: "GET", Path: "/api/sysprompt"}, + {Method: "POST", Path: "/api/sysprompt", Body: `{"text":" Réponds en français. "}`}, + {Method: "POST", Path: "/api/sysprompt", Body: `{"text":5}`}, + {Method: "PUT", Path: "/api/sysprompt"}, + // Preset alpha actif : son propre prompt, repli sur le global tant qu'il + // n'en a pas. + {Config: map[string]string{"MODEL": "a.gguf", "CTX": "8192"}, + Preset: map[string]string{"alpha": "# Alpha\nMODEL=a.gguf\nCTX=8192\n", "beta": "# Beta\nMODEL=b.gguf\n"}, + Method: "GET", Path: "/api/sysprompt"}, + {Method: "POST", Path: "/api/sysprompt", Body: `{"text":"Prompt d'Alpha"}`}, + {Method: "GET", Path: "/api/sysprompt"}, + {Config: map[string]string{"MODEL": "b.gguf"}, Method: "GET", Path: "/api/sysprompt"}, + {Method: "POST", Path: "/api/sysprompt", Body: `{"text":""}`}, + {Config: map[string]string{"MODEL": "a.gguf", "CTX": "8192"}, Method: "POST", Path: "/api/sysprompt", Body: `{"text":" "}`}, + {Method: "GET", Path: "/api/sysprompt"}, + // Intensité du raisonnement. + {Config: map[string]string{"MODEL": "a.gguf"}, Method: "GET", Path: "/api/reasoning-effort"}, + {Method: "POST", Path: "/api/reasoning-effort", Body: `{"value":" HIGH "}`}, + {Method: "POST", Path: "/api/reasoning-effort", Body: `{"value":"auto"}`}, + {Config: map[string]string{"MODEL": "a.gguf", "REASONING": "on", "REASONING_EFFORT": "off"}, Method: "GET", Path: "/api/reasoning-effort"}, + {Method: "POST", Path: "/api/reasoning-effort", Body: `{"value":""}`}, + {Config: map[string]string{"REASONING": "off", "REASONING_EFFORT": "bizarre"}, Method: "GET", Path: "/api/reasoning-effort"}, + // Adresse publique. + {Method: "GET", Path: "/api/oai/public"}, + {Method: "POST", Path: "/api/oai/public", Body: `{"url":"ftp://x"}`}, + {Method: "POST", Path: "/api/oai/public", Body: `pas du json`}, + {Method: "POST", Path: "/api/oai/public", Body: `{"url":" ia.exemple.fr/v1 "}`}, + {Method: "GET", Path: "/api/oai/public"}, + {Method: "POST", Path: "/api/oai/public", Body: `{"url":""}`}, + } { + fx.Steps = append(fx.Steps, run(st)) + } + + got, err := parityJSON(fx) + if err != nil { + t.Fatal(err) + } + if os.Getenv("LOKI_PARITY_UPDATE") == "1" { + if err := os.MkdirAll(filepath.Dir(paritySettingsFile), 0o755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(paritySettingsFile, got, 0o644); err != nil { + t.Fatal(err) + } + return + } + want, err := os.ReadFile(paritySettingsFile) + 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 réglages ne produisent plus le journal rangé dans %s.\n"+ + "Si c'est voulu : LOKI_PARITY_UPDATE=1 go test ./internal/loki -run TestPariteReglages,\n"+ + "puis cargo test --test settings dit si le port Rust suit.\n--- obtenu ---\n%s", paritySettingsFile, got) + } +} diff --git a/internal/loki/web_server.go b/internal/loki/web_server.go index 9b35c1b..70623b7 100644 --- a/internal/loki/web_server.go +++ b/internal/loki/web_server.go @@ -221,7 +221,8 @@ func newWebMux() *http.ServeMux { // withFacadeChat : routes du chat remises à la façade quand elle tient la // conversation (LOKI_CHAT=rust, facade_chat.go), celles des modèles quand // elle les tient (LOKI_MODELS=rust, facade_models.go), celles de la dictée - // (LOKI_DICTATE=rust, facade_dictate.go) ; sans effet sinon. + // (LOKI_DICTATE=rust, facade_dictate.go) et des réglages partagés + // (LOKI_SETTINGS=rust, facade_settings.go) ; sans effet sinon. api := func(path string, h http.HandlerFunc) { mux.HandleFunc(path, requireWebAuth(withFacadeChat(path, h))) } api("/api/ping", handlePing) api("/api/status", handleStatus) diff --git a/rust/loki-rs/src/api.rs b/rust/loki-rs/src/api.rs index 6f5cde0..e094f36 100644 --- a/rust/loki-rs/src/api.rs +++ b/rust/loki-rs/src/api.rs @@ -32,11 +32,19 @@ pub struct Ctx { pub models: Option>, /// Dictée vocale tenue par la façade (`LOKI_DICTATE=rust`) ; `None` : au Go. pub dictate: Option>, + /// Réglages partagés tenus par la façade (`LOKI_SETTINGS=rust`). + pub settings: bool, /// Jeton de l'API interne : une requête qui le porte vient du Go. pub internal_token: String, } impl Ctx { + /// La façade tient-elle au moins un lot (le Go lui remet alors le trafic + /// de ce lot qui lui arrive par le tunnel) ? + pub fn holds_any(&self) -> bool { + self.chat.is_some() || self.models.is_some() || self.dictate.is_some() || self.settings + } + /// La requête vient du Go (jeton interne valide) : déjà authentifiée par /// lui (tunnel du relais, redistribution E2E). pub fn from_go(&self, headers: &HeaderMap) -> bool { diff --git a/rust/loki-rs/src/lib.rs b/rust/loki-rs/src/lib.rs index 3da8474..7b1913c 100644 --- a/rust/loki-rs/src/lib.rs +++ b/rust/loki-rs/src/lib.rs @@ -26,6 +26,7 @@ pub mod oai; pub mod paths; pub mod presets; pub mod proxy; +pub mod settings; pub mod store; pub mod sysinfo; pub mod tools; diff --git a/rust/loki-rs/src/main.rs b/rust/loki-rs/src/main.rs index fcd56b3..06cd973 100644 --- a/rust/loki-rs/src/main.rs +++ b/rust/loki-rs/src/main.rs @@ -124,6 +124,9 @@ fn serve() -> ExitCode { // LOKI_DICTATE=rust : la dictée vocale aussi (le Go lui demande alors // d'arrêter son serveur quand il libère la VRAM). let dictate_rust = held_by_facade("LOKI_DICTATE"); + // LOKI_SETTINGS=rust : les réglages partagés (préférences, prompt + // système, intensité du raisonnement, adresse publique). + let settings_rust = held_by_facade("LOKI_SETTINGS"); let mut env = vec![(auth::INTERNAL_ENV.to_string(), token.clone())]; if chat_rust { env.push(("LOKI_CHAT".into(), "rust".into())); @@ -134,7 +137,10 @@ fn serve() -> ExitCode { if dictate_rust { env.push(("LOKI_DICTATE".into(), "rust".into())); } - if chat_rust || models_rust || dictate_rust { + if settings_rust { + env.push(("LOKI_SETTINGS".into(), "rust".into())); + } + if chat_rust || models_rust || dictate_rust || settings_rust { env.push(("LOKI_FACADE_ADDR".into(), format!("127.0.0.1:{web_port}"))); } let sup = child::Supervisor::spawn(go_bin, go_port, env); @@ -196,6 +202,7 @@ fn serve() -> ExitCode { chat, models, dictate, + settings: settings_rust, }; let res = proxy::serve(listener, cfg, shutdown_signal()).await; eprintln!("[loki-rs] arrêt demandé — arrêt du serveur Go"); diff --git a/rust/loki-rs/src/proxy.rs b/rust/loki-rs/src/proxy.rs index ebc6839..c9bdac1 100644 --- a/rust/loki-rs/src/proxy.rs +++ b/rust/loki-rs/src/proxy.rs @@ -69,6 +69,8 @@ pub struct Config { /// Dictée vocale tenue par la façade (`LOKI_DICTATE=rust`) : servie ici ; /// `None` : relayée au Go. pub dictate: Option>, + /// Réglages partagés tenus par la façade (`LOKI_SETTINGS=rust`). + pub settings: bool, } struct Proxy { @@ -98,6 +100,7 @@ pub async fn serve( chat: cfg.chat, models: cfg.models, dictate: cfg.dictate, + settings: cfg.settings, internal_token: cfg.internal_token.clone(), }, }); @@ -137,11 +140,9 @@ pub async fn serve( impl Proxy { async fn handle(&self, mut req: Request) -> Response { // Le Go (et lui seul : jeton tiré à chaque démarrage) remet à la - // façade le trafic du chat, des modèles et de la dictée qui lui arrive - // sans passer par elle. - let from_go = - (self.ctx.chat.is_some() || self.ctx.models.is_some() || self.ctx.dictate.is_some()) - && self.ctx.from_go(req.headers()); + // façade le trafic des lots qu'elle tient et qui lui arrive sans passer + // par elle. + let from_go = self.ctx.holds_any() && self.ctx.from_go(req.headers()); // Jamais de confiance accordée sur la foi d'un en-tête du client : // ni « je viens du tunnel », ni « je suis la façade ». req.headers_mut().remove(VIA_TUNNEL); @@ -181,6 +182,9 @@ impl Proxy { { return chatapi::serve(&self.ctx, chat, req, from_go).await; } + if self.ctx.settings && crate::settings::handles(path) { + return crate::settings::serve(&self.ctx, req, from_go).await; + } if let Some(d) = &self.ctx.dictate && crate::dictate::handles(path) { diff --git a/rust/loki-rs/src/settings.rs b/rust/loki-rs/src/settings.rs new file mode 100644 index 0000000..dbf3599 --- /dev/null +++ b/rust/loki-rs/src/settings.rs @@ -0,0 +1,490 @@ +//! Réglages partagés entre appareils — port de `web_prefs.go`, +//! `web_sysprompt.go`, `handleReasoningEffort` et `handlePublicAddress` +//! (lot 10 de la migration), servis par la façade quand +//! `LOKI_SETTINGS=rust` (expérimental ; le Go par défaut) : +//! +//! - `GET|POST /api/prefs` : thème et affichage, prénom, avatars ; +//! - `GET|POST /api/sysprompt` : prompt système du preset actif (repli sur le +//! prompt global) ; +//! - `GET|POST /api/reasoning-effort` : intensité du raisonnement +//! (`REASONING_EFFORT` de la configuration vivante) ; +//! - `GET|POST /api/oai/public` : adresse publique de l'instance. +//! +//! Tout vit dans la base du Go (mêmes clés) : le Go relit ces valeurs là où il +//! s'en sert (tour de chat, accès OpenAI). +//! +//! Écarts assumés : message d'un corps JSON syntaxiquement illisible (serde) ; +//! préférences réécrites clé par clé au lieu d'une transaction (le Go +//! remplaçait le bucket d'un bloc) ; port d'adresse publique nommé +//! (`:https`) refusé — le Go le résolvait par /etc/services. + +use std::collections::{BTreeMap, HashMap}; + +use hyper::body::Incoming; +use hyper::{Method, Request, Response, StatusCode}; +use serde_json::{Value, json}; + +use crate::api::Ctx; +use crate::auth::json as send_json; +use crate::chat::wire::{reasoning_active, reasoning_effort_value}; +use crate::models::body_json; +use crate::presets; +use crate::proxy::Body; +use crate::store::BK_STATE; + +const BK_PREFS: &str = "prefs"; +const BK_CONFIG: &str = "config"; +const SYSPROMPT_GLOBAL: &str = "sysprompt"; +const SYSPROMPT_PREFIX: &str = "sysprompt:"; +const PUBLIC_URL_KEY: &str = "public_url"; + +/// Valeurs proposées pour `REASONING_EFFORT` (`ReasoningEfforts`). +pub const REASONING_EFFORTS: [&str; 5] = ["none", "low", "medium", "high", "xhigh"]; + +/// Avatars proposés (liste fermée). +pub const AVATARS: [&str; 16] = [ + "🙂", "😎", "🧑", "👤", "🦊", "🐱", "🐼", "🦉", "🚀", "⚡", "🌙", "🔥", "🌿", "🎯", "🧠", "🤖", +]; + +/// Routes servies ici. +pub fn handles(path: &str) -> bool { + matches!( + path, + "/api/prefs" | "/api/sysprompt" | "/api/reasoning-effort" | "/api/oai/public" + ) +} + +// --------------------------------------------------------------------------- +// Préférences +// --------------------------------------------------------------------------- + +/// `webPrefsAllowed` : clés à valeurs fermées. +fn allowed(key: &str) -> Option<&'static [&'static str]> { + Some(match key { + "theme" => &["light", "dark"], + "hide_reasoning" | "hide_tools" | "fold_tools" | "hide_side" | "hide_stats" => &["0", "1"], + _ => return None, + }) +} + +/// `strings.TrimSpace` (espaces Unicode). +fn go_trim(s: &str) -> &str { + s.trim_matches(char::is_whitespace) +} + +/// `webPrefsText` : nettoyage des préférences libres ; `None` : refusée. +fn clean_text(key: &str, v: &str) -> Option> { + match key { + "user_name" => { + let v: String = v + .chars() + .filter(|&c| c >= '\u{20}' && c != '\u{7f}') + .collect(); + let mut v = go_trim(&v).to_string(); + if v.chars().count() > 24 { + v = go_trim(&v.chars().take(24).collect::()).to_string(); + } + Some(Some(v)) + } + "avatar_user" | "avatar_ai" => { + let v = go_trim(v); + if v.is_empty() || AVATARS.contains(&v) { + Some(Some(v.to_string())) + } else { + Some(None) + } + } + _ => None, + } +} + +/// `saveWebPrefs` : fusionne les valeurs valides de `req` dans `prefs`. +pub fn merge_prefs(prefs: &mut BTreeMap, req: &Value) { + let Some(obj) = req.as_object() else { + return; + }; + for (k, v) in obj { + // map[string]string du Go : une valeur non textuelle est sautée. + let Some(v) = v.as_str() else { continue }; + if let Some(res) = clean_text(k, v) { + if let Some(val) = res { + prefs.insert(k.clone(), val); + } + continue; + } + let Some(ok) = allowed(k) else { continue }; + let v = go_trim(v).to_lowercase(); + if ok.contains(&v.as_str()) { + prefs.insert(k.clone(), v); + } + } +} + +// --------------------------------------------------------------------------- +// Adresse publique +// --------------------------------------------------------------------------- + +/// Caractères refusés dans un nom d'hôte par `url.Parse` (`InvalidHostError`). +fn host_char_ok(c: char) -> bool { + !c.is_ascii() || c.is_ascii_alphanumeric() || "-_.~!$&'()*+,;=:[]<>\"%".contains(c) +} + +/// `url.Parse` du Go, réduit à ce que l'adresse publique examine. +struct GoUrl { + scheme: String, + user: Option, + host: String, + path: String, + query: String, + fragment: String, +} + +fn go_url_parse(raw: &str) -> Option { + let (rest, fragment) = match raw.split_once('#') { + Some((a, b)) => (a, b.to_string()), + None => (raw, String::new()), + }; + let (scheme, rest) = rest.split_once("://")?; + let mut sc = scheme.chars(); + if !sc.next().is_some_and(|c| c.is_ascii_alphabetic()) + || !sc.all(|c| c.is_ascii_alphanumeric() || "+-.".contains(c)) + { + return None; + } + let (rest, query) = match rest.split_once('?') { + Some((a, b)) => (a, b.to_string()), + None => (rest, String::new()), + }; + let (authority, path) = match rest.find('/') { + Some(i) => (&rest[..i], &rest[i..]), + None => (rest, ""), + }; + let (user, host) = match authority.rfind('@') { + Some(i) => { + let info = &authority[..i]; + let name = info.split(':').next().unwrap_or(""); + (Some(percent_decode(name)?), &authority[i + 1..]) + } + None => (None, authority), + }; + // Port : des chiffres seulement après le dernier « : » (hors crochets). + if let Some(stripped) = host.strip_prefix('[') { + let end = stripped.find(']')?; + let after = &stripped[end + 1..]; + if !after.is_empty() + && !(after.starts_with(':') && after[1..].bytes().all(|b| b.is_ascii_digit())) + { + return None; + } + } else if let Some(i) = host.rfind(':') + && !host[i + 1..].bytes().all(|b| b.is_ascii_digit()) + { + return None; + } + if !host.chars().all(host_char_ok) { + return None; + } + Some(GoUrl { + scheme: scheme.to_string(), + user, + host: host.to_string(), + path: percent_decode(path)?, + query, + fragment, + }) +} + +fn percent_decode(s: &str) -> Option { + let b = s.as_bytes(); + let mut out = Vec::with_capacity(b.len()); + let mut i = 0; + while i < b.len() { + if b[i] == b'%' { + let h = std::str::from_utf8(b.get(i + 1..i + 3)?).ok()?; + out.push(u8::from_str_radix(h, 16).ok()?); + i += 3; + } else { + out.push(b[i]); + i += 1; + } + } + String::from_utf8(out).ok() +} + +/// `net.SplitHostPort`, réduit : (hôte, port) ou rien. +fn split_host_port(host: &str) -> Option<(&str, &str)> { + if let Some(rest) = host.strip_prefix('[') { + let end = rest.find(']')?; + let after = &rest[end + 1..]; + return after.strip_prefix(':').map(|p| (&rest[..end], p)); + } + let i = host.rfind(':')?; + let h = &host[..i]; + if h.contains(':') { + return None; // trop de « : » + } + Some((h, &host[i + 1..])) +} + +/// `normalizePublicURL` : « schéma://hôte[:port] », sans slash final. +pub fn normalize_public_url(raw: &str) -> Result { + let raw = go_trim(raw); + if raw.is_empty() { + return Ok(String::new()); + } + let raw = if raw.contains("://") { + raw.to_string() + } else { + format!("https://{raw}") + }; + let u = go_url_parse(&raw).ok_or_else(|| format!("adresse illisible : {raw}"))?; + let scheme = u.scheme.to_lowercase(); + if scheme != "http" && scheme != "https" { + return Err(format!( + "adresse en {scheme}:// — seuls http:// et https:// sont acceptés" + )); + } + if u.host.is_empty() { + return Err("adresse sans nom de machine".into()); + } + if let Some(name) = u.user { + return Err(format!( + "l'adresse ne doit pas porter d'identifiants ({name}@…)" + )); + } + if !u.query.is_empty() || !u.fragment.is_empty() { + return Err("l'adresse ne doit porter ni paramètre ni ancre".into()); + } + match u.path.strip_suffix('/').unwrap_or(&u.path) { + "" | "/v1" => {} + _ => { + return Err(format!( + "l'adresse ne doit pas contenir de chemin ({}) : Loki sert /v1 à la racine de son port", + u.path + )); + } + } + match split_host_port(&u.host) { + Some((h, port)) => { + if h.is_empty() { + return Err("adresse sans nom de machine".into()); + } + if !port.parse::().is_ok_and(|n| n > 0 && n <= 65535) { + return Err(format!("port invalide : {port}")); + } + } + None if u.host.contains(':') && !u.host.starts_with('[') => { + return Err(format!( + "adresse IPv6 à écrire entre crochets : [{}]", + u.host + )); + } + None => {} + } + Ok(format!("{scheme}://{}", u.host)) +} + +// --------------------------------------------------------------------------- +// Routes +// --------------------------------------------------------------------------- + +/// Sérialise les écritures de préférences (lecture, fusion, écriture). +static PREFS_LOCK: tokio::sync::Mutex<()> = tokio::sync::Mutex::const_new(()); + +async fn load_prefs(ctx: &Ctx) -> Result, String> { + Ok(ctx.store.all(BK_PREFS).await?.into_iter().collect()) +} + +/// `putStr` : une chaîne vide efface la clé. +async fn put_str(ctx: &Ctx, bucket: &str, key: &str, val: &str) -> Result<(), String> { + if val.is_empty() { + ctx.store.del(bucket, key).await + } else { + ctx.store.put(bucket, key, val.as_bytes().to_vec()).await + } +} + +/// Erreur de type d'`encoding/json` pour un champ texte attendu. +fn go_type_error(field: &str, v: &Value) -> Option { + let kind = match v { + Value::Null | Value::String(_) => return None, + Value::Bool(_) => "bool", + Value::Number(_) => "number", + Value::Array(_) => "array", + Value::Object(_) => "object", + }; + Some(format!( + "json: cannot unmarshal {kind} into Go struct field .{field} of type string" + )) +} + +async fn active_preset(ctx: &Ctx) -> String { + let cfg: HashMap = ctx.store.config().await; + let order = presets::parse_preset_order( + ctx.store + .get(BK_STATE, presets::PRESET_ORDER_KEY) + .await + .ok() + .flatten() + .as_deref(), + ); + let home = ctx.engine.home.clone(); + tokio::task::spawn_blocking(move || presets::active_preset_id(&home, &cfg, &order)) + .await + .unwrap_or_default() +} + +/// `readSysPrompt` : celui du preset actif, sinon le global. +async fn read_sysprompt(ctx: &Ctx) -> String { + let id = active_preset(ctx).await; + if !id.is_empty() { + let s = ctx + .store + .get_str(BK_STATE, &format!("{SYSPROMPT_PREFIX}{id}")) + .await; + if !go_trim(&s).is_empty() { + return go_trim(&s).to_string(); + } + } + ctx.store.get_str(BK_STATE, SYSPROMPT_GLOBAL).await +} + +fn fail(code: StatusCode, e: impl Into) -> Response { + send_json(code, &json!({ "ok": false, "error": e.into() })) +} + +/// `from_go` : remise par le Go (tunnel), déjà authentifiée par lui. +pub async fn serve(ctx: &Ctx, req: Request, from_go: bool) -> Response { + let (parts, body) = req.into_parts(); + if !from_go && let Err(refus) = ctx.guard(&parts.headers).await { + return *refus; + } + let post = parts.method == Method::POST; + match parts.uri.path() { + "/api/prefs" if post => { + let req = body_json(body).await.unwrap_or(Value::Null); + let _guard = PREFS_LOCK.lock().await; + let old = match load_prefs(ctx).await { + Ok(p) => p, + Err(e) => return fail(StatusCode::INTERNAL_SERVER_ERROR, e), + }; + let mut prefs = old.clone(); + merge_prefs(&mut prefs, &req); + // replaceKV : les valeurs vides ne sont pas gardées. + for (k, v) in &prefs { + let res = if v.is_empty() { + if old.contains_key(k) { + ctx.store.del(BK_PREFS, k).await + } else { + Ok(()) + } + } else if old.get(k) != Some(v) { + ctx.store.put(BK_PREFS, k, v.as_bytes().to_vec()).await + } else { + Ok(()) + }; + if let Err(e) = res { + return fail(StatusCode::INTERNAL_SERVER_ERROR, e); + } + } + send_json(StatusCode::OK, &json!({ "ok": true, "prefs": prefs })) + } + "/api/prefs" => match load_prefs(ctx).await { + Ok(p) => send_json( + StatusCode::OK, + &json!({ "ok": true, "prefs": p, "avatars": AVATARS }), + ), + Err(_) => send_json( + StatusCode::OK, + &json!({ "ok": true, "prefs": {}, "avatars": AVATARS }), + ), + }, + "/api/sysprompt" => match parts.method { + Method::GET => send_json( + StatusCode::OK, + &json!({ "ok": true, "text": read_sysprompt(ctx).await }), + ), + Method::POST => { + let v = match body_json(body).await { + Ok(v) => v, + Err(e) => return fail(StatusCode::BAD_REQUEST, e), + }; + let raw = v.get("text").cloned().unwrap_or(Value::Null); + if let Some(e) = go_type_error("text", &raw) { + return fail(StatusCode::BAD_REQUEST, e); + } + let text = go_trim(raw.as_str().unwrap_or("")).to_string(); + let id = active_preset(ctx).await; + let key = if id.is_empty() { + SYSPROMPT_GLOBAL.to_string() + } else { + format!("{SYSPROMPT_PREFIX}{id}") + }; + if let Err(e) = put_str(ctx, BK_STATE, &key, &text).await { + return fail(StatusCode::INTERNAL_SERVER_ERROR, e); + } + send_json( + StatusCode::OK, + &json!({ "ok": true, "text": read_sysprompt(ctx).await }), + ) + } + _ => fail(StatusCode::METHOD_NOT_ALLOWED, "méthode non autorisée"), + }, + "/api/reasoning-effort" => { + if post { + let v = body_json(body).await.unwrap_or(Value::Null); + let v = + go_trim(v.get("value").and_then(Value::as_str).unwrap_or("")).to_lowercase(); + if !v.is_empty() && !REASONING_EFFORTS.contains(&v.as_str()) { + return fail(StatusCode::BAD_REQUEST, format!("valeur inconnue : {v}")); + } + if let Err(e) = put_str(ctx, BK_CONFIG, "REASONING_EFFORT", &v).await { + return fail(StatusCode::INTERNAL_SERVER_ERROR, e); + } + } + let cfg = ctx.store.config().await; + let get = |k: &str| cfg.get(k).map(String::as_str).unwrap_or(""); + send_json( + StatusCode::OK, + &json!({ + "ok": true, + "value": reasoning_effort_value(get("REASONING_EFFORT")), + "on": reasoning_active(get("REASONING")), + "choices": REASONING_EFFORTS, + }), + ) + } + "/api/oai/public" if post => { + let v = match body_json(body).await { + Ok(Value::Object(o)) => o, + _ => return fail(StatusCode::BAD_REQUEST, "requête illisible"), + }; + let raw = v.get("url").cloned().unwrap_or(Value::Null); + if go_type_error("url", &raw).is_some() { + return fail(StatusCode::BAD_REQUEST, "requête illisible"); + } + let clean = match normalize_public_url(raw.as_str().unwrap_or("")) { + Ok(c) => c, + Err(e) => return fail(StatusCode::BAD_REQUEST, e), + }; + if let Err(e) = put_str(ctx, BK_STATE, PUBLIC_URL_KEY, &clean).await { + return fail(StatusCode::BAD_REQUEST, e); + } + send_json( + StatusCode::OK, + &json!({ "ok": true, "url": clean, "set": !clean.is_empty() }), + ) + } + "/api/oai/public" => { + let u = ctx.store.get_str(BK_STATE, PUBLIC_URL_KEY).await; + send_json( + StatusCode::OK, + &json!({ "ok": true, "url": u, "set": !u.is_empty() }), + ) + } + _ => send_json( + StatusCode::NOT_FOUND, + &json!({ "error": "404 page not found" }), + ), + } +} diff --git a/rust/loki-rs/tests/common/fakego.rs b/rust/loki-rs/tests/common/fakego.rs index bbddc8f..b45e3ca 100644 --- a/rust/loki-rs/tests/common/fakego.rs +++ b/rust/loki-rs/tests/common/fakego.rs @@ -465,6 +465,7 @@ pub struct Opts { pub engine_port: Option, pub models_hf: Option, pub dictate: Option>, + pub settings: bool, } /// Comme [`start`], avec la conversation tenue par la façade @@ -556,6 +557,7 @@ pub async fn start_with( }) }), dictate: opts.dictate, + settings: opts.settings, }; tokio::spawn(loki_rs::proxy::serve(fl, cfg, async move { let _ = rx.await; diff --git a/rust/loki-rs/tests/lot2.rs b/rust/loki-rs/tests/lot2.rs index 2c17ae7..c3810f9 100644 --- a/rust/loki-rs/tests/lot2.rs +++ b/rust/loki-rs/tests/lot2.rs @@ -96,6 +96,7 @@ async fn banc(cle: Option<&str>) -> Banc { chat: None, models: None, dictate: None, + settings: false, }; tokio::spawn(loki_rs::proxy::serve(l, cfg, async move { let _ = rx.await; diff --git a/rust/loki-rs/tests/parity_settings/settings.json b/rust/loki-rs/tests/parity_settings/settings.json new file mode 100644 index 0000000..ec65cae --- /dev/null +++ b/rust/loki-rs/tests/parity_settings/settings.json @@ -0,0 +1,731 @@ +{ + "public_url": [ + { + "in": "" + }, + { + "in": " " + }, + { + "in": "exemple.fr", + "out": "https://exemple.fr" + }, + { + "in": "https://ia.exemple.fr/", + "out": "https://ia.exemple.fr" + }, + { + "in": "http://10.0.0.5:8090/v1/", + "out": "http://10.0.0.5:8090" + }, + { + "in": "HTTPS://Exemple.FR:443", + "out": "https://Exemple.FR:443" + }, + { + "in": "ftp://x.fr", + "err": "adresse en ftp:// — seuls http:// et https:// sont acceptés" + }, + { + "in": "https://", + "err": "adresse sans nom de machine" + }, + { + "in": "https://moi:secret@x.fr", + "err": "l'adresse ne doit pas porter d'identifiants (moi@…)" + }, + { + "in": "https://x.fr/?a=1", + "err": "l'adresse ne doit porter ni paramètre ni ancre" + }, + { + "in": "https://x.fr/#ancre", + "err": "l'adresse ne doit porter ni paramètre ni ancre" + }, + { + "in": "https://x.fr/api", + "err": "l'adresse ne doit pas contenir de chemin (/api) : Loki sert /v1 à la racine de son port" + }, + { + "in": "https://x.fr:0", + "err": "port invalide : 0" + }, + { + "in": "https://x.fr:99999", + "err": "port invalide : 99999" + }, + { + "in": "https://x.fr:abc", + "err": "adresse illisible : https://x.fr:abc" + }, + { + "in": "https://[::1]:8090", + "out": "https://[::1]:8090" + }, + { + "in": "https://::1", + "err": "adresse IPv6 à écrire entre crochets : [::1]" + }, + { + "in": "https://:8080", + "err": "adresse sans nom de machine" + }, + { + "in": "exemple.fr:8090/v1", + "out": "https://exemple.fr:8090" + } + ], + "effort_value": { + "": "", + " High ": "high", + "0": "none", + "Default": "", + "NONE": "none", + "auto": "", + "bizarre": "bizarre", + "low": "low", + "medium": "medium", + "off": "none", + "xhigh": "xhigh" + }, + "steps": [ + { + "method": "GET", + "path": "/api/prefs", + "status": 200, + "out": { + "avatars": [ + "🙂", + "😎", + "🧑", + "👤", + "🦊", + "🐱", + "🐼", + "🦉", + "🚀", + "⚡", + "🌙", + "🔥", + "🌿", + "🎯", + "🧠", + "🤖" + ], + "ok": true, + "prefs": {} + } + }, + { + "method": "POST", + "path": "/api/prefs", + "body": "{\"theme\":\" DARK \",\"hide_tools\":\"1\",\"hide_stats\":\"2\",\"inconnue\":\"x\",\"user_name\":\" Ma\\u0007rie \",\"avatar_user\":\"🦊\",\"avatar_ai\":\"💩\"}", + "status": 200, + "out": { + "ok": true, + "prefs": { + "avatar_user": "🦊", + "hide_tools": "1", + "theme": "dark", + "user_name": "Marie" + } + }, + "prefs": { + "avatar_user": "🦊", + "hide_tools": "1", + "theme": "dark", + "user_name": "Marie" + } + }, + { + "method": "POST", + "path": "/api/prefs", + "body": "{\"user_name\":\"Une très très longue signature éèà\",\"fold_tools\":1,\"hide_side\":\"0\"}", + "status": 200, + "out": { + "ok": true, + "prefs": { + "avatar_user": "🦊", + "hide_side": "0", + "hide_tools": "1", + "theme": "dark", + "user_name": "Une très très longue sig" + } + }, + "prefs": { + "avatar_user": "🦊", + "hide_side": "0", + "hide_tools": "1", + "theme": "dark", + "user_name": "Une très très longue sig" + } + }, + { + "method": "POST", + "path": "/api/prefs", + "body": "pas du json", + "status": 200, + "out": { + "ok": true, + "prefs": { + "avatar_user": "🦊", + "hide_side": "0", + "hide_tools": "1", + "theme": "dark", + "user_name": "Une très très longue sig" + } + }, + "prefs": { + "avatar_user": "🦊", + "hide_side": "0", + "hide_tools": "1", + "theme": "dark", + "user_name": "Une très très longue sig" + } + }, + { + "method": "POST", + "path": "/api/prefs", + "body": "{\"user_name\":\" \",\"avatar_user\":\"\"}", + "status": 200, + "out": { + "ok": true, + "prefs": { + "avatar_user": "", + "hide_side": "0", + "hide_tools": "1", + "theme": "dark", + "user_name": "" + } + }, + "prefs": { + "hide_side": "0", + "hide_tools": "1", + "theme": "dark" + } + }, + { + "method": "GET", + "path": "/api/prefs", + "status": 200, + "out": { + "avatars": [ + "🙂", + "😎", + "🧑", + "👤", + "🦊", + "🐱", + "🐼", + "🦉", + "🚀", + "⚡", + "🌙", + "🔥", + "🌿", + "🎯", + "🧠", + "🤖" + ], + "ok": true, + "prefs": { + "hide_side": "0", + "hide_tools": "1", + "theme": "dark" + } + }, + "prefs": { + "hide_side": "0", + "hide_tools": "1", + "theme": "dark" + } + }, + { + "method": "GET", + "path": "/api/sysprompt", + "status": 200, + "out": { + "ok": true, + "text": "" + }, + "prefs": { + "hide_side": "0", + "hide_tools": "1", + "theme": "dark" + } + }, + { + "method": "POST", + "path": "/api/sysprompt", + "body": "{\"text\":\" Réponds en français. \"}", + "status": 200, + "out": { + "ok": true, + "text": "Réponds en français." + }, + "prefs": { + "hide_side": "0", + "hide_tools": "1", + "theme": "dark" + }, + "state": { + "sysprompt": "Réponds en français." + } + }, + { + "method": "POST", + "path": "/api/sysprompt", + "body": "{\"text\":5}", + "status": 400, + "out": { + "error": "json: cannot unmarshal number into Go struct field .text of type string", + "ok": false + }, + "prefs": { + "hide_side": "0", + "hide_tools": "1", + "theme": "dark" + }, + "state": { + "sysprompt": "Réponds en français." + } + }, + { + "method": "PUT", + "path": "/api/sysprompt", + "status": 405, + "out": { + "error": "méthode non autorisée", + "ok": false + }, + "prefs": { + "hide_side": "0", + "hide_tools": "1", + "theme": "dark" + }, + "state": { + "sysprompt": "Réponds en français." + } + }, + { + "config": { + "CTX": "8192", + "MODEL": "a.gguf" + }, + "preset": { + "alpha": "# Alpha\nMODEL=a.gguf\nCTX=8192\n", + "beta": "# Beta\nMODEL=b.gguf\n" + }, + "method": "GET", + "path": "/api/sysprompt", + "status": 200, + "out": { + "ok": true, + "text": "Réponds en français." + }, + "prefs": { + "hide_side": "0", + "hide_tools": "1", + "theme": "dark" + }, + "state": { + "sysprompt": "Réponds en français." + } + }, + { + "method": "POST", + "path": "/api/sysprompt", + "body": "{\"text\":\"Prompt d'Alpha\"}", + "status": 200, + "out": { + "ok": true, + "text": "Prompt d'Alpha" + }, + "prefs": { + "hide_side": "0", + "hide_tools": "1", + "theme": "dark" + }, + "state": { + "sysprompt": "Réponds en français.", + "sysprompt:alpha": "Prompt d'Alpha" + } + }, + { + "method": "GET", + "path": "/api/sysprompt", + "status": 200, + "out": { + "ok": true, + "text": "Prompt d'Alpha" + }, + "prefs": { + "hide_side": "0", + "hide_tools": "1", + "theme": "dark" + }, + "state": { + "sysprompt": "Réponds en français.", + "sysprompt:alpha": "Prompt d'Alpha" + } + }, + { + "config": { + "MODEL": "b.gguf" + }, + "method": "GET", + "path": "/api/sysprompt", + "status": 200, + "out": { + "ok": true, + "text": "Réponds en français." + }, + "prefs": { + "hide_side": "0", + "hide_tools": "1", + "theme": "dark" + }, + "state": { + "sysprompt": "Réponds en français.", + "sysprompt:alpha": "Prompt d'Alpha" + } + }, + { + "method": "POST", + "path": "/api/sysprompt", + "body": "{\"text\":\"\"}", + "status": 200, + "out": { + "ok": true, + "text": "Réponds en français." + }, + "prefs": { + "hide_side": "0", + "hide_tools": "1", + "theme": "dark" + }, + "state": { + "sysprompt": "Réponds en français.", + "sysprompt:alpha": "Prompt d'Alpha" + } + }, + { + "config": { + "CTX": "8192", + "MODEL": "a.gguf" + }, + "method": "POST", + "path": "/api/sysprompt", + "body": "{\"text\":\" \"}", + "status": 200, + "out": { + "ok": true, + "text": "Réponds en français." + }, + "prefs": { + "hide_side": "0", + "hide_tools": "1", + "theme": "dark" + }, + "state": { + "sysprompt": "Réponds en français." + } + }, + { + "method": "GET", + "path": "/api/sysprompt", + "status": 200, + "out": { + "ok": true, + "text": "Réponds en français." + }, + "prefs": { + "hide_side": "0", + "hide_tools": "1", + "theme": "dark" + }, + "state": { + "sysprompt": "Réponds en français." + } + }, + { + "config": { + "MODEL": "a.gguf" + }, + "method": "GET", + "path": "/api/reasoning-effort", + "status": 200, + "out": { + "choices": [ + "none", + "low", + "medium", + "high", + "xhigh" + ], + "ok": true, + "on": false, + "value": "" + }, + "prefs": { + "hide_side": "0", + "hide_tools": "1", + "theme": "dark" + }, + "state": { + "sysprompt": "Réponds en français." + } + }, + { + "method": "POST", + "path": "/api/reasoning-effort", + "body": "{\"value\":\" HIGH \"}", + "status": 200, + "out": { + "choices": [ + "none", + "low", + "medium", + "high", + "xhigh" + ], + "ok": true, + "on": false, + "value": "high" + }, + "prefs": { + "hide_side": "0", + "hide_tools": "1", + "theme": "dark" + }, + "state": { + "config:REASONING_EFFORT": "high", + "sysprompt": "Réponds en français." + } + }, + { + "method": "POST", + "path": "/api/reasoning-effort", + "body": "{\"value\":\"auto\"}", + "status": 400, + "out": { + "error": "valeur inconnue : auto", + "ok": false + }, + "prefs": { + "hide_side": "0", + "hide_tools": "1", + "theme": "dark" + }, + "state": { + "config:REASONING_EFFORT": "high", + "sysprompt": "Réponds en français." + } + }, + { + "config": { + "MODEL": "a.gguf", + "REASONING": "on", + "REASONING_EFFORT": "off" + }, + "method": "GET", + "path": "/api/reasoning-effort", + "status": 200, + "out": { + "choices": [ + "none", + "low", + "medium", + "high", + "xhigh" + ], + "ok": true, + "on": true, + "value": "none" + }, + "prefs": { + "hide_side": "0", + "hide_tools": "1", + "theme": "dark" + }, + "state": { + "config:REASONING_EFFORT": "off", + "sysprompt": "Réponds en français." + } + }, + { + "method": "POST", + "path": "/api/reasoning-effort", + "body": "{\"value\":\"\"}", + "status": 200, + "out": { + "choices": [ + "none", + "low", + "medium", + "high", + "xhigh" + ], + "ok": true, + "on": true, + "value": "" + }, + "prefs": { + "hide_side": "0", + "hide_tools": "1", + "theme": "dark" + }, + "state": { + "sysprompt": "Réponds en français." + } + }, + { + "config": { + "REASONING": "off", + "REASONING_EFFORT": "bizarre" + }, + "method": "GET", + "path": "/api/reasoning-effort", + "status": 200, + "out": { + "choices": [ + "none", + "low", + "medium", + "high", + "xhigh" + ], + "ok": true, + "on": false, + "value": "bizarre" + }, + "prefs": { + "hide_side": "0", + "hide_tools": "1", + "theme": "dark" + }, + "state": { + "config:REASONING_EFFORT": "bizarre", + "sysprompt": "Réponds en français." + } + }, + { + "method": "GET", + "path": "/api/oai/public", + "status": 200, + "out": { + "ok": true, + "set": false, + "url": "" + }, + "prefs": { + "hide_side": "0", + "hide_tools": "1", + "theme": "dark" + }, + "state": { + "config:REASONING_EFFORT": "bizarre", + "sysprompt": "Réponds en français." + } + }, + { + "method": "POST", + "path": "/api/oai/public", + "body": "{\"url\":\"ftp://x\"}", + "status": 400, + "out": { + "error": "adresse en ftp:// — seuls http:// et https:// sont acceptés", + "ok": false + }, + "prefs": { + "hide_side": "0", + "hide_tools": "1", + "theme": "dark" + }, + "state": { + "config:REASONING_EFFORT": "bizarre", + "sysprompt": "Réponds en français." + } + }, + { + "method": "POST", + "path": "/api/oai/public", + "body": "pas du json", + "status": 400, + "out": { + "error": "requête illisible", + "ok": false + }, + "prefs": { + "hide_side": "0", + "hide_tools": "1", + "theme": "dark" + }, + "state": { + "config:REASONING_EFFORT": "bizarre", + "sysprompt": "Réponds en français." + } + }, + { + "method": "POST", + "path": "/api/oai/public", + "body": "{\"url\":\" ia.exemple.fr/v1 \"}", + "status": 200, + "out": { + "ok": true, + "set": true, + "url": "https://ia.exemple.fr" + }, + "prefs": { + "hide_side": "0", + "hide_tools": "1", + "theme": "dark" + }, + "state": { + "config:REASONING_EFFORT": "bizarre", + "public_url": "https://ia.exemple.fr", + "sysprompt": "Réponds en français." + } + }, + { + "method": "GET", + "path": "/api/oai/public", + "status": 200, + "out": { + "ok": true, + "set": true, + "url": "https://ia.exemple.fr" + }, + "prefs": { + "hide_side": "0", + "hide_tools": "1", + "theme": "dark" + }, + "state": { + "config:REASONING_EFFORT": "bizarre", + "public_url": "https://ia.exemple.fr", + "sysprompt": "Réponds en français." + } + }, + { + "method": "POST", + "path": "/api/oai/public", + "body": "{\"url\":\"\"}", + "status": 200, + "out": { + "ok": true, + "set": false, + "url": "" + }, + "prefs": { + "hide_side": "0", + "hide_tools": "1", + "theme": "dark" + }, + "state": { + "config:REASONING_EFFORT": "bizarre", + "sysprompt": "Réponds en français." + } + } + ] +} diff --git a/rust/loki-rs/tests/proxy.rs b/rust/loki-rs/tests/proxy.rs index 58b0b98..02409c1 100644 --- a/rust/loki-rs/tests/proxy.rs +++ b/rust/loki-rs/tests/proxy.rs @@ -102,6 +102,7 @@ async fn facade(backend: SocketAddr) -> (SocketAddr, oneshot::Sender<()>) { chat: None, models: None, dictate: None, + settings: false, }; tokio::spawn(loki_rs::proxy::serve(l, cfg, async move { let _ = rx.await; diff --git a/rust/loki-rs/tests/settings.rs b/rust/loki-rs/tests/settings.rs new file mode 100644 index 0000000..eaa73de --- /dev/null +++ b/rust/loki-rs/tests/settings.rs @@ -0,0 +1,146 @@ +//! Lot 10 : réglages partagés tenus par la façade (`LOKI_SETTINGS=rust`). +//! +//! Le journal tiré du Go (`internal/loki/parity_settings_test.go` → +//! `tests/parity_settings/settings.json`) est rejoué : normalisation de +//! l'adresse publique, intensité du raisonnement, puis 29 appels de route +//! par la façade, avec le contenu de la base après chacun. + +mod common; + +use std::collections::BTreeMap; + +use common::fakego; +use loki_rs::settings; +use serde_json::Value; + +fn fixture() -> Value { + serde_json::from_str(include_str!("parity_settings/settings.json")).expect("journal") +} + +#[test] +fn parite_des_fonctions_pures() { + let fx = fixture(); + for c in fx["public_url"].as_array().unwrap() { + let input = c["in"].as_str().unwrap(); + match settings::normalize_public_url(input) { + Ok(o) => { + assert!( + c.get("err").is_none(), + "{input:?} : erreur attendue {}", + c["err"] + ); + assert_eq!(o, c["out"].as_str().unwrap_or(""), "{input:?}"); + } + Err(e) => assert_eq!(e, c["err"].as_str().unwrap_or(""), "{input:?}"), + } + } + for (v, want) in fx["effort_value"].as_object().unwrap() { + assert_eq!( + loki_rs::chat::wire::reasoning_effort_value(v), + want.as_str().unwrap(), + "{v:?}" + ); + } +} + +#[tokio::test] +async fn parite_des_routes() { + let fx = fixture(); + let tmp = tempfile::tempdir().unwrap(); + let home = tmp.path().to_path_buf(); + let opts = fakego::Opts { + settings: true, + ..Default::default() + }; + let (facade, go, _stop, _) = fakego::start_with(home.clone(), opts).await; + let keys = [ + "sysprompt", + "sysprompt:alpha", + "sysprompt:beta", + "public_url", + ]; + for (i, st) in fx["steps"].as_array().unwrap().iter().enumerate() { + if let Some(cfg) = st.get("config").and_then(Value::as_object) { + let mut s = go.store.lock().unwrap(); + let b = s.entry("config".into()).or_default(); + b.clear(); + for (k, v) in cfg { + b.insert(k.clone(), v.as_str().unwrap().as_bytes().to_vec()); + } + } + if let Some(p) = st.get("preset").and_then(Value::as_object) { + std::fs::create_dir_all(home.join("presets")).unwrap(); + for (id, content) in p { + std::fs::write( + home.join("presets").join(format!("{id}.env")), + content.as_str().unwrap(), + ) + .unwrap(); + } + } + let (m, p) = (st["method"].as_str().unwrap(), st["path"].as_str().unwrap()); + let body = st.get("body").and_then(Value::as_str).unwrap_or(""); + let (code, b) = fakego::send(facade, m, p, &[], body).await; + let ctx = format!("étape {i} : {m} {p} {body}"); + assert_eq!( + code.as_u16() as u64, + st["status"].as_u64().unwrap(), + "{ctx}" + ); + let got: Value = serde_json::from_slice(&b).unwrap(); + assert_eq!(got, st["out"], "{ctx}"); + + let prefs: BTreeMap = go + .store + .lock() + .unwrap() + .get("prefs") + .map(|b| { + b.iter() + .map(|(k, v)| (k.clone(), String::from_utf8_lossy(v).into_owned())) + .collect() + }) + .unwrap_or_default(); + let want_prefs: BTreeMap = serde_json::from_value( + st.get("prefs") + .cloned() + .unwrap_or(Value::Object(Default::default())), + ) + .unwrap(); + assert_eq!(prefs, want_prefs, "{ctx} (préférences en base)"); + let mut state = BTreeMap::new(); + for k in keys { + if let Some(v) = go.get("state", k).filter(|v| !v.is_empty()) { + state.insert(k.to_string(), v); + } + } + if let Some(v) = go + .get("config", "REASONING_EFFORT") + .filter(|v| !v.is_empty()) + { + state.insert("config:REASONING_EFFORT".into(), v); + } + let want_state: BTreeMap = serde_json::from_value( + st.get("state") + .cloned() + .unwrap_or(Value::Object(Default::default())), + ) + .unwrap(); + assert_eq!(state, want_state, "{ctx} (base)"); + } +} + +#[tokio::test] +async fn sans_la_variable_les_reglages_restent_au_go() { + let tmp = tempfile::tempdir().unwrap(); + let (facade, _go, _stop) = fakego::start(tmp.path().to_path_buf()).await; + for p in [ + "/api/prefs", + "/api/sysprompt", + "/api/reasoning-effort", + "/api/oai/public", + ] { + let (_, _, b) = fakego::get(facade, p, &[]).await; + assert_eq!(&b[..], format!("go:{p}").as_bytes()); + } +} From 8bba80dee893ac613c1cf6ecfd325536acd0563a Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 15 Oct 2026 16:45:17 +0000 Subject: [PATCH 06/52] =?UTF-8?q?Lot=2011=20:=20fichiers=20de=20la=20discu?= =?UTF-8?q?ssion=20tenus=20par=20la=20fa=C3=A7ade=20(LOKI=5FFILES=3Drust)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit La façade sert le dépôt des pièces jointes (morceaux base64, 1 Gio au plus, espace disque vérifié), le panneau Fichiers, la suppression, le téléchargement (fiche, tranches base64 pour le tunnel E2E, brut avec plage d'octets), l'affichage des images (type reniflé, CSP fermée, vignette redressée et réduite par moyennage) et l'archive .zip, quand LOKI_FILES=rust (expérimental ; le Go reste le défaut). Côté Go : la nouvelle route /internal/v1/conv/workspace donne le dossier de la discussion active (le Go la connaît toujours), facade_files.go remet ces routes à la façade quand elles arrivent par le tunnel, et les .part d'envois ne sont plus vidés par le Go au démarrage. Parité : parity_files_test.go tire du Go les noms, slugs, en-têtes EXIF, orientations et réductions (pixel pour pixel), reniflages, puis 42 appels de route sur une arborescence fixe, rejoués par tests/convfiles.rs. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_016tcGfJWefW5kUGoC2wL8cv --- README.md | 1 + ...15-rust-migration-lot11-fichiers-design.md | 63 + internal/loki/facade_chat.go | 5 +- internal/loki/facade_chat_test.go | 46 + internal/loki/facade_files.go | 67 + internal/loki/parity_files_test.go | 435 +++++ internal/loki/web_internal.go | 2 + internal/loki/web_server.go | 10 +- rust/Cargo.lock | 176 +- rust/loki-rs/Cargo.toml | 3 + rust/loki-rs/src/api.rs | 8 +- rust/loki-rs/src/convfiles.rs | 1573 +++++++++++++++++ rust/loki-rs/src/lib.rs | 1 + rust/loki-rs/src/main.rs | 42 +- rust/loki-rs/src/proxy.rs | 8 + rust/loki-rs/tests/common/fakego.rs | 8 + rust/loki-rs/tests/convfiles.rs | 334 ++++ rust/loki-rs/tests/lot2.rs | 1 + rust/loki-rs/tests/parity_files/files.json | 975 ++++++++++ rust/loki-rs/tests/proxy.rs | 1 + 20 files changed, 3751 insertions(+), 8 deletions(-) create mode 100644 docs/superpowers/specs/2026-10-15-rust-migration-lot11-fichiers-design.md create mode 100644 internal/loki/facade_files.go create mode 100644 internal/loki/parity_files_test.go create mode 100644 rust/loki-rs/src/convfiles.rs create mode 100644 rust/loki-rs/tests/convfiles.rs create mode 100644 rust/loki-rs/tests/parity_files/files.json diff --git a/README.md b/README.md index a47e4c2..1e7fe6a 100644 --- a/README.md +++ b/README.md @@ -231,6 +231,7 @@ redémarrages (volume `/data`). Variables d'environnement du conteneur : | `LOKI_MODELS` | `rust` : les routes des modèles — liste, dossiers, suppression, téléchargements, catalogue Hugging Face — sont servies par la façade Rust (expérimental, migration en cours) | Go | | `LOKI_DICTATE` | `rust` : la dictée vocale (`/api/transcribe`, `/api/dictate/*`) et son serveur de transcription sont tenus par la façade Rust (expérimental, migration en cours) | Go | | `LOKI_SETTINGS` | `rust` : les réglages partagés — préférences d'affichage, prompt système, intensité du raisonnement, adresse publique — sont servis par la façade Rust (expérimental, migration en cours) | Go | +| `LOKI_FILES` | `rust` : les fichiers de la discussion — dépôt, panneau Fichiers, archive .zip, téléchargement, images et vignettes — sont servis par la façade Rust (expérimental, migration en cours) | Go | **Accès par nom de domaine.** Sans clé de pilotage, l'API n'accepte que les hôtes locaux (IP, `localhost`, nom sans point, `.local`/`.lan`…) et refuse diff --git a/docs/superpowers/specs/2026-10-15-rust-migration-lot11-fichiers-design.md b/docs/superpowers/specs/2026-10-15-rust-migration-lot11-fichiers-design.md new file mode 100644 index 0000000..37fc335 --- /dev/null +++ b/docs/superpowers/specs/2026-10-15-rust-migration-lot11-fichiers-design.md @@ -0,0 +1,63 @@ +# Lot 11 — fichiers de la discussion tenus par la façade + +## Contexte + +Les fichiers rangés par discussion vivaient dans le Go : + +- `web_upload.go` : dépôt, téléchargement, image ; +- `web_files.go` : panneau Fichiers ; +- `web_files_zip.go` : archive ; +- `web_thumb.go` et `web_upload_orient.go` : vignettes ; +- `chat_convfiles.go` : chemins. + +Lot 11 : la façade les sert quand `LOKI_FILES=rust` (`convfiles.rs`, +expérimental ; sans la variable rien ne change). + +| Route | Rôle | +|---|---| +| `POST /api/chat/upload` | dépôt en morceaux base64 (8 Mio), fichier de 1 Gio au plus, espace disque vérifié, `.part` renommé à la fin sous un nom sûr et unique | +| `GET /api/chat/files` | panneau : dossiers d'abord puis du plus récent au plus ancien, tailles récursives (liens non suivis), vue « hors discussion » | +| `POST /api/chat/file/delete` | suppression ; refus de la racine et du dossier des discussions | +| `GET /api/chat/file` | fiche, tranche base64 (tunnel E2E) ou brut en pièce jointe (plage d'octets) | +| `GET /api/chat/image` | type reniflé sur le contenu, en ligne sous une CSP fermée ; vignette JPEG `thumb=` redressée (EXIF) et réduite par moyennage | +| `GET /api/chat/files/zip` | archive construite à la fiche, puis servie en tranches ou en brut | + +Le dossier de la discussion active reste décidé par le Go : la façade le lui +demande à chaque requête par la nouvelle route `/internal/v1/conv/workspace` +(`{dir, root, id, title}`). Il connaît la discussion active, qu'il tienne la +conversation ou que ce soit la façade. Les liens des anciens messages +(`captures//…`, `uploads/…` à la racine) restent résolus comme avant. + +`facade_files.go` remet les routes du tunnel à la façade. Il ne vide plus les +`.part` d'envois au démarrage : la façade le fait au sien. + +## Écarts assumés + +- Octets des vignettes JPEG : l'encodeur diffère. Les dimensions, le + redressement et la réduction (algorithme porté pixel pour pixel) sont les + mêmes. +- Horodatage des entrées `.zip` en UTC. +- Une seule plage d'octets par requête de téléchargement : le Go en acceptait + plusieurs, en multipart. + +## Parité + +`internal/loki/parity_files_test.go` tire du Go : + +- 10 noms de dépôt et 6 slugs ; +- 6 en-têtes EXIF ; +- les 8 orientations d'une image de 5×3 et 4 réductions, pixel pour pixel ; +- 10 reniflages ; +- 42 appels de route sur une arborescence fixe, dates comprises : + - panneau, téléchargement, image et vignette ; + - dépôt en un morceau, en deux morceaux, envoi expiré, fichier vide, + base64 illisible ; + - suppressions ; + - archive de la discussion et « hors discussion », contenu comparé + entrée par entrée ; + - les fichiers restants après chaque POST. + +`tests/convfiles.rs` rejoue le tout par la façade, plus une plage d'octets, +un lien vers l'extérieur (refusé, non compté) et le nom d'archive tiré du +titre. Deux mutations (ordre du panneau, suffixe des doublons) font échouer +le rejeu. diff --git a/internal/loki/facade_chat.go b/internal/loki/facade_chat.go index 4a892c6..411aee0 100644 --- a/internal/loki/facade_chat.go +++ b/internal/loki/facade_chat.go @@ -142,7 +142,8 @@ var facadeChatRoutes = map[string]bool{ // withFacadeChat remet la requête à la façade quand elle tient la // conversation — ou les routes des modèles (facade_models.go), la dictée -// (facade_dictate.go), les réglages partagés (facade_settings.go). +// (facade_dictate.go), les réglages partagés (facade_settings.go), les fichiers +// de la discussion (facade_files.go). // Posé APRÈS requireWebAuth : seule une requête authentifiée ici part, avec le // jeton interne qui en tient lieu là-bas. func withFacadeChat(path string, h http.HandlerFunc) http.HandlerFunc { @@ -155,6 +156,8 @@ func withFacadeChat(path string, h http.HandlerFunc) http.HandlerFunc { held, unavailable = facadeDictate, "dictée indisponible : " case facadeSettingsRoutes[path]: held, unavailable = facadeSettings, "réglages indisponibles : " + case facadeFilesRoutes[path]: + held, unavailable = facadeFiles, "fichiers indisponibles : " default: return h } diff --git a/internal/loki/facade_chat_test.go b/internal/loki/facade_chat_test.go index c55d9de..d45bf30 100644 --- a/internal/loki/facade_chat_test.go +++ b/internal/loki/facade_chat_test.go @@ -438,3 +438,49 @@ func TestFacadeReglages(t *testing.T) { t.Fatalf("relayé sans la variable : %q", w.Body.String()) } } + +// LOKI_FILES=rust : les routes des fichiers de la discussion partent à la +// façade ; /internal/v1/conv/workspace lui donne le dossier de la discussion +// active ; les .part d'envois ne sont plus vidés ici au démarrage. +func TestFacadeFichiers(t *testing.T) { + home := testHome(t) + f := demarrerFacade(t) + t.Setenv(facadeChatEnv, "") + t.Setenv(facadeFilesEnv, "rust") + local := func(w http.ResponseWriter, r *http.Request) { io.WriteString(w, "go") } + for p := range facadeFilesRoutes { + w := httptest.NewRecorder() + withFacadeChat(p, local)(w, httptest.NewRequest("GET", p, nil)) + if w.Body.String() != f.reply { + t.Fatalf("%s : réponse %q", p, w.Body.String()) + } + } + prev := agentWorkspace() + workspacePath = filepath.Join(home, "ws") + t.Cleanup(func() { workspacePath = prev }) + convActiveRemember("c-test") + w := httptest.NewRecorder() + handleInternalConvWorkspace(w, httptest.NewRequest("POST", "/internal/v1/conv/workspace", strings.NewReader("{}"))) + var ws map[string]string + if err := json.Unmarshal(w.Body.Bytes(), &ws); err != nil { + t.Fatal(err) + } + if ws["dir"] != filepath.Join(home, "ws", "discussions", "c-test") || ws["root"] != filepath.Join(home, "ws") || ws["id"] != "c-test" { + t.Fatalf("dossier de la discussion : %v", ws) + } + if st, err := os.Stat(ws["dir"]); err != nil || !st.IsDir() { + t.Fatalf("dossier non créé : %v", err) + } + part := filepath.Join(ws["dir"], "uploads", ".upload-1.part") + _ = os.MkdirAll(filepath.Dir(part), 0o755) + _ = os.WriteFile(part, []byte("x"), 0o644) + cleanStartupUploadParts() + if _, err := os.Stat(part); err != nil { + t.Fatalf(".part effacé alors que la façade tient les fichiers : %v", err) + } + t.Setenv(facadeFilesEnv, "") + cleanStartupUploadParts() + if _, err := os.Stat(part); !os.IsNotExist(err) { + t.Fatalf(".part gardé sans la variable : %v", err) + } +} diff --git a/internal/loki/facade_files.go b/internal/loki/facade_files.go new file mode 100644 index 0000000..4510d5d --- /dev/null +++ b/internal/loki/facade_files.go @@ -0,0 +1,67 @@ +package loki + +import ( + "net/http" + "os" + "strings" +) + +// facade_files.go — fichiers de la discussion tenus par la façade Rust +// (LOKI_FILES=rust, lot 11 de la migration ; expérimental, le Go reste le +// défaut) : dépôt des pièces jointes, panneau Fichiers, archive .zip, +// téléchargement et affichage des fichiers produits par l'agent. +// +// Le dossier de la discussion active reste décidé ICI (convWorkspace : la +// discussion active est connue de ce process, que la conversation soit tenue +// par lui ou par la façade) : la façade le demande à chaque requête par +// /internal/v1/conv/workspace. Les .part d'envois en cours sont les siens : +// ce process ne les vide plus au démarrage (la façade le fait au sien). + +const facadeFilesEnv = "LOKI_FILES" + +// facadeFiles : la façade tient-elle les fichiers de la discussion ? +func facadeFiles() bool { + return strings.EqualFold(strings.TrimSpace(os.Getenv(facadeFilesEnv)), "rust") && + facadeAddr() != "" && internalToken() != "" +} + +// facadeFilesRoutes : les routes que la façade sert alors (convfiles.rs). +var facadeFilesRoutes = map[string]bool{ + "/api/chat/upload": true, + "/api/chat/files": true, + "/api/chat/files/zip": true, + "/api/chat/file/delete": true, + "/api/chat/file": true, + "/api/chat/image": true, +} + +// cleanStartupUploadParts vide les envois interrompus au démarrage — sauf quand +// la façade tient les fichiers : ce sont alors les siens. +func cleanStartupUploadParts() { + if !facadeFiles() { + cleanStaleUploadParts() + } +} + +// handleInternalConvWorkspace : le dossier de la discussion active (créé au +// besoin), la racine du dossier de travail, et l'identité de la discussion +// (nom de l'archive .zip). +// +// POST /internal/v1/conv/workspace {} → {"dir","root","id","title"} +func handleInternalConvWorkspace(w http.ResponseWriter, r *http.Request) { + var in struct{} + if !readInternalJSON(w, r, &in) { + return + } + id := convEnsureActive() + title := "" + for _, m := range convIndex() { + if m.ID == id { + title = m.Title + break + } + } + sendJSON(w, http.StatusOK, map[string]any{ + "dir": convWorkspace(), "root": agentWorkspace(), "id": id, "title": title, + }) +} diff --git a/internal/loki/parity_files_test.go b/internal/loki/parity_files_test.go new file mode 100644 index 0000000..25c2bf6 --- /dev/null +++ b/internal/loki/parity_files_test.go @@ -0,0 +1,435 @@ +package loki + +import ( + "archive/zip" + "bytes" + "encoding/base64" + "encoding/json" + "image" + _ "image/jpeg" + "image/png" + "io" + "net/http" + "net/http/httptest" + "os" + "path/filepath" + "regexp" + "runtime" + "sort" + "strings" + "testing" + "time" +) + +// parity_files_test.go — journal de référence des fichiers de la discussion +// (lot 11 de la migration Rust) : noms de dépôt, slug, orientation EXIF, +// redressement et réduction des pixels, reniflage du type, puis un scénario +// de routes (panneau Fichiers, téléchargement, image, vignette, dépôt en +// morceaux, suppression, archive .zip) sur une arborescence fixe. Rangé dans +// rust/loki-rs/tests/parity_files/files.json ; tests/convfiles.rs le rejoue +// par la façade. LOKI_HOME réécrit en {{HOME}}, la date en {{DATE}}. +// +// LOKI_PARITY_UPDATE=1 go test ./internal/loki -run TestPariteFichiers + +const parityFilesFile = "../../rust/loki-rs/tests/parity_files/files.json" + +type parityPix struct { + W int `json:"w"` + H int `json:"h"` + Pix []byte `json:"pix"` +} + +type parityFileNode struct { + // Contenu (base64) d'un fichier, ou dossier si Dir. + Data []byte `json:"data,omitempty"` + Dir bool `json:"dir,omitempty"` + Mtime int64 `json:"mtime"` +} + +type parityFilesCall struct { + Method string `json:"method"` + URL string `json:"url"` + Body string `json:"body,omitempty"` + Status int `json:"status"` + // JSON (masqué), ou corps brut (base64) et en-têtes choisis. + Out any `json:"out,omitempty"` + Raw []byte `json:"raw,omitempty"` + Headers map[string]string `json:"headers,omitempty"` + // Vignette : dimensions de l'image décodée. + Thumb []int `json:"thumb,omitempty"` + // Archive : entrées (nom → contenu base64). + Zip map[string][]byte `json:"zip,omitempty"` + // Après l'appel : les fichiers de la discussion (chemin relatif → taille). + Tree map[string]int64 `json:"tree,omitempty"` +} + +type parityFilesFixture struct { + UploadName map[string]string `json:"upload_name"` + Slug map[string]string `json:"slug"` + Exif []struct { + In []byte `json:"in"` + Out int `json:"out"` + } `json:"exif"` + Orient []struct { + Orient int `json:"orient"` + Out parityPix `json:"out"` + } `json:"orient"` + OrientIn parityPix `json:"orient_in"` + Scale []struct { + In parityPix `json:"in"` + Max int `json:"max"` + Out parityPix `json:"out"` + } `json:"scale"` + Sniff []struct { + In []byte `json:"in"` + Out string `json:"out"` + } `json:"sniff"` + // Ce que /internal/v1/conv/workspace répond pendant le scénario. + Workspace map[string]string `json:"workspace"` + Tree map[string]parityFileNode `json:"tree"` + Calls []parityFilesCall `json:"calls"` +} + +func parityNRGBA(w, h int, seed int) *image.NRGBA { + img := image.NewNRGBA(image.Rect(0, 0, w, h)) + for i := range img.Pix { + img.Pix[i] = byte((i*37 + seed*11) % 256) + } + return img +} + +func pixOf(img image.Image) parityPix { + n := toNRGBA(img) + b := n.Bounds() + return parityPix{W: b.Dx(), H: b.Dy(), Pix: append([]byte(nil), n.Pix...)} +} + +// parityJPEGExif : un en-tête JPEG avec un segment APP1 EXIF portant +// l'orientation o (ordre d'octets « II » ou « MM »), précédé d'un APP0. +func parityJPEGExif(order string, o uint16, tagFirst bool) []byte { + be := order == "MM" + u16 := func(v uint16) []byte { + if be { + return []byte{byte(v >> 8), byte(v)} + } + return []byte{byte(v), byte(v >> 8)} + } + u32 := func(v uint32) []byte { + if be { + return []byte{byte(v >> 24), byte(v >> 16), byte(v >> 8), byte(v)} + } + return []byte{byte(v), byte(v >> 8), byte(v >> 16), byte(v >> 24)} + } + tiff := []byte(order) + tiff = append(tiff, u16(0x2A)...) + tiff = append(tiff, u32(8)...) + entries := [][]byte{} + other := append(append(append(u16(0x010F), u16(2)...), u32(1)...), u32(0)...) + orient := append(append(append(u16(0x0112), u16(3)...), u32(1)...), append(u16(o), 0, 0)...) + if tagFirst { + entries = append(entries, orient, other) + } else { + entries = append(entries, other, orient) + } + tiff = append(tiff, u16(uint16(len(entries)))...) + for _, e := range entries { + tiff = append(tiff, e...) + } + app1 := append([]byte("Exif\x00\x00"), tiff...) + out := []byte{0xFF, 0xD8, 0xFF, 0xE0, 0x00, 0x06, 'J', 'F', 'I', 'F'} + out = append(out, 0xFF, 0xE1, byte((len(app1)+2)>>8), byte(len(app1)+2)) + out = append(out, app1...) + return append(out, 0xFF, 0xDA, 0x00, 0x02) +} + +func TestPariteFichiers(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_WORKSPACE", filepath.Join(home, "workspace")) + t.Setenv("TMPDIR", filepath.Join(home, "tmp")) + // agentWorkspace est mémorisé pour tout le process (sync.Once) : le dossier + // de travail de CE test est imposé, comme dans withWorkspace. + prevWS := agentWorkspace() + workspacePath = filepath.Join(home, "workspace") + _ = os.MkdirAll(workspacePath, 0o755) + t.Cleanup(func() { workspacePath = prevWS }) + _ = os.MkdirAll(filepath.Join(home, "tmp"), 0o755) + date := time.Now().Format("2006-01-02") + norm := func(s string) string { + return strings.ReplaceAll(strings.ReplaceAll(s, home, "{{HOME}}"), date, "{{DATE}}") + } + var fx parityFilesFixture + + fx.UploadName = map[string]string{} + for _, n := range []string{"rapport.pdf", "../../etc/passwd", `C:\Users\moi\photo.jpg`, "a:b*c?d\"eg|h.txt", + " .cache ", "..", "", "x\x01y.txt", strings.Repeat("n", 130) + ".pdf", strings.Repeat("n", 130) + "." + strings.Repeat("e", 20)} { + fx.UploadName[n] = safeUploadName(n) + } + fx.Slug = map[string]string{} + for _, s := range []string{"Déjà vu", " Rapport_Final.v2 ", "!!!", "", strings.Repeat("abc ", 20), "--a--"} { + fx.Slug[s] = slugify(s) + } + for _, c := range []struct { + in []byte + }{ + {parityJPEGExif("II", 6, true)}, {parityJPEGExif("MM", 8, false)}, {parityJPEGExif("II", 1, false)}, + {parityJPEGExif("II", 6, true)[:30]}, {[]byte{0xFF, 0xD8, 0xFF, 0xDA}}, {[]byte("pas un jpeg")}, + } { + fx.Exif = append(fx.Exif, struct { + In []byte `json:"in"` + Out int `json:"out"` + }{c.in, exifOrientation(c.in)}) + } + src := parityNRGBA(5, 3, 1) + fx.OrientIn = pixOf(src) + for o := 1; o <= 8; o++ { + fx.Orient = append(fx.Orient, struct { + Orient int `json:"orient"` + Out parityPix `json:"out"` + }{o, pixOf(applyOrientation(src, o))}) + } + for _, c := range []struct{ w, h, max int }{{7, 5, 3}, {5, 9, 4}, {10, 10, 3}, {3, 2, 5}} { + in := parityNRGBA(c.w, c.h, c.w*c.h) + fx.Scale = append(fx.Scale, struct { + In parityPix `json:"in"` + Max int `json:"max"` + Out parityPix `json:"out"` + }{pixOf(in), c.max, pixOf(scaleDownToMax(in, c.max))}) + } + var pngBuf bytes.Buffer + _ = png.Encode(&pngBuf, parityNRGBA(4, 3, 2)) + pngBytes := pngBuf.Bytes() + for _, in := range [][]byte{pngBytes[:16], {0xFF, 0xD8, 0xFF, 0xE0}, []byte("GIF89a\x01\x00"), []byte("RIFF\x10\x00\x00\x00WEBPVP8 "), + []byte("BM\x00\x00"), {0, 0, 1, 0, 1, 0}, []byte(""), []byte("x"), []byte("bonjour"), {}} { + fx.Sniff = append(fx.Sniff, struct { + In []byte `json:"in"` + Out string `json:"out"` + }{in, http.DetectContentType(in)}) + } + + // Arborescence : la discussion c-fichiers, une ancienne capture d'une autre + // discussion, des fichiers d'avant le rangement à la racine. + convActiveRemember("c-fichiers") + conv := "workspace/discussions/c-fichiers/" + fx.Tree = map[string]parityFileNode{ + conv + "rapport.md": {Data: []byte("# Rapport\n"), Mtime: 1700000030}, + conv + "image.png": {Data: pngBytes, Mtime: 1700000040}, + conv + "faux.png": {Data: []byte("pas une image"), Mtime: 1700000005}, + conv + "notes/a.txt": {Data: []byte("alpha"), Mtime: 1700000010}, + conv + "notes/sous/b.txt": {Data: []byte("beta!!"), Mtime: 1700000011}, + conv + "notes/sous": {Dir: true, Mtime: 1700000012}, + conv + "notes": {Dir: true, Mtime: 1700000020}, + "workspace/vieux.txt": {Data: []byte("old"), Mtime: 1700000001}, + "workspace/uploads/ancien.pdf": {Data: []byte("%PDF"), Mtime: 1700000002}, + "workspace/uploads": {Dir: true, Mtime: 1700000003}, + "workspace/discussions/c-autre/captures/x.jpg": {Data: []byte("jpeg?"), Mtime: 1700000004}, + } + keys := make([]string, 0, len(fx.Tree)) + for k := range fx.Tree { + keys = append(keys, k) + } + sort.Slice(keys, func(i, j int) bool { return len(keys[i]) > len(keys[j]) }) // fichiers profonds d'abord + for _, k := range keys { + n := fx.Tree[k] + p := filepath.Join(home, k) + if n.Dir { + _ = os.MkdirAll(p, 0o755) + continue + } + _ = os.MkdirAll(filepath.Dir(p), 0o755) + if err := os.WriteFile(p, n.Data, 0o644); err != nil { + t.Fatal(err) + } + } + for _, k := range keys { // dates des dossiers après leur contenu + n := fx.Tree[k] + ts := time.Unix(n.Mtime, 0) + if err := os.Chtimes(filepath.Join(home, k), ts, ts); err != nil { + t.Fatal(err) + } + } + w := httptest.NewRecorder() + handleInternalConvWorkspace(w, httptest.NewRequest("POST", "/internal/v1/conv/workspace", strings.NewReader("{}"))) + var ws map[string]string + _ = json.Unmarshal([]byte(norm(w.Body.String())), &ws) + fx.Workspace = ws + + idRe := regexp.MustCompile(`\.upload-[0-9]+\.part`) + convDir := filepath.Join(home, conv) + tree := func() map[string]int64 { + out := map[string]int64{} + _ = filepath.Walk(convDir, func(p string, info os.FileInfo, err error) error { + if err == nil && !info.IsDir() { + rel, _ := filepath.Rel(convDir, p) + out[idRe.ReplaceAllString(filepath.ToSlash(rel), ".upload-{{N}}.part")] = info.Size() + } + return nil + }) + return out + } + lastID := "" + call := func(method, url, body string) parityFilesCall { + body = strings.ReplaceAll(body, "{{ID}}", lastID) + w := httptest.NewRecorder() + r := httptest.NewRequest(method, url, strings.NewReader(body)) + path := url + if i := strings.IndexByte(url, '?'); i >= 0 { + path = url[:i] + } + switch path { + case "/api/chat/files": + handleChatFiles(w, r) + case "/api/chat/files/zip": + handleChatFilesZip(w, r) + case "/api/chat/file": + handleChatFile(w, r) + case "/api/chat/file/delete": + handleChatFileDelete(w, r) + case "/api/chat/image": + handleChatImage(w, r) + case "/api/chat/upload": + handleChatUpload(w, r) + default: + t.Fatalf("route inattendue %s", url) + } + shown := body + if lastID != "" { + shown = strings.ReplaceAll(body, lastID, "{{ID}}") + } + c := parityFilesCall{Method: method, URL: url, Body: shown, Status: w.Code} + ct := w.Header().Get("Content-Type") + switch { + case strings.HasPrefix(ct, "application/json"): + txt := norm(w.Body.String()) + var m map[string]any + if err := json.Unmarshal([]byte(txt), &m); err == nil { + if id, ok := m["id"].(string); ok { + lastID = id + m["id"] = "{{ID}}" + } + if d, ok := m["data"].(string); ok && path == "/api/chat/files/zip" { + raw, _ := base64.StdEncoding.DecodeString(d) + zr, err := zip.NewReader(bytes.NewReader(raw), int64(len(raw))) + if err != nil { + t.Fatal(err) + } + c.Zip = map[string][]byte{} + for _, f := range zr.File { + rc, _ := f.Open() + b, _ := io.ReadAll(rc) + rc.Close() + c.Zip[f.Name] = b + } + m["data"] = "…" + } + if _, ok := m["size"]; ok && path == "/api/chat/files/zip" { + m["size"] = "…" + } + c.Out = m + } else { + _ = json.Unmarshal([]byte(txt), &c.Out) + } + case ct == "image/jpeg" && strings.Contains(url, "thumb="): + img, _, err := image.Decode(bytes.NewReader(w.Body.Bytes())) + if err != nil { + t.Fatal(err) + } + c.Thumb = []int{img.Bounds().Dx(), img.Bounds().Dy()} + c.Headers = map[string]string{} + default: + c.Raw = w.Body.Bytes() + c.Headers = map[string]string{} + } + if c.Headers != nil { + for _, h := range []string{"Content-Type", "Content-Disposition", "X-Content-Type-Options", "Content-Security-Policy", "Cache-Control"} { + if v := w.Header().Get(h); v != "" { + c.Headers[h] = v + } + } + } + if method == "POST" { + c.Tree = tree() + } + if s, ok := c.Out.(map[string]any); ok { + if p, ok := s["path"].(string); ok { + s["path"] = idRe.ReplaceAllString(p, ".upload-{{N}}.part") + } + } + return c + } + for _, c := range []struct{ m, u, b string }{ + {"GET", "/api/chat/files", ""}, + {"GET", "/api/chat/files?dir=notes", ""}, + {"GET", "/api/chat/files?dir=notes/sous/", ""}, + {"GET", "/api/chat/files?dir=../../..", ""}, + {"GET", "/api/chat/files?dir=rapport.md", ""}, + {"GET", "/api/chat/files?scope=legacy", ""}, + {"GET", "/api/chat/file?path=rapport.md&meta=1", ""}, + {"GET", "/api/chat/file?path=rapport.md&b64=1&offset=2&len=4", ""}, + {"GET", "/api/chat/file?path=rapport.md&b64=1&offset=5", ""}, + {"GET", "/api/chat/file?path=rapport.md&b64=1&offset=99", ""}, + {"GET", "/api/chat/file?path=../../vieux.txt", ""}, + {"GET", "/api/chat/file?path=absent.txt", ""}, + {"GET", "/api/chat/file?path=", ""}, + {"GET", "/api/chat/file?path=captures/c-autre/x.jpg&meta=1", ""}, + {"GET", "/api/chat/file?path=uploads/ancien.pdf&meta=1", ""}, + {"GET", "/api/chat/file?path=notes", ""}, + {"GET", "/api/chat/file?path=/notes/a.txt", ""}, + {"GET", "/api/chat/image?path=faux.png", ""}, + {"GET", "/api/chat/image?path=image.png", ""}, + {"GET", "/api/chat/image?path=image.png&thumb=2", ""}, + {"GET", "/api/chat/image?path=rapport.md&thumb=64", ""}, + {"POST", "/api/chat/upload", `{"name":"../Mon fichier?.txt","data":"aGVsbG8="}`}, + {"POST", "/api/chat/upload", `{"name":"Mon fichier?.txt","data":"aGVsbG8gIQ=="}`}, + {"POST", "/api/chat/upload", `{"name":"gros.bin","data":"AAEC","more":true,"size":5}`}, + {"POST", "/api/chat/upload", `{"id":"{{ID}}","data":"data:application/octet-stream;base64,AwQ=","more":false}`}, + {"POST", "/api/chat/upload", `{"id":"inconnu","data":"AA=="}`}, + {"POST", "/api/chat/upload", `{"name":"vide.txt","data":""}`}, + {"POST", "/api/chat/upload", `{"name":"x","data":"!!!"}`}, + {"POST", "/api/chat/upload", `pas du json`}, + {"POST", "/api/chat/file/delete", `{"path":""}`}, + {"POST", "/api/chat/file/delete", `{"path":"."}`}, + {"POST", "/api/chat/file/delete", `{"path":"../.."}`}, + {"POST", "/api/chat/file/delete", `{"path":"discussions","scope":"legacy"}`}, + {"POST", "/api/chat/file/delete", `{"path":"absent"}`}, + {"POST", "/api/chat/file/delete", `{"path":"faux.png"}`}, + {"POST", "/api/chat/file/delete", `{"path":"notes/sous"}`}, + {"GET", "/api/chat/files/zip", ""}, + {"GET", "/api/chat/files/zip?meta=1", ""}, + {"GET", "/api/chat/files/zip?b64=1&offset=0&len=99999999", ""}, + {"GET", "/api/chat/files/zip?b64=1&offset=-1", ""}, + {"GET", "/api/chat/files/zip?meta=1&scope=legacy", ""}, + {"GET", "/api/chat/files/zip?b64=1&scope=legacy", ""}, + } { + fx.Calls = append(fx.Calls, call(c.m, c.u, c.b)) + } + + got, err := parityJSON(fx) + if err != nil { + t.Fatal(err) + } + if os.Getenv("LOKI_PARITY_UPDATE") == "1" { + if err := os.MkdirAll(filepath.Dir(parityFilesFile), 0o755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(parityFilesFile, got, 0o644); err != nil { + t.Fatal(err) + } + return + } + want, err := os.ReadFile(parityFilesFile) + 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 fichiers de la discussion ne produisent plus le journal rangé dans %s.\n"+ + "Si c'est voulu : LOKI_PARITY_UPDATE=1 go test ./internal/loki -run TestPariteFichiers,\n"+ + "puis cargo test --test convfiles dit si le port Rust suit.\n--- obtenu ---\n%s", parityFilesFile, got) + } +} diff --git a/internal/loki/web_internal.go b/internal/loki/web_internal.go index 5fad81b..719f34d 100644 --- a/internal/loki/web_internal.go +++ b/internal/loki/web_internal.go @@ -107,6 +107,8 @@ func withInternalAPI(next http.Handler) http.Handler { handleInternalConvActive(w, r) case "/internal/v1/conv/dropped": handleInternalConvDropped(w, r) + case "/internal/v1/conv/workspace": + handleInternalConvWorkspace(w, r) default: http.NotFound(w, r) } diff --git a/internal/loki/web_server.go b/internal/loki/web_server.go index 70623b7..d5538c3 100644 --- a/internal/loki/web_server.go +++ b/internal/loki/web_server.go @@ -137,8 +137,9 @@ func newWebMux() *http.ServeMux { // range dans le dossier de leur discussion (une fois par process). migrateConvFiles() // Idem pour un envoi de fichier coupé en plein transfert : les sessions vivent - // en mémoire, aucun .part ne survit utilement à l'arrêt du process. - cleanStaleUploadParts() + // en mémoire, aucun .part ne survit utilement à l'arrêt du process (sauf + // quand la façade tient les fichiers, facade_files.go). + cleanStartupUploadParts() // Idem pour une installation de moteur : elle meurt avec le process. On // recharge son état pour l'annoncer « interrompue » au lieu de n'afficher // plus rien du tout. @@ -221,8 +222,9 @@ func newWebMux() *http.ServeMux { // withFacadeChat : routes du chat remises à la façade quand elle tient la // conversation (LOKI_CHAT=rust, facade_chat.go), celles des modèles quand // elle les tient (LOKI_MODELS=rust, facade_models.go), celles de la dictée - // (LOKI_DICTATE=rust, facade_dictate.go) et des réglages partagés - // (LOKI_SETTINGS=rust, facade_settings.go) ; sans effet sinon. + // (LOKI_DICTATE=rust, facade_dictate.go), des réglages partagés + // (LOKI_SETTINGS=rust, facade_settings.go) et des fichiers de la + // discussion (LOKI_FILES=rust, facade_files.go) ; sans effet sinon. api := func(path string, h http.HandlerFunc) { mux.HandleFunc(path, requireWebAuth(withFacadeChat(path, h))) } api("/api/ping", handlePing) api("/api/status", handleStatus) diff --git a/rust/Cargo.lock b/rust/Cargo.lock index 7221873..0bbcbed 100644 --- a/rust/Cargo.lock +++ b/rust/Cargo.lock @@ -17,12 +17,27 @@ dependencies = [ "memchr", ] +[[package]] +name = "arbitrary" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3bc62ac97cc33321f50863d514c3bc38a453947a8f9e781137e47c7401020aed" +dependencies = [ + "derive_arbitrary", +] + [[package]] name = "atomic-waker" version = "1.1.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1505bd5d3d116872e7271a6d4e16d81d0c8570876c8de68093a09ac269d8aac0" +[[package]] +name = "autocfg" +version = "1.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f2032f911046de80f0a198e0901378627c33f59ea0ac00e363d481118bd70a53" + [[package]] name = "base64" version = "0.23.1" @@ -50,6 +65,18 @@ version = "3.20.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "72f5acc6cb2ba439de613abc23857ec3d78374d8ed5ac84e9d11336e87da8649" +[[package]] +name = "bytemuck" +version = "1.25.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "95832e849adfb21180ccb6826a99da14e5d266ae5c2e668e1602cf234f153797" + +[[package]] +name = "byteorder-lite" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f1fe948ff07f4bd06c30984e69f5b4899c516a3ef74f34df92a2df2ab535495" + [[package]] name = "bytes" version = "1.12.1" @@ -92,6 +119,12 @@ dependencies = [ "rand_core", ] +[[package]] +name = "color_quant" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3d7b894f5411737b7867f4827955924d7c254fc9f4d91a6aad6b097804b1018b" + [[package]] name = "combine" version = "4.6.8" @@ -142,6 +175,12 @@ dependencies = [ "cfg-if", ] +[[package]] +name = "crossbeam-utils" +version = "0.8.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a31eee39dddec8330830986fcd7625edb5a24ec90ea038215273bbc3adb08ac6" + [[package]] name = "crypto-common" version = "0.2.2" @@ -157,6 +196,17 @@ version = "2.11.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "4583a4551df46e2792f82ceeac45e850d2e2d5debba0b91f102385cda5b11f06" +[[package]] +name = "derive_arbitrary" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1b034bd7d5f032402a2479444dcc6f74e36a03f31854d41680fb240ef682a1ac" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.9", +] + [[package]] name = "digest" version = "0.11.3" @@ -201,6 +251,15 @@ version = "2.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "da7c62ceae207dd37ea5b845da6a0696c799f85e97da1ab5b7910be3c1c80223" +[[package]] +name = "fdeflate" +version = "0.3.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e6853b52649d4ac5c0bd02320cddc5ba956bdb407c4b75a2c6b75bf51500f8c" +dependencies = [ + "simd-adler32", +] + [[package]] name = "filetime" version = "0.2.29" @@ -224,7 +283,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6e634e2e0ebac1ee034020da1ca582e17ffe4e0f5e985823721e168928136dcb" dependencies = [ "crc32fast", - "miniz_oxide", + "miniz_oxide 0.9.1", "zlib-rs", ] @@ -326,6 +385,16 @@ dependencies = [ "rand_core", ] +[[package]] +name = "gif" +version = "0.14.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ee8cfcc411d9adbbaba82fb72661cc1bcca13e8bba98b364e62b2dba8f960159" +dependencies = [ + "color_quant", + "weezl", +] + [[package]] name = "h2" version = "0.4.20" @@ -576,6 +645,23 @@ dependencies = [ "icu_properties", ] +[[package]] +name = "image" +version = "0.25.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85ab80394333c02fe689eaf900ab500fbd0c2213da414687ebf995a65d5a6104" +dependencies = [ + "bytemuck", + "byteorder-lite", + "color_quant", + "gif", + "moxcms", + "num-traits", + "png", + "zune-core", + "zune-jpeg", +] + [[package]] name = "indexmap" version = "2.14.2" @@ -692,6 +778,7 @@ checksum = "f9f8bd3e56ce4dfc153cf470fffbfa98c7620958b312ca5c3a4b8d5181fd13c6" name = "loki-rs" version = "0.1.0" dependencies = [ + "base64", "bytes", "bzip2", "flate2", @@ -701,6 +788,7 @@ dependencies = [ "http-body-util", "hyper", "hyper-util", + "image", "libc", "regex", "reqwest", @@ -714,6 +802,7 @@ dependencies = [ "tokio", "tokio-tungstenite", "url", + "zip", ] [[package]] @@ -722,6 +811,16 @@ version = "2.8.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "cf8baf1c55e62ffcace7a9f06f4bd9cd3f0c4beb022d3b367256b91b87513d98" +[[package]] +name = "miniz_oxide" +version = "0.8.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fa76a2c86f704bdb222d66965fb3d63269ce38518b83cb0575fca855ebb6316" +dependencies = [ + "adler2", + "simd-adler32", +] + [[package]] name = "miniz_oxide" version = "0.9.1" @@ -743,6 +842,25 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "moxcms" +version = "0.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bb85c154ba489f01b25c0d36ae69a87e4a1c73a72631fc6c0eb6dde34a73e44b" +dependencies = [ + "num-traits", + "pxfm", +] + +[[package]] +name = "num-traits" +version = "0.2.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841" +dependencies = [ + "autocfg", +] + [[package]] name = "once_cell" version = "1.21.4" @@ -767,6 +885,19 @@ version = "0.2.17" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd" +[[package]] +name = "png" +version = "0.18.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "60769b8b31b2a9f263dae2776c37b1b28ae246943cf719eb6946a1db05128a61" +dependencies = [ + "bitflags", + "crc32fast", + "fdeflate", + "flate2", + "miniz_oxide 0.8.9", +] + [[package]] name = "potential_utf" version = "0.1.6" @@ -785,6 +916,12 @@ dependencies = [ "unicode-ident", ] +[[package]] +name = "pxfm" +version = "0.1.30" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d55d956fa96f5ec02be2e13af0e20391a5aa83d6a074e3ad368959d0fab299ea" + [[package]] name = "quote" version = "1.0.47" @@ -1576,6 +1713,12 @@ dependencies = [ "rustls-pki-types", ] +[[package]] +name = "weezl" +version = "0.1.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a28ac98ddc8b9274cb41bb4d9d4d5c425b6020c50c46f25559911905610b4a88" + [[package]] name = "winapi-util" version = "0.1.11" @@ -1762,6 +1905,22 @@ dependencies = [ "syn 3.0.9", ] +[[package]] +name = "zip" +version = "2.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fabe6324e908f85a1c52063ce7aa26b68dcb7eb6dbc83a2d148403c9bc3eba50" +dependencies = [ + "arbitrary", + "crc32fast", + "crossbeam-utils", + "displaydoc", + "flate2", + "indexmap", + "memchr", + "thiserror", +] + [[package]] name = "zlib-rs" version = "0.6.8" @@ -1773,3 +1932,18 @@ name = "zmij" version = "1.0.23" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "29666d0abbfad1e3dc4dcf6144730dd3a3ab225bbbdac83319345b1b44ccfc1b" + +[[package]] +name = "zune-core" +version = "0.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d56377fd46368984a170bc5aac5567e52ca5da874caa60bea39fcbca78fb658b" + +[[package]] +name = "zune-jpeg" +version = "0.5.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "27bc9d5b815bc103f142aa054f561d9187d191692ec7c2d1e2b4737f8dbd7296" +dependencies = [ + "zune-core", +] diff --git a/rust/loki-rs/Cargo.toml b/rust/loki-rs/Cargo.toml index 9d5e62e..81ab438 100644 --- a/rust/loki-rs/Cargo.toml +++ b/rust/loki-rs/Cargo.toml @@ -4,6 +4,7 @@ version = "0.1.0" edition = "2024" [dependencies] +base64 = "0.23.1" bytes = "1.12.1" bzip2 = "0.6.1" flate2 = "1.1.10" @@ -11,10 +12,12 @@ futures-util = "0.3.34" getrandom = "0.4.3" hex = "0.4.3" http-body-util = "0.1.5" +image = { version = "0.25.10", default-features = false, features = ["jpeg", "png", "gif"] } hyper = { version = "1.12.0", features = ["http1", "server", "client"] } hyper-util = { version = "0.1.21", features = ["tokio", "server", "client-legacy", "http1"] } regex = "1.13.1" url = "2.5.8" +zip = { version = "2.4.2", default-features = false, features = ["deflate-flate2", "flate2"] } reqwest = { version = "0.13.5", default-features = false, features = ["rustls-no-provider", "stream", "http2"] } rustls = { version = "0.23.46", default-features = false, features = ["ring", "std", "tls12"], optional = true } serde = { version = "1.0.229", features = ["derive"] } diff --git a/rust/loki-rs/src/api.rs b/rust/loki-rs/src/api.rs index e094f36..c812991 100644 --- a/rust/loki-rs/src/api.rs +++ b/rust/loki-rs/src/api.rs @@ -34,6 +34,8 @@ pub struct Ctx { pub dictate: Option>, /// Réglages partagés tenus par la façade (`LOKI_SETTINGS=rust`). pub settings: bool, + /// Fichiers de la discussion tenus par la façade (`LOKI_FILES=rust`). + pub files: Option>, /// Jeton de l'API interne : une requête qui le porte vient du Go. pub internal_token: String, } @@ -42,7 +44,11 @@ impl Ctx { /// La façade tient-elle au moins un lot (le Go lui remet alors le trafic /// de ce lot qui lui arrive par le tunnel) ? pub fn holds_any(&self) -> bool { - self.chat.is_some() || self.models.is_some() || self.dictate.is_some() || self.settings + self.chat.is_some() + || self.models.is_some() + || self.dictate.is_some() + || self.settings + || self.files.is_some() } /// La requête vient du Go (jeton interne valide) : déjà authentifiée par diff --git a/rust/loki-rs/src/convfiles.rs b/rust/loki-rs/src/convfiles.rs new file mode 100644 index 0000000..3d634a6 --- /dev/null +++ b/rust/loki-rs/src/convfiles.rs @@ -0,0 +1,1573 @@ +//! Fichiers de la discussion — port de `web_files.go`, `web_files_zip.go`, +//! `web_upload.go` (dépôt, téléchargement, image), `web_thumb.go`, +//! `web_upload_orient.go` (orientation, réduction) et des chemins de +//! `chat_convfiles.go` (lot 11 de la migration), servis par la façade quand +//! `LOKI_FILES=rust` (expérimental ; le Go par défaut) : +//! +//! - `POST /api/chat/upload` : dépôt en un ou plusieurs morceaux base64 ; +//! - `GET /api/chat/files` : panneau Fichiers ; +//! - `GET /api/chat/files/zip` : toute la discussion en .zip (fiche, tranches +//! base64, ou brut) ; +//! - `POST /api/chat/file/delete` : suppression ; +//! - `GET /api/chat/file` : téléchargement (fiche, tranche base64, ou brut) ; +//! - `GET /api/chat/image` : affichage d'une image (vignette `thumb=`). +//! +//! Le dossier de la discussion active reste décidé par le Go +//! (`/internal/v1/conv/workspace`), qui la connaît dans tous les cas. +//! +//! Écarts assumés : octets des vignettes JPEG (encodeur différent : mêmes +//! dimensions, même redressement, même réduction par moyennage) ; horodatage +//! des entrées .zip en UTC ; une seule plage d'octets par requête de +//! téléchargement (le Go en acceptait plusieurs, en multipart). + +use std::collections::HashMap; +use std::io::{Read, Seek, Write}; +use std::path::{Path, PathBuf}; +use std::sync::{Arc, Mutex}; +use std::time::{Duration, Instant, SystemTime, UNIX_EPOCH}; + +use base64::Engine; +use bytes::Bytes; +use http_body_util::{BodyExt, Full, Limited, StreamBody}; +use hyper::body::{Frame, Incoming}; +use hyper::header::{self, HeaderMap, HeaderValue}; +use hyper::{Method, Request, Response, StatusCode}; +use serde::Deserialize; +use serde_json::{Value, json}; + +use crate::api::Ctx; +use crate::auth::json as send_json; +use crate::downloads::{disk_free, human_bytes}; +use crate::paths::{go_clean, go_join}; +use crate::proxy::Body; + +const CONV_FILES_ROOT: &str = "discussions"; +const UPLOADS_SUB: &str = "uploads"; +const CAPTURE_DIR: &str = "captures"; +const UPLOAD_MAX_BYTES: i64 = 1 << 30; +const UPLOAD_CHUNK_MAX: usize = 8 << 20; +const DOWNLOAD_CHUNK_MAX: i64 = 8 << 20; +const UPLOAD_SPACE_MARGIN: i64 = 512 << 20; +const UP_SESSION_TTL: Duration = Duration::from_secs(600); +const WS_WALK_LIMIT: usize = 20000; +const THUMB_MAX_SOURCE: u64 = 40 << 20; +const THUMB_CACHE_MAX: usize = 300; +/// Plus grand côté d'une image redressée pour l'affichage (vignette). +const E2E_HEADER: &str = "x-loki-e2e"; + +/// Routes servies ici. +pub fn handles(path: &str) -> bool { + matches!( + path, + "/api/chat/upload" + | "/api/chat/files" + | "/api/chat/files/zip" + | "/api/chat/file/delete" + | "/api/chat/file" + | "/api/chat/image" + ) +} + +// --------------------------------------------------------------------------- +// Fonctions pures +// --------------------------------------------------------------------------- + +/// `safeUploadName` : un nom de fichier simple, sans chemin ni caractère +/// interdit, tronqué à 120 octets en gardant l'extension. +pub fn safe_upload_name(name: &str) -> String { + let name = name.replace('\\', "/"); + let base = name.trim_end_matches('/').rsplit('/').next().unwrap_or(""); + let base = if base.is_empty() { "." } else { base }; + let mut b = String::new(); + for r in base.chars() { + if r.is_control() || matches!(r, '/' | '\\' | ':' | '*' | '?' | '"' | '<' | '>' | '|') { + b.push('_'); + } else { + b.push(r); + } + } + let mut name = b + .trim_matches(char::is_whitespace) + .trim_matches('.') + .to_string(); + if name.is_empty() { + name = "fichier".into(); + } + if name.len() > 120 { + let mut ext = go_ext(&name).to_string(); + if ext.len() > 16 { + ext.clear(); + } + let mut cut = 120 - ext.len(); + while !name.is_char_boundary(cut) { + cut -= 1; + } + name = format!("{}{ext}", &name[..cut]); + } + name +} + +/// `filepath.Ext`. +fn go_ext(name: &str) -> &str { + match name.rfind(['.', '/']) { + Some(i) if name.as_bytes()[i] == b'.' => &name[i..], + _ => "", + } +} + +/// `slugify` : minuscules ASCII et tirets, 40 caractères au plus. +pub fn slugify(s: &str) -> String { + let mut b = String::new(); + for r in s.trim_matches(char::is_whitespace).to_lowercase().chars() { + if r.is_ascii_lowercase() || r.is_ascii_digit() { + b.push(r); + } else if matches!(r, ' ' | '-' | '_' | '.') && !b.is_empty() && !b.ends_with('-') { + b.push('-'); + } + if b.len() >= 40 { + break; + } + } + b.trim_matches('-').to_string() +} + +/// `safeConvID`. +fn safe_conv_id(id: &str) -> String { + let id = id.trim(); + if id.is_empty() + || id.len() > 64 + || !id + .chars() + .all(|r| r == '-' || r == '_' || r.is_numeric() || r.is_ascii_alphabetic()) + { + return String::new(); + } + id.to_string() +} + +/// `exifOrientation` : le tag Orientation (1..8) d'un JPEG, 0 sinon. +pub fn exif_orientation(b: &[u8]) -> i32 { + if b.len() < 4 || b[0] != 0xFF || b[1] != 0xD8 { + return 0; + } + let mut pos = 2; + while pos + 4 <= b.len() { + if b[pos] != 0xFF { + return 0; + } + let marker = b[pos + 1]; + if marker == 0xDA || marker == 0xD9 { + return 0; + } + let seg_len = u16::from_be_bytes([b[pos + 2], b[pos + 3]]) as usize; + if seg_len < 2 || pos + 2 + seg_len > b.len() { + return 0; + } + let seg = &b[pos + 4..pos + 2 + seg_len]; + if marker == 0xE1 && seg.len() >= 6 && &seg[..6] == b"Exif\0\0" { + return exif_from_tiff(&seg[6..]); + } + pos += 2 + seg_len; + } + 0 +} + +fn exif_from_tiff(t: &[u8]) -> i32 { + if t.len() < 8 { + return 0; + } + let be = match &t[..2] { + b"II" => false, + b"MM" => true, + _ => return 0, + }; + let u16_at = |i: usize| { + let x = [t[i], t[i + 1]]; + if be { + u16::from_be_bytes(x) + } else { + u16::from_le_bytes(x) + } + }; + let u32_at = |i: usize| { + let x = [t[i], t[i + 1], t[i + 2], t[i + 3]]; + if be { + u32::from_be_bytes(x) + } else { + u32::from_le_bytes(x) + } + }; + if u16_at(2) != 0x2A { + return 0; + } + let ifd = u32_at(4) as usize; + if ifd < 8 || ifd + 2 > t.len() { + return 0; + } + let n = u16_at(ifd) as usize; + let mut entry = ifd + 2; + for _ in 0..n { + if entry + 12 > t.len() { + return 0; + } + if u16_at(entry) == 0x0112 { + return u16_at(entry + 8) as i32; + } + entry += 12; + } + 0 +} + +/// Une image RGBA non prémultipliée (`*image.NRGBA`). +#[derive(Debug, Clone, PartialEq)] +pub struct Rgba { + pub w: usize, + pub h: usize, + pub pix: Vec, +} + +/// `applyOrientation` : rotations et miroirs du tag EXIF cuits dans les pixels. +pub fn apply_orientation(src: &Rgba, orient: i32) -> Rgba { + let (w, h) = (src.w, src.h); + let (dw, dh) = if orient >= 5 { (h, w) } else { (w, h) }; + let mut dst = vec![0u8; dw * dh * 4]; + for y in 0..h { + for x in 0..w { + let (nx, ny) = match orient { + 2 => (w - 1 - x, y), + 3 => (w - 1 - x, h - 1 - y), + 4 => (x, h - 1 - y), + 5 => (y, x), + 6 => (h - 1 - y, x), + 7 => (h - 1 - y, w - 1 - x), + 8 => (y, w - 1 - x), + _ => (x, y), + }; + let s = (y * w + x) * 4; + let d = (ny * dw + nx) * 4; + dst[d..d + 4].copy_from_slice(&src.pix[s..s + 4]); + } + } + Rgba { + w: dw, + h: dh, + pix: dst, + } +} + +/// `scaleDownToMax` : réduction par moyennage d'aire, ratio conservé. +pub fn scale_down_to_max(src: &Rgba, max: usize) -> Rgba { + let (w, h) = (src.w, src.h); + if w <= max && h <= max { + return src.clone(); + } + let scale = if h > w { + max as f64 / h as f64 + } else { + max as f64 / w as f64 + }; + let nw = ((w as f64 * scale).round() as usize).max(1); + let nh = ((h as f64 * scale).round() as usize).max(1); + let mut dst = vec![0u8; nw * nh * 4]; + for dy in 0..nh { + let sy0 = dy * h / nh; + let sy1 = ((dy + 1) * h / nh).max(sy0 + 1); + for dx in 0..nw { + let sx0 = dx * w / nw; + let sx1 = ((dx + 1) * w / nw).max(sx0 + 1); + let mut acc = [0u64; 4]; + let mut cnt = 0u64; + for sy in sy0..sy1 { + for sx in sx0..sx1 { + let i = (sy * w + sx) * 4; + for (a, p) in acc.iter_mut().zip(&src.pix[i..i + 4]) { + *a += *p as u64; + } + cnt += 1; + } + } + let di = (dy * nw + dx) * 4; + for c in 0..4 { + dst[di + c] = (acc[c] / cnt) as u8; + } + } + } + Rgba { + w: nw, + h: nh, + pix: dst, + } +} + +/// `http.DetectContentType`, réduit aux signatures d'images (le reste n'est +/// jamais servi comme image) ; "" si ce n'en est pas une. +pub fn sniff_image(head: &[u8]) -> &'static str { + let starts = |sig: &[u8]| head.starts_with(sig); + if starts(b"\x00\x00\x01\x00") || starts(b"\x00\x00\x02\x00") { + "image/x-icon" + } else if starts(b"BM") { + "image/bmp" + } else if starts(b"GIF87a") || starts(b"GIF89a") { + "image/gif" + } else if head.len() >= 14 && &head[..4] == b"RIFF" && &head[8..14] == b"WEBPVP" { + "image/webp" + } else if starts(b"\x89PNG\r\n\x1a\n") { + "image/png" + } else if starts(b"\xff\xd8\xff") { + "image/jpeg" + } else { + "" + } +} + +/// `relWithin` : `abs` est-il DANS `root` (liens résolus des deux côtés) ? Rend +/// le chemin relatif en `/`. +pub fn rel_within(root: &str, abs: &str) -> Option { + if root.is_empty() { + return None; + } + let resolve = |p: &str| { + std::fs::canonicalize(p) + .map(|p| p.to_string_lossy().into_owned()) + .unwrap_or_else(|_| go_clean(p)) + }; + let (root, abs) = (resolve(root), resolve(abs)); + if abs == root { + return None; + } + let prefix = if root.ends_with('/') { + root.clone() + } else { + format!("{root}/") + }; + abs.strip_prefix(&prefix).map(str::to_string) +} + +/// `wsResolve` : un chemin relatif du client sous la racine du panneau. +pub fn ws_resolve(root: &str, rel: &str) -> Option { + let rel = rel.trim().replace('\\', "/"); + let rel = rel.trim_start_matches('/'); + if rel.is_empty() || rel == "." { + return Some(root.to_string()); + } + let abs = go_join(&[root, rel]); + rel_within(root, &abs).map(|_| abs) +} + +/// `dirSize` : somme du contenu (liens non suivis), bornée. +pub fn dir_size(root: &Path) -> (i64, usize) { + fn walk(dir: &Path, total: &mut i64, count: &mut usize) { + if *count >= WS_WALK_LIMIT { + return; + } + let Ok(rd) = std::fs::read_dir(dir) else { + return; + }; + let mut ents: Vec<_> = rd.filter_map(Result::ok).collect(); + ents.sort_by_key(|e| e.file_name()); + for e in ents { + if *count >= WS_WALK_LIMIT { + return; + } + let Ok(ft) = e.file_type() else { continue }; + if ft.is_symlink() { + continue; + } + if ft.is_dir() { + walk(&e.path(), total, count); + continue; + } + if let Ok(m) = e.metadata() { + *total += m.len() as i64; + *count += 1; + } + } + } + let (mut total, mut count) = (0, 0); + walk(root, &mut total, &mut count); + (total, count) +} + +fn mtime_secs(m: &std::fs::Metadata) -> i64 { + m.modified() + .ok() + .and_then(|t| t.duration_since(UNIX_EPOCH).ok()) + .map_or(0, |d| d.as_secs() as i64) +} + +/// Les extensions présentées comme images dans le panneau. +fn is_image_ext(name: &str) -> bool { + matches!( + go_ext(name).to_lowercase().as_str(), + ".png" | ".jpg" | ".jpeg" | ".gif" | ".webp" | ".bmp" | ".svg" | ".avif" + ) +} + +/// La discussion active vue du Go. +#[derive(Debug, Clone, Deserialize)] +pub struct Workspace { + pub dir: String, + pub root: String, + #[serde(default)] + pub id: String, + #[serde(default)] + pub title: String, +} + +impl Workspace { + fn conv_dir_for(&self, id: &str) -> String { + let id = safe_conv_id(id); + if id.is_empty() { + self.root.clone() + } else { + go_join(&[&self.root, CONV_FILES_ROOT, &id]) + } + } + + /// `filesRoot` : la discussion, ou la racine pour `scope=legacy`. + fn files_root(&self, scope: &str) -> (String, bool) { + if scope.trim() == "legacy" { + (self.root.clone(), true) + } else { + (self.dir.clone(), false) + } + } + + /// `workspaceFile` : discussion d'abord, emplacements d'avant ensuite. + pub fn workspace_file(&self, rel: &str) -> Option { + let rel = rel.trim().replace('\\', "/"); + let rel = rel.trim_start_matches('/'); + if rel.is_empty() { + return None; + } + let p = go_join(&[&self.dir, rel]); + rel_within(&self.dir, &p)?; + if std::fs::metadata(&p).is_ok() { + return Some(p); + } + if let Some(l) = self.legacy_file(rel) { + return Some(l); + } + Some(p) + } + + fn legacy_file(&self, rel: &str) -> Option { + let file = |p: &str| std::fs::metadata(p).is_ok_and(|m| !m.is_dir()); + let parts: Vec<&str> = rel.splitn(3, '/').collect(); + if parts.len() == 3 && parts[0] == CAPTURE_DIR && !safe_conv_id(parts[1]).is_empty() { + let p = go_join(&[&self.conv_dir_for(parts[1]), CAPTURE_DIR, parts[2]]); + if file(&p) { + return Some(p); + } + } + let p = go_join(&[&self.root, rel]); + rel_within(&self.root, &p)?; + file(&p).then_some(p) + } + + /// `legacyCount`. + fn legacy_count(&self) -> usize { + std::fs::read_dir(&self.root) + .map(|rd| { + rd.filter_map(Result::ok) + .filter(|e| { + let n = e.file_name().to_string_lossy().into_owned(); + n != CONV_FILES_ROOT && !n.starts_with('.') + }) + .count() + }) + .unwrap_or(0) + } +} + +// --------------------------------------------------------------------------- +// Service +// --------------------------------------------------------------------------- + +struct UpSession { + f: std::fs::File, + path: PathBuf, + name: String, + written: i64, +} + +struct UpEntry { + sess: tokio::sync::Mutex>, + last: Mutex, + busy: std::sync::atomic::AtomicBool, +} + +/// Les fichiers de la discussion, tenus par la façade. +pub struct ConvFiles { + tmp_dir: PathBuf, + uploads: Mutex>>, + thumbs: Mutex>, +} + +impl Default for ConvFiles { + fn default() -> Self { + Self::new(std::env::temp_dir()) + } +} + +fn lock(m: &Mutex) -> std::sync::MutexGuard<'_, T> { + m.lock().unwrap_or_else(|e| e.into_inner()) +} + +impl ConvFiles { + /// `tmp_dir` : où se construisent les archives .zip. + pub fn new(tmp_dir: PathBuf) -> Self { + ConvFiles { + tmp_dir, + uploads: Mutex::new(HashMap::new()), + thumbs: Mutex::new(HashMap::new()), + } + } + + /// `sweepUploadSessions` : les envois abandonnés sont fermés et effacés. + fn sweep(&self) { + let mut map = lock(&self.uploads); + let stale: Vec = map + .iter() + .filter(|(_, e)| { + !e.busy.load(std::sync::atomic::Ordering::SeqCst) + && lock(&e.last).elapsed() > UP_SESSION_TTL + }) + .map(|(k, _)| k.clone()) + .collect(); + for k in stale { + if let Some(e) = map.remove(&k) + && let Ok(mut s) = e.sess.try_lock() + && let Some(s) = s.take() + { + let _ = std::fs::remove_file(&s.path); + } + } + } + + /// `chatThumb` : vignette JPEG (grand côté borné 64..1600), en cache tant + /// que le fichier ne change pas. + fn thumb(&self, path: &str, meta: &std::fs::Metadata, px: i64) -> Result { + let px = px.clamp(64, 1600) as usize; + if meta.len() > THUMB_MAX_SOURCE { + return Err("image trop lourde pour une vignette".into()); + } + let key = format!("{path}\0{px}"); + let modt = meta.modified().unwrap_or(UNIX_EPOCH); + if let Some((m, s, d)) = lock(&self.thumbs).get(&key) + && *m == modt + && *s == meta.len() + { + return Ok(d.clone()); + } + let raw = std::fs::read(path).map_err(|e| e.to_string())?; + let img = image::load_from_memory(&raw) + .map_err(|_| "format non pris en charge pour une vignette".to_string())? + .to_rgba8(); + let mut src = Rgba { + w: img.width() as usize, + h: img.height() as usize, + pix: img.into_raw(), + }; + let o = exif_orientation(&raw); + if (2..=8).contains(&o) { + src = apply_orientation(&src, o); + } + if src.w > px || src.h > px { + src = scale_down_to_max(&src, px); + } + // Fond blanc sous la transparence. + let mut rgb = Vec::with_capacity(src.w * src.h * 3); + for p in src.pix.chunks_exact(4) { + let a = p[3] as u32; + for c in &p[..3] { + rgb.push(((*c as u32 * a + 255 * (255 - a) + 127) / 255) as u8); + } + } + let mut out = Vec::new(); + image::codecs::jpeg::JpegEncoder::new_with_quality(&mut out, 78) + .encode( + &rgb, + src.w as u32, + src.h as u32, + image::ExtendedColorType::Rgb8, + ) + .map_err(|e| e.to_string())?; + let data = Bytes::from(out); + let mut cache = lock(&self.thumbs); + if cache.len() >= THUMB_CACHE_MAX { + let keys: Vec = cache + .keys() + .take(THUMB_CACHE_MAX / 2 + 1) + .cloned() + .collect(); + for k in keys { + cache.remove(&k); + } + } + cache.insert(key, (modt, meta.len(), data.clone())); + Ok(data) + } +} + +/// `cleanStaleUploadParts` : au démarrage, les envois interrompus. +pub fn clean_stale_upload_parts(root: &str) { + let mut dirs = vec![go_join(&[root, UPLOADS_SUB])]; + if let Ok(rd) = std::fs::read_dir(go_join(&[root, CONV_FILES_ROOT])) { + for e in rd.filter_map(Result::ok) { + if e.file_type().is_ok_and(|t| t.is_dir()) { + dirs.push(e.path().join(UPLOADS_SUB).to_string_lossy().into_owned()); + } + } + } + for d in dirs { + let Ok(rd) = std::fs::read_dir(&d) else { + continue; + }; + for e in rd.filter_map(Result::ok) { + let n = e.file_name().to_string_lossy().into_owned(); + if !e.file_type().is_ok_and(|t| t.is_dir()) + && n.starts_with(".upload-") + && n.ends_with(".part") + { + let _ = std::fs::remove_file(e.path()); + } + } + } +} + +/// `uniqueUploadPath` : rapport.pdf, rapport-2.pdf… +fn unique_upload_path(dir: &str, name: &str) -> String { + let p = go_join(&[dir, name]); + if std::fs::symlink_metadata(&p).is_err() { + return p; + } + let ext = go_ext(name); + let base = &name[..name.len() - ext.len()]; + let mut p = p; + for i in 2..1000 { + p = go_join(&[dir, &format!("{base}-{i}{ext}")]); + if std::fs::symlink_metadata(&p).is_err() { + return p; + } + } + p +} + +/// La date locale (`2006-01-02`). +fn local_date() -> String { + #[cfg(unix)] + { + let t = SystemTime::now() + .duration_since(UNIX_EPOCH) + .map_or(0, |d| d.as_secs() as libc::time_t); + let mut tm: libc::tm = unsafe { std::mem::zeroed() }; + // SAFETY : pointeurs valides, structure de sortie initialisée. + if !unsafe { libc::localtime_r(&t, &mut tm) }.is_null() { + return format!( + "{:04}-{:02}-{:02}", + tm.tm_year + 1900, + tm.tm_mon + 1, + tm.tm_mday + ); + } + } + let days = SystemTime::now() + .duration_since(UNIX_EPOCH) + .map_or(0, |d| d.as_secs() / 86400) as i64; + let (y, m, d) = civil(days); + format!("{y:04}-{m:02}-{d:02}") +} + +/// Jours depuis 1970 → (année, mois, jour), calendrier grégorien. +fn civil(days: i64) -> (i64, u32, u32) { + let z = days + 719468; + let era = z.div_euclid(146097); + let doe = z.rem_euclid(146097); + let yoe = (doe - doe / 1460 + doe / 36524 - doe / 146096) / 365; + let doy = doe - (365 * yoe + yoe / 4 - yoe / 100); + let mp = (5 * doy + 2) / 153; + let d = (doy - (153 * mp + 2) / 5 + 1) as u32; + let m = if mp < 10 { mp + 3 } else { mp - 9 } as u32; + (era * 400 + yoe + i64::from(m <= 2), m, d) +} + +/// Date HTTP (`Last-Modified`). +fn http_date(secs: i64) -> String { + let (y, m, d) = civil(secs.div_euclid(86400)); + let rem = secs.rem_euclid(86400); + let wd = ["Thu", "Fri", "Sat", "Sun", "Mon", "Tue", "Wed"] + [secs.div_euclid(86400).rem_euclid(7) as usize]; + let mon = [ + "Jan", "Feb", "Mar", "Apr", "May", "Jun", "Jul", "Aug", "Sep", "Oct", "Nov", "Dec", + ][m as usize - 1]; + format!( + "{wd}, {d:02} {mon} {y:04} {:02}:{:02}:{:02} GMT", + rem / 3600, + (rem / 60) % 60, + rem % 60 + ) +} + +/// `url.PathEscape`. +fn path_escape(s: &str) -> String { + crate::hf::go_path_escape(s) +} + +// --------------------------------------------------------------------------- +// Archives +// --------------------------------------------------------------------------- + +/// `buildZip` : liens ignorés, parcours borné, chemins relatifs en `/` ; +/// le dossier des discussions exclu de la vue « hors discussion ». +pub fn build_zip(root: &Path, dest: &Path, exclude_convs: bool) -> Result { + let out = std::fs::File::create(dest).map_err(|e| e.to_string())?; + let mut zw = zip::ZipWriter::new(out); + let mut files = 0usize; + fn walk( + zw: &mut zip::ZipWriter, + dir: &Path, + rel: &str, + exclude: bool, + files: &mut usize, + ) -> Result<(), String> { + let Ok(rd) = std::fs::read_dir(dir) else { + return Ok(()); + }; + let mut ents: Vec<_> = rd.filter_map(Result::ok).collect(); + ents.sort_by_key(|e| e.file_name()); + for e in ents { + if *files >= WS_WALK_LIMIT { + return Ok(()); + } + let Ok(ft) = e.file_type() else { continue }; + if ft.is_symlink() { + continue; + } + let name = e.file_name().to_string_lossy().into_owned(); + let child = if rel.is_empty() { + name.clone() + } else { + format!("{rel}/{name}") + }; + if ft.is_dir() { + if exclude && rel.is_empty() && name == CONV_FILES_ROOT { + continue; + } + walk(zw, &e.path(), &child, exclude, files)?; + continue; + } + let Ok(meta) = e.metadata() else { continue }; + let Ok(mut f) = std::fs::File::open(e.path()) else { + continue; + }; + let mut opts = zip::write::SimpleFileOptions::default() + .compression_method(zip::CompressionMethod::Deflated) + .large_file(meta.len() >= 0xFFFF_FFFF); + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + opts = opts.unix_permissions(meta.permissions().mode() & 0o777); + } + let secs = mtime_secs(&meta); + let (y, mo, d) = civil(secs.div_euclid(86400)); + let rem = secs.rem_euclid(86400); + if let Ok(dt) = zip::DateTime::from_date_and_time( + y.clamp(1980, 2107) as u16, + mo as u8, + d as u8, + (rem / 3600) as u8, + ((rem / 60) % 60) as u8, + (rem % 60) as u8, + ) { + opts = opts.last_modified_time(dt); + } + zw.start_file(child, opts).map_err(|e| e.to_string())?; + std::io::copy(&mut f, zw).map_err(|e| e.to_string())?; + *files += 1; + } + Ok(()) + } + walk(&mut zw, root, "", exclude_convs, &mut files)?; + zw.finish().map_err(|e| e.to_string())?; + Ok(files) +} + +// --------------------------------------------------------------------------- +// Routes +// --------------------------------------------------------------------------- + +fn fail(code: StatusCode, msg: impl Into) -> Response { + send_json(code, &json!({ "ok": false, "error": msg.into() })) +} + +fn query(uri: &hyper::Uri) -> HashMap { + let mut out = HashMap::new(); + for kv in uri + .query() + .unwrap_or("") + .split('&') + .filter(|s| !s.is_empty()) + { + let (k, v) = kv.split_once('=').unwrap_or((kv, "")); + // r.URL.Query().Get : la première valeur. + out.entry(form_decode(k)).or_insert_with(|| form_decode(v)); + } + out +} + +fn form_decode(s: &str) -> String { + let b = s.as_bytes(); + let mut out = Vec::with_capacity(b.len()); + let mut i = 0; + while i < b.len() { + match b[i] { + b'+' => out.push(b' '), + b'%' if i + 2 < b.len() => { + match std::str::from_utf8(&b[i + 1..i + 3]) + .ok() + .and_then(|h| u8::from_str_radix(h, 16).ok()) + { + Some(v) => { + out.push(v); + i += 3; + continue; + } + None => out.push(b'%'), + } + } + c => out.push(c), + } + i += 1; + } + String::from_utf8_lossy(&out).into_owned() +} + +/// `strconv.ParseInt` ou 0. +fn parse_i64(s: Option<&String>) -> i64 { + s.and_then(|v| v.parse::().ok()).unwrap_or(0) +} + +async fn workspace(ctx: &Ctx) -> Result { + let v = ctx + .store + .internal_json( + Method::POST, + "/internal/v1/conv/workspace", + Some(&json!({})), + ) + .await?; + serde_json::from_value(v).map_err(|e| e.to_string()) +} + +async fn blocking(f: impl FnOnce() -> T + Send + 'static) -> T { + tokio::task::spawn_blocking(f) + .await + .expect("tâche bloquante interrompue") +} + +/// Une tranche base64 d'un fichier (`b64=1&offset=&len=`). +fn b64_slice(path: &str, name: &str, size: i64, q: &HashMap) -> Response { + let off = parse_i64(q.get("offset")); + let mut length = parse_i64(q.get("len")); + if length <= 0 || length > DOWNLOAD_CHUNK_MAX { + length = DOWNLOAD_CHUNK_MAX; + } + if off < 0 || off > size { + return fail(StatusCode::BAD_REQUEST, "position hors du fichier"); + } + if off + length > size { + length = size - off; + } + let mut buf = vec![0u8; length as usize]; + let n = match std::fs::File::open(path).and_then(|mut f| { + f.seek(std::io::SeekFrom::Start(off as u64))?; + let mut n = 0; + while n < buf.len() { + match f.read(&mut buf[n..])? { + 0 => break, + k => n += k, + } + } + Ok(n) + }) { + Ok(n) => n, + Err(e) => return fail(StatusCode::INTERNAL_SERVER_ERROR, e.to_string()), + }; + send_json( + StatusCode::OK, + &json!({ + "ok": true, "name": name, "size": size, "offset": off, + "data": base64::engine::general_purpose::STANDARD.encode(&buf[..n]), + "eof": off + n as i64 >= size, + }), + ) +} + +/// Corps d'un fichier, en flux (une plage d'octets au plus). +fn serve_file( + path: &str, + meta: &std::fs::Metadata, + req_headers: &HeaderMap, + extra: &[(&'static str, String)], +) -> Response { + let size = meta.len(); + let modified = mtime_secs(meta); + if let Some(ims) = req_headers + .get(header::IF_MODIFIED_SINCE) + .and_then(|v| v.to_str().ok()) + && ims == http_date(modified) + { + let mut r = Response::new(Full::new(Bytes::new()).map_err(|n| match n {}).boxed()); + *r.status_mut() = StatusCode::NOT_MODIFIED; + return r; + } + let (mut start, mut end, mut status) = (0u64, size.saturating_sub(1), StatusCode::OK); + if let Some(range) = req_headers + .get(header::RANGE) + .and_then(|v| v.to_str().ok()) + .and_then(|r| r.strip_prefix("bytes=")) + && !range.contains(',') + && size > 0 + { + let (a, b) = range.split_once('-').unwrap_or((range, "")); + let parsed = match (a.trim().parse::(), b.trim().parse::()) { + (Ok(a), Ok(b)) if a <= b && a < size => Some((a, b.min(size - 1))), + (Ok(a), Err(_)) if b.trim().is_empty() && a < size => Some((a, size - 1)), + (Err(_), Ok(n)) if a.trim().is_empty() && n > 0 => { + Some((size.saturating_sub(n), size - 1)) + } + _ => None, + }; + match parsed { + Some((a, b)) => { + (start, end, status) = (a, b, StatusCode::PARTIAL_CONTENT); + } + None => { + let mut r = fail(StatusCode::RANGE_NOT_SATISFIABLE, "plage invalide"); + if let Ok(v) = HeaderValue::from_str(&format!("bytes */{size}")) { + r.headers_mut().insert(header::CONTENT_RANGE, v); + } + return r; + } + } + } + let len = if size == 0 { 0 } else { end - start + 1 }; + let path = path.to_string(); + let stream = futures_util::stream::unfold( + (None::, start, len), + move |(f, pos, left)| { + let path = path.clone(); + async move { + if left == 0 { + return None; + } + let res = tokio::task::spawn_blocking(move || { + let mut f = match f { + Some(f) => f, + None => { + let mut f = std::fs::File::open(&path)?; + f.seek(std::io::SeekFrom::Start(pos))?; + f + } + }; + let mut buf = vec![0u8; left.min(64 << 10) as usize]; + let n = f.read(&mut buf)?; + buf.truncate(n); + Ok::<_, std::io::Error>((f, buf)) + }) + .await + .ok()?; + match res { + Ok((_, buf)) if buf.is_empty() => None, + Ok((f, buf)) => { + let n = buf.len() as u64; + Some(( + Ok::<_, hyper::Error>(Frame::data(Bytes::from(buf))), + (Some(f), pos + n, left - n), + )) + } + Err(_) => None, + } + } + }, + ); + let mut r = Response::new(BodyExt::boxed(StreamBody::new(stream))); + *r.status_mut() = status; + let h = r.headers_mut(); + for (k, v) in extra { + if let Ok(v) = HeaderValue::from_str(v) { + h.insert(*k, v); + } + } + h.insert(header::ACCEPT_RANGES, HeaderValue::from_static("bytes")); + if let Ok(v) = HeaderValue::from_str(&len.to_string()) { + h.insert(header::CONTENT_LENGTH, v); + } + if let Ok(v) = HeaderValue::from_str(&http_date(modified)) { + h.insert(header::LAST_MODIFIED, v); + } + if status == StatusCode::PARTIAL_CONTENT + && let Ok(v) = HeaderValue::from_str(&format!("bytes {start}-{end}/{size}")) + { + h.insert(header::CONTENT_RANGE, v); + } + r +} + +#[derive(Deserialize, Default)] +struct UploadReq { + #[serde(default)] + name: String, + #[serde(default)] + data: String, + #[serde(default)] + id: String, + #[serde(default)] + more: bool, + #[serde(default)] + size: i64, +} + +/// `from_go` : remise par le Go (tunnel), déjà authentifiée par lui. +pub async fn serve( + ctx: &Ctx, + cf: &Arc, + req: Request, + from_go: bool, +) -> Response { + let (parts, body) = req.into_parts(); + if !from_go && let Err(refus) = ctx.guard(&parts.headers).await { + return *refus; + } + let ws = match workspace(ctx).await { + Ok(w) => w, + Err(e) => { + return fail( + StatusCode::SERVICE_UNAVAILABLE, + format!("fichiers indisponibles : {e}"), + ); + } + }; + let q = query(&parts.uri); + let e2e = parts.headers.contains_key(E2E_HEADER); + match parts.uri.path() { + "/api/chat/files" => { + let scope = q.get("scope").cloned().unwrap_or_default(); + let dir = q.get("dir").cloned().unwrap_or_default(); + blocking(move || list_files(&ws, &scope, &dir)).await + } + "/api/chat/file/delete" => { + let v = read_json(body, 1 << 20).await.unwrap_or(Value::Null); + let s = |k: &str| v.get(k).and_then(Value::as_str).unwrap_or("").to_string(); + let (path, scope) = (s("path"), s("scope")); + blocking(move || delete_file(&ws, &path, &scope)).await + } + "/api/chat/file" => { + let rel = q.get("path").cloned().unwrap_or_default(); + if rel.trim().is_empty() { + return fail(StatusCode::BAD_REQUEST, "chemin manquant"); + } + let Some(abs) = ws.workspace_file(&rel) else { + return fail(StatusCode::FORBIDDEN, "hors du dossier de travail"); + }; + let meta = match std::fs::metadata(&abs) { + Ok(m) if !m.is_dir() => m, + _ => return fail(StatusCode::NOT_FOUND, "fichier introuvable"), + }; + let name = crate::paths::base_name(&abs); + if q.contains_key("meta") && !q["meta"].is_empty() { + return send_json( + StatusCode::OK, + &json!({ "ok": true, "name": name, "size": meta.len(), "e2e": e2e }), + ); + } + if q.get("b64").is_some_and(|v| !v.is_empty()) { + let size = meta.len() as i64; + return blocking(move || b64_slice(&abs, &name, size, &q)).await; + } + serve_file( + &abs, + &meta, + &parts.headers, + &[ + ("content-type", "application/octet-stream".into()), + ("x-content-type-options", "nosniff".into()), + ( + "content-disposition", + format!("attachment; filename*=UTF-8''{}", path_escape(&name)), + ), + ], + ) + } + "/api/chat/image" => { + let rel = q.get("path").cloned().unwrap_or_default(); + if rel.trim().is_empty() { + return fail(StatusCode::BAD_REQUEST, "chemin manquant"); + } + let Some(abs) = ws.workspace_file(&rel) else { + return fail(StatusCode::FORBIDDEN, "hors du dossier de travail"); + }; + let meta = match std::fs::metadata(&abs) { + Ok(m) if !m.is_dir() => m, + _ => return fail(StatusCode::NOT_FOUND, "fichier introuvable"), + }; + if let Some(t) = q.get("thumb").filter(|t| !t.is_empty()) { + let px = t.parse::().unwrap_or(0); + let (cf2, abs2, meta2) = (cf.clone(), abs.clone(), meta.clone()); + if let Ok(data) = blocking(move || cf2.thumb(&abs2, &meta2, px)).await { + let mut r = Response::new(Full::new(data).map_err(|n| match n {}).boxed()); + let h = r.headers_mut(); + h.insert(header::CONTENT_TYPE, HeaderValue::from_static("image/jpeg")); + h.insert( + "x-content-type-options", + HeaderValue::from_static("nosniff"), + ); + h.insert( + "content-security-policy", + HeaderValue::from_static("default-src 'none'; sandbox"), + ); + h.insert( + header::CACHE_CONTROL, + HeaderValue::from_static("private, max-age=3600"), + ); + return r; + } + } + let mut head = [0u8; 512]; + let n = match std::fs::File::open(&abs).and_then(|mut f| { + let mut n = 0; + while n < head.len() { + match f.read(&mut head[n..])? { + 0 => break, + k => n += k, + } + } + Ok(n) + }) { + Ok(n) => n, + Err(e) => return fail(StatusCode::INTERNAL_SERVER_ERROR, e.to_string()), + }; + let mime = sniff_image(&head[..n]); + if mime.is_empty() { + return fail( + StatusCode::UNSUPPORTED_MEDIA_TYPE, + "ce fichier n'est pas une image", + ); + } + let name = crate::paths::base_name(&abs); + serve_file( + &abs, + &meta, + &parts.headers, + &[ + ("content-type", mime.into()), + ("x-content-type-options", "nosniff".into()), + ( + "content-security-policy", + "default-src 'none'; sandbox".into(), + ), + ( + "content-disposition", + format!("inline; filename*=UTF-8''{}", path_escape(&name)), + ), + ], + ) + } + "/api/chat/files/zip" => { + let scope = q.get("scope").cloned().unwrap_or_default(); + let legacy = scope.trim() == "legacy"; + let (root, _) = ws.files_root(&scope); + let id = if legacy { + "legacy".to_string() + } else { + let id = safe_conv_id(&ws.id); + if id.is_empty() { "conv".into() } else { id } + }; + let tmp = cf.tmp_dir.join(format!("loki-files-{id}.zip")); + let name = if legacy { + format!("loki-hors-discussion-{}.zip", local_date()) + } else { + let slug = slugify(&ws.title); + let slug = if slug.is_empty() { ws.id.clone() } else { slug }; + format!("loki-{slug}-{}.zip", local_date()) + }; + if q.get("meta").is_some_and(|v| !v.is_empty()) { + let tmp2 = tmp.clone(); + let res = blocking(move || build_zip(Path::new(&root), &tmp2, legacy)).await; + let files = match res { + Ok(n) => n, + Err(e) => return fail(StatusCode::INTERNAL_SERVER_ERROR, e), + }; + if files == 0 { + let _ = std::fs::remove_file(&tmp); + return fail(StatusCode::NOT_FOUND, "aucun fichier à archiver"); + } + let size = std::fs::metadata(&tmp).map(|m| m.len()).unwrap_or(0); + return send_json( + StatusCode::OK, + &json!({ "ok": true, "name": name, "size": size, "files": files, "e2e": e2e }), + ); + } + let meta = match std::fs::metadata(&tmp) { + Ok(m) => m, + Err(_) => { + return fail( + StatusCode::NOT_FOUND, + "archive absente — redemande la fiche (meta=1) d'abord", + ); + } + }; + let tmp_s = tmp.to_string_lossy().into_owned(); + if q.get("b64").is_some_and(|v| !v.is_empty()) { + let size = meta.len() as i64; + return blocking(move || b64_slice(&tmp_s, &name, size, &q)).await; + } + serve_file( + &tmp_s, + &meta, + &HeaderMap::new(), + &[ + ("content-type", "application/zip".into()), + ( + "content-disposition", + format!( + "attachment; filename={}", + serde_json::to_string(&name).unwrap_or_default() + ), + ), + ], + ) + } + "/api/chat/upload" => upload(cf, &ws, body).await, + _ => send_json( + StatusCode::NOT_FOUND, + &json!({ "error": "404 page not found" }), + ), + } +} + +async fn read_json(body: Incoming, max: usize) -> Result { + let b = Limited::new(body, max) + .collect() + .await + .map_err(|e| e.to_string())? + .to_bytes(); + serde_json::from_slice(&b).map_err(|e| e.to_string()) +} + +/// `handleChatFiles`. +fn list_files(ws: &Workspace, scope: &str, dir: &str) -> Response { + let (root, legacy) = ws.files_root(scope); + if !legacy { + let _ = std::fs::create_dir_all(&root); + } + let Some(abs) = ws_resolve(&root, dir) else { + return fail(StatusCode::FORBIDDEN, "hors du dossier de travail"); + }; + if !std::fs::metadata(&abs).is_ok_and(|m| m.is_dir()) { + return fail(StatusCode::NOT_FOUND, "dossier introuvable"); + } + let rd = match std::fs::read_dir(&abs) { + Ok(rd) => rd, + Err(e) => return fail(StatusCode::INTERNAL_SERVER_ERROR, e.to_string()), + }; + let mut ents: Vec<_> = rd.filter_map(Result::ok).collect(); + ents.sort_by_key(|e| e.file_name()); + let cur = rel_within(&root, &abs).unwrap_or_default(); + let mut entries: Vec<(bool, i64, Value)> = Vec::new(); + for e in ents { + let name = e.file_name().to_string_lossy().into_owned(); + if legacy && cur.is_empty() && name == CONV_FILES_ROOT { + continue; + } + let Ok(meta) = e.metadata() else { continue }; + let is_dir = e.file_type().is_ok_and(|t| t.is_dir()); + let p = if cur.is_empty() { + name.clone() + } else { + format!("{cur}/{name}") + }; + let m = mtime_secs(&meta); + let mut it = json!({ "name": name, "path": p, "dir": is_dir, "mod": m }); + if is_dir { + let (size, items) = dir_size(&e.path()); + it["size"] = json!(size); + it["image"] = json!(false); + if items > 0 { + it["items"] = json!(items); + } + } else { + it["size"] = json!(meta.len()); + it["image"] = json!(is_image_ext(&name)); + } + entries.push((is_dir, m, it)); + } + // Dossiers d'abord, puis du plus récent au plus ancien (tri stable). + entries.sort_by(|a, b| b.0.cmp(&a.0).then(b.1.cmp(&a.1))); + let parent = match cur.rfind('/') { + Some(i) => cur[..i].to_string(), + None => String::new(), + }; + let (total, count) = dir_size(Path::new(&root)); + send_json( + StatusCode::OK, + &json!({ + "ok": true, "dir": cur, "parent": parent, "at_root": cur.is_empty(), + "entries": entries.into_iter().map(|e| e.2).collect::>(), + "total": total, "count": count, "root": root, + "scope": if legacy { "legacy" } else { "" }, "legacy": ws.legacy_count(), + }), + ) +} + +/// `handleChatFileDelete`. +fn delete_file(ws: &Workspace, path: &str, scope: &str) -> Response { + if path.trim().is_empty() { + return fail(StatusCode::BAD_REQUEST, "chemin manquant"); + } + let (root, legacy) = ws.files_root(scope); + let Some(abs) = ws_resolve(&root, path) else { + return fail(StatusCode::FORBIDDEN, "hors du dossier de travail"); + }; + if abs == root { + return fail( + StatusCode::FORBIDDEN, + "le dossier de travail lui-même ne se supprime pas", + ); + } + if legacy && abs == go_join(&[&ws.root, CONV_FILES_ROOT]) { + return fail( + StatusCode::FORBIDDEN, + "les dossiers des discussions se suppriment discussion par discussion", + ); + } + let meta = match std::fs::symlink_metadata(&abs) { + Ok(m) => m, + Err(_) => return fail(StatusCode::NOT_FOUND, "introuvable"), + }; + let (freed, res) = if meta.is_dir() { + (dir_size(Path::new(&abs)).0, std::fs::remove_dir_all(&abs)) + } else { + (meta.len() as i64, std::fs::remove_file(&abs)) + }; + if let Err(e) = res { + return fail( + StatusCode::INTERNAL_SERVER_ERROR, + crate::paths::os_err("unlinkat", &abs, &e), + ); + } + send_json(StatusCode::OK, &json!({ "ok": true, "freed": freed })) +} + +/// `handleChatUpload` : un morceau ; le dernier donne au fichier son nom. +async fn upload(cf: &Arc, ws: &Workspace, body: Incoming) -> Response { + let req: UploadReq = match Limited::new(body, 2 * UPLOAD_CHUNK_MAX) + .collect() + .await + .ok() + .and_then(|b| serde_json::from_slice(&b.to_bytes()).ok()) + { + Some(r) => r, + None => { + return fail( + StatusCode::BAD_REQUEST, + "morceau trop gros ou requête invalide", + ); + } + }; + let mut data = req.data.as_str(); + if data.starts_with("data:") + && let Some(i) = data.find(',') + { + data = &data[i + 1..]; + } + // StdEncoding.DecodeString ignore les retours à la ligne. + let cleaned: String = data + .trim() + .chars() + .filter(|c| *c != '\r' && *c != '\n') + .collect(); + let raw = match base64::engine::general_purpose::STANDARD.decode(cleaned) { + Ok(r) => r, + Err(_) => { + return fail( + StatusCode::BAD_REQUEST, + "contenu illisible (base64 attendu)", + ); + } + }; + // Le verrou de la table ne couvre que la recherche ou la création de la + // session : jamais d'attente sous lui. + let found: Result<(String, Arc), Box>> = (|| { + cf.sweep(); + let mut map = lock(&cf.uploads); + if let Some(e) = map.get(&req.id) { + return Ok((req.id.clone(), e.clone())); + } + if !req.id.is_empty() { + return Err(Box::new(fail( + StatusCode::CONFLICT, + "envoi expiré — recommence le fichier", + ))); + } + let dir = go_join(&[&ws.dir, UPLOADS_SUB]); + if let Err(e) = std::fs::create_dir_all(&dir) { + return Err(Box::new(fail( + StatusCode::INTERNAL_SERVER_ERROR, + e.to_string(), + ))); + } + let free = disk_free(&dir); + if free > 0 && req.size > 0 && free < req.size + UPLOAD_SPACE_MARGIN { + return Err(Box::new(send_json( + StatusCode::INSUFFICIENT_STORAGE, + &json!({ "ok": false, "error": format!( + "espace insuffisant : {} libres, {} nécessaires", + human_bytes(free), human_bytes(req.size + UPLOAD_SPACE_MARGIN)) }), + ))); + } + let (f, path) = + create_part(&dir).map_err(|e| Box::new(fail(StatusCode::INTERNAL_SERVER_ERROR, e)))?; + let id = path + .file_name() + .map(|n| n.to_string_lossy().into_owned()) + .unwrap_or_default(); + let e = Arc::new(UpEntry { + sess: tokio::sync::Mutex::new(Some(UpSession { + f, + path, + name: safe_upload_name(&req.name), + written: 0, + })), + last: Mutex::new(Instant::now()), + busy: std::sync::atomic::AtomicBool::new(false), + }); + map.insert(id.clone(), e.clone()); + Ok((id, e)) + })(); + let (id, entry) = match found { + Ok(x) => x, + Err(r) => return *r, + }; + finish_chunk(cf, &id, entry, raw, req.more).await +} + +/// `.upload-.part`, créé exclusivement (`os.CreateTemp`). +fn create_part(dir: &str) -> Result<(std::fs::File, PathBuf), String> { + for _ in 0..10000 { + let mut b = [0u8; 4]; + getrandom::fill(&mut b).map_err(|e| e.to_string())?; + let p = PathBuf::from(go_join(&[ + dir, + &format!(".upload-{}.part", u32::from_le_bytes(b)), + ])); + match std::fs::OpenOptions::new() + .write(true) + .create_new(true) + .open(&p) + { + Ok(f) => return Ok((f, p)), + Err(e) if e.kind() == std::io::ErrorKind::AlreadyExists => continue, + Err(e) => return Err(e.to_string()), + } + } + Err("aucun nom de fichier temporaire libre".into()) +} + +async fn finish_chunk( + cf: &Arc, + id: &str, + entry: Arc, + raw: Vec, + more: bool, +) -> Response { + use std::sync::atomic::Ordering; + *lock(&entry.last) = Instant::now(); + entry.busy.store(true, Ordering::SeqCst); + let mut guard = entry.sess.lock().await; + let res = (|| { + let Some(s) = guard.as_mut() else { + return Err(( + StatusCode::CONFLICT, + "envoi expiré — recommence le fichier".to_string(), + )); + }; + let drop_sess = |s: &UpSession| { + lock(&cf.uploads).remove(id); + let _ = std::fs::remove_file(&s.path); + }; + if s.written + raw.len() as i64 > UPLOAD_MAX_BYTES { + drop_sess(s); + return Err(( + StatusCode::PAYLOAD_TOO_LARGE, + format!("fichier trop gros (max {})", human_bytes(UPLOAD_MAX_BYTES)), + )); + } + if !raw.is_empty() { + if let Err(e) = s.f.write_all(&raw) { + drop_sess(s); + return Err((StatusCode::INTERNAL_SERVER_ERROR, e.to_string())); + } + s.written += raw.len() as i64; + } + if more { + return Ok(json!({ "ok": true, "id": id, "received": s.written })); + } + let sync = s.f.sync_all(); + lock(&cf.uploads).remove(id); + let part = s.path.clone(); + if let Err(e) = sync { + let _ = std::fs::remove_file(&part); + return Err((StatusCode::INTERNAL_SERVER_ERROR, e.to_string())); + } + if s.written == 0 { + let _ = std::fs::remove_file(&part); + return Err((StatusCode::BAD_REQUEST, "fichier vide".to_string())); + } + let dir = part + .parent() + .map(|p| p.to_string_lossy().into_owned()) + .unwrap_or_default(); + let dest = unique_upload_path(&dir, &s.name); + if let Err(e) = std::fs::rename(&part, &dest) { + let _ = std::fs::remove_file(&part); + return Err((StatusCode::INTERNAL_SERVER_ERROR, e.to_string())); + } + Ok(json!({ + "ok": true, + "path": format!("uploads/{}", crate::paths::base_name(&dest)), + "abs": dest, + "size": s.written, + })) + })(); + if !more || res.is_err() { + guard.take(); + } + drop(guard); + entry.busy.store(false, Ordering::SeqCst); + match res { + Ok(v) => send_json(StatusCode::OK, &v), + Err((code, msg)) => fail(code, msg), + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn dates() { + assert_eq!(civil(0), (1970, 1, 1)); + assert_eq!(civil(19723), (2024, 1, 1)); + assert_eq!(http_date(1700000000), "Tue, 14 Nov 2023 22:13:20 GMT"); + assert_eq!(local_date().len(), 10); + } + + #[test] + fn extensions() { + assert_eq!(go_ext("a.tar.gz"), ".gz"); + assert_eq!(go_ext("rep.d/fichier"), ""); + assert_eq!(go_ext("sans"), ""); + } +} diff --git a/rust/loki-rs/src/lib.rs b/rust/loki-rs/src/lib.rs index 7b1913c..e3dcf35 100644 --- a/rust/loki-rs/src/lib.rs +++ b/rust/loki-rs/src/lib.rs @@ -15,6 +15,7 @@ pub mod auth; pub mod chat; pub mod chatapi; pub mod child; +pub mod convfiles; pub mod dictate; pub mod downloads; pub mod engine; diff --git a/rust/loki-rs/src/main.rs b/rust/loki-rs/src/main.rs index 06cd973..ec19d76 100644 --- a/rust/loki-rs/src/main.rs +++ b/rust/loki-rs/src/main.rs @@ -127,6 +127,9 @@ fn serve() -> ExitCode { // LOKI_SETTINGS=rust : les réglages partagés (préférences, prompt // système, intensité du raisonnement, adresse publique). let settings_rust = held_by_facade("LOKI_SETTINGS"); + // LOKI_FILES=rust : les fichiers de la discussion (dépôt, panneau + // Fichiers, archive, téléchargement, images). + let files_rust = held_by_facade("LOKI_FILES"); let mut env = vec![(auth::INTERNAL_ENV.to_string(), token.clone())]; if chat_rust { env.push(("LOKI_CHAT".into(), "rust".into())); @@ -140,7 +143,10 @@ fn serve() -> ExitCode { if settings_rust { env.push(("LOKI_SETTINGS".into(), "rust".into())); } - if chat_rust || models_rust || dictate_rust || settings_rust { + if files_rust { + env.push(("LOKI_FILES".into(), "rust".into())); + } + if chat_rust || models_rust || dictate_rust || settings_rust || files_rust { env.push(("LOKI_FACADE_ADDR".into(), format!("127.0.0.1:{web_port}"))); } let sup = child::Supervisor::spawn(go_bin, go_port, env); @@ -184,6 +190,12 @@ fn serve() -> ExitCode { std::sync::Arc::new(loki_rs::dictate::Dictation::new(&engine_paths.home)) }); + let files = if files_rust { + Some(start_files(backend, &token).await) + } else { + None + }; + let addr = SocketAddr::from(([0, 0, 0, 0], web_port)); let listener = match tokio::net::TcpListener::bind(addr).await { Ok(l) => l, @@ -203,6 +215,7 @@ fn serve() -> ExitCode { models, dictate, settings: settings_rust, + files, }; let res = proxy::serve(listener, cfg, shutdown_signal()).await; eprintln!("[loki-rs] arrêt demandé — arrêt du serveur Go"); @@ -258,6 +271,33 @@ async fn start_models( std::sync::Arc::new(loki_rs::models::Models::new()) } +/// Les envois interrompus (process arrêté en plein dépôt) ne reprendront +/// jamais : leurs `.part` sont vidés au démarrage (le Go ne le fait plus quand +/// la façade tient les fichiers). +async fn start_files( + backend: SocketAddr, + token: &str, +) -> std::sync::Arc { + let st = loki_rs::store::Store::new(backend, token.to_string()); + let root = st + .internal_json( + hyper::Method::POST, + "/internal/v1/conv/workspace", + Some(&serde_json::json!({})), + ) + .await + .ok() + .and_then(|v| v.get("root").and_then(|r| r.as_str()).map(str::to_string)); + if let Some(root) = root { + let _ = tokio::task::spawn_blocking(move || { + loki_rs::convfiles::clean_stale_upload_parts(&root) + }) + .await; + } + eprintln!("[loki-rs] fichiers de la discussion tenus par la façade (LOKI_FILES=rust)"); + std::sync::Arc::new(loki_rs::convfiles::ConvFiles::default()) +} + /// Le Go garde-t-il la conversation ? (quelques essais : il vient de démarrer) async fn go_keeps_chat(backend: SocketAddr, token: &str) -> Result, String> { let store = loki_rs::store::Store::new(backend, token.to_string()); diff --git a/rust/loki-rs/src/proxy.rs b/rust/loki-rs/src/proxy.rs index c9bdac1..5197da7 100644 --- a/rust/loki-rs/src/proxy.rs +++ b/rust/loki-rs/src/proxy.rs @@ -71,6 +71,8 @@ pub struct Config { pub dictate: Option>, /// Réglages partagés tenus par la façade (`LOKI_SETTINGS=rust`). pub settings: bool, + /// Fichiers de la discussion tenus par la façade (`LOKI_FILES=rust`). + pub files: Option>, } struct Proxy { @@ -101,6 +103,7 @@ pub async fn serve( models: cfg.models, dictate: cfg.dictate, settings: cfg.settings, + files: cfg.files, internal_token: cfg.internal_token.clone(), }, }); @@ -182,6 +185,11 @@ impl Proxy { { return chatapi::serve(&self.ctx, chat, req, from_go).await; } + if let Some(f) = &self.ctx.files + && crate::convfiles::handles(path) + { + return crate::convfiles::serve(&self.ctx, f, req, from_go).await; + } if self.ctx.settings && crate::settings::handles(path) { return crate::settings::serve(&self.ctx, req, from_go).await; } diff --git a/rust/loki-rs/tests/common/fakego.rs b/rust/loki-rs/tests/common/fakego.rs index b45e3ca..766ba26 100644 --- a/rust/loki-rs/tests/common/fakego.rs +++ b/rust/loki-rs/tests/common/fakego.rs @@ -62,6 +62,8 @@ pub struct FakeGo { pub turn_view: Mutex>, pub turn_verifier: Mutex>, pub tool_results: Mutex>, + /// Réponse de /internal/v1/conv/workspace (None : 404). + pub conv_workspace: Mutex>, } impl FakeGo { @@ -291,6 +293,10 @@ async fn handle(st: Arc, req: Request) -> Response *st.go_busy.lock().unwrap() = None; resp(204, "") } + "/internal/v1/conv/workspace" => match st.conv_workspace.lock().unwrap().clone() { + Some(v) => resp(200, v.to_string()), + None => resp(404, "404 page not found"), + }, "/internal/v1/tool/context" => match st.tool_ctx.lock().unwrap().clone() { Some(v) => resp(200, v.to_string()), None => resp(404, "404 page not found"), @@ -466,6 +472,7 @@ pub struct Opts { pub models_hf: Option, pub dictate: Option>, pub settings: bool, + pub files: Option>, } /// Comme [`start`], avec la conversation tenue par la façade @@ -558,6 +565,7 @@ pub async fn start_with( }), dictate: opts.dictate, settings: opts.settings, + files: opts.files, }; tokio::spawn(loki_rs::proxy::serve(fl, cfg, async move { let _ = rx.await; diff --git a/rust/loki-rs/tests/convfiles.rs b/rust/loki-rs/tests/convfiles.rs new file mode 100644 index 0000000..546b137 --- /dev/null +++ b/rust/loki-rs/tests/convfiles.rs @@ -0,0 +1,334 @@ +//! Lot 11 : fichiers de la discussion tenus par la façade (`LOKI_FILES=rust`). +//! +//! Le journal tiré du Go (`internal/loki/parity_files_test.go` → +//! `tests/parity_files/files.json`) est rejoué : noms de dépôt, slug, +//! orientation EXIF, redressement et réduction des pixels, reniflage, puis +//! 42 appels de route par la façade sur la même arborescence — panneau +//! Fichiers, téléchargement, image et vignette, dépôt en morceaux, +//! suppression, archive .zip — avec les fichiers restants après chaque POST. + +mod common; + +use std::collections::BTreeMap; +use std::net::SocketAddr; +use std::path::Path; +use std::sync::Arc; +use std::time::{Duration, UNIX_EPOCH}; + +use bytes::Bytes; +use common::fakego; +use http_body_util::{BodyExt, Full}; +use hyper::{Request, StatusCode}; +use loki_rs::convfiles::{self, Rgba}; +use serde_json::Value; + +fn fixture() -> Value { + serde_json::from_str(include_str!("parity_files/files.json")).expect("journal") +} + +fn b64(s: &str) -> Vec { + use base64::Engine; + base64::engine::general_purpose::STANDARD.decode(s).unwrap() +} + +fn pix(v: &Value) -> Rgba { + Rgba { + w: v["w"].as_u64().unwrap() as usize, + h: v["h"].as_u64().unwrap() as usize, + pix: b64(v["pix"].as_str().unwrap_or("")), + } +} + +#[test] +fn parite_des_fonctions_pures() { + let fx = fixture(); + for (n, want) in fx["upload_name"].as_object().unwrap() { + assert_eq!( + convfiles::safe_upload_name(n), + want.as_str().unwrap(), + "{n:?}" + ); + } + for (s, want) in fx["slug"].as_object().unwrap() { + assert_eq!(convfiles::slugify(s), want.as_str().unwrap(), "{s:?}"); + } + for c in fx["exif"].as_array().unwrap() { + assert_eq!( + convfiles::exif_orientation(&b64(c["in"].as_str().unwrap())), + c["out"].as_i64().unwrap() as i32 + ); + } + let src = pix(&fx["orient_in"]); + for c in fx["orient"].as_array().unwrap() { + let o = c["orient"].as_i64().unwrap() as i32; + assert_eq!( + convfiles::apply_orientation(&src, o), + pix(&c["out"]), + "orientation {o}" + ); + } + for c in fx["scale"].as_array().unwrap() { + let max = c["max"].as_u64().unwrap() as usize; + assert_eq!( + convfiles::scale_down_to_max(&pix(&c["in"]), max), + pix(&c["out"]), + "{max}" + ); + } + for c in fx["sniff"].as_array().unwrap() { + let want = c["out"].as_str().unwrap(); + let got = convfiles::sniff_image(&b64(c["in"].as_str().unwrap_or(""))); + if want.starts_with("image/") { + assert_eq!(got, want); + } else { + assert_eq!(got, "", "{want}"); + } + } +} + +fn set_mtime(p: &Path, secs: i64) { + let f = std::fs::File::open(p).unwrap(); + f.set_modified(UNIX_EPOCH + Duration::from_secs(secs as u64)) + .unwrap(); +} + +fn tree(dir: &Path) -> BTreeMap { + let mut out = BTreeMap::new(); + let mut stack = vec![dir.to_path_buf()]; + while let Some(d) = stack.pop() { + let Ok(rd) = std::fs::read_dir(&d) else { + continue; + }; + for e in rd.filter_map(Result::ok) { + let p = e.path(); + if p.is_dir() { + stack.push(p); + } else { + let rel = p.strip_prefix(dir).unwrap().to_string_lossy().into_owned(); + let rel = mask_part(&rel); + out.insert(rel, e.metadata().unwrap().len()); + } + } + } + out +} + +fn mask_part(s: &str) -> String { + let re = regex::Regex::new(r"\.upload-[0-9]+\.part").unwrap(); + re.replace_all(s, ".upload-{{N}}.part").into_owned() +} + +async fn call( + facade: SocketAddr, + method: &str, + url: &str, + body: &str, +) -> (StatusCode, hyper::HeaderMap, Bytes) { + use hyper_util::client::legacy::Client; + use hyper_util::rt::TokioExecutor; + let client: Client<_, Full> = Client::builder(TokioExecutor::new()).build_http(); + let r = client + .request( + Request::builder() + .method(method) + .uri(format!("http://{facade}{url}")) + .body(Full::new(Bytes::from(body.to_string()))) + .unwrap(), + ) + .await + .unwrap(); + let (p, b) = r.into_parts(); + (p.status, p.headers, b.collect().await.unwrap().to_bytes()) +} + +#[tokio::test] +async fn parite_des_routes() { + let fx = fixture(); + let tmp = tempfile::tempdir().unwrap(); + let home = tmp.path().canonicalize().unwrap(); + let h = home.to_string_lossy().into_owned(); + // L'arborescence, fichiers profonds d'abord, puis les dates des dossiers. + let mut nodes: Vec<(&String, &Value)> = fx["tree"].as_object().unwrap().iter().collect(); + nodes.sort_by_key(|(k, _)| std::cmp::Reverse(k.len())); + for (k, n) in &nodes { + let p = home.join(k); + if n.get("dir").and_then(Value::as_bool).unwrap_or(false) { + std::fs::create_dir_all(&p).unwrap(); + } else { + std::fs::create_dir_all(p.parent().unwrap()).unwrap(); + std::fs::write(&p, b64(n.get("data").and_then(Value::as_str).unwrap_or(""))).unwrap(); + } + } + for (k, n) in &nodes { + set_mtime(&home.join(k), n["mtime"].as_i64().unwrap()); + } + let zips = home.join("tmp"); + std::fs::create_dir_all(&zips).unwrap(); + let opts = fakego::Opts { + files: Some(Arc::new(convfiles::ConvFiles::new(zips))), + ..Default::default() + }; + let (facade, go, _stop, _) = fakego::start_with(home.clone(), opts).await; + let ws: Value = + serde_json::from_str(&fx["workspace"].to_string().replace("{{HOME}}", &h)).unwrap(); + *go.conv_workspace.lock().unwrap() = Some(ws.clone()); + let conv_dir = Path::new(ws["dir"].as_str().unwrap()).to_path_buf(); + + let date_re = regex::Regex::new(r"[0-9]{4}-[0-9]{2}-[0-9]{2}\.zip").unwrap(); + let mut last_id = String::new(); + for (i, c) in fx["calls"].as_array().unwrap().iter().enumerate() { + let (m, url) = (c["method"].as_str().unwrap(), c["url"].as_str().unwrap()); + let body = c + .get("body") + .and_then(Value::as_str) + .unwrap_or("") + .replace("{{ID}}", &last_id); + let (code, headers, b) = call(facade, m, url, &body).await; + let ctx = format!("appel {i} : {m} {url} {body}"); + assert_eq!( + code.as_u16() as u64, + c["status"].as_u64().unwrap(), + "{ctx} : {b:?}" + ); + if let Some(want) = c.get("out") { + let txt = String::from_utf8_lossy(&b).replace(&h, "{{HOME}}"); + let txt = date_re.replace_all(&txt, "{{DATE}}.zip").into_owned(); + let mut got: Value = serde_json::from_str(&txt).unwrap(); + if let Some(id) = got.get("id").and_then(Value::as_str) { + last_id = id.to_string(); + got["id"] = Value::String("{{ID}}".into()); + } + if url.starts_with("/api/chat/files/zip") { + if let Some(d) = got.get("data").and_then(Value::as_str) { + let raw = b64(d); + let mut zr = zip::ZipArchive::new(std::io::Cursor::new(raw)).unwrap(); + let mut entries = BTreeMap::new(); + for k in 0..zr.len() { + let mut f = zr.by_index(k).unwrap(); + let mut v = Vec::new(); + std::io::Read::read_to_end(&mut f, &mut v).unwrap(); + entries.insert(f.name().to_string(), v); + } + let want_zip: BTreeMap> = c["zip"] + .as_object() + .unwrap() + .iter() + .map(|(k, v)| (k.clone(), b64(v.as_str().unwrap_or("")))) + .collect(); + assert_eq!(entries, want_zip, "{ctx} (contenu de l'archive)"); + got["data"] = Value::String("…".into()); + } + if got.get("size").is_some() { + got["size"] = Value::String("…".into()); + } + } + if let Some(p) = got.get("path").and_then(Value::as_str) { + got["path"] = Value::String(mask_part(p)); + } + assert_eq!(&got, want, "{ctx}"); + } + if let Some(raw) = c.get("raw").and_then(Value::as_str) { + assert_eq!(b.to_vec(), b64(raw), "{ctx} (corps)"); + } + if let Some(dims) = c.get("thumb").and_then(Value::as_array) { + let img = image::load_from_memory(&b).unwrap(); + assert_eq!( + [img.width() as u64, img.height() as u64], + [dims[0].as_u64().unwrap(), dims[1].as_u64().unwrap()], + "{ctx} (vignette)" + ); + } + if let Some(hs) = c.get("headers").and_then(Value::as_object) { + for (k, v) in hs { + assert_eq!( + headers.get(k.as_str()).and_then(|x| x.to_str().ok()), + v.as_str(), + "{ctx} (en-tête {k})" + ); + } + } + if let Some(want) = c.get("tree") { + let want: BTreeMap = serde_json::from_value(want.clone()).unwrap(); + assert_eq!(tree(&conv_dir), want, "{ctx} (fichiers)"); + } + } +} + +#[tokio::test] +async fn plages_et_liens() { + let tmp = tempfile::tempdir().unwrap(); + let home = tmp.path().canonicalize().unwrap(); + let dir = home.join("ws/discussions/c1"); + std::fs::create_dir_all(&dir).unwrap(); + std::fs::write(dir.join("a.txt"), b"0123456789").unwrap(); + // Un lien vers l'extérieur ne sert pas d'échappatoire. + std::os::unix::fs::symlink("/etc", dir.join("dehors")).unwrap(); + let opts = fakego::Opts { + files: Some(Arc::new(convfiles::ConvFiles::new(home.clone()))), + ..Default::default() + }; + let (facade, go, _stop, _) = fakego::start_with(home.clone(), opts).await; + *go.conv_workspace.lock().unwrap() = Some(serde_json::json!({ + "dir": dir, "root": home.join("ws"), "id": "c1", "title": "Ma discussion" + })); + use hyper_util::client::legacy::Client; + use hyper_util::rt::TokioExecutor; + let client: Client<_, Full> = Client::builder(TokioExecutor::new()).build_http(); + let r = client + .request( + Request::get(format!("http://{facade}/api/chat/file?path=a.txt")) + .header("range", "bytes=2-5") + .body(Full::new(Bytes::new())) + .unwrap(), + ) + .await + .unwrap(); + assert_eq!(r.status(), StatusCode::PARTIAL_CONTENT); + assert_eq!(r.headers()["content-range"], "bytes 2-5/10"); + assert_eq!( + &r.into_body().collect().await.unwrap().to_bytes()[..], + b"2345" + ); + let (code, _, _) = call( + facade, + "GET", + "/api/chat/file?path=dehors/passwd&meta=1", + "", + ) + .await; + assert_eq!(code, StatusCode::FORBIDDEN); + let (code, _, b) = call(facade, "GET", "/api/chat/files", "").await; + assert_eq!(code, StatusCode::OK); + let v: Value = serde_json::from_slice(&b).unwrap(); + // Le lien est listé (comme le Go), mais son contenu ne compte pas. + assert_eq!( + (v["total"].as_i64(), v["count"].as_i64()), + (Some(10), Some(1)) + ); + // Nom d'archive tiré du titre. + let (_, _, b) = call(facade, "GET", "/api/chat/files/zip?meta=1", "").await; + let v: Value = serde_json::from_slice(&b).unwrap(); + assert!( + v["name"] + .as_str() + .unwrap() + .starts_with("loki-ma-discussion-"), + "{v}" + ); + assert_eq!(v["files"], 1); +} + +#[tokio::test] +async fn sans_la_variable_les_fichiers_restent_au_go() { + let tmp = tempfile::tempdir().unwrap(); + let (facade, _go, _stop) = fakego::start(tmp.path().to_path_buf()).await; + for p in [ + "/api/chat/files", + "/api/chat/file", + "/api/chat/image", + "/api/chat/files/zip", + ] { + let (_, _, b) = fakego::get(facade, p, &[]).await; + assert_eq!(&b[..], format!("go:{p}").as_bytes()); + } +} diff --git a/rust/loki-rs/tests/lot2.rs b/rust/loki-rs/tests/lot2.rs index c3810f9..d52cce2 100644 --- a/rust/loki-rs/tests/lot2.rs +++ b/rust/loki-rs/tests/lot2.rs @@ -97,6 +97,7 @@ async fn banc(cle: Option<&str>) -> Banc { models: None, dictate: None, settings: false, + files: None, }; tokio::spawn(loki_rs::proxy::serve(l, cfg, async move { let _ = rx.await; diff --git a/rust/loki-rs/tests/parity_files/files.json b/rust/loki-rs/tests/parity_files/files.json new file mode 100644 index 0000000..9cfc7bc --- /dev/null +++ b/rust/loki-rs/tests/parity_files/files.json @@ -0,0 +1,975 @@ +{ + "upload_name": { + "": "fichier", + " .cache ": "cache", + "..": "fichier", + "../../etc/passwd": "passwd", + "C:\\Users\\moi\\photo.jpg": "photo.jpg", + "a:b*c?d\"eg|h.txt": "a_b_c_d_e_f_g_h.txt", + "nnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnn.eeeeeeeeeeeeeeeeeeee": "nnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnn", + "nnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnn.pdf": "nnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnn.pdf", + "rapport.pdf": "rapport.pdf", + "x\u0001y.txt": "x_y.txt" + }, + "slug": { + "": "", + " Rapport_Final.v2 ": "rapport-final-v2", + "!!!": "", + "--a--": "a", + "Déjà vu": "dj-vu", + "abc abc abc abc abc abc abc abc abc abc abc abc abc abc abc abc abc abc abc abc ": "abc-abc-abc-abc-abc-abc-abc-abc-abc-abc" + }, + "exif": [ + { + "in": "/9j/4AAGSkZJRv/hACpFeGlmAABJSSoACAAAAAIAEgEDAAEAAAAGAAAADwECAAEAAAAAAAAA/9oAAg==", + "out": 6 + }, + { + "in": "/9j/4AAGSkZJRv/hACpFeGlmAABNTQAqAAAACAACAQ8AAgAAAAEAAAAAARIAAwAAAAEACAAA/9oAAg==", + "out": 8 + }, + { + "in": "/9j/4AAGSkZJRv/hACpFeGlmAABJSSoACAAAAAIADwECAAEAAAAAAAAAEgEDAAEAAAABAAAA/9oAAg==", + "out": 1 + }, + { + "in": "/9j/4AAGSkZJRv/hACpFeGlmAABJSSoACAAAAAIA", + "out": 0 + }, + { + "in": "/9j/2g==", + "out": 0 + }, + { + "in": "cGFzIHVuIGpwZWc=", + "out": 0 + } + ], + "orient": [ + { + "orient": 1, + "out": { + "w": 5, + "h": 3, + "pix": "CzBVep/E6Q4zWH2ix+wRNluApcrvFDleg6jN8hc8YYar0PUaP2SJrtP4HUJnjLHW+yBFao+02f4jSG2S" + } + }, + { + "orient": 2, + "out": { + "w": 5, + "h": 3, + "pix": "W4ClysfsETYzWH2in8TpDgswVXo/ZImuq9D1Ghc8YYaDqM3y7xQ5XiNIbZKPtNn++yBFameMsdbT+B1C" + } + }, + { + "orient": 3, + "out": { + "w": 5, + "h": 3, + "pix": "I0htko+02f77IEVqZ4yx1tP4HUI/ZImuq9D1Ghc8YYaDqM3y7xQ5XluApcrH7BE2M1h9op/E6Q4LMFV6" + } + }, + { + "orient": 4, + "out": { + "w": 5, + "h": 3, + "pix": "0/gdQmeMsdb7IEVqj7TZ/iNIbZLvFDleg6jN8hc8YYar0PUaP2SJrgswVXqfxOkOM1h9osfsETZbgKXK" + } + }, + { + "orient": 5, + "out": { + "w": 3, + "h": 5, + "pix": "CzBVeu8UOV7T+B1Cn8TpDoOozfJnjLHWM1h9ohc8YYb7IEVqx+wRNqvQ9RqPtNn+W4Clyj9kia4jSG2S" + } + }, + { + "orient": 6, + "out": { + "w": 3, + "h": 5, + "pix": "0/gdQu8UOV4LMFV6Z4yx1oOozfKfxOkO+yBFahc8YYYzWH2ij7TZ/qvQ9RrH7BE2I0htkj9kia5bgKXK" + } + }, + { + "orient": 7, + "out": { + "w": 3, + "h": 5, + "pix": "I0htkj9kia5bgKXKj7TZ/qvQ9RrH7BE2+yBFahc8YYYzWH2iZ4yx1oOozfKfxOkO0/gdQu8UOV4LMFV6" + } + }, + { + "orient": 8, + "out": { + "w": 3, + "h": 5, + "pix": "W4Clyj9kia4jSG2Sx+wRNqvQ9RqPtNn+M1h9ohc8YYb7IEVqn8TpDoOozfJnjLHWCzBVeu8UOV7T+B1C" + } + } + ], + "orient_in": { + "w": 5, + "h": 3, + "pix": "CzBVep/E6Q4zWH2ix+wRNluApcrvFDleg6jN8hc8YYar0PUaP2SJrtP4HUJnjLHW+yBFao+02f4jSG2S" + }, + "scale": [ + { + "in": { + "w": 7, + "h": 5, + "pix": "gabL8BU6X4SpzvMYPWKHrNH2G0Bliq/U+R5DaI2y1/whRmuQtdr/JEluk7jdAidMcZa74AUqT3SZvuMILVJ3nMHmCzBVep/E6Q4zWH2ix+wRNluApcrvFDleg6jN8hc8YYar0PUaP2SJrtP4HUJnjLHW+yBFao+02f4jSG2St9wBJktwlbrfBClOc5g=" + }, + "max": 3, + "out": { + "w": 3, + "h": 2, + "pix": "UXabwHmew2iVZV+Eb5S5Xpe8YYZsWH2G" + } + }, + { + "in": { + "w": 5, + "h": 9, + "pix": "7xQ5XoOozfIXPGGGq9D1Gj9kia7T+B1CZ4yx1vsgRWqPtNn+I0htkrfcASZLcJW63wQpTnOYveIHLFF2m8DlCi9UeZ7D6A0yV3yhxusQNVp/pMnuEzhdgqfM8RY7YIWqz/QZPmOIrdL3HEFmi7DV+h9EaY6z2P0iR2yRttsAJUpvlLneAyhNcpe84QYrUHWav+QJLlN4ncLnDDFWe6DF6g80WX6jyO0SN1yBpsvwFTpfhKnO" + }, + "max": 4, + "out": { + "w": 2, + "h": 4, + "pix": "q5B1mnJskYxzmH1ij19ZfntghaqCp6FxdW9pZHV9ho4=" + } + }, + { + "in": { + "w": 10, + "h": 10, + "pix": "THGWu+AFKk90mb7jCC1Sd5zB5gswVXqfxOkOM1h9osfsETZbgKXK7xQ5XoOozfIXPGGGq9D1Gj9kia7T+B1CZ4yx1vsgRWqPtNn+I0htkrfcASZLcJW63wQpTnOYveIHLFF2m8DlCi9UeZ7D6A0yV3yhxusQNVp/pMnuEzhdgqfM8RY7YIWqz/QZPmOIrdL3HEFmi7DV+h9EaY6z2P0iR2yRttsAJUpvlLneAyhNcpe84QYrUHWav+QJLlN4ncLnDDFWe6DF6g80WX6jyO0SN1yBpsvwFTpfhKnO8xg9Yoes0fYbQGWKr9T5HkNojbLX/CFGa5C12v8kSW6TuN0CJ0xxlrvgBSpPdJm+4wgtUnecweYLMFV6n8TpDjNYfaLH7BE2W4Clyu8UOV6DqM3yFzxhhqvQ9Ro/ZImu0/gdQmeMsdb7IEVqj7TZ/iNIbZK33AEmS3CVut8EKU5zmL3iByxRdpvA5QovVHmew+gNMld8ocbrEDVaf6TJ7hM4XYKnzPEWO2CFqs/0GT5jiK3S9w==" + }, + "max": 3, + "out": { + "w": 3, + "h": 3, + "pix": "b1uAiICJdWF/eYmZcZaCb4Nvd5yCkYxxkXaGlXhygnx+g3h9" + } + }, + { + "in": { + "w": 3, + "h": 2, + "pix": "QmeMsdb7IEVqj7TZ/iNIbZK33AEmS3CV" + }, + "max": 5, + "out": { + "w": 3, + "h": 2, + "pix": "QmeMsdb7IEVqj7TZ/iNIbZK33AEmS3CV" + } + } + ], + "sniff": [ + { + "in": "iVBORw0KGgoAAAANSUhEUg==", + "out": "image/png" + }, + { + "in": "/9j/4A==", + "out": "image/jpeg" + }, + { + "in": "R0lGODlhAQA=", + "out": "image/gif" + }, + { + "in": "UklGRhAAAABXRUJQVlA4IA==", + "out": "image/webp" + }, + { + "in": "Qk0AAA==", + "out": "image/bmp" + }, + { + "in": "AAABAAEA", + "out": "image/x-icon" + }, + { + "in": "PHN2ZyB4bWxucz0neCcvPg==", + "out": "text/plain; charset=utf-8" + }, + { + "in": "PGh0bWw+PGJvZHk+eA==", + "out": "text/html; charset=utf-8" + }, + { + "in": "Ym9uam91cg==", + "out": "text/plain; charset=utf-8" + }, + { + "in": "", + "out": "text/plain; charset=utf-8" + } + ], + "workspace": { + "dir": "{{HOME}}/workspace/discussions/c-fichiers", + "id": "c-fichiers", + "root": "{{HOME}}/workspace", + "title": "" + }, + "tree": { + "workspace/discussions/c-autre/captures/x.jpg": { + "data": "anBlZz8=", + "mtime": 1700000004 + }, + "workspace/discussions/c-fichiers/faux.png": { + "data": "PGh0bWw+cGFzIHVuZSBpbWFnZTwvaHRtbD4=", + "mtime": 1700000005 + }, + "workspace/discussions/c-fichiers/image.png": { + "data": "iVBORw0KGgoAAAANSUhEUgAAAAQAAAADCAYAAAC09K7GAAAAQ0lEQVR4nAAzAMz/AxY7YIWfssTX6fwOobPG2OsAZouw1fofRGmOs9j9IkdskQC22wAlSm+Uud4DKE1yl7zhAQAA//+6OxpmJmJbrAAAAABJRU5ErkJggg==", + "mtime": 1700000040 + }, + "workspace/discussions/c-fichiers/notes": { + "dir": true, + "mtime": 1700000020 + }, + "workspace/discussions/c-fichiers/notes/a.txt": { + "data": "YWxwaGE=", + "mtime": 1700000010 + }, + "workspace/discussions/c-fichiers/notes/sous": { + "dir": true, + "mtime": 1700000012 + }, + "workspace/discussions/c-fichiers/notes/sous/b.txt": { + "data": "YmV0YSEh", + "mtime": 1700000011 + }, + "workspace/discussions/c-fichiers/rapport.md": { + "data": "IyBSYXBwb3J0Cg==", + "mtime": 1700000030 + }, + "workspace/uploads": { + "dir": true, + "mtime": 1700000003 + }, + "workspace/uploads/ancien.pdf": { + "data": "JVBERg==", + "mtime": 1700000002 + }, + "workspace/vieux.txt": { + "data": "b2xk", + "mtime": 1700000001 + } + }, + "calls": [ + { + "method": "GET", + "url": "/api/chat/files", + "status": 200, + "out": { + "at_root": true, + "count": 5, + "dir": "", + "entries": [ + { + "dir": true, + "image": false, + "items": 2, + "mod": 1700000020, + "name": "notes", + "path": "notes", + "size": 11 + }, + { + "dir": false, + "image": true, + "mod": 1700000040, + "name": "image.png", + "path": "image.png", + "size": 124 + }, + { + "dir": false, + "image": false, + "mod": 1700000030, + "name": "rapport.md", + "path": "rapport.md", + "size": 10 + }, + { + "dir": false, + "image": true, + "mod": 1700000005, + "name": "faux.png", + "path": "faux.png", + "size": 26 + } + ], + "legacy": 2, + "ok": true, + "parent": "", + "root": "{{HOME}}/workspace/discussions/c-fichiers", + "scope": "", + "total": 171 + } + }, + { + "method": "GET", + "url": "/api/chat/files?dir=notes", + "status": 200, + "out": { + "at_root": false, + "count": 5, + "dir": "notes", + "entries": [ + { + "dir": true, + "image": false, + "items": 1, + "mod": 1700000012, + "name": "sous", + "path": "notes/sous", + "size": 6 + }, + { + "dir": false, + "image": false, + "mod": 1700000010, + "name": "a.txt", + "path": "notes/a.txt", + "size": 5 + } + ], + "legacy": 2, + "ok": true, + "parent": "", + "root": "{{HOME}}/workspace/discussions/c-fichiers", + "scope": "", + "total": 171 + } + }, + { + "method": "GET", + "url": "/api/chat/files?dir=notes/sous/", + "status": 200, + "out": { + "at_root": false, + "count": 5, + "dir": "notes/sous", + "entries": [ + { + "dir": false, + "image": false, + "mod": 1700000011, + "name": "b.txt", + "path": "notes/sous/b.txt", + "size": 6 + } + ], + "legacy": 2, + "ok": true, + "parent": "notes", + "root": "{{HOME}}/workspace/discussions/c-fichiers", + "scope": "", + "total": 171 + } + }, + { + "method": "GET", + "url": "/api/chat/files?dir=../../..", + "status": 403, + "out": { + "error": "hors du dossier de travail", + "ok": false + } + }, + { + "method": "GET", + "url": "/api/chat/files?dir=rapport.md", + "status": 404, + "out": { + "error": "dossier introuvable", + "ok": false + } + }, + { + "method": "GET", + "url": "/api/chat/files?scope=legacy", + "status": 200, + "out": { + "at_root": true, + "count": 8, + "dir": "", + "entries": [ + { + "dir": true, + "image": false, + "items": 1, + "mod": 1700000003, + "name": "uploads", + "path": "uploads", + "size": 4 + }, + { + "dir": false, + "image": false, + "mod": 1700000001, + "name": "vieux.txt", + "path": "vieux.txt", + "size": 3 + } + ], + "legacy": 2, + "ok": true, + "parent": "", + "root": "{{HOME}}/workspace", + "scope": "legacy", + "total": 183 + } + }, + { + "method": "GET", + "url": "/api/chat/file?path=rapport.md&meta=1", + "status": 200, + "out": { + "e2e": false, + "name": "rapport.md", + "ok": true, + "size": 10 + } + }, + { + "method": "GET", + "url": "/api/chat/file?path=rapport.md&b64=1&offset=2&len=4", + "status": 200, + "out": { + "data": "UmFwcA==", + "eof": false, + "name": "rapport.md", + "offset": 2, + "ok": true, + "size": 10 + } + }, + { + "method": "GET", + "url": "/api/chat/file?path=rapport.md&b64=1&offset=5", + "status": 200, + "out": { + "data": "cG9ydAo=", + "eof": true, + "name": "rapport.md", + "offset": 5, + "ok": true, + "size": 10 + } + }, + { + "method": "GET", + "url": "/api/chat/file?path=rapport.md&b64=1&offset=99", + "status": 400, + "out": { + "error": "position hors du fichier", + "ok": false + } + }, + { + "method": "GET", + "url": "/api/chat/file?path=../../vieux.txt", + "status": 403, + "out": { + "error": "hors du dossier de travail", + "ok": false + } + }, + { + "method": "GET", + "url": "/api/chat/file?path=absent.txt", + "status": 404, + "out": { + "error": "fichier introuvable", + "ok": false + } + }, + { + "method": "GET", + "url": "/api/chat/file?path=", + "status": 400, + "out": { + "error": "chemin manquant", + "ok": false + } + }, + { + "method": "GET", + "url": "/api/chat/file?path=captures/c-autre/x.jpg&meta=1", + "status": 200, + "out": { + "e2e": false, + "name": "x.jpg", + "ok": true, + "size": 5 + } + }, + { + "method": "GET", + "url": "/api/chat/file?path=uploads/ancien.pdf&meta=1", + "status": 200, + "out": { + "e2e": false, + "name": "ancien.pdf", + "ok": true, + "size": 4 + } + }, + { + "method": "GET", + "url": "/api/chat/file?path=notes", + "status": 404, + "out": { + "error": "fichier introuvable", + "ok": false + } + }, + { + "method": "GET", + "url": "/api/chat/file?path=/notes/a.txt", + "status": 200, + "raw": "YWxwaGE=", + "headers": { + "Content-Disposition": "attachment; filename*=UTF-8''a.txt", + "Content-Type": "application/octet-stream", + "X-Content-Type-Options": "nosniff" + } + }, + { + "method": "GET", + "url": "/api/chat/image?path=faux.png", + "status": 415, + "out": { + "error": "ce fichier n'est pas une image", + "ok": false + } + }, + { + "method": "GET", + "url": "/api/chat/image?path=image.png", + "status": 200, + "raw": "iVBORw0KGgoAAAANSUhEUgAAAAQAAAADCAYAAAC09K7GAAAAQ0lEQVR4nAAzAMz/AxY7YIWfssTX6fwOobPG2OsAZouw1fofRGmOs9j9IkdskQC22wAlSm+Uud4DKE1yl7zhAQAA//+6OxpmJmJbrAAAAABJRU5ErkJggg==", + "headers": { + "Content-Disposition": "inline; filename*=UTF-8''image.png", + "Content-Security-Policy": "default-src 'none'; sandbox", + "Content-Type": "image/png", + "X-Content-Type-Options": "nosniff" + } + }, + { + "method": "GET", + "url": "/api/chat/image?path=image.png&thumb=2", + "status": 200, + "headers": { + "Cache-Control": "private, max-age=3600", + "Content-Security-Policy": "default-src 'none'; sandbox", + "Content-Type": "image/jpeg", + "X-Content-Type-Options": "nosniff" + }, + "thumb": [ + 4, + 3 + ] + }, + { + "method": "GET", + "url": "/api/chat/image?path=rapport.md&thumb=64", + "status": 415, + "out": { + "error": "ce fichier n'est pas une image", + "ok": false + } + }, + { + "method": "POST", + "url": "/api/chat/upload", + "body": "{\"name\":\"../Mon fichier?.txt\",\"data\":\"aGVsbG8=\"}", + "status": 200, + "out": { + "abs": "{{HOME}}/workspace/discussions/c-fichiers/uploads/Mon fichier_.txt", + "ok": true, + "path": "uploads/Mon fichier_.txt", + "size": 5 + }, + "tree": { + "faux.png": 26, + "image.png": 124, + "notes/a.txt": 5, + "notes/sous/b.txt": 6, + "rapport.md": 10, + "uploads/Mon fichier_.txt": 5 + } + }, + { + "method": "POST", + "url": "/api/chat/upload", + "body": "{\"name\":\"Mon fichier?.txt\",\"data\":\"aGVsbG8gIQ==\"}", + "status": 200, + "out": { + "abs": "{{HOME}}/workspace/discussions/c-fichiers/uploads/Mon fichier_-2.txt", + "ok": true, + "path": "uploads/Mon fichier_-2.txt", + "size": 7 + }, + "tree": { + "faux.png": 26, + "image.png": 124, + "notes/a.txt": 5, + "notes/sous/b.txt": 6, + "rapport.md": 10, + "uploads/Mon fichier_-2.txt": 7, + "uploads/Mon fichier_.txt": 5 + } + }, + { + "method": "POST", + "url": "/api/chat/upload", + "body": "{\"name\":\"gros.bin\",\"data\":\"AAEC\",\"more\":true,\"size\":5}", + "status": 200, + "out": { + "id": "{{ID}}", + "ok": true, + "received": 3 + }, + "tree": { + "faux.png": 26, + "image.png": 124, + "notes/a.txt": 5, + "notes/sous/b.txt": 6, + "rapport.md": 10, + "uploads/.upload-{{N}}.part": 3, + "uploads/Mon fichier_-2.txt": 7, + "uploads/Mon fichier_.txt": 5 + } + }, + { + "method": "POST", + "url": "/api/chat/upload", + "body": "{\"id\":\"{{ID}}\",\"data\":\"data:application/octet-stream;base64,AwQ=\",\"more\":false}", + "status": 200, + "out": { + "abs": "{{HOME}}/workspace/discussions/c-fichiers/uploads/gros.bin", + "ok": true, + "path": "uploads/gros.bin", + "size": 5 + }, + "tree": { + "faux.png": 26, + "image.png": 124, + "notes/a.txt": 5, + "notes/sous/b.txt": 6, + "rapport.md": 10, + "uploads/Mon fichier_-2.txt": 7, + "uploads/Mon fichier_.txt": 5, + "uploads/gros.bin": 5 + } + }, + { + "method": "POST", + "url": "/api/chat/upload", + "body": "{\"id\":\"inconnu\",\"data\":\"AA==\"}", + "status": 409, + "out": { + "error": "envoi expiré — recommence le fichier", + "ok": false + }, + "tree": { + "faux.png": 26, + "image.png": 124, + "notes/a.txt": 5, + "notes/sous/b.txt": 6, + "rapport.md": 10, + "uploads/Mon fichier_-2.txt": 7, + "uploads/Mon fichier_.txt": 5, + "uploads/gros.bin": 5 + } + }, + { + "method": "POST", + "url": "/api/chat/upload", + "body": "{\"name\":\"vide.txt\",\"data\":\"\"}", + "status": 400, + "out": { + "error": "fichier vide", + "ok": false + }, + "tree": { + "faux.png": 26, + "image.png": 124, + "notes/a.txt": 5, + "notes/sous/b.txt": 6, + "rapport.md": 10, + "uploads/Mon fichier_-2.txt": 7, + "uploads/Mon fichier_.txt": 5, + "uploads/gros.bin": 5 + } + }, + { + "method": "POST", + "url": "/api/chat/upload", + "body": "{\"name\":\"x\",\"data\":\"!!!\"}", + "status": 400, + "out": { + "error": "contenu illisible (base64 attendu)", + "ok": false + }, + "tree": { + "faux.png": 26, + "image.png": 124, + "notes/a.txt": 5, + "notes/sous/b.txt": 6, + "rapport.md": 10, + "uploads/Mon fichier_-2.txt": 7, + "uploads/Mon fichier_.txt": 5, + "uploads/gros.bin": 5 + } + }, + { + "method": "POST", + "url": "/api/chat/upload", + "body": "pas du json", + "status": 400, + "out": { + "error": "morceau trop gros ou requête invalide", + "ok": false + }, + "tree": { + "faux.png": 26, + "image.png": 124, + "notes/a.txt": 5, + "notes/sous/b.txt": 6, + "rapport.md": 10, + "uploads/Mon fichier_-2.txt": 7, + "uploads/Mon fichier_.txt": 5, + "uploads/gros.bin": 5 + } + }, + { + "method": "POST", + "url": "/api/chat/file/delete", + "body": "{\"path\":\"\"}", + "status": 400, + "out": { + "error": "chemin manquant", + "ok": false + }, + "tree": { + "faux.png": 26, + "image.png": 124, + "notes/a.txt": 5, + "notes/sous/b.txt": 6, + "rapport.md": 10, + "uploads/Mon fichier_-2.txt": 7, + "uploads/Mon fichier_.txt": 5, + "uploads/gros.bin": 5 + } + }, + { + "method": "POST", + "url": "/api/chat/file/delete", + "body": "{\"path\":\".\"}", + "status": 403, + "out": { + "error": "le dossier de travail lui-même ne se supprime pas", + "ok": false + }, + "tree": { + "faux.png": 26, + "image.png": 124, + "notes/a.txt": 5, + "notes/sous/b.txt": 6, + "rapport.md": 10, + "uploads/Mon fichier_-2.txt": 7, + "uploads/Mon fichier_.txt": 5, + "uploads/gros.bin": 5 + } + }, + { + "method": "POST", + "url": "/api/chat/file/delete", + "body": "{\"path\":\"../..\"}", + "status": 403, + "out": { + "error": "hors du dossier de travail", + "ok": false + }, + "tree": { + "faux.png": 26, + "image.png": 124, + "notes/a.txt": 5, + "notes/sous/b.txt": 6, + "rapport.md": 10, + "uploads/Mon fichier_-2.txt": 7, + "uploads/Mon fichier_.txt": 5, + "uploads/gros.bin": 5 + } + }, + { + "method": "POST", + "url": "/api/chat/file/delete", + "body": "{\"path\":\"discussions\",\"scope\":\"legacy\"}", + "status": 403, + "out": { + "error": "les dossiers des discussions se suppriment discussion par discussion", + "ok": false + }, + "tree": { + "faux.png": 26, + "image.png": 124, + "notes/a.txt": 5, + "notes/sous/b.txt": 6, + "rapport.md": 10, + "uploads/Mon fichier_-2.txt": 7, + "uploads/Mon fichier_.txt": 5, + "uploads/gros.bin": 5 + } + }, + { + "method": "POST", + "url": "/api/chat/file/delete", + "body": "{\"path\":\"absent\"}", + "status": 404, + "out": { + "error": "introuvable", + "ok": false + }, + "tree": { + "faux.png": 26, + "image.png": 124, + "notes/a.txt": 5, + "notes/sous/b.txt": 6, + "rapport.md": 10, + "uploads/Mon fichier_-2.txt": 7, + "uploads/Mon fichier_.txt": 5, + "uploads/gros.bin": 5 + } + }, + { + "method": "POST", + "url": "/api/chat/file/delete", + "body": "{\"path\":\"faux.png\"}", + "status": 200, + "out": { + "freed": 26, + "ok": true + }, + "tree": { + "image.png": 124, + "notes/a.txt": 5, + "notes/sous/b.txt": 6, + "rapport.md": 10, + "uploads/Mon fichier_-2.txt": 7, + "uploads/Mon fichier_.txt": 5, + "uploads/gros.bin": 5 + } + }, + { + "method": "POST", + "url": "/api/chat/file/delete", + "body": "{\"path\":\"notes/sous\"}", + "status": 200, + "out": { + "freed": 6, + "ok": true + }, + "tree": { + "image.png": 124, + "notes/a.txt": 5, + "rapport.md": 10, + "uploads/Mon fichier_-2.txt": 7, + "uploads/Mon fichier_.txt": 5, + "uploads/gros.bin": 5 + } + }, + { + "method": "GET", + "url": "/api/chat/files/zip", + "status": 404, + "out": { + "error": "archive absente — redemande la fiche (meta=1) d'abord", + "ok": false + } + }, + { + "method": "GET", + "url": "/api/chat/files/zip?meta=1", + "status": 200, + "out": { + "e2e": false, + "files": 6, + "name": "loki-c-fichiers-{{DATE}}.zip", + "ok": true, + "size": "…" + } + }, + { + "method": "GET", + "url": "/api/chat/files/zip?b64=1&offset=0&len=99999999", + "status": 200, + "out": { + "data": "…", + "eof": true, + "name": "loki-c-fichiers-{{DATE}}.zip", + "offset": 0, + "ok": true, + "size": "…" + }, + "zip": { + "image.png": "iVBORw0KGgoAAAANSUhEUgAAAAQAAAADCAYAAAC09K7GAAAAQ0lEQVR4nAAzAMz/AxY7YIWfssTX6fwOobPG2OsAZouw1fofRGmOs9j9IkdskQC22wAlSm+Uud4DKE1yl7zhAQAA//+6OxpmJmJbrAAAAABJRU5ErkJggg==", + "notes/a.txt": "YWxwaGE=", + "rapport.md": "IyBSYXBwb3J0Cg==", + "uploads/Mon fichier_-2.txt": "aGVsbG8gIQ==", + "uploads/Mon fichier_.txt": "aGVsbG8=", + "uploads/gros.bin": "AAECAwQ=" + } + }, + { + "method": "GET", + "url": "/api/chat/files/zip?b64=1&offset=-1", + "status": 400, + "out": { + "error": "position hors du fichier", + "ok": false + } + }, + { + "method": "GET", + "url": "/api/chat/files/zip?meta=1&scope=legacy", + "status": 200, + "out": { + "e2e": false, + "files": 2, + "name": "loki-hors-discussion-{{DATE}}.zip", + "ok": true, + "size": "…" + } + }, + { + "method": "GET", + "url": "/api/chat/files/zip?b64=1&scope=legacy", + "status": 200, + "out": { + "data": "…", + "eof": true, + "name": "loki-hors-discussion-{{DATE}}.zip", + "offset": 0, + "ok": true, + "size": "…" + }, + "zip": { + "uploads/ancien.pdf": "JVBERg==", + "vieux.txt": "b2xk" + } + } + ] +} diff --git a/rust/loki-rs/tests/proxy.rs b/rust/loki-rs/tests/proxy.rs index 02409c1..71d207b 100644 --- a/rust/loki-rs/tests/proxy.rs +++ b/rust/loki-rs/tests/proxy.rs @@ -103,6 +103,7 @@ async fn facade(backend: SocketAddr) -> (SocketAddr, oneshot::Sender<()>) { models: None, dictate: None, settings: false, + files: None, }; tokio::spawn(loki_rs::proxy::serve(l, cfg, async move { let _ = rx.await; From cbd0d5e35625370bd5e4c8e4e819215905d4620d Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 15 Oct 2026 16:50:25 +0000 Subject: [PATCH 07/52] =?UTF-8?q?Tests=20:=20scripts=20d'essai=20=C3=A9cri?= =?UTF-8?q?ts=20par=20un=20sh=20=C3=A0=20part=20(plus=20d'ETXTBSY)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit tests/engine.rs (redemarre) a échoué en CI sur « Text file busy » : un test écrivait son faux binaire pendant qu'un test voisin, dans un autre thread du même process, lançait un enfant — qui héritait du descripteur d'écriture le temps de son exec, et l'exec du script échouait. Les faux binaires (moteur, serveur de dictée) sont désormais écrits par un sh à part : le process des tests ne tient jamais le fichier ouvert en écriture. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_016tcGfJWefW5kUGoC2wL8cv --- rust/loki-rs/tests/dictate.rs | 20 +++++++++++--------- rust/loki-rs/tests/engine.rs | 16 ++++++++++++++-- 2 files changed, 25 insertions(+), 11 deletions(-) diff --git a/rust/loki-rs/tests/dictate.rs b/rust/loki-rs/tests/dictate.rs index 49bfe83..566483f 100644 --- a/rust/loki-rs/tests/dictate.rs +++ b/rust/loki-rs/tests/dictate.rs @@ -10,7 +10,6 @@ mod common; use std::convert::Infallible; -use std::io::Write; use std::net::SocketAddr; use std::path::{Path, PathBuf}; use std::sync::{Arc, Mutex}; @@ -467,16 +466,19 @@ while True: c.close() "#; +/// Écrit par un `sh` à part : si ce process tenait lui-même le fichier ouvert +/// en écriture, un `fork` lancé au même moment par un test voisin en +/// hériterait, et l'`exec` du script échouerait sur ETXTBSY. fn script(dir: &Path, name: &str, body: &str) -> String { let p = dir.join(name); - let mut f = std::fs::File::create(&p).unwrap(); - f.write_all(body.as_bytes()).unwrap(); - drop(f); - #[cfg(unix)] - { - use std::os::unix::fs::PermissionsExt; - std::fs::set_permissions(&p, std::fs::Permissions::from_mode(0o755)).unwrap(); - } + let ok = std::process::Command::new("sh") + .args(["-c", r#"printf '%s' "$1" > "$2" && chmod 755 "$2""#, "sh"]) + .arg(body) + .arg(&p) + .status() + .unwrap() + .success(); + assert!(ok, "script non écrit"); p.to_string_lossy().into_owned() } diff --git a/rust/loki-rs/tests/engine.rs b/rust/loki-rs/tests/engine.rs index c25c11e..77e608d 100644 --- a/rust/loki-rs/tests/engine.rs +++ b/rust/loki-rs/tests/engine.rs @@ -9,10 +9,22 @@ use std::time::Duration; /// Faux binaire Go : `serve` lance un « llama-server » dans sa session et un /// « moteur Strata » qui prend SA PROPRE session (comme le vrai), puis attend. +/// +/// Écrit par un `sh` à part : si ce process tenait lui-même le fichier ouvert +/// en écriture, un `fork` lancé au même moment par un test voisin (les tests +/// tournent en parallèle) en hériterait, et l'`exec` du script échouerait sur +/// ETXTBSY (« Text file busy »). fn faux_go(dir: &Path, corps: &str) -> PathBuf { let p = dir.join("faux-loki"); - std::fs::write(&p, format!("#!/bin/sh\n{corps}\n")).unwrap(); - std::fs::set_permissions(&p, std::fs::Permissions::from_mode(0o755)).unwrap(); + let ok = std::process::Command::new("sh") + .args(["-c", r#"printf '%s' "$1" > "$2" && chmod 755 "$2""#, "sh"]) + .arg(format!("#!/bin/sh\n{corps}\n")) + .arg(&p) + .status() + .unwrap() + .success(); + assert!(ok, "faux binaire non écrit"); + assert!(std::fs::metadata(&p).unwrap().permissions().mode() & 0o111 != 0); p } From eddcf97e3be29350b4dc07faa68c7f4ff3123223 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 15 Oct 2026 17:01:07 +0000 Subject: [PATCH 08/52] =?UTF-8?q?Lot=2012=20:=20notifications=20Web=20Push?= =?UTF-8?q?=20tenues=20par=20la=20fa=C3=A7ade=20(LOKI=5FPUSH=3Drust)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit La façade sert la clé VAPID et les (dés)abonnements, et pousse elle-même les notifications « réponse prête » quand LOKI_PUSH=rust (expérimental ; le Go reste le défaut) : chiffrement RFC 8291 (aes128gcm, enregistrement complété à 4096 octets comme webpush-go), signature VAPID RFC 8292 (JWT ES256), abonnements purgés sur 404/410, par ring déjà présent via rustls. Mêmes clés en base que le Go. Côté Go : facade_push.go remet les routes du tunnel à la façade, et sendPushToAll (fin de tour, fin de tâche) lui délègue l'envoi. Parité : parity_push_test.go tire du Go les durées et onze appels de route avec la liste des abonnements en base, rejoués par tests/push.rs ; le chiffrement reproduit l'exemple de la RFC 8291 à l'octet près, et un envoi de bout en bout est déchiffré côté navigateur. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_016tcGfJWefW5kUGoC2wL8cv --- README.md | 1 + ...st-migration-lot12-notifications-design.md | 47 ++ internal/loki/facade_chat.go | 4 +- internal/loki/facade_chat_test.go | 27 + internal/loki/facade_push.go | 43 ++ internal/loki/parity_push_test.go | 109 ++++ internal/loki/push.go | 4 + internal/loki/web_server.go | 5 +- rust/Cargo.lock | 1 + rust/loki-rs/Cargo.toml | 3 +- rust/loki-rs/src/api.rs | 3 + rust/loki-rs/src/lib.rs | 1 + rust/loki-rs/src/main.rs | 9 +- rust/loki-rs/src/proxy.rs | 14 + rust/loki-rs/src/push.rs | 608 ++++++++++++++++++ rust/loki-rs/tests/common/fakego.rs | 2 + rust/loki-rs/tests/lot2.rs | 1 + rust/loki-rs/tests/parity_push/push.json | 126 ++++ rust/loki-rs/tests/proxy.rs | 1 + rust/loki-rs/tests/push.rs | 316 +++++++++ 20 files changed, 1320 insertions(+), 5 deletions(-) create mode 100644 docs/superpowers/specs/2026-10-15-rust-migration-lot12-notifications-design.md create mode 100644 internal/loki/facade_push.go create mode 100644 internal/loki/parity_push_test.go create mode 100644 rust/loki-rs/src/push.rs create mode 100644 rust/loki-rs/tests/parity_push/push.json create mode 100644 rust/loki-rs/tests/push.rs diff --git a/README.md b/README.md index 1e7fe6a..6b58d0d 100644 --- a/README.md +++ b/README.md @@ -232,6 +232,7 @@ redémarrages (volume `/data`). Variables d'environnement du conteneur : | `LOKI_DICTATE` | `rust` : la dictée vocale (`/api/transcribe`, `/api/dictate/*`) et son serveur de transcription sont tenus par la façade Rust (expérimental, migration en cours) | Go | | `LOKI_SETTINGS` | `rust` : les réglages partagés — préférences d'affichage, prompt système, intensité du raisonnement, adresse publique — sont servis par la façade Rust (expérimental, migration en cours) | Go | | `LOKI_FILES` | `rust` : les fichiers de la discussion — dépôt, panneau Fichiers, archive .zip, téléchargement, images et vignettes — sont servis par la façade Rust (expérimental, migration en cours) | Go | +| `LOKI_PUSH` | `rust` : les notifications Web Push (clé VAPID, abonnements, envoi « réponse prête ») sont tenues par la façade Rust (expérimental, migration en cours) | Go | **Accès par nom de domaine.** Sans clé de pilotage, l'API n'accepte que les hôtes locaux (IP, `localhost`, nom sans point, `.local`/`.lan`…) et refuse diff --git a/docs/superpowers/specs/2026-10-15-rust-migration-lot12-notifications-design.md b/docs/superpowers/specs/2026-10-15-rust-migration-lot12-notifications-design.md new file mode 100644 index 0000000..c9fdd09 --- /dev/null +++ b/docs/superpowers/specs/2026-10-15-rust-migration-lot12-notifications-design.md @@ -0,0 +1,47 @@ +# Lot 12 — notifications Web Push tenues par la façade + +## Contexte + +Les notifications « réponse prête » vivaient dans le Go : `push.go`, +`web_push.go` et webpush-go. Lot 12 : la façade les tient quand +`LOKI_PUSH=rust` (`push.rs`, expérimental ; sans la variable rien ne change). + +| Route | Rôle | +|---|---| +| `GET /api/push/key` | clé publique VAPID, générée au premier appel puis stable | +| `POST /api/push/subscribe` | enregistre un abonnement (idempotent par endpoint, clés mises à jour) | +| `POST /api/push/unsubscribe` | le retire | +| `POST /internal/facade/v1/push/send` | (le Go, jeton interne) pousse une notification à tous les abonnés, en tâche de fond | + +Les envois du Go passent tous par `sendPushToAll` : fin de tour du chat Go, +fin de tour tenu par la façade (`/internal/v1/turn/done`), fin de tâche. Ils +sont délégués à la façade. Clés et abonnements restent en base aux mêmes +clés (`push_vapid_priv`, `push_vapid_pub`, `push_subs`). + +Chiffrement RFC 8291 (`aes128gcm`), enregistrement complété à 4096 octets +comme webpush-go. Signature VAPID RFC 8292 (JWT ES256, 12 h, sujet +« mailto: »). TTL de 120 s, urgence haute. Un abonnement refusé pour de bon +(404/410) est purgé. La cryptographie passe par ring, déjà présent via +rustls. Une façade construite sans TLS répond que les notifications sont +indisponibles. + +## Écarts assumés + +- Messages des journaux d'envoi au format des crates Rust. + +## Parité + +- `internal/loki/parity_push_test.go` tire du Go : + - les durées du corps de notification ; + - onze appels de route (clé, abonnements valides et refusés, mise à jour + des clés, désabonnements), avec la liste en base après chacun. + + `tests/push.rs` rejoue le tout par la façade. La liste en base est + comparée à l'octet près, et la clé reste la même d'un appel à l'autre. +- Le chiffrement reproduit l'exemple de l'annexe A de la RFC 8291 à l'octet + près. +- Un envoi de bout en bout vers un faux service de push vérifie : + - les en-têtes ; + - la signature VAPID, contrôlée avec la clé rangée ; + - le corps, déchiffré côté navigateur avec la clé de l'abonnement ; + - l'abonnement en 410, purgé. diff --git a/internal/loki/facade_chat.go b/internal/loki/facade_chat.go index 411aee0..7e1b5d9 100644 --- a/internal/loki/facade_chat.go +++ b/internal/loki/facade_chat.go @@ -143,7 +143,7 @@ var facadeChatRoutes = map[string]bool{ // withFacadeChat remet la requête à la façade quand elle tient la // conversation — ou les routes des modèles (facade_models.go), la dictée // (facade_dictate.go), les réglages partagés (facade_settings.go), les fichiers -// de la discussion (facade_files.go). +// de la discussion (facade_files.go), les notifications (facade_push.go). // Posé APRÈS requireWebAuth : seule une requête authentifiée ici part, avec le // jeton interne qui en tient lieu là-bas. func withFacadeChat(path string, h http.HandlerFunc) http.HandlerFunc { @@ -158,6 +158,8 @@ func withFacadeChat(path string, h http.HandlerFunc) http.HandlerFunc { held, unavailable = facadeSettings, "réglages indisponibles : " case facadeFilesRoutes[path]: held, unavailable = facadeFiles, "fichiers indisponibles : " + case facadePushRoutes[path]: + held, unavailable = facadePush, "notifications indisponibles : " default: return h } diff --git a/internal/loki/facade_chat_test.go b/internal/loki/facade_chat_test.go index d45bf30..bdc8e1d 100644 --- a/internal/loki/facade_chat_test.go +++ b/internal/loki/facade_chat_test.go @@ -484,3 +484,30 @@ func TestFacadeFichiers(t *testing.T) { t.Fatalf(".part gardé sans la variable : %v", err) } } + +// LOKI_PUSH=rust : les routes des notifications partent à la façade, et les +// envois de ce process (fin de tour, fin de tâche) lui sont délégués. +func TestFacadeNotifications(t *testing.T) { + testHome(t) + f := demarrerFacade(t) + t.Setenv(facadeChatEnv, "") + t.Setenv(facadePushEnv, "rust") + local := func(w http.ResponseWriter, r *http.Request) { io.WriteString(w, "go") } + for p := range facadePushRoutes { + w := httptest.NewRecorder() + withFacadeChat(p, local)(w, httptest.NewRequest("POST", p, strings.NewReader("{}"))) + if w.Body.String() != f.reply { + t.Fatalf("%s : réponse %q", p, w.Body.String()) + } + } + sendPushToAll("Loki", "Réponse prête · 3s") + got := f.recu() + if len(got) != len(facadePushRoutes)+1 || got[len(got)-1] != `POST /internal/facade/v1/push/send jeton {"body":"Réponse prête · 3s","title":"Loki"}` { + t.Fatalf("reçu par la façade : %v", got) + } + t.Setenv(facadePushEnv, "") + sendPushToAll("Loki", "x") // aucun abonné : rien à envoyer, rien de délégué + if n := len(f.recu()); n != len(facadePushRoutes)+1 { + t.Fatalf("envoi délégué sans la variable (%d appels)", n) + } +} diff --git a/internal/loki/facade_push.go b/internal/loki/facade_push.go new file mode 100644 index 0000000..014c967 --- /dev/null +++ b/internal/loki/facade_push.go @@ -0,0 +1,43 @@ +package loki + +import ( + "fmt" + "os" + "strings" +) + +// facade_push.go — notifications Web Push tenues par la façade Rust +// (LOKI_PUSH=rust, lot 12 de la migration ; expérimental, le Go reste le +// défaut). +// +// La façade sert la clé VAPID et les (dés)abonnements, et pousse elle-même +// les notifications : les envois de ce process (fin de tour, fin de tâche) lui +// sont délégués par /internal/facade/v1/push/send. Clés et abonnements restent +// en base aux mêmes clés : hasPushSubs les lit toujours ici. + +const facadePushEnv = "LOKI_PUSH" + +// facadePush : la façade tient-elle les notifications ? +func facadePush() bool { + return strings.EqualFold(strings.TrimSpace(os.Getenv(facadePushEnv)), "rust") && + facadeAddr() != "" && internalToken() != "" +} + +// facadePushRoutes : les routes que la façade sert alors (push.rs). +var facadePushRoutes = map[string]bool{ + "/api/push/key": true, + "/api/push/subscribe": true, + "/api/push/unsubscribe": true, +} + +// facadePushSend délègue un envoi à la façade ; false quand elle ne tient pas +// les notifications (l'appelant envoie alors lui-même). +func facadePushSend(title, body string) bool { + if !facadePush() { + return false + } + if err := facadePost("/internal/facade/v1/push/send", map[string]any{"title": title, "body": body}); err != nil { + fmt.Printf("[push] envoi délégué à la façade : %v\n", err) + } + return true +} diff --git a/internal/loki/parity_push_test.go b/internal/loki/parity_push_test.go new file mode 100644 index 0000000..976b9c6 --- /dev/null +++ b/internal/loki/parity_push_test.go @@ -0,0 +1,109 @@ +package loki + +import ( + "encoding/json" + "net/http/httptest" + "os" + "path/filepath" + "regexp" + "strings" + "testing" + "time" +) + +// parity_push_test.go — journal de référence des notifications Web Push (lot +// 12 de la migration Rust) : clé VAPID (générée une fois, stable), abonnement, +// désabonnement, et le texte du corps de notification. Rangé dans +// rust/loki-rs/tests/parity_push/push.json ; tests/push.rs le rejoue par la +// façade. Les clés tirées au hasard sont masquées (seule leur forme compte). +// +// LOKI_PARITY_UPDATE=1 go test ./internal/loki -run TestPariteNotifications + +const parityPushFile = "../../rust/loki-rs/tests/parity_push/push.json" + +type parityPushCall 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 : les abonnements enregistrés (JSON brut, tel qu'en base). + Subs string `json:"subs"` +} + +type parityPushFixture struct { + Durations map[string]string `json:"durations"` + Calls []parityPushCall `json:"calls"` +} + +func TestPariteNotifications(t *testing.T) { + testHome(t) + var fx parityPushFixture + fx.Durations = map[string]string{} + for _, ms := range []int64{0, 400, 600, 42_000, 185_000, 3_600_000, 4_330_000, -5_000} { + fx.Durations[strings.TrimSpace(time.Duration(ms*int64(time.Millisecond)).String())] = fmtDurFR(time.Duration(ms) * time.Millisecond) + } + keyRe := regexp.MustCompile(`"key":"[A-Za-z0-9_-]{87}"`) + call := func(method, path, body string) parityPushCall { + w := httptest.NewRecorder() + r := httptest.NewRequest(method, path, strings.NewReader(body)) + switch path { + case "/api/push/key": + handlePushKey(w, r) + case "/api/push/subscribe": + handlePushSubscribe(w, r) + case "/api/push/unsubscribe": + handlePushUnsubscribe(w, r) + default: + t.Fatalf("route inattendue %s", path) + } + out := keyRe.ReplaceAllString(w.Body.String(), `"key":"{{CLE}}"`) + c := parityPushCall{Method: method, Path: path, Body: body, Status: w.Code, Subs: string(getBytes(bkState, stPushSubs))} + if err := json.Unmarshal([]byte(out), &c.Out); err != nil { + t.Fatalf("%s : %v (%s)", path, err, w.Body.String()) + } + return c + } + for _, c := range []struct{ m, p, b string }{ + {"GET", "/api/push/key", ""}, + {"POST", "/api/push/subscribe", `{"endpoint":"https://push.exemple.fr/a","keys":{"p256dh":"BAAA","auth":"x1"}}`}, + {"POST", "/api/push/subscribe", `{"endpoint":"https://push.exemple.fr/b","keys":{"p256dh":"BBBB","auth":"x2"},"expirationTime":null}`}, + {"POST", "/api/push/subscribe", `{"endpoint":"https://push.exemple.fr/a","keys":{"p256dh":"BCCC","auth":"x3"}}`}, + {"POST", "/api/push/subscribe", `{"keys":{"p256dh":"B","auth":"y"}}`}, + {"POST", "/api/push/subscribe", `pas du json`}, + {"POST", "/api/push/unsubscribe", `{"endpoint":"https://push.exemple.fr/inconnu"}`}, + {"POST", "/api/push/unsubscribe", `{"endpoint":"https://push.exemple.fr/a"}`}, + {"POST", "/api/push/unsubscribe", `pas du json`}, + {"POST", "/api/push/unsubscribe", `{"endpoint":"https://push.exemple.fr/b"}`}, + {"GET", "/api/push/key", ""}, + } { + fx.Calls = append(fx.Calls, call(c.m, c.p, c.b)) + } + // La paire VAPID est stable : la clé servie est la clé rangée. + if pub := getStr(bkState, stPushVAPIDPub); len(pub) != 87 || len(getStr(bkState, stPushVAPIDPriv)) != 43 { + t.Fatalf("paire VAPID inattendue : %q", pub) + } + + got, err := parityJSON(fx) + if err != nil { + t.Fatal(err) + } + if os.Getenv("LOKI_PARITY_UPDATE") == "1" { + if err := os.MkdirAll(filepath.Dir(parityPushFile), 0o755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(parityPushFile, got, 0o644); err != nil { + t.Fatal(err) + } + return + } + want, err := os.ReadFile(parityPushFile) + 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 notifications ne produisent plus le journal rangé dans %s.\n"+ + "Si c'est voulu : LOKI_PARITY_UPDATE=1 go test ./internal/loki -run TestPariteNotifications,\n"+ + "puis cargo test --test push dit si le port Rust suit.\n--- obtenu ---\n%s", parityPushFile, got) + } +} diff --git a/internal/loki/push.go b/internal/loki/push.go index 5f5a69a..5763283 100644 --- a/internal/loki/push.go +++ b/internal/loki/push.go @@ -150,6 +150,10 @@ func hasPushSubs() bool { return len(loadSubs()) > 0 } // abonnement rejeté définitivement (404/410) est purgé pour ne pas s'accumuler. // Appelé depuis la goroutine de génération, à turn_done — jamais bloquant pour l'UI. func sendPushToAll(title, body string) { + // Notifications tenues par la façade (LOKI_PUSH=rust) : c'est elle qui pousse. + if facadePushSend(title, body) { + return + } subs := loadSubs() if len(subs) == 0 { fmt.Printf("[push] fin de tour : aucun abonné enregistré (rien à envoyer)\n") diff --git a/internal/loki/web_server.go b/internal/loki/web_server.go index d5538c3..aaef0c7 100644 --- a/internal/loki/web_server.go +++ b/internal/loki/web_server.go @@ -223,8 +223,9 @@ func newWebMux() *http.ServeMux { // conversation (LOKI_CHAT=rust, facade_chat.go), celles des modèles quand // elle les tient (LOKI_MODELS=rust, facade_models.go), celles de la dictée // (LOKI_DICTATE=rust, facade_dictate.go), des réglages partagés - // (LOKI_SETTINGS=rust, facade_settings.go) et des fichiers de la - // discussion (LOKI_FILES=rust, facade_files.go) ; sans effet sinon. + // (LOKI_SETTINGS=rust, facade_settings.go), des fichiers de la discussion + // (LOKI_FILES=rust, facade_files.go) et des notifications (LOKI_PUSH=rust, + // facade_push.go) ; sans effet sinon. api := func(path string, h http.HandlerFunc) { mux.HandleFunc(path, requireWebAuth(withFacadeChat(path, h))) } api("/api/ping", handlePing) api("/api/status", handleStatus) diff --git a/rust/Cargo.lock b/rust/Cargo.lock index 0bbcbed..b07e29f 100644 --- a/rust/Cargo.lock +++ b/rust/Cargo.lock @@ -792,6 +792,7 @@ dependencies = [ "libc", "regex", "reqwest", + "ring", "rustls", "serde", "serde_json", diff --git a/rust/loki-rs/Cargo.toml b/rust/loki-rs/Cargo.toml index 81ab438..6e3dd4c 100644 --- a/rust/loki-rs/Cargo.toml +++ b/rust/loki-rs/Cargo.toml @@ -18,6 +18,7 @@ hyper-util = { version = "0.1.21", features = ["tokio", "server", "client-legacy regex = "1.13.1" url = "2.5.8" zip = { version = "2.4.2", default-features = false, features = ["deflate-flate2", "flate2"] } +ring = { version = "0.17.14", optional = true } reqwest = { version = "0.13.5", default-features = false, features = ["rustls-no-provider", "stream", "http2"] } rustls = { version = "0.23.46", default-features = false, features = ["ring", "std", "tls12"], optional = true } serde = { version = "1.0.229", features = ["derive"] } @@ -32,7 +33,7 @@ tokio-tungstenite = "0.30.0" # Crypto TLS (ring) pour les API externes en HTTPS. Activée par défaut ; le # contrôle croisé Linux sous Windows s'en passe (pas d'en-têtes libc Linux). default = ["tls"] -tls = ["dep:rustls"] +tls = ["dep:rustls", "dep:ring"] [target.'cfg(unix)'.dependencies] libc = "0.2" diff --git a/rust/loki-rs/src/api.rs b/rust/loki-rs/src/api.rs index c812991..0e34e26 100644 --- a/rust/loki-rs/src/api.rs +++ b/rust/loki-rs/src/api.rs @@ -36,6 +36,8 @@ pub struct Ctx { pub settings: bool, /// Fichiers de la discussion tenus par la façade (`LOKI_FILES=rust`). pub files: Option>, + /// Notifications Web Push tenues par la façade (`LOKI_PUSH=rust`). + pub push: Option>, /// Jeton de l'API interne : une requête qui le porte vient du Go. pub internal_token: String, } @@ -49,6 +51,7 @@ impl Ctx { || self.dictate.is_some() || self.settings || self.files.is_some() + || self.push.is_some() } /// La requête vient du Go (jeton interne valide) : déjà authentifiée par diff --git a/rust/loki-rs/src/lib.rs b/rust/loki-rs/src/lib.rs index e3dcf35..f3d2198 100644 --- a/rust/loki-rs/src/lib.rs +++ b/rust/loki-rs/src/lib.rs @@ -27,6 +27,7 @@ pub mod oai; pub mod paths; pub mod presets; pub mod proxy; +pub mod push; pub mod settings; pub mod store; pub mod sysinfo; diff --git a/rust/loki-rs/src/main.rs b/rust/loki-rs/src/main.rs index ec19d76..ee6d1c4 100644 --- a/rust/loki-rs/src/main.rs +++ b/rust/loki-rs/src/main.rs @@ -130,6 +130,9 @@ fn serve() -> ExitCode { // LOKI_FILES=rust : les fichiers de la discussion (dépôt, panneau // Fichiers, archive, téléchargement, images). let files_rust = held_by_facade("LOKI_FILES"); + // LOKI_PUSH=rust : les notifications Web Push (le Go lui délègue ses + // envois de fin de tour et de fin de tâche). + let push_rust = held_by_facade("LOKI_PUSH"); let mut env = vec![(auth::INTERNAL_ENV.to_string(), token.clone())]; if chat_rust { env.push(("LOKI_CHAT".into(), "rust".into())); @@ -146,7 +149,10 @@ fn serve() -> ExitCode { if files_rust { env.push(("LOKI_FILES".into(), "rust".into())); } - if chat_rust || models_rust || dictate_rust || settings_rust || files_rust { + if push_rust { + env.push(("LOKI_PUSH".into(), "rust".into())); + } + if chat_rust || models_rust || dictate_rust || settings_rust || files_rust || push_rust { env.push(("LOKI_FACADE_ADDR".into(), format!("127.0.0.1:{web_port}"))); } let sup = child::Supervisor::spawn(go_bin, go_port, env); @@ -216,6 +222,7 @@ fn serve() -> ExitCode { dictate, settings: settings_rust, files, + push: push_rust.then(|| std::sync::Arc::new(loki_rs::push::Push::new())), }; let res = proxy::serve(listener, cfg, shutdown_signal()).await; eprintln!("[loki-rs] arrêt demandé — arrêt du serveur Go"); diff --git a/rust/loki-rs/src/proxy.rs b/rust/loki-rs/src/proxy.rs index 5197da7..bd38044 100644 --- a/rust/loki-rs/src/proxy.rs +++ b/rust/loki-rs/src/proxy.rs @@ -73,6 +73,8 @@ pub struct Config { pub settings: bool, /// Fichiers de la discussion tenus par la façade (`LOKI_FILES=rust`). pub files: Option>, + /// Notifications Web Push tenues par la façade (`LOKI_PUSH=rust`). + pub push: Option>, } struct Proxy { @@ -104,6 +106,7 @@ pub async fn serve( dictate: cfg.dictate, settings: cfg.settings, files: cfg.files, + push: cfg.push, internal_token: cfg.internal_token.clone(), }, }); @@ -159,6 +162,12 @@ impl Proxy { { return chatapi::serve_internal(chat, req).await; } + if from_go + && let Some(p) = &self.ctx.push + && path == crate::push::INTERNAL_SEND + { + return crate::push::serve_internal(self.ctx.store.clone(), p.clone(), req).await; + } if from_go && let Some(d) = &self.ctx.dictate && path == crate::dictate::INTERNAL_STOP @@ -185,6 +194,11 @@ impl Proxy { { return chatapi::serve(&self.ctx, chat, req, from_go).await; } + if let Some(p) = &self.ctx.push + && crate::push::handles(path) + { + return crate::push::serve(&self.ctx, p, req, from_go).await; + } if let Some(f) = &self.ctx.files && crate::convfiles::handles(path) { diff --git a/rust/loki-rs/src/push.rs b/rust/loki-rs/src/push.rs new file mode 100644 index 0000000..4e0403d --- /dev/null +++ b/rust/loki-rs/src/push.rs @@ -0,0 +1,608 @@ +//! Notifications Web Push — port de `push.go` et `web_push.go` (lot 12 de la +//! migration), tenues par la façade quand `LOKI_PUSH=rust` (expérimental ; le +//! Go par défaut) : +//! +//! - `GET /api/push/key` : clé publique VAPID (générée une fois, stable) ; +//! - `POST /api/push/subscribe` : enregistre un abonnement du navigateur ; +//! - `POST /api/push/unsubscribe` : le retire ; +//! - `POST /internal/facade/v1/push/send` (le Go, jeton interne) : pousse +//! « réponse prête » à tous les abonnés — le Go y délègue ses envois de fin +//! de tour et de fin de tâche. +//! +//! Le chiffrement est celui de RFC 8291 (`aes128gcm`, enregistrement complété +//! à 4096 octets comme webpush-go), signé VAPID (JWT ES256, 12 h). Mêmes clés +//! en base que le Go (`push_vapid_priv`, `push_vapid_pub`, `push_subs`) : un +//! abonnement pris d'un côté vaut de l'autre. +//! +//! Écart assumé : messages des journaux d'envoi au format des crates Rust. + +use std::time::{Duration, SystemTime, UNIX_EPOCH}; + +use hyper::body::Incoming; +use hyper::{Request, Response, StatusCode}; +use serde::{Deserialize, Serialize}; +use serde_json::{Value, json}; + +use crate::api::Ctx; +use crate::auth::json as send_json; +use crate::proxy::Body; +use crate::store::{BK_STATE, Store}; + +const ST_PRIV: &str = "push_vapid_priv"; +const ST_PUB: &str = "push_vapid_pub"; +const ST_SUBS: &str = "push_subs"; +/// Taille d'enregistrement (`MaxRecordSize` de webpush-go). +pub const RECORD_SIZE: usize = 4096; +/// Sujet VAPID : l'adresse nue, « mailto: » ajouté à la signature. +#[cfg_attr(not(feature = "tls"), allow(dead_code))] +const SUBSCRIBER: &str = "mail@nathaninline.com"; +/// Périmé après 2 min : une notification tardive n'a pas de sens. +const TTL: u32 = 120; + +/// La route interne d'envoi. +pub const INTERNAL_SEND: &str = "/internal/facade/v1/push/send"; + +/// Routes servies ici. +pub fn handles(path: &str) -> bool { + matches!( + path, + "/api/push/key" | "/api/push/subscribe" | "/api/push/unsubscribe" + ) +} + +/// Clés d'un abonnement (`webpush.Keys`). +#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq)] +pub struct Keys { + #[serde(default)] + pub auth: String, + #[serde(default)] + pub p256dh: String, +} + +/// Un abonnement du navigateur (`pushSub`). +#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq)] +pub struct PushSub { + #[serde(default)] + pub endpoint: String, + #[serde(default)] + pub keys: Keys, +} + +/// `fmtDurFR` : « 42s », « 3 mn 05s », « 1 h 12 mn ». +pub fn fmt_dur_fr(ms: i64) -> String { + // d.Round(time.Second) : à la seconde la plus proche, moitié vers le haut. + let secs = ((ms.max(0) + 500) / 1000).max(0); + let (h, m, s) = (secs / 3600, (secs % 3600) / 60, secs % 60); + if h > 0 { + format!("{h} h {m:02} mn") + } else if m > 0 { + format!("{m} mn {s:02}s") + } else { + format!("{s}s") + } +} + +// --------------------------------------------------------------------------- +// Chiffrement (RFC 8291) et signature (RFC 8292) +// --------------------------------------------------------------------------- + +#[cfg(feature = "tls")] +mod crypto { + use super::{PushSub, RECORD_SIZE, SUBSCRIBER}; + use base64::Engine; + use base64::engine::general_purpose::{STANDARD, URL_SAFE, URL_SAFE_NO_PAD}; + use ring::rand::SystemRandom; + use ring::signature::{ECDSA_P256_SHA256_FIXED_SIGNING, EcdsaKeyPair, KeyPair}; + use ring::{aead, agreement, hkdf}; + use serde_json::json; + + /// `decodeSubscriptionKey` : base64 standard, sinon URL, remplissage ajouté. + pub fn decode_sub_key(key: &str) -> Result, String> { + let mut k = key.to_string(); + let rem = k.len() % 4; + if rem != 0 { + k.push_str(&"=".repeat(4 - rem)); + } + STANDARD + .decode(&k) + .or_else(|_| URL_SAFE.decode(&k)) + .map_err(|e| e.to_string()) + } + + /// `decodeVapidKey` : base64 URL avec ou sans remplissage. + fn decode_vapid_key(key: &str) -> Result, String> { + URL_SAFE + .decode(key) + .or_else(|_| URL_SAFE_NO_PAD.decode(key)) + .map_err(|e| e.to_string()) + } + + struct Len(usize); + + impl hkdf::KeyType for Len { + fn len(&self) -> usize { + self.0 + } + } + + fn hkdf_expand(salt: &[u8], ikm: &[u8], info: &[u8], len: usize) -> Result, String> { + let prk = hkdf::Salt::new(hkdf::HKDF_SHA256, salt).extract(ikm); + let parts = [info]; + let okm = prk + .expand(&parts, Len(len)) + .map_err(|_| "hkdf".to_string())?; + let mut out = vec![0u8; len]; + okm.fill(&mut out).map_err(|_| "hkdf".to_string())?; + Ok(out) + } + + /// Le corps chiffré d'une notification : en-tête `aes128gcm` (sel, taille + /// d'enregistrement, clé publique éphémère) puis le message complété de + /// `0x02` et de zéros jusqu'à remplir l'enregistrement, comme webpush-go. + /// + /// `local_public` et `shared` : clé publique éphémère et secret ECDH — + /// tirés au hasard en production, imposés par les tests. `pad` : complété + /// jusqu'à remplir l'enregistrement (webpush-go), ou pas (exemple de la + /// RFC 8291). + #[allow(clippy::too_many_arguments)] + pub fn encrypt_with( + message: &[u8], + auth: &[u8], + ua_public: &[u8], + salt: &[u8; 16], + local_public: &[u8], + shared: &[u8], + record_size: usize, + pad: bool, + ) -> Result, String> { + let mut info = b"WebPush: info\0".to_vec(); + info.extend_from_slice(ua_public); + info.extend_from_slice(local_public); + let ikm = hkdf_expand(auth, shared, &info, 32)?; + let cek = hkdf_expand(salt, &ikm, b"Content-Encoding: aes128gcm\0", 16)?; + let nonce = hkdf_expand(salt, &ikm, b"Content-Encoding: nonce\0", 12)?; + let mut header = salt.to_vec(); + header.extend_from_slice(&(record_size as u32).to_be_bytes()); + header.push(local_public.len() as u8); + header.extend_from_slice(local_public); + let record_len = record_size - 16; + let mut data = message.to_vec(); + data.push(0x02); + let target = record_len.saturating_sub(header.len()); + if data.len() > target { + return Err("payload has exceeded the maximum length".into()); + } + if pad { + data.resize(target, 0); + } + let key = aead::LessSafeKey::new( + aead::UnboundKey::new(&aead::AES_128_GCM, &cek).map_err(|_| "aes".to_string())?, + ); + let nonce = + aead::Nonce::try_assume_unique_for_key(&nonce).map_err(|_| "nonce".to_string())?; + key.seal_in_place_append_tag(nonce, aead::Aad::empty(), &mut data) + .map_err(|_| "chiffrement".to_string())?; + header.extend_from_slice(&data); + Ok(header) + } + + /// [`encrypt_with`] avec un sel et une clé éphémère tirés au hasard. + pub fn encrypt(message: &[u8], sub: &PushSub) -> Result, String> { + let auth = decode_sub_key(&sub.keys.auth)?; + let ua = decode_sub_key(&sub.keys.p256dh)?; + let rng = SystemRandom::new(); + let mut salt = [0u8; 16]; + ring::rand::SecureRandom::fill(&rng, &mut salt).map_err(|_| "aléa".to_string())?; + let local = agreement::EphemeralPrivateKey::generate(&agreement::ECDH_P256, &rng) + .map_err(|_| "clé éphémère".to_string())?; + let local_pub = local + .compute_public_key() + .map_err(|_| "clé éphémère".to_string())? + .as_ref() + .to_vec(); + let peer = agreement::UnparsedPublicKey::new(&agreement::ECDH_P256, ua.clone()); + let shared = agreement::agree_ephemeral(local, &peer, |s| s.to_vec()).map_err(|_| { + "Unmarshal Error: Public key is not a valid point on the curve".to_string() + })?; + encrypt_with( + message, + &auth, + &ua, + &salt, + &local_pub, + &shared, + RECORD_SIZE, + true, + ) + } + + /// `getVAPIDAuthorizationHeader` : « vapid t=, k= ». + pub fn vapid_header( + endpoint: &str, + public: &str, + private: &str, + now: u64, + ) -> Result { + let u = reqwest::Url::parse(endpoint).map_err(|e| e.to_string())?; + let host = match u.port() { + Some(p) => format!("{}:{p}", u.host_str().unwrap_or("")), + None => u.host_str().unwrap_or("").to_string(), + }; + let sub = if SUBSCRIBER.starts_with("https:") { + SUBSCRIBER.to_string() + } else { + format!("mailto:{SUBSCRIBER}") + }; + // json.Marshal d'une MapClaims : clés triées. + let claims = json!({ "aud": format!("{}://{host}", u.scheme()), "exp": now + 12 * 3600, "sub": sub }); + let signing_input = format!( + "{}.{}", + URL_SAFE_NO_PAD.encode(br#"{"alg":"ES256","typ":"JWT"}"#), + URL_SAFE_NO_PAD.encode(claims.to_string()) + ); + let priv_raw = decode_vapid_key(private)?; + let pub_raw = decode_vapid_key(public)?; + let rng = SystemRandom::new(); + let kp = EcdsaKeyPair::from_private_key_and_public_key( + &ECDSA_P256_SHA256_FIXED_SIGNING, + &priv_raw, + &pub_raw, + &rng, + ) + .map_err(|e| format!("clé VAPID illisible : {e}"))?; + let sig = kp + .sign(&rng, signing_input.as_bytes()) + .map_err(|_| "signature VAPID".to_string())?; + Ok(format!( + "vapid t={signing_input}.{}, k={}", + URL_SAFE_NO_PAD.encode(sig.as_ref()), + URL_SAFE_NO_PAD.encode(&pub_raw) + )) + } + + /// `GenerateVAPIDKeys` : (privée 32 octets, publique 65 octets), base64 URL + /// sans remplissage. + pub fn generate_vapid_keys() -> Result<(String, String), String> { + let rng = SystemRandom::new(); + let doc = EcdsaKeyPair::generate_pkcs8(&ECDSA_P256_SHA256_FIXED_SIGNING, &rng) + .map_err(|_| "génération VAPID".to_string())?; + let kp = EcdsaKeyPair::from_pkcs8(&ECDSA_P256_SHA256_FIXED_SIGNING, doc.as_ref(), &rng) + .map_err(|_| "génération VAPID".to_string())?; + let public = kp.public_key().as_ref().to_vec(); + // ECPrivateKey dans le PKCS#8 : « 04 20 » puis les 32 octets du scalaire. + let d = doc.as_ref(); + let i = d + .windows(2) + .position(|w| w == [0x04, 0x20]) + .ok_or("génération VAPID")?; + let private = d.get(i + 2..i + 34).ok_or("génération VAPID")?.to_vec(); + Ok(( + URL_SAFE_NO_PAD.encode(private), + URL_SAFE_NO_PAD.encode(public), + )) + } +} + +#[cfg(not(feature = "tls"))] +mod crypto { + //! Sans `tls` (pas de ring) : pas de notifications. + use super::PushSub; + const NO: &str = "notifications indisponibles (façade construite sans TLS)"; + pub fn decode_sub_key(_: &str) -> Result, String> { + Err(NO.into()) + } + pub fn encrypt(_: &[u8], _: &PushSub) -> Result, String> { + Err(NO.into()) + } + pub fn vapid_header(_: &str, _: &str, _: &str, _: u64) -> Result { + Err(NO.into()) + } + pub fn generate_vapid_keys() -> Result<(String, String), String> { + Err(NO.into()) + } +} + +pub use crypto::*; + +// --------------------------------------------------------------------------- +// Service +// --------------------------------------------------------------------------- + +/// Les notifications tenues par la façade. +pub struct Push { + client: reqwest::Client, + /// Génération paresseuse des clés : une seule paire, même sous la course. + vapid: tokio::sync::Mutex<()>, + /// Lecture-modification-écriture de la liste des abonnements. + subs: tokio::sync::Mutex<()>, +} + +impl Default for Push { + fn default() -> Self { + Self::new() + } +} + +/// Hôte d'un endpoint pour les journaux (jamais le jeton entier). +fn endpoint_host(endpoint: &str) -> String { + reqwest::Url::parse(endpoint) + .ok() + .and_then(|u| { + u.host_str().map(|h| match u.port() { + Some(p) => format!("{h}:{p}"), + None => h.to_string(), + }) + }) + .filter(|h| !h.is_empty()) + .unwrap_or_else(|| "?".into()) +} + +impl Push { + pub fn new() -> Self { + crate::chat::engine::install_crypto(); + Push { + client: reqwest::Client::builder() + .timeout(Duration::from_secs(30)) + .build() + .unwrap_or_default(), + vapid: tokio::sync::Mutex::new(()), + subs: tokio::sync::Mutex::new(()), + } + } + + /// `vapidKeys` : la paire, générée et rangée au premier appel. + pub async fn vapid_keys(&self, store: &Store) -> Result<(String, String), String> { + let _g = self.vapid.lock().await; + let private = store.get_str(BK_STATE, ST_PRIV).await; + let public = store.get_str(BK_STATE, ST_PUB).await; + if !private.is_empty() && !public.is_empty() { + return Ok((private, public)); + } + let (private, public) = generate_vapid_keys()?; + store + .put(BK_STATE, ST_PRIV, private.clone().into_bytes()) + .await?; + store + .put(BK_STATE, ST_PUB, public.clone().into_bytes()) + .await?; + Ok((private, public)) + } + + async fn load_subs(store: &Store) -> Vec { + store + .get(BK_STATE, ST_SUBS) + .await + .ok() + .flatten() + .and_then(|b| { + serde_json::from_slice::>>(&b) + .ok() + .flatten() + }) + .unwrap_or_default() + } + + async fn save_subs(store: &Store, subs: &[PushSub]) -> Result<(), String> { + let raw = serde_json::to_vec(subs).map_err(|e| e.to_string())?; + store.put(BK_STATE, ST_SUBS, raw).await + } + + /// `addSub` : idempotent par endpoint (les clés sont mises à jour). + pub async fn add_sub(&self, store: &Store, s: PushSub) -> Result { + let _g = self.subs.lock().await; + let mut subs = Self::load_subs(store).await; + match subs.iter_mut().find(|x| x.endpoint == s.endpoint) { + Some(x) => *x = s, + None => subs.push(s), + } + Self::save_subs(store, &subs).await?; + Ok(subs.len()) + } + + /// `removeSub`. + pub async fn remove_sub(&self, store: &Store, endpoint: &str) { + if endpoint.is_empty() { + return; + } + let _g = self.subs.lock().await; + let mut subs = Self::load_subs(store).await; + subs.retain(|x| x.endpoint != endpoint); + let _ = Self::save_subs(store, &subs).await; + } + + /// `sendPushToAll` : best-effort ; un abonnement rejeté pour de bon + /// (404/410) est purgé. + pub async fn send_all(&self, store: &Store, title: &str, body: &str) { + let subs = Self::load_subs(store).await; + if subs.is_empty() { + println!("[push] fin de tour : aucun abonné enregistré (rien à envoyer)"); + return; + } + let (private, public) = match self.vapid_keys(store).await { + Ok(k) => k, + Err(e) => { + println!("[push] clés VAPID indisponibles : {e}"); + return; + } + }; + #[derive(Serialize)] + struct Payload<'a> { + title: &'a str, + body: &'a str, + tag: &'a str, + } + let msg = serde_json::to_vec(&Payload { + title, + body, + tag: "loki-turn", + }) + .unwrap_or_default(); + println!("[push] envoi à {} abonné(s)…", subs.len()); + let now = SystemTime::now() + .duration_since(UNIX_EPOCH) + .map_or(0, |d| d.as_secs()); + for s in subs { + let host = endpoint_host(&s.endpoint); + let sent = async { + let body = encrypt(&msg, &s)?; + let auth = vapid_header(&s.endpoint, &public, &private, now)?; + self.client + .post(&s.endpoint) + .header("Content-Encoding", "aes128gcm") + .header("Content-Type", "application/octet-stream") + .header("TTL", TTL.to_string()) + .header("Urgency", "high") + .header("Authorization", auth) + .body(body) + .send() + .await + .map_err(|e| e.to_string()) + } + .await; + let resp = match sent { + Ok(r) => r, + Err(e) => { + println!("[push] échec envoi ({host}) : {e}"); + continue; + } + }; + let code = resp.status().as_u16(); + if code >= 300 { + let text = resp.text().await.unwrap_or_default(); + let text: String = text.chars().take(512).collect(); + println!("[push] refus {code} ({host}) : {}", text.trim()); + } else { + println!("[push] accepté {code} ({host})"); + } + if code == 404 || code == 410 { + self.remove_sub(store, &s.endpoint).await; + } + } + } +} + +// --------------------------------------------------------------------------- +// Routes +// --------------------------------------------------------------------------- + +async fn body_json(body: Incoming) -> Option { + use http_body_util::BodyExt; + let b = http_body_util::Limited::new(body, 1 << 20) + .collect() + .await + .ok()? + .to_bytes(); + serde_json::from_slice(&b).ok() +} + +/// `from_go` : remise par le Go (tunnel), déjà authentifiée par lui. +pub async fn serve( + ctx: &Ctx, + push: &std::sync::Arc, + req: Request, + from_go: bool, +) -> Response { + let (parts, body) = req.into_parts(); + if !from_go && let Err(refus) = ctx.guard(&parts.headers).await { + return *refus; + } + let fail = |code: StatusCode, e: String| send_json(code, &json!({ "ok": false, "error": e })); + match parts.uri.path() { + "/api/push/key" => match push.vapid_keys(&ctx.store).await { + Ok((_, public)) => send_json(StatusCode::OK, &json!({ "ok": true, "key": public })), + Err(e) => fail(StatusCode::INTERNAL_SERVER_ERROR, e), + }, + "/api/push/subscribe" => { + let s: PushSub = match body_json(body) + .await + .and_then(|v| serde_json::from_value::(v).ok()) + { + Some(s) if !s.endpoint.is_empty() => s, + _ => return fail(StatusCode::BAD_REQUEST, "abonnement invalide".into()), + }; + let host = endpoint_host(&s.endpoint); + match push.add_sub(&ctx.store, s).await { + Ok(n) => { + println!("[push] abonnement enregistré ({host}) — {n} au total"); + send_json(StatusCode::OK, &json!({ "ok": true })) + } + Err(e) => { + println!("[push] abonnement REFUSÉ (stockage) : {e}"); + fail(StatusCode::INTERNAL_SERVER_ERROR, e) + } + } + } + "/api/push/unsubscribe" => { + let ep = body_json(body) + .await + .and_then(|v| { + v.get("endpoint") + .and_then(Value::as_str) + .map(str::to_string) + }) + .unwrap_or_default(); + push.remove_sub(&ctx.store, &ep).await; + send_json(StatusCode::OK, &json!({ "ok": true })) + } + _ => send_json( + StatusCode::NOT_FOUND, + &json!({ "error": "404 page not found" }), + ), + } +} + +/// `POST /internal/facade/v1/push/send {title, body}` : envoi en tâche de +/// fond (le Go n'attend pas les services de push). +pub async fn serve_internal( + store: Store, + push: std::sync::Arc, + req: Request, +) -> Response { + let v = body_json(req.into_body()).await.unwrap_or(Value::Null); + let title = v + .get("title") + .and_then(Value::as_str) + .unwrap_or("Loki") + .to_string(); + let body = v + .get("body") + .and_then(Value::as_str) + .unwrap_or("") + .to_string(); + tokio::spawn(async move { push.send_all(&store, &title, &body).await }); + use http_body_util::BodyExt; + let mut r = Response::new( + http_body_util::Full::new(bytes::Bytes::new()) + .map_err(|n| match n {}) + .boxed(), + ); + *r.status_mut() = StatusCode::NO_CONTENT; + r +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn durees() { + assert_eq!(fmt_dur_fr(42_000), "42s"); + assert_eq!(fmt_dur_fr(185_000), "3 mn 05s"); + assert_eq!(fmt_dur_fr(4_330_000), "1 h 12 mn"); + assert_eq!(fmt_dur_fr(600), "1s"); + assert_eq!(fmt_dur_fr(-5), "0s"); + } + + #[cfg(feature = "tls")] + #[test] + fn cles_vapid() { + let (p, q) = generate_vapid_keys().unwrap(); + assert_eq!((p.len(), q.len()), (43, 87)); + let h = vapid_header("https://push.exemple.fr:8443/x/y", &q, &p, 1_700_000_000).unwrap(); + assert!( + h.starts_with("vapid t=eyJhbGciOiJFUzI1NiIsInR5cCI6IkpXVCJ9."), + "{h}" + ); + assert!(h.ends_with(&format!(", k={q}"))); + } +} diff --git a/rust/loki-rs/tests/common/fakego.rs b/rust/loki-rs/tests/common/fakego.rs index 766ba26..08a3b24 100644 --- a/rust/loki-rs/tests/common/fakego.rs +++ b/rust/loki-rs/tests/common/fakego.rs @@ -473,6 +473,7 @@ pub struct Opts { pub dictate: Option>, pub settings: bool, pub files: Option>, + pub push: Option>, } /// Comme [`start`], avec la conversation tenue par la façade @@ -566,6 +567,7 @@ pub async fn start_with( dictate: opts.dictate, settings: opts.settings, files: opts.files, + push: opts.push, }; tokio::spawn(loki_rs::proxy::serve(fl, cfg, async move { let _ = rx.await; diff --git a/rust/loki-rs/tests/lot2.rs b/rust/loki-rs/tests/lot2.rs index d52cce2..826f9a8 100644 --- a/rust/loki-rs/tests/lot2.rs +++ b/rust/loki-rs/tests/lot2.rs @@ -98,6 +98,7 @@ async fn banc(cle: Option<&str>) -> Banc { dictate: None, settings: false, files: None, + push: None, }; tokio::spawn(loki_rs::proxy::serve(l, cfg, async move { let _ = rx.await; diff --git a/rust/loki-rs/tests/parity_push/push.json b/rust/loki-rs/tests/parity_push/push.json new file mode 100644 index 0000000..3d7577b --- /dev/null +++ b/rust/loki-rs/tests/parity_push/push.json @@ -0,0 +1,126 @@ +{ + "durations": { + "-5s": "0s", + "0s": "0s", + "1h0m0s": "1 h 00 mn", + "1h12m10s": "1 h 12 mn", + "3m5s": "3 mn 05s", + "400ms": "0s", + "42s": "42s", + "600ms": "1s" + }, + "calls": [ + { + "method": "GET", + "path": "/api/push/key", + "status": 200, + "out": { + "key": "{{CLE}}", + "ok": true + }, + "subs": "" + }, + { + "method": "POST", + "path": "/api/push/subscribe", + "body": "{\"endpoint\":\"https://push.exemple.fr/a\",\"keys\":{\"p256dh\":\"BAAA\",\"auth\":\"x1\"}}", + "status": 200, + "out": { + "ok": true + }, + "subs": "[{\"endpoint\":\"https://push.exemple.fr/a\",\"keys\":{\"auth\":\"x1\",\"p256dh\":\"BAAA\"}}]" + }, + { + "method": "POST", + "path": "/api/push/subscribe", + "body": "{\"endpoint\":\"https://push.exemple.fr/b\",\"keys\":{\"p256dh\":\"BBBB\",\"auth\":\"x2\"},\"expirationTime\":null}", + "status": 200, + "out": { + "ok": true + }, + "subs": "[{\"endpoint\":\"https://push.exemple.fr/a\",\"keys\":{\"auth\":\"x1\",\"p256dh\":\"BAAA\"}},{\"endpoint\":\"https://push.exemple.fr/b\",\"keys\":{\"auth\":\"x2\",\"p256dh\":\"BBBB\"}}]" + }, + { + "method": "POST", + "path": "/api/push/subscribe", + "body": "{\"endpoint\":\"https://push.exemple.fr/a\",\"keys\":{\"p256dh\":\"BCCC\",\"auth\":\"x3\"}}", + "status": 200, + "out": { + "ok": true + }, + "subs": "[{\"endpoint\":\"https://push.exemple.fr/a\",\"keys\":{\"auth\":\"x3\",\"p256dh\":\"BCCC\"}},{\"endpoint\":\"https://push.exemple.fr/b\",\"keys\":{\"auth\":\"x2\",\"p256dh\":\"BBBB\"}}]" + }, + { + "method": "POST", + "path": "/api/push/subscribe", + "body": "{\"keys\":{\"p256dh\":\"B\",\"auth\":\"y\"}}", + "status": 400, + "out": { + "error": "abonnement invalide", + "ok": false + }, + "subs": "[{\"endpoint\":\"https://push.exemple.fr/a\",\"keys\":{\"auth\":\"x3\",\"p256dh\":\"BCCC\"}},{\"endpoint\":\"https://push.exemple.fr/b\",\"keys\":{\"auth\":\"x2\",\"p256dh\":\"BBBB\"}}]" + }, + { + "method": "POST", + "path": "/api/push/subscribe", + "body": "pas du json", + "status": 400, + "out": { + "error": "abonnement invalide", + "ok": false + }, + "subs": "[{\"endpoint\":\"https://push.exemple.fr/a\",\"keys\":{\"auth\":\"x3\",\"p256dh\":\"BCCC\"}},{\"endpoint\":\"https://push.exemple.fr/b\",\"keys\":{\"auth\":\"x2\",\"p256dh\":\"BBBB\"}}]" + }, + { + "method": "POST", + "path": "/api/push/unsubscribe", + "body": "{\"endpoint\":\"https://push.exemple.fr/inconnu\"}", + "status": 200, + "out": { + "ok": true + }, + "subs": "[{\"endpoint\":\"https://push.exemple.fr/a\",\"keys\":{\"auth\":\"x3\",\"p256dh\":\"BCCC\"}},{\"endpoint\":\"https://push.exemple.fr/b\",\"keys\":{\"auth\":\"x2\",\"p256dh\":\"BBBB\"}}]" + }, + { + "method": "POST", + "path": "/api/push/unsubscribe", + "body": "{\"endpoint\":\"https://push.exemple.fr/a\"}", + "status": 200, + "out": { + "ok": true + }, + "subs": "[{\"endpoint\":\"https://push.exemple.fr/b\",\"keys\":{\"auth\":\"x2\",\"p256dh\":\"BBBB\"}}]" + }, + { + "method": "POST", + "path": "/api/push/unsubscribe", + "body": "pas du json", + "status": 200, + "out": { + "ok": true + }, + "subs": "[{\"endpoint\":\"https://push.exemple.fr/b\",\"keys\":{\"auth\":\"x2\",\"p256dh\":\"BBBB\"}}]" + }, + { + "method": "POST", + "path": "/api/push/unsubscribe", + "body": "{\"endpoint\":\"https://push.exemple.fr/b\"}", + "status": 200, + "out": { + "ok": true + }, + "subs": "[]" + }, + { + "method": "GET", + "path": "/api/push/key", + "status": 200, + "out": { + "key": "{{CLE}}", + "ok": true + }, + "subs": "[]" + } + ] +} diff --git a/rust/loki-rs/tests/proxy.rs b/rust/loki-rs/tests/proxy.rs index 71d207b..347d2aa 100644 --- a/rust/loki-rs/tests/proxy.rs +++ b/rust/loki-rs/tests/proxy.rs @@ -104,6 +104,7 @@ async fn facade(backend: SocketAddr) -> (SocketAddr, oneshot::Sender<()>) { dictate: None, settings: false, files: None, + push: None, }; tokio::spawn(loki_rs::proxy::serve(l, cfg, async move { let _ = rx.await; diff --git a/rust/loki-rs/tests/push.rs b/rust/loki-rs/tests/push.rs new file mode 100644 index 0000000..98e088c --- /dev/null +++ b/rust/loki-rs/tests/push.rs @@ -0,0 +1,316 @@ +//! Lot 12 : notifications Web Push tenues par la façade (`LOKI_PUSH=rust`). +//! +//! - le journal tiré du Go (`internal/loki/parity_push_test.go` → +//! `tests/parity_push/push.json`) : durées, clé, abonnements — la liste en +//! base comparée à l'octet près ; +//! - le chiffrement contre l'exemple de la RFC 8291 (annexe A) ; +//! - un envoi de bout en bout vers un faux service de push : en-têtes, +//! signature VAPID vérifiée, corps déchiffré côté navigateur, abonnement +//! purgé sur 410. +#![cfg(feature = "tls")] + +mod common; + +use std::convert::Infallible; +use std::net::SocketAddr; +use std::sync::{Arc, Mutex}; +use std::time::Duration; + +use base64::Engine; +use base64::engine::general_purpose::URL_SAFE_NO_PAD; +use bytes::Bytes; +use common::fakego; +use http_body_util::{BodyExt, Full}; +use hyper::body::Incoming; +use hyper::{Request, Response, StatusCode, service::service_fn}; +use hyper_util::rt::TokioIo; +use loki_rs::push; +use ring::{aead, agreement, hkdf, signature}; +use serde_json::Value; + +fn fixture() -> Value { + serde_json::from_str(include_str!("parity_push/push.json")).expect("journal") +} + +/// `time.ParseDuration`, réduit à ce que le journal emploie. +fn go_ms(s: &str) -> i64 { + let (neg, mut s) = match s.strip_prefix('-') { + Some(r) => (true, r), + None => (false, s), + }; + let mut ms = 0f64; + while !s.is_empty() { + let n = s.find(|c: char| !c.is_ascii_digit() && c != '.').unwrap(); + let v: f64 = s[..n].parse().unwrap(); + let rest = &s[n..]; + let (mul, len) = if rest.starts_with("ms") { + (1.0, 2) + } else if rest.starts_with('h') { + (3_600_000.0, 1) + } else if rest.starts_with('m') { + (60_000.0, 1) + } else { + (1000.0, 1) + }; + ms += v * mul; + s = &rest[len..]; + } + if neg { -(ms as i64) } else { ms as i64 } +} + +#[test] +fn parite_des_durees() { + for (d, want) in fixture()["durations"].as_object().unwrap() { + assert_eq!(push::fmt_dur_fr(go_ms(d)), want.as_str().unwrap(), "{d}"); + } +} + +#[test] +fn exemple_de_la_rfc_8291() { + let b = |s: &str| URL_SAFE_NO_PAD.decode(s).unwrap(); + let body = push::encrypt_with( + b"When I grow up, I want to be a watermelon", + &b("BTBZMqHH6r4Tts7J_aSIgg"), + &b("BCVxsr7N_eNgVRqvHtD0zTZsEc6-VV-JvLexhqUzORcxaOzi6-AYWXvTBHm4bjyPjs7Vd8pZGH6SRpkNtoIAiw4"), + b("DGv6ra1nlYgDCS1FRnbzlw").as_slice().try_into().unwrap(), + &b("BP4z9KsN6nGRTbVYI_c7VJSPQTBtkgcy27mlmlMoZIIgDll6e3vCYLocInmYWAmS6TlzAC8wEqKK6PBru3jl7A8"), + &b("kyrL1jIIOHEzg3sM2ZWRHDRB62YACZhhSlknJ672kSs"), + 4096, + false, + ) + .unwrap(); + assert_eq!( + URL_SAFE_NO_PAD.encode(body), + "DGv6ra1nlYgDCS1FRnbzlwAAEABBBP4z9KsN6nGRTbVYI_c7VJSPQTBtkgcy27mlmlMoZIIgDll6e3vCYLocInmYWAmS6TlzAC8wEqKK6PBru3jl7A_yl95bQpu6cVPTpK4Mqgkf1CXztLVBSt2Ks3oZwbuwXPXLWyouBWLVWGNWQexSgSxsj_Qulcy4a-fN" + ); +} + +#[tokio::test] +async fn parite_des_routes() { + let fx = fixture(); + let tmp = tempfile::tempdir().unwrap(); + let opts = fakego::Opts { + push: Some(Arc::new(push::Push::new())), + ..Default::default() + }; + let (facade, go, _stop, _) = fakego::start_with(tmp.path().to_path_buf(), opts).await; + let key_re = regex::Regex::new(r#""key":"[A-Za-z0-9_-]{87}""#).unwrap(); + let mut first_key = String::new(); + for (i, c) in fx["calls"].as_array().unwrap().iter().enumerate() { + let (m, p) = (c["method"].as_str().unwrap(), c["path"].as_str().unwrap()); + let body = c.get("body").and_then(Value::as_str).unwrap_or(""); + let (code, b) = fakego::send(facade, m, p, &[], body).await; + let txt = String::from_utf8_lossy(&b).into_owned(); + if let Some(k) = key_re.find(&txt) { + // Clé stable d'un appel à l'autre. + if first_key.is_empty() { + first_key = k.as_str().to_string(); + } + assert_eq!(k.as_str(), first_key, "clé VAPID changée"); + } + let got: Value = + serde_json::from_str(&key_re.replace_all(&txt, r#""key":"{{CLE}}""#)).unwrap(); + let ctx = format!("appel {i} : {m} {p} {body}"); + assert_eq!(code.as_u16() as u64, c["status"].as_u64().unwrap(), "{ctx}"); + assert_eq!(got, c["out"], "{ctx}"); + assert_eq!( + go.get("state", "push_subs").unwrap_or_default(), + c["subs"].as_str().unwrap(), + "{ctx} (abonnements en base)" + ); + } + assert_eq!(go.get("state", "push_vapid_priv").unwrap().len(), 43); +} + +/// Faux service de push : répond 201 au premier endpoint, 410 au second, et +/// garde chaque requête (chemin, en-têtes, corps). +#[derive(Default)] +struct FakePush { + seen: Mutex>, +} + +async fn fake_push() -> (SocketAddr, Arc) { + let st = Arc::new(FakePush::default()); + let l = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap(); + let addr = l.local_addr().unwrap(); + let s2 = st.clone(); + tokio::spawn(async move { + while let Ok((s, _)) = l.accept().await { + let st = s2.clone(); + tokio::spawn(async move { + let svc = service_fn(move |req: Request| { + let st = st.clone(); + async move { + let path = req.uri().path().to_string(); + let (parts, body) = req.into_parts(); + let b = body.collect().await.unwrap().to_bytes(); + st.seen + .lock() + .unwrap() + .push((path.clone(), parts.headers, b)); + let code = if path.ends_with("/perime") { 410 } else { 201 }; + let mut r = Response::new(Full::new(Bytes::from_static(b""))); + *r.status_mut() = StatusCode::from_u16(code).unwrap(); + Ok::<_, Infallible>(r) + } + }); + let _ = hyper::server::conn::http1::Builder::new() + .serve_connection(TokioIo::new(s), svc) + .await; + }); + } + }); + (addr, st) +} + +struct Len(usize); +impl hkdf::KeyType for Len { + fn len(&self) -> usize { + self.0 + } +} + +fn expand(salt: &[u8], ikm: &[u8], info: &[u8], n: usize) -> Vec { + let prk = hkdf::Salt::new(hkdf::HKDF_SHA256, salt).extract(ikm); + let parts = [info]; + let okm = prk.expand(&parts, Len(n)).unwrap(); + let mut out = vec![0u8; n]; + okm.fill(&mut out).unwrap(); + out +} + +#[tokio::test] +async fn envoi_de_bout_en_bout() { + let (addr, svc) = fake_push().await; + let tmp = tempfile::tempdir().unwrap(); + let p = Arc::new(push::Push::new()); + let opts = fakego::Opts { + push: Some(p.clone()), + ..Default::default() + }; + let (facade, go, _stop, _) = fakego::start_with(tmp.path().to_path_buf(), opts).await; + // Côté navigateur : une clé ECDH (à usage unique ici) et le secret d'auth. + let rng = ring::rand::SystemRandom::new(); + let ua_priv = agreement::EphemeralPrivateKey::generate(&agreement::ECDH_P256, &rng).unwrap(); + let ua_pub = ua_priv.compute_public_key().unwrap().as_ref().to_vec(); + let auth = b"seize octets !!!".to_vec(); + let subs = format!( + r#"[{{"endpoint":"http://{addr}/push/ok","keys":{{"auth":"{}","p256dh":"{}"}}}},{{"endpoint":"http://{addr}/push/perime","keys":{{"auth":"{}","p256dh":"{}"}}}}]"#, + URL_SAFE_NO_PAD.encode(&auth), + URL_SAFE_NO_PAD.encode(&ua_pub), + URL_SAFE_NO_PAD.encode(&auth), + URL_SAFE_NO_PAD.encode(&ua_pub), + ); + go.set("state", "push_subs", &subs); + let (code, _) = fakego::send( + facade, + "POST", + push::INTERNAL_SEND, + &[("x-loki-internal", fakego::JETON)], + r#"{"title":"Loki","body":"Réponse prête · 42s"}"#, + ) + .await; + assert_eq!(code, StatusCode::NO_CONTENT); + for _ in 0..200 { + if svc.seen.lock().unwrap().len() >= 2 + && go + .get("state", "push_subs") + .is_some_and(|s| !s.contains("perime")) + { + break; + } + tokio::time::sleep(Duration::from_millis(20)).await; + } + let seen = svc.seen.lock().unwrap().clone(); + assert_eq!(seen.len(), 2); + // Abonnement périmé (410) purgé, l'autre gardé. + let left = go.get("state", "push_subs").unwrap(); + assert!( + left.contains("/push/ok") && !left.contains("perime"), + "{left}" + ); + + let (path, h, body) = seen.iter().find(|s| s.0 == "/push/ok").unwrap().clone(); + assert_eq!(path, "/push/ok"); + assert_eq!(h["content-encoding"], "aes128gcm"); + assert_eq!(h["content-type"], "application/octet-stream"); + assert_eq!(h["ttl"], "120"); + assert_eq!(h["urgency"], "high"); + // VAPID : JWT ES256 signé par la clé rangée, audience = origine du service. + let vapid_pub = go.get("state", "push_vapid_pub").unwrap(); + let auth_h = h["authorization"].to_str().unwrap().to_string(); + let rest = auth_h.strip_prefix("vapid t=").unwrap(); + let (jwt, k) = rest.split_once(", k=").unwrap(); + assert_eq!(k, vapid_pub); + let mut parts = jwt.split('.'); + let (h64, c64, s64) = ( + parts.next().unwrap(), + parts.next().unwrap(), + parts.next().unwrap(), + ); + let claims: Value = serde_json::from_slice(&URL_SAFE_NO_PAD.decode(c64).unwrap()).unwrap(); + assert_eq!(claims["aud"], format!("http://{addr}")); + assert_eq!(claims["sub"], "mailto:mail@nathaninline.com"); + signature::UnparsedPublicKey::new( + &signature::ECDSA_P256_SHA256_FIXED, + URL_SAFE_NO_PAD.decode(&vapid_pub).unwrap(), + ) + .verify( + format!("{h64}.{c64}").as_bytes(), + &URL_SAFE_NO_PAD.decode(s64).unwrap(), + ) + .expect("signature VAPID invalide"); + + // Déchiffrement côté navigateur (RFC 8291). + assert_eq!( + body.len(), + push::RECORD_SIZE, + "enregistrement complété à 4096 octets" + ); + let salt = &body[..16]; + assert_eq!(u32::from_be_bytes(body[16..20].try_into().unwrap()), 4096); + let id_len = body[20] as usize; + let as_pub = body[21..21 + id_len].to_vec(); + let cipher = body[21 + id_len..].to_vec(); + let peer = agreement::UnparsedPublicKey::new(&agreement::ECDH_P256, as_pub.clone()); + let shared = agreement::agree_ephemeral(ua_priv, &peer, |s| s.to_vec()).unwrap(); + let mut info = b"WebPush: info\0".to_vec(); + info.extend_from_slice(&ua_pub); + info.extend_from_slice(&as_pub); + let ikm = expand(&auth, &shared, &info, 32); + let cek = expand(salt, &ikm, b"Content-Encoding: aes128gcm\0", 16); + let nonce = expand(salt, &ikm, b"Content-Encoding: nonce\0", 12); + let key = aead::LessSafeKey::new(aead::UnboundKey::new(&aead::AES_128_GCM, &cek).unwrap()); + let mut buf = cipher; + let plain = key + .open_in_place( + aead::Nonce::try_assume_unique_for_key(&nonce).unwrap(), + aead::Aad::empty(), + &mut buf, + ) + .expect("déchiffrement"); + let end = plain.iter().rposition(|b| *b == 0x02).unwrap(); + assert!(plain[end + 1..].iter().all(|b| *b == 0)); + let msg: Value = serde_json::from_slice(&plain[..end]).unwrap(); + assert_eq!( + msg, + serde_json::json!({ "title": "Loki", "body": "Réponse prête · 42s", "tag": "loki-turn" }) + ); +} + +#[tokio::test] +async fn sans_la_variable_les_notifications_restent_au_go() { + let tmp = tempfile::tempdir().unwrap(); + let (facade, _go, _stop) = fakego::start(tmp.path().to_path_buf()).await; + let (_, _, b) = fakego::get(facade, "/api/push/key", &[]).await; + assert_eq!(&b[..], b"go:/api/push/key"); + let (code, _) = fakego::send( + facade, + "POST", + push::INTERNAL_SEND, + &[("x-loki-internal", fakego::JETON)], + "{}", + ) + .await; + assert_eq!(code, StatusCode::NOT_FOUND); +} From db9b0b44f536801c15a4185083ae159993c3ccf8 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 15 Oct 2026 17:05:47 +0000 Subject: [PATCH 09/52] Clippy 1.99 : as_chunks au lieu de chunks_exact(4) pour la vignette MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Le clippy de la CI (Rust 1.99) refuse chunks_exact à taille constante (chunks_exact_to_as_chunks). Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_016tcGfJWefW5kUGoC2wL8cv --- rust/loki-rs/src/convfiles.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/rust/loki-rs/src/convfiles.rs b/rust/loki-rs/src/convfiles.rs index 3d634a6..1cd8608 100644 --- a/rust/loki-rs/src/convfiles.rs +++ b/rust/loki-rs/src/convfiles.rs @@ -578,7 +578,7 @@ impl ConvFiles { } // Fond blanc sous la transparence. let mut rgb = Vec::with_capacity(src.w * src.h * 3); - for p in src.pix.chunks_exact(4) { + for p in src.pix.as_chunks::<4>().0 { let a = p[3] as u32; for c in &p[..3] { rgb.push(((*c as u32 * a + 255 * (255 - a) + 127) / 255) as u8); From 1e5f0904f2f388315de8a80eb5a260058cdbd8fa Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 15 Oct 2026 17:33:22 +0000 Subject: [PATCH 10/52] =?UTF-8?q?Lot=2013=20:=20m=C3=A9moire=20tenue=20par?= =?UTF-8?q?=20la=20fa=C3=A7ade=20(LOKI=5FMEM=3Drust)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit La façade sert l'éditeur des pages mémoire (lecture, enregistrement, renommage, suppression, déplacement vers un autre projet, index MEMORY.md tenu par le code), le panneau des trackers, le mode mémoire du projet actif et les interrupteurs du mode agent, quand LOKI_MEM=rust (expérimental ; le Go reste le défaut). Les pages restent des fichiers memory//*.md écrits de façon sûre (.bak, temporaire synchronisé puis renommé, relecture vérifiée), les trackers des valeurs du bucket tracker. Le chiffrement reste au Go : la façade fait ouvrir et sceller ses contenus par /internal/v1/crypt/*, et n'écrit jamais en clair quand la mémoire est verrouillée. gojson.rs reproduit le décodage d'encoding/json (première valeur, EOF, erreurs du scanner et de type, structs anonymes) pour renvoyer au client les mêmes messages que le Go ; il servira aux lots suivants. Côté Go : facade_mem.go remet ces routes à la façade quand elles arrivent par le tunnel ; coffre, instantanés, sauvegarde et projets restent au Go. Parité : parity_gojson_test.go (81 corps) → tests/gojson.rs ; parity_mem_test.go (noms, titres, slugs, modes, 36 dates, 102 appels de route avec le disque et la base après chacun) → tests/memory.rs. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_016tcGfJWefW5kUGoC2wL8cv --- README.md | 1 + ...-15-rust-migration-lot13-memoire-design.md | 117 + internal/loki/facade_chat.go | 2 + internal/loki/facade_chat_test.go | 34 + internal/loki/facade_mem.go | 45 + internal/loki/parity_gojson_test.go | 85 + internal/loki/parity_mem_test.go | 442 + rust/loki-rs/src/api.rs | 3 + rust/loki-rs/src/gojson.rs | 687 ++ rust/loki-rs/src/lib.rs | 2 + rust/loki-rs/src/main.rs | 37 +- rust/loki-rs/src/memory.rs | 1379 +++ rust/loki-rs/src/proxy.rs | 8 + rust/loki-rs/tests/common/fakego.rs | 34 + rust/loki-rs/tests/gojson.rs | 41 + rust/loki-rs/tests/lot2.rs | 1 + rust/loki-rs/tests/memory.rs | 394 + rust/loki-rs/tests/parity_gojson/decode.json | 1052 +++ rust/loki-rs/tests/parity_mem/mem.json | 7597 +++++++++++++++++ rust/loki-rs/tests/proxy.rs | 1 + 20 files changed, 11944 insertions(+), 18 deletions(-) create mode 100644 docs/superpowers/specs/2026-10-15-rust-migration-lot13-memoire-design.md create mode 100644 internal/loki/facade_mem.go create mode 100644 internal/loki/parity_gojson_test.go create mode 100644 internal/loki/parity_mem_test.go create mode 100644 rust/loki-rs/src/gojson.rs create mode 100644 rust/loki-rs/src/memory.rs create mode 100644 rust/loki-rs/tests/gojson.rs create mode 100644 rust/loki-rs/tests/memory.rs create mode 100644 rust/loki-rs/tests/parity_gojson/decode.json create mode 100644 rust/loki-rs/tests/parity_mem/mem.json diff --git a/README.md b/README.md index 6b58d0d..e30d5b1 100644 --- a/README.md +++ b/README.md @@ -233,6 +233,7 @@ redémarrages (volume `/data`). Variables d'environnement du conteneur : | `LOKI_SETTINGS` | `rust` : les réglages partagés — préférences d'affichage, prompt système, intensité du raisonnement, adresse publique — sont servis par la façade Rust (expérimental, migration en cours) | Go | | `LOKI_FILES` | `rust` : les fichiers de la discussion — dépôt, panneau Fichiers, archive .zip, téléchargement, images et vignettes — sont servis par la façade Rust (expérimental, migration en cours) | Go | | `LOKI_PUSH` | `rust` : les notifications Web Push (clé VAPID, abonnements, envoi « réponse prête ») sont tenues par la façade Rust (expérimental, migration en cours) | Go | +| `LOKI_MEM` | `rust` : la mémoire (éditeur des pages, index, déplacement entre projets, trackers, mode mémoire, interrupteurs du mode agent) est tenue par la façade Rust ; le chiffrement reste au Go (expérimental, migration en cours) | Go | **Accès par nom de domaine.** Sans clé de pilotage, l'API n'accepte que les hôtes locaux (IP, `localhost`, nom sans point, `.local`/`.lan`…) et refuse diff --git a/docs/superpowers/specs/2026-10-15-rust-migration-lot13-memoire-design.md b/docs/superpowers/specs/2026-10-15-rust-migration-lot13-memoire-design.md new file mode 100644 index 0000000..dcf05a9 --- /dev/null +++ b/docs/superpowers/specs/2026-10-15-rust-migration-lot13-memoire-design.md @@ -0,0 +1,117 @@ +# Lot 13 — mémoire tenue par la façade + +## Contexte + +La mémoire de Loki vivait dans le Go : +- `chat_memory.go`, `mem_index.go`, `mem_io.go` et `mem_fsutil.go` pour les pages ; +- `moveMemPage` (`projects.go`) pour le déplacement entre projets ; +- `tracker.go` et `web_tracker.go` pour les trackers ; +- les routes du mode agent. + +Lot 13 : la façade tient ces routes quand `LOKI_MEM=rust` (`memory.rs`, +expérimental). Sans la variable, rien ne change. + +| Route | Rôle | +|---|---| +| `GET /api/mem?name=` | contenu d'une page (modèle de page neuve sans nom) | +| `POST /api/mem/save` | crée, écrase ou renomme une page ; l'index suit | +| `POST /api/mem/delete` | supprime une page ; sa ligne d'index part avec elle | +| `POST /api/mem/move` | déplace une page vers un autre projet ; les deux index suivent | +| `GET /api/agent` (alias `/api/tools`, `/api/skills`) | mode agent, compactage, mode mémoire, liste des pages | +| `POST /api/agent/toggle` (et alias) | mode agent (`state/agent`) | +| `POST /api/agent/compact` | compactage automatique (`COMPACT`) | +| `GET|POST /api/memory` | mode mémoire du projet actif, repli sur `MEM_MODE` | +| `GET|POST /api/tracker` | panneau des trackers : liste, détail, ajout, modification, suppression, renommage, déplacement | + +### Disque et base + +Les pages restent des fichiers `memory//.md`, et l'index +`MEMORY.md` reste en clair. Le Go continue de les lire et de les écrire pour +sa propre boucle : outils `mem_*`, index injecté au modèle. Les écritures +restent sûres, comme dans le Go : +- un `.bak` de la version précédente est gardé ; +- le fichier est écrit dans un temporaire, synchronisé, puis renommé ; +- le contenu est relu et comparé octet par octet. + +Les trackers restent dans le bucket `tracker`, sous la clé +`/`. Le registre des projets (`state/projects`) est lu tel +quel. Le mode mémoire y est écrit avec l'échappement HTML de +`encoding/json`, pour garder les mêmes octets en base. + +### Chiffrement + +La clé de la mémoire ne quitte pas le Go : +- une page qui porte le magic `LOKIMEMv1` est ouverte par + `/internal/v1/crypt/open` ; +- toute écriture de page ou de tracker passe par `/internal/v1/crypt/seal`, + qui rend le clair tel quel quand le chiffrement est coupé. + +Quand la mémoire est verrouillée : +- les pages restent listées avec le titre « 🔒 (chiffré — mémoire + verrouillée) » ; +- les trackers illisibles sont tus ; +- aucune écriture ne retombe en clair. + +### Messages d'erreur JSON + +Ces routes renvoient au client le message d'erreur de décodage du Go. +`gojson.rs` reproduit `json.NewDecoder(r.Body).Decode(&req)` : +- seule la première valeur compte ; +- `EOF` et `unexpected EOF` ; +- les erreurs du scanner (`invalid character …`) ; +- les erreurs de type, champs anonymes compris ; +- la casse des noms de champs, et l'allocation des pointeurs. + +`gojson.rs` est mutualisé pour les lots suivants. + +## Restent au Go + +- Le coffre et le chiffrement (`/api/mem/encrypt|decrypt|unlock|lock`). +- La santé, les instantanés, la sauvegarde. +- Le hub des projets (`/api/projects`) : sa bascule recharge la discussion. +- La bascule temporaire de projet d'une tâche planifiée. Elle n'existe qu'en + RAM du Go : la façade sert toujours le projet actif rangé. + +## Écarts assumés + +- Un corps JSON syntaxiquement valide mais qui fait échouer serde (UTF-8 + invalide, nombre hors limites) donne le message de serde. +- Une date d'un tracker qui tombe dans un trou d'heure d'été est résolue par + `mktime`. + +## Parité + +### Décodage JSON + +`internal/loki/parity_gojson_test.go` tire du Go 81 corps décodés : champs +et message d'erreur. `tests/gojson.rs` les rejoue. + +### Mémoire + +`internal/loki/parity_mem_test.go` tire du Go : +- les noms de pages, les titres, les slugs et les modes mémoire ; +- 36 dates de trackers, lues dans un fuseau fixe (UTC+2) ; +- 102 appels de route, avec après chacun le dossier `memory/` complet + (`.bak` compris) et les clés touchées en base (mode agent, compactage, + mode mémoire, projets, trackers). + +Ces appels couvrent : +- les pages : création, renommage, réenregistrement qui perd l'accroche, + noms refusés, fichiers cachés et dossiers ignorés ; +- le déplacement, avec ses six refus ; +- la bascule de projet ; +- le mode agent et le compactage ; +- le mode mémoire, y compris un projet actif inconnu ; +- les trackers : ajout, modification, suppression, renommage, déplacement, + valeurs illisibles et événements nuls. + +`tests/memory.rs` rejoue le tout par la façade, avec le même masquage des +identifiants et des instants « maintenant ». + +Une mutation de chaque harnais fait échouer son rejeu. + +Un essai de bout en bout simule le chiffrement par le faux Go : +- les pages sont écrites scellées et relues en clair ; +- l'index reste en clair ; +- les trackers sont scellés ; +- une fois la mémoire verrouillée, rien n'est réécrit en clair. diff --git a/internal/loki/facade_chat.go b/internal/loki/facade_chat.go index 7e1b5d9..28de305 100644 --- a/internal/loki/facade_chat.go +++ b/internal/loki/facade_chat.go @@ -160,6 +160,8 @@ func withFacadeChat(path string, h http.HandlerFunc) http.HandlerFunc { held, unavailable = facadeFiles, "fichiers indisponibles : " case facadePushRoutes[path]: held, unavailable = facadePush, "notifications indisponibles : " + case facadeMemRoutes[path]: + held, unavailable = facadeMem, "mémoire indisponible : " default: return h } diff --git a/internal/loki/facade_chat_test.go b/internal/loki/facade_chat_test.go index bdc8e1d..18289fb 100644 --- a/internal/loki/facade_chat_test.go +++ b/internal/loki/facade_chat_test.go @@ -511,3 +511,37 @@ func TestFacadeNotifications(t *testing.T) { t.Fatalf("envoi délégué sans la variable (%d appels)", n) } } + +func TestFacadeMemoire(t *testing.T) { + testHome(t) + f := demarrerFacade(t) + t.Setenv(facadeChatEnv, "") + local := func(w http.ResponseWriter, r *http.Request) { io.WriteString(w, "go") } + // Sans la variable : le Go sert lui-même. + for p := range facadeMemRoutes { + w := httptest.NewRecorder() + withFacadeChat(p, local)(w, httptest.NewRequest("GET", p, nil)) + if w.Body.String() != "go" { + t.Fatalf("%s servi ailleurs sans LOKI_MEM : %q", p, w.Body.String()) + } + } + t.Setenv(facadeMemEnv, "rust") + for p := range facadeMemRoutes { + w := httptest.NewRecorder() + withFacadeChat(p, local)(w, httptest.NewRequest("POST", p, strings.NewReader("{}"))) + if w.Body.String() != f.reply { + t.Fatalf("%s : réponse %q", p, w.Body.String()) + } + } + if n := len(f.recu()); n != len(facadeMemRoutes) { + t.Fatalf("reçu par la façade : %d appels", n) + } + // Les routes voisines (coffre, projets) restent au Go. + for _, p := range []string{"/api/mem/health", "/api/mem/unlock", "/api/projects", "/api/backup/export"} { + w := httptest.NewRecorder() + withFacadeChat(p, local)(w, httptest.NewRequest("GET", p, nil)) + if w.Body.String() != "go" { + t.Fatalf("%s remis à la façade : %q", p, w.Body.String()) + } + } +} diff --git a/internal/loki/facade_mem.go b/internal/loki/facade_mem.go new file mode 100644 index 0000000..884dfbe --- /dev/null +++ b/internal/loki/facade_mem.go @@ -0,0 +1,45 @@ +package loki + +import ( + "os" + "strings" +) + +// facade_mem.go — mémoire tenue par la façade Rust (LOKI_MEM=rust, lot 13 de +// la migration ; expérimental, le Go reste le défaut) : éditeur des pages, +// index MEMORY.md, déplacement d'une page vers un autre projet, panneau des +// trackers, mode mémoire et interrupteurs du mode agent. +// +// Les pages restent des fichiers memory//*.md et les trackers des +// valeurs du bucket tracker : ce process continue de les lire et de les +// écrire pour sa propre boucle (outils mem_* et tracker, index injecté au +// modèle). Le chiffrement reste ici (la clé ne quitte pas ce process) : la +// façade fait sceller et ouvrir ses contenus par /internal/v1/crypt/*. +// +// Seules les routes changent de main ; celles qui arrivent ici par le tunnel +// du relais sont remises à la façade (withFacadeChat). + +const facadeMemEnv = "LOKI_MEM" + +// facadeMem : la façade tient-elle la mémoire ? +func facadeMem() bool { + return strings.EqualFold(strings.TrimSpace(os.Getenv(facadeMemEnv)), "rust") && + facadeAddr() != "" && internalToken() != "" +} + +// facadeMemRoutes : les routes que la façade sert alors (memory.rs). +var facadeMemRoutes = map[string]bool{ + "/api/mem": true, + "/api/mem/save": true, + "/api/mem/delete": true, + "/api/mem/move": true, + "/api/agent": true, + "/api/tools": true, + "/api/skills": true, + "/api/agent/toggle": true, + "/api/tools/toggle": true, + "/api/skills/toggle": true, + "/api/agent/compact": true, + "/api/memory": true, + "/api/tracker": true, +} diff --git a/internal/loki/parity_gojson_test.go b/internal/loki/parity_gojson_test.go new file mode 100644 index 0000000..1fc6443 --- /dev/null +++ b/internal/loki/parity_gojson_test.go @@ -0,0 +1,85 @@ +package loki + +import ( + "encoding/json" + "os" + "path/filepath" + "strings" + "testing" +) + +// parity_gojson_test.go — journal de référence du décodage JSON des requêtes +// (json.NewDecoder(r.Body).Decode(&req)) : champs décodés et message d'erreur, +// que plusieurs routes renvoient tels quels au client. Rangé dans +// rust/loki-rs/tests/parity_gojson/decode.json ; tests/gojson.rs le rejoue +// contre src/gojson.rs. +// +// LOKI_PARITY_UPDATE=1 go test ./internal/loki -run TestPariteDecodageJSON + +const parityGoJSONFile = "../../rust/loki-rs/tests/parity_gojson/decode.json" + +type parityDecodeT struct { + Name string `json:"name"` + On bool `json:"on"` + N int `json:"n"` + Args []string `json:"args"` + Env map[string]string `json:"env"` + Opt *string `json:"opt"` + Flag *bool `json:"flag"` +} + +func TestPariteDecodageJSON(t *testing.T) { + type cas struct { + In string `json:"in"` + Out parityDecodeT `json:"out"` + Err string `json:"err,omitempty"` + } + var fx struct { + Cases []cas `json:"cases"` + } + bodies := []string{ + ``, ` `, "\n\t", `pas du json`, `{`, `{"name"`, `{"name":`, `{"name":"a"`, `{"name":"a",}`, + `{"name" "a"}`, `{"name":"a" "on":true}`, `{name:"a"}`, `{'name':"a"}`, `[`, `[1,]`, `[1 2]`, + `{"name":"a"} et la suite`, `{"name":"a"}{"name":"b"}`, `null`, `true`, `"texte"`, `12`, `[1,2]`, + `{"name":5}`, `{"name":true}`, `{"name":{}}`, `{"name":[]}`, `{"name":null}`, `{"NAME":"maj"}`, + `{"Name":"a","name":"b"}`, `{"on":"oui"}`, `{"on":1}`, `{"on":true,"name":3,"n":4}`, + `{"n":1.5}`, `{"n":"3"}`, `{"n":-7}`, `{"n":1e2}`, `{"args":["a","b"]}`, `{"args":["a",1,"c"]}`, + `{"args":"x"}`, `{"args":[]}`, `{"args":[null]}`, `{"env":{"A":"1","B":2}}`, `{"env":[]}`, + `{"opt":""}`, `{"opt":null}`, `{"opt":"x","flag":false}`, `{"flag":"non"}`, + `{"name":"été"}`, `{"name":"a\nb"}`, "{\"name\":\"a\x01b\"}", `{"name":"\x"}`, + `{"name":"\u12G4"}`, `tru`, `trux`, `nul`, `nulx`, `fals`, `falsy`, `-`, `-x`, `1.`, `1.x`, `1e`, + `1ex`, `{"a":1.}`, `{"a":01}`, `{"a":-}`, `[1,2,3}`, `{"a":1]`, "\x01", "\x7f", "\xc3\xa9", + `'x'`, `"x`, `{"a":"b"}}`, `}`, `]`, `,`, `:`, `{"a" : [ { "b" : null } ] , "name" : "ok" }`, + } + for _, b := range bodies { + var v parityDecodeT + c := cas{In: b} + if err := json.NewDecoder(strings.NewReader(b)).Decode(&v); err != nil { + c.Err = err.Error() + } + c.Out = v + fx.Cases = append(fx.Cases, c) + } + got, err := parityJSON(fx) + if err != nil { + t.Fatal(err) + } + if os.Getenv("LOKI_PARITY_UPDATE") == "1" { + if err := os.MkdirAll(filepath.Dir(parityGoJSONFile), 0o755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(parityGoJSONFile, got, 0o644); err != nil { + t.Fatal(err) + } + return + } + want, err := os.ReadFile(parityGoJSONFile) + 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("le décodage JSON ne produit plus le journal rangé dans %s.\n"+ + "Si c'est voulu : LOKI_PARITY_UPDATE=1 go test ./internal/loki -run TestPariteDecodageJSON,\n"+ + "puis cargo test --test gojson dit si le port Rust suit.\n--- obtenu ---\n%s", parityGoJSONFile, got) + } +} diff --git a/internal/loki/parity_mem_test.go b/internal/loki/parity_mem_test.go new file mode 100644 index 0000000..7c542a3 --- /dev/null +++ b/internal/loki/parity_mem_test.go @@ -0,0 +1,442 @@ +package loki + +import ( + "encoding/json" + "io/fs" + "net/http/httptest" + "os" + "path/filepath" + "regexp" + "sort" + "strconv" + "strings" + "testing" + "time" +) + +// parity_mem_test.go — journal de référence de la mémoire (lot 13 de la +// migration Rust) : noms et titres de pages, slugs, modes mémoire, dates des +// trackers, puis une suite d'appels aux routes (éditeur des pages, index +// MEMORY.md, déplacement entre projets, mode agent, mode mémoire, trackers) +// avec, après chacun, le contenu du dossier memory/ et des clés touchées en +// base. Rangé dans rust/loki-rs/tests/parity_mem/mem.json ; tests/memory.rs le +// rejoue par la façade. +// +// Les dates sont lues dans un fuseau fixe (UTC+2) ; les identifiants de points +// (horodatages en ns) deviennent {{ID1}}, {{ID2}}… dans l'ordre d'apparition, +// et les instants « maintenant » {{NOW}}. +// +// LOKI_PARITY_UPDATE=1 go test ./internal/loki -run TestPariteMemoire + +const parityMemFile = "../../rust/loki-rs/tests/parity_mem/mem.json" + +type parityMemStep struct { + // Avant l'appel : fichiers sous memory/ et valeurs en base (« bucket/clé ») + // posés ; une valeur vide supprime. + Files map[string]string `json:"files,omitempty"` + Store map[string]string `json:"store,omitempty"` + 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. + Disk map[string]string `json:"disk"` + State any `json:"state"` +} + +type parityMemWhen struct { + In string `json:"in"` + TS any `json:"ts,omitempty"` + DateOnly bool `json:"date_only,omitempty"` + Err string `json:"err,omitempty"` +} + +type parityMemFixture struct { + Names []parityNameCase `json:"names"` + Titles map[string]string `json:"titles"` + Slugs map[string]string `json:"slugs"` + Modes map[string]string `json:"modes"` + When []parityMemWhen `json:"when"` + Seed map[string]string `json:"seed"` + Steps []parityMemStep `json:"steps"` +} + +type parityNameCase struct { + In string `json:"in"` + Out string `json:"out,omitempty"` + Err string `json:"err,omitempty"` +} + +var ( + parityIDRe = regexp.MustCompile(`"id":"(\d{15,})"`) + parityNowRe = regexp.MustCompile(`"(ts|first_ts|last_ts)":(\d{12,14})`) +) + +// parityMasker remplace identifiants et instants « maintenant » d'un JSON +// canonique (clés triées), dans l'ordre d'apparition. +type parityMasker struct { + ids map[string]string +} + +func (m *parityMasker) mask(s string) string { + s = parityIDRe.ReplaceAllStringFunc(s, func(x string) string { + id := parityIDRe.FindStringSubmatch(x)[1] + ph, ok := m.ids[id] + if !ok { + ph = "{{ID" + strconv.Itoa(len(m.ids)+1) + "}}" + m.ids[id] = ph + } + return `"id":"` + ph + `"` + }) + now := time.Now().UnixMilli() + return parityNowRe.ReplaceAllStringFunc(s, func(x string) string { + sm := parityNowRe.FindStringSubmatch(x) + v, _ := strconv.ParseInt(sm[2], 10, 64) + if d := v - now; d > -86400000 && d < 86400000 { + return `"` + sm[1] + `":"{{NOW}}"` + } + return x + }) +} + +// unmask : les {{IDn}} d'un corps de requête rendus à leur valeur. +func (m *parityMasker) unmask(s string) string { + for id, ph := range m.ids { + s = strings.ReplaceAll(s, ph, id) + } + return s +} + +// canon : JSON aux clés triées, masqué, relu. +func (m *parityMasker) canon(t *testing.T, raw []byte) any { + var v any + if err := json.Unmarshal(raw, &v); err != nil { + t.Fatalf("JSON illisible : %v (%s)", err, raw) + } + b, _ := json.Marshal(v) + var out any + if err := json.Unmarshal([]byte(m.mask(string(b))), &out); err != nil { + t.Fatal(err) + } + return out +} + +func TestPariteMemoire(t *testing.T) { + home := testHome(t) + old := time.Local + time.Local = time.FixedZone("essai", 7200) + t.Cleanup(func() { time.Local = old }) + + var fx parityMemFixture + for _, in := range []string{"", " ", "notes", "notes.md", "NOTES.MD", "a b", "../x", "x/y", "é", "a.b-c_d", + ".md", ".", "..", "x.md.md", " pad ", "tab\t", "MEMORY", "memory.md", "x.Md"} { + c := parityNameCase{In: in} + if out, err := memFileName(in); err != nil { + c.Err = err.Error() + } else { + c.Out = out + } + fx.Names = append(fx.Names, c) + } + fx.Titles = map[string]string{} + for _, in := range []string{"", "\n\n", "# Titre", "## Titre ##", " ### ", "\n \n# A\n# B", "sans dièse\nsuite", + "#\n#\nVrai titre", "\t# tab", "#  espace insécable ", "# # double"} { + fx.Titles[in] = titleOf(in) + } + fx.Slugs = map[string]string{} + for _, in := range []string{"Réseau NAS", " Hello World ", "àâäáã éèêë", "İstanbul", "Ωmega", strings.Repeat("ab", 30), + "---x---", "", "Ça_va?", "ÉCOLE", "Straße", strings.Repeat("a", 39) + "-bcd", "!!!", "Perso & Cie", "ÎLE ÔÔ ÙÛ Ñ Ç"} { + fx.Slugs[in] = slugifyIdent(in) + } + fx.Modes = map[string]string{} + for _, in := range []string{"", "off", " OFF ", "ondemand", "on-demand", "search", "always", "auto", "bizarre"} { + fx.Modes[in] = string(normalizeMemMode(in)) + } + mk := &parityMasker{ids: map[string]string{}} + for _, in := range []string{"", "now", "NOW", " Maintenant ", "2024-03-10", "2024-03-10T08:15", "2024-03-10 7:05", + "2024-03-10 07:5", "2024-03-10 07:05:09", "2024-03-10 07:05:09.5", "2024-03-10 07:05:09,123456789999", + "2024-03-10 24:00", "2024-03-10 23:60", "2024-13-01", "2024-02-29", "2023-02-29", "2024-04-31", "2024-03", + "2024", "24", "2024-3", "2024-03-1", " 2024-03-10 ", "2024-03-10 07:05", "demain", "2024-03-10T07:05Z", + "today", "+024", "0000-01-01", "9999-12-31 23:59:59", "1234", "2024-03-10 07", "2024-03-10 7", + "x\x01y", "2024-03-10T", "2024-03-10 07:05:60"} { + c := parityMemWhen{In: in} + ts, dateOnly, err := parseWhen(in) + if err != nil { + c.Err = err.Error() + } else { + c.TS = ts + if d := ts - time.Now().UnixMilli(); d > -86400000 && d < 86400000 { + c.TS = "{{NOW}}" + } + c.DateOnly = dateOnly + } + fx.When = append(fx.When, c) + } + + // Base de départ : deux projets, le premier actif, mode mémoire global. + fx.Seed = map[string]string{ + "state/projects": `[{"slug":"generale","name":"Générale","created_at":1000},{"slug":"perso","name":"Perso & Cie","created_at":2000,"desc":"à moi"}]`, + "state/active_project": "generale", + "config/MEM_MODE": "ondemand", + } + apply := func(store map[string]string) { + keys := make([]string, 0, len(store)) + for k := range store { + keys = append(keys, k) + } + sort.Strings(keys) + for _, k := range keys { + b, key, _ := strings.Cut(k, "/") + if b == bkState && key == stActiveProjectKey { + if store[k] == "" { + putStr(bkState, key, "") + activeProjMu.Lock() + activeProjCache = "" + activeProjMu.Unlock() + } else if err := setActiveProject(store[k]); err != nil { + t.Fatal(err) + } + continue + } + if err := putStr(b, key, store[k]); err != nil { + t.Fatal(err) + } + } + } + apply(fx.Seed) + + memRoot := filepath.Join(home, "memory") + disk := func() map[string]string { + out := map[string]string{} + _ = filepath.WalkDir(memRoot, func(p string, d fs.DirEntry, err error) error { + if err != nil || d.IsDir() { + return nil + } + rel, _ := filepath.Rel(memRoot, p) + b, _ := os.ReadFile(p) + out[filepath.ToSlash(rel)] = string(b) + return nil + }) + return out + } + state := func() any { + st := map[string]any{ + "agent": getStr(bkState, "agent"), + "active": getStr(bkState, stActiveProjectKey), + "compact": ReadConfig()["COMPACT"], + "mem_mode": ReadConfig()["MEM_MODE"], + "projects": nil, + "trackers": map[string]any{}, + "tracker_raw": map[string]string{}, + } + if b := getBytes(bkState, stProjectsKey); len(b) > 0 { + var v any + if json.Unmarshal(b, &v) == nil { + st["projects"] = v + } + } + for k, v := range allKV(bkTracker) { + var x any + if json.Unmarshal([]byte(v), &x) == nil { + st["trackers"].(map[string]any)[k] = x + } else { + st["tracker_raw"].(map[string]string)[k] = v + } + } + b, _ := json.Marshal(st) + return mk.canon(t, b) + } + + run := func(st parityMemStep) parityMemStep { + for rel, content := range st.Files { + p := filepath.Join(memRoot, filepath.FromSlash(rel)) + if content == "" { + _ = os.RemoveAll(p) + continue + } + if strings.HasSuffix(rel, "/") { + _ = os.MkdirAll(p, 0o755) + continue + } + _ = os.MkdirAll(filepath.Dir(p), 0o755) + if err := os.WriteFile(p, []byte(content), 0o600); err != nil { + t.Fatal(err) + } + } + apply(st.Store) + w := httptest.NewRecorder() + r := httptest.NewRequest(st.Method, mk.unmask(st.Path), strings.NewReader(mk.unmask(st.Body))) + switch r.URL.Path { + case "/api/mem": + handleMem(w, r) + case "/api/mem/save": + handleMemSave(w, r) + case "/api/mem/delete": + handleMemDelete(w, r) + case "/api/mem/move": + handleMemMove(w, r) + case "/api/agent", "/api/tools", "/api/skills": + handleAgent(w, r) + case "/api/agent/toggle", "/api/tools/toggle", "/api/skills/toggle": + handleAgentToggle(w, r) + case "/api/agent/compact": + handleCompactToggle(w, r) + case "/api/memory": + handleMemoryMode(w, r) + case "/api/tracker": + handleTracker(w, r) + default: + t.Fatalf("route inattendue %s", st.Path) + } + st.Status = w.Code + st.Out = mk.canon(t, w.Body.Bytes()) + st.Disk = disk() + st.State = state() + return st + } + + for _, st := range []parityMemStep{ + // --- pages --- + {Method: "GET", Path: "/api/mem"}, + {Method: "GET", Path: "/api/mem?name=absente"}, + {Method: "GET", Path: "/api/agent"}, + {Method: "POST", Path: "/api/mem/save", Body: `{"name":"courses","content":"# Courses\n\nlait\n\n\n"}`}, + {Method: "POST", Path: "/api/mem/save", Body: `{"name":"notes.md","content":"Sans titre"}`}, + {Method: "POST", Path: "/api/mem/save", Body: `{"name":"../evil","content":"x"}`}, + {Method: "POST", Path: "/api/mem/save", Body: `{"name":" ","content":"x"}`}, + {Method: "POST", Path: "/api/mem/save", Body: `pas du json`}, + {Method: "POST", Path: "/api/mem/save", Body: `{"name":5}`}, + {Method: "POST", Path: "/api/mem/save", Body: `[1]`}, + {Method: "POST", Path: "/api/mem/save", Body: ``}, + {Method: "POST", Path: "/api/mem/save", Body: `{"name":"courses-v2","old":"courses","content":"# Courses v2\nœufs & "}`}, + {Method: "POST", Path: "/api/mem/save", Body: `{"name":"notes","old":"notes","content":"## Titre\nmaj"}`}, + {Method: "GET", Path: "/api/mem?name=courses-v2"}, + {Method: "GET", Path: "/api/mem?name=+courses-v2.md+"}, + {Method: "GET", Path: "/api/mem?name=a&name=courses-v2"}, + {Method: "GET", Path: "/api/mem?name=../x"}, + {Files: map[string]string{"generale/Zeta.MD": "# Zêta\n", "generale/.cache.md": "caché", "generale/dossier.md/": "-", + "generale/notes.txt": "pas une page", "generale/vide.md": "\n\n"}, + Method: "GET", Path: "/api/tools"}, + {Method: "POST", Path: "/api/mem/delete", Body: `{"name":"absente"}`}, + {Method: "POST", Path: "/api/mem/delete", Body: `{"name":"bad/name"}`}, + {Method: "POST", Path: "/api/mem/delete", Body: `{"name":"vide"}`}, + {Method: "POST", Path: "/api/mem/delete", Body: `{nope}`}, + // Accroche écrite à la main : un réenregistrement du même nom la perd + // (ligne retirée puis rajoutée à la fin), comme le Go. + {Files: map[string]string{"generale/MEMORY.md": "# Index mémoire — Générale\n\n- [Courses v2](courses-v2.md) — la liste\n- [Titre](notes.md)\n"}, + Method: "POST", Path: "/api/mem/save", Body: `{"name":"courses-v2","old":"courses-v2","content":"# Courses v3"}`}, + {Method: "POST", Path: "/api/mem/save", Body: `{"name":"neuve","content":"# Neuve"}`}, + // --- déplacement --- + {Method: "POST", Path: "/api/mem/move", Body: `{"name":"notes","to":"perso"}`}, + {Method: "POST", Path: "/api/mem/move", Body: `{"name":"notes","to":"perso"}`}, + {Method: "POST", Path: "/api/mem/move", Body: `{"name":"courses-v2","to":"generale"}`}, + {Method: "POST", Path: "/api/mem/move", Body: `{"name":"courses-v2","to":" generale "}`}, + {Method: "POST", Path: "/api/mem/move", Body: `{"name":"courses-v2","to":"inconnu"}`}, + {Method: "POST", Path: "/api/mem/move", Body: `{"name":"MEMORY","to":"perso"}`}, + {Method: "POST", Path: "/api/mem/move", Body: `{"name":"x/y","to":"perso"}`}, + {Method: "POST", Path: "/api/mem/move", Body: `[]`}, + {Method: "GET", Path: "/api/mem/move"}, + {Files: map[string]string{"generale/doublon.md": "# A", "perso/doublon.md": "# B"}, + Method: "POST", Path: "/api/mem/move", Body: `{"name":"doublon","to":"perso"}`}, + // Bascule vers le projet perso : ses pages, son nom. + {Store: map[string]string{"state/active_project": "perso"}, Method: "GET", Path: "/api/skills"}, + {Method: "POST", Path: "/api/mem/save", Body: `{"name":"perso-1","content":"Première\nligne"}`}, + {Method: "POST", Path: "/api/mem/move", Body: `{"name":"doublon","to":"generale"}`}, + {Store: map[string]string{"state/active_project": "generale"}, Method: "GET", Path: "/api/agent"}, + // --- mode agent, compactage --- + {Method: "POST", Path: "/api/agent/toggle", Body: `{"on":true}`}, + {Method: "GET", Path: "/api/tools"}, + {Method: "POST", Path: "/api/skills/toggle", Body: `{"on":"oui"}`}, + {Method: "POST", Path: "/api/tools/toggle", Body: `{"on":true}`}, + {Method: "POST", Path: "/api/agent/toggle", Body: `pas du json`}, + {Method: "POST", Path: "/api/agent/compact", Body: `{"on":false}`}, + {Method: "GET", Path: "/api/agent"}, + {Method: "POST", Path: "/api/agent/compact", Body: `{}`}, + {Store: map[string]string{"config/COMPACT": " NON "}, Method: "GET", Path: "/api/agent"}, + {Method: "POST", Path: "/api/agent/compact", Body: `{"on":true}`}, + // --- mode mémoire --- + {Method: "GET", Path: "/api/memory"}, + {Method: "POST", Path: "/api/memory", Body: `{"mode":" Search "}`}, + {Method: "GET", Path: "/api/agent"}, + {Method: "POST", Path: "/api/memory", Body: `pas du json`}, + {Method: "POST", Path: "/api/memory", Body: `{"mode":"off"}`}, + {Method: "PUT", Path: "/api/memory", Body: `{"mode":"ondemand"}`}, + {Store: map[string]string{"state/active_project": "fantome"}, Method: "POST", Path: "/api/memory", Body: `{"mode":"off"}`}, + {Method: "GET", Path: "/api/memory"}, + {Store: map[string]string{"state/active_project": "perso"}, Method: "POST", Path: "/api/memory", Body: `{"mode":"ondemand"}`}, + {Store: map[string]string{"state/active_project": "generale"}, Method: "GET", Path: "/api/memory"}, + // --- trackers --- + {Method: "GET", Path: "/api/tracker"}, + {Method: "POST", Path: "/api/tracker", Body: `{"action":"add","name":"Poids","when":"2024-03-10","text":" 78,2 kg "}`}, + {Method: "POST", Path: "/api/tracker", Body: `{"action":"add","name":"Poids","when":"2024-03-09 08:15","text":"78,9"}`}, + {Method: "POST", Path: "/api/tracker", Body: `{"action":"add","name":"poids","text":"77"}`}, + {Method: "POST", Path: "/api/tracker", Body: `{"action":"ADD","name":"Réseau NAS","when":"2023","text":"installé & fini"}`}, + {Method: "POST", Path: "/api/tracker", Body: `{"action":"add","name":"","text":"x"}`}, + {Method: "POST", Path: "/api/tracker", Body: `{"action":"add","name":"x","text":" "}`}, + {Method: "POST", Path: "/api/tracker", Body: `{"action":"add","name":"x","when":"demain","text":"y"}`}, + {Method: "POST", Path: "/api/tracker", Body: `{"action":"add","name":"!!!","when":"2025-01","text":"z"}`}, + {Method: "POST", Path: "/api/tracker", Body: `{"action":"add","name":"Même jour","when":"2025-01","text":"m"}`}, + {Method: "GET", Path: "/api/tracker?slug=poids"}, + {Method: "GET", Path: "/api/tracker?slug=absent"}, + {Method: "POST", Path: "/api/tracker", Body: `{"action":"edit","slug":"poids","id":"{{ID2}}","text":" 78,0 kg "}`}, + {Method: "POST", Path: "/api/tracker", Body: `{"action":"edit","slug":"poids","id":"{{ID2}}","when":"2024-03-11 07:00"}`}, + {Method: "POST", Path: "/api/tracker", Body: `{"action":"edit","slug":"poids","id":"{{ID1}}","when":"hier","text":"perdu"}`}, + {Method: "POST", Path: "/api/tracker", Body: `{"action":"edit","slug":"poids","id":"999","text":"x"}`}, + {Method: "POST", Path: "/api/tracker", Body: `{"action":"edit","slug":"absent","id":"{{ID1}}","text":"x"}`}, + {Method: "POST", Path: "/api/tracker", Body: `{"action":"delete","slug":"poids","id":"{{ID1}}"}`}, + {Method: "POST", Path: "/api/tracker", Body: `{"action":"delete","slug":"poids","id":"{{ID1}}"}`}, + {Method: "POST", Path: "/api/tracker", Body: `{"action":"rename","slug":"reseau-nas","name":" Réseau NAS (bureau) "}`}, + {Method: "POST", Path: "/api/tracker", Body: `{"action":"rename","slug":"poids","name":"POIDS"}`}, + {Method: "POST", Path: "/api/tracker", Body: `{"action":"rename","slug":"tracker","name":"Poids"}`}, + {Method: "POST", Path: "/api/tracker", Body: `{"action":"rename","slug":"tracker","name":" "}`}, + {Method: "POST", Path: "/api/tracker", Body: `{"action":"rename","slug":"tracker","name":"???"}`}, + {Method: "POST", Path: "/api/tracker", Body: `{"action":"rename","slug":"absent","name":"Neuf"}`}, + {Method: "POST", Path: "/api/tracker", Body: `{"action":"move","slug":"tracker","to":"perso"}`}, + {Method: "POST", Path: "/api/tracker", Body: `{"action":"move","slug":"poids","to":"inconnu"}`}, + {Method: "POST", Path: "/api/tracker", Body: `{"action":"move","slug":"poids","to":"generale"}`}, + {Method: "POST", Path: "/api/tracker", Body: `{"action":"move","slug":"absent","to":"perso"}`}, + {Method: "POST", Path: "/api/tracker", Body: `{"action":"add","name":"Même jour","when":"2025-02","text":"perso"}`, + Store: map[string]string{"state/active_project": "perso"}}, + {Store: map[string]string{"state/active_project": "generale"}, + Method: "POST", Path: "/api/tracker", Body: `{"action":"move","slug":"meme-jour","to":"perso"}`}, + {Method: "POST", Path: "/api/tracker", Body: `{"action":"delete_tracker","slug":"poids"}`}, + {Method: "POST", Path: "/api/tracker", Body: `{"action":"delete_tracker","slug":"absent"}`}, + {Method: "POST", Path: "/api/tracker", Body: `{"action":"bidule"}`}, + {Method: "POST", Path: "/api/tracker", Body: `pas du json`}, + {Method: "POST", Path: "/api/tracker", Body: `"texte"`}, + {Method: "POST", Path: "/api/tracker", Body: `{"action":"add","name":3}`}, + {Method: "PUT", Path: "/api/tracker"}, + {Store: map[string]string{"tracker/generale/casse": "pas du json"}, Method: "GET", Path: "/api/tracker"}, + {Method: "GET", Path: "/api/tracker?slug=casse"}, + {Store: map[string]string{"tracker/generale/vide": `{"name":"Vide","events":null}`}, Method: "GET", Path: "/api/tracker?slug=vide"}, + {Method: "POST", Path: "/api/tracker", Body: `{"action":"rename","slug":"vide","name":"VIDE"}`}, + {Method: "POST", Path: "/api/tracker", Body: `{"action":"delete","slug":"vide","id":"1"}`}, + {Method: "GET", Path: "/api/tracker"}, + } { + fx.Steps = append(fx.Steps, run(st)) + } + + got, err := parityJSON(fx) + if err != nil { + t.Fatal(err) + } + if os.Getenv("LOKI_PARITY_UPDATE") == "1" { + if err := os.MkdirAll(filepath.Dir(parityMemFile), 0o755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(parityMemFile, got, 0o644); err != nil { + t.Fatal(err) + } + return + } + want, err := os.ReadFile(parityMemFile) + 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("la mémoire ne produit plus le journal rangé dans %s.\n"+ + "Si c'est voulu : LOKI_PARITY_UPDATE=1 go test ./internal/loki -run TestPariteMemoire,\n"+ + "puis cargo test --test memory dit si le port Rust suit.\n--- obtenu ---\n%s", parityMemFile, got) + } +} diff --git a/rust/loki-rs/src/api.rs b/rust/loki-rs/src/api.rs index 0e34e26..9f2a1a5 100644 --- a/rust/loki-rs/src/api.rs +++ b/rust/loki-rs/src/api.rs @@ -38,6 +38,8 @@ pub struct Ctx { pub files: Option>, /// Notifications Web Push tenues par la façade (`LOKI_PUSH=rust`). pub push: Option>, + /// Mémoire tenue par la façade (`LOKI_MEM=rust`). + pub mem: Option>, /// Jeton de l'API interne : une requête qui le porte vient du Go. pub internal_token: String, } @@ -52,6 +54,7 @@ impl Ctx { || self.settings || self.files.is_some() || self.push.is_some() + || self.mem.is_some() } /// La requête vient du Go (jeton interne valide) : déjà authentifiée par diff --git a/rust/loki-rs/src/gojson.rs b/rust/loki-rs/src/gojson.rs new file mode 100644 index 0000000..415b322 --- /dev/null +++ b/rust/loki-rs/src/gojson.rs @@ -0,0 +1,687 @@ +//! Décodage JSON au comportement d'`encoding/json` du Go +//! (`json.NewDecoder(r.Body).Decode(&req)`), pour les routes portées qui +//! renvoient le message d'erreur du Go au client (`{"error": err.Error()}`). +//! +//! Ce qui est reproduit : +//! - seule la PREMIÈRE valeur du corps compte (ce qui suit est ignoré) ; +//! - corps vide → `EOF`, valeur tronquée → `unexpected EOF` ; +//! - erreurs de syntaxe du scanner Go (`invalid character 'p' looking for +//! beginning of value`, `… after object key`, `… in string literal`…) ; +//! - `null` ne touche à rien ; noms de champs sans égard à la casse ; +//! - erreurs de type (`json: cannot unmarshal number into Go struct field +//! saveReq.name of type string`) : la première est rendue, les autres champs +//! sont décodés quand même, comme le Go. + +use serde::de::{Deserialize, Deserializer, MapAccess, Visitor}; +use serde_json::value::RawValue; +use serde_json::{Map, Value}; +use std::marker::PhantomData; + +/// Type Go d'un champ de la struct cible. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum Kind { + /// `string` + Str, + /// `bool` + Bool, + /// `int` + Int, + /// `[]string` + StrList, + /// `map[string]string` + StrMap, + /// `*string` : présent même vide, `null` = absent. + OptStr, + /// `*bool` + OptBool, + /// `any` / `json.RawMessage` : tout est accepté tel quel. + Any, +} + +impl Kind { + fn go_type(self) -> &'static str { + match self { + Kind::Str => "string", + Kind::Bool => "bool", + Kind::Int => "int", + Kind::StrList => "[]string", + Kind::StrMap => "map[string]string", + Kind::OptStr => "*string", + Kind::OptBool => "*bool", + Kind::Any => "interface {}", + } + } +} + +/// Résultat d'un décodage : les champs posés (clé = nom JSON de la struct, +/// valeur déjà au bon type) et la première erreur, comme `Decode` la rend. +#[derive(Debug, Default)] +pub struct Decoded { + pub fields: Map, + pub err: Option, +} + +impl Decoded { + /// Champ texte (`""` si absent). + pub fn str(&self, name: &str) -> String { + self.fields + .get(name) + .and_then(Value::as_str) + .unwrap_or("") + .to_string() + } + + /// Champ `*string` : `None` si absent ou `null`. + pub fn opt_str(&self, name: &str) -> Option { + self.fields + .get(name) + .and_then(Value::as_str) + .map(str::to_string) + } + + /// Champ booléen (`false` si absent). + pub fn bool(&self, name: &str) -> bool { + self.fields + .get(name) + .and_then(Value::as_bool) + .unwrap_or(false) + } + + /// Champ `*bool`. + pub fn opt_bool(&self, name: &str) -> Option { + self.fields.get(name).and_then(Value::as_bool) + } + + /// Champ entier (`0` si absent). + pub fn int(&self, name: &str) -> i64 { + self.fields.get(name).and_then(Value::as_i64).unwrap_or(0) + } + + /// Champ `[]string` (`None` = nil côté Go). + pub fn str_list(&self, name: &str) -> Option> { + self.fields.get(name).and_then(Value::as_array).map(|a| { + a.iter() + .map(|v| v.as_str().unwrap_or("").to_string()) + .collect() + }) + } + + /// Champ `map[string]string` (`None` = nil côté Go). + pub fn str_map(&self, name: &str) -> Option> { + self.fields.get(name).and_then(Value::as_object).map(|m| { + m.iter() + .map(|(k, v)| (k.clone(), v.as_str().unwrap_or("").to_string())) + .collect() + }) + } +} + +/// `json.NewDecoder(body).Decode(&v)` avec `v` une struct Go dont les +/// champs sont `fields` (nom JSON, type). `ty` : nom de la struct sans le +/// paquet (`saveReq`), ou, pour une struct anonyme, son type complet tel que +/// `reflect` l'écrit (`struct { On bool "json:\"on\"" }`). +pub fn decode_struct(body: &[u8], ty: &str, fields: &[(&str, Kind)]) -> Decoded { + let (prefix, full) = if ty.starts_with("struct {") { + ("", ty.to_string()) + } else { + (ty, format!("loki.{ty}")) + }; + let ty = prefix; + let mut out = Decoded::default(); + let raw = match first_value(body) { + Ok(r) => r, + Err(e) => { + out.err = Some(e); + return out; + } + }; + let first = raw.first().copied().unwrap_or(b' '); + if first != b'{' { + return match serde_json::from_slice::(raw) { + Ok(Value::Null) => out, + Ok(other) => { + out.err = Some(format!( + "json: cannot unmarshal {} into Go value of type {full}", + kind_of(&other) + )); + out + } + Err(e) => { + out.err = Some(e.to_string()); + out + } + }; + } + // Paires dans l'ordre du document (la dernière l'emporte, comme en Go), + // valeurs brutes (le texte d'un nombre entre dans le message d'erreur). + let obj = match serde_json::from_slice::(raw) { + Ok(p) => p.0, + Err(e) => { + out.err = Some(e.to_string()); + return out; + } + }; + let fail = |out: &mut Decoded, what: String, field: &str, kind: Kind| { + if out.err.is_none() { + out.err = Some(format!( + "json: cannot unmarshal {what} into Go struct field {ty}.{field} of type {}", + kind.go_type() + )); + } + }; + for (k, rawv) in obj { + let Ok(v) = serde_json::from_str::(rawv.get()) else { + continue; + }; + // Correspondance exacte d'abord, puis sans égard à la casse. + let f = fields + .iter() + .find(|(n, _)| *n == k) + .or_else(|| fields.iter().find(|(n, _)| n.eq_ignore_ascii_case(&k))); + let Some(&(name, kind)) = f else { continue }; + if v.is_null() { + if matches!(kind, Kind::OptStr | Kind::OptBool) { + out.fields.remove(name); + } + continue; + } + match kind { + Kind::Str | Kind::OptStr => match v { + Value::String(_) => { + out.fields.insert(name.into(), v); + } + other => { + fail(&mut out, kind_of(&other).into(), name, Kind::Str); + // Un pointeur est alloué avant l'échec : il reste posé. + if kind == Kind::OptStr { + out.fields + .entry(name) + .or_insert(Value::String(String::new())); + } + } + }, + Kind::Bool | Kind::OptBool => match v { + Value::Bool(_) => { + out.fields.insert(name.into(), v); + } + other => { + fail(&mut out, kind_of(&other).into(), name, Kind::Bool); + if kind == Kind::OptBool { + out.fields.entry(name).or_insert(Value::Bool(false)); + } + } + }, + Kind::Int => match &v { + Value::Number(n) => match n.as_i64() { + Some(_) => { + out.fields.insert(name.into(), v); + } + None => fail(&mut out, format!("number {}", rawv.get()), name, kind), + }, + other => fail(&mut out, kind_of(other).into(), name, kind), + }, + Kind::StrList => match v { + Value::Array(a) => { + let mut list = Vec::with_capacity(a.len()); + for e in a { + match e { + Value::String(s) => list.push(Value::String(s)), + Value::Null => list.push(Value::String(String::new())), + other => { + fail(&mut out, kind_of(&other).into(), name, Kind::Str); + list.push(Value::String(String::new())); + } + } + } + out.fields.insert(name.into(), Value::Array(list)); + } + other => fail(&mut out, kind_of(&other).into(), name, kind), + }, + Kind::StrMap => match v { + Value::Object(m) => { + let mut dst = match out.fields.remove(name) { + Some(Value::Object(d)) => d, + _ => Map::new(), + }; + for (mk, mv) in m { + match mv { + Value::String(s) => { + dst.insert(mk, Value::String(s)); + } + Value::Null => { + dst.insert(mk, Value::String(String::new())); + } + other => { + fail(&mut out, kind_of(&other).into(), name, Kind::Str); + dst.insert(mk, Value::String(String::new())); + } + } + } + out.fields.insert(name.into(), Value::Object(dst)); + } + other => fail(&mut out, kind_of(&other).into(), name, kind), + }, + Kind::Any => { + out.fields.insert(name.into(), v); + } + } + } + out +} + +/// Objet JSON en paires ordonnées, valeurs brutes. +struct Pairs<'a>(Vec<(String, &'a RawValue)>); + +impl<'de: 'a, 'a> Deserialize<'de> for Pairs<'a> { + fn deserialize>(d: D) -> Result { + struct V<'a>(PhantomData<&'a ()>); + impl<'de: 'a, 'a> Visitor<'de> for V<'a> { + type Value = Pairs<'a>; + fn expecting(&self, f: &mut std::fmt::Formatter) -> std::fmt::Result { + f.write_str("un objet JSON") + } + fn visit_map>(self, mut m: A) -> Result, A::Error> { + let mut v = Vec::new(); + while let Some((k, val)) = m.next_entry::()? { + v.push((k, val)); + } + Ok(Pairs(v)) + } + } + d.deserialize_map(V(PhantomData)) + } +} + +fn kind_of(v: &Value) -> &'static str { + match v { + Value::Null => "null", + Value::Bool(_) => "bool", + Value::Number(_) => "number", + Value::String(_) => "string", + Value::Array(_) => "array", + Value::Object(_) => "object", + } +} + +/// `quoteChar` du scanner Go. +fn quote_char(c: u8) -> String { + match c { + b'\'' => r"'\''".into(), + b'"' => "'\"'".into(), + _ => { + let ch = c as char; // octet → U+00XX, comme string(byte) en Go + let inner = match ch { + '\x07' => r"\a".to_string(), + '\x08' => r"\b".to_string(), + '\x0c' => r"\f".to_string(), + '\n' => r"\n".to_string(), + '\r' => r"\r".to_string(), + '\t' => r"\t".to_string(), + '\x0b' => r"\v".to_string(), + '\\' => r"\\".to_string(), + c if (c as u32) < 0x20 || c as u32 == 0x7f => format!(r"\x{:02x}", c as u32), + c if (0x80..0xa1).contains(&(c as u32)) || c as u32 == 0xad => { + format!(r"\u{:04x}", c as u32) + } + c => c.to_string(), + }; + format!("'{inner}'") + } + } +} + +fn syntax(c: u8, ctx: &str) -> String { + format!("invalid character {} {ctx}", quote_char(c)) +} + +fn is_space(c: u8) -> bool { + matches!(c, b' ' | b'\t' | b'\r' | b'\n') +} + +/// Première valeur JSON du corps, validée par le scanner du Go +/// (`Decoder.readValue`) : la tranche de la valeur, ou l'erreur du Go. +pub fn first_value(body: &[u8]) -> Result<&[u8], String> { + let mut i = 0; + while i < body.len() && is_space(body[i]) { + i += 1; + } + if i == body.len() { + return Err("EOF".into()); + } + let start = i; + let mut p = Scan { + b: body, + i, + depth: 0, + }; + p.value()?; + Ok(&body[start..p.i]) +} + +struct Scan<'a> { + b: &'a [u8], + i: usize, + depth: usize, +} + +const MAX_DEPTH: usize = 10000; + +impl Scan<'_> { + fn peek(&self) -> Option { + self.b.get(self.i).copied() + } + + fn eof() -> String { + "unexpected EOF".into() + } + + fn skip_ws(&mut self) { + while let Some(c) = self.peek() { + if !is_space(c) { + break; + } + self.i += 1; + } + } + + fn value(&mut self) -> Result<(), String> { + self.skip_ws(); + let c = self.peek().ok_or_else(Self::eof)?; + match c { + b'{' => self.object(), + b'[' => self.array(), + b'"' => self.string(), + b'-' | b'0'..=b'9' => self.number(), + b't' => self.literal(b"true", "true"), + b'f' => self.literal(b"false", "false"), + b'n' => self.literal(b"null", "null"), + _ => Err(syntax(c, "looking for beginning of value")), + } + } + + fn push(&mut self) -> Result<(), String> { + self.depth += 1; + if self.depth > MAX_DEPTH { + return Err("exceeded max depth".into()); + } + Ok(()) + } + + fn object(&mut self) -> Result<(), String> { + self.push()?; + self.i += 1; // { + self.skip_ws(); + match self.peek() { + None => return Err(Self::eof()), + Some(b'}') => { + self.i += 1; + self.depth -= 1; + return Ok(()); + } + _ => {} + } + loop { + self.skip_ws(); + match self.peek() { + None => return Err(Self::eof()), + Some(b'"') => self.string()?, + Some(c) => return Err(syntax(c, "looking for beginning of object key string")), + } + self.skip_ws(); + match self.peek() { + None => return Err(Self::eof()), + Some(b':') => self.i += 1, + Some(c) => return Err(syntax(c, "after object key")), + } + self.value()?; + self.skip_ws(); + match self.peek() { + None => return Err(Self::eof()), + Some(b',') => self.i += 1, + Some(b'}') => { + self.i += 1; + self.depth -= 1; + return Ok(()); + } + Some(c) => return Err(syntax(c, "after object key:value pair")), + } + } + } + + fn array(&mut self) -> Result<(), String> { + self.push()?; + self.i += 1; // [ + self.skip_ws(); + match self.peek() { + None => return Err(Self::eof()), + Some(b']') => { + self.i += 1; + self.depth -= 1; + return Ok(()); + } + _ => {} + } + loop { + self.value()?; + self.skip_ws(); + match self.peek() { + None => return Err(Self::eof()), + Some(b',') => self.i += 1, + Some(b']') => { + self.i += 1; + self.depth -= 1; + return Ok(()); + } + Some(c) => return Err(syntax(c, "after array element")), + } + } + } + + fn string(&mut self) -> Result<(), String> { + self.i += 1; // " + loop { + let c = self.peek().ok_or_else(Self::eof)?; + self.i += 1; + match c { + b'"' => return Ok(()), + b'\\' => { + let e = self.peek().ok_or_else(Self::eof)?; + self.i += 1; + match e { + b'b' | b'f' | b'n' | b'r' | b't' | b'\\' | b'/' | b'"' => {} + b'u' => { + for _ in 0..4 { + let h = self.peek().ok_or_else(Self::eof)?; + if !h.is_ascii_hexdigit() { + return Err(syntax(h, "in \\u hexadecimal character escape")); + } + self.i += 1; + } + } + _ => return Err(syntax(e, "in string escape code")), + } + } + c if c < 0x20 => return Err(syntax(c, "in string literal")), + _ => {} + } + } + } + + fn digits(&mut self) { + while matches!(self.peek(), Some(b'0'..=b'9')) { + self.i += 1; + } + } + + fn number(&mut self) -> Result<(), String> { + if self.peek() == Some(b'-') { + self.i += 1; + match self.peek() { + None => return Err(Self::eof()), + Some(b'0'..=b'9') => {} + Some(c) => return Err(syntax(c, "in numeric literal")), + } + } + if self.peek() == Some(b'0') { + self.i += 1; + } else { + self.digits(); + } + if self.peek() == Some(b'.') { + self.i += 1; + match self.peek() { + // Valeur de tête tronquée : le Go la complète d'un espace, + // qui échoue ici. + None => return Err(Self::eof()), + Some(b'0'..=b'9') => self.digits(), + Some(c) => return Err(syntax(c, "after decimal point in numeric literal")), + } + } + if matches!(self.peek(), Some(b'e' | b'E')) { + self.i += 1; + if matches!(self.peek(), Some(b'+' | b'-')) { + self.i += 1; + } + match self.peek() { + None => return Err(Self::eof()), + Some(b'0'..=b'9') => self.digits(), + Some(c) => return Err(syntax(c, "in exponent of numeric literal")), + } + } + Ok(()) + } + + fn literal(&mut self, word: &[u8], name: &str) -> Result<(), String> { + self.i += 1; + for &want in &word[1..] { + match self.peek() { + None => return Err(Self::eof()), + Some(c) if c == want => self.i += 1, + Some(c) => { + return Err(syntax( + c, + &format!("in literal {name} (expecting {})", quote_char(want)), + )); + } + } + } + Ok(()) + } +} + +/// `json.Marshal` du Go : comme serde_json, avec l'échappement HTML du Go +/// (`<`, `>`, `&`, U+2028, U+2029), pour écrire en base les mêmes octets. +pub fn marshal(v: &T) -> String { + let s = serde_json::to_string(v).unwrap_or_default(); + if !s.contains(['<', '>', '&', '\u{2028}', '\u{2029}']) { + return s; + } + let mut out = String::with_capacity(s.len() + 16); + for c in s.chars() { + match c { + '<' => out.push_str("\\u003c"), + '>' => out.push_str("\\u003e"), + '&' => out.push_str("\\u0026"), + '\u{2028}' => out.push_str("\\u2028"), + '\u{2029}' => out.push_str("\\u2029"), + c => out.push(c), + } + } + out +} + +/// `unicode.IsPrint` (approché : catégories de contrôle, de format, privées, +/// séparateurs autres que l'espace ASCII → non imprimables). +pub fn is_print(c: char) -> bool { + let u = c as u32; + if c == ' ' { + return true; + } + if c.is_control() || c.is_whitespace() { + return false; + } + !matches!(u, + 0xAD | 0x600..=0x605 | 0x61C | 0x6DD | 0x70F | 0x890..=0x891 | 0x8E2 | 0x180E + | 0x200B..=0x200F | 0x202A..=0x202E | 0x2060..=0x2064 | 0x2066..=0x206F + | 0xFEFF | 0xFFF9..=0xFFFB | 0xE000..=0xF8FF | 0xFFFE | 0xFFFF + | 0x110BD | 0x110CD | 0x13430..=0x1343F | 0x1BCA0..=0x1BCA3 + | 0x1D173..=0x1D17A | 0xE0001 | 0xE0020..=0xE007F + | 0xF0000..=0x10FFFF) +} + +/// `strconv.Quote` du Go (`%q` des messages d'erreur). +pub fn quote(s: &str) -> String { + let mut out = String::with_capacity(s.len() + 2); + out.push('"'); + for c in s.chars() { + match c { + '"' => out.push_str("\\\""), + '\\' => out.push_str("\\\\"), + '\x07' => out.push_str("\\a"), + '\x08' => out.push_str("\\b"), + '\x0c' => out.push_str("\\f"), + '\n' => out.push_str("\\n"), + '\r' => out.push_str("\\r"), + '\t' => out.push_str("\\t"), + '\x0b' => out.push_str("\\v"), + c if is_print(c) => out.push(c), + c if (c as u32) < 0x80 => out.push_str(&format!("\\x{:02x}", c as u32)), + c if (c as u32) < 0x10000 => out.push_str(&format!("\\u{:04x}", c as u32)), + c => out.push_str(&format!("\\U{:08x}", c as u32)), + } + } + out.push('"'); + out +} + +#[cfg(test)] +mod tests { + use super::*; + + const T: &[(&str, Kind)] = &[("name", Kind::Str), ("on", Kind::Bool)]; + + #[test] + fn erreurs_du_scanner() { + let e = |b: &str| decode_struct(b.as_bytes(), "t", T).err.unwrap_or_default(); + assert_eq!(e(""), "EOF"); + assert_eq!(e(" \n"), "EOF"); + assert_eq!( + e("pas du json"), + "invalid character 'p' looking for beginning of value" + ); + assert_eq!(e("{\"name\""), "unexpected EOF"); + assert_eq!(e("{\"name\" 1}"), "invalid character '1' after object key"); + assert_eq!( + e("{'a':1}"), + r"invalid character '\'' looking for beginning of object key string" + ); + assert_eq!(e("tru"), "unexpected EOF"); + assert_eq!( + e("trux"), + "invalid character 'x' in literal true (expecting 'e')" + ); + } + + #[test] + fn premiere_valeur_seulement() { + let d = decode_struct(br#"{"name":"a"} et la suite"#, "t", T); + assert_eq!(d.err, None); + assert_eq!(d.str("name"), "a"); + } + + #[test] + fn erreurs_de_type_et_casse() { + let d = decode_struct(br#"{"NAME":"x","on":"oui"}"#, "saveReq", T); + assert_eq!(d.str("name"), "x"); + assert_eq!( + d.err.as_deref(), + Some("json: cannot unmarshal string into Go struct field saveReq.on of type bool") + ); + let d = decode_struct(b"[1]", "saveReq", T); + assert_eq!( + d.err.as_deref(), + Some("json: cannot unmarshal array into Go value of type loki.saveReq") + ); + assert!(decode_struct(b"null", "t", T).err.is_none()); + } +} diff --git a/rust/loki-rs/src/lib.rs b/rust/loki-rs/src/lib.rs index f3d2198..185dc36 100644 --- a/rust/loki-rs/src/lib.rs +++ b/rust/loki-rs/src/lib.rs @@ -21,7 +21,9 @@ pub mod downloads; pub mod engine; pub mod engstatus; pub mod gguf; +pub mod gojson; pub mod hf; +pub mod memory; pub mod models; pub mod oai; pub mod paths; diff --git a/rust/loki-rs/src/main.rs b/rust/loki-rs/src/main.rs index ee6d1c4..e736fc5 100644 --- a/rust/loki-rs/src/main.rs +++ b/rust/loki-rs/src/main.rs @@ -133,26 +133,25 @@ fn serve() -> ExitCode { // LOKI_PUSH=rust : les notifications Web Push (le Go lui délègue ses // envois de fin de tour et de fin de tâche). let push_rust = held_by_facade("LOKI_PUSH"); + // LOKI_MEM=rust : la mémoire (pages, index, trackers, mode mémoire, + // interrupteurs du mode agent). + let mem_rust = held_by_facade("LOKI_MEM"); let mut env = vec![(auth::INTERNAL_ENV.to_string(), token.clone())]; - if chat_rust { - env.push(("LOKI_CHAT".into(), "rust".into())); + let held = [ + ("LOKI_CHAT", chat_rust), + ("LOKI_MODELS", models_rust), + ("LOKI_DICTATE", dictate_rust), + ("LOKI_SETTINGS", settings_rust), + ("LOKI_FILES", files_rust), + ("LOKI_PUSH", push_rust), + ("LOKI_MEM", mem_rust), + ]; + for (var, on) in held { + if on { + env.push((var.into(), "rust".into())); + } } - if models_rust { - env.push(("LOKI_MODELS".into(), "rust".into())); - } - if dictate_rust { - env.push(("LOKI_DICTATE".into(), "rust".into())); - } - if settings_rust { - env.push(("LOKI_SETTINGS".into(), "rust".into())); - } - if files_rust { - env.push(("LOKI_FILES".into(), "rust".into())); - } - if push_rust { - env.push(("LOKI_PUSH".into(), "rust".into())); - } - if chat_rust || models_rust || dictate_rust || settings_rust || files_rust || push_rust { + if held.iter().any(|(_, on)| *on) { env.push(("LOKI_FACADE_ADDR".into(), format!("127.0.0.1:{web_port}"))); } let sup = child::Supervisor::spawn(go_bin, go_port, env); @@ -212,6 +211,7 @@ fn serve() -> ExitCode { } }; eprintln!("[loki-rs] façade : http://{addr} → {backend}"); + let mem_home = engine_paths.home.clone(); let cfg = proxy::Config { backend, internal_token: token, @@ -223,6 +223,7 @@ fn serve() -> ExitCode { settings: settings_rust, files, push: push_rust.then(|| std::sync::Arc::new(loki_rs::push::Push::new())), + mem: mem_rust.then(|| std::sync::Arc::new(loki_rs::memory::Memory::new(mem_home))), }; let res = proxy::serve(listener, cfg, shutdown_signal()).await; eprintln!("[loki-rs] arrêt demandé — arrêt du serveur Go"); diff --git a/rust/loki-rs/src/memory.rs b/rust/loki-rs/src/memory.rs new file mode 100644 index 0000000..2b94a09 --- /dev/null +++ b/rust/loki-rs/src/memory.rs @@ -0,0 +1,1379 @@ +//! Mémoire de Loki — port de `chat_memory.go` (pages), `mem_index.go` +//! (index MEMORY.md tenu par le code), `mem_io.go` / `mem_fsutil.go` +//! (écritures sûres), `moveMemPage` (projects.go), `tracker.go` + +//! `web_tracker.go` (trackers) et des routes du mode agent (`handleAgent`, +//! `handleAgentToggle`, `handleCompactToggle`, `handleMemoryMode`) — lot 13 +//! de la migration, servi par la façade quand `LOKI_MEM=rust` (expérimental ; +//! le Go par défaut) : +//! +//! - `GET /api/mem?name=` : contenu d'une page ; `POST /api/mem/save`, +//! `/api/mem/delete`, `/api/mem/move` : éditeur web des pages ; +//! - `GET /api/agent` (alias `/api/tools`, `/api/skills`) : mode agent, +//! compactage, mode mémoire et liste des pages ; `POST /api/agent/toggle` +//! (et alias), `/api/agent/compact`, `GET|POST /api/memory` ; +//! - `GET|POST /api/tracker` : panneau des trackers. +//! +//! Les pages restent des fichiers `memory//.md` : le Go (outils +//! `mem_*` de sa boucle, index injecté au modèle) les lit et les écrit sur le +//! même disque. Le chiffrement reste au Go, seul à tenir la clé en mémoire : +//! une page qui porte le magic `LOKIMEMv1` est ouverte par +//! `/internal/v1/crypt/open`, et toute écriture passe par +//! `/internal/v1/crypt/seal` (clair rendu tel quel quand le chiffrement est +//! coupé). Les trackers vivent dans le bucket `tracker`, scellés de même. +//! +//! Hors périmètre (restent au Go) : coffre et chiffrement +//! (`/api/mem/encrypt|decrypt|unlock|lock`), santé, instantanés, sauvegarde, +//! hub des projets (`/api/projects`), dont la bascule recharge la discussion. + +use std::collections::HashMap; +use std::io::Write as _; +use std::path::{Path, PathBuf}; +use std::sync::atomic::{AtomicU64, Ordering}; +use std::time::{SystemTime, UNIX_EPOCH}; + +use http_body_util::{BodyExt, Limited}; +use hyper::body::Incoming; +use hyper::{Method, Request, Response, StatusCode}; +use serde::{Deserialize, Serialize}; +use serde_json::{Value, json}; + +use crate::api::Ctx; +use crate::auth::json as send_json; +use crate::gojson::{self, Kind}; +use crate::proxy::Body; +use crate::store::{BK_CONFIG, BK_STATE, CryptError, Store}; + +/// Index du projet, tenu par le code. +pub const MEM_INDEX_FILE: &str = "MEMORY.md"; +/// Magic des contenus chiffrés (`memPageMagic`). +pub const PAGE_MAGIC: &[u8] = b"LOKIMEMv1"; +pub const BK_TRACKER: &str = "tracker"; +const ST_PROJECTS: &str = "projects"; +const ST_ACTIVE_PROJECT: &str = "active_project"; +const ST_AGENT: &str = "agent"; +const DEFAULT_PROJECT_SLUG: &str = "generale"; +const DEFAULT_PROJECT_NAME: &str = "Générale"; +const NEW_PAGE: &str = "# nouvelle page\n\nNote ici ce que loki doit retenir entre les sessions.\n"; +const LOCKED_TITLE: &str = "🔒 (chiffré — mémoire verrouillée)"; +const ERR_MEM_LOCKED: &str = "page chiffrée, mémoire verrouillée"; +const ERR_STORE_LOCKED: &str = "valeur chiffrée, mémoire verrouillée"; + +/// Routes servies ici. +pub fn handles(path: &str) -> bool { + matches!( + path, + "/api/mem" + | "/api/mem/save" + | "/api/mem/delete" + | "/api/mem/move" + | "/api/agent" + | "/api/tools" + | "/api/skills" + | "/api/agent/toggle" + | "/api/tools/toggle" + | "/api/skills/toggle" + | "/api/agent/compact" + | "/api/memory" + | "/api/tracker" + ) +} + +/// `strings.TrimSpace`. +fn go_trim(s: &str) -> &str { + s.trim_matches(char::is_whitespace) +} + +// --------------------------------------------------------------------------- +// Fonctions pures +// --------------------------------------------------------------------------- + +/// `memFileName` : ajoute `.md` au besoin et valide le nom. +pub fn mem_file_name(name: &str) -> Result { + let mut name = go_trim(name).to_string(); + if name.is_empty() { + return Err("nom vide".into()); + } + if !name.to_lowercase().ends_with(".md") { + name.push_str(".md"); + } + if !name + .bytes() + .all(|b| b.is_ascii_alphanumeric() || matches!(b, b'.' | b'_' | b'-')) + { + return Err("nom invalide (alphanum, ._-)".into()); + } + Ok(name) +} + +/// `titleOf` : 1re ligne non vide, sans les `#` de tête. +pub fn title_of(content: &str) -> String { + for line in content.split('\n') { + let s = go_trim(go_trim(line).trim_start_matches('#')); + if !s.is_empty() { + return s.to_string(); + } + } + String::new() +} + +/// `isIndexFile`. +pub fn is_index_file(name: &str) -> bool { + go_trim(name).eq_ignore_ascii_case(MEM_INDEX_FILE) +} + +/// `memorySeed` : en-tête de l'index d'un projet. +pub fn memory_seed(name: &str) -> String { + format!( + "# Index mémoire — {name}\n\nUne ligne par page de ce projet (`- [titre](fichier.md) — accroche`). \ + Tenu à jour automatiquement à la création et à la suppression d'une page ; \ + l'accroche après le titre, elle, est libre.\n" + ) +} + +/// `normalizeMemMode`. +pub fn normalize_mem_mode(v: &str) -> &'static str { + match go_trim(v).to_lowercase().as_str() { + "off" => "off", + "ondemand" => "ondemand", + "search" => "search", + _ => "always", + } +} + +/// `slugifyIdent` : slug de fichier sûr (accents courants translittérés). +pub fn slugify_ident(name: &str) -> String { + let mut b = String::new(); + for c in go_trim(name).chars() { + // unicode.ToLower, rune par rune (pas de minuscule à plusieurs runes). + let lc = if c == 'İ' { + 'i' + } else { + let mut l = c.to_lowercase(); + match (l.next(), l.next()) { + (Some(x), None) => x, + _ => c, + } + }; + let rep = match lc { + 'à' | 'â' | 'ä' | 'á' | 'ã' => "a", + 'é' | 'è' | 'ê' | 'ë' => "e", + 'î' | 'ï' | 'í' => "i", + 'ô' | 'ö' | 'ó' | 'õ' => "o", + 'ù' | 'û' | 'ü' | 'ú' => "u", + 'ç' => "c", + 'ñ' => "n", + _ => { + b.push(lc); + continue; + } + }; + b.push_str(rep); + } + // [^a-z0-9]+ → « - » + let mut slug = String::with_capacity(b.len()); + let mut in_run = false; + for c in b.chars() { + if c.is_ascii_lowercase() || c.is_ascii_digit() { + slug.push(c); + in_run = false; + } else if !in_run { + slug.push('-'); + in_run = true; + } + } + let mut slug = slug.trim_matches('-').to_string(); + if slug.len() > 40 { + slug = slug[..40].trim_matches('-').to_string(); + } + slug +} + +// --------------------------------------------------------------------------- +// Dates des trackers +// --------------------------------------------------------------------------- + +/// Fuseau des dates saisies (`time.Local` du Go). +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum Tz { + /// Fuseau de la machine (mktime). + Local, + /// Décalage fixe, en secondes à l'est d'UTC (essais). + Fixed(i64), +} + +/// Date-heure civile lue dans une saisie. +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] +pub struct Civil { + pub year: i64, + pub month: u32, + pub day: u32, + pub hour: u32, + pub min: u32, + pub sec: u32, + pub nanos: u32, +} + +fn days_from_civil(y: i64, m: u32, d: u32) -> i64 { + let y = if m <= 2 { y - 1 } else { y }; + let era = y.div_euclid(400); + let yoe = y.rem_euclid(400); + let m = i64::from(m); + let mp = if m > 2 { m - 3 } else { m + 9 }; + let doy = (153 * mp + 2) / 5 + i64::from(d) - 1; + let doe = yoe * 365 + yoe / 4 - yoe / 100 + doy; + era * 146097 + doe - 719468 +} + +fn is_leap(y: i64) -> bool { + y % 4 == 0 && (y % 100 != 0 || y % 400 == 0) +} + +fn days_in(m: u32, y: i64) -> u32 { + match m { + 2 if is_leap(y) => 29, + 2 => 28, + 4 | 6 | 9 | 11 => 30, + _ => 31, + } +} + +impl Tz { + /// Secondes Unix de la date civile dans ce fuseau. + pub fn to_unix(self, c: &Civil) -> i64 { + let naive = days_from_civil(c.year, c.month, c.day) * 86400 + + i64::from(c.hour) * 3600 + + i64::from(c.min) * 60 + + i64::from(c.sec); + match self { + Tz::Fixed(off) => naive - off, + Tz::Local => local_to_unix(c).unwrap_or(naive), + } + } +} + +#[cfg(unix)] +fn local_to_unix(c: &Civil) -> Option { + let mut tm: libc::tm = unsafe { std::mem::zeroed() }; + tm.tm_year = i32::try_from(c.year - 1900).ok()?; + tm.tm_mon = c.month as i32 - 1; + tm.tm_mday = c.day as i32; + tm.tm_hour = c.hour as i32; + tm.tm_min = c.min as i32; + tm.tm_sec = c.sec as i32; + tm.tm_isdst = -1; + // SAFETY : structure initialisée, pointeur valide. + let t = unsafe { libc::mktime(&mut tm) }; + (t != -1).then_some(t as i64) +} + +#[cfg(not(unix))] +fn local_to_unix(_: &Civil) -> Option { + None +} + +/// `getnum` du paquet time : 1 ou 2 chiffres (`fixed` : exactement 2). +fn getnum(v: &[u8], i: &mut usize, fixed: bool) -> Option { + let d0 = *v.get(*i)?; + if !d0.is_ascii_digit() { + return None; + } + match v.get(*i + 1) { + Some(d1) if d1.is_ascii_digit() => { + *i += 2; + Some(u32::from(d0 - b'0') * 10 + u32::from(d1 - b'0')) + } + _ if fixed => None, + _ => { + *i += 1; + Some(u32::from(d0 - b'0')) + } + } +} + +fn expect(v: &[u8], i: &mut usize, c: u8) -> Option<()> { + (v.get(*i) == Some(&c)).then(|| *i += 1) +} + +/// `time.ParseInLocation` restreint aux mises en page de `parseWhen` +/// (`2006[-01[-02[ 15[:04[:05]]]]]`). `parts` : nombre d'éléments après +/// l'année (0 à 5). +fn parse_layout(s: &str, parts: u8) -> Option { + let v = s.as_bytes(); + if v.len() < 4 || !v[0].is_ascii_digit() || !v[..4].iter().all(u8::is_ascii_digit) { + return None; + } + let mut c = Civil { + year: s[..4].parse().ok()?, + month: 1, + day: 1, + ..Civil::default() + }; + let mut i = 4; + if parts >= 1 { + expect(v, &mut i, b'-')?; + c.month = getnum(v, &mut i, true)?; + if !(1..=12).contains(&c.month) { + return None; + } + } + if parts >= 2 { + expect(v, &mut i, b'-')?; + c.day = getnum(v, &mut i, true)?; + } + if parts >= 3 { + // `skip` du paquet time : une espace de la mise en page vaut une + // suite d'espaces (ou rien en fin de saisie). + if i < v.len() && v[i] != b' ' { + return None; + } + while i < v.len() && v[i] == b' ' { + i += 1; + } + c.hour = getnum(v, &mut i, false)?; + if c.hour >= 24 { + return None; + } + } + if parts >= 4 { + expect(v, &mut i, b':')?; + c.min = getnum(v, &mut i, true)?; + if c.min >= 60 { + return None; + } + } + if parts >= 5 { + expect(v, &mut i, b':')?; + c.sec = getnum(v, &mut i, true)?; + if c.sec >= 60 { + return None; + } + // Fraction de seconde acceptée sans figurer dans la mise en page. + if v.len() >= i + 2 && matches!(v[i], b'.' | b',') && v[i + 1].is_ascii_digit() { + let mut n = i + 1; + while n < v.len() && v[n].is_ascii_digit() { + n += 1; + } + let digits = &s[i + 1..n.min(i + 10)]; + let mut ns: u32 = digits.parse().ok()?; + for _ in digits.len()..9 { + ns *= 10; + } + c.nanos = ns; + i = n; + } + } + if i != v.len() { + return None; + } + if c.day < 1 || c.day > days_in(c.month, c.year) { + return None; + } + Some(c) +} + +/// `parseWhen` : date à précision variable → (instant en ms, sans heure). +/// Vide ou « now » = `now_ms`. +pub fn parse_when(when: &str, now_ms: i64, tz: Tz) -> Result<(i64, bool), String> { + let w = go_trim(when); + if w.is_empty() || w.eq_ignore_ascii_case("now") || w.eq_ignore_ascii_case("maintenant") { + return Ok((now_ms, false)); + } + let w = w.replacen('T', " ", 1); + for (parts, date_only) in [ + (5, false), + (4, false), + (3, false), + (2, true), + (1, true), + (0, true), + ] { + if let Some(c) = parse_layout(&w, parts) { + return Ok(( + tz.to_unix(&c) * 1000 + i64::from(c.nanos / 1_000_000), + date_only, + )); + } + } + Err(format!( + "date non comprise : {} (attendu AAAA, AAAA-MM, AAAA-MM-JJ ou AAAA-MM-JJ HH:MM)", + gojson::quote(when) + )) +} + +fn now_ms() -> i64 { + SystemTime::now() + .duration_since(UNIX_EPOCH) + .map_or(0, |d| d.as_millis() as i64) +} + +fn now_nanos() -> u128 { + SystemTime::now() + .duration_since(UNIX_EPOCH) + .map_or(0, |d| d.as_nanos()) +} + +// --------------------------------------------------------------------------- +// Écritures sûres (mem_fsutil.go) +// --------------------------------------------------------------------------- + +static TMP_SEQ: AtomicU64 = AtomicU64::new(0); + +#[cfg(unix)] +fn set_mode(path: &Path, perm: u32) -> std::io::Result<()> { + use std::os::unix::fs::PermissionsExt; + std::fs::set_permissions(path, std::fs::Permissions::from_mode(perm)) +} + +#[cfg(not(unix))] +fn set_mode(_: &Path, _: u32) -> std::io::Result<()> { + Ok(()) +} + +/// `memWriteFileAtomic` : temporaire + fsync + rename. +pub fn write_file_atomic(path: &Path, data: &[u8], perm: u32) -> std::io::Result<()> { + let dir = path.parent().unwrap_or(Path::new(".")); + std::fs::create_dir_all(dir)?; + let base = path + .file_name() + .map(|n| n.to_string_lossy().into_owned()) + .unwrap_or_default(); + let (tmp_path, mut f) = loop { + let n = TMP_SEQ.fetch_add(1, Ordering::Relaxed); + let p = dir.join(format!(".tmp-{base}-{}{n}", now_nanos() % 1_000_000_000)); + let mut o = std::fs::OpenOptions::new(); + o.write(true).create_new(true); + #[cfg(unix)] + { + use std::os::unix::fs::OpenOptionsExt; + o.mode(0o600); + } + match o.open(&p) { + Ok(f) => break (p, f), + Err(e) if e.kind() == std::io::ErrorKind::AlreadyExists => continue, + Err(e) => return Err(e), + } + }; + let res = (|| { + f.write_all(data)?; + f.sync_all()?; + drop(f); + set_mode(&tmp_path, perm)?; + std::fs::rename(&tmp_path, path) + })(); + if res.is_err() { + let _ = std::fs::remove_file(&tmp_path); + } + res +} + +/// `memWriteFileVerified` : écriture atomique puis relecture octet par octet. +pub fn write_file_verified(path: &Path, data: &[u8], perm: u32) -> Result<(), String> { + write_file_atomic(path, data, perm).map_err(|e| e.to_string())?; + let back = + std::fs::read(path).map_err(|e| format!("relecture de vérification impossible : {e}"))?; + if back.len() != data.len() { + let _ = std::fs::remove_file(path); + return Err(format!( + "vérification échouée : {} octets relus, {} écrits", + back.len(), + data.len() + )); + } + if let Some(i) = back.iter().zip(data).position(|(a, b)| a != b) { + let _ = std::fs::remove_file(path); + return Err(format!("vérification échouée : octet {i} diffère")); + } + Ok(()) +} + +/// `os.WriteFile` : création avec `perm`, sinon le fichier garde ses droits. +fn write_file(path: &Path, data: &[u8], perm: u32) -> std::io::Result<()> { + let mut o = std::fs::OpenOptions::new(); + o.write(true).create(true).truncate(true); + #[cfg(unix)] + { + use std::os::unix::fs::OpenOptionsExt; + o.mode(perm); + } + #[cfg(not(unix))] + let _ = perm; + o.open(path)?.write_all(data) +} + +// --------------------------------------------------------------------------- +// Projets (lecture du registre du Go) +// --------------------------------------------------------------------------- + +/// `Project` (bucket state, clé `projects`). +#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)] +pub struct Project { + pub slug: String, + pub name: String, + #[serde(default)] + pub created_at: i64, + #[serde(default, skip_serializing_if = "String::is_empty")] + pub desc: String, + #[serde(default, skip_serializing_if = "String::is_empty")] + pub mem_mode: String, +} + +/// `listProjects` : le registre, le plus ancien d'abord ; au moins le +/// projet par défaut. +pub async fn list_projects(store: &Store) -> Vec { + let mut out: Vec = match store.get(BK_STATE, ST_PROJECTS).await { + Ok(Some(b)) if !b.is_empty() => serde_json::from_slice(&b).unwrap_or_default(), + _ => Vec::new(), + }; + if out.is_empty() { + out.push(Project { + slug: DEFAULT_PROJECT_SLUG.into(), + name: DEFAULT_PROJECT_NAME.into(), + created_at: now_ms(), + desc: String::new(), + mem_mode: String::new(), + }); + } + out.sort_by_key(|p| p.created_at); + out +} + +/// `activeProjectSlug` (valeur rangée ; le Go seul connaît la bascule +/// temporaire d'une tâche planifiée). +pub async fn active_project(store: &Store) -> String { + match go_trim(&store.get_str(BK_STATE, ST_ACTIVE_PROJECT).await) { + "" => DEFAULT_PROJECT_SLUG.into(), + s => s.into(), + } +} + +fn project_name(list: &[Project], slug: &str) -> String { + list.iter() + .find(|p| p.slug == slug) + .map_or_else(|| slug.to_string(), |p| p.name.clone()) +} + +// --------------------------------------------------------------------------- +// Service +// --------------------------------------------------------------------------- + +/// La mémoire tenue par la façade. +pub struct Memory { + home: PathBuf, + tz: Tz, +} + +/// Lecture d'une page. +#[derive(Debug)] +enum ReadErr { + Invalid, + Missing, + Locked, + Other, +} + +impl Memory { + pub fn new(home: PathBuf) -> Self { + Memory { + home, + tz: Tz::Local, + } + } + + /// Fuseau des dates de trackers (essais : décalage fixe). + pub fn with_tz(home: PathBuf, tz: Tz) -> Self { + Memory { home, tz } + } + + /// `projectMemoryDir`. + fn project_dir(&self, slug: &str) -> PathBuf { + let slug = if slug.is_empty() { + DEFAULT_PROJECT_SLUG + } else { + slug + }; + self.home.join("memory").join(slug) + } + + /// `safeMemPath`. + fn safe_path(dir: &Path, name: &str) -> Result { + let f = mem_file_name(name)?; + let p = dir.join(&f); + if p.parent() != Some(dir) { + return Err("path invalide".into()); + } + Ok(p) + } + + /// `decodeMemContent`. + async fn decode(store: &Store, raw: Vec) -> Result, ReadErr> { + if !raw.starts_with(PAGE_MAGIC) { + return Ok(raw); + } + match store.open(raw).await { + Ok(b) => Ok(b), + Err(CryptError::Locked) => Err(ReadErr::Locked), + Err(CryptError::Other(_)) => Err(ReadErr::Other), + } + } + + /// `memReadPage`. + async fn read_page(store: &Store, dir: &Path, name: &str) -> Result, ReadErr> { + let p = Self::safe_path(dir, name).map_err(|_| ReadErr::Invalid)?; + let raw = std::fs::read(&p).map_err(|_| ReadErr::Missing)?; + Self::decode(store, raw).await + } + + /// `MemContent` : contenu clair, vide si illisible. + async fn content(store: &Store, dir: &Path, name: &str) -> String { + match Self::read_page(store, dir, name).await { + Ok(b) => String::from_utf8_lossy(&b).into_owned(), + Err(_) => String::new(), + } + } + + /// `writeMemFile` : scellé selon le chiffrement, `.bak` de la version + /// précédente, écriture atomique vérifiée. + async fn write_page(store: &Store, dir: &Path, name: &str, plain: &[u8]) -> Result<(), String> { + let p = Self::safe_path(dir, name)?; + let out = store.seal(plain.to_vec()).await.map_err(|e| match e { + CryptError::Locked => ERR_MEM_LOCKED.to_string(), + CryptError::Other(e) => e, + })?; + std::fs::create_dir_all(dir).map_err(|e| e.to_string())?; + if let Ok(old) = std::fs::read(&p) { + let mut bak = p.clone().into_os_string(); + bak.push(".bak"); + let _ = write_file_atomic(Path::new(&bak), &old, 0o600); + } + write_file_verified(&p, &out, 0o600) + } + + /// `MemList` : pages (nom, titre), triées par nom. + async fn list(store: &Store, dir: &Path) -> Vec<(String, String)> { + let Ok(rd) = std::fs::read_dir(dir) else { + return Vec::new(); + }; + let mut names: Vec = rd + .filter_map(Result::ok) + .filter(|e| !e.file_type().is_ok_and(|t| t.is_dir())) + .map(|e| e.file_name().to_string_lossy().into_owned()) + .filter(|n| !n.starts_with('.') && n.to_lowercase().ends_with(".md")) + .collect(); + names.sort(); + let mut out = Vec::with_capacity(names.len()); + for n in names { + let title = match Self::read_page(store, dir, &n).await { + Ok(b) => title_of(&String::from_utf8_lossy(&b)), + Err(_) => LOCKED_TITLE.to_string(), + }; + out.push((n, title)); + } + out + } + + // --- index ---------------------------------------------------------- + + /// `writeIndex` : en clair, comme le Go. + fn write_index(dir: &Path, body: &str) { + if std::fs::create_dir_all(dir).is_err() { + return; + } + let _ = write_file(&dir.join(MEM_INDEX_FILE), body.as_bytes(), 0o644); + } + + /// `ensureIndexSeed`. + async fn ensure_index_seed(store: &Store, dir: &Path, project: &str) { + if !go_trim(&Self::content(store, dir, MEM_INDEX_FILE).await).is_empty() { + return; + } + Self::write_index(dir, &memory_seed(project)); + } + + /// `indexLineFor`. + async fn index_line(store: &Store, dir: &Path, name: &str) -> String { + let t = title_of(&Self::content(store, dir, name).await); + let title = if t.is_empty() { name.to_string() } else { t }; + format!("- [{title}]({name})") + } + + /// `memIndexAdd`. + async fn index_add(store: &Store, dir: &Path, name: &str, project: &str) { + let Ok(f) = mem_file_name(name) else { return }; + if is_index_file(&f) { + return; + } + Self::ensure_index_seed(store, dir, project).await; + let content = Self::content(store, dir, MEM_INDEX_FILE).await; + let r = format!("]({f})"); + if content.split('\n').any(|l| l.contains(&r)) { + return; + } + let line = Self::index_line(store, dir, &f).await; + Self::write_index( + dir, + &format!("{}\n{line}\n", content.trim_end_matches('\n')), + ); + } + + /// `memIndexRemove`. + async fn index_remove(store: &Store, dir: &Path, name: &str) { + let Ok(f) = mem_file_name(name) else { return }; + if is_index_file(&f) { + return; + } + let content = Self::content(store, dir, MEM_INDEX_FILE).await; + if content.is_empty() { + return; + } + let r = format!("]({f})"); + let lines: Vec<&str> = content.split('\n').collect(); + let kept: Vec<&str> = lines.iter().copied().filter(|l| !l.contains(&r)).collect(); + if kept.len() == lines.len() { + return; + } + Self::write_index( + dir, + &format!("{}\n", kept.join("\n").trim_end_matches('\n')), + ); + } + + // --- pages ---------------------------------------------------------- + + /// `MemSave` : crée ou écrase ; `old` différent : l'ancienne est retirée. + async fn save( + store: &Store, + dir: &Path, + project: &str, + name: &str, + old: &str, + content: &str, + ) -> Result<(), String> { + if !old.is_empty() + && let Ok(of) = mem_file_name(old) + && let Ok(nf) = mem_file_name(name) + && of != nf + && let Ok(od) = Self::safe_path(dir, old) + { + let _ = std::fs::remove_file(od); + } + let body = format!("{}\n", content.trim_end_matches('\n')); + Self::write_page(store, dir, name, body.as_bytes()).await?; + // memIndexRename : ancienne ligne retirée (même nom compris), nouvelle + // ajoutée. + if !old.is_empty() { + Self::index_remove(store, dir, old).await; + } + Self::index_add(store, dir, name, project).await; + Ok(()) + } + + /// `MemDelete`. + async fn delete(store: &Store, dir: &Path, name: &str) -> Result<(), String> { + let p = Self::safe_path(dir, name)?; + if std::fs::metadata(&p).is_err() { + return Err("introuvable".into()); + } + std::fs::remove_file(&p).map_err(|e| e.to_string())?; + Self::index_remove(store, dir, name).await; + Ok(()) + } + + /// `moveMemPage`. + async fn move_page( + &self, + store: &Store, + name: &str, + from: &str, + to: &str, + ) -> Result<(), String> { + if go_trim(from) == go_trim(to) { + return Err("déjà dans ce projet".into()); + } + let projects = list_projects(store).await; + if !projects.iter().any(|p| p.slug == to) { + return Err("projet cible introuvable".into()); + } + let f = mem_file_name(name)?; + if is_index_file(&f) { + return Err("l'index du projet n'est pas déplaçable".into()); + } + let (fd, td) = (self.project_dir(from), self.project_dir(to)); + let raw = std::fs::read(fd.join(&f)).map_err(|_| "page introuvable".to_string())?; + if std::fs::metadata(td.join(&f)).is_ok() { + return Err("une page du même nom existe déjà dans le projet cible".into()); + } + std::fs::create_dir_all(&td).map_err(|e| e.to_string())?; + write_file(&td.join(&f), &raw, 0o600).map_err(|e| e.to_string())?; + let _ = std::fs::remove_file(fd.join(&f)); + Self::index_remove(store, &fd, &f).await; + Self::index_add(store, &td, &f, &project_name(&projects, to)).await; + Ok(()) + } + + // --- mode mémoire --------------------------------------------------- + + /// `memMode` : mode du projet actif, repli sur `MEM_MODE`. + async fn mem_mode(store: &Store) -> &'static str { + let active = active_project(store).await; + let projects = list_projects(store).await; + if let Some(p) = projects.iter().find(|p| p.slug == active) + && !p.mem_mode.is_empty() + { + return normalize_mem_mode(&p.mem_mode); + } + normalize_mem_mode( + store + .config() + .await + .get("MEM_MODE") + .map_or("", String::as_str), + ) + } + + /// `setMemMode` → `setProjectMemMode(activeProjectSlug(), mode)`. + async fn set_mem_mode(store: &Store, mode: &str) -> Result<(), String> { + let mode = go_trim(mode).to_lowercase(); + if !matches!(mode.as_str(), "" | "off" | "ondemand" | "always" | "search") { + return Err(format!("mode mémoire invalide: {mode}")); + } + let active = active_project(store).await; + let mut list = list_projects(store).await; + let Some(p) = list.iter_mut().find(|p| p.slug == active) else { + return Err("projet introuvable".into()); + }; + p.mem_mode = mode; + store + .put(BK_STATE, ST_PROJECTS, gojson::marshal(&list).into_bytes()) + .await + } + + // --- trackers ------------------------------------------------------- + + async fn tracker_load(store: &Store, proj: &str, slug: &str) -> Option { + let b = store + .get_open(BK_TRACKER, &format!("{proj}/{slug}")) + .await + .ok() + .flatten()?; + serde_json::from_slice(&b).ok() + } + + async fn tracker_save( + store: &Store, + proj: &str, + slug: &str, + t: &mut Tracker, + ) -> Result<(), String> { + if let Some(ev) = t.events.as_mut() { + ev.sort_by_key(|e| e.ts); + } + store + .put_seal( + BK_TRACKER, + &format!("{proj}/{slug}"), + gojson::marshal(t).into_bytes(), + ) + .await + .map_err(|e| match e { + CryptError::Locked => ERR_STORE_LOCKED.to_string(), + CryptError::Other(e) => e, + }) + } + + /// `trackerList` : métadonnées, le plus récemment alimenté d'abord. + async fn tracker_list(store: &Store, proj: &str) -> Vec { + let prefix = format!("{proj}/"); + let keys: Vec = store + .all(BK_TRACKER) + .await + .unwrap_or_default() + .into_keys() + .filter(|k| k.starts_with(&prefix)) + .collect(); + let mut rows: Vec<(i64, String, String, Value)> = Vec::new(); + for k in keys { + let slug = k[prefix.len()..].to_string(); + let Some(t) = Self::tracker_load(store, proj, &slug).await else { + continue; + }; + let d = TrackerDto::of(&slug, &t, false); + rows.push((d.last_ts, d.name.clone(), slug, json!(d))); + } + rows.sort_by(|a, b| { + b.0.cmp(&a.0) + .then_with(|| a.1.cmp(&b.1)) + .then_with(|| a.2.cmp(&b.2)) + }); + rows.into_iter().map(|r| r.3).collect() + } + + async fn tracker_action( + &self, + store: &Store, + proj: &str, + req: &gojson::Decoded, + ) -> Result<(), String> { + let slug = go_trim(&req.str("slug")).to_string(); + match go_trim(&req.str("action")).to_lowercase().as_str() { + "add" => self + .tracker_add( + store, + proj, + &req.str("name"), + &req.str("when"), + &req.str("text"), + ) + .await + .map(|_| ()), + "edit" => { + let mut t = Self::tracker_load(store, proj, &slug) + .await + .ok_or("tracker introuvable")?; + let id = req.str("id"); + let (when, text) = (req.str("when"), req.str("text")); + let ev = t.events.get_or_insert_with(Vec::new); + let Some(e) = ev.iter_mut().find(|e| e.id == id) else { + return Err("événement introuvable".into()); + }; + if !go_trim(&text).is_empty() { + e.text = go_trim(&text).to_string(); + } + if !go_trim(&when).is_empty() { + let (ts, date_only) = parse_when(&when, now_ms(), self.tz)?; + e.ts = ts; + e.date_only = date_only; + } + Self::tracker_save(store, proj, &slug, &mut t).await + } + "delete" => { + let mut t = Self::tracker_load(store, proj, &slug) + .await + .ok_or("tracker introuvable")?; + let id = req.str("id"); + let Some(ev) = t.events.as_mut() else { + return Err("événement introuvable".into()); + }; + let n = ev.len(); + ev.retain(|e| e.id != id); + if ev.len() == n { + return Err("événement introuvable".into()); + } + Self::tracker_save(store, proj, &slug, &mut t).await + } + "delete_tracker" => store.del(BK_TRACKER, &format!("{proj}/{slug}")).await, + "rename" => { + let name = go_trim(&req.str("name")).to_string(); + if name.is_empty() { + return Err("nom vide".into()); + } + let mut t = Self::tracker_load(store, proj, &slug) + .await + .ok_or("tracker introuvable")?; + let new_slug = slugify_ident(&name); + if new_slug.is_empty() { + return Err("nom invalide".into()); + } + t.name = name; + if new_slug == slug { + return Self::tracker_save(store, proj, &slug, &mut t).await; + } + if Self::tracker_load(store, proj, &new_slug).await.is_some() { + return Err("un tracker du même nom existe déjà".into()); + } + Self::tracker_save(store, proj, &new_slug, &mut t).await?; + store.del(BK_TRACKER, &format!("{proj}/{slug}")).await + } + "move" => { + let to = go_trim(&req.str("to")).to_string(); + if !list_projects(store).await.iter().any(|p| p.slug == to) { + return Err("projet cible introuvable".into()); + } + if proj == to { + return Ok(()); + } + let (fk, tk) = (format!("{proj}/{slug}"), format!("{to}/{slug}")); + let raw = store.get(BK_TRACKER, &fk).await?.unwrap_or_default(); + if raw.is_empty() { + return Err("tracker introuvable".into()); + } + if store + .get(BK_TRACKER, &tk) + .await? + .is_some_and(|v| !v.is_empty()) + { + return Err("un tracker du même nom existe déjà dans le projet cible".into()); + } + store.put(BK_TRACKER, &tk, raw).await?; + store.del(BK_TRACKER, &fk).await + } + _ => Err(format!( + "action inconnue : {}", + gojson::quote(&req.str("action")) + )), + } + } + + /// `trackerAdd` : ajoute un point (tracker créé au besoin) ; rend l'id. + async fn tracker_add( + &self, + store: &Store, + proj: &str, + name: &str, + when: &str, + text: &str, + ) -> Result { + let name = go_trim(name).to_string(); + if name.is_empty() { + return Err("nom de tracker vide".into()); + } + if go_trim(text).is_empty() { + return Err("contenu vide".into()); + } + let (ts, date_only) = parse_when(when, now_ms(), self.tz)?; + let mut slug = slugify_ident(&name); + if slug.is_empty() { + slug = "tracker".into(); + } + let mut t = Self::tracker_load(store, proj, &slug) + .await + .unwrap_or_else(|| Tracker { + name: name.clone(), + events: None, + }); + if go_trim(&t.name).is_empty() { + t.name = name; + } + let id = now_nanos().to_string(); + t.events.get_or_insert_with(Vec::new).push(TrackerEvent { + id: id.clone(), + ts, + text: go_trim(text).to_string(), + date_only, + }); + Self::tracker_save(store, proj, &slug, &mut t).await?; + Ok(id) + } +} + +/// Point daté d'un tracker (`TrackerEvent`). +#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)] +pub struct TrackerEvent { + #[serde(default)] + pub id: String, + #[serde(default)] + pub ts: i64, + #[serde(default)] + pub text: String, + #[serde(default, skip_serializing_if = "std::ops::Not::not")] + pub date_only: bool, +} + +/// `Tracker` : `events` à `None` = `nil` du Go (`null` en JSON). +#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)] +pub struct Tracker { + #[serde(default)] + pub name: String, + #[serde(default)] + pub events: Option>, +} + +/// `trackerDTO`. +#[derive(Serialize)] +struct TrackerDto { + slug: String, + name: String, + count: usize, + first_ts: i64, + last_ts: i64, + last_text: String, + #[serde(skip_serializing_if = "Option::is_none")] + events: Option>, +} + +impl TrackerDto { + fn of(slug: &str, t: &Tracker, with_events: bool) -> Self { + let ev = t.events.as_deref().unwrap_or(&[]); + TrackerDto { + slug: slug.into(), + name: t.name.clone(), + count: ev.len(), + first_ts: ev.first().map_or(0, |e| e.ts), + last_ts: ev.last().map_or(0, |e| e.ts), + last_text: ev.last().map_or_else(String::new, |e| e.text.clone()), + events: (with_events && !ev.is_empty()).then(|| ev.to_vec()), + } + } +} + +// --------------------------------------------------------------------------- +// Routes +// --------------------------------------------------------------------------- + +fn fail(code: StatusCode, e: impl Into) -> Response { + send_json(code, &json!({ "ok": false, "error": e.into() })) +} + +async fn read_body(body: Incoming) -> Vec { + Limited::new(body, 8 << 20) + .collect() + .await + .map(|b| b.to_bytes().to_vec()) + .unwrap_or_default() +} + +/// Types des structs anonymes des handlers Go (messages d'erreur de type). +const MOVE_REQ_TYPE: &str = r#"struct { Name string "json:\"name\""; To string "json:\"to\"" }"#; +const TRACKER_REQ_TYPE: &str = r#"struct { Action string "json:\"action\""; Slug string "json:\"slug\""; Name string "json:\"name\""; When string "json:\"when\""; Text string "json:\"text\""; ID string "json:\"id\""; To string "json:\"to\"" }"#; + +const SAVE_REQ: &[(&str, Kind)] = &[ + ("name", Kind::Str), + ("old", Kind::Str), + ("content", Kind::Str), +]; + +/// `from_go` : remise par le Go (tunnel), déjà authentifiée par lui. +pub async fn serve( + ctx: &Ctx, + mem: &Memory, + req: Request, + from_go: bool, +) -> Response { + let (parts, body) = req.into_parts(); + if !from_go && let Err(refus) = ctx.guard(&parts.headers).await { + return *refus; + } + let store = &ctx.store; + let path = parts.uri.path(); + // `r.URL.Query().Get` : la première valeur d'un paramètre. + let mut query: HashMap = HashMap::new(); + for (k, v) in url::form_urlencoded::parse(parts.uri.query().unwrap_or("").as_bytes()) { + query + .entry(k.into_owned()) + .or_insert_with(|| v.into_owned()); + } + let active = active_project(store).await; + let dir = mem.project_dir(&active); + match path { + "/api/mem" => { + let name = go_trim(query.get("name").map_or("", String::as_str)).to_string(); + if name.is_empty() { + return send_json(StatusCode::OK, &json!({ "name": "", "content": NEW_PAGE })); + } + let c = Memory::content(store, &dir, &name).await; + if c.is_empty() { + return send_json(StatusCode::NOT_FOUND, &json!({ "error": "not found" })); + } + send_json(StatusCode::OK, &json!({ "name": name, "content": c })) + } + "/api/mem/save" => { + let d = gojson::decode_struct(&read_body(body).await, "saveReq", SAVE_REQ); + if let Some(e) = d.err { + return fail(StatusCode::BAD_REQUEST, e); + } + let pname = project_name(&list_projects(store).await, &active); + match Memory::save( + store, + &dir, + &pname, + &d.str("name"), + &d.str("old"), + &d.str("content"), + ) + .await + { + Ok(()) => send_json( + StatusCode::OK, + &json!({ "ok": true, "name": d.str("name") }), + ), + Err(e) => fail(StatusCode::BAD_REQUEST, e), + } + } + "/api/mem/delete" => { + let d = gojson::decode_struct(&read_body(body).await, "saveReq", SAVE_REQ); + if let Some(e) = d.err { + return fail(StatusCode::BAD_REQUEST, e); + } + match Memory::delete(store, &dir, &d.str("name")).await { + Ok(()) => send_json(StatusCode::OK, &json!({ "ok": true })), + Err(e) => fail(StatusCode::BAD_REQUEST, e), + } + } + "/api/mem/move" => { + if parts.method != Method::POST { + return fail(StatusCode::METHOD_NOT_ALLOWED, "méthode non autorisée"); + } + let d = gojson::decode_struct( + &read_body(body).await, + MOVE_REQ_TYPE, + &[("name", Kind::Str), ("to", Kind::Str)], + ); + if let Some(e) = d.err { + return fail(StatusCode::BAD_REQUEST, e); + } + match mem + .move_page(store, &d.str("name"), &active, go_trim(&d.str("to"))) + .await + { + Ok(()) => send_json(StatusCode::OK, &json!({ "ok": true })), + Err(e) => fail(StatusCode::BAD_REQUEST, e), + } + } + "/api/agent" | "/api/tools" | "/api/skills" => { + let pages: Vec = Memory::list(store, &dir) + .await + .into_iter() + .map(|(n, t)| json!({ "name": n, "desc": t })) + .collect(); + send_json( + StatusCode::OK, + &json!({ + "enabled": agent_enabled(store).await, + "compact": compact_enabled(store).await, + "mem_mode": Memory::mem_mode(store).await, + "pages": pages, + "skills": pages, + }), + ) + } + "/api/agent/toggle" | "/api/tools/toggle" | "/api/skills/toggle" => { + let d = gojson::decode_struct(&read_body(body).await, "", &[("on", Kind::Bool)]); + let res = if d.bool("on") { + store.put(BK_STATE, ST_AGENT, b"1".to_vec()).await + } else { + store.del(BK_STATE, ST_AGENT).await + }; + if let Err(e) = res { + return fail(StatusCode::INTERNAL_SERVER_ERROR, e); + } + send_json( + StatusCode::OK, + &json!({ "ok": true, "enabled": agent_enabled(store).await }), + ) + } + "/api/agent/compact" => { + let d = gojson::decode_struct(&read_body(body).await, "", &[("on", Kind::Bool)]); + let res = if d.bool("on") { + store.del(BK_CONFIG, "COMPACT").await + } else { + store.put(BK_CONFIG, "COMPACT", b"off".to_vec()).await + }; + if let Err(e) = res { + return fail(StatusCode::INTERNAL_SERVER_ERROR, e); + } + send_json( + StatusCode::OK, + &json!({ "ok": true, "compact": compact_enabled(store).await }), + ) + } + "/api/memory" => { + if parts.method == Method::POST { + let d = gojson::decode_struct(&read_body(body).await, "", &[("mode", Kind::Str)]); + if let Err(e) = + Memory::set_mem_mode(store, normalize_mem_mode(&d.str("mode"))).await + { + return fail(StatusCode::INTERNAL_SERVER_ERROR, e); + } + } + send_json( + StatusCode::OK, + &json!({ "ok": true, "mode": Memory::mem_mode(store).await }), + ) + } + "/api/tracker" => match parts.method { + Method::GET => { + let slug = go_trim(query.get("slug").map_or("", String::as_str)).to_string(); + if slug.is_empty() { + return send_json( + StatusCode::OK, + &json!({ "ok": true, "trackers": Memory::tracker_list(store, &active).await }), + ); + } + match Memory::tracker_load(store, &active, &slug).await { + Some(t) => send_json( + StatusCode::OK, + &json!({ "ok": true, "tracker": TrackerDto::of(&slug, &t, true) }), + ), + None => send_json( + StatusCode::NOT_FOUND, + &json!({ "ok": false, "error": "tracker introuvable" }), + ), + } + } + Method::POST => { + let d = gojson::decode_struct( + &read_body(body).await, + TRACKER_REQ_TYPE, + &[ + ("action", Kind::Str), + ("slug", Kind::Str), + ("name", Kind::Str), + ("when", Kind::Str), + ("text", Kind::Str), + ("id", Kind::Str), + ("to", Kind::Str), + ], + ); + if let Some(e) = d.err.clone() { + return fail(StatusCode::BAD_REQUEST, e); + } + if let Err(e) = mem.tracker_action(store, &active, &d).await { + return fail(StatusCode::BAD_REQUEST, e); + } + send_json( + StatusCode::OK, + &json!({ "ok": true, "trackers": Memory::tracker_list(store, &active).await }), + ) + } + _ => fail(StatusCode::METHOD_NOT_ALLOWED, "méthode non autorisée"), + }, + _ => fail(StatusCode::NOT_FOUND, "route inconnue"), + } +} + +/// `agentEnabled`. +async fn agent_enabled(store: &Store) -> bool { + store.get_str(BK_STATE, ST_AGENT).await == "1" +} + +/// `compactEnabled`. +async fn compact_enabled(store: &Store) -> bool { + !matches!( + go_trim( + store + .config() + .await + .get("COMPACT") + .map_or("", String::as_str) + ) + .to_lowercase() + .as_str(), + "off" | "false" | "0" | "no" | "non" + ) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn noms_et_titres() { + assert_eq!(mem_file_name(" notes ").unwrap(), "notes.md"); + assert_eq!(mem_file_name("A.MD").unwrap(), "A.MD"); + assert!(mem_file_name("a/b").is_err()); + assert_eq!(title_of("\n\n## Titre \nx"), "Titre"); + assert_eq!(slugify_ident("Réseau NAS"), "reseau-nas"); + assert_eq!(slugify_ident("İstanbul"), "istanbul"); + } + + #[test] + fn dates() { + let tz = Tz::Fixed(7200); + assert_eq!( + parse_when("2024-03-10", 0, tz).unwrap(), + (1_710_021_600_000, true) + ); + assert!(!parse_when("2024-03-10T08:15", 0, tz).unwrap().1); + assert!(parse_when("2024-02-30", 0, tz).is_err()); + assert_eq!(parse_when(" now ", 42, tz).unwrap(), (42, false)); + } +} diff --git a/rust/loki-rs/src/proxy.rs b/rust/loki-rs/src/proxy.rs index bd38044..71e77f4 100644 --- a/rust/loki-rs/src/proxy.rs +++ b/rust/loki-rs/src/proxy.rs @@ -75,6 +75,8 @@ pub struct Config { pub files: Option>, /// Notifications Web Push tenues par la façade (`LOKI_PUSH=rust`). pub push: Option>, + /// Mémoire (pages, trackers, mode) tenue par la façade (`LOKI_MEM=rust`). + pub mem: Option>, } struct Proxy { @@ -107,6 +109,7 @@ pub async fn serve( settings: cfg.settings, files: cfg.files, push: cfg.push, + mem: cfg.mem, internal_token: cfg.internal_token.clone(), }, }); @@ -194,6 +197,11 @@ impl Proxy { { return chatapi::serve(&self.ctx, chat, req, from_go).await; } + if let Some(m) = &self.ctx.mem + && crate::memory::handles(path) + { + return crate::memory::serve(&self.ctx, m, req, from_go).await; + } if let Some(p) = &self.ctx.push && crate::push::handles(path) { diff --git a/rust/loki-rs/tests/common/fakego.rs b/rust/loki-rs/tests/common/fakego.rs index 08a3b24..6f133d4 100644 --- a/rust/loki-rs/tests/common/fakego.rs +++ b/rust/loki-rs/tests/common/fakego.rs @@ -16,6 +16,18 @@ use tokio::sync::oneshot; pub const JETON: &str = "0123456789abcdef0123456789abcdef"; +/// Scellement simulé par le faux Go. +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] +pub enum CryptMode { + /// « SCELLE: » + clair (historique des essais de la conversation). + #[default] + Scelle, + /// Chiffrement coupé : rien ne change. + Plain, + /// Magic des pages mémoire (`LOKIMEMv1`) + clair. + Magic, +} + #[derive(Default)] pub struct FakeGo { /// bucket → clé → valeur. @@ -62,6 +74,8 @@ pub struct FakeGo { pub turn_view: Mutex>, pub turn_verifier: Mutex>, pub tool_results: Mutex>, + /// Comportement de /internal/v1/crypt/* (préfixe « SCELLE: » par défaut). + pub crypt_mode: Mutex, /// Réponse de /internal/v1/conv/workspace (None : 404). pub conv_workspace: Mutex>, } @@ -148,6 +162,24 @@ async fn handle(st: Arc, req: Request) -> Response return resp(423, r#"{"error":"mémoire verrouillée"}"#); } let body = req.into_body().collect().await.unwrap().to_bytes(); + match *st.crypt_mode.lock().unwrap() { + // Chiffrement coupé : clair rendu tel quel. + CryptMode::Plain => return resp(200, body), + // Enveloppe au magic des pages (`LOKIMEMv1`). + CryptMode::Magic => { + return if path.ends_with("seal") { + let mut out = b"LOKIMEMv1".to_vec(); + out.extend_from_slice(&body); + resp(200, out) + } else { + resp( + 200, + body.strip_prefix(b"LOKIMEMv1").unwrap_or(&body).to_vec(), + ) + }; + } + CryptMode::Scelle => {} + } if path.ends_with("seal") { let mut out = b"SCELLE:".to_vec(); out.extend_from_slice(&body); @@ -474,6 +506,7 @@ pub struct Opts { pub settings: bool, pub files: Option>, pub push: Option>, + pub mem: Option>, } /// Comme [`start`], avec la conversation tenue par la façade @@ -568,6 +601,7 @@ pub async fn start_with( settings: opts.settings, files: opts.files, push: opts.push, + mem: opts.mem, }; tokio::spawn(loki_rs::proxy::serve(fl, cfg, async move { let _ = rx.await; diff --git a/rust/loki-rs/tests/gojson.rs b/rust/loki-rs/tests/gojson.rs new file mode 100644 index 0000000..2870052 --- /dev/null +++ b/rust/loki-rs/tests/gojson.rs @@ -0,0 +1,41 @@ +//! Décodage JSON des requêtes au comportement d'`encoding/json` : le journal +//! tiré du Go (`internal/loki/parity_gojson_test.go` → +//! `tests/parity_gojson/decode.json`) est rejoué contre `gojson`. + +use loki_rs::gojson::{Kind, decode_struct}; +use serde_json::{Value, json}; + +const FIELDS: &[(&str, Kind)] = &[ + ("name", Kind::Str), + ("on", Kind::Bool), + ("n", Kind::Int), + ("args", Kind::StrList), + ("env", Kind::StrMap), + ("opt", Kind::OptStr), + ("flag", Kind::OptBool), +]; + +#[test] +fn parite_du_decodage() { + let fx: Value = + serde_json::from_str(include_str!("parity_gojson/decode.json")).expect("journal"); + for c in fx["cases"].as_array().unwrap() { + let input = c["in"].as_str().unwrap(); + let d = decode_struct(input.as_bytes(), "parityDecodeT", FIELDS); + let out = json!({ + "name": d.str("name"), + "on": d.bool("on"), + "n": d.int("n"), + "args": d.str_list("args"), + "env": d.str_map("env").map(|m| m.into_iter().map(|(k, v)| (k, Value::String(v))).collect::>()), + "opt": d.opt_str("opt"), + "flag": d.opt_bool("flag"), + }); + assert_eq!(out, c["out"], "{input:?} : champs"); + assert_eq!( + d.err.as_deref().unwrap_or(""), + c["err"].as_str().unwrap_or(""), + "{input:?} : erreur" + ); + } +} diff --git a/rust/loki-rs/tests/lot2.rs b/rust/loki-rs/tests/lot2.rs index 826f9a8..166e75e 100644 --- a/rust/loki-rs/tests/lot2.rs +++ b/rust/loki-rs/tests/lot2.rs @@ -99,6 +99,7 @@ async fn banc(cle: Option<&str>) -> Banc { settings: false, files: None, push: None, + mem: None, }; tokio::spawn(loki_rs::proxy::serve(l, cfg, async move { let _ = rx.await; diff --git a/rust/loki-rs/tests/memory.rs b/rust/loki-rs/tests/memory.rs new file mode 100644 index 0000000..071525d --- /dev/null +++ b/rust/loki-rs/tests/memory.rs @@ -0,0 +1,394 @@ +//! Lot 13 : mémoire tenue par la façade (`LOKI_MEM=rust`). +//! +//! Le journal tiré du Go (`internal/loki/parity_mem_test.go` → +//! `tests/parity_mem/mem.json`) est rejoué : noms et titres de pages, slugs, +//! modes mémoire, dates des trackers (fuseau fixe UTC+2), puis la suite +//! d'appels aux routes par la façade, avec après chacun le dossier memory/ et +//! les clés touchées en base. + +mod common; + +use std::collections::BTreeMap; +use std::path::Path; +use std::sync::Arc; + +use common::fakego::{self, CryptMode}; +use loki_rs::memory::{self, Memory, Tz}; +use serde_json::{Value, json}; + +fn fixture() -> Value { + serde_json::from_str(include_str!("parity_mem/mem.json")).expect("journal") +} + +fn now_ms() -> i64 { + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap() + .as_millis() as i64 +} + +/// Masque identifiants de points et instants « maintenant », dans l'ordre +/// d'apparition (même règle que le test Go). +#[derive(Default)] +struct Masker { + ids: Vec<(String, String)>, +} + +impl Masker { + fn mask(&mut self, s: &str) -> String { + let id_re = regex::Regex::new(r#""id":"(\d{15,})""#).unwrap(); + let mut out = String::new(); + let mut last = 0; + for c in id_re.captures_iter(s) { + let m = c.get(0).unwrap(); + let id = c[1].to_string(); + let ph = match self.ids.iter().find(|(i, _)| *i == id) { + Some((_, p)) => p.clone(), + None => { + let p = format!("{{{{ID{}}}}}", self.ids.len() + 1); + self.ids.push((id, p.clone())); + p + } + }; + out.push_str(&s[last..m.start()]); + out.push_str(&format!(r#""id":"{ph}""#)); + last = m.end(); + } + out.push_str(&s[last..]); + let now_re = regex::Regex::new(r#""(ts|first_ts|last_ts)":(\d{12,14})"#).unwrap(); + let now = now_ms(); + now_re + .replace_all(&out, |c: ®ex::Captures| { + let v: i64 = c[2].parse().unwrap(); + if (v - now).abs() < 86_400_000 { + format!(r#""{}":"{{{{NOW}}}}""#, &c[1]) + } else { + c[0].to_string() + } + }) + .into_owned() + } + + fn unmask(&self, s: &str) -> String { + let mut s = s.to_string(); + for (id, ph) in &self.ids { + s = s.replace(ph, id); + } + s + } + + fn canon(&mut self, v: &Value) -> Value { + serde_json::from_str(&self.mask(&serde_json::to_string(v).unwrap())).unwrap() + } +} + +#[test] +fn parite_des_fonctions_pures() { + let fx = fixture(); + for c in fx["names"].as_array().unwrap() { + let input = c["in"].as_str().unwrap(); + match memory::mem_file_name(input) { + Ok(o) => assert_eq!(o, c["out"].as_str().unwrap_or(""), "{input:?}"), + Err(e) => assert_eq!(e, c["err"].as_str().unwrap_or(""), "{input:?}"), + } + } + for (input, want) in fx["titles"].as_object().unwrap() { + assert_eq!(memory::title_of(input), want.as_str().unwrap(), "{input:?}"); + } + for (input, want) in fx["slugs"].as_object().unwrap() { + assert_eq!( + memory::slugify_ident(input), + want.as_str().unwrap(), + "{input:?}" + ); + } + for (input, want) in fx["modes"].as_object().unwrap() { + assert_eq!( + memory::normalize_mem_mode(input), + want.as_str().unwrap(), + "{input:?}" + ); + } + for c in fx["when"].as_array().unwrap() { + let input = c["in"].as_str().unwrap(); + let now = now_ms(); + match memory::parse_when(input, now, Tz::Fixed(7200)) { + Ok((ts, date_only)) => { + assert!(c.get("err").is_none(), "{input:?} : erreur attendue"); + let got = if (ts - now).abs() < 86_400_000 { + json!("{{NOW}}") + } else { + json!(ts) + }; + assert_eq!(got, c["ts"], "{input:?}"); + assert_eq!( + date_only, + c["date_only"].as_bool().unwrap_or(false), + "{input:?}" + ); + } + Err(e) => assert_eq!(e, c["err"].as_str().unwrap_or(""), "{input:?}"), + } + } +} + +fn apply_store(go: &fakego::FakeGo, m: &serde_json::Map) { + for (k, v) in m { + let (b, key) = k.split_once('/').unwrap(); + let v = v.as_str().unwrap(); + let mut s = go.store.lock().unwrap(); + let bucket = s.entry(b.to_string()).or_default(); + if v.is_empty() { + bucket.remove(key); + } else { + bucket.insert(key.to_string(), v.as_bytes().to_vec()); + } + } +} + +fn disk(root: &Path) -> BTreeMap { + fn walk(root: &Path, dir: &Path, out: &mut BTreeMap) { + let Ok(rd) = std::fs::read_dir(dir) else { + return; + }; + for e in rd.flatten() { + let p = e.path(); + if e.file_type().unwrap().is_dir() { + walk(root, &p, out); + } else { + let rel = p + .strip_prefix(root) + .unwrap() + .to_string_lossy() + .replace('\\', "/"); + out.insert( + rel, + String::from_utf8_lossy(&std::fs::read(&p).unwrap()).into_owned(), + ); + } + } + } + let mut out = BTreeMap::new(); + walk(root, root, &mut out); + out +} + +fn state(go: &fakego::FakeGo) -> Value { + let get = |b: &str, k: &str| go.get(b, k).unwrap_or_default(); + let projects = go + .get("state", "projects") + .and_then(|p| serde_json::from_str::(&p).ok()) + .unwrap_or(Value::Null); + let mut trackers = serde_json::Map::new(); + let mut raw = serde_json::Map::new(); + if let Some(b) = go.store.lock().unwrap().get("tracker") { + for (k, v) in b { + let s = String::from_utf8_lossy(v).into_owned(); + match serde_json::from_str::(&s) { + Ok(x) => { + trackers.insert(k.clone(), x); + } + Err(_) => { + raw.insert(k.clone(), Value::String(s)); + } + } + } + } + json!({ + "agent": get("state", "agent"), + "active": get("state", "active_project"), + "compact": get("config", "COMPACT"), + "mem_mode": get("config", "MEM_MODE"), + "projects": projects, + "trackers": trackers, + "tracker_raw": raw, + }) +} + +async fn start(home: &Path, tz: Tz) -> (std::net::SocketAddr, Arc, impl Drop) { + let opts = fakego::Opts { + mem: Some(Arc::new(Memory::with_tz(home.to_path_buf(), tz))), + ..Default::default() + }; + let (facade, go, stop, _) = fakego::start_with(home.to_path_buf(), opts).await; + struct Stop(Option>); + impl Drop for Stop { + fn drop(&mut self) { + if let Some(s) = self.0.take() { + let _ = s.send(()); + } + } + } + (facade, go, Stop(Some(stop))) +} + +#[tokio::test] +async fn parite_des_routes() { + let fx = fixture(); + let tmp = tempfile::tempdir().unwrap(); + let home = tmp.path().to_path_buf(); + let (facade, go, _stop) = start(&home, Tz::Fixed(7200)).await; + *go.crypt_mode.lock().unwrap() = CryptMode::Plain; + apply_store(&go, fx["seed"].as_object().unwrap()); + let root = home.join("memory"); + let mut mk = Masker::default(); + for (i, st) in fx["steps"].as_array().unwrap().iter().enumerate() { + if let Some(files) = st.get("files").and_then(Value::as_object) { + for (rel, content) in files { + let p = root.join(rel); + let content = content.as_str().unwrap(); + if content.is_empty() { + let _ = std::fs::remove_dir_all(&p); + let _ = std::fs::remove_file(&p); + } else if rel.ends_with('/') { + std::fs::create_dir_all(&p).unwrap(); + } else { + std::fs::create_dir_all(p.parent().unwrap()).unwrap(); + std::fs::write(&p, content).unwrap(); + } + } + } + if let Some(s) = st.get("store").and_then(Value::as_object) { + apply_store(&go, s); + } + let (m, p) = (st["method"].as_str().unwrap(), st["path"].as_str().unwrap()); + let body = st.get("body").and_then(Value::as_str).unwrap_or(""); + let (path, body) = (mk.unmask(p), mk.unmask(body)); + let (code, b) = fakego::send(facade, m, &path, &[], &body).await; + let ctx = format!("étape {i} : {m} {p} {body}"); + assert_eq!( + u64::from(code.as_u16()), + st["status"].as_u64().unwrap(), + "{ctx} : {}", + String::from_utf8_lossy(&b) + ); + let got: Value = serde_json::from_slice(&b).unwrap(); + assert_eq!(mk.canon(&got), st["out"], "{ctx}"); + let want_disk: BTreeMap = + serde_json::from_value(st["disk"].clone()).unwrap(); + assert_eq!(disk(&root), want_disk, "{ctx} (disque)"); + assert_eq!(mk.canon(&state(&go)), st["state"], "{ctx} (base)"); + } +} + +/// Pages chiffrées : ouvertes par le Go, écrites scellées ; mémoire +/// verrouillée : listées sans contenu, jamais réécrites en clair. +#[tokio::test] +async fn pages_et_trackers_chiffres() { + let tmp = tempfile::tempdir().unwrap(); + let home = tmp.path().to_path_buf(); + let (facade, go, _stop) = start(&home, Tz::Fixed(0)).await; + *go.crypt_mode.lock().unwrap() = CryptMode::Magic; + let dir = home.join("memory").join("generale"); + let (code, _) = fakego::send( + facade, + "POST", + "/api/mem/save", + &[], + r##"{"name":"secret","content":"# Secret\nmot de passe"}"##, + ) + .await; + assert_eq!(code, 200); + let raw = std::fs::read(dir.join("secret.md")).unwrap(); + assert_eq!(raw, b"LOKIMEMv1# Secret\nmot de passe\n"); + // L'index, lui, reste en clair (comme le Go). + let idx = std::fs::read_to_string(dir.join("MEMORY.md")).unwrap(); + assert!(idx.ends_with("- [Secret](secret.md)\n"), "{idx}"); + let (_, b) = fakego::send(facade, "GET", "/api/mem?name=secret", &[], "").await; + let v: Value = serde_json::from_slice(&b).unwrap(); + assert_eq!(v["content"], "# Secret\nmot de passe\n"); + let (_, b) = fakego::send( + facade, + "POST", + "/api/tracker", + &[], + r#"{"action":"add","name":"Tension","when":"2024-01-02","text":"12/8"}"#, + ) + .await; + assert_eq!(serde_json::from_slice::(&b).unwrap()["ok"], true); + let sealed = go.get("tracker", "generale/tension").unwrap(); + assert!(sealed.starts_with("LOKIMEMv1{"), "{sealed}"); + + // Verrouillée. + *go.locked.lock().unwrap() = true; + let (_, b) = fakego::send(facade, "GET", "/api/agent", &[], "").await; + let v: Value = serde_json::from_slice(&b).unwrap(); + let pages = v["pages"].as_array().unwrap(); + let secret = pages.iter().find(|p| p["name"] == "secret.md").unwrap(); + assert_eq!(secret["desc"], "🔒 (chiffré — mémoire verrouillée)"); + let (code, _) = fakego::send(facade, "GET", "/api/mem?name=secret", &[], "").await; + assert_eq!(code, 404); + let (code, b) = fakego::send( + facade, + "POST", + "/api/mem/save", + &[], + r#"{"name":"secret","content":"écrasé"}"#, + ) + .await; + assert_eq!(code, 400); + assert_eq!( + serde_json::from_slice::(&b).unwrap()["error"], + "page chiffrée, mémoire verrouillée" + ); + assert_eq!( + std::fs::read(dir.join("secret.md")).unwrap(), + raw, + "page réécrite alors que la mémoire est verrouillée" + ); + let (code, b) = fakego::send( + facade, + "POST", + "/api/tracker", + &[], + r#"{"action":"add","name":"Autre","text":"x"}"#, + ) + .await; + assert_eq!(code, 400); + assert_eq!( + serde_json::from_slice::(&b).unwrap()["error"], + "valeur chiffrée, mémoire verrouillée" + ); + // Les trackers scellés ne sont plus lisibles : la liste les tait. + let (_, b) = fakego::send(facade, "GET", "/api/tracker", &[], "").await; + assert_eq!( + serde_json::from_slice::(&b).unwrap()["trackers"], + json!([]) + ); +} + +#[tokio::test] +async fn sans_la_variable_la_memoire_reste_au_go() { + let tmp = tempfile::tempdir().unwrap(); + let (facade, _go, _stop) = fakego::start(tmp.path().to_path_buf()).await; + for p in [ + "/api/mem", + "/api/mem/save", + "/api/mem/move", + "/api/agent", + "/api/skills/toggle", + "/api/memory", + "/api/tracker", + ] { + let (_, _, b) = fakego::get(facade, p, &[]).await; + assert_eq!(&b[..], format!("go:{p}").as_bytes()); + } +} + +/// Le coffre, les instantanés et les projets restent au Go même avec la +/// variable. +#[tokio::test] +async fn coffre_et_projets_restent_au_go() { + let tmp = tempfile::tempdir().unwrap(); + let (facade, _go, _stop) = start(tmp.path(), Tz::Local).await; + for p in [ + "/api/mem/health", + "/api/mem/unlock", + "/api/mem/snapshots", + "/api/projects", + "/api/backup/export", + ] { + let (_, _, b) = fakego::get(facade, p, &[]).await; + assert_eq!(&b[..], format!("go:{p}").as_bytes()); + } +} diff --git a/rust/loki-rs/tests/parity_gojson/decode.json b/rust/loki-rs/tests/parity_gojson/decode.json new file mode 100644 index 0000000..570cb2b --- /dev/null +++ b/rust/loki-rs/tests/parity_gojson/decode.json @@ -0,0 +1,1052 @@ +{ + "cases": [ + { + "in": "", + "out": { + "name": "", + "on": false, + "n": 0, + "args": null, + "env": null, + "opt": null, + "flag": null + }, + "err": "EOF" + }, + { + "in": " ", + "out": { + "name": "", + "on": false, + "n": 0, + "args": null, + "env": null, + "opt": null, + "flag": null + }, + "err": "EOF" + }, + { + "in": "\n\t", + "out": { + "name": "", + "on": false, + "n": 0, + "args": null, + "env": null, + "opt": null, + "flag": null + }, + "err": "EOF" + }, + { + "in": "pas du json", + "out": { + "name": "", + "on": false, + "n": 0, + "args": null, + "env": null, + "opt": null, + "flag": null + }, + "err": "invalid character 'p' looking for beginning of value" + }, + { + "in": "{", + "out": { + "name": "", + "on": false, + "n": 0, + "args": null, + "env": null, + "opt": null, + "flag": null + }, + "err": "unexpected EOF" + }, + { + "in": "{\"name\"", + "out": { + "name": "", + "on": false, + "n": 0, + "args": null, + "env": null, + "opt": null, + "flag": null + }, + "err": "unexpected EOF" + }, + { + "in": "{\"name\":", + "out": { + "name": "", + "on": false, + "n": 0, + "args": null, + "env": null, + "opt": null, + "flag": null + }, + "err": "unexpected EOF" + }, + { + "in": "{\"name\":\"a\"", + "out": { + "name": "", + "on": false, + "n": 0, + "args": null, + "env": null, + "opt": null, + "flag": null + }, + "err": "unexpected EOF" + }, + { + "in": "{\"name\":\"a\",}", + "out": { + "name": "", + "on": false, + "n": 0, + "args": null, + "env": null, + "opt": null, + "flag": null + }, + "err": "invalid character '}' looking for beginning of object key string" + }, + { + "in": "{\"name\" \"a\"}", + "out": { + "name": "", + "on": false, + "n": 0, + "args": null, + "env": null, + "opt": null, + "flag": null + }, + "err": "invalid character '\"' after object key" + }, + { + "in": "{\"name\":\"a\" \"on\":true}", + "out": { + "name": "", + "on": false, + "n": 0, + "args": null, + "env": null, + "opt": null, + "flag": null + }, + "err": "invalid character '\"' after object key:value pair" + }, + { + "in": "{name:\"a\"}", + "out": { + "name": "", + "on": false, + "n": 0, + "args": null, + "env": null, + "opt": null, + "flag": null + }, + "err": "invalid character 'n' looking for beginning of object key string" + }, + { + "in": "{'name':\"a\"}", + "out": { + "name": "", + "on": false, + "n": 0, + "args": null, + "env": null, + "opt": null, + "flag": null + }, + "err": "invalid character '\\'' looking for beginning of object key string" + }, + { + "in": "[", + "out": { + "name": "", + "on": false, + "n": 0, + "args": null, + "env": null, + "opt": null, + "flag": null + }, + "err": "unexpected EOF" + }, + { + "in": "[1,]", + "out": { + "name": "", + "on": false, + "n": 0, + "args": null, + "env": null, + "opt": null, + "flag": null + }, + "err": "invalid character ']' looking for beginning of value" + }, + { + "in": "[1 2]", + "out": { + "name": "", + "on": false, + "n": 0, + "args": null, + "env": null, + "opt": null, + "flag": null + }, + "err": "invalid character '2' after array element" + }, + { + "in": "{\"name\":\"a\"} et la suite", + "out": { + "name": "a", + "on": false, + "n": 0, + "args": null, + "env": null, + "opt": null, + "flag": null + } + }, + { + "in": "{\"name\":\"a\"}{\"name\":\"b\"}", + "out": { + "name": "a", + "on": false, + "n": 0, + "args": null, + "env": null, + "opt": null, + "flag": null + } + }, + { + "in": "null", + "out": { + "name": "", + "on": false, + "n": 0, + "args": null, + "env": null, + "opt": null, + "flag": null + } + }, + { + "in": "true", + "out": { + "name": "", + "on": false, + "n": 0, + "args": null, + "env": null, + "opt": null, + "flag": null + }, + "err": "json: cannot unmarshal bool into Go value of type loki.parityDecodeT" + }, + { + "in": "\"texte\"", + "out": { + "name": "", + "on": false, + "n": 0, + "args": null, + "env": null, + "opt": null, + "flag": null + }, + "err": "json: cannot unmarshal string into Go value of type loki.parityDecodeT" + }, + { + "in": "12", + "out": { + "name": "", + "on": false, + "n": 0, + "args": null, + "env": null, + "opt": null, + "flag": null + }, + "err": "json: cannot unmarshal number into Go value of type loki.parityDecodeT" + }, + { + "in": "[1,2]", + "out": { + "name": "", + "on": false, + "n": 0, + "args": null, + "env": null, + "opt": null, + "flag": null + }, + "err": "json: cannot unmarshal array into Go value of type loki.parityDecodeT" + }, + { + "in": "{\"name\":5}", + "out": { + "name": "", + "on": false, + "n": 0, + "args": null, + "env": null, + "opt": null, + "flag": null + }, + "err": "json: cannot unmarshal number into Go struct field parityDecodeT.name of type string" + }, + { + "in": "{\"name\":true}", + "out": { + "name": "", + "on": false, + "n": 0, + "args": null, + "env": null, + "opt": null, + "flag": null + }, + "err": "json: cannot unmarshal bool into Go struct field parityDecodeT.name of type string" + }, + { + "in": "{\"name\":{}}", + "out": { + "name": "", + "on": false, + "n": 0, + "args": null, + "env": null, + "opt": null, + "flag": null + }, + "err": "json: cannot unmarshal object into Go struct field parityDecodeT.name of type string" + }, + { + "in": "{\"name\":[]}", + "out": { + "name": "", + "on": false, + "n": 0, + "args": null, + "env": null, + "opt": null, + "flag": null + }, + "err": "json: cannot unmarshal array into Go struct field parityDecodeT.name of type string" + }, + { + "in": "{\"name\":null}", + "out": { + "name": "", + "on": false, + "n": 0, + "args": null, + "env": null, + "opt": null, + "flag": null + } + }, + { + "in": "{\"NAME\":\"maj\"}", + "out": { + "name": "maj", + "on": false, + "n": 0, + "args": null, + "env": null, + "opt": null, + "flag": null + } + }, + { + "in": "{\"Name\":\"a\",\"name\":\"b\"}", + "out": { + "name": "b", + "on": false, + "n": 0, + "args": null, + "env": null, + "opt": null, + "flag": null + } + }, + { + "in": "{\"on\":\"oui\"}", + "out": { + "name": "", + "on": false, + "n": 0, + "args": null, + "env": null, + "opt": null, + "flag": null + }, + "err": "json: cannot unmarshal string into Go struct field parityDecodeT.on of type bool" + }, + { + "in": "{\"on\":1}", + "out": { + "name": "", + "on": false, + "n": 0, + "args": null, + "env": null, + "opt": null, + "flag": null + }, + "err": "json: cannot unmarshal number into Go struct field parityDecodeT.on of type bool" + }, + { + "in": "{\"on\":true,\"name\":3,\"n\":4}", + "out": { + "name": "", + "on": true, + "n": 4, + "args": null, + "env": null, + "opt": null, + "flag": null + }, + "err": "json: cannot unmarshal number into Go struct field parityDecodeT.name of type string" + }, + { + "in": "{\"n\":1.5}", + "out": { + "name": "", + "on": false, + "n": 0, + "args": null, + "env": null, + "opt": null, + "flag": null + }, + "err": "json: cannot unmarshal number 1.5 into Go struct field parityDecodeT.n of type int" + }, + { + "in": "{\"n\":\"3\"}", + "out": { + "name": "", + "on": false, + "n": 0, + "args": null, + "env": null, + "opt": null, + "flag": null + }, + "err": "json: cannot unmarshal string into Go struct field parityDecodeT.n of type int" + }, + { + "in": "{\"n\":-7}", + "out": { + "name": "", + "on": false, + "n": -7, + "args": null, + "env": null, + "opt": null, + "flag": null + } + }, + { + "in": "{\"n\":1e2}", + "out": { + "name": "", + "on": false, + "n": 0, + "args": null, + "env": null, + "opt": null, + "flag": null + }, + "err": "json: cannot unmarshal number 1e2 into Go struct field parityDecodeT.n of type int" + }, + { + "in": "{\"args\":[\"a\",\"b\"]}", + "out": { + "name": "", + "on": false, + "n": 0, + "args": [ + "a", + "b" + ], + "env": null, + "opt": null, + "flag": null + } + }, + { + "in": "{\"args\":[\"a\",1,\"c\"]}", + "out": { + "name": "", + "on": false, + "n": 0, + "args": [ + "a", + "", + "c" + ], + "env": null, + "opt": null, + "flag": null + }, + "err": "json: cannot unmarshal number into Go struct field parityDecodeT.args of type string" + }, + { + "in": "{\"args\":\"x\"}", + "out": { + "name": "", + "on": false, + "n": 0, + "args": null, + "env": null, + "opt": null, + "flag": null + }, + "err": "json: cannot unmarshal string into Go struct field parityDecodeT.args of type []string" + }, + { + "in": "{\"args\":[]}", + "out": { + "name": "", + "on": false, + "n": 0, + "args": [], + "env": null, + "opt": null, + "flag": null + } + }, + { + "in": "{\"args\":[null]}", + "out": { + "name": "", + "on": false, + "n": 0, + "args": [ + "" + ], + "env": null, + "opt": null, + "flag": null + } + }, + { + "in": "{\"env\":{\"A\":\"1\",\"B\":2}}", + "out": { + "name": "", + "on": false, + "n": 0, + "args": null, + "env": { + "A": "1", + "B": "" + }, + "opt": null, + "flag": null + }, + "err": "json: cannot unmarshal number into Go struct field parityDecodeT.env of type string" + }, + { + "in": "{\"env\":[]}", + "out": { + "name": "", + "on": false, + "n": 0, + "args": null, + "env": null, + "opt": null, + "flag": null + }, + "err": "json: cannot unmarshal array into Go struct field parityDecodeT.env of type map[string]string" + }, + { + "in": "{\"opt\":\"\"}", + "out": { + "name": "", + "on": false, + "n": 0, + "args": null, + "env": null, + "opt": "", + "flag": null + } + }, + { + "in": "{\"opt\":null}", + "out": { + "name": "", + "on": false, + "n": 0, + "args": null, + "env": null, + "opt": null, + "flag": null + } + }, + { + "in": "{\"opt\":\"x\",\"flag\":false}", + "out": { + "name": "", + "on": false, + "n": 0, + "args": null, + "env": null, + "opt": "x", + "flag": false + } + }, + { + "in": "{\"flag\":\"non\"}", + "out": { + "name": "", + "on": false, + "n": 0, + "args": null, + "env": null, + "opt": null, + "flag": false + }, + "err": "json: cannot unmarshal string into Go struct field parityDecodeT.flag of type bool" + }, + { + "in": "{\"name\":\"été\"}", + "out": { + "name": "été", + "on": false, + "n": 0, + "args": null, + "env": null, + "opt": null, + "flag": null + } + }, + { + "in": "{\"name\":\"a\\nb\"}", + "out": { + "name": "a\nb", + "on": false, + "n": 0, + "args": null, + "env": null, + "opt": null, + "flag": null + } + }, + { + "in": "{\"name\":\"a\u0001b\"}", + "out": { + "name": "", + "on": false, + "n": 0, + "args": null, + "env": null, + "opt": null, + "flag": null + }, + "err": "invalid character '\\x01' in string literal" + }, + { + "in": "{\"name\":\"\\x\"}", + "out": { + "name": "", + "on": false, + "n": 0, + "args": null, + "env": null, + "opt": null, + "flag": null + }, + "err": "invalid character 'x' in string escape code" + }, + { + "in": "{\"name\":\"\\u12G4\"}", + "out": { + "name": "", + "on": false, + "n": 0, + "args": null, + "env": null, + "opt": null, + "flag": null + }, + "err": "invalid character 'G' in \\u hexadecimal character escape" + }, + { + "in": "tru", + "out": { + "name": "", + "on": false, + "n": 0, + "args": null, + "env": null, + "opt": null, + "flag": null + }, + "err": "unexpected EOF" + }, + { + "in": "trux", + "out": { + "name": "", + "on": false, + "n": 0, + "args": null, + "env": null, + "opt": null, + "flag": null + }, + "err": "invalid character 'x' in literal true (expecting 'e')" + }, + { + "in": "nul", + "out": { + "name": "", + "on": false, + "n": 0, + "args": null, + "env": null, + "opt": null, + "flag": null + }, + "err": "unexpected EOF" + }, + { + "in": "nulx", + "out": { + "name": "", + "on": false, + "n": 0, + "args": null, + "env": null, + "opt": null, + "flag": null + }, + "err": "invalid character 'x' in literal null (expecting 'l')" + }, + { + "in": "fals", + "out": { + "name": "", + "on": false, + "n": 0, + "args": null, + "env": null, + "opt": null, + "flag": null + }, + "err": "unexpected EOF" + }, + { + "in": "falsy", + "out": { + "name": "", + "on": false, + "n": 0, + "args": null, + "env": null, + "opt": null, + "flag": null + }, + "err": "invalid character 'y' in literal false (expecting 'e')" + }, + { + "in": "-", + "out": { + "name": "", + "on": false, + "n": 0, + "args": null, + "env": null, + "opt": null, + "flag": null + }, + "err": "unexpected EOF" + }, + { + "in": "-x", + "out": { + "name": "", + "on": false, + "n": 0, + "args": null, + "env": null, + "opt": null, + "flag": null + }, + "err": "invalid character 'x' in numeric literal" + }, + { + "in": "1.", + "out": { + "name": "", + "on": false, + "n": 0, + "args": null, + "env": null, + "opt": null, + "flag": null + }, + "err": "unexpected EOF" + }, + { + "in": "1.x", + "out": { + "name": "", + "on": false, + "n": 0, + "args": null, + "env": null, + "opt": null, + "flag": null + }, + "err": "invalid character 'x' after decimal point in numeric literal" + }, + { + "in": "1e", + "out": { + "name": "", + "on": false, + "n": 0, + "args": null, + "env": null, + "opt": null, + "flag": null + }, + "err": "unexpected EOF" + }, + { + "in": "1ex", + "out": { + "name": "", + "on": false, + "n": 0, + "args": null, + "env": null, + "opt": null, + "flag": null + }, + "err": "invalid character 'x' in exponent of numeric literal" + }, + { + "in": "{\"a\":1.}", + "out": { + "name": "", + "on": false, + "n": 0, + "args": null, + "env": null, + "opt": null, + "flag": null + }, + "err": "invalid character '}' after decimal point in numeric literal" + }, + { + "in": "{\"a\":01}", + "out": { + "name": "", + "on": false, + "n": 0, + "args": null, + "env": null, + "opt": null, + "flag": null + }, + "err": "invalid character '1' after object key:value pair" + }, + { + "in": "{\"a\":-}", + "out": { + "name": "", + "on": false, + "n": 0, + "args": null, + "env": null, + "opt": null, + "flag": null + }, + "err": "invalid character '}' in numeric literal" + }, + { + "in": "[1,2,3}", + "out": { + "name": "", + "on": false, + "n": 0, + "args": null, + "env": null, + "opt": null, + "flag": null + }, + "err": "invalid character '}' after array element" + }, + { + "in": "{\"a\":1]", + "out": { + "name": "", + "on": false, + "n": 0, + "args": null, + "env": null, + "opt": null, + "flag": null + }, + "err": "invalid character ']' after object key:value pair" + }, + { + "in": "\u0001", + "out": { + "name": "", + "on": false, + "n": 0, + "args": null, + "env": null, + "opt": null, + "flag": null + }, + "err": "invalid character '\\x01' looking for beginning of value" + }, + { + "in": "", + "out": { + "name": "", + "on": false, + "n": 0, + "args": null, + "env": null, + "opt": null, + "flag": null + }, + "err": "invalid character '\\x7f' looking for beginning of value" + }, + { + "in": "é", + "out": { + "name": "", + "on": false, + "n": 0, + "args": null, + "env": null, + "opt": null, + "flag": null + }, + "err": "invalid character 'Ã' looking for beginning of value" + }, + { + "in": "'x'", + "out": { + "name": "", + "on": false, + "n": 0, + "args": null, + "env": null, + "opt": null, + "flag": null + }, + "err": "invalid character '\\'' looking for beginning of value" + }, + { + "in": "\"x", + "out": { + "name": "", + "on": false, + "n": 0, + "args": null, + "env": null, + "opt": null, + "flag": null + }, + "err": "unexpected EOF" + }, + { + "in": "{\"a\":\"b\"}}", + "out": { + "name": "", + "on": false, + "n": 0, + "args": null, + "env": null, + "opt": null, + "flag": null + } + }, + { + "in": "}", + "out": { + "name": "", + "on": false, + "n": 0, + "args": null, + "env": null, + "opt": null, + "flag": null + }, + "err": "invalid character '}' looking for beginning of value" + }, + { + "in": "]", + "out": { + "name": "", + "on": false, + "n": 0, + "args": null, + "env": null, + "opt": null, + "flag": null + }, + "err": "invalid character ']' looking for beginning of value" + }, + { + "in": ",", + "out": { + "name": "", + "on": false, + "n": 0, + "args": null, + "env": null, + "opt": null, + "flag": null + }, + "err": "invalid character ',' looking for beginning of value" + }, + { + "in": ":", + "out": { + "name": "", + "on": false, + "n": 0, + "args": null, + "env": null, + "opt": null, + "flag": null + }, + "err": "invalid character ':' looking for beginning of value" + }, + { + "in": "{\"a\" : [ { \"b\" : null } ] , \"name\" : \"ok\" }", + "out": { + "name": "ok", + "on": false, + "n": 0, + "args": null, + "env": null, + "opt": null, + "flag": null + } + } + ] +} diff --git a/rust/loki-rs/tests/parity_mem/mem.json b/rust/loki-rs/tests/parity_mem/mem.json new file mode 100644 index 0000000..ae37f42 --- /dev/null +++ b/rust/loki-rs/tests/parity_mem/mem.json @@ -0,0 +1,7597 @@ +{ + "names": [ + { + "in": "", + "err": "nom vide" + }, + { + "in": " ", + "err": "nom vide" + }, + { + "in": "notes", + "out": "notes.md" + }, + { + "in": "notes.md", + "out": "notes.md" + }, + { + "in": "NOTES.MD", + "out": "NOTES.MD" + }, + { + "in": "a b", + "err": "nom invalide (alphanum, ._-)" + }, + { + "in": "../x", + "err": "nom invalide (alphanum, ._-)" + }, + { + "in": "x/y", + "err": "nom invalide (alphanum, ._-)" + }, + { + "in": "é", + "err": "nom invalide (alphanum, ._-)" + }, + { + "in": "a.b-c_d", + "out": "a.b-c_d.md" + }, + { + "in": ".md", + "out": ".md" + }, + { + "in": ".", + "out": "..md" + }, + { + "in": "..", + "out": "...md" + }, + { + "in": "x.md.md", + "out": "x.md.md" + }, + { + "in": " pad ", + "out": "pad.md" + }, + { + "in": "tab\t", + "out": "tab.md" + }, + { + "in": "MEMORY", + "out": "MEMORY.md" + }, + { + "in": "memory.md", + "out": "memory.md" + }, + { + "in": "x.Md", + "out": "x.Md" + } + ], + "titles": { + "": "", + "\t# tab": "tab", + "\n\n": "", + "\n \n# A\n# B": "A", + " ### ": "", + "#\n#\nVrai titre": "Vrai titre", + "# # double": "# double", + "# Titre": "Titre", + "#  espace insécable ": "espace insécable", + "## Titre ##": "Titre ##", + "sans dièse\nsuite": "sans dièse" + }, + "slugs": { + "": "", + " Hello World ": "hello-world", + "!!!": "", + "---x---": "x", + "Perso & Cie": "perso-cie", + "Réseau NAS": "reseau-nas", + "Straße": "stra-e", + "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa-bcd": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "abababababababababababababababababababababababababababababab": "abababababababababababababababababababab", + "Ça_va?": "ca-va", + "ÉCOLE": "ecole", + "ÎLE ÔÔ ÙÛ Ñ Ç": "ile-oo-uu-n-c", + "àâäáã éèêë": "aaaaa-eeee", + "İstanbul": "istanbul", + "Ωmega": "mega" + }, + "modes": { + "": "always", + " OFF ": "off", + "always": "always", + "auto": "always", + "bizarre": "always", + "off": "off", + "on-demand": "always", + "ondemand": "ondemand", + "search": "search" + }, + "when": [ + { + "in": "", + "ts": "{{NOW}}" + }, + { + "in": "now", + "ts": "{{NOW}}" + }, + { + "in": "NOW", + "ts": "{{NOW}}" + }, + { + "in": " Maintenant ", + "ts": "{{NOW}}" + }, + { + "in": "2024-03-10", + "ts": 1710021600000, + "date_only": true + }, + { + "in": "2024-03-10T08:15", + "ts": 1710051300000 + }, + { + "in": "2024-03-10 7:05", + "ts": 1710047100000 + }, + { + "in": "2024-03-10 07:5", + "err": "date non comprise : \"2024-03-10 07:5\" (attendu AAAA, AAAA-MM, AAAA-MM-JJ ou AAAA-MM-JJ HH:MM)" + }, + { + "in": "2024-03-10 07:05:09", + "ts": 1710047109000 + }, + { + "in": "2024-03-10 07:05:09.5", + "ts": 1710047109500 + }, + { + "in": "2024-03-10 07:05:09,123456789999", + "ts": 1710047109123 + }, + { + "in": "2024-03-10 24:00", + "err": "date non comprise : \"2024-03-10 24:00\" (attendu AAAA, AAAA-MM, AAAA-MM-JJ ou AAAA-MM-JJ HH:MM)" + }, + { + "in": "2024-03-10 23:60", + "err": "date non comprise : \"2024-03-10 23:60\" (attendu AAAA, AAAA-MM, AAAA-MM-JJ ou AAAA-MM-JJ HH:MM)" + }, + { + "in": "2024-13-01", + "err": "date non comprise : \"2024-13-01\" (attendu AAAA, AAAA-MM, AAAA-MM-JJ ou AAAA-MM-JJ HH:MM)" + }, + { + "in": "2024-02-29", + "ts": 1709157600000, + "date_only": true + }, + { + "in": "2023-02-29", + "err": "date non comprise : \"2023-02-29\" (attendu AAAA, AAAA-MM, AAAA-MM-JJ ou AAAA-MM-JJ HH:MM)" + }, + { + "in": "2024-04-31", + "err": "date non comprise : \"2024-04-31\" (attendu AAAA, AAAA-MM, AAAA-MM-JJ ou AAAA-MM-JJ HH:MM)" + }, + { + "in": "2024-03", + "ts": 1709244000000, + "date_only": true + }, + { + "in": "2024", + "ts": 1704060000000, + "date_only": true + }, + { + "in": "24", + "err": "date non comprise : \"24\" (attendu AAAA, AAAA-MM, AAAA-MM-JJ ou AAAA-MM-JJ HH:MM)" + }, + { + "in": "2024-3", + "err": "date non comprise : \"2024-3\" (attendu AAAA, AAAA-MM, AAAA-MM-JJ ou AAAA-MM-JJ HH:MM)" + }, + { + "in": "2024-03-1", + "err": "date non comprise : \"2024-03-1\" (attendu AAAA, AAAA-MM, AAAA-MM-JJ ou AAAA-MM-JJ HH:MM)" + }, + { + "in": " 2024-03-10 ", + "ts": 1710021600000, + "date_only": true + }, + { + "in": "2024-03-10 07:05", + "ts": 1710047100000 + }, + { + "in": "demain", + "err": "date non comprise : \"demain\" (attendu AAAA, AAAA-MM, AAAA-MM-JJ ou AAAA-MM-JJ HH:MM)" + }, + { + "in": "2024-03-10T07:05Z", + "err": "date non comprise : \"2024-03-10T07:05Z\" (attendu AAAA, AAAA-MM, AAAA-MM-JJ ou AAAA-MM-JJ HH:MM)" + }, + { + "in": "today", + "err": "date non comprise : \"today\" (attendu AAAA, AAAA-MM, AAAA-MM-JJ ou AAAA-MM-JJ HH:MM)" + }, + { + "in": "+024", + "err": "date non comprise : \"+024\" (attendu AAAA, AAAA-MM, AAAA-MM-JJ ou AAAA-MM-JJ HH:MM)" + }, + { + "in": "0000-01-01", + "ts": -62167226400000, + "date_only": true + }, + { + "in": "9999-12-31 23:59:59", + "ts": 253402293599000 + }, + { + "in": "1234", + "err": "date non comprise : \"1234\" (attendu AAAA, AAAA-MM, AAAA-MM-JJ ou AAAA-MM-JJ HH:MM)" + }, + { + "in": "2024-03-10 07", + "ts": 1710046800000 + }, + { + "in": "2024-03-10 7", + "ts": 1710046800000 + }, + { + "in": "x\u0001y", + "err": "date non comprise : \"x\\x01y\" (attendu AAAA, AAAA-MM, AAAA-MM-JJ ou AAAA-MM-JJ HH:MM)" + }, + { + "in": "2024-03-10T", + "err": "date non comprise : \"2024-03-10T\" (attendu AAAA, AAAA-MM, AAAA-MM-JJ ou AAAA-MM-JJ HH:MM)" + }, + { + "in": "2024-03-10 07:05:60", + "err": "date non comprise : \"2024-03-10 07:05:60\" (attendu AAAA, AAAA-MM, AAAA-MM-JJ ou AAAA-MM-JJ HH:MM)" + } + ], + "seed": { + "config/MEM_MODE": "ondemand", + "state/active_project": "generale", + "state/projects": "[{\"slug\":\"generale\",\"name\":\"Générale\",\"created_at\":1000},{\"slug\":\"perso\",\"name\":\"Perso & Cie\",\"created_at\":2000,\"desc\":\"à moi\"}]" + }, + "steps": [ + { + "method": "GET", + "path": "/api/mem", + "status": 200, + "out": { + "content": "# nouvelle page\n\nNote ici ce que loki doit retenir entre les sessions.\n", + "name": "" + }, + "disk": {}, + "state": { + "active": "generale", + "agent": "", + "compact": "", + "mem_mode": "ondemand", + "projects": [ + { + "created_at": 1000, + "name": "Générale", + "slug": "generale" + }, + { + "created_at": 2000, + "desc": "à moi", + "name": "Perso & Cie", + "slug": "perso" + } + ], + "tracker_raw": {}, + "trackers": {} + } + }, + { + "method": "GET", + "path": "/api/mem?name=absente", + "status": 404, + "out": { + "error": "not found" + }, + "disk": {}, + "state": { + "active": "generale", + "agent": "", + "compact": "", + "mem_mode": "ondemand", + "projects": [ + { + "created_at": 1000, + "name": "Générale", + "slug": "generale" + }, + { + "created_at": 2000, + "desc": "à moi", + "name": "Perso & Cie", + "slug": "perso" + } + ], + "tracker_raw": {}, + "trackers": {} + } + }, + { + "method": "GET", + "path": "/api/agent", + "status": 200, + "out": { + "compact": true, + "enabled": false, + "mem_mode": "ondemand", + "pages": [], + "skills": [] + }, + "disk": {}, + "state": { + "active": "generale", + "agent": "", + "compact": "", + "mem_mode": "ondemand", + "projects": [ + { + "created_at": 1000, + "name": "Générale", + "slug": "generale" + }, + { + "created_at": 2000, + "desc": "à moi", + "name": "Perso & Cie", + "slug": "perso" + } + ], + "tracker_raw": {}, + "trackers": {} + } + }, + { + "method": "POST", + "path": "/api/mem/save", + "body": "{\"name\":\"courses\",\"content\":\"# Courses\\n\\nlait\\n\\n\\n\"}", + "status": 200, + "out": { + "name": "courses", + "ok": true + }, + "disk": { + "generale/MEMORY.md": "# Index mémoire — Générale\n\nUne ligne par page de ce projet (`- [titre](fichier.md) — accroche`). Tenu à jour automatiquement à la création et à la suppression d'une page ; l'accroche après le titre, elle, est libre.\n- [Courses](courses.md)\n", + "generale/courses.md": "# Courses\n\nlait\n" + }, + "state": { + "active": "generale", + "agent": "", + "compact": "", + "mem_mode": "ondemand", + "projects": [ + { + "created_at": 1000, + "name": "Générale", + "slug": "generale" + }, + { + "created_at": 2000, + "desc": "à moi", + "name": "Perso & Cie", + "slug": "perso" + } + ], + "tracker_raw": {}, + "trackers": {} + } + }, + { + "method": "POST", + "path": "/api/mem/save", + "body": "{\"name\":\"notes.md\",\"content\":\"Sans titre\"}", + "status": 200, + "out": { + "name": "notes.md", + "ok": true + }, + "disk": { + "generale/MEMORY.md": "# Index mémoire — Générale\n\nUne ligne par page de ce projet (`- [titre](fichier.md) — accroche`). Tenu à jour automatiquement à la création et à la suppression d'une page ; l'accroche après le titre, elle, est libre.\n- [Courses](courses.md)\n- [Sans titre](notes.md)\n", + "generale/courses.md": "# Courses\n\nlait\n", + "generale/notes.md": "Sans titre\n" + }, + "state": { + "active": "generale", + "agent": "", + "compact": "", + "mem_mode": "ondemand", + "projects": [ + { + "created_at": 1000, + "name": "Générale", + "slug": "generale" + }, + { + "created_at": 2000, + "desc": "à moi", + "name": "Perso & Cie", + "slug": "perso" + } + ], + "tracker_raw": {}, + "trackers": {} + } + }, + { + "method": "POST", + "path": "/api/mem/save", + "body": "{\"name\":\"../evil\",\"content\":\"x\"}", + "status": 400, + "out": { + "error": "nom invalide (alphanum, ._-)", + "ok": false + }, + "disk": { + "generale/MEMORY.md": "# Index mémoire — Générale\n\nUne ligne par page de ce projet (`- [titre](fichier.md) — accroche`). Tenu à jour automatiquement à la création et à la suppression d'une page ; l'accroche après le titre, elle, est libre.\n- [Courses](courses.md)\n- [Sans titre](notes.md)\n", + "generale/courses.md": "# Courses\n\nlait\n", + "generale/notes.md": "Sans titre\n" + }, + "state": { + "active": "generale", + "agent": "", + "compact": "", + "mem_mode": "ondemand", + "projects": [ + { + "created_at": 1000, + "name": "Générale", + "slug": "generale" + }, + { + "created_at": 2000, + "desc": "à moi", + "name": "Perso & Cie", + "slug": "perso" + } + ], + "tracker_raw": {}, + "trackers": {} + } + }, + { + "method": "POST", + "path": "/api/mem/save", + "body": "{\"name\":\" \",\"content\":\"x\"}", + "status": 400, + "out": { + "error": "nom vide", + "ok": false + }, + "disk": { + "generale/MEMORY.md": "# Index mémoire — Générale\n\nUne ligne par page de ce projet (`- [titre](fichier.md) — accroche`). Tenu à jour automatiquement à la création et à la suppression d'une page ; l'accroche après le titre, elle, est libre.\n- [Courses](courses.md)\n- [Sans titre](notes.md)\n", + "generale/courses.md": "# Courses\n\nlait\n", + "generale/notes.md": "Sans titre\n" + }, + "state": { + "active": "generale", + "agent": "", + "compact": "", + "mem_mode": "ondemand", + "projects": [ + { + "created_at": 1000, + "name": "Générale", + "slug": "generale" + }, + { + "created_at": 2000, + "desc": "à moi", + "name": "Perso & Cie", + "slug": "perso" + } + ], + "tracker_raw": {}, + "trackers": {} + } + }, + { + "method": "POST", + "path": "/api/mem/save", + "body": "pas du json", + "status": 400, + "out": { + "error": "invalid character 'p' looking for beginning of value", + "ok": false + }, + "disk": { + "generale/MEMORY.md": "# Index mémoire — Générale\n\nUne ligne par page de ce projet (`- [titre](fichier.md) — accroche`). Tenu à jour automatiquement à la création et à la suppression d'une page ; l'accroche après le titre, elle, est libre.\n- [Courses](courses.md)\n- [Sans titre](notes.md)\n", + "generale/courses.md": "# Courses\n\nlait\n", + "generale/notes.md": "Sans titre\n" + }, + "state": { + "active": "generale", + "agent": "", + "compact": "", + "mem_mode": "ondemand", + "projects": [ + { + "created_at": 1000, + "name": "Générale", + "slug": "generale" + }, + { + "created_at": 2000, + "desc": "à moi", + "name": "Perso & Cie", + "slug": "perso" + } + ], + "tracker_raw": {}, + "trackers": {} + } + }, + { + "method": "POST", + "path": "/api/mem/save", + "body": "{\"name\":5}", + "status": 400, + "out": { + "error": "json: cannot unmarshal number into Go struct field saveReq.name of type string", + "ok": false + }, + "disk": { + "generale/MEMORY.md": "# Index mémoire — Générale\n\nUne ligne par page de ce projet (`- [titre](fichier.md) — accroche`). Tenu à jour automatiquement à la création et à la suppression d'une page ; l'accroche après le titre, elle, est libre.\n- [Courses](courses.md)\n- [Sans titre](notes.md)\n", + "generale/courses.md": "# Courses\n\nlait\n", + "generale/notes.md": "Sans titre\n" + }, + "state": { + "active": "generale", + "agent": "", + "compact": "", + "mem_mode": "ondemand", + "projects": [ + { + "created_at": 1000, + "name": "Générale", + "slug": "generale" + }, + { + "created_at": 2000, + "desc": "à moi", + "name": "Perso & Cie", + "slug": "perso" + } + ], + "tracker_raw": {}, + "trackers": {} + } + }, + { + "method": "POST", + "path": "/api/mem/save", + "body": "[1]", + "status": 400, + "out": { + "error": "json: cannot unmarshal array into Go value of type loki.saveReq", + "ok": false + }, + "disk": { + "generale/MEMORY.md": "# Index mémoire — Générale\n\nUne ligne par page de ce projet (`- [titre](fichier.md) — accroche`). Tenu à jour automatiquement à la création et à la suppression d'une page ; l'accroche après le titre, elle, est libre.\n- [Courses](courses.md)\n- [Sans titre](notes.md)\n", + "generale/courses.md": "# Courses\n\nlait\n", + "generale/notes.md": "Sans titre\n" + }, + "state": { + "active": "generale", + "agent": "", + "compact": "", + "mem_mode": "ondemand", + "projects": [ + { + "created_at": 1000, + "name": "Générale", + "slug": "generale" + }, + { + "created_at": 2000, + "desc": "à moi", + "name": "Perso & Cie", + "slug": "perso" + } + ], + "tracker_raw": {}, + "trackers": {} + } + }, + { + "method": "POST", + "path": "/api/mem/save", + "status": 400, + "out": { + "error": "EOF", + "ok": false + }, + "disk": { + "generale/MEMORY.md": "# Index mémoire — Générale\n\nUne ligne par page de ce projet (`- [titre](fichier.md) — accroche`). Tenu à jour automatiquement à la création et à la suppression d'une page ; l'accroche après le titre, elle, est libre.\n- [Courses](courses.md)\n- [Sans titre](notes.md)\n", + "generale/courses.md": "# Courses\n\nlait\n", + "generale/notes.md": "Sans titre\n" + }, + "state": { + "active": "generale", + "agent": "", + "compact": "", + "mem_mode": "ondemand", + "projects": [ + { + "created_at": 1000, + "name": "Générale", + "slug": "generale" + }, + { + "created_at": 2000, + "desc": "à moi", + "name": "Perso & Cie", + "slug": "perso" + } + ], + "tracker_raw": {}, + "trackers": {} + } + }, + { + "method": "POST", + "path": "/api/mem/save", + "body": "{\"name\":\"courses-v2\",\"old\":\"courses\",\"content\":\"# Courses v2\\nœufs & \"}", + "status": 200, + "out": { + "name": "courses-v2", + "ok": true + }, + "disk": { + "generale/MEMORY.md": "# Index mémoire — Générale\n\nUne ligne par page de ce projet (`- [titre](fichier.md) — accroche`). Tenu à jour automatiquement à la création et à la suppression d'une page ; l'accroche après le titre, elle, est libre.\n- [Sans titre](notes.md)\n- [Courses v2](courses-v2.md)\n", + "generale/courses-v2.md": "# Courses v2\nœufs & \n", + "generale/notes.md": "Sans titre\n" + }, + "state": { + "active": "generale", + "agent": "", + "compact": "", + "mem_mode": "ondemand", + "projects": [ + { + "created_at": 1000, + "name": "Générale", + "slug": "generale" + }, + { + "created_at": 2000, + "desc": "à moi", + "name": "Perso & Cie", + "slug": "perso" + } + ], + "tracker_raw": {}, + "trackers": {} + } + }, + { + "method": "POST", + "path": "/api/mem/save", + "body": "{\"name\":\"notes\",\"old\":\"notes\",\"content\":\"## Titre\\nmaj\"}", + "status": 200, + "out": { + "name": "notes", + "ok": true + }, + "disk": { + "generale/MEMORY.md": "# Index mémoire — Générale\n\nUne ligne par page de ce projet (`- [titre](fichier.md) — accroche`). Tenu à jour automatiquement à la création et à la suppression d'une page ; l'accroche après le titre, elle, est libre.\n- [Courses v2](courses-v2.md)\n- [Titre](notes.md)\n", + "generale/courses-v2.md": "# Courses v2\nœufs & \n", + "generale/notes.md": "## Titre\nmaj\n", + "generale/notes.md.bak": "Sans titre\n" + }, + "state": { + "active": "generale", + "agent": "", + "compact": "", + "mem_mode": "ondemand", + "projects": [ + { + "created_at": 1000, + "name": "Générale", + "slug": "generale" + }, + { + "created_at": 2000, + "desc": "à moi", + "name": "Perso & Cie", + "slug": "perso" + } + ], + "tracker_raw": {}, + "trackers": {} + } + }, + { + "method": "GET", + "path": "/api/mem?name=courses-v2", + "status": 200, + "out": { + "content": "# Courses v2\nœufs & \n", + "name": "courses-v2" + }, + "disk": { + "generale/MEMORY.md": "# Index mémoire — Générale\n\nUne ligne par page de ce projet (`- [titre](fichier.md) — accroche`). Tenu à jour automatiquement à la création et à la suppression d'une page ; l'accroche après le titre, elle, est libre.\n- [Courses v2](courses-v2.md)\n- [Titre](notes.md)\n", + "generale/courses-v2.md": "# Courses v2\nœufs & \n", + "generale/notes.md": "## Titre\nmaj\n", + "generale/notes.md.bak": "Sans titre\n" + }, + "state": { + "active": "generale", + "agent": "", + "compact": "", + "mem_mode": "ondemand", + "projects": [ + { + "created_at": 1000, + "name": "Générale", + "slug": "generale" + }, + { + "created_at": 2000, + "desc": "à moi", + "name": "Perso & Cie", + "slug": "perso" + } + ], + "tracker_raw": {}, + "trackers": {} + } + }, + { + "method": "GET", + "path": "/api/mem?name=+courses-v2.md+", + "status": 200, + "out": { + "content": "# Courses v2\nœufs & \n", + "name": "courses-v2.md" + }, + "disk": { + "generale/MEMORY.md": "# Index mémoire — Générale\n\nUne ligne par page de ce projet (`- [titre](fichier.md) — accroche`). Tenu à jour automatiquement à la création et à la suppression d'une page ; l'accroche après le titre, elle, est libre.\n- [Courses v2](courses-v2.md)\n- [Titre](notes.md)\n", + "generale/courses-v2.md": "# Courses v2\nœufs & \n", + "generale/notes.md": "## Titre\nmaj\n", + "generale/notes.md.bak": "Sans titre\n" + }, + "state": { + "active": "generale", + "agent": "", + "compact": "", + "mem_mode": "ondemand", + "projects": [ + { + "created_at": 1000, + "name": "Générale", + "slug": "generale" + }, + { + "created_at": 2000, + "desc": "à moi", + "name": "Perso & Cie", + "slug": "perso" + } + ], + "tracker_raw": {}, + "trackers": {} + } + }, + { + "method": "GET", + "path": "/api/mem?name=a&name=courses-v2", + "status": 404, + "out": { + "error": "not found" + }, + "disk": { + "generale/MEMORY.md": "# Index mémoire — Générale\n\nUne ligne par page de ce projet (`- [titre](fichier.md) — accroche`). Tenu à jour automatiquement à la création et à la suppression d'une page ; l'accroche après le titre, elle, est libre.\n- [Courses v2](courses-v2.md)\n- [Titre](notes.md)\n", + "generale/courses-v2.md": "# Courses v2\nœufs & \n", + "generale/notes.md": "## Titre\nmaj\n", + "generale/notes.md.bak": "Sans titre\n" + }, + "state": { + "active": "generale", + "agent": "", + "compact": "", + "mem_mode": "ondemand", + "projects": [ + { + "created_at": 1000, + "name": "Générale", + "slug": "generale" + }, + { + "created_at": 2000, + "desc": "à moi", + "name": "Perso & Cie", + "slug": "perso" + } + ], + "tracker_raw": {}, + "trackers": {} + } + }, + { + "method": "GET", + "path": "/api/mem?name=../x", + "status": 404, + "out": { + "error": "not found" + }, + "disk": { + "generale/MEMORY.md": "# Index mémoire — Générale\n\nUne ligne par page de ce projet (`- [titre](fichier.md) — accroche`). Tenu à jour automatiquement à la création et à la suppression d'une page ; l'accroche après le titre, elle, est libre.\n- [Courses v2](courses-v2.md)\n- [Titre](notes.md)\n", + "generale/courses-v2.md": "# Courses v2\nœufs & \n", + "generale/notes.md": "## Titre\nmaj\n", + "generale/notes.md.bak": "Sans titre\n" + }, + "state": { + "active": "generale", + "agent": "", + "compact": "", + "mem_mode": "ondemand", + "projects": [ + { + "created_at": 1000, + "name": "Générale", + "slug": "generale" + }, + { + "created_at": 2000, + "desc": "à moi", + "name": "Perso & Cie", + "slug": "perso" + } + ], + "tracker_raw": {}, + "trackers": {} + } + }, + { + "files": { + "generale/.cache.md": "caché", + "generale/Zeta.MD": "# Zêta\n", + "generale/dossier.md/": "-", + "generale/notes.txt": "pas une page", + "generale/vide.md": "\n\n" + }, + "method": "GET", + "path": "/api/tools", + "status": 200, + "out": { + "compact": true, + "enabled": false, + "mem_mode": "ondemand", + "pages": [ + { + "desc": "Index mémoire — Générale", + "name": "MEMORY.md" + }, + { + "desc": "Zêta", + "name": "Zeta.MD" + }, + { + "desc": "Courses v2", + "name": "courses-v2.md" + }, + { + "desc": "Titre", + "name": "notes.md" + }, + { + "desc": "", + "name": "vide.md" + } + ], + "skills": [ + { + "desc": "Index mémoire — Générale", + "name": "MEMORY.md" + }, + { + "desc": "Zêta", + "name": "Zeta.MD" + }, + { + "desc": "Courses v2", + "name": "courses-v2.md" + }, + { + "desc": "Titre", + "name": "notes.md" + }, + { + "desc": "", + "name": "vide.md" + } + ] + }, + "disk": { + "generale/.cache.md": "caché", + "generale/MEMORY.md": "# Index mémoire — Générale\n\nUne ligne par page de ce projet (`- [titre](fichier.md) — accroche`). Tenu à jour automatiquement à la création et à la suppression d'une page ; l'accroche après le titre, elle, est libre.\n- [Courses v2](courses-v2.md)\n- [Titre](notes.md)\n", + "generale/Zeta.MD": "# Zêta\n", + "generale/courses-v2.md": "# Courses v2\nœufs & \n", + "generale/notes.md": "## Titre\nmaj\n", + "generale/notes.md.bak": "Sans titre\n", + "generale/notes.txt": "pas une page", + "generale/vide.md": "\n\n" + }, + "state": { + "active": "generale", + "agent": "", + "compact": "", + "mem_mode": "ondemand", + "projects": [ + { + "created_at": 1000, + "name": "Générale", + "slug": "generale" + }, + { + "created_at": 2000, + "desc": "à moi", + "name": "Perso & Cie", + "slug": "perso" + } + ], + "tracker_raw": {}, + "trackers": {} + } + }, + { + "method": "POST", + "path": "/api/mem/delete", + "body": "{\"name\":\"absente\"}", + "status": 400, + "out": { + "error": "introuvable", + "ok": false + }, + "disk": { + "generale/.cache.md": "caché", + "generale/MEMORY.md": "# Index mémoire — Générale\n\nUne ligne par page de ce projet (`- [titre](fichier.md) — accroche`). Tenu à jour automatiquement à la création et à la suppression d'une page ; l'accroche après le titre, elle, est libre.\n- [Courses v2](courses-v2.md)\n- [Titre](notes.md)\n", + "generale/Zeta.MD": "# Zêta\n", + "generale/courses-v2.md": "# Courses v2\nœufs & \n", + "generale/notes.md": "## Titre\nmaj\n", + "generale/notes.md.bak": "Sans titre\n", + "generale/notes.txt": "pas une page", + "generale/vide.md": "\n\n" + }, + "state": { + "active": "generale", + "agent": "", + "compact": "", + "mem_mode": "ondemand", + "projects": [ + { + "created_at": 1000, + "name": "Générale", + "slug": "generale" + }, + { + "created_at": 2000, + "desc": "à moi", + "name": "Perso & Cie", + "slug": "perso" + } + ], + "tracker_raw": {}, + "trackers": {} + } + }, + { + "method": "POST", + "path": "/api/mem/delete", + "body": "{\"name\":\"bad/name\"}", + "status": 400, + "out": { + "error": "nom invalide (alphanum, ._-)", + "ok": false + }, + "disk": { + "generale/.cache.md": "caché", + "generale/MEMORY.md": "# Index mémoire — Générale\n\nUne ligne par page de ce projet (`- [titre](fichier.md) — accroche`). Tenu à jour automatiquement à la création et à la suppression d'une page ; l'accroche après le titre, elle, est libre.\n- [Courses v2](courses-v2.md)\n- [Titre](notes.md)\n", + "generale/Zeta.MD": "# Zêta\n", + "generale/courses-v2.md": "# Courses v2\nœufs & \n", + "generale/notes.md": "## Titre\nmaj\n", + "generale/notes.md.bak": "Sans titre\n", + "generale/notes.txt": "pas une page", + "generale/vide.md": "\n\n" + }, + "state": { + "active": "generale", + "agent": "", + "compact": "", + "mem_mode": "ondemand", + "projects": [ + { + "created_at": 1000, + "name": "Générale", + "slug": "generale" + }, + { + "created_at": 2000, + "desc": "à moi", + "name": "Perso & Cie", + "slug": "perso" + } + ], + "tracker_raw": {}, + "trackers": {} + } + }, + { + "method": "POST", + "path": "/api/mem/delete", + "body": "{\"name\":\"vide\"}", + "status": 200, + "out": { + "ok": true + }, + "disk": { + "generale/.cache.md": "caché", + "generale/MEMORY.md": "# Index mémoire — Générale\n\nUne ligne par page de ce projet (`- [titre](fichier.md) — accroche`). Tenu à jour automatiquement à la création et à la suppression d'une page ; l'accroche après le titre, elle, est libre.\n- [Courses v2](courses-v2.md)\n- [Titre](notes.md)\n", + "generale/Zeta.MD": "# Zêta\n", + "generale/courses-v2.md": "# Courses v2\nœufs & \n", + "generale/notes.md": "## Titre\nmaj\n", + "generale/notes.md.bak": "Sans titre\n", + "generale/notes.txt": "pas une page" + }, + "state": { + "active": "generale", + "agent": "", + "compact": "", + "mem_mode": "ondemand", + "projects": [ + { + "created_at": 1000, + "name": "Générale", + "slug": "generale" + }, + { + "created_at": 2000, + "desc": "à moi", + "name": "Perso & Cie", + "slug": "perso" + } + ], + "tracker_raw": {}, + "trackers": {} + } + }, + { + "method": "POST", + "path": "/api/mem/delete", + "body": "{nope}", + "status": 400, + "out": { + "error": "invalid character 'n' looking for beginning of object key string", + "ok": false + }, + "disk": { + "generale/.cache.md": "caché", + "generale/MEMORY.md": "# Index mémoire — Générale\n\nUne ligne par page de ce projet (`- [titre](fichier.md) — accroche`). Tenu à jour automatiquement à la création et à la suppression d'une page ; l'accroche après le titre, elle, est libre.\n- [Courses v2](courses-v2.md)\n- [Titre](notes.md)\n", + "generale/Zeta.MD": "# Zêta\n", + "generale/courses-v2.md": "# Courses v2\nœufs & \n", + "generale/notes.md": "## Titre\nmaj\n", + "generale/notes.md.bak": "Sans titre\n", + "generale/notes.txt": "pas une page" + }, + "state": { + "active": "generale", + "agent": "", + "compact": "", + "mem_mode": "ondemand", + "projects": [ + { + "created_at": 1000, + "name": "Générale", + "slug": "generale" + }, + { + "created_at": 2000, + "desc": "à moi", + "name": "Perso & Cie", + "slug": "perso" + } + ], + "tracker_raw": {}, + "trackers": {} + } + }, + { + "files": { + "generale/MEMORY.md": "# Index mémoire — Générale\n\n- [Courses v2](courses-v2.md) — la liste\n- [Titre](notes.md)\n" + }, + "method": "POST", + "path": "/api/mem/save", + "body": "{\"name\":\"courses-v2\",\"old\":\"courses-v2\",\"content\":\"# Courses v3\"}", + "status": 200, + "out": { + "name": "courses-v2", + "ok": true + }, + "disk": { + "generale/.cache.md": "caché", + "generale/MEMORY.md": "# Index mémoire — Générale\n\n- [Titre](notes.md)\n- [Courses v3](courses-v2.md)\n", + "generale/Zeta.MD": "# Zêta\n", + "generale/courses-v2.md": "# Courses v3\n", + "generale/courses-v2.md.bak": "# Courses v2\nœufs & \n", + "generale/notes.md": "## Titre\nmaj\n", + "generale/notes.md.bak": "Sans titre\n", + "generale/notes.txt": "pas une page" + }, + "state": { + "active": "generale", + "agent": "", + "compact": "", + "mem_mode": "ondemand", + "projects": [ + { + "created_at": 1000, + "name": "Générale", + "slug": "generale" + }, + { + "created_at": 2000, + "desc": "à moi", + "name": "Perso & Cie", + "slug": "perso" + } + ], + "tracker_raw": {}, + "trackers": {} + } + }, + { + "method": "POST", + "path": "/api/mem/save", + "body": "{\"name\":\"neuve\",\"content\":\"# Neuve\"}", + "status": 200, + "out": { + "name": "neuve", + "ok": true + }, + "disk": { + "generale/.cache.md": "caché", + "generale/MEMORY.md": "# Index mémoire — Générale\n\n- [Titre](notes.md)\n- [Courses v3](courses-v2.md)\n- [Neuve](neuve.md)\n", + "generale/Zeta.MD": "# Zêta\n", + "generale/courses-v2.md": "# Courses v3\n", + "generale/courses-v2.md.bak": "# Courses v2\nœufs & \n", + "generale/neuve.md": "# Neuve\n", + "generale/notes.md": "## Titre\nmaj\n", + "generale/notes.md.bak": "Sans titre\n", + "generale/notes.txt": "pas une page" + }, + "state": { + "active": "generale", + "agent": "", + "compact": "", + "mem_mode": "ondemand", + "projects": [ + { + "created_at": 1000, + "name": "Générale", + "slug": "generale" + }, + { + "created_at": 2000, + "desc": "à moi", + "name": "Perso & Cie", + "slug": "perso" + } + ], + "tracker_raw": {}, + "trackers": {} + } + }, + { + "method": "POST", + "path": "/api/mem/move", + "body": "{\"name\":\"notes\",\"to\":\"perso\"}", + "status": 200, + "out": { + "ok": true + }, + "disk": { + "generale/.cache.md": "caché", + "generale/MEMORY.md": "# Index mémoire — Générale\n\n- [Courses v3](courses-v2.md)\n- [Neuve](neuve.md)\n", + "generale/Zeta.MD": "# Zêta\n", + "generale/courses-v2.md": "# Courses v3\n", + "generale/courses-v2.md.bak": "# Courses v2\nœufs & \n", + "generale/neuve.md": "# Neuve\n", + "generale/notes.md.bak": "Sans titre\n", + "generale/notes.txt": "pas une page", + "perso/MEMORY.md": "# Index mémoire — Perso & Cie\n\nUne ligne par page de ce projet (`- [titre](fichier.md) — accroche`). Tenu à jour automatiquement à la création et à la suppression d'une page ; l'accroche après le titre, elle, est libre.\n- [Titre](notes.md)\n", + "perso/notes.md": "## Titre\nmaj\n" + }, + "state": { + "active": "generale", + "agent": "", + "compact": "", + "mem_mode": "ondemand", + "projects": [ + { + "created_at": 1000, + "name": "Générale", + "slug": "generale" + }, + { + "created_at": 2000, + "desc": "à moi", + "name": "Perso & Cie", + "slug": "perso" + } + ], + "tracker_raw": {}, + "trackers": {} + } + }, + { + "method": "POST", + "path": "/api/mem/move", + "body": "{\"name\":\"notes\",\"to\":\"perso\"}", + "status": 400, + "out": { + "error": "page introuvable", + "ok": false + }, + "disk": { + "generale/.cache.md": "caché", + "generale/MEMORY.md": "# Index mémoire — Générale\n\n- [Courses v3](courses-v2.md)\n- [Neuve](neuve.md)\n", + "generale/Zeta.MD": "# Zêta\n", + "generale/courses-v2.md": "# Courses v3\n", + "generale/courses-v2.md.bak": "# Courses v2\nœufs & \n", + "generale/neuve.md": "# Neuve\n", + "generale/notes.md.bak": "Sans titre\n", + "generale/notes.txt": "pas une page", + "perso/MEMORY.md": "# Index mémoire — Perso & Cie\n\nUne ligne par page de ce projet (`- [titre](fichier.md) — accroche`). Tenu à jour automatiquement à la création et à la suppression d'une page ; l'accroche après le titre, elle, est libre.\n- [Titre](notes.md)\n", + "perso/notes.md": "## Titre\nmaj\n" + }, + "state": { + "active": "generale", + "agent": "", + "compact": "", + "mem_mode": "ondemand", + "projects": [ + { + "created_at": 1000, + "name": "Générale", + "slug": "generale" + }, + { + "created_at": 2000, + "desc": "à moi", + "name": "Perso & Cie", + "slug": "perso" + } + ], + "tracker_raw": {}, + "trackers": {} + } + }, + { + "method": "POST", + "path": "/api/mem/move", + "body": "{\"name\":\"courses-v2\",\"to\":\"generale\"}", + "status": 400, + "out": { + "error": "déjà dans ce projet", + "ok": false + }, + "disk": { + "generale/.cache.md": "caché", + "generale/MEMORY.md": "# Index mémoire — Générale\n\n- [Courses v3](courses-v2.md)\n- [Neuve](neuve.md)\n", + "generale/Zeta.MD": "# Zêta\n", + "generale/courses-v2.md": "# Courses v3\n", + "generale/courses-v2.md.bak": "# Courses v2\nœufs & \n", + "generale/neuve.md": "# Neuve\n", + "generale/notes.md.bak": "Sans titre\n", + "generale/notes.txt": "pas une page", + "perso/MEMORY.md": "# Index mémoire — Perso & Cie\n\nUne ligne par page de ce projet (`- [titre](fichier.md) — accroche`). Tenu à jour automatiquement à la création et à la suppression d'une page ; l'accroche après le titre, elle, est libre.\n- [Titre](notes.md)\n", + "perso/notes.md": "## Titre\nmaj\n" + }, + "state": { + "active": "generale", + "agent": "", + "compact": "", + "mem_mode": "ondemand", + "projects": [ + { + "created_at": 1000, + "name": "Générale", + "slug": "generale" + }, + { + "created_at": 2000, + "desc": "à moi", + "name": "Perso & Cie", + "slug": "perso" + } + ], + "tracker_raw": {}, + "trackers": {} + } + }, + { + "method": "POST", + "path": "/api/mem/move", + "body": "{\"name\":\"courses-v2\",\"to\":\" generale \"}", + "status": 400, + "out": { + "error": "déjà dans ce projet", + "ok": false + }, + "disk": { + "generale/.cache.md": "caché", + "generale/MEMORY.md": "# Index mémoire — Générale\n\n- [Courses v3](courses-v2.md)\n- [Neuve](neuve.md)\n", + "generale/Zeta.MD": "# Zêta\n", + "generale/courses-v2.md": "# Courses v3\n", + "generale/courses-v2.md.bak": "# Courses v2\nœufs & \n", + "generale/neuve.md": "# Neuve\n", + "generale/notes.md.bak": "Sans titre\n", + "generale/notes.txt": "pas une page", + "perso/MEMORY.md": "# Index mémoire — Perso & Cie\n\nUne ligne par page de ce projet (`- [titre](fichier.md) — accroche`). Tenu à jour automatiquement à la création et à la suppression d'une page ; l'accroche après le titre, elle, est libre.\n- [Titre](notes.md)\n", + "perso/notes.md": "## Titre\nmaj\n" + }, + "state": { + "active": "generale", + "agent": "", + "compact": "", + "mem_mode": "ondemand", + "projects": [ + { + "created_at": 1000, + "name": "Générale", + "slug": "generale" + }, + { + "created_at": 2000, + "desc": "à moi", + "name": "Perso & Cie", + "slug": "perso" + } + ], + "tracker_raw": {}, + "trackers": {} + } + }, + { + "method": "POST", + "path": "/api/mem/move", + "body": "{\"name\":\"courses-v2\",\"to\":\"inconnu\"}", + "status": 400, + "out": { + "error": "projet cible introuvable", + "ok": false + }, + "disk": { + "generale/.cache.md": "caché", + "generale/MEMORY.md": "# Index mémoire — Générale\n\n- [Courses v3](courses-v2.md)\n- [Neuve](neuve.md)\n", + "generale/Zeta.MD": "# Zêta\n", + "generale/courses-v2.md": "# Courses v3\n", + "generale/courses-v2.md.bak": "# Courses v2\nœufs & \n", + "generale/neuve.md": "# Neuve\n", + "generale/notes.md.bak": "Sans titre\n", + "generale/notes.txt": "pas une page", + "perso/MEMORY.md": "# Index mémoire — Perso & Cie\n\nUne ligne par page de ce projet (`- [titre](fichier.md) — accroche`). Tenu à jour automatiquement à la création et à la suppression d'une page ; l'accroche après le titre, elle, est libre.\n- [Titre](notes.md)\n", + "perso/notes.md": "## Titre\nmaj\n" + }, + "state": { + "active": "generale", + "agent": "", + "compact": "", + "mem_mode": "ondemand", + "projects": [ + { + "created_at": 1000, + "name": "Générale", + "slug": "generale" + }, + { + "created_at": 2000, + "desc": "à moi", + "name": "Perso & Cie", + "slug": "perso" + } + ], + "tracker_raw": {}, + "trackers": {} + } + }, + { + "method": "POST", + "path": "/api/mem/move", + "body": "{\"name\":\"MEMORY\",\"to\":\"perso\"}", + "status": 400, + "out": { + "error": "l'index du projet n'est pas déplaçable", + "ok": false + }, + "disk": { + "generale/.cache.md": "caché", + "generale/MEMORY.md": "# Index mémoire — Générale\n\n- [Courses v3](courses-v2.md)\n- [Neuve](neuve.md)\n", + "generale/Zeta.MD": "# Zêta\n", + "generale/courses-v2.md": "# Courses v3\n", + "generale/courses-v2.md.bak": "# Courses v2\nœufs & \n", + "generale/neuve.md": "# Neuve\n", + "generale/notes.md.bak": "Sans titre\n", + "generale/notes.txt": "pas une page", + "perso/MEMORY.md": "# Index mémoire — Perso & Cie\n\nUne ligne par page de ce projet (`- [titre](fichier.md) — accroche`). Tenu à jour automatiquement à la création et à la suppression d'une page ; l'accroche après le titre, elle, est libre.\n- [Titre](notes.md)\n", + "perso/notes.md": "## Titre\nmaj\n" + }, + "state": { + "active": "generale", + "agent": "", + "compact": "", + "mem_mode": "ondemand", + "projects": [ + { + "created_at": 1000, + "name": "Générale", + "slug": "generale" + }, + { + "created_at": 2000, + "desc": "à moi", + "name": "Perso & Cie", + "slug": "perso" + } + ], + "tracker_raw": {}, + "trackers": {} + } + }, + { + "method": "POST", + "path": "/api/mem/move", + "body": "{\"name\":\"x/y\",\"to\":\"perso\"}", + "status": 400, + "out": { + "error": "nom invalide (alphanum, ._-)", + "ok": false + }, + "disk": { + "generale/.cache.md": "caché", + "generale/MEMORY.md": "# Index mémoire — Générale\n\n- [Courses v3](courses-v2.md)\n- [Neuve](neuve.md)\n", + "generale/Zeta.MD": "# Zêta\n", + "generale/courses-v2.md": "# Courses v3\n", + "generale/courses-v2.md.bak": "# Courses v2\nœufs & \n", + "generale/neuve.md": "# Neuve\n", + "generale/notes.md.bak": "Sans titre\n", + "generale/notes.txt": "pas une page", + "perso/MEMORY.md": "# Index mémoire — Perso & Cie\n\nUne ligne par page de ce projet (`- [titre](fichier.md) — accroche`). Tenu à jour automatiquement à la création et à la suppression d'une page ; l'accroche après le titre, elle, est libre.\n- [Titre](notes.md)\n", + "perso/notes.md": "## Titre\nmaj\n" + }, + "state": { + "active": "generale", + "agent": "", + "compact": "", + "mem_mode": "ondemand", + "projects": [ + { + "created_at": 1000, + "name": "Générale", + "slug": "generale" + }, + { + "created_at": 2000, + "desc": "à moi", + "name": "Perso & Cie", + "slug": "perso" + } + ], + "tracker_raw": {}, + "trackers": {} + } + }, + { + "method": "POST", + "path": "/api/mem/move", + "body": "[]", + "status": 400, + "out": { + "error": "json: cannot unmarshal array into Go value of type struct { Name string \"json:\\\"name\\\"\"; To string \"json:\\\"to\\\"\" }", + "ok": false + }, + "disk": { + "generale/.cache.md": "caché", + "generale/MEMORY.md": "# Index mémoire — Générale\n\n- [Courses v3](courses-v2.md)\n- [Neuve](neuve.md)\n", + "generale/Zeta.MD": "# Zêta\n", + "generale/courses-v2.md": "# Courses v3\n", + "generale/courses-v2.md.bak": "# Courses v2\nœufs & \n", + "generale/neuve.md": "# Neuve\n", + "generale/notes.md.bak": "Sans titre\n", + "generale/notes.txt": "pas une page", + "perso/MEMORY.md": "# Index mémoire — Perso & Cie\n\nUne ligne par page de ce projet (`- [titre](fichier.md) — accroche`). Tenu à jour automatiquement à la création et à la suppression d'une page ; l'accroche après le titre, elle, est libre.\n- [Titre](notes.md)\n", + "perso/notes.md": "## Titre\nmaj\n" + }, + "state": { + "active": "generale", + "agent": "", + "compact": "", + "mem_mode": "ondemand", + "projects": [ + { + "created_at": 1000, + "name": "Générale", + "slug": "generale" + }, + { + "created_at": 2000, + "desc": "à moi", + "name": "Perso & Cie", + "slug": "perso" + } + ], + "tracker_raw": {}, + "trackers": {} + } + }, + { + "method": "GET", + "path": "/api/mem/move", + "status": 405, + "out": { + "error": "méthode non autorisée", + "ok": false + }, + "disk": { + "generale/.cache.md": "caché", + "generale/MEMORY.md": "# Index mémoire — Générale\n\n- [Courses v3](courses-v2.md)\n- [Neuve](neuve.md)\n", + "generale/Zeta.MD": "# Zêta\n", + "generale/courses-v2.md": "# Courses v3\n", + "generale/courses-v2.md.bak": "# Courses v2\nœufs & \n", + "generale/neuve.md": "# Neuve\n", + "generale/notes.md.bak": "Sans titre\n", + "generale/notes.txt": "pas une page", + "perso/MEMORY.md": "# Index mémoire — Perso & Cie\n\nUne ligne par page de ce projet (`- [titre](fichier.md) — accroche`). Tenu à jour automatiquement à la création et à la suppression d'une page ; l'accroche après le titre, elle, est libre.\n- [Titre](notes.md)\n", + "perso/notes.md": "## Titre\nmaj\n" + }, + "state": { + "active": "generale", + "agent": "", + "compact": "", + "mem_mode": "ondemand", + "projects": [ + { + "created_at": 1000, + "name": "Générale", + "slug": "generale" + }, + { + "created_at": 2000, + "desc": "à moi", + "name": "Perso & Cie", + "slug": "perso" + } + ], + "tracker_raw": {}, + "trackers": {} + } + }, + { + "files": { + "generale/doublon.md": "# A", + "perso/doublon.md": "# B" + }, + "method": "POST", + "path": "/api/mem/move", + "body": "{\"name\":\"doublon\",\"to\":\"perso\"}", + "status": 400, + "out": { + "error": "une page du même nom existe déjà dans le projet cible", + "ok": false + }, + "disk": { + "generale/.cache.md": "caché", + "generale/MEMORY.md": "# Index mémoire — Générale\n\n- [Courses v3](courses-v2.md)\n- [Neuve](neuve.md)\n", + "generale/Zeta.MD": "# Zêta\n", + "generale/courses-v2.md": "# Courses v3\n", + "generale/courses-v2.md.bak": "# Courses v2\nœufs & \n", + "generale/doublon.md": "# A", + "generale/neuve.md": "# Neuve\n", + "generale/notes.md.bak": "Sans titre\n", + "generale/notes.txt": "pas une page", + "perso/MEMORY.md": "# Index mémoire — Perso & Cie\n\nUne ligne par page de ce projet (`- [titre](fichier.md) — accroche`). Tenu à jour automatiquement à la création et à la suppression d'une page ; l'accroche après le titre, elle, est libre.\n- [Titre](notes.md)\n", + "perso/doublon.md": "# B", + "perso/notes.md": "## Titre\nmaj\n" + }, + "state": { + "active": "generale", + "agent": "", + "compact": "", + "mem_mode": "ondemand", + "projects": [ + { + "created_at": 1000, + "name": "Générale", + "slug": "generale" + }, + { + "created_at": 2000, + "desc": "à moi", + "name": "Perso & Cie", + "slug": "perso" + } + ], + "tracker_raw": {}, + "trackers": {} + } + }, + { + "store": { + "state/active_project": "perso" + }, + "method": "GET", + "path": "/api/skills", + "status": 200, + "out": { + "compact": true, + "enabled": false, + "mem_mode": "ondemand", + "pages": [ + { + "desc": "Index mémoire — Perso & Cie", + "name": "MEMORY.md" + }, + { + "desc": "B", + "name": "doublon.md" + }, + { + "desc": "Titre", + "name": "notes.md" + } + ], + "skills": [ + { + "desc": "Index mémoire — Perso & Cie", + "name": "MEMORY.md" + }, + { + "desc": "B", + "name": "doublon.md" + }, + { + "desc": "Titre", + "name": "notes.md" + } + ] + }, + "disk": { + "generale/.cache.md": "caché", + "generale/MEMORY.md": "# Index mémoire — Générale\n\n- [Courses v3](courses-v2.md)\n- [Neuve](neuve.md)\n", + "generale/Zeta.MD": "# Zêta\n", + "generale/courses-v2.md": "# Courses v3\n", + "generale/courses-v2.md.bak": "# Courses v2\nœufs & \n", + "generale/doublon.md": "# A", + "generale/neuve.md": "# Neuve\n", + "generale/notes.md.bak": "Sans titre\n", + "generale/notes.txt": "pas une page", + "perso/MEMORY.md": "# Index mémoire — Perso & Cie\n\nUne ligne par page de ce projet (`- [titre](fichier.md) — accroche`). Tenu à jour automatiquement à la création et à la suppression d'une page ; l'accroche après le titre, elle, est libre.\n- [Titre](notes.md)\n", + "perso/doublon.md": "# B", + "perso/notes.md": "## Titre\nmaj\n" + }, + "state": { + "active": "perso", + "agent": "", + "compact": "", + "mem_mode": "ondemand", + "projects": [ + { + "created_at": 1000, + "name": "Générale", + "slug": "generale" + }, + { + "created_at": 2000, + "desc": "à moi", + "name": "Perso & Cie", + "slug": "perso" + } + ], + "tracker_raw": {}, + "trackers": {} + } + }, + { + "method": "POST", + "path": "/api/mem/save", + "body": "{\"name\":\"perso-1\",\"content\":\"Première\\nligne\"}", + "status": 200, + "out": { + "name": "perso-1", + "ok": true + }, + "disk": { + "generale/.cache.md": "caché", + "generale/MEMORY.md": "# Index mémoire — Générale\n\n- [Courses v3](courses-v2.md)\n- [Neuve](neuve.md)\n", + "generale/Zeta.MD": "# Zêta\n", + "generale/courses-v2.md": "# Courses v3\n", + "generale/courses-v2.md.bak": "# Courses v2\nœufs & \n", + "generale/doublon.md": "# A", + "generale/neuve.md": "# Neuve\n", + "generale/notes.md.bak": "Sans titre\n", + "generale/notes.txt": "pas une page", + "perso/MEMORY.md": "# Index mémoire — Perso & Cie\n\nUne ligne par page de ce projet (`- [titre](fichier.md) — accroche`). Tenu à jour automatiquement à la création et à la suppression d'une page ; l'accroche après le titre, elle, est libre.\n- [Titre](notes.md)\n- [Première](perso-1.md)\n", + "perso/doublon.md": "# B", + "perso/notes.md": "## Titre\nmaj\n", + "perso/perso-1.md": "Première\nligne\n" + }, + "state": { + "active": "perso", + "agent": "", + "compact": "", + "mem_mode": "ondemand", + "projects": [ + { + "created_at": 1000, + "name": "Générale", + "slug": "generale" + }, + { + "created_at": 2000, + "desc": "à moi", + "name": "Perso & Cie", + "slug": "perso" + } + ], + "tracker_raw": {}, + "trackers": {} + } + }, + { + "method": "POST", + "path": "/api/mem/move", + "body": "{\"name\":\"doublon\",\"to\":\"generale\"}", + "status": 400, + "out": { + "error": "une page du même nom existe déjà dans le projet cible", + "ok": false + }, + "disk": { + "generale/.cache.md": "caché", + "generale/MEMORY.md": "# Index mémoire — Générale\n\n- [Courses v3](courses-v2.md)\n- [Neuve](neuve.md)\n", + "generale/Zeta.MD": "# Zêta\n", + "generale/courses-v2.md": "# Courses v3\n", + "generale/courses-v2.md.bak": "# Courses v2\nœufs & \n", + "generale/doublon.md": "# A", + "generale/neuve.md": "# Neuve\n", + "generale/notes.md.bak": "Sans titre\n", + "generale/notes.txt": "pas une page", + "perso/MEMORY.md": "# Index mémoire — Perso & Cie\n\nUne ligne par page de ce projet (`- [titre](fichier.md) — accroche`). Tenu à jour automatiquement à la création et à la suppression d'une page ; l'accroche après le titre, elle, est libre.\n- [Titre](notes.md)\n- [Première](perso-1.md)\n", + "perso/doublon.md": "# B", + "perso/notes.md": "## Titre\nmaj\n", + "perso/perso-1.md": "Première\nligne\n" + }, + "state": { + "active": "perso", + "agent": "", + "compact": "", + "mem_mode": "ondemand", + "projects": [ + { + "created_at": 1000, + "name": "Générale", + "slug": "generale" + }, + { + "created_at": 2000, + "desc": "à moi", + "name": "Perso & Cie", + "slug": "perso" + } + ], + "tracker_raw": {}, + "trackers": {} + } + }, + { + "store": { + "state/active_project": "generale" + }, + "method": "GET", + "path": "/api/agent", + "status": 200, + "out": { + "compact": true, + "enabled": false, + "mem_mode": "ondemand", + "pages": [ + { + "desc": "Index mémoire — Générale", + "name": "MEMORY.md" + }, + { + "desc": "Zêta", + "name": "Zeta.MD" + }, + { + "desc": "Courses v3", + "name": "courses-v2.md" + }, + { + "desc": "A", + "name": "doublon.md" + }, + { + "desc": "Neuve", + "name": "neuve.md" + } + ], + "skills": [ + { + "desc": "Index mémoire — Générale", + "name": "MEMORY.md" + }, + { + "desc": "Zêta", + "name": "Zeta.MD" + }, + { + "desc": "Courses v3", + "name": "courses-v2.md" + }, + { + "desc": "A", + "name": "doublon.md" + }, + { + "desc": "Neuve", + "name": "neuve.md" + } + ] + }, + "disk": { + "generale/.cache.md": "caché", + "generale/MEMORY.md": "# Index mémoire — Générale\n\n- [Courses v3](courses-v2.md)\n- [Neuve](neuve.md)\n", + "generale/Zeta.MD": "# Zêta\n", + "generale/courses-v2.md": "# Courses v3\n", + "generale/courses-v2.md.bak": "# Courses v2\nœufs & \n", + "generale/doublon.md": "# A", + "generale/neuve.md": "# Neuve\n", + "generale/notes.md.bak": "Sans titre\n", + "generale/notes.txt": "pas une page", + "perso/MEMORY.md": "# Index mémoire — Perso & Cie\n\nUne ligne par page de ce projet (`- [titre](fichier.md) — accroche`). Tenu à jour automatiquement à la création et à la suppression d'une page ; l'accroche après le titre, elle, est libre.\n- [Titre](notes.md)\n- [Première](perso-1.md)\n", + "perso/doublon.md": "# B", + "perso/notes.md": "## Titre\nmaj\n", + "perso/perso-1.md": "Première\nligne\n" + }, + "state": { + "active": "generale", + "agent": "", + "compact": "", + "mem_mode": "ondemand", + "projects": [ + { + "created_at": 1000, + "name": "Générale", + "slug": "generale" + }, + { + "created_at": 2000, + "desc": "à moi", + "name": "Perso & Cie", + "slug": "perso" + } + ], + "tracker_raw": {}, + "trackers": {} + } + }, + { + "method": "POST", + "path": "/api/agent/toggle", + "body": "{\"on\":true}", + "status": 200, + "out": { + "enabled": true, + "ok": true + }, + "disk": { + "generale/.cache.md": "caché", + "generale/MEMORY.md": "# Index mémoire — Générale\n\n- [Courses v3](courses-v2.md)\n- [Neuve](neuve.md)\n", + "generale/Zeta.MD": "# Zêta\n", + "generale/courses-v2.md": "# Courses v3\n", + "generale/courses-v2.md.bak": "# Courses v2\nœufs & \n", + "generale/doublon.md": "# A", + "generale/neuve.md": "# Neuve\n", + "generale/notes.md.bak": "Sans titre\n", + "generale/notes.txt": "pas une page", + "perso/MEMORY.md": "# Index mémoire — Perso & Cie\n\nUne ligne par page de ce projet (`- [titre](fichier.md) — accroche`). Tenu à jour automatiquement à la création et à la suppression d'une page ; l'accroche après le titre, elle, est libre.\n- [Titre](notes.md)\n- [Première](perso-1.md)\n", + "perso/doublon.md": "# B", + "perso/notes.md": "## Titre\nmaj\n", + "perso/perso-1.md": "Première\nligne\n" + }, + "state": { + "active": "generale", + "agent": "1", + "compact": "", + "mem_mode": "ondemand", + "projects": [ + { + "created_at": 1000, + "name": "Générale", + "slug": "generale" + }, + { + "created_at": 2000, + "desc": "à moi", + "name": "Perso & Cie", + "slug": "perso" + } + ], + "tracker_raw": {}, + "trackers": {} + } + }, + { + "method": "GET", + "path": "/api/tools", + "status": 200, + "out": { + "compact": true, + "enabled": true, + "mem_mode": "ondemand", + "pages": [ + { + "desc": "Index mémoire — Générale", + "name": "MEMORY.md" + }, + { + "desc": "Zêta", + "name": "Zeta.MD" + }, + { + "desc": "Courses v3", + "name": "courses-v2.md" + }, + { + "desc": "A", + "name": "doublon.md" + }, + { + "desc": "Neuve", + "name": "neuve.md" + } + ], + "skills": [ + { + "desc": "Index mémoire — Générale", + "name": "MEMORY.md" + }, + { + "desc": "Zêta", + "name": "Zeta.MD" + }, + { + "desc": "Courses v3", + "name": "courses-v2.md" + }, + { + "desc": "A", + "name": "doublon.md" + }, + { + "desc": "Neuve", + "name": "neuve.md" + } + ] + }, + "disk": { + "generale/.cache.md": "caché", + "generale/MEMORY.md": "# Index mémoire — Générale\n\n- [Courses v3](courses-v2.md)\n- [Neuve](neuve.md)\n", + "generale/Zeta.MD": "# Zêta\n", + "generale/courses-v2.md": "# Courses v3\n", + "generale/courses-v2.md.bak": "# Courses v2\nœufs & \n", + "generale/doublon.md": "# A", + "generale/neuve.md": "# Neuve\n", + "generale/notes.md.bak": "Sans titre\n", + "generale/notes.txt": "pas une page", + "perso/MEMORY.md": "# Index mémoire — Perso & Cie\n\nUne ligne par page de ce projet (`- [titre](fichier.md) — accroche`). Tenu à jour automatiquement à la création et à la suppression d'une page ; l'accroche après le titre, elle, est libre.\n- [Titre](notes.md)\n- [Première](perso-1.md)\n", + "perso/doublon.md": "# B", + "perso/notes.md": "## Titre\nmaj\n", + "perso/perso-1.md": "Première\nligne\n" + }, + "state": { + "active": "generale", + "agent": "1", + "compact": "", + "mem_mode": "ondemand", + "projects": [ + { + "created_at": 1000, + "name": "Générale", + "slug": "generale" + }, + { + "created_at": 2000, + "desc": "à moi", + "name": "Perso & Cie", + "slug": "perso" + } + ], + "tracker_raw": {}, + "trackers": {} + } + }, + { + "method": "POST", + "path": "/api/skills/toggle", + "body": "{\"on\":\"oui\"}", + "status": 200, + "out": { + "enabled": false, + "ok": true + }, + "disk": { + "generale/.cache.md": "caché", + "generale/MEMORY.md": "# Index mémoire — Générale\n\n- [Courses v3](courses-v2.md)\n- [Neuve](neuve.md)\n", + "generale/Zeta.MD": "# Zêta\n", + "generale/courses-v2.md": "# Courses v3\n", + "generale/courses-v2.md.bak": "# Courses v2\nœufs & \n", + "generale/doublon.md": "# A", + "generale/neuve.md": "# Neuve\n", + "generale/notes.md.bak": "Sans titre\n", + "generale/notes.txt": "pas une page", + "perso/MEMORY.md": "# Index mémoire — Perso & Cie\n\nUne ligne par page de ce projet (`- [titre](fichier.md) — accroche`). Tenu à jour automatiquement à la création et à la suppression d'une page ; l'accroche après le titre, elle, est libre.\n- [Titre](notes.md)\n- [Première](perso-1.md)\n", + "perso/doublon.md": "# B", + "perso/notes.md": "## Titre\nmaj\n", + "perso/perso-1.md": "Première\nligne\n" + }, + "state": { + "active": "generale", + "agent": "", + "compact": "", + "mem_mode": "ondemand", + "projects": [ + { + "created_at": 1000, + "name": "Générale", + "slug": "generale" + }, + { + "created_at": 2000, + "desc": "à moi", + "name": "Perso & Cie", + "slug": "perso" + } + ], + "tracker_raw": {}, + "trackers": {} + } + }, + { + "method": "POST", + "path": "/api/tools/toggle", + "body": "{\"on\":true}", + "status": 200, + "out": { + "enabled": true, + "ok": true + }, + "disk": { + "generale/.cache.md": "caché", + "generale/MEMORY.md": "# Index mémoire — Générale\n\n- [Courses v3](courses-v2.md)\n- [Neuve](neuve.md)\n", + "generale/Zeta.MD": "# Zêta\n", + "generale/courses-v2.md": "# Courses v3\n", + "generale/courses-v2.md.bak": "# Courses v2\nœufs & \n", + "generale/doublon.md": "# A", + "generale/neuve.md": "# Neuve\n", + "generale/notes.md.bak": "Sans titre\n", + "generale/notes.txt": "pas une page", + "perso/MEMORY.md": "# Index mémoire — Perso & Cie\n\nUne ligne par page de ce projet (`- [titre](fichier.md) — accroche`). Tenu à jour automatiquement à la création et à la suppression d'une page ; l'accroche après le titre, elle, est libre.\n- [Titre](notes.md)\n- [Première](perso-1.md)\n", + "perso/doublon.md": "# B", + "perso/notes.md": "## Titre\nmaj\n", + "perso/perso-1.md": "Première\nligne\n" + }, + "state": { + "active": "generale", + "agent": "1", + "compact": "", + "mem_mode": "ondemand", + "projects": [ + { + "created_at": 1000, + "name": "Générale", + "slug": "generale" + }, + { + "created_at": 2000, + "desc": "à moi", + "name": "Perso & Cie", + "slug": "perso" + } + ], + "tracker_raw": {}, + "trackers": {} + } + }, + { + "method": "POST", + "path": "/api/agent/toggle", + "body": "pas du json", + "status": 200, + "out": { + "enabled": false, + "ok": true + }, + "disk": { + "generale/.cache.md": "caché", + "generale/MEMORY.md": "# Index mémoire — Générale\n\n- [Courses v3](courses-v2.md)\n- [Neuve](neuve.md)\n", + "generale/Zeta.MD": "# Zêta\n", + "generale/courses-v2.md": "# Courses v3\n", + "generale/courses-v2.md.bak": "# Courses v2\nœufs & \n", + "generale/doublon.md": "# A", + "generale/neuve.md": "# Neuve\n", + "generale/notes.md.bak": "Sans titre\n", + "generale/notes.txt": "pas une page", + "perso/MEMORY.md": "# Index mémoire — Perso & Cie\n\nUne ligne par page de ce projet (`- [titre](fichier.md) — accroche`). Tenu à jour automatiquement à la création et à la suppression d'une page ; l'accroche après le titre, elle, est libre.\n- [Titre](notes.md)\n- [Première](perso-1.md)\n", + "perso/doublon.md": "# B", + "perso/notes.md": "## Titre\nmaj\n", + "perso/perso-1.md": "Première\nligne\n" + }, + "state": { + "active": "generale", + "agent": "", + "compact": "", + "mem_mode": "ondemand", + "projects": [ + { + "created_at": 1000, + "name": "Générale", + "slug": "generale" + }, + { + "created_at": 2000, + "desc": "à moi", + "name": "Perso & Cie", + "slug": "perso" + } + ], + "tracker_raw": {}, + "trackers": {} + } + }, + { + "method": "POST", + "path": "/api/agent/compact", + "body": "{\"on\":false}", + "status": 200, + "out": { + "compact": false, + "ok": true + }, + "disk": { + "generale/.cache.md": "caché", + "generale/MEMORY.md": "# Index mémoire — Générale\n\n- [Courses v3](courses-v2.md)\n- [Neuve](neuve.md)\n", + "generale/Zeta.MD": "# Zêta\n", + "generale/courses-v2.md": "# Courses v3\n", + "generale/courses-v2.md.bak": "# Courses v2\nœufs & \n", + "generale/doublon.md": "# A", + "generale/neuve.md": "# Neuve\n", + "generale/notes.md.bak": "Sans titre\n", + "generale/notes.txt": "pas une page", + "perso/MEMORY.md": "# Index mémoire — Perso & Cie\n\nUne ligne par page de ce projet (`- [titre](fichier.md) — accroche`). Tenu à jour automatiquement à la création et à la suppression d'une page ; l'accroche après le titre, elle, est libre.\n- [Titre](notes.md)\n- [Première](perso-1.md)\n", + "perso/doublon.md": "# B", + "perso/notes.md": "## Titre\nmaj\n", + "perso/perso-1.md": "Première\nligne\n" + }, + "state": { + "active": "generale", + "agent": "", + "compact": "off", + "mem_mode": "ondemand", + "projects": [ + { + "created_at": 1000, + "name": "Générale", + "slug": "generale" + }, + { + "created_at": 2000, + "desc": "à moi", + "name": "Perso & Cie", + "slug": "perso" + } + ], + "tracker_raw": {}, + "trackers": {} + } + }, + { + "method": "GET", + "path": "/api/agent", + "status": 200, + "out": { + "compact": false, + "enabled": false, + "mem_mode": "ondemand", + "pages": [ + { + "desc": "Index mémoire — Générale", + "name": "MEMORY.md" + }, + { + "desc": "Zêta", + "name": "Zeta.MD" + }, + { + "desc": "Courses v3", + "name": "courses-v2.md" + }, + { + "desc": "A", + "name": "doublon.md" + }, + { + "desc": "Neuve", + "name": "neuve.md" + } + ], + "skills": [ + { + "desc": "Index mémoire — Générale", + "name": "MEMORY.md" + }, + { + "desc": "Zêta", + "name": "Zeta.MD" + }, + { + "desc": "Courses v3", + "name": "courses-v2.md" + }, + { + "desc": "A", + "name": "doublon.md" + }, + { + "desc": "Neuve", + "name": "neuve.md" + } + ] + }, + "disk": { + "generale/.cache.md": "caché", + "generale/MEMORY.md": "# Index mémoire — Générale\n\n- [Courses v3](courses-v2.md)\n- [Neuve](neuve.md)\n", + "generale/Zeta.MD": "# Zêta\n", + "generale/courses-v2.md": "# Courses v3\n", + "generale/courses-v2.md.bak": "# Courses v2\nœufs & \n", + "generale/doublon.md": "# A", + "generale/neuve.md": "# Neuve\n", + "generale/notes.md.bak": "Sans titre\n", + "generale/notes.txt": "pas une page", + "perso/MEMORY.md": "# Index mémoire — Perso & Cie\n\nUne ligne par page de ce projet (`- [titre](fichier.md) — accroche`). Tenu à jour automatiquement à la création et à la suppression d'une page ; l'accroche après le titre, elle, est libre.\n- [Titre](notes.md)\n- [Première](perso-1.md)\n", + "perso/doublon.md": "# B", + "perso/notes.md": "## Titre\nmaj\n", + "perso/perso-1.md": "Première\nligne\n" + }, + "state": { + "active": "generale", + "agent": "", + "compact": "off", + "mem_mode": "ondemand", + "projects": [ + { + "created_at": 1000, + "name": "Générale", + "slug": "generale" + }, + { + "created_at": 2000, + "desc": "à moi", + "name": "Perso & Cie", + "slug": "perso" + } + ], + "tracker_raw": {}, + "trackers": {} + } + }, + { + "method": "POST", + "path": "/api/agent/compact", + "body": "{}", + "status": 200, + "out": { + "compact": false, + "ok": true + }, + "disk": { + "generale/.cache.md": "caché", + "generale/MEMORY.md": "# Index mémoire — Générale\n\n- [Courses v3](courses-v2.md)\n- [Neuve](neuve.md)\n", + "generale/Zeta.MD": "# Zêta\n", + "generale/courses-v2.md": "# Courses v3\n", + "generale/courses-v2.md.bak": "# Courses v2\nœufs & \n", + "generale/doublon.md": "# A", + "generale/neuve.md": "# Neuve\n", + "generale/notes.md.bak": "Sans titre\n", + "generale/notes.txt": "pas une page", + "perso/MEMORY.md": "# Index mémoire — Perso & Cie\n\nUne ligne par page de ce projet (`- [titre](fichier.md) — accroche`). Tenu à jour automatiquement à la création et à la suppression d'une page ; l'accroche après le titre, elle, est libre.\n- [Titre](notes.md)\n- [Première](perso-1.md)\n", + "perso/doublon.md": "# B", + "perso/notes.md": "## Titre\nmaj\n", + "perso/perso-1.md": "Première\nligne\n" + }, + "state": { + "active": "generale", + "agent": "", + "compact": "off", + "mem_mode": "ondemand", + "projects": [ + { + "created_at": 1000, + "name": "Générale", + "slug": "generale" + }, + { + "created_at": 2000, + "desc": "à moi", + "name": "Perso & Cie", + "slug": "perso" + } + ], + "tracker_raw": {}, + "trackers": {} + } + }, + { + "store": { + "config/COMPACT": " NON " + }, + "method": "GET", + "path": "/api/agent", + "status": 200, + "out": { + "compact": false, + "enabled": false, + "mem_mode": "ondemand", + "pages": [ + { + "desc": "Index mémoire — Générale", + "name": "MEMORY.md" + }, + { + "desc": "Zêta", + "name": "Zeta.MD" + }, + { + "desc": "Courses v3", + "name": "courses-v2.md" + }, + { + "desc": "A", + "name": "doublon.md" + }, + { + "desc": "Neuve", + "name": "neuve.md" + } + ], + "skills": [ + { + "desc": "Index mémoire — Générale", + "name": "MEMORY.md" + }, + { + "desc": "Zêta", + "name": "Zeta.MD" + }, + { + "desc": "Courses v3", + "name": "courses-v2.md" + }, + { + "desc": "A", + "name": "doublon.md" + }, + { + "desc": "Neuve", + "name": "neuve.md" + } + ] + }, + "disk": { + "generale/.cache.md": "caché", + "generale/MEMORY.md": "# Index mémoire — Générale\n\n- [Courses v3](courses-v2.md)\n- [Neuve](neuve.md)\n", + "generale/Zeta.MD": "# Zêta\n", + "generale/courses-v2.md": "# Courses v3\n", + "generale/courses-v2.md.bak": "# Courses v2\nœufs & \n", + "generale/doublon.md": "# A", + "generale/neuve.md": "# Neuve\n", + "generale/notes.md.bak": "Sans titre\n", + "generale/notes.txt": "pas une page", + "perso/MEMORY.md": "# Index mémoire — Perso & Cie\n\nUne ligne par page de ce projet (`- [titre](fichier.md) — accroche`). Tenu à jour automatiquement à la création et à la suppression d'une page ; l'accroche après le titre, elle, est libre.\n- [Titre](notes.md)\n- [Première](perso-1.md)\n", + "perso/doublon.md": "# B", + "perso/notes.md": "## Titre\nmaj\n", + "perso/perso-1.md": "Première\nligne\n" + }, + "state": { + "active": "generale", + "agent": "", + "compact": " NON ", + "mem_mode": "ondemand", + "projects": [ + { + "created_at": 1000, + "name": "Générale", + "slug": "generale" + }, + { + "created_at": 2000, + "desc": "à moi", + "name": "Perso & Cie", + "slug": "perso" + } + ], + "tracker_raw": {}, + "trackers": {} + } + }, + { + "method": "POST", + "path": "/api/agent/compact", + "body": "{\"on\":true}", + "status": 200, + "out": { + "compact": true, + "ok": true + }, + "disk": { + "generale/.cache.md": "caché", + "generale/MEMORY.md": "# Index mémoire — Générale\n\n- [Courses v3](courses-v2.md)\n- [Neuve](neuve.md)\n", + "generale/Zeta.MD": "# Zêta\n", + "generale/courses-v2.md": "# Courses v3\n", + "generale/courses-v2.md.bak": "# Courses v2\nœufs & \n", + "generale/doublon.md": "# A", + "generale/neuve.md": "# Neuve\n", + "generale/notes.md.bak": "Sans titre\n", + "generale/notes.txt": "pas une page", + "perso/MEMORY.md": "# Index mémoire — Perso & Cie\n\nUne ligne par page de ce projet (`- [titre](fichier.md) — accroche`). Tenu à jour automatiquement à la création et à la suppression d'une page ; l'accroche après le titre, elle, est libre.\n- [Titre](notes.md)\n- [Première](perso-1.md)\n", + "perso/doublon.md": "# B", + "perso/notes.md": "## Titre\nmaj\n", + "perso/perso-1.md": "Première\nligne\n" + }, + "state": { + "active": "generale", + "agent": "", + "compact": "", + "mem_mode": "ondemand", + "projects": [ + { + "created_at": 1000, + "name": "Générale", + "slug": "generale" + }, + { + "created_at": 2000, + "desc": "à moi", + "name": "Perso & Cie", + "slug": "perso" + } + ], + "tracker_raw": {}, + "trackers": {} + } + }, + { + "method": "GET", + "path": "/api/memory", + "status": 200, + "out": { + "mode": "ondemand", + "ok": true + }, + "disk": { + "generale/.cache.md": "caché", + "generale/MEMORY.md": "# Index mémoire — Générale\n\n- [Courses v3](courses-v2.md)\n- [Neuve](neuve.md)\n", + "generale/Zeta.MD": "# Zêta\n", + "generale/courses-v2.md": "# Courses v3\n", + "generale/courses-v2.md.bak": "# Courses v2\nœufs & \n", + "generale/doublon.md": "# A", + "generale/neuve.md": "# Neuve\n", + "generale/notes.md.bak": "Sans titre\n", + "generale/notes.txt": "pas une page", + "perso/MEMORY.md": "# Index mémoire — Perso & Cie\n\nUne ligne par page de ce projet (`- [titre](fichier.md) — accroche`). Tenu à jour automatiquement à la création et à la suppression d'une page ; l'accroche après le titre, elle, est libre.\n- [Titre](notes.md)\n- [Première](perso-1.md)\n", + "perso/doublon.md": "# B", + "perso/notes.md": "## Titre\nmaj\n", + "perso/perso-1.md": "Première\nligne\n" + }, + "state": { + "active": "generale", + "agent": "", + "compact": "", + "mem_mode": "ondemand", + "projects": [ + { + "created_at": 1000, + "name": "Générale", + "slug": "generale" + }, + { + "created_at": 2000, + "desc": "à moi", + "name": "Perso & Cie", + "slug": "perso" + } + ], + "tracker_raw": {}, + "trackers": {} + } + }, + { + "method": "POST", + "path": "/api/memory", + "body": "{\"mode\":\" Search \"}", + "status": 200, + "out": { + "mode": "search", + "ok": true + }, + "disk": { + "generale/.cache.md": "caché", + "generale/MEMORY.md": "# Index mémoire — Générale\n\n- [Courses v3](courses-v2.md)\n- [Neuve](neuve.md)\n", + "generale/Zeta.MD": "# Zêta\n", + "generale/courses-v2.md": "# Courses v3\n", + "generale/courses-v2.md.bak": "# Courses v2\nœufs & \n", + "generale/doublon.md": "# A", + "generale/neuve.md": "# Neuve\n", + "generale/notes.md.bak": "Sans titre\n", + "generale/notes.txt": "pas une page", + "perso/MEMORY.md": "# Index mémoire — Perso & Cie\n\nUne ligne par page de ce projet (`- [titre](fichier.md) — accroche`). Tenu à jour automatiquement à la création et à la suppression d'une page ; l'accroche après le titre, elle, est libre.\n- [Titre](notes.md)\n- [Première](perso-1.md)\n", + "perso/doublon.md": "# B", + "perso/notes.md": "## Titre\nmaj\n", + "perso/perso-1.md": "Première\nligne\n" + }, + "state": { + "active": "generale", + "agent": "", + "compact": "", + "mem_mode": "ondemand", + "projects": [ + { + "created_at": 1000, + "mem_mode": "search", + "name": "Générale", + "slug": "generale" + }, + { + "created_at": 2000, + "desc": "à moi", + "name": "Perso & Cie", + "slug": "perso" + } + ], + "tracker_raw": {}, + "trackers": {} + } + }, + { + "method": "GET", + "path": "/api/agent", + "status": 200, + "out": { + "compact": true, + "enabled": false, + "mem_mode": "search", + "pages": [ + { + "desc": "Index mémoire — Générale", + "name": "MEMORY.md" + }, + { + "desc": "Zêta", + "name": "Zeta.MD" + }, + { + "desc": "Courses v3", + "name": "courses-v2.md" + }, + { + "desc": "A", + "name": "doublon.md" + }, + { + "desc": "Neuve", + "name": "neuve.md" + } + ], + "skills": [ + { + "desc": "Index mémoire — Générale", + "name": "MEMORY.md" + }, + { + "desc": "Zêta", + "name": "Zeta.MD" + }, + { + "desc": "Courses v3", + "name": "courses-v2.md" + }, + { + "desc": "A", + "name": "doublon.md" + }, + { + "desc": "Neuve", + "name": "neuve.md" + } + ] + }, + "disk": { + "generale/.cache.md": "caché", + "generale/MEMORY.md": "# Index mémoire — Générale\n\n- [Courses v3](courses-v2.md)\n- [Neuve](neuve.md)\n", + "generale/Zeta.MD": "# Zêta\n", + "generale/courses-v2.md": "# Courses v3\n", + "generale/courses-v2.md.bak": "# Courses v2\nœufs & \n", + "generale/doublon.md": "# A", + "generale/neuve.md": "# Neuve\n", + "generale/notes.md.bak": "Sans titre\n", + "generale/notes.txt": "pas une page", + "perso/MEMORY.md": "# Index mémoire — Perso & Cie\n\nUne ligne par page de ce projet (`- [titre](fichier.md) — accroche`). Tenu à jour automatiquement à la création et à la suppression d'une page ; l'accroche après le titre, elle, est libre.\n- [Titre](notes.md)\n- [Première](perso-1.md)\n", + "perso/doublon.md": "# B", + "perso/notes.md": "## Titre\nmaj\n", + "perso/perso-1.md": "Première\nligne\n" + }, + "state": { + "active": "generale", + "agent": "", + "compact": "", + "mem_mode": "ondemand", + "projects": [ + { + "created_at": 1000, + "mem_mode": "search", + "name": "Générale", + "slug": "generale" + }, + { + "created_at": 2000, + "desc": "à moi", + "name": "Perso & Cie", + "slug": "perso" + } + ], + "tracker_raw": {}, + "trackers": {} + } + }, + { + "method": "POST", + "path": "/api/memory", + "body": "pas du json", + "status": 200, + "out": { + "mode": "always", + "ok": true + }, + "disk": { + "generale/.cache.md": "caché", + "generale/MEMORY.md": "# Index mémoire — Générale\n\n- [Courses v3](courses-v2.md)\n- [Neuve](neuve.md)\n", + "generale/Zeta.MD": "# Zêta\n", + "generale/courses-v2.md": "# Courses v3\n", + "generale/courses-v2.md.bak": "# Courses v2\nœufs & \n", + "generale/doublon.md": "# A", + "generale/neuve.md": "# Neuve\n", + "generale/notes.md.bak": "Sans titre\n", + "generale/notes.txt": "pas une page", + "perso/MEMORY.md": "# Index mémoire — Perso & Cie\n\nUne ligne par page de ce projet (`- [titre](fichier.md) — accroche`). Tenu à jour automatiquement à la création et à la suppression d'une page ; l'accroche après le titre, elle, est libre.\n- [Titre](notes.md)\n- [Première](perso-1.md)\n", + "perso/doublon.md": "# B", + "perso/notes.md": "## Titre\nmaj\n", + "perso/perso-1.md": "Première\nligne\n" + }, + "state": { + "active": "generale", + "agent": "", + "compact": "", + "mem_mode": "ondemand", + "projects": [ + { + "created_at": 1000, + "mem_mode": "always", + "name": "Générale", + "slug": "generale" + }, + { + "created_at": 2000, + "desc": "à moi", + "name": "Perso & Cie", + "slug": "perso" + } + ], + "tracker_raw": {}, + "trackers": {} + } + }, + { + "method": "POST", + "path": "/api/memory", + "body": "{\"mode\":\"off\"}", + "status": 200, + "out": { + "mode": "off", + "ok": true + }, + "disk": { + "generale/.cache.md": "caché", + "generale/MEMORY.md": "# Index mémoire — Générale\n\n- [Courses v3](courses-v2.md)\n- [Neuve](neuve.md)\n", + "generale/Zeta.MD": "# Zêta\n", + "generale/courses-v2.md": "# Courses v3\n", + "generale/courses-v2.md.bak": "# Courses v2\nœufs & \n", + "generale/doublon.md": "# A", + "generale/neuve.md": "# Neuve\n", + "generale/notes.md.bak": "Sans titre\n", + "generale/notes.txt": "pas une page", + "perso/MEMORY.md": "# Index mémoire — Perso & Cie\n\nUne ligne par page de ce projet (`- [titre](fichier.md) — accroche`). Tenu à jour automatiquement à la création et à la suppression d'une page ; l'accroche après le titre, elle, est libre.\n- [Titre](notes.md)\n- [Première](perso-1.md)\n", + "perso/doublon.md": "# B", + "perso/notes.md": "## Titre\nmaj\n", + "perso/perso-1.md": "Première\nligne\n" + }, + "state": { + "active": "generale", + "agent": "", + "compact": "", + "mem_mode": "ondemand", + "projects": [ + { + "created_at": 1000, + "mem_mode": "off", + "name": "Générale", + "slug": "generale" + }, + { + "created_at": 2000, + "desc": "à moi", + "name": "Perso & Cie", + "slug": "perso" + } + ], + "tracker_raw": {}, + "trackers": {} + } + }, + { + "method": "PUT", + "path": "/api/memory", + "body": "{\"mode\":\"ondemand\"}", + "status": 200, + "out": { + "mode": "off", + "ok": true + }, + "disk": { + "generale/.cache.md": "caché", + "generale/MEMORY.md": "# Index mémoire — Générale\n\n- [Courses v3](courses-v2.md)\n- [Neuve](neuve.md)\n", + "generale/Zeta.MD": "# Zêta\n", + "generale/courses-v2.md": "# Courses v3\n", + "generale/courses-v2.md.bak": "# Courses v2\nœufs & \n", + "generale/doublon.md": "# A", + "generale/neuve.md": "# Neuve\n", + "generale/notes.md.bak": "Sans titre\n", + "generale/notes.txt": "pas une page", + "perso/MEMORY.md": "# Index mémoire — Perso & Cie\n\nUne ligne par page de ce projet (`- [titre](fichier.md) — accroche`). Tenu à jour automatiquement à la création et à la suppression d'une page ; l'accroche après le titre, elle, est libre.\n- [Titre](notes.md)\n- [Première](perso-1.md)\n", + "perso/doublon.md": "# B", + "perso/notes.md": "## Titre\nmaj\n", + "perso/perso-1.md": "Première\nligne\n" + }, + "state": { + "active": "generale", + "agent": "", + "compact": "", + "mem_mode": "ondemand", + "projects": [ + { + "created_at": 1000, + "mem_mode": "off", + "name": "Générale", + "slug": "generale" + }, + { + "created_at": 2000, + "desc": "à moi", + "name": "Perso & Cie", + "slug": "perso" + } + ], + "tracker_raw": {}, + "trackers": {} + } + }, + { + "store": { + "state/active_project": "fantome" + }, + "method": "POST", + "path": "/api/memory", + "body": "{\"mode\":\"off\"}", + "status": 500, + "out": { + "error": "projet introuvable", + "ok": false + }, + "disk": { + "generale/.cache.md": "caché", + "generale/MEMORY.md": "# Index mémoire — Générale\n\n- [Courses v3](courses-v2.md)\n- [Neuve](neuve.md)\n", + "generale/Zeta.MD": "# Zêta\n", + "generale/courses-v2.md": "# Courses v3\n", + "generale/courses-v2.md.bak": "# Courses v2\nœufs & \n", + "generale/doublon.md": "# A", + "generale/neuve.md": "# Neuve\n", + "generale/notes.md.bak": "Sans titre\n", + "generale/notes.txt": "pas une page", + "perso/MEMORY.md": "# Index mémoire — Perso & Cie\n\nUne ligne par page de ce projet (`- [titre](fichier.md) — accroche`). Tenu à jour automatiquement à la création et à la suppression d'une page ; l'accroche après le titre, elle, est libre.\n- [Titre](notes.md)\n- [Première](perso-1.md)\n", + "perso/doublon.md": "# B", + "perso/notes.md": "## Titre\nmaj\n", + "perso/perso-1.md": "Première\nligne\n" + }, + "state": { + "active": "fantome", + "agent": "", + "compact": "", + "mem_mode": "ondemand", + "projects": [ + { + "created_at": 1000, + "mem_mode": "off", + "name": "Générale", + "slug": "generale" + }, + { + "created_at": 2000, + "desc": "à moi", + "name": "Perso & Cie", + "slug": "perso" + } + ], + "tracker_raw": {}, + "trackers": {} + } + }, + { + "method": "GET", + "path": "/api/memory", + "status": 200, + "out": { + "mode": "ondemand", + "ok": true + }, + "disk": { + "generale/.cache.md": "caché", + "generale/MEMORY.md": "# Index mémoire — Générale\n\n- [Courses v3](courses-v2.md)\n- [Neuve](neuve.md)\n", + "generale/Zeta.MD": "# Zêta\n", + "generale/courses-v2.md": "# Courses v3\n", + "generale/courses-v2.md.bak": "# Courses v2\nœufs & \n", + "generale/doublon.md": "# A", + "generale/neuve.md": "# Neuve\n", + "generale/notes.md.bak": "Sans titre\n", + "generale/notes.txt": "pas une page", + "perso/MEMORY.md": "# Index mémoire — Perso & Cie\n\nUne ligne par page de ce projet (`- [titre](fichier.md) — accroche`). Tenu à jour automatiquement à la création et à la suppression d'une page ; l'accroche après le titre, elle, est libre.\n- [Titre](notes.md)\n- [Première](perso-1.md)\n", + "perso/doublon.md": "# B", + "perso/notes.md": "## Titre\nmaj\n", + "perso/perso-1.md": "Première\nligne\n" + }, + "state": { + "active": "fantome", + "agent": "", + "compact": "", + "mem_mode": "ondemand", + "projects": [ + { + "created_at": 1000, + "mem_mode": "off", + "name": "Générale", + "slug": "generale" + }, + { + "created_at": 2000, + "desc": "à moi", + "name": "Perso & Cie", + "slug": "perso" + } + ], + "tracker_raw": {}, + "trackers": {} + } + }, + { + "store": { + "state/active_project": "perso" + }, + "method": "POST", + "path": "/api/memory", + "body": "{\"mode\":\"ondemand\"}", + "status": 200, + "out": { + "mode": "ondemand", + "ok": true + }, + "disk": { + "generale/.cache.md": "caché", + "generale/MEMORY.md": "# Index mémoire — Générale\n\n- [Courses v3](courses-v2.md)\n- [Neuve](neuve.md)\n", + "generale/Zeta.MD": "# Zêta\n", + "generale/courses-v2.md": "# Courses v3\n", + "generale/courses-v2.md.bak": "# Courses v2\nœufs & \n", + "generale/doublon.md": "# A", + "generale/neuve.md": "# Neuve\n", + "generale/notes.md.bak": "Sans titre\n", + "generale/notes.txt": "pas une page", + "perso/MEMORY.md": "# Index mémoire — Perso & Cie\n\nUne ligne par page de ce projet (`- [titre](fichier.md) — accroche`). Tenu à jour automatiquement à la création et à la suppression d'une page ; l'accroche après le titre, elle, est libre.\n- [Titre](notes.md)\n- [Première](perso-1.md)\n", + "perso/doublon.md": "# B", + "perso/notes.md": "## Titre\nmaj\n", + "perso/perso-1.md": "Première\nligne\n" + }, + "state": { + "active": "perso", + "agent": "", + "compact": "", + "mem_mode": "ondemand", + "projects": [ + { + "created_at": 1000, + "mem_mode": "off", + "name": "Générale", + "slug": "generale" + }, + { + "created_at": 2000, + "desc": "à moi", + "mem_mode": "ondemand", + "name": "Perso & Cie", + "slug": "perso" + } + ], + "tracker_raw": {}, + "trackers": {} + } + }, + { + "store": { + "state/active_project": "generale" + }, + "method": "GET", + "path": "/api/memory", + "status": 200, + "out": { + "mode": "off", + "ok": true + }, + "disk": { + "generale/.cache.md": "caché", + "generale/MEMORY.md": "# Index mémoire — Générale\n\n- [Courses v3](courses-v2.md)\n- [Neuve](neuve.md)\n", + "generale/Zeta.MD": "# Zêta\n", + "generale/courses-v2.md": "# Courses v3\n", + "generale/courses-v2.md.bak": "# Courses v2\nœufs & \n", + "generale/doublon.md": "# A", + "generale/neuve.md": "# Neuve\n", + "generale/notes.md.bak": "Sans titre\n", + "generale/notes.txt": "pas une page", + "perso/MEMORY.md": "# Index mémoire — Perso & Cie\n\nUne ligne par page de ce projet (`- [titre](fichier.md) — accroche`). Tenu à jour automatiquement à la création et à la suppression d'une page ; l'accroche après le titre, elle, est libre.\n- [Titre](notes.md)\n- [Première](perso-1.md)\n", + "perso/doublon.md": "# B", + "perso/notes.md": "## Titre\nmaj\n", + "perso/perso-1.md": "Première\nligne\n" + }, + "state": { + "active": "generale", + "agent": "", + "compact": "", + "mem_mode": "ondemand", + "projects": [ + { + "created_at": 1000, + "mem_mode": "off", + "name": "Générale", + "slug": "generale" + }, + { + "created_at": 2000, + "desc": "à moi", + "mem_mode": "ondemand", + "name": "Perso & Cie", + "slug": "perso" + } + ], + "tracker_raw": {}, + "trackers": {} + } + }, + { + "method": "GET", + "path": "/api/tracker", + "status": 200, + "out": { + "ok": true, + "trackers": [] + }, + "disk": { + "generale/.cache.md": "caché", + "generale/MEMORY.md": "# Index mémoire — Générale\n\n- [Courses v3](courses-v2.md)\n- [Neuve](neuve.md)\n", + "generale/Zeta.MD": "# Zêta\n", + "generale/courses-v2.md": "# Courses v3\n", + "generale/courses-v2.md.bak": "# Courses v2\nœufs & \n", + "generale/doublon.md": "# A", + "generale/neuve.md": "# Neuve\n", + "generale/notes.md.bak": "Sans titre\n", + "generale/notes.txt": "pas une page", + "perso/MEMORY.md": "# Index mémoire — Perso & Cie\n\nUne ligne par page de ce projet (`- [titre](fichier.md) — accroche`). Tenu à jour automatiquement à la création et à la suppression d'une page ; l'accroche après le titre, elle, est libre.\n- [Titre](notes.md)\n- [Première](perso-1.md)\n", + "perso/doublon.md": "# B", + "perso/notes.md": "## Titre\nmaj\n", + "perso/perso-1.md": "Première\nligne\n" + }, + "state": { + "active": "generale", + "agent": "", + "compact": "", + "mem_mode": "ondemand", + "projects": [ + { + "created_at": 1000, + "mem_mode": "off", + "name": "Générale", + "slug": "generale" + }, + { + "created_at": 2000, + "desc": "à moi", + "mem_mode": "ondemand", + "name": "Perso & Cie", + "slug": "perso" + } + ], + "tracker_raw": {}, + "trackers": {} + } + }, + { + "method": "POST", + "path": "/api/tracker", + "body": "{\"action\":\"add\",\"name\":\"Poids\",\"when\":\"2024-03-10\",\"text\":\" 78,2 kg \"}", + "status": 200, + "out": { + "ok": true, + "trackers": [ + { + "count": 1, + "first_ts": 1710021600000, + "last_text": "78,2 kg", + "last_ts": 1710021600000, + "name": "Poids", + "slug": "poids" + } + ] + }, + "disk": { + "generale/.cache.md": "caché", + "generale/MEMORY.md": "# Index mémoire — Générale\n\n- [Courses v3](courses-v2.md)\n- [Neuve](neuve.md)\n", + "generale/Zeta.MD": "# Zêta\n", + "generale/courses-v2.md": "# Courses v3\n", + "generale/courses-v2.md.bak": "# Courses v2\nœufs & \n", + "generale/doublon.md": "# A", + "generale/neuve.md": "# Neuve\n", + "generale/notes.md.bak": "Sans titre\n", + "generale/notes.txt": "pas une page", + "perso/MEMORY.md": "# Index mémoire — Perso & Cie\n\nUne ligne par page de ce projet (`- [titre](fichier.md) — accroche`). Tenu à jour automatiquement à la création et à la suppression d'une page ; l'accroche après le titre, elle, est libre.\n- [Titre](notes.md)\n- [Première](perso-1.md)\n", + "perso/doublon.md": "# B", + "perso/notes.md": "## Titre\nmaj\n", + "perso/perso-1.md": "Première\nligne\n" + }, + "state": { + "active": "generale", + "agent": "", + "compact": "", + "mem_mode": "ondemand", + "projects": [ + { + "created_at": 1000, + "mem_mode": "off", + "name": "Générale", + "slug": "generale" + }, + { + "created_at": 2000, + "desc": "à moi", + "mem_mode": "ondemand", + "name": "Perso & Cie", + "slug": "perso" + } + ], + "tracker_raw": {}, + "trackers": { + "generale/poids": { + "events": [ + { + "date_only": true, + "id": "{{ID1}}", + "text": "78,2 kg", + "ts": 1710021600000 + } + ], + "name": "Poids" + } + } + } + }, + { + "method": "POST", + "path": "/api/tracker", + "body": "{\"action\":\"add\",\"name\":\"Poids\",\"when\":\"2024-03-09 08:15\",\"text\":\"78,9\"}", + "status": 200, + "out": { + "ok": true, + "trackers": [ + { + "count": 2, + "first_ts": 1709964900000, + "last_text": "78,2 kg", + "last_ts": 1710021600000, + "name": "Poids", + "slug": "poids" + } + ] + }, + "disk": { + "generale/.cache.md": "caché", + "generale/MEMORY.md": "# Index mémoire — Générale\n\n- [Courses v3](courses-v2.md)\n- [Neuve](neuve.md)\n", + "generale/Zeta.MD": "# Zêta\n", + "generale/courses-v2.md": "# Courses v3\n", + "generale/courses-v2.md.bak": "# Courses v2\nœufs & \n", + "generale/doublon.md": "# A", + "generale/neuve.md": "# Neuve\n", + "generale/notes.md.bak": "Sans titre\n", + "generale/notes.txt": "pas une page", + "perso/MEMORY.md": "# Index mémoire — Perso & Cie\n\nUne ligne par page de ce projet (`- [titre](fichier.md) — accroche`). Tenu à jour automatiquement à la création et à la suppression d'une page ; l'accroche après le titre, elle, est libre.\n- [Titre](notes.md)\n- [Première](perso-1.md)\n", + "perso/doublon.md": "# B", + "perso/notes.md": "## Titre\nmaj\n", + "perso/perso-1.md": "Première\nligne\n" + }, + "state": { + "active": "generale", + "agent": "", + "compact": "", + "mem_mode": "ondemand", + "projects": [ + { + "created_at": 1000, + "mem_mode": "off", + "name": "Générale", + "slug": "generale" + }, + { + "created_at": 2000, + "desc": "à moi", + "mem_mode": "ondemand", + "name": "Perso & Cie", + "slug": "perso" + } + ], + "tracker_raw": {}, + "trackers": { + "generale/poids": { + "events": [ + { + "id": "{{ID2}}", + "text": "78,9", + "ts": 1709964900000 + }, + { + "date_only": true, + "id": "{{ID1}}", + "text": "78,2 kg", + "ts": 1710021600000 + } + ], + "name": "Poids" + } + } + } + }, + { + "method": "POST", + "path": "/api/tracker", + "body": "{\"action\":\"add\",\"name\":\"poids\",\"text\":\"77\"}", + "status": 200, + "out": { + "ok": true, + "trackers": [ + { + "count": 3, + "first_ts": 1709964900000, + "last_text": "77", + "last_ts": "{{NOW}}", + "name": "Poids", + "slug": "poids" + } + ] + }, + "disk": { + "generale/.cache.md": "caché", + "generale/MEMORY.md": "# Index mémoire — Générale\n\n- [Courses v3](courses-v2.md)\n- [Neuve](neuve.md)\n", + "generale/Zeta.MD": "# Zêta\n", + "generale/courses-v2.md": "# Courses v3\n", + "generale/courses-v2.md.bak": "# Courses v2\nœufs & \n", + "generale/doublon.md": "# A", + "generale/neuve.md": "# Neuve\n", + "generale/notes.md.bak": "Sans titre\n", + "generale/notes.txt": "pas une page", + "perso/MEMORY.md": "# Index mémoire — Perso & Cie\n\nUne ligne par page de ce projet (`- [titre](fichier.md) — accroche`). Tenu à jour automatiquement à la création et à la suppression d'une page ; l'accroche après le titre, elle, est libre.\n- [Titre](notes.md)\n- [Première](perso-1.md)\n", + "perso/doublon.md": "# B", + "perso/notes.md": "## Titre\nmaj\n", + "perso/perso-1.md": "Première\nligne\n" + }, + "state": { + "active": "generale", + "agent": "", + "compact": "", + "mem_mode": "ondemand", + "projects": [ + { + "created_at": 1000, + "mem_mode": "off", + "name": "Générale", + "slug": "generale" + }, + { + "created_at": 2000, + "desc": "à moi", + "mem_mode": "ondemand", + "name": "Perso & Cie", + "slug": "perso" + } + ], + "tracker_raw": {}, + "trackers": { + "generale/poids": { + "events": [ + { + "id": "{{ID2}}", + "text": "78,9", + "ts": 1709964900000 + }, + { + "date_only": true, + "id": "{{ID1}}", + "text": "78,2 kg", + "ts": 1710021600000 + }, + { + "id": "{{ID3}}", + "text": "77", + "ts": "{{NOW}}" + } + ], + "name": "Poids" + } + } + } + }, + { + "method": "POST", + "path": "/api/tracker", + "body": "{\"action\":\"ADD\",\"name\":\"Réseau NAS\",\"when\":\"2023\",\"text\":\"installé & fini\"}", + "status": 200, + "out": { + "ok": true, + "trackers": [ + { + "count": 3, + "first_ts": 1709964900000, + "last_text": "77", + "last_ts": "{{NOW}}", + "name": "Poids", + "slug": "poids" + }, + { + "count": 1, + "first_ts": 1672524000000, + "last_text": "installé & fini", + "last_ts": 1672524000000, + "name": "Réseau NAS", + "slug": "reseau-nas" + } + ] + }, + "disk": { + "generale/.cache.md": "caché", + "generale/MEMORY.md": "# Index mémoire — Générale\n\n- [Courses v3](courses-v2.md)\n- [Neuve](neuve.md)\n", + "generale/Zeta.MD": "# Zêta\n", + "generale/courses-v2.md": "# Courses v3\n", + "generale/courses-v2.md.bak": "# Courses v2\nœufs & \n", + "generale/doublon.md": "# A", + "generale/neuve.md": "# Neuve\n", + "generale/notes.md.bak": "Sans titre\n", + "generale/notes.txt": "pas une page", + "perso/MEMORY.md": "# Index mémoire — Perso & Cie\n\nUne ligne par page de ce projet (`- [titre](fichier.md) — accroche`). Tenu à jour automatiquement à la création et à la suppression d'une page ; l'accroche après le titre, elle, est libre.\n- [Titre](notes.md)\n- [Première](perso-1.md)\n", + "perso/doublon.md": "# B", + "perso/notes.md": "## Titre\nmaj\n", + "perso/perso-1.md": "Première\nligne\n" + }, + "state": { + "active": "generale", + "agent": "", + "compact": "", + "mem_mode": "ondemand", + "projects": [ + { + "created_at": 1000, + "mem_mode": "off", + "name": "Générale", + "slug": "generale" + }, + { + "created_at": 2000, + "desc": "à moi", + "mem_mode": "ondemand", + "name": "Perso & Cie", + "slug": "perso" + } + ], + "tracker_raw": {}, + "trackers": { + "generale/poids": { + "events": [ + { + "id": "{{ID2}}", + "text": "78,9", + "ts": 1709964900000 + }, + { + "date_only": true, + "id": "{{ID1}}", + "text": "78,2 kg", + "ts": 1710021600000 + }, + { + "id": "{{ID3}}", + "text": "77", + "ts": "{{NOW}}" + } + ], + "name": "Poids" + }, + "generale/reseau-nas": { + "events": [ + { + "date_only": true, + "id": "{{ID4}}", + "text": "installé & fini", + "ts": 1672524000000 + } + ], + "name": "Réseau NAS" + } + } + } + }, + { + "method": "POST", + "path": "/api/tracker", + "body": "{\"action\":\"add\",\"name\":\"\",\"text\":\"x\"}", + "status": 400, + "out": { + "error": "nom de tracker vide", + "ok": false + }, + "disk": { + "generale/.cache.md": "caché", + "generale/MEMORY.md": "# Index mémoire — Générale\n\n- [Courses v3](courses-v2.md)\n- [Neuve](neuve.md)\n", + "generale/Zeta.MD": "# Zêta\n", + "generale/courses-v2.md": "# Courses v3\n", + "generale/courses-v2.md.bak": "# Courses v2\nœufs & \n", + "generale/doublon.md": "# A", + "generale/neuve.md": "# Neuve\n", + "generale/notes.md.bak": "Sans titre\n", + "generale/notes.txt": "pas une page", + "perso/MEMORY.md": "# Index mémoire — Perso & Cie\n\nUne ligne par page de ce projet (`- [titre](fichier.md) — accroche`). Tenu à jour automatiquement à la création et à la suppression d'une page ; l'accroche après le titre, elle, est libre.\n- [Titre](notes.md)\n- [Première](perso-1.md)\n", + "perso/doublon.md": "# B", + "perso/notes.md": "## Titre\nmaj\n", + "perso/perso-1.md": "Première\nligne\n" + }, + "state": { + "active": "generale", + "agent": "", + "compact": "", + "mem_mode": "ondemand", + "projects": [ + { + "created_at": 1000, + "mem_mode": "off", + "name": "Générale", + "slug": "generale" + }, + { + "created_at": 2000, + "desc": "à moi", + "mem_mode": "ondemand", + "name": "Perso & Cie", + "slug": "perso" + } + ], + "tracker_raw": {}, + "trackers": { + "generale/poids": { + "events": [ + { + "id": "{{ID2}}", + "text": "78,9", + "ts": 1709964900000 + }, + { + "date_only": true, + "id": "{{ID1}}", + "text": "78,2 kg", + "ts": 1710021600000 + }, + { + "id": "{{ID3}}", + "text": "77", + "ts": "{{NOW}}" + } + ], + "name": "Poids" + }, + "generale/reseau-nas": { + "events": [ + { + "date_only": true, + "id": "{{ID4}}", + "text": "installé & fini", + "ts": 1672524000000 + } + ], + "name": "Réseau NAS" + } + } + } + }, + { + "method": "POST", + "path": "/api/tracker", + "body": "{\"action\":\"add\",\"name\":\"x\",\"text\":\" \"}", + "status": 400, + "out": { + "error": "contenu vide", + "ok": false + }, + "disk": { + "generale/.cache.md": "caché", + "generale/MEMORY.md": "# Index mémoire — Générale\n\n- [Courses v3](courses-v2.md)\n- [Neuve](neuve.md)\n", + "generale/Zeta.MD": "# Zêta\n", + "generale/courses-v2.md": "# Courses v3\n", + "generale/courses-v2.md.bak": "# Courses v2\nœufs & \n", + "generale/doublon.md": "# A", + "generale/neuve.md": "# Neuve\n", + "generale/notes.md.bak": "Sans titre\n", + "generale/notes.txt": "pas une page", + "perso/MEMORY.md": "# Index mémoire — Perso & Cie\n\nUne ligne par page de ce projet (`- [titre](fichier.md) — accroche`). Tenu à jour automatiquement à la création et à la suppression d'une page ; l'accroche après le titre, elle, est libre.\n- [Titre](notes.md)\n- [Première](perso-1.md)\n", + "perso/doublon.md": "# B", + "perso/notes.md": "## Titre\nmaj\n", + "perso/perso-1.md": "Première\nligne\n" + }, + "state": { + "active": "generale", + "agent": "", + "compact": "", + "mem_mode": "ondemand", + "projects": [ + { + "created_at": 1000, + "mem_mode": "off", + "name": "Générale", + "slug": "generale" + }, + { + "created_at": 2000, + "desc": "à moi", + "mem_mode": "ondemand", + "name": "Perso & Cie", + "slug": "perso" + } + ], + "tracker_raw": {}, + "trackers": { + "generale/poids": { + "events": [ + { + "id": "{{ID2}}", + "text": "78,9", + "ts": 1709964900000 + }, + { + "date_only": true, + "id": "{{ID1}}", + "text": "78,2 kg", + "ts": 1710021600000 + }, + { + "id": "{{ID3}}", + "text": "77", + "ts": "{{NOW}}" + } + ], + "name": "Poids" + }, + "generale/reseau-nas": { + "events": [ + { + "date_only": true, + "id": "{{ID4}}", + "text": "installé & fini", + "ts": 1672524000000 + } + ], + "name": "Réseau NAS" + } + } + } + }, + { + "method": "POST", + "path": "/api/tracker", + "body": "{\"action\":\"add\",\"name\":\"x\",\"when\":\"demain\",\"text\":\"y\"}", + "status": 400, + "out": { + "error": "date non comprise : \"demain\" (attendu AAAA, AAAA-MM, AAAA-MM-JJ ou AAAA-MM-JJ HH:MM)", + "ok": false + }, + "disk": { + "generale/.cache.md": "caché", + "generale/MEMORY.md": "# Index mémoire — Générale\n\n- [Courses v3](courses-v2.md)\n- [Neuve](neuve.md)\n", + "generale/Zeta.MD": "# Zêta\n", + "generale/courses-v2.md": "# Courses v3\n", + "generale/courses-v2.md.bak": "# Courses v2\nœufs & \n", + "generale/doublon.md": "# A", + "generale/neuve.md": "# Neuve\n", + "generale/notes.md.bak": "Sans titre\n", + "generale/notes.txt": "pas une page", + "perso/MEMORY.md": "# Index mémoire — Perso & Cie\n\nUne ligne par page de ce projet (`- [titre](fichier.md) — accroche`). Tenu à jour automatiquement à la création et à la suppression d'une page ; l'accroche après le titre, elle, est libre.\n- [Titre](notes.md)\n- [Première](perso-1.md)\n", + "perso/doublon.md": "# B", + "perso/notes.md": "## Titre\nmaj\n", + "perso/perso-1.md": "Première\nligne\n" + }, + "state": { + "active": "generale", + "agent": "", + "compact": "", + "mem_mode": "ondemand", + "projects": [ + { + "created_at": 1000, + "mem_mode": "off", + "name": "Générale", + "slug": "generale" + }, + { + "created_at": 2000, + "desc": "à moi", + "mem_mode": "ondemand", + "name": "Perso & Cie", + "slug": "perso" + } + ], + "tracker_raw": {}, + "trackers": { + "generale/poids": { + "events": [ + { + "id": "{{ID2}}", + "text": "78,9", + "ts": 1709964900000 + }, + { + "date_only": true, + "id": "{{ID1}}", + "text": "78,2 kg", + "ts": 1710021600000 + }, + { + "id": "{{ID3}}", + "text": "77", + "ts": "{{NOW}}" + } + ], + "name": "Poids" + }, + "generale/reseau-nas": { + "events": [ + { + "date_only": true, + "id": "{{ID4}}", + "text": "installé & fini", + "ts": 1672524000000 + } + ], + "name": "Réseau NAS" + } + } + } + }, + { + "method": "POST", + "path": "/api/tracker", + "body": "{\"action\":\"add\",\"name\":\"!!!\",\"when\":\"2025-01\",\"text\":\"z\"}", + "status": 200, + "out": { + "ok": true, + "trackers": [ + { + "count": 3, + "first_ts": 1709964900000, + "last_text": "77", + "last_ts": "{{NOW}}", + "name": "Poids", + "slug": "poids" + }, + { + "count": 1, + "first_ts": 1735682400000, + "last_text": "z", + "last_ts": 1735682400000, + "name": "!!!", + "slug": "tracker" + }, + { + "count": 1, + "first_ts": 1672524000000, + "last_text": "installé & fini", + "last_ts": 1672524000000, + "name": "Réseau NAS", + "slug": "reseau-nas" + } + ] + }, + "disk": { + "generale/.cache.md": "caché", + "generale/MEMORY.md": "# Index mémoire — Générale\n\n- [Courses v3](courses-v2.md)\n- [Neuve](neuve.md)\n", + "generale/Zeta.MD": "# Zêta\n", + "generale/courses-v2.md": "# Courses v3\n", + "generale/courses-v2.md.bak": "# Courses v2\nœufs & \n", + "generale/doublon.md": "# A", + "generale/neuve.md": "# Neuve\n", + "generale/notes.md.bak": "Sans titre\n", + "generale/notes.txt": "pas une page", + "perso/MEMORY.md": "# Index mémoire — Perso & Cie\n\nUne ligne par page de ce projet (`- [titre](fichier.md) — accroche`). Tenu à jour automatiquement à la création et à la suppression d'une page ; l'accroche après le titre, elle, est libre.\n- [Titre](notes.md)\n- [Première](perso-1.md)\n", + "perso/doublon.md": "# B", + "perso/notes.md": "## Titre\nmaj\n", + "perso/perso-1.md": "Première\nligne\n" + }, + "state": { + "active": "generale", + "agent": "", + "compact": "", + "mem_mode": "ondemand", + "projects": [ + { + "created_at": 1000, + "mem_mode": "off", + "name": "Générale", + "slug": "generale" + }, + { + "created_at": 2000, + "desc": "à moi", + "mem_mode": "ondemand", + "name": "Perso & Cie", + "slug": "perso" + } + ], + "tracker_raw": {}, + "trackers": { + "generale/poids": { + "events": [ + { + "id": "{{ID2}}", + "text": "78,9", + "ts": 1709964900000 + }, + { + "date_only": true, + "id": "{{ID1}}", + "text": "78,2 kg", + "ts": 1710021600000 + }, + { + "id": "{{ID3}}", + "text": "77", + "ts": "{{NOW}}" + } + ], + "name": "Poids" + }, + "generale/reseau-nas": { + "events": [ + { + "date_only": true, + "id": "{{ID4}}", + "text": "installé & fini", + "ts": 1672524000000 + } + ], + "name": "Réseau NAS" + }, + "generale/tracker": { + "events": [ + { + "date_only": true, + "id": "{{ID5}}", + "text": "z", + "ts": 1735682400000 + } + ], + "name": "!!!" + } + } + } + }, + { + "method": "POST", + "path": "/api/tracker", + "body": "{\"action\":\"add\",\"name\":\"Même jour\",\"when\":\"2025-01\",\"text\":\"m\"}", + "status": 200, + "out": { + "ok": true, + "trackers": [ + { + "count": 3, + "first_ts": 1709964900000, + "last_text": "77", + "last_ts": "{{NOW}}", + "name": "Poids", + "slug": "poids" + }, + { + "count": 1, + "first_ts": 1735682400000, + "last_text": "z", + "last_ts": 1735682400000, + "name": "!!!", + "slug": "tracker" + }, + { + "count": 1, + "first_ts": 1735682400000, + "last_text": "m", + "last_ts": 1735682400000, + "name": "Même jour", + "slug": "meme-jour" + }, + { + "count": 1, + "first_ts": 1672524000000, + "last_text": "installé & fini", + "last_ts": 1672524000000, + "name": "Réseau NAS", + "slug": "reseau-nas" + } + ] + }, + "disk": { + "generale/.cache.md": "caché", + "generale/MEMORY.md": "# Index mémoire — Générale\n\n- [Courses v3](courses-v2.md)\n- [Neuve](neuve.md)\n", + "generale/Zeta.MD": "# Zêta\n", + "generale/courses-v2.md": "# Courses v3\n", + "generale/courses-v2.md.bak": "# Courses v2\nœufs & \n", + "generale/doublon.md": "# A", + "generale/neuve.md": "# Neuve\n", + "generale/notes.md.bak": "Sans titre\n", + "generale/notes.txt": "pas une page", + "perso/MEMORY.md": "# Index mémoire — Perso & Cie\n\nUne ligne par page de ce projet (`- [titre](fichier.md) — accroche`). Tenu à jour automatiquement à la création et à la suppression d'une page ; l'accroche après le titre, elle, est libre.\n- [Titre](notes.md)\n- [Première](perso-1.md)\n", + "perso/doublon.md": "# B", + "perso/notes.md": "## Titre\nmaj\n", + "perso/perso-1.md": "Première\nligne\n" + }, + "state": { + "active": "generale", + "agent": "", + "compact": "", + "mem_mode": "ondemand", + "projects": [ + { + "created_at": 1000, + "mem_mode": "off", + "name": "Générale", + "slug": "generale" + }, + { + "created_at": 2000, + "desc": "à moi", + "mem_mode": "ondemand", + "name": "Perso & Cie", + "slug": "perso" + } + ], + "tracker_raw": {}, + "trackers": { + "generale/meme-jour": { + "events": [ + { + "date_only": true, + "id": "{{ID6}}", + "text": "m", + "ts": 1735682400000 + } + ], + "name": "Même jour" + }, + "generale/poids": { + "events": [ + { + "id": "{{ID2}}", + "text": "78,9", + "ts": 1709964900000 + }, + { + "date_only": true, + "id": "{{ID1}}", + "text": "78,2 kg", + "ts": 1710021600000 + }, + { + "id": "{{ID3}}", + "text": "77", + "ts": "{{NOW}}" + } + ], + "name": "Poids" + }, + "generale/reseau-nas": { + "events": [ + { + "date_only": true, + "id": "{{ID4}}", + "text": "installé & fini", + "ts": 1672524000000 + } + ], + "name": "Réseau NAS" + }, + "generale/tracker": { + "events": [ + { + "date_only": true, + "id": "{{ID5}}", + "text": "z", + "ts": 1735682400000 + } + ], + "name": "!!!" + } + } + } + }, + { + "method": "GET", + "path": "/api/tracker?slug=poids", + "status": 200, + "out": { + "ok": true, + "tracker": { + "count": 3, + "events": [ + { + "id": "{{ID2}}", + "text": "78,9", + "ts": 1709964900000 + }, + { + "date_only": true, + "id": "{{ID1}}", + "text": "78,2 kg", + "ts": 1710021600000 + }, + { + "id": "{{ID3}}", + "text": "77", + "ts": "{{NOW}}" + } + ], + "first_ts": 1709964900000, + "last_text": "77", + "last_ts": "{{NOW}}", + "name": "Poids", + "slug": "poids" + } + }, + "disk": { + "generale/.cache.md": "caché", + "generale/MEMORY.md": "# Index mémoire — Générale\n\n- [Courses v3](courses-v2.md)\n- [Neuve](neuve.md)\n", + "generale/Zeta.MD": "# Zêta\n", + "generale/courses-v2.md": "# Courses v3\n", + "generale/courses-v2.md.bak": "# Courses v2\nœufs & \n", + "generale/doublon.md": "# A", + "generale/neuve.md": "# Neuve\n", + "generale/notes.md.bak": "Sans titre\n", + "generale/notes.txt": "pas une page", + "perso/MEMORY.md": "# Index mémoire — Perso & Cie\n\nUne ligne par page de ce projet (`- [titre](fichier.md) — accroche`). Tenu à jour automatiquement à la création et à la suppression d'une page ; l'accroche après le titre, elle, est libre.\n- [Titre](notes.md)\n- [Première](perso-1.md)\n", + "perso/doublon.md": "# B", + "perso/notes.md": "## Titre\nmaj\n", + "perso/perso-1.md": "Première\nligne\n" + }, + "state": { + "active": "generale", + "agent": "", + "compact": "", + "mem_mode": "ondemand", + "projects": [ + { + "created_at": 1000, + "mem_mode": "off", + "name": "Générale", + "slug": "generale" + }, + { + "created_at": 2000, + "desc": "à moi", + "mem_mode": "ondemand", + "name": "Perso & Cie", + "slug": "perso" + } + ], + "tracker_raw": {}, + "trackers": { + "generale/meme-jour": { + "events": [ + { + "date_only": true, + "id": "{{ID6}}", + "text": "m", + "ts": 1735682400000 + } + ], + "name": "Même jour" + }, + "generale/poids": { + "events": [ + { + "id": "{{ID2}}", + "text": "78,9", + "ts": 1709964900000 + }, + { + "date_only": true, + "id": "{{ID1}}", + "text": "78,2 kg", + "ts": 1710021600000 + }, + { + "id": "{{ID3}}", + "text": "77", + "ts": "{{NOW}}" + } + ], + "name": "Poids" + }, + "generale/reseau-nas": { + "events": [ + { + "date_only": true, + "id": "{{ID4}}", + "text": "installé & fini", + "ts": 1672524000000 + } + ], + "name": "Réseau NAS" + }, + "generale/tracker": { + "events": [ + { + "date_only": true, + "id": "{{ID5}}", + "text": "z", + "ts": 1735682400000 + } + ], + "name": "!!!" + } + } + } + }, + { + "method": "GET", + "path": "/api/tracker?slug=absent", + "status": 404, + "out": { + "error": "tracker introuvable", + "ok": false + }, + "disk": { + "generale/.cache.md": "caché", + "generale/MEMORY.md": "# Index mémoire — Générale\n\n- [Courses v3](courses-v2.md)\n- [Neuve](neuve.md)\n", + "generale/Zeta.MD": "# Zêta\n", + "generale/courses-v2.md": "# Courses v3\n", + "generale/courses-v2.md.bak": "# Courses v2\nœufs & \n", + "generale/doublon.md": "# A", + "generale/neuve.md": "# Neuve\n", + "generale/notes.md.bak": "Sans titre\n", + "generale/notes.txt": "pas une page", + "perso/MEMORY.md": "# Index mémoire — Perso & Cie\n\nUne ligne par page de ce projet (`- [titre](fichier.md) — accroche`). Tenu à jour automatiquement à la création et à la suppression d'une page ; l'accroche après le titre, elle, est libre.\n- [Titre](notes.md)\n- [Première](perso-1.md)\n", + "perso/doublon.md": "# B", + "perso/notes.md": "## Titre\nmaj\n", + "perso/perso-1.md": "Première\nligne\n" + }, + "state": { + "active": "generale", + "agent": "", + "compact": "", + "mem_mode": "ondemand", + "projects": [ + { + "created_at": 1000, + "mem_mode": "off", + "name": "Générale", + "slug": "generale" + }, + { + "created_at": 2000, + "desc": "à moi", + "mem_mode": "ondemand", + "name": "Perso & Cie", + "slug": "perso" + } + ], + "tracker_raw": {}, + "trackers": { + "generale/meme-jour": { + "events": [ + { + "date_only": true, + "id": "{{ID6}}", + "text": "m", + "ts": 1735682400000 + } + ], + "name": "Même jour" + }, + "generale/poids": { + "events": [ + { + "id": "{{ID2}}", + "text": "78,9", + "ts": 1709964900000 + }, + { + "date_only": true, + "id": "{{ID1}}", + "text": "78,2 kg", + "ts": 1710021600000 + }, + { + "id": "{{ID3}}", + "text": "77", + "ts": "{{NOW}}" + } + ], + "name": "Poids" + }, + "generale/reseau-nas": { + "events": [ + { + "date_only": true, + "id": "{{ID4}}", + "text": "installé & fini", + "ts": 1672524000000 + } + ], + "name": "Réseau NAS" + }, + "generale/tracker": { + "events": [ + { + "date_only": true, + "id": "{{ID5}}", + "text": "z", + "ts": 1735682400000 + } + ], + "name": "!!!" + } + } + } + }, + { + "method": "POST", + "path": "/api/tracker", + "body": "{\"action\":\"edit\",\"slug\":\"poids\",\"id\":\"{{ID2}}\",\"text\":\" 78,0 kg \"}", + "status": 200, + "out": { + "ok": true, + "trackers": [ + { + "count": 3, + "first_ts": 1709964900000, + "last_text": "77", + "last_ts": "{{NOW}}", + "name": "Poids", + "slug": "poids" + }, + { + "count": 1, + "first_ts": 1735682400000, + "last_text": "z", + "last_ts": 1735682400000, + "name": "!!!", + "slug": "tracker" + }, + { + "count": 1, + "first_ts": 1735682400000, + "last_text": "m", + "last_ts": 1735682400000, + "name": "Même jour", + "slug": "meme-jour" + }, + { + "count": 1, + "first_ts": 1672524000000, + "last_text": "installé & fini", + "last_ts": 1672524000000, + "name": "Réseau NAS", + "slug": "reseau-nas" + } + ] + }, + "disk": { + "generale/.cache.md": "caché", + "generale/MEMORY.md": "# Index mémoire — Générale\n\n- [Courses v3](courses-v2.md)\n- [Neuve](neuve.md)\n", + "generale/Zeta.MD": "# Zêta\n", + "generale/courses-v2.md": "# Courses v3\n", + "generale/courses-v2.md.bak": "# Courses v2\nœufs & \n", + "generale/doublon.md": "# A", + "generale/neuve.md": "# Neuve\n", + "generale/notes.md.bak": "Sans titre\n", + "generale/notes.txt": "pas une page", + "perso/MEMORY.md": "# Index mémoire — Perso & Cie\n\nUne ligne par page de ce projet (`- [titre](fichier.md) — accroche`). Tenu à jour automatiquement à la création et à la suppression d'une page ; l'accroche après le titre, elle, est libre.\n- [Titre](notes.md)\n- [Première](perso-1.md)\n", + "perso/doublon.md": "# B", + "perso/notes.md": "## Titre\nmaj\n", + "perso/perso-1.md": "Première\nligne\n" + }, + "state": { + "active": "generale", + "agent": "", + "compact": "", + "mem_mode": "ondemand", + "projects": [ + { + "created_at": 1000, + "mem_mode": "off", + "name": "Générale", + "slug": "generale" + }, + { + "created_at": 2000, + "desc": "à moi", + "mem_mode": "ondemand", + "name": "Perso & Cie", + "slug": "perso" + } + ], + "tracker_raw": {}, + "trackers": { + "generale/meme-jour": { + "events": [ + { + "date_only": true, + "id": "{{ID6}}", + "text": "m", + "ts": 1735682400000 + } + ], + "name": "Même jour" + }, + "generale/poids": { + "events": [ + { + "id": "{{ID2}}", + "text": "78,0 kg", + "ts": 1709964900000 + }, + { + "date_only": true, + "id": "{{ID1}}", + "text": "78,2 kg", + "ts": 1710021600000 + }, + { + "id": "{{ID3}}", + "text": "77", + "ts": "{{NOW}}" + } + ], + "name": "Poids" + }, + "generale/reseau-nas": { + "events": [ + { + "date_only": true, + "id": "{{ID4}}", + "text": "installé & fini", + "ts": 1672524000000 + } + ], + "name": "Réseau NAS" + }, + "generale/tracker": { + "events": [ + { + "date_only": true, + "id": "{{ID5}}", + "text": "z", + "ts": 1735682400000 + } + ], + "name": "!!!" + } + } + } + }, + { + "method": "POST", + "path": "/api/tracker", + "body": "{\"action\":\"edit\",\"slug\":\"poids\",\"id\":\"{{ID2}}\",\"when\":\"2024-03-11 07:00\"}", + "status": 200, + "out": { + "ok": true, + "trackers": [ + { + "count": 3, + "first_ts": 1710021600000, + "last_text": "77", + "last_ts": "{{NOW}}", + "name": "Poids", + "slug": "poids" + }, + { + "count": 1, + "first_ts": 1735682400000, + "last_text": "z", + "last_ts": 1735682400000, + "name": "!!!", + "slug": "tracker" + }, + { + "count": 1, + "first_ts": 1735682400000, + "last_text": "m", + "last_ts": 1735682400000, + "name": "Même jour", + "slug": "meme-jour" + }, + { + "count": 1, + "first_ts": 1672524000000, + "last_text": "installé & fini", + "last_ts": 1672524000000, + "name": "Réseau NAS", + "slug": "reseau-nas" + } + ] + }, + "disk": { + "generale/.cache.md": "caché", + "generale/MEMORY.md": "# Index mémoire — Générale\n\n- [Courses v3](courses-v2.md)\n- [Neuve](neuve.md)\n", + "generale/Zeta.MD": "# Zêta\n", + "generale/courses-v2.md": "# Courses v3\n", + "generale/courses-v2.md.bak": "# Courses v2\nœufs & \n", + "generale/doublon.md": "# A", + "generale/neuve.md": "# Neuve\n", + "generale/notes.md.bak": "Sans titre\n", + "generale/notes.txt": "pas une page", + "perso/MEMORY.md": "# Index mémoire — Perso & Cie\n\nUne ligne par page de ce projet (`- [titre](fichier.md) — accroche`). Tenu à jour automatiquement à la création et à la suppression d'une page ; l'accroche après le titre, elle, est libre.\n- [Titre](notes.md)\n- [Première](perso-1.md)\n", + "perso/doublon.md": "# B", + "perso/notes.md": "## Titre\nmaj\n", + "perso/perso-1.md": "Première\nligne\n" + }, + "state": { + "active": "generale", + "agent": "", + "compact": "", + "mem_mode": "ondemand", + "projects": [ + { + "created_at": 1000, + "mem_mode": "off", + "name": "Générale", + "slug": "generale" + }, + { + "created_at": 2000, + "desc": "à moi", + "mem_mode": "ondemand", + "name": "Perso & Cie", + "slug": "perso" + } + ], + "tracker_raw": {}, + "trackers": { + "generale/meme-jour": { + "events": [ + { + "date_only": true, + "id": "{{ID6}}", + "text": "m", + "ts": 1735682400000 + } + ], + "name": "Même jour" + }, + "generale/poids": { + "events": [ + { + "date_only": true, + "id": "{{ID1}}", + "text": "78,2 kg", + "ts": 1710021600000 + }, + { + "id": "{{ID2}}", + "text": "78,0 kg", + "ts": 1710133200000 + }, + { + "id": "{{ID3}}", + "text": "77", + "ts": "{{NOW}}" + } + ], + "name": "Poids" + }, + "generale/reseau-nas": { + "events": [ + { + "date_only": true, + "id": "{{ID4}}", + "text": "installé & fini", + "ts": 1672524000000 + } + ], + "name": "Réseau NAS" + }, + "generale/tracker": { + "events": [ + { + "date_only": true, + "id": "{{ID5}}", + "text": "z", + "ts": 1735682400000 + } + ], + "name": "!!!" + } + } + } + }, + { + "method": "POST", + "path": "/api/tracker", + "body": "{\"action\":\"edit\",\"slug\":\"poids\",\"id\":\"{{ID1}}\",\"when\":\"hier\",\"text\":\"perdu\"}", + "status": 400, + "out": { + "error": "date non comprise : \"hier\" (attendu AAAA, AAAA-MM, AAAA-MM-JJ ou AAAA-MM-JJ HH:MM)", + "ok": false + }, + "disk": { + "generale/.cache.md": "caché", + "generale/MEMORY.md": "# Index mémoire — Générale\n\n- [Courses v3](courses-v2.md)\n- [Neuve](neuve.md)\n", + "generale/Zeta.MD": "# Zêta\n", + "generale/courses-v2.md": "# Courses v3\n", + "generale/courses-v2.md.bak": "# Courses v2\nœufs & \n", + "generale/doublon.md": "# A", + "generale/neuve.md": "# Neuve\n", + "generale/notes.md.bak": "Sans titre\n", + "generale/notes.txt": "pas une page", + "perso/MEMORY.md": "# Index mémoire — Perso & Cie\n\nUne ligne par page de ce projet (`- [titre](fichier.md) — accroche`). Tenu à jour automatiquement à la création et à la suppression d'une page ; l'accroche après le titre, elle, est libre.\n- [Titre](notes.md)\n- [Première](perso-1.md)\n", + "perso/doublon.md": "# B", + "perso/notes.md": "## Titre\nmaj\n", + "perso/perso-1.md": "Première\nligne\n" + }, + "state": { + "active": "generale", + "agent": "", + "compact": "", + "mem_mode": "ondemand", + "projects": [ + { + "created_at": 1000, + "mem_mode": "off", + "name": "Générale", + "slug": "generale" + }, + { + "created_at": 2000, + "desc": "à moi", + "mem_mode": "ondemand", + "name": "Perso & Cie", + "slug": "perso" + } + ], + "tracker_raw": {}, + "trackers": { + "generale/meme-jour": { + "events": [ + { + "date_only": true, + "id": "{{ID6}}", + "text": "m", + "ts": 1735682400000 + } + ], + "name": "Même jour" + }, + "generale/poids": { + "events": [ + { + "date_only": true, + "id": "{{ID1}}", + "text": "78,2 kg", + "ts": 1710021600000 + }, + { + "id": "{{ID2}}", + "text": "78,0 kg", + "ts": 1710133200000 + }, + { + "id": "{{ID3}}", + "text": "77", + "ts": "{{NOW}}" + } + ], + "name": "Poids" + }, + "generale/reseau-nas": { + "events": [ + { + "date_only": true, + "id": "{{ID4}}", + "text": "installé & fini", + "ts": 1672524000000 + } + ], + "name": "Réseau NAS" + }, + "generale/tracker": { + "events": [ + { + "date_only": true, + "id": "{{ID5}}", + "text": "z", + "ts": 1735682400000 + } + ], + "name": "!!!" + } + } + } + }, + { + "method": "POST", + "path": "/api/tracker", + "body": "{\"action\":\"edit\",\"slug\":\"poids\",\"id\":\"999\",\"text\":\"x\"}", + "status": 400, + "out": { + "error": "événement introuvable", + "ok": false + }, + "disk": { + "generale/.cache.md": "caché", + "generale/MEMORY.md": "# Index mémoire — Générale\n\n- [Courses v3](courses-v2.md)\n- [Neuve](neuve.md)\n", + "generale/Zeta.MD": "# Zêta\n", + "generale/courses-v2.md": "# Courses v3\n", + "generale/courses-v2.md.bak": "# Courses v2\nœufs & \n", + "generale/doublon.md": "# A", + "generale/neuve.md": "# Neuve\n", + "generale/notes.md.bak": "Sans titre\n", + "generale/notes.txt": "pas une page", + "perso/MEMORY.md": "# Index mémoire — Perso & Cie\n\nUne ligne par page de ce projet (`- [titre](fichier.md) — accroche`). Tenu à jour automatiquement à la création et à la suppression d'une page ; l'accroche après le titre, elle, est libre.\n- [Titre](notes.md)\n- [Première](perso-1.md)\n", + "perso/doublon.md": "# B", + "perso/notes.md": "## Titre\nmaj\n", + "perso/perso-1.md": "Première\nligne\n" + }, + "state": { + "active": "generale", + "agent": "", + "compact": "", + "mem_mode": "ondemand", + "projects": [ + { + "created_at": 1000, + "mem_mode": "off", + "name": "Générale", + "slug": "generale" + }, + { + "created_at": 2000, + "desc": "à moi", + "mem_mode": "ondemand", + "name": "Perso & Cie", + "slug": "perso" + } + ], + "tracker_raw": {}, + "trackers": { + "generale/meme-jour": { + "events": [ + { + "date_only": true, + "id": "{{ID6}}", + "text": "m", + "ts": 1735682400000 + } + ], + "name": "Même jour" + }, + "generale/poids": { + "events": [ + { + "date_only": true, + "id": "{{ID1}}", + "text": "78,2 kg", + "ts": 1710021600000 + }, + { + "id": "{{ID2}}", + "text": "78,0 kg", + "ts": 1710133200000 + }, + { + "id": "{{ID3}}", + "text": "77", + "ts": "{{NOW}}" + } + ], + "name": "Poids" + }, + "generale/reseau-nas": { + "events": [ + { + "date_only": true, + "id": "{{ID4}}", + "text": "installé & fini", + "ts": 1672524000000 + } + ], + "name": "Réseau NAS" + }, + "generale/tracker": { + "events": [ + { + "date_only": true, + "id": "{{ID5}}", + "text": "z", + "ts": 1735682400000 + } + ], + "name": "!!!" + } + } + } + }, + { + "method": "POST", + "path": "/api/tracker", + "body": "{\"action\":\"edit\",\"slug\":\"absent\",\"id\":\"{{ID1}}\",\"text\":\"x\"}", + "status": 400, + "out": { + "error": "tracker introuvable", + "ok": false + }, + "disk": { + "generale/.cache.md": "caché", + "generale/MEMORY.md": "# Index mémoire — Générale\n\n- [Courses v3](courses-v2.md)\n- [Neuve](neuve.md)\n", + "generale/Zeta.MD": "# Zêta\n", + "generale/courses-v2.md": "# Courses v3\n", + "generale/courses-v2.md.bak": "# Courses v2\nœufs & \n", + "generale/doublon.md": "# A", + "generale/neuve.md": "# Neuve\n", + "generale/notes.md.bak": "Sans titre\n", + "generale/notes.txt": "pas une page", + "perso/MEMORY.md": "# Index mémoire — Perso & Cie\n\nUne ligne par page de ce projet (`- [titre](fichier.md) — accroche`). Tenu à jour automatiquement à la création et à la suppression d'une page ; l'accroche après le titre, elle, est libre.\n- [Titre](notes.md)\n- [Première](perso-1.md)\n", + "perso/doublon.md": "# B", + "perso/notes.md": "## Titre\nmaj\n", + "perso/perso-1.md": "Première\nligne\n" + }, + "state": { + "active": "generale", + "agent": "", + "compact": "", + "mem_mode": "ondemand", + "projects": [ + { + "created_at": 1000, + "mem_mode": "off", + "name": "Générale", + "slug": "generale" + }, + { + "created_at": 2000, + "desc": "à moi", + "mem_mode": "ondemand", + "name": "Perso & Cie", + "slug": "perso" + } + ], + "tracker_raw": {}, + "trackers": { + "generale/meme-jour": { + "events": [ + { + "date_only": true, + "id": "{{ID6}}", + "text": "m", + "ts": 1735682400000 + } + ], + "name": "Même jour" + }, + "generale/poids": { + "events": [ + { + "date_only": true, + "id": "{{ID1}}", + "text": "78,2 kg", + "ts": 1710021600000 + }, + { + "id": "{{ID2}}", + "text": "78,0 kg", + "ts": 1710133200000 + }, + { + "id": "{{ID3}}", + "text": "77", + "ts": "{{NOW}}" + } + ], + "name": "Poids" + }, + "generale/reseau-nas": { + "events": [ + { + "date_only": true, + "id": "{{ID4}}", + "text": "installé & fini", + "ts": 1672524000000 + } + ], + "name": "Réseau NAS" + }, + "generale/tracker": { + "events": [ + { + "date_only": true, + "id": "{{ID5}}", + "text": "z", + "ts": 1735682400000 + } + ], + "name": "!!!" + } + } + } + }, + { + "method": "POST", + "path": "/api/tracker", + "body": "{\"action\":\"delete\",\"slug\":\"poids\",\"id\":\"{{ID1}}\"}", + "status": 200, + "out": { + "ok": true, + "trackers": [ + { + "count": 2, + "first_ts": 1710133200000, + "last_text": "77", + "last_ts": "{{NOW}}", + "name": "Poids", + "slug": "poids" + }, + { + "count": 1, + "first_ts": 1735682400000, + "last_text": "z", + "last_ts": 1735682400000, + "name": "!!!", + "slug": "tracker" + }, + { + "count": 1, + "first_ts": 1735682400000, + "last_text": "m", + "last_ts": 1735682400000, + "name": "Même jour", + "slug": "meme-jour" + }, + { + "count": 1, + "first_ts": 1672524000000, + "last_text": "installé & fini", + "last_ts": 1672524000000, + "name": "Réseau NAS", + "slug": "reseau-nas" + } + ] + }, + "disk": { + "generale/.cache.md": "caché", + "generale/MEMORY.md": "# Index mémoire — Générale\n\n- [Courses v3](courses-v2.md)\n- [Neuve](neuve.md)\n", + "generale/Zeta.MD": "# Zêta\n", + "generale/courses-v2.md": "# Courses v3\n", + "generale/courses-v2.md.bak": "# Courses v2\nœufs & \n", + "generale/doublon.md": "# A", + "generale/neuve.md": "# Neuve\n", + "generale/notes.md.bak": "Sans titre\n", + "generale/notes.txt": "pas une page", + "perso/MEMORY.md": "# Index mémoire — Perso & Cie\n\nUne ligne par page de ce projet (`- [titre](fichier.md) — accroche`). Tenu à jour automatiquement à la création et à la suppression d'une page ; l'accroche après le titre, elle, est libre.\n- [Titre](notes.md)\n- [Première](perso-1.md)\n", + "perso/doublon.md": "# B", + "perso/notes.md": "## Titre\nmaj\n", + "perso/perso-1.md": "Première\nligne\n" + }, + "state": { + "active": "generale", + "agent": "", + "compact": "", + "mem_mode": "ondemand", + "projects": [ + { + "created_at": 1000, + "mem_mode": "off", + "name": "Générale", + "slug": "generale" + }, + { + "created_at": 2000, + "desc": "à moi", + "mem_mode": "ondemand", + "name": "Perso & Cie", + "slug": "perso" + } + ], + "tracker_raw": {}, + "trackers": { + "generale/meme-jour": { + "events": [ + { + "date_only": true, + "id": "{{ID6}}", + "text": "m", + "ts": 1735682400000 + } + ], + "name": "Même jour" + }, + "generale/poids": { + "events": [ + { + "id": "{{ID2}}", + "text": "78,0 kg", + "ts": 1710133200000 + }, + { + "id": "{{ID3}}", + "text": "77", + "ts": "{{NOW}}" + } + ], + "name": "Poids" + }, + "generale/reseau-nas": { + "events": [ + { + "date_only": true, + "id": "{{ID4}}", + "text": "installé & fini", + "ts": 1672524000000 + } + ], + "name": "Réseau NAS" + }, + "generale/tracker": { + "events": [ + { + "date_only": true, + "id": "{{ID5}}", + "text": "z", + "ts": 1735682400000 + } + ], + "name": "!!!" + } + } + } + }, + { + "method": "POST", + "path": "/api/tracker", + "body": "{\"action\":\"delete\",\"slug\":\"poids\",\"id\":\"{{ID1}}\"}", + "status": 400, + "out": { + "error": "événement introuvable", + "ok": false + }, + "disk": { + "generale/.cache.md": "caché", + "generale/MEMORY.md": "# Index mémoire — Générale\n\n- [Courses v3](courses-v2.md)\n- [Neuve](neuve.md)\n", + "generale/Zeta.MD": "# Zêta\n", + "generale/courses-v2.md": "# Courses v3\n", + "generale/courses-v2.md.bak": "# Courses v2\nœufs & \n", + "generale/doublon.md": "# A", + "generale/neuve.md": "# Neuve\n", + "generale/notes.md.bak": "Sans titre\n", + "generale/notes.txt": "pas une page", + "perso/MEMORY.md": "# Index mémoire — Perso & Cie\n\nUne ligne par page de ce projet (`- [titre](fichier.md) — accroche`). Tenu à jour automatiquement à la création et à la suppression d'une page ; l'accroche après le titre, elle, est libre.\n- [Titre](notes.md)\n- [Première](perso-1.md)\n", + "perso/doublon.md": "# B", + "perso/notes.md": "## Titre\nmaj\n", + "perso/perso-1.md": "Première\nligne\n" + }, + "state": { + "active": "generale", + "agent": "", + "compact": "", + "mem_mode": "ondemand", + "projects": [ + { + "created_at": 1000, + "mem_mode": "off", + "name": "Générale", + "slug": "generale" + }, + { + "created_at": 2000, + "desc": "à moi", + "mem_mode": "ondemand", + "name": "Perso & Cie", + "slug": "perso" + } + ], + "tracker_raw": {}, + "trackers": { + "generale/meme-jour": { + "events": [ + { + "date_only": true, + "id": "{{ID6}}", + "text": "m", + "ts": 1735682400000 + } + ], + "name": "Même jour" + }, + "generale/poids": { + "events": [ + { + "id": "{{ID2}}", + "text": "78,0 kg", + "ts": 1710133200000 + }, + { + "id": "{{ID3}}", + "text": "77", + "ts": "{{NOW}}" + } + ], + "name": "Poids" + }, + "generale/reseau-nas": { + "events": [ + { + "date_only": true, + "id": "{{ID4}}", + "text": "installé & fini", + "ts": 1672524000000 + } + ], + "name": "Réseau NAS" + }, + "generale/tracker": { + "events": [ + { + "date_only": true, + "id": "{{ID5}}", + "text": "z", + "ts": 1735682400000 + } + ], + "name": "!!!" + } + } + } + }, + { + "method": "POST", + "path": "/api/tracker", + "body": "{\"action\":\"rename\",\"slug\":\"reseau-nas\",\"name\":\" Réseau NAS (bureau) \"}", + "status": 200, + "out": { + "ok": true, + "trackers": [ + { + "count": 2, + "first_ts": 1710133200000, + "last_text": "77", + "last_ts": "{{NOW}}", + "name": "Poids", + "slug": "poids" + }, + { + "count": 1, + "first_ts": 1735682400000, + "last_text": "z", + "last_ts": 1735682400000, + "name": "!!!", + "slug": "tracker" + }, + { + "count": 1, + "first_ts": 1735682400000, + "last_text": "m", + "last_ts": 1735682400000, + "name": "Même jour", + "slug": "meme-jour" + }, + { + "count": 1, + "first_ts": 1672524000000, + "last_text": "installé & fini", + "last_ts": 1672524000000, + "name": "Réseau NAS (bureau)", + "slug": "reseau-nas-bureau" + } + ] + }, + "disk": { + "generale/.cache.md": "caché", + "generale/MEMORY.md": "# Index mémoire — Générale\n\n- [Courses v3](courses-v2.md)\n- [Neuve](neuve.md)\n", + "generale/Zeta.MD": "# Zêta\n", + "generale/courses-v2.md": "# Courses v3\n", + "generale/courses-v2.md.bak": "# Courses v2\nœufs & \n", + "generale/doublon.md": "# A", + "generale/neuve.md": "# Neuve\n", + "generale/notes.md.bak": "Sans titre\n", + "generale/notes.txt": "pas une page", + "perso/MEMORY.md": "# Index mémoire — Perso & Cie\n\nUne ligne par page de ce projet (`- [titre](fichier.md) — accroche`). Tenu à jour automatiquement à la création et à la suppression d'une page ; l'accroche après le titre, elle, est libre.\n- [Titre](notes.md)\n- [Première](perso-1.md)\n", + "perso/doublon.md": "# B", + "perso/notes.md": "## Titre\nmaj\n", + "perso/perso-1.md": "Première\nligne\n" + }, + "state": { + "active": "generale", + "agent": "", + "compact": "", + "mem_mode": "ondemand", + "projects": [ + { + "created_at": 1000, + "mem_mode": "off", + "name": "Générale", + "slug": "generale" + }, + { + "created_at": 2000, + "desc": "à moi", + "mem_mode": "ondemand", + "name": "Perso & Cie", + "slug": "perso" + } + ], + "tracker_raw": {}, + "trackers": { + "generale/meme-jour": { + "events": [ + { + "date_only": true, + "id": "{{ID6}}", + "text": "m", + "ts": 1735682400000 + } + ], + "name": "Même jour" + }, + "generale/poids": { + "events": [ + { + "id": "{{ID2}}", + "text": "78,0 kg", + "ts": 1710133200000 + }, + { + "id": "{{ID3}}", + "text": "77", + "ts": "{{NOW}}" + } + ], + "name": "Poids" + }, + "generale/reseau-nas-bureau": { + "events": [ + { + "date_only": true, + "id": "{{ID4}}", + "text": "installé & fini", + "ts": 1672524000000 + } + ], + "name": "Réseau NAS (bureau)" + }, + "generale/tracker": { + "events": [ + { + "date_only": true, + "id": "{{ID5}}", + "text": "z", + "ts": 1735682400000 + } + ], + "name": "!!!" + } + } + } + }, + { + "method": "POST", + "path": "/api/tracker", + "body": "{\"action\":\"rename\",\"slug\":\"poids\",\"name\":\"POIDS\"}", + "status": 200, + "out": { + "ok": true, + "trackers": [ + { + "count": 2, + "first_ts": 1710133200000, + "last_text": "77", + "last_ts": "{{NOW}}", + "name": "POIDS", + "slug": "poids" + }, + { + "count": 1, + "first_ts": 1735682400000, + "last_text": "z", + "last_ts": 1735682400000, + "name": "!!!", + "slug": "tracker" + }, + { + "count": 1, + "first_ts": 1735682400000, + "last_text": "m", + "last_ts": 1735682400000, + "name": "Même jour", + "slug": "meme-jour" + }, + { + "count": 1, + "first_ts": 1672524000000, + "last_text": "installé & fini", + "last_ts": 1672524000000, + "name": "Réseau NAS (bureau)", + "slug": "reseau-nas-bureau" + } + ] + }, + "disk": { + "generale/.cache.md": "caché", + "generale/MEMORY.md": "# Index mémoire — Générale\n\n- [Courses v3](courses-v2.md)\n- [Neuve](neuve.md)\n", + "generale/Zeta.MD": "# Zêta\n", + "generale/courses-v2.md": "# Courses v3\n", + "generale/courses-v2.md.bak": "# Courses v2\nœufs & \n", + "generale/doublon.md": "# A", + "generale/neuve.md": "# Neuve\n", + "generale/notes.md.bak": "Sans titre\n", + "generale/notes.txt": "pas une page", + "perso/MEMORY.md": "# Index mémoire — Perso & Cie\n\nUne ligne par page de ce projet (`- [titre](fichier.md) — accroche`). Tenu à jour automatiquement à la création et à la suppression d'une page ; l'accroche après le titre, elle, est libre.\n- [Titre](notes.md)\n- [Première](perso-1.md)\n", + "perso/doublon.md": "# B", + "perso/notes.md": "## Titre\nmaj\n", + "perso/perso-1.md": "Première\nligne\n" + }, + "state": { + "active": "generale", + "agent": "", + "compact": "", + "mem_mode": "ondemand", + "projects": [ + { + "created_at": 1000, + "mem_mode": "off", + "name": "Générale", + "slug": "generale" + }, + { + "created_at": 2000, + "desc": "à moi", + "mem_mode": "ondemand", + "name": "Perso & Cie", + "slug": "perso" + } + ], + "tracker_raw": {}, + "trackers": { + "generale/meme-jour": { + "events": [ + { + "date_only": true, + "id": "{{ID6}}", + "text": "m", + "ts": 1735682400000 + } + ], + "name": "Même jour" + }, + "generale/poids": { + "events": [ + { + "id": "{{ID2}}", + "text": "78,0 kg", + "ts": 1710133200000 + }, + { + "id": "{{ID3}}", + "text": "77", + "ts": "{{NOW}}" + } + ], + "name": "POIDS" + }, + "generale/reseau-nas-bureau": { + "events": [ + { + "date_only": true, + "id": "{{ID4}}", + "text": "installé & fini", + "ts": 1672524000000 + } + ], + "name": "Réseau NAS (bureau)" + }, + "generale/tracker": { + "events": [ + { + "date_only": true, + "id": "{{ID5}}", + "text": "z", + "ts": 1735682400000 + } + ], + "name": "!!!" + } + } + } + }, + { + "method": "POST", + "path": "/api/tracker", + "body": "{\"action\":\"rename\",\"slug\":\"tracker\",\"name\":\"Poids\"}", + "status": 400, + "out": { + "error": "un tracker du même nom existe déjà", + "ok": false + }, + "disk": { + "generale/.cache.md": "caché", + "generale/MEMORY.md": "# Index mémoire — Générale\n\n- [Courses v3](courses-v2.md)\n- [Neuve](neuve.md)\n", + "generale/Zeta.MD": "# Zêta\n", + "generale/courses-v2.md": "# Courses v3\n", + "generale/courses-v2.md.bak": "# Courses v2\nœufs & \n", + "generale/doublon.md": "# A", + "generale/neuve.md": "# Neuve\n", + "generale/notes.md.bak": "Sans titre\n", + "generale/notes.txt": "pas une page", + "perso/MEMORY.md": "# Index mémoire — Perso & Cie\n\nUne ligne par page de ce projet (`- [titre](fichier.md) — accroche`). Tenu à jour automatiquement à la création et à la suppression d'une page ; l'accroche après le titre, elle, est libre.\n- [Titre](notes.md)\n- [Première](perso-1.md)\n", + "perso/doublon.md": "# B", + "perso/notes.md": "## Titre\nmaj\n", + "perso/perso-1.md": "Première\nligne\n" + }, + "state": { + "active": "generale", + "agent": "", + "compact": "", + "mem_mode": "ondemand", + "projects": [ + { + "created_at": 1000, + "mem_mode": "off", + "name": "Générale", + "slug": "generale" + }, + { + "created_at": 2000, + "desc": "à moi", + "mem_mode": "ondemand", + "name": "Perso & Cie", + "slug": "perso" + } + ], + "tracker_raw": {}, + "trackers": { + "generale/meme-jour": { + "events": [ + { + "date_only": true, + "id": "{{ID6}}", + "text": "m", + "ts": 1735682400000 + } + ], + "name": "Même jour" + }, + "generale/poids": { + "events": [ + { + "id": "{{ID2}}", + "text": "78,0 kg", + "ts": 1710133200000 + }, + { + "id": "{{ID3}}", + "text": "77", + "ts": "{{NOW}}" + } + ], + "name": "POIDS" + }, + "generale/reseau-nas-bureau": { + "events": [ + { + "date_only": true, + "id": "{{ID4}}", + "text": "installé & fini", + "ts": 1672524000000 + } + ], + "name": "Réseau NAS (bureau)" + }, + "generale/tracker": { + "events": [ + { + "date_only": true, + "id": "{{ID5}}", + "text": "z", + "ts": 1735682400000 + } + ], + "name": "!!!" + } + } + } + }, + { + "method": "POST", + "path": "/api/tracker", + "body": "{\"action\":\"rename\",\"slug\":\"tracker\",\"name\":\" \"}", + "status": 400, + "out": { + "error": "nom vide", + "ok": false + }, + "disk": { + "generale/.cache.md": "caché", + "generale/MEMORY.md": "# Index mémoire — Générale\n\n- [Courses v3](courses-v2.md)\n- [Neuve](neuve.md)\n", + "generale/Zeta.MD": "# Zêta\n", + "generale/courses-v2.md": "# Courses v3\n", + "generale/courses-v2.md.bak": "# Courses v2\nœufs & \n", + "generale/doublon.md": "# A", + "generale/neuve.md": "# Neuve\n", + "generale/notes.md.bak": "Sans titre\n", + "generale/notes.txt": "pas une page", + "perso/MEMORY.md": "# Index mémoire — Perso & Cie\n\nUne ligne par page de ce projet (`- [titre](fichier.md) — accroche`). Tenu à jour automatiquement à la création et à la suppression d'une page ; l'accroche après le titre, elle, est libre.\n- [Titre](notes.md)\n- [Première](perso-1.md)\n", + "perso/doublon.md": "# B", + "perso/notes.md": "## Titre\nmaj\n", + "perso/perso-1.md": "Première\nligne\n" + }, + "state": { + "active": "generale", + "agent": "", + "compact": "", + "mem_mode": "ondemand", + "projects": [ + { + "created_at": 1000, + "mem_mode": "off", + "name": "Générale", + "slug": "generale" + }, + { + "created_at": 2000, + "desc": "à moi", + "mem_mode": "ondemand", + "name": "Perso & Cie", + "slug": "perso" + } + ], + "tracker_raw": {}, + "trackers": { + "generale/meme-jour": { + "events": [ + { + "date_only": true, + "id": "{{ID6}}", + "text": "m", + "ts": 1735682400000 + } + ], + "name": "Même jour" + }, + "generale/poids": { + "events": [ + { + "id": "{{ID2}}", + "text": "78,0 kg", + "ts": 1710133200000 + }, + { + "id": "{{ID3}}", + "text": "77", + "ts": "{{NOW}}" + } + ], + "name": "POIDS" + }, + "generale/reseau-nas-bureau": { + "events": [ + { + "date_only": true, + "id": "{{ID4}}", + "text": "installé & fini", + "ts": 1672524000000 + } + ], + "name": "Réseau NAS (bureau)" + }, + "generale/tracker": { + "events": [ + { + "date_only": true, + "id": "{{ID5}}", + "text": "z", + "ts": 1735682400000 + } + ], + "name": "!!!" + } + } + } + }, + { + "method": "POST", + "path": "/api/tracker", + "body": "{\"action\":\"rename\",\"slug\":\"tracker\",\"name\":\"???\"}", + "status": 400, + "out": { + "error": "nom invalide", + "ok": false + }, + "disk": { + "generale/.cache.md": "caché", + "generale/MEMORY.md": "# Index mémoire — Générale\n\n- [Courses v3](courses-v2.md)\n- [Neuve](neuve.md)\n", + "generale/Zeta.MD": "# Zêta\n", + "generale/courses-v2.md": "# Courses v3\n", + "generale/courses-v2.md.bak": "# Courses v2\nœufs & \n", + "generale/doublon.md": "# A", + "generale/neuve.md": "# Neuve\n", + "generale/notes.md.bak": "Sans titre\n", + "generale/notes.txt": "pas une page", + "perso/MEMORY.md": "# Index mémoire — Perso & Cie\n\nUne ligne par page de ce projet (`- [titre](fichier.md) — accroche`). Tenu à jour automatiquement à la création et à la suppression d'une page ; l'accroche après le titre, elle, est libre.\n- [Titre](notes.md)\n- [Première](perso-1.md)\n", + "perso/doublon.md": "# B", + "perso/notes.md": "## Titre\nmaj\n", + "perso/perso-1.md": "Première\nligne\n" + }, + "state": { + "active": "generale", + "agent": "", + "compact": "", + "mem_mode": "ondemand", + "projects": [ + { + "created_at": 1000, + "mem_mode": "off", + "name": "Générale", + "slug": "generale" + }, + { + "created_at": 2000, + "desc": "à moi", + "mem_mode": "ondemand", + "name": "Perso & Cie", + "slug": "perso" + } + ], + "tracker_raw": {}, + "trackers": { + "generale/meme-jour": { + "events": [ + { + "date_only": true, + "id": "{{ID6}}", + "text": "m", + "ts": 1735682400000 + } + ], + "name": "Même jour" + }, + "generale/poids": { + "events": [ + { + "id": "{{ID2}}", + "text": "78,0 kg", + "ts": 1710133200000 + }, + { + "id": "{{ID3}}", + "text": "77", + "ts": "{{NOW}}" + } + ], + "name": "POIDS" + }, + "generale/reseau-nas-bureau": { + "events": [ + { + "date_only": true, + "id": "{{ID4}}", + "text": "installé & fini", + "ts": 1672524000000 + } + ], + "name": "Réseau NAS (bureau)" + }, + "generale/tracker": { + "events": [ + { + "date_only": true, + "id": "{{ID5}}", + "text": "z", + "ts": 1735682400000 + } + ], + "name": "!!!" + } + } + } + }, + { + "method": "POST", + "path": "/api/tracker", + "body": "{\"action\":\"rename\",\"slug\":\"absent\",\"name\":\"Neuf\"}", + "status": 400, + "out": { + "error": "tracker introuvable", + "ok": false + }, + "disk": { + "generale/.cache.md": "caché", + "generale/MEMORY.md": "# Index mémoire — Générale\n\n- [Courses v3](courses-v2.md)\n- [Neuve](neuve.md)\n", + "generale/Zeta.MD": "# Zêta\n", + "generale/courses-v2.md": "# Courses v3\n", + "generale/courses-v2.md.bak": "# Courses v2\nœufs & \n", + "generale/doublon.md": "# A", + "generale/neuve.md": "# Neuve\n", + "generale/notes.md.bak": "Sans titre\n", + "generale/notes.txt": "pas une page", + "perso/MEMORY.md": "# Index mémoire — Perso & Cie\n\nUne ligne par page de ce projet (`- [titre](fichier.md) — accroche`). Tenu à jour automatiquement à la création et à la suppression d'une page ; l'accroche après le titre, elle, est libre.\n- [Titre](notes.md)\n- [Première](perso-1.md)\n", + "perso/doublon.md": "# B", + "perso/notes.md": "## Titre\nmaj\n", + "perso/perso-1.md": "Première\nligne\n" + }, + "state": { + "active": "generale", + "agent": "", + "compact": "", + "mem_mode": "ondemand", + "projects": [ + { + "created_at": 1000, + "mem_mode": "off", + "name": "Générale", + "slug": "generale" + }, + { + "created_at": 2000, + "desc": "à moi", + "mem_mode": "ondemand", + "name": "Perso & Cie", + "slug": "perso" + } + ], + "tracker_raw": {}, + "trackers": { + "generale/meme-jour": { + "events": [ + { + "date_only": true, + "id": "{{ID6}}", + "text": "m", + "ts": 1735682400000 + } + ], + "name": "Même jour" + }, + "generale/poids": { + "events": [ + { + "id": "{{ID2}}", + "text": "78,0 kg", + "ts": 1710133200000 + }, + { + "id": "{{ID3}}", + "text": "77", + "ts": "{{NOW}}" + } + ], + "name": "POIDS" + }, + "generale/reseau-nas-bureau": { + "events": [ + { + "date_only": true, + "id": "{{ID4}}", + "text": "installé & fini", + "ts": 1672524000000 + } + ], + "name": "Réseau NAS (bureau)" + }, + "generale/tracker": { + "events": [ + { + "date_only": true, + "id": "{{ID5}}", + "text": "z", + "ts": 1735682400000 + } + ], + "name": "!!!" + } + } + } + }, + { + "method": "POST", + "path": "/api/tracker", + "body": "{\"action\":\"move\",\"slug\":\"tracker\",\"to\":\"perso\"}", + "status": 200, + "out": { + "ok": true, + "trackers": [ + { + "count": 2, + "first_ts": 1710133200000, + "last_text": "77", + "last_ts": "{{NOW}}", + "name": "POIDS", + "slug": "poids" + }, + { + "count": 1, + "first_ts": 1735682400000, + "last_text": "m", + "last_ts": 1735682400000, + "name": "Même jour", + "slug": "meme-jour" + }, + { + "count": 1, + "first_ts": 1672524000000, + "last_text": "installé & fini", + "last_ts": 1672524000000, + "name": "Réseau NAS (bureau)", + "slug": "reseau-nas-bureau" + } + ] + }, + "disk": { + "generale/.cache.md": "caché", + "generale/MEMORY.md": "# Index mémoire — Générale\n\n- [Courses v3](courses-v2.md)\n- [Neuve](neuve.md)\n", + "generale/Zeta.MD": "# Zêta\n", + "generale/courses-v2.md": "# Courses v3\n", + "generale/courses-v2.md.bak": "# Courses v2\nœufs & \n", + "generale/doublon.md": "# A", + "generale/neuve.md": "# Neuve\n", + "generale/notes.md.bak": "Sans titre\n", + "generale/notes.txt": "pas une page", + "perso/MEMORY.md": "# Index mémoire — Perso & Cie\n\nUne ligne par page de ce projet (`- [titre](fichier.md) — accroche`). Tenu à jour automatiquement à la création et à la suppression d'une page ; l'accroche après le titre, elle, est libre.\n- [Titre](notes.md)\n- [Première](perso-1.md)\n", + "perso/doublon.md": "# B", + "perso/notes.md": "## Titre\nmaj\n", + "perso/perso-1.md": "Première\nligne\n" + }, + "state": { + "active": "generale", + "agent": "", + "compact": "", + "mem_mode": "ondemand", + "projects": [ + { + "created_at": 1000, + "mem_mode": "off", + "name": "Générale", + "slug": "generale" + }, + { + "created_at": 2000, + "desc": "à moi", + "mem_mode": "ondemand", + "name": "Perso & Cie", + "slug": "perso" + } + ], + "tracker_raw": {}, + "trackers": { + "generale/meme-jour": { + "events": [ + { + "date_only": true, + "id": "{{ID6}}", + "text": "m", + "ts": 1735682400000 + } + ], + "name": "Même jour" + }, + "generale/poids": { + "events": [ + { + "id": "{{ID2}}", + "text": "78,0 kg", + "ts": 1710133200000 + }, + { + "id": "{{ID3}}", + "text": "77", + "ts": "{{NOW}}" + } + ], + "name": "POIDS" + }, + "generale/reseau-nas-bureau": { + "events": [ + { + "date_only": true, + "id": "{{ID4}}", + "text": "installé & fini", + "ts": 1672524000000 + } + ], + "name": "Réseau NAS (bureau)" + }, + "perso/tracker": { + "events": [ + { + "date_only": true, + "id": "{{ID5}}", + "text": "z", + "ts": 1735682400000 + } + ], + "name": "!!!" + } + } + } + }, + { + "method": "POST", + "path": "/api/tracker", + "body": "{\"action\":\"move\",\"slug\":\"poids\",\"to\":\"inconnu\"}", + "status": 400, + "out": { + "error": "projet cible introuvable", + "ok": false + }, + "disk": { + "generale/.cache.md": "caché", + "generale/MEMORY.md": "# Index mémoire — Générale\n\n- [Courses v3](courses-v2.md)\n- [Neuve](neuve.md)\n", + "generale/Zeta.MD": "# Zêta\n", + "generale/courses-v2.md": "# Courses v3\n", + "generale/courses-v2.md.bak": "# Courses v2\nœufs & \n", + "generale/doublon.md": "# A", + "generale/neuve.md": "# Neuve\n", + "generale/notes.md.bak": "Sans titre\n", + "generale/notes.txt": "pas une page", + "perso/MEMORY.md": "# Index mémoire — Perso & Cie\n\nUne ligne par page de ce projet (`- [titre](fichier.md) — accroche`). Tenu à jour automatiquement à la création et à la suppression d'une page ; l'accroche après le titre, elle, est libre.\n- [Titre](notes.md)\n- [Première](perso-1.md)\n", + "perso/doublon.md": "# B", + "perso/notes.md": "## Titre\nmaj\n", + "perso/perso-1.md": "Première\nligne\n" + }, + "state": { + "active": "generale", + "agent": "", + "compact": "", + "mem_mode": "ondemand", + "projects": [ + { + "created_at": 1000, + "mem_mode": "off", + "name": "Générale", + "slug": "generale" + }, + { + "created_at": 2000, + "desc": "à moi", + "mem_mode": "ondemand", + "name": "Perso & Cie", + "slug": "perso" + } + ], + "tracker_raw": {}, + "trackers": { + "generale/meme-jour": { + "events": [ + { + "date_only": true, + "id": "{{ID6}}", + "text": "m", + "ts": 1735682400000 + } + ], + "name": "Même jour" + }, + "generale/poids": { + "events": [ + { + "id": "{{ID2}}", + "text": "78,0 kg", + "ts": 1710133200000 + }, + { + "id": "{{ID3}}", + "text": "77", + "ts": "{{NOW}}" + } + ], + "name": "POIDS" + }, + "generale/reseau-nas-bureau": { + "events": [ + { + "date_only": true, + "id": "{{ID4}}", + "text": "installé & fini", + "ts": 1672524000000 + } + ], + "name": "Réseau NAS (bureau)" + }, + "perso/tracker": { + "events": [ + { + "date_only": true, + "id": "{{ID5}}", + "text": "z", + "ts": 1735682400000 + } + ], + "name": "!!!" + } + } + } + }, + { + "method": "POST", + "path": "/api/tracker", + "body": "{\"action\":\"move\",\"slug\":\"poids\",\"to\":\"generale\"}", + "status": 200, + "out": { + "ok": true, + "trackers": [ + { + "count": 2, + "first_ts": 1710133200000, + "last_text": "77", + "last_ts": "{{NOW}}", + "name": "POIDS", + "slug": "poids" + }, + { + "count": 1, + "first_ts": 1735682400000, + "last_text": "m", + "last_ts": 1735682400000, + "name": "Même jour", + "slug": "meme-jour" + }, + { + "count": 1, + "first_ts": 1672524000000, + "last_text": "installé & fini", + "last_ts": 1672524000000, + "name": "Réseau NAS (bureau)", + "slug": "reseau-nas-bureau" + } + ] + }, + "disk": { + "generale/.cache.md": "caché", + "generale/MEMORY.md": "# Index mémoire — Générale\n\n- [Courses v3](courses-v2.md)\n- [Neuve](neuve.md)\n", + "generale/Zeta.MD": "# Zêta\n", + "generale/courses-v2.md": "# Courses v3\n", + "generale/courses-v2.md.bak": "# Courses v2\nœufs & \n", + "generale/doublon.md": "# A", + "generale/neuve.md": "# Neuve\n", + "generale/notes.md.bak": "Sans titre\n", + "generale/notes.txt": "pas une page", + "perso/MEMORY.md": "# Index mémoire — Perso & Cie\n\nUne ligne par page de ce projet (`- [titre](fichier.md) — accroche`). Tenu à jour automatiquement à la création et à la suppression d'une page ; l'accroche après le titre, elle, est libre.\n- [Titre](notes.md)\n- [Première](perso-1.md)\n", + "perso/doublon.md": "# B", + "perso/notes.md": "## Titre\nmaj\n", + "perso/perso-1.md": "Première\nligne\n" + }, + "state": { + "active": "generale", + "agent": "", + "compact": "", + "mem_mode": "ondemand", + "projects": [ + { + "created_at": 1000, + "mem_mode": "off", + "name": "Générale", + "slug": "generale" + }, + { + "created_at": 2000, + "desc": "à moi", + "mem_mode": "ondemand", + "name": "Perso & Cie", + "slug": "perso" + } + ], + "tracker_raw": {}, + "trackers": { + "generale/meme-jour": { + "events": [ + { + "date_only": true, + "id": "{{ID6}}", + "text": "m", + "ts": 1735682400000 + } + ], + "name": "Même jour" + }, + "generale/poids": { + "events": [ + { + "id": "{{ID2}}", + "text": "78,0 kg", + "ts": 1710133200000 + }, + { + "id": "{{ID3}}", + "text": "77", + "ts": "{{NOW}}" + } + ], + "name": "POIDS" + }, + "generale/reseau-nas-bureau": { + "events": [ + { + "date_only": true, + "id": "{{ID4}}", + "text": "installé & fini", + "ts": 1672524000000 + } + ], + "name": "Réseau NAS (bureau)" + }, + "perso/tracker": { + "events": [ + { + "date_only": true, + "id": "{{ID5}}", + "text": "z", + "ts": 1735682400000 + } + ], + "name": "!!!" + } + } + } + }, + { + "method": "POST", + "path": "/api/tracker", + "body": "{\"action\":\"move\",\"slug\":\"absent\",\"to\":\"perso\"}", + "status": 400, + "out": { + "error": "tracker introuvable", + "ok": false + }, + "disk": { + "generale/.cache.md": "caché", + "generale/MEMORY.md": "# Index mémoire — Générale\n\n- [Courses v3](courses-v2.md)\n- [Neuve](neuve.md)\n", + "generale/Zeta.MD": "# Zêta\n", + "generale/courses-v2.md": "# Courses v3\n", + "generale/courses-v2.md.bak": "# Courses v2\nœufs & \n", + "generale/doublon.md": "# A", + "generale/neuve.md": "# Neuve\n", + "generale/notes.md.bak": "Sans titre\n", + "generale/notes.txt": "pas une page", + "perso/MEMORY.md": "# Index mémoire — Perso & Cie\n\nUne ligne par page de ce projet (`- [titre](fichier.md) — accroche`). Tenu à jour automatiquement à la création et à la suppression d'une page ; l'accroche après le titre, elle, est libre.\n- [Titre](notes.md)\n- [Première](perso-1.md)\n", + "perso/doublon.md": "# B", + "perso/notes.md": "## Titre\nmaj\n", + "perso/perso-1.md": "Première\nligne\n" + }, + "state": { + "active": "generale", + "agent": "", + "compact": "", + "mem_mode": "ondemand", + "projects": [ + { + "created_at": 1000, + "mem_mode": "off", + "name": "Générale", + "slug": "generale" + }, + { + "created_at": 2000, + "desc": "à moi", + "mem_mode": "ondemand", + "name": "Perso & Cie", + "slug": "perso" + } + ], + "tracker_raw": {}, + "trackers": { + "generale/meme-jour": { + "events": [ + { + "date_only": true, + "id": "{{ID6}}", + "text": "m", + "ts": 1735682400000 + } + ], + "name": "Même jour" + }, + "generale/poids": { + "events": [ + { + "id": "{{ID2}}", + "text": "78,0 kg", + "ts": 1710133200000 + }, + { + "id": "{{ID3}}", + "text": "77", + "ts": "{{NOW}}" + } + ], + "name": "POIDS" + }, + "generale/reseau-nas-bureau": { + "events": [ + { + "date_only": true, + "id": "{{ID4}}", + "text": "installé & fini", + "ts": 1672524000000 + } + ], + "name": "Réseau NAS (bureau)" + }, + "perso/tracker": { + "events": [ + { + "date_only": true, + "id": "{{ID5}}", + "text": "z", + "ts": 1735682400000 + } + ], + "name": "!!!" + } + } + } + }, + { + "store": { + "state/active_project": "perso" + }, + "method": "POST", + "path": "/api/tracker", + "body": "{\"action\":\"add\",\"name\":\"Même jour\",\"when\":\"2025-02\",\"text\":\"perso\"}", + "status": 200, + "out": { + "ok": true, + "trackers": [ + { + "count": 1, + "first_ts": 1738360800000, + "last_text": "perso", + "last_ts": 1738360800000, + "name": "Même jour", + "slug": "meme-jour" + }, + { + "count": 1, + "first_ts": 1735682400000, + "last_text": "z", + "last_ts": 1735682400000, + "name": "!!!", + "slug": "tracker" + } + ] + }, + "disk": { + "generale/.cache.md": "caché", + "generale/MEMORY.md": "# Index mémoire — Générale\n\n- [Courses v3](courses-v2.md)\n- [Neuve](neuve.md)\n", + "generale/Zeta.MD": "# Zêta\n", + "generale/courses-v2.md": "# Courses v3\n", + "generale/courses-v2.md.bak": "# Courses v2\nœufs & \n", + "generale/doublon.md": "# A", + "generale/neuve.md": "# Neuve\n", + "generale/notes.md.bak": "Sans titre\n", + "generale/notes.txt": "pas une page", + "perso/MEMORY.md": "# Index mémoire — Perso & Cie\n\nUne ligne par page de ce projet (`- [titre](fichier.md) — accroche`). Tenu à jour automatiquement à la création et à la suppression d'une page ; l'accroche après le titre, elle, est libre.\n- [Titre](notes.md)\n- [Première](perso-1.md)\n", + "perso/doublon.md": "# B", + "perso/notes.md": "## Titre\nmaj\n", + "perso/perso-1.md": "Première\nligne\n" + }, + "state": { + "active": "perso", + "agent": "", + "compact": "", + "mem_mode": "ondemand", + "projects": [ + { + "created_at": 1000, + "mem_mode": "off", + "name": "Générale", + "slug": "generale" + }, + { + "created_at": 2000, + "desc": "à moi", + "mem_mode": "ondemand", + "name": "Perso & Cie", + "slug": "perso" + } + ], + "tracker_raw": {}, + "trackers": { + "generale/meme-jour": { + "events": [ + { + "date_only": true, + "id": "{{ID6}}", + "text": "m", + "ts": 1735682400000 + } + ], + "name": "Même jour" + }, + "generale/poids": { + "events": [ + { + "id": "{{ID2}}", + "text": "78,0 kg", + "ts": 1710133200000 + }, + { + "id": "{{ID3}}", + "text": "77", + "ts": "{{NOW}}" + } + ], + "name": "POIDS" + }, + "generale/reseau-nas-bureau": { + "events": [ + { + "date_only": true, + "id": "{{ID4}}", + "text": "installé & fini", + "ts": 1672524000000 + } + ], + "name": "Réseau NAS (bureau)" + }, + "perso/meme-jour": { + "events": [ + { + "date_only": true, + "id": "{{ID7}}", + "text": "perso", + "ts": 1738360800000 + } + ], + "name": "Même jour" + }, + "perso/tracker": { + "events": [ + { + "date_only": true, + "id": "{{ID5}}", + "text": "z", + "ts": 1735682400000 + } + ], + "name": "!!!" + } + } + } + }, + { + "store": { + "state/active_project": "generale" + }, + "method": "POST", + "path": "/api/tracker", + "body": "{\"action\":\"move\",\"slug\":\"meme-jour\",\"to\":\"perso\"}", + "status": 400, + "out": { + "error": "un tracker du même nom existe déjà dans le projet cible", + "ok": false + }, + "disk": { + "generale/.cache.md": "caché", + "generale/MEMORY.md": "# Index mémoire — Générale\n\n- [Courses v3](courses-v2.md)\n- [Neuve](neuve.md)\n", + "generale/Zeta.MD": "# Zêta\n", + "generale/courses-v2.md": "# Courses v3\n", + "generale/courses-v2.md.bak": "# Courses v2\nœufs & \n", + "generale/doublon.md": "# A", + "generale/neuve.md": "# Neuve\n", + "generale/notes.md.bak": "Sans titre\n", + "generale/notes.txt": "pas une page", + "perso/MEMORY.md": "# Index mémoire — Perso & Cie\n\nUne ligne par page de ce projet (`- [titre](fichier.md) — accroche`). Tenu à jour automatiquement à la création et à la suppression d'une page ; l'accroche après le titre, elle, est libre.\n- [Titre](notes.md)\n- [Première](perso-1.md)\n", + "perso/doublon.md": "# B", + "perso/notes.md": "## Titre\nmaj\n", + "perso/perso-1.md": "Première\nligne\n" + }, + "state": { + "active": "generale", + "agent": "", + "compact": "", + "mem_mode": "ondemand", + "projects": [ + { + "created_at": 1000, + "mem_mode": "off", + "name": "Générale", + "slug": "generale" + }, + { + "created_at": 2000, + "desc": "à moi", + "mem_mode": "ondemand", + "name": "Perso & Cie", + "slug": "perso" + } + ], + "tracker_raw": {}, + "trackers": { + "generale/meme-jour": { + "events": [ + { + "date_only": true, + "id": "{{ID6}}", + "text": "m", + "ts": 1735682400000 + } + ], + "name": "Même jour" + }, + "generale/poids": { + "events": [ + { + "id": "{{ID2}}", + "text": "78,0 kg", + "ts": 1710133200000 + }, + { + "id": "{{ID3}}", + "text": "77", + "ts": "{{NOW}}" + } + ], + "name": "POIDS" + }, + "generale/reseau-nas-bureau": { + "events": [ + { + "date_only": true, + "id": "{{ID4}}", + "text": "installé & fini", + "ts": 1672524000000 + } + ], + "name": "Réseau NAS (bureau)" + }, + "perso/meme-jour": { + "events": [ + { + "date_only": true, + "id": "{{ID7}}", + "text": "perso", + "ts": 1738360800000 + } + ], + "name": "Même jour" + }, + "perso/tracker": { + "events": [ + { + "date_only": true, + "id": "{{ID5}}", + "text": "z", + "ts": 1735682400000 + } + ], + "name": "!!!" + } + } + } + }, + { + "method": "POST", + "path": "/api/tracker", + "body": "{\"action\":\"delete_tracker\",\"slug\":\"poids\"}", + "status": 200, + "out": { + "ok": true, + "trackers": [ + { + "count": 1, + "first_ts": 1735682400000, + "last_text": "m", + "last_ts": 1735682400000, + "name": "Même jour", + "slug": "meme-jour" + }, + { + "count": 1, + "first_ts": 1672524000000, + "last_text": "installé & fini", + "last_ts": 1672524000000, + "name": "Réseau NAS (bureau)", + "slug": "reseau-nas-bureau" + } + ] + }, + "disk": { + "generale/.cache.md": "caché", + "generale/MEMORY.md": "# Index mémoire — Générale\n\n- [Courses v3](courses-v2.md)\n- [Neuve](neuve.md)\n", + "generale/Zeta.MD": "# Zêta\n", + "generale/courses-v2.md": "# Courses v3\n", + "generale/courses-v2.md.bak": "# Courses v2\nœufs & \n", + "generale/doublon.md": "# A", + "generale/neuve.md": "# Neuve\n", + "generale/notes.md.bak": "Sans titre\n", + "generale/notes.txt": "pas une page", + "perso/MEMORY.md": "# Index mémoire — Perso & Cie\n\nUne ligne par page de ce projet (`- [titre](fichier.md) — accroche`). Tenu à jour automatiquement à la création et à la suppression d'une page ; l'accroche après le titre, elle, est libre.\n- [Titre](notes.md)\n- [Première](perso-1.md)\n", + "perso/doublon.md": "# B", + "perso/notes.md": "## Titre\nmaj\n", + "perso/perso-1.md": "Première\nligne\n" + }, + "state": { + "active": "generale", + "agent": "", + "compact": "", + "mem_mode": "ondemand", + "projects": [ + { + "created_at": 1000, + "mem_mode": "off", + "name": "Générale", + "slug": "generale" + }, + { + "created_at": 2000, + "desc": "à moi", + "mem_mode": "ondemand", + "name": "Perso & Cie", + "slug": "perso" + } + ], + "tracker_raw": {}, + "trackers": { + "generale/meme-jour": { + "events": [ + { + "date_only": true, + "id": "{{ID6}}", + "text": "m", + "ts": 1735682400000 + } + ], + "name": "Même jour" + }, + "generale/reseau-nas-bureau": { + "events": [ + { + "date_only": true, + "id": "{{ID4}}", + "text": "installé & fini", + "ts": 1672524000000 + } + ], + "name": "Réseau NAS (bureau)" + }, + "perso/meme-jour": { + "events": [ + { + "date_only": true, + "id": "{{ID7}}", + "text": "perso", + "ts": 1738360800000 + } + ], + "name": "Même jour" + }, + "perso/tracker": { + "events": [ + { + "date_only": true, + "id": "{{ID5}}", + "text": "z", + "ts": 1735682400000 + } + ], + "name": "!!!" + } + } + } + }, + { + "method": "POST", + "path": "/api/tracker", + "body": "{\"action\":\"delete_tracker\",\"slug\":\"absent\"}", + "status": 200, + "out": { + "ok": true, + "trackers": [ + { + "count": 1, + "first_ts": 1735682400000, + "last_text": "m", + "last_ts": 1735682400000, + "name": "Même jour", + "slug": "meme-jour" + }, + { + "count": 1, + "first_ts": 1672524000000, + "last_text": "installé & fini", + "last_ts": 1672524000000, + "name": "Réseau NAS (bureau)", + "slug": "reseau-nas-bureau" + } + ] + }, + "disk": { + "generale/.cache.md": "caché", + "generale/MEMORY.md": "# Index mémoire — Générale\n\n- [Courses v3](courses-v2.md)\n- [Neuve](neuve.md)\n", + "generale/Zeta.MD": "# Zêta\n", + "generale/courses-v2.md": "# Courses v3\n", + "generale/courses-v2.md.bak": "# Courses v2\nœufs & \n", + "generale/doublon.md": "# A", + "generale/neuve.md": "# Neuve\n", + "generale/notes.md.bak": "Sans titre\n", + "generale/notes.txt": "pas une page", + "perso/MEMORY.md": "# Index mémoire — Perso & Cie\n\nUne ligne par page de ce projet (`- [titre](fichier.md) — accroche`). Tenu à jour automatiquement à la création et à la suppression d'une page ; l'accroche après le titre, elle, est libre.\n- [Titre](notes.md)\n- [Première](perso-1.md)\n", + "perso/doublon.md": "# B", + "perso/notes.md": "## Titre\nmaj\n", + "perso/perso-1.md": "Première\nligne\n" + }, + "state": { + "active": "generale", + "agent": "", + "compact": "", + "mem_mode": "ondemand", + "projects": [ + { + "created_at": 1000, + "mem_mode": "off", + "name": "Générale", + "slug": "generale" + }, + { + "created_at": 2000, + "desc": "à moi", + "mem_mode": "ondemand", + "name": "Perso & Cie", + "slug": "perso" + } + ], + "tracker_raw": {}, + "trackers": { + "generale/meme-jour": { + "events": [ + { + "date_only": true, + "id": "{{ID6}}", + "text": "m", + "ts": 1735682400000 + } + ], + "name": "Même jour" + }, + "generale/reseau-nas-bureau": { + "events": [ + { + "date_only": true, + "id": "{{ID4}}", + "text": "installé & fini", + "ts": 1672524000000 + } + ], + "name": "Réseau NAS (bureau)" + }, + "perso/meme-jour": { + "events": [ + { + "date_only": true, + "id": "{{ID7}}", + "text": "perso", + "ts": 1738360800000 + } + ], + "name": "Même jour" + }, + "perso/tracker": { + "events": [ + { + "date_only": true, + "id": "{{ID5}}", + "text": "z", + "ts": 1735682400000 + } + ], + "name": "!!!" + } + } + } + }, + { + "method": "POST", + "path": "/api/tracker", + "body": "{\"action\":\"bidule\"}", + "status": 400, + "out": { + "error": "action inconnue : \"bidule\"", + "ok": false + }, + "disk": { + "generale/.cache.md": "caché", + "generale/MEMORY.md": "# Index mémoire — Générale\n\n- [Courses v3](courses-v2.md)\n- [Neuve](neuve.md)\n", + "generale/Zeta.MD": "# Zêta\n", + "generale/courses-v2.md": "# Courses v3\n", + "generale/courses-v2.md.bak": "# Courses v2\nœufs & \n", + "generale/doublon.md": "# A", + "generale/neuve.md": "# Neuve\n", + "generale/notes.md.bak": "Sans titre\n", + "generale/notes.txt": "pas une page", + "perso/MEMORY.md": "# Index mémoire — Perso & Cie\n\nUne ligne par page de ce projet (`- [titre](fichier.md) — accroche`). Tenu à jour automatiquement à la création et à la suppression d'une page ; l'accroche après le titre, elle, est libre.\n- [Titre](notes.md)\n- [Première](perso-1.md)\n", + "perso/doublon.md": "# B", + "perso/notes.md": "## Titre\nmaj\n", + "perso/perso-1.md": "Première\nligne\n" + }, + "state": { + "active": "generale", + "agent": "", + "compact": "", + "mem_mode": "ondemand", + "projects": [ + { + "created_at": 1000, + "mem_mode": "off", + "name": "Générale", + "slug": "generale" + }, + { + "created_at": 2000, + "desc": "à moi", + "mem_mode": "ondemand", + "name": "Perso & Cie", + "slug": "perso" + } + ], + "tracker_raw": {}, + "trackers": { + "generale/meme-jour": { + "events": [ + { + "date_only": true, + "id": "{{ID6}}", + "text": "m", + "ts": 1735682400000 + } + ], + "name": "Même jour" + }, + "generale/reseau-nas-bureau": { + "events": [ + { + "date_only": true, + "id": "{{ID4}}", + "text": "installé & fini", + "ts": 1672524000000 + } + ], + "name": "Réseau NAS (bureau)" + }, + "perso/meme-jour": { + "events": [ + { + "date_only": true, + "id": "{{ID7}}", + "text": "perso", + "ts": 1738360800000 + } + ], + "name": "Même jour" + }, + "perso/tracker": { + "events": [ + { + "date_only": true, + "id": "{{ID5}}", + "text": "z", + "ts": 1735682400000 + } + ], + "name": "!!!" + } + } + } + }, + { + "method": "POST", + "path": "/api/tracker", + "body": "pas du json", + "status": 400, + "out": { + "error": "invalid character 'p' looking for beginning of value", + "ok": false + }, + "disk": { + "generale/.cache.md": "caché", + "generale/MEMORY.md": "# Index mémoire — Générale\n\n- [Courses v3](courses-v2.md)\n- [Neuve](neuve.md)\n", + "generale/Zeta.MD": "# Zêta\n", + "generale/courses-v2.md": "# Courses v3\n", + "generale/courses-v2.md.bak": "# Courses v2\nœufs & \n", + "generale/doublon.md": "# A", + "generale/neuve.md": "# Neuve\n", + "generale/notes.md.bak": "Sans titre\n", + "generale/notes.txt": "pas une page", + "perso/MEMORY.md": "# Index mémoire — Perso & Cie\n\nUne ligne par page de ce projet (`- [titre](fichier.md) — accroche`). Tenu à jour automatiquement à la création et à la suppression d'une page ; l'accroche après le titre, elle, est libre.\n- [Titre](notes.md)\n- [Première](perso-1.md)\n", + "perso/doublon.md": "# B", + "perso/notes.md": "## Titre\nmaj\n", + "perso/perso-1.md": "Première\nligne\n" + }, + "state": { + "active": "generale", + "agent": "", + "compact": "", + "mem_mode": "ondemand", + "projects": [ + { + "created_at": 1000, + "mem_mode": "off", + "name": "Générale", + "slug": "generale" + }, + { + "created_at": 2000, + "desc": "à moi", + "mem_mode": "ondemand", + "name": "Perso & Cie", + "slug": "perso" + } + ], + "tracker_raw": {}, + "trackers": { + "generale/meme-jour": { + "events": [ + { + "date_only": true, + "id": "{{ID6}}", + "text": "m", + "ts": 1735682400000 + } + ], + "name": "Même jour" + }, + "generale/reseau-nas-bureau": { + "events": [ + { + "date_only": true, + "id": "{{ID4}}", + "text": "installé & fini", + "ts": 1672524000000 + } + ], + "name": "Réseau NAS (bureau)" + }, + "perso/meme-jour": { + "events": [ + { + "date_only": true, + "id": "{{ID7}}", + "text": "perso", + "ts": 1738360800000 + } + ], + "name": "Même jour" + }, + "perso/tracker": { + "events": [ + { + "date_only": true, + "id": "{{ID5}}", + "text": "z", + "ts": 1735682400000 + } + ], + "name": "!!!" + } + } + } + }, + { + "method": "POST", + "path": "/api/tracker", + "body": "\"texte\"", + "status": 400, + "out": { + "error": "json: cannot unmarshal string into Go value of type struct { Action string \"json:\\\"action\\\"\"; Slug string \"json:\\\"slug\\\"\"; Name string \"json:\\\"name\\\"\"; When string \"json:\\\"when\\\"\"; Text string \"json:\\\"text\\\"\"; ID string \"json:\\\"id\\\"\"; To string \"json:\\\"to\\\"\" }", + "ok": false + }, + "disk": { + "generale/.cache.md": "caché", + "generale/MEMORY.md": "# Index mémoire — Générale\n\n- [Courses v3](courses-v2.md)\n- [Neuve](neuve.md)\n", + "generale/Zeta.MD": "# Zêta\n", + "generale/courses-v2.md": "# Courses v3\n", + "generale/courses-v2.md.bak": "# Courses v2\nœufs & \n", + "generale/doublon.md": "# A", + "generale/neuve.md": "# Neuve\n", + "generale/notes.md.bak": "Sans titre\n", + "generale/notes.txt": "pas une page", + "perso/MEMORY.md": "# Index mémoire — Perso & Cie\n\nUne ligne par page de ce projet (`- [titre](fichier.md) — accroche`). Tenu à jour automatiquement à la création et à la suppression d'une page ; l'accroche après le titre, elle, est libre.\n- [Titre](notes.md)\n- [Première](perso-1.md)\n", + "perso/doublon.md": "# B", + "perso/notes.md": "## Titre\nmaj\n", + "perso/perso-1.md": "Première\nligne\n" + }, + "state": { + "active": "generale", + "agent": "", + "compact": "", + "mem_mode": "ondemand", + "projects": [ + { + "created_at": 1000, + "mem_mode": "off", + "name": "Générale", + "slug": "generale" + }, + { + "created_at": 2000, + "desc": "à moi", + "mem_mode": "ondemand", + "name": "Perso & Cie", + "slug": "perso" + } + ], + "tracker_raw": {}, + "trackers": { + "generale/meme-jour": { + "events": [ + { + "date_only": true, + "id": "{{ID6}}", + "text": "m", + "ts": 1735682400000 + } + ], + "name": "Même jour" + }, + "generale/reseau-nas-bureau": { + "events": [ + { + "date_only": true, + "id": "{{ID4}}", + "text": "installé & fini", + "ts": 1672524000000 + } + ], + "name": "Réseau NAS (bureau)" + }, + "perso/meme-jour": { + "events": [ + { + "date_only": true, + "id": "{{ID7}}", + "text": "perso", + "ts": 1738360800000 + } + ], + "name": "Même jour" + }, + "perso/tracker": { + "events": [ + { + "date_only": true, + "id": "{{ID5}}", + "text": "z", + "ts": 1735682400000 + } + ], + "name": "!!!" + } + } + } + }, + { + "method": "POST", + "path": "/api/tracker", + "body": "{\"action\":\"add\",\"name\":3}", + "status": 400, + "out": { + "error": "json: cannot unmarshal number into Go struct field .name of type string", + "ok": false + }, + "disk": { + "generale/.cache.md": "caché", + "generale/MEMORY.md": "# Index mémoire — Générale\n\n- [Courses v3](courses-v2.md)\n- [Neuve](neuve.md)\n", + "generale/Zeta.MD": "# Zêta\n", + "generale/courses-v2.md": "# Courses v3\n", + "generale/courses-v2.md.bak": "# Courses v2\nœufs & \n", + "generale/doublon.md": "# A", + "generale/neuve.md": "# Neuve\n", + "generale/notes.md.bak": "Sans titre\n", + "generale/notes.txt": "pas une page", + "perso/MEMORY.md": "# Index mémoire — Perso & Cie\n\nUne ligne par page de ce projet (`- [titre](fichier.md) — accroche`). Tenu à jour automatiquement à la création et à la suppression d'une page ; l'accroche après le titre, elle, est libre.\n- [Titre](notes.md)\n- [Première](perso-1.md)\n", + "perso/doublon.md": "# B", + "perso/notes.md": "## Titre\nmaj\n", + "perso/perso-1.md": "Première\nligne\n" + }, + "state": { + "active": "generale", + "agent": "", + "compact": "", + "mem_mode": "ondemand", + "projects": [ + { + "created_at": 1000, + "mem_mode": "off", + "name": "Générale", + "slug": "generale" + }, + { + "created_at": 2000, + "desc": "à moi", + "mem_mode": "ondemand", + "name": "Perso & Cie", + "slug": "perso" + } + ], + "tracker_raw": {}, + "trackers": { + "generale/meme-jour": { + "events": [ + { + "date_only": true, + "id": "{{ID6}}", + "text": "m", + "ts": 1735682400000 + } + ], + "name": "Même jour" + }, + "generale/reseau-nas-bureau": { + "events": [ + { + "date_only": true, + "id": "{{ID4}}", + "text": "installé & fini", + "ts": 1672524000000 + } + ], + "name": "Réseau NAS (bureau)" + }, + "perso/meme-jour": { + "events": [ + { + "date_only": true, + "id": "{{ID7}}", + "text": "perso", + "ts": 1738360800000 + } + ], + "name": "Même jour" + }, + "perso/tracker": { + "events": [ + { + "date_only": true, + "id": "{{ID5}}", + "text": "z", + "ts": 1735682400000 + } + ], + "name": "!!!" + } + } + } + }, + { + "method": "PUT", + "path": "/api/tracker", + "status": 405, + "out": { + "error": "méthode non autorisée", + "ok": false + }, + "disk": { + "generale/.cache.md": "caché", + "generale/MEMORY.md": "# Index mémoire — Générale\n\n- [Courses v3](courses-v2.md)\n- [Neuve](neuve.md)\n", + "generale/Zeta.MD": "# Zêta\n", + "generale/courses-v2.md": "# Courses v3\n", + "generale/courses-v2.md.bak": "# Courses v2\nœufs & \n", + "generale/doublon.md": "# A", + "generale/neuve.md": "# Neuve\n", + "generale/notes.md.bak": "Sans titre\n", + "generale/notes.txt": "pas une page", + "perso/MEMORY.md": "# Index mémoire — Perso & Cie\n\nUne ligne par page de ce projet (`- [titre](fichier.md) — accroche`). Tenu à jour automatiquement à la création et à la suppression d'une page ; l'accroche après le titre, elle, est libre.\n- [Titre](notes.md)\n- [Première](perso-1.md)\n", + "perso/doublon.md": "# B", + "perso/notes.md": "## Titre\nmaj\n", + "perso/perso-1.md": "Première\nligne\n" + }, + "state": { + "active": "generale", + "agent": "", + "compact": "", + "mem_mode": "ondemand", + "projects": [ + { + "created_at": 1000, + "mem_mode": "off", + "name": "Générale", + "slug": "generale" + }, + { + "created_at": 2000, + "desc": "à moi", + "mem_mode": "ondemand", + "name": "Perso & Cie", + "slug": "perso" + } + ], + "tracker_raw": {}, + "trackers": { + "generale/meme-jour": { + "events": [ + { + "date_only": true, + "id": "{{ID6}}", + "text": "m", + "ts": 1735682400000 + } + ], + "name": "Même jour" + }, + "generale/reseau-nas-bureau": { + "events": [ + { + "date_only": true, + "id": "{{ID4}}", + "text": "installé & fini", + "ts": 1672524000000 + } + ], + "name": "Réseau NAS (bureau)" + }, + "perso/meme-jour": { + "events": [ + { + "date_only": true, + "id": "{{ID7}}", + "text": "perso", + "ts": 1738360800000 + } + ], + "name": "Même jour" + }, + "perso/tracker": { + "events": [ + { + "date_only": true, + "id": "{{ID5}}", + "text": "z", + "ts": 1735682400000 + } + ], + "name": "!!!" + } + } + } + }, + { + "store": { + "tracker/generale/casse": "pas du json" + }, + "method": "GET", + "path": "/api/tracker", + "status": 200, + "out": { + "ok": true, + "trackers": [ + { + "count": 1, + "first_ts": 1735682400000, + "last_text": "m", + "last_ts": 1735682400000, + "name": "Même jour", + "slug": "meme-jour" + }, + { + "count": 1, + "first_ts": 1672524000000, + "last_text": "installé & fini", + "last_ts": 1672524000000, + "name": "Réseau NAS (bureau)", + "slug": "reseau-nas-bureau" + } + ] + }, + "disk": { + "generale/.cache.md": "caché", + "generale/MEMORY.md": "# Index mémoire — Générale\n\n- [Courses v3](courses-v2.md)\n- [Neuve](neuve.md)\n", + "generale/Zeta.MD": "# Zêta\n", + "generale/courses-v2.md": "# Courses v3\n", + "generale/courses-v2.md.bak": "# Courses v2\nœufs & \n", + "generale/doublon.md": "# A", + "generale/neuve.md": "# Neuve\n", + "generale/notes.md.bak": "Sans titre\n", + "generale/notes.txt": "pas une page", + "perso/MEMORY.md": "# Index mémoire — Perso & Cie\n\nUne ligne par page de ce projet (`- [titre](fichier.md) — accroche`). Tenu à jour automatiquement à la création et à la suppression d'une page ; l'accroche après le titre, elle, est libre.\n- [Titre](notes.md)\n- [Première](perso-1.md)\n", + "perso/doublon.md": "# B", + "perso/notes.md": "## Titre\nmaj\n", + "perso/perso-1.md": "Première\nligne\n" + }, + "state": { + "active": "generale", + "agent": "", + "compact": "", + "mem_mode": "ondemand", + "projects": [ + { + "created_at": 1000, + "mem_mode": "off", + "name": "Générale", + "slug": "generale" + }, + { + "created_at": 2000, + "desc": "à moi", + "mem_mode": "ondemand", + "name": "Perso & Cie", + "slug": "perso" + } + ], + "tracker_raw": { + "generale/casse": "pas du json" + }, + "trackers": { + "generale/meme-jour": { + "events": [ + { + "date_only": true, + "id": "{{ID6}}", + "text": "m", + "ts": 1735682400000 + } + ], + "name": "Même jour" + }, + "generale/reseau-nas-bureau": { + "events": [ + { + "date_only": true, + "id": "{{ID4}}", + "text": "installé & fini", + "ts": 1672524000000 + } + ], + "name": "Réseau NAS (bureau)" + }, + "perso/meme-jour": { + "events": [ + { + "date_only": true, + "id": "{{ID7}}", + "text": "perso", + "ts": 1738360800000 + } + ], + "name": "Même jour" + }, + "perso/tracker": { + "events": [ + { + "date_only": true, + "id": "{{ID5}}", + "text": "z", + "ts": 1735682400000 + } + ], + "name": "!!!" + } + } + } + }, + { + "method": "GET", + "path": "/api/tracker?slug=casse", + "status": 404, + "out": { + "error": "tracker introuvable", + "ok": false + }, + "disk": { + "generale/.cache.md": "caché", + "generale/MEMORY.md": "# Index mémoire — Générale\n\n- [Courses v3](courses-v2.md)\n- [Neuve](neuve.md)\n", + "generale/Zeta.MD": "# Zêta\n", + "generale/courses-v2.md": "# Courses v3\n", + "generale/courses-v2.md.bak": "# Courses v2\nœufs & \n", + "generale/doublon.md": "# A", + "generale/neuve.md": "# Neuve\n", + "generale/notes.md.bak": "Sans titre\n", + "generale/notes.txt": "pas une page", + "perso/MEMORY.md": "# Index mémoire — Perso & Cie\n\nUne ligne par page de ce projet (`- [titre](fichier.md) — accroche`). Tenu à jour automatiquement à la création et à la suppression d'une page ; l'accroche après le titre, elle, est libre.\n- [Titre](notes.md)\n- [Première](perso-1.md)\n", + "perso/doublon.md": "# B", + "perso/notes.md": "## Titre\nmaj\n", + "perso/perso-1.md": "Première\nligne\n" + }, + "state": { + "active": "generale", + "agent": "", + "compact": "", + "mem_mode": "ondemand", + "projects": [ + { + "created_at": 1000, + "mem_mode": "off", + "name": "Générale", + "slug": "generale" + }, + { + "created_at": 2000, + "desc": "à moi", + "mem_mode": "ondemand", + "name": "Perso & Cie", + "slug": "perso" + } + ], + "tracker_raw": { + "generale/casse": "pas du json" + }, + "trackers": { + "generale/meme-jour": { + "events": [ + { + "date_only": true, + "id": "{{ID6}}", + "text": "m", + "ts": 1735682400000 + } + ], + "name": "Même jour" + }, + "generale/reseau-nas-bureau": { + "events": [ + { + "date_only": true, + "id": "{{ID4}}", + "text": "installé & fini", + "ts": 1672524000000 + } + ], + "name": "Réseau NAS (bureau)" + }, + "perso/meme-jour": { + "events": [ + { + "date_only": true, + "id": "{{ID7}}", + "text": "perso", + "ts": 1738360800000 + } + ], + "name": "Même jour" + }, + "perso/tracker": { + "events": [ + { + "date_only": true, + "id": "{{ID5}}", + "text": "z", + "ts": 1735682400000 + } + ], + "name": "!!!" + } + } + } + }, + { + "store": { + "tracker/generale/vide": "{\"name\":\"Vide\",\"events\":null}" + }, + "method": "GET", + "path": "/api/tracker?slug=vide", + "status": 200, + "out": { + "ok": true, + "tracker": { + "count": 0, + "first_ts": 0, + "last_text": "", + "last_ts": 0, + "name": "Vide", + "slug": "vide" + } + }, + "disk": { + "generale/.cache.md": "caché", + "generale/MEMORY.md": "# Index mémoire — Générale\n\n- [Courses v3](courses-v2.md)\n- [Neuve](neuve.md)\n", + "generale/Zeta.MD": "# Zêta\n", + "generale/courses-v2.md": "# Courses v3\n", + "generale/courses-v2.md.bak": "# Courses v2\nœufs & \n", + "generale/doublon.md": "# A", + "generale/neuve.md": "# Neuve\n", + "generale/notes.md.bak": "Sans titre\n", + "generale/notes.txt": "pas une page", + "perso/MEMORY.md": "# Index mémoire — Perso & Cie\n\nUne ligne par page de ce projet (`- [titre](fichier.md) — accroche`). Tenu à jour automatiquement à la création et à la suppression d'une page ; l'accroche après le titre, elle, est libre.\n- [Titre](notes.md)\n- [Première](perso-1.md)\n", + "perso/doublon.md": "# B", + "perso/notes.md": "## Titre\nmaj\n", + "perso/perso-1.md": "Première\nligne\n" + }, + "state": { + "active": "generale", + "agent": "", + "compact": "", + "mem_mode": "ondemand", + "projects": [ + { + "created_at": 1000, + "mem_mode": "off", + "name": "Générale", + "slug": "generale" + }, + { + "created_at": 2000, + "desc": "à moi", + "mem_mode": "ondemand", + "name": "Perso & Cie", + "slug": "perso" + } + ], + "tracker_raw": { + "generale/casse": "pas du json" + }, + "trackers": { + "generale/meme-jour": { + "events": [ + { + "date_only": true, + "id": "{{ID6}}", + "text": "m", + "ts": 1735682400000 + } + ], + "name": "Même jour" + }, + "generale/reseau-nas-bureau": { + "events": [ + { + "date_only": true, + "id": "{{ID4}}", + "text": "installé & fini", + "ts": 1672524000000 + } + ], + "name": "Réseau NAS (bureau)" + }, + "generale/vide": { + "events": null, + "name": "Vide" + }, + "perso/meme-jour": { + "events": [ + { + "date_only": true, + "id": "{{ID7}}", + "text": "perso", + "ts": 1738360800000 + } + ], + "name": "Même jour" + }, + "perso/tracker": { + "events": [ + { + "date_only": true, + "id": "{{ID5}}", + "text": "z", + "ts": 1735682400000 + } + ], + "name": "!!!" + } + } + } + }, + { + "method": "POST", + "path": "/api/tracker", + "body": "{\"action\":\"rename\",\"slug\":\"vide\",\"name\":\"VIDE\"}", + "status": 200, + "out": { + "ok": true, + "trackers": [ + { + "count": 1, + "first_ts": 1735682400000, + "last_text": "m", + "last_ts": 1735682400000, + "name": "Même jour", + "slug": "meme-jour" + }, + { + "count": 1, + "first_ts": 1672524000000, + "last_text": "installé & fini", + "last_ts": 1672524000000, + "name": "Réseau NAS (bureau)", + "slug": "reseau-nas-bureau" + }, + { + "count": 0, + "first_ts": 0, + "last_text": "", + "last_ts": 0, + "name": "VIDE", + "slug": "vide" + } + ] + }, + "disk": { + "generale/.cache.md": "caché", + "generale/MEMORY.md": "# Index mémoire — Générale\n\n- [Courses v3](courses-v2.md)\n- [Neuve](neuve.md)\n", + "generale/Zeta.MD": "# Zêta\n", + "generale/courses-v2.md": "# Courses v3\n", + "generale/courses-v2.md.bak": "# Courses v2\nœufs & \n", + "generale/doublon.md": "# A", + "generale/neuve.md": "# Neuve\n", + "generale/notes.md.bak": "Sans titre\n", + "generale/notes.txt": "pas une page", + "perso/MEMORY.md": "# Index mémoire — Perso & Cie\n\nUne ligne par page de ce projet (`- [titre](fichier.md) — accroche`). Tenu à jour automatiquement à la création et à la suppression d'une page ; l'accroche après le titre, elle, est libre.\n- [Titre](notes.md)\n- [Première](perso-1.md)\n", + "perso/doublon.md": "# B", + "perso/notes.md": "## Titre\nmaj\n", + "perso/perso-1.md": "Première\nligne\n" + }, + "state": { + "active": "generale", + "agent": "", + "compact": "", + "mem_mode": "ondemand", + "projects": [ + { + "created_at": 1000, + "mem_mode": "off", + "name": "Générale", + "slug": "generale" + }, + { + "created_at": 2000, + "desc": "à moi", + "mem_mode": "ondemand", + "name": "Perso & Cie", + "slug": "perso" + } + ], + "tracker_raw": { + "generale/casse": "pas du json" + }, + "trackers": { + "generale/meme-jour": { + "events": [ + { + "date_only": true, + "id": "{{ID6}}", + "text": "m", + "ts": 1735682400000 + } + ], + "name": "Même jour" + }, + "generale/reseau-nas-bureau": { + "events": [ + { + "date_only": true, + "id": "{{ID4}}", + "text": "installé & fini", + "ts": 1672524000000 + } + ], + "name": "Réseau NAS (bureau)" + }, + "generale/vide": { + "events": null, + "name": "VIDE" + }, + "perso/meme-jour": { + "events": [ + { + "date_only": true, + "id": "{{ID7}}", + "text": "perso", + "ts": 1738360800000 + } + ], + "name": "Même jour" + }, + "perso/tracker": { + "events": [ + { + "date_only": true, + "id": "{{ID5}}", + "text": "z", + "ts": 1735682400000 + } + ], + "name": "!!!" + } + } + } + }, + { + "method": "POST", + "path": "/api/tracker", + "body": "{\"action\":\"delete\",\"slug\":\"vide\",\"id\":\"1\"}", + "status": 400, + "out": { + "error": "événement introuvable", + "ok": false + }, + "disk": { + "generale/.cache.md": "caché", + "generale/MEMORY.md": "# Index mémoire — Générale\n\n- [Courses v3](courses-v2.md)\n- [Neuve](neuve.md)\n", + "generale/Zeta.MD": "# Zêta\n", + "generale/courses-v2.md": "# Courses v3\n", + "generale/courses-v2.md.bak": "# Courses v2\nœufs & \n", + "generale/doublon.md": "# A", + "generale/neuve.md": "# Neuve\n", + "generale/notes.md.bak": "Sans titre\n", + "generale/notes.txt": "pas une page", + "perso/MEMORY.md": "# Index mémoire — Perso & Cie\n\nUne ligne par page de ce projet (`- [titre](fichier.md) — accroche`). Tenu à jour automatiquement à la création et à la suppression d'une page ; l'accroche après le titre, elle, est libre.\n- [Titre](notes.md)\n- [Première](perso-1.md)\n", + "perso/doublon.md": "# B", + "perso/notes.md": "## Titre\nmaj\n", + "perso/perso-1.md": "Première\nligne\n" + }, + "state": { + "active": "generale", + "agent": "", + "compact": "", + "mem_mode": "ondemand", + "projects": [ + { + "created_at": 1000, + "mem_mode": "off", + "name": "Générale", + "slug": "generale" + }, + { + "created_at": 2000, + "desc": "à moi", + "mem_mode": "ondemand", + "name": "Perso & Cie", + "slug": "perso" + } + ], + "tracker_raw": { + "generale/casse": "pas du json" + }, + "trackers": { + "generale/meme-jour": { + "events": [ + { + "date_only": true, + "id": "{{ID6}}", + "text": "m", + "ts": 1735682400000 + } + ], + "name": "Même jour" + }, + "generale/reseau-nas-bureau": { + "events": [ + { + "date_only": true, + "id": "{{ID4}}", + "text": "installé & fini", + "ts": 1672524000000 + } + ], + "name": "Réseau NAS (bureau)" + }, + "generale/vide": { + "events": null, + "name": "VIDE" + }, + "perso/meme-jour": { + "events": [ + { + "date_only": true, + "id": "{{ID7}}", + "text": "perso", + "ts": 1738360800000 + } + ], + "name": "Même jour" + }, + "perso/tracker": { + "events": [ + { + "date_only": true, + "id": "{{ID5}}", + "text": "z", + "ts": 1735682400000 + } + ], + "name": "!!!" + } + } + } + }, + { + "method": "GET", + "path": "/api/tracker", + "status": 200, + "out": { + "ok": true, + "trackers": [ + { + "count": 1, + "first_ts": 1735682400000, + "last_text": "m", + "last_ts": 1735682400000, + "name": "Même jour", + "slug": "meme-jour" + }, + { + "count": 1, + "first_ts": 1672524000000, + "last_text": "installé & fini", + "last_ts": 1672524000000, + "name": "Réseau NAS (bureau)", + "slug": "reseau-nas-bureau" + }, + { + "count": 0, + "first_ts": 0, + "last_text": "", + "last_ts": 0, + "name": "VIDE", + "slug": "vide" + } + ] + }, + "disk": { + "generale/.cache.md": "caché", + "generale/MEMORY.md": "# Index mémoire — Générale\n\n- [Courses v3](courses-v2.md)\n- [Neuve](neuve.md)\n", + "generale/Zeta.MD": "# Zêta\n", + "generale/courses-v2.md": "# Courses v3\n", + "generale/courses-v2.md.bak": "# Courses v2\nœufs & \n", + "generale/doublon.md": "# A", + "generale/neuve.md": "# Neuve\n", + "generale/notes.md.bak": "Sans titre\n", + "generale/notes.txt": "pas une page", + "perso/MEMORY.md": "# Index mémoire — Perso & Cie\n\nUne ligne par page de ce projet (`- [titre](fichier.md) — accroche`). Tenu à jour automatiquement à la création et à la suppression d'une page ; l'accroche après le titre, elle, est libre.\n- [Titre](notes.md)\n- [Première](perso-1.md)\n", + "perso/doublon.md": "# B", + "perso/notes.md": "## Titre\nmaj\n", + "perso/perso-1.md": "Première\nligne\n" + }, + "state": { + "active": "generale", + "agent": "", + "compact": "", + "mem_mode": "ondemand", + "projects": [ + { + "created_at": 1000, + "mem_mode": "off", + "name": "Générale", + "slug": "generale" + }, + { + "created_at": 2000, + "desc": "à moi", + "mem_mode": "ondemand", + "name": "Perso & Cie", + "slug": "perso" + } + ], + "tracker_raw": { + "generale/casse": "pas du json" + }, + "trackers": { + "generale/meme-jour": { + "events": [ + { + "date_only": true, + "id": "{{ID6}}", + "text": "m", + "ts": 1735682400000 + } + ], + "name": "Même jour" + }, + "generale/reseau-nas-bureau": { + "events": [ + { + "date_only": true, + "id": "{{ID4}}", + "text": "installé & fini", + "ts": 1672524000000 + } + ], + "name": "Réseau NAS (bureau)" + }, + "generale/vide": { + "events": null, + "name": "VIDE" + }, + "perso/meme-jour": { + "events": [ + { + "date_only": true, + "id": "{{ID7}}", + "text": "perso", + "ts": 1738360800000 + } + ], + "name": "Même jour" + }, + "perso/tracker": { + "events": [ + { + "date_only": true, + "id": "{{ID5}}", + "text": "z", + "ts": 1735682400000 + } + ], + "name": "!!!" + } + } + } + } + ] +} diff --git a/rust/loki-rs/tests/proxy.rs b/rust/loki-rs/tests/proxy.rs index 347d2aa..8bdaadb 100644 --- a/rust/loki-rs/tests/proxy.rs +++ b/rust/loki-rs/tests/proxy.rs @@ -105,6 +105,7 @@ async fn facade(backend: SocketAddr) -> (SocketAddr, oneshot::Sender<()>) { settings: false, files: None, push: None, + mem: None, }; tokio::spawn(loki_rs::proxy::serve(l, cfg, async move { let _ = rx.await; From 13bcbc74fe3efa562accf40af18019c913d61259 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 15 Oct 2026 17:58:24 +0000 Subject: [PATCH 11/52] =?UTF-8?q?Lot=2014=20:=20ligne=20de=20commande=20du?= =?UTF-8?q?=20moteur=20compos=C3=A9e=20en=20Rust=20(LOKI=5FSERVE=3Drust)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit loki-rs engine serve fait ce que faisait loki serve : lire la configuration (par l'API interne du Go), sonder le monde (aide du moteur, --version, --list-devices, GGUF, RAM, VRAM, cgroup, cpuset, variables LLAMA_ARG_*/GGML_*), composer la ligne de llama-server, vérifier le port, garder la commande pour l'interface, puis exec. Le superviseur le lance à la place de loki serve quand LOKI_SERVE=rust (expérimental ; le Go reste le défaut). serve/ porte backend_serve*.go fonction par fonction (couches GPU, --fit et sa marge, threads, files CUDA, second slot, mode tensor, cache de prompts, points de reprise, décodage spéculatif, fidélité, LOAD_GUARD, SLOT_PERSIST), toute la décision restant pure. Strata, l'essai de l'optimiseur et un lancement sans API interne passent toujours par loki serve (Go). La façade donne au Go l'adresse de son API interne (LOKI_GO_ADDR), héritée par le superviseur. Parité : parity_serve_test.go tire du Go 1 872 lancements (12 machines × 112 presets × 3 aides de moteur × 13 jeux de variables) et les sondes pures, rejoués argument par argument par tests/serve.rs ; deux essais de bout en bout lancent le vrai binaire contre un faux Go et un faux moteur. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_016tcGfJWefW5kUGoC2wL8cv --- README.md | 1 + ...10-15-rust-migration-lot14-serve-design.md | 97 + internal/loki/parity_serve_test.go | 350 + rust/loki-rs/src/engine/mod.rs | 9 +- rust/loki-rs/src/lib.rs | 1 + rust/loki-rs/src/main.rs | 20 +- rust/loki-rs/src/serve/cache.rs | 374 + rust/loki-rs/src/serve/ckpt.rs | 257 + rust/loki-rs/src/serve/expert.rs | 209 + rust/loki-rs/src/serve/fidelity.rs | 172 + rust/loki-rs/src/serve/mod.rs | 600 + rust/loki-rs/src/serve/moe.rs | 309 + rust/loki-rs/src/serve/persist.rs | 282 + rust/loki-rs/src/serve/queues.rs | 168 + rust/loki-rs/src/serve/run.rs | 884 + rust/loki-rs/src/serve/side.rs | 152 + rust/loki-rs/src/serve/spec.rs | 568 + rust/loki-rs/src/serve/split.rs | 382 + rust/loki-rs/src/serve/threads.rs | 256 + rust/loki-rs/tests/engine.rs | 1 + rust/loki-rs/tests/parity_serve/serve.json | 61321 ++++++++++++++++ rust/loki-rs/tests/serve.rs | 523 + 22 files changed, 66931 insertions(+), 5 deletions(-) create mode 100644 docs/superpowers/specs/2026-10-15-rust-migration-lot14-serve-design.md create mode 100644 internal/loki/parity_serve_test.go create mode 100644 rust/loki-rs/src/serve/cache.rs create mode 100644 rust/loki-rs/src/serve/ckpt.rs create mode 100644 rust/loki-rs/src/serve/expert.rs create mode 100644 rust/loki-rs/src/serve/fidelity.rs create mode 100644 rust/loki-rs/src/serve/mod.rs create mode 100644 rust/loki-rs/src/serve/moe.rs create mode 100644 rust/loki-rs/src/serve/persist.rs create mode 100644 rust/loki-rs/src/serve/queues.rs create mode 100644 rust/loki-rs/src/serve/run.rs create mode 100644 rust/loki-rs/src/serve/side.rs create mode 100644 rust/loki-rs/src/serve/spec.rs create mode 100644 rust/loki-rs/src/serve/split.rs create mode 100644 rust/loki-rs/src/serve/threads.rs create mode 100644 rust/loki-rs/tests/parity_serve/serve.json create mode 100644 rust/loki-rs/tests/serve.rs diff --git a/README.md b/README.md index e30d5b1..03c5ae6 100644 --- a/README.md +++ b/README.md @@ -234,6 +234,7 @@ redémarrages (volume `/data`). Variables d'environnement du conteneur : | `LOKI_FILES` | `rust` : les fichiers de la discussion — dépôt, panneau Fichiers, archive .zip, téléchargement, images et vignettes — sont servis par la façade Rust (expérimental, migration en cours) | Go | | `LOKI_PUSH` | `rust` : les notifications Web Push (clé VAPID, abonnements, envoi « réponse prête ») sont tenues par la façade Rust (expérimental, migration en cours) | Go | | `LOKI_MEM` | `rust` : la mémoire (éditeur des pages, index, déplacement entre projets, trackers, mode mémoire, interrupteurs du mode agent) est tenue par la façade Rust ; le chiffrement reste au Go (expérimental, migration en cours) | Go | +| `LOKI_SERVE` | `rust` : la ligne de commande de llama-server est composée par la façade Rust (`loki-rs engine serve`) au lieu de `loki serve` ; Strata et l'optimiseur restent au Go (expérimental, migration en cours) | Go | **Accès par nom de domaine.** Sans clé de pilotage, l'API n'accepte que les hôtes locaux (IP, `localhost`, nom sans point, `.local`/`.lan`…) et refuse diff --git a/docs/superpowers/specs/2026-10-15-rust-migration-lot14-serve-design.md b/docs/superpowers/specs/2026-10-15-rust-migration-lot14-serve-design.md new file mode 100644 index 0000000..2b8ee76 --- /dev/null +++ b/docs/superpowers/specs/2026-10-15-rust-migration-lot14-serve-design.md @@ -0,0 +1,97 @@ +# Lot 14 — `loki serve` composé en Rust + +## Contexte + +Depuis le lot 3, la supervision du moteur est en Rust : `loki-rs engine +start|stop|restart`. Mais elle lançait encore `loki serve` (Go), qui compose +la ligne de commande de llama-server avant de lui céder la place. + +Cette ligne vit dans `backend_serve*.go`, environ 4 000 lignes. Chaque option +y a été réglée contre un défaut mesuré : +- couches GPU et placement automatique (`--fit`, `--fit-target`) ; +- threads et sonde de conteneur ; +- files de lancement CUDA ; +- second slot, parallélisme de tenseurs ; +- cache de prompts, points de reprise des hybrides ; +- décodage spéculatif (MTP, brouillon, n-grammes) ; +- garde-fous de fidélité, garde-fou de chargement (LOAD_GUARD) ; +- état des slots gardé à la bascule. + +Lot 14 : `loki-rs engine serve` fait ce travail quand `LOKI_SERVE=rust` +(expérimental). Le superviseur le lance à la place de `loki serve`. Sans la +variable, rien ne change. + +## Découpage + +- `serve/` : port fonction par fonction. `mod.rs` contient + `build_serve_args` et les outils de drapeaux. Les modules suivent les + fichiers Go : + - `threads`, `queues`, `expert`, `fidelity` ; + - `side`, `split`, `cache`, `ckpt` ; + - `spec`, `moe`, `persist`. + + Tout ce qui décide est pur. +- `serve/run.rs` porte `cmdServe`. Il lit la configuration, la clé d'API, les + dossiers de modèles et le jeton de tentative de SPEC=auto par l'API interne + du Go. Il sonde ensuite : + - l'aide du moteur, `--version`, `--list-devices` (bornés dans le temps, + avec les bibliothèques du moteur) ; + - le GGUF ; + - la RAM, la VRAM (nvidia-smi), le cgroup et le cpuset ; + - les variables `LLAMA_ARG_*` et `GGML_*`. + + Il vérifie que le port est libre et écrit le marqueur de SLOT_PERSIST. Il + garde la commande en base pour l'interface (`engine_cmdline`), puis fait + `exec` du moteur. +- La façade passe au Go l'adresse de son API interne (`LOKI_GO_ADDR`), à côté + du jeton. Le superviseur, lancé par le Go, en hérite, et `engine serve` aussi. + +## Restent au Go + +`loki serve` (Go) est lancé à notre place dans ces cas, à l'identique : +- un preset Strata (son propre serveur, lot à venir) ; +- le moteur d'essai de l'optimiseur (`LOKI_TUNE_TRIAL`) ; +- l'absence d'API interne (lancement à la main hors de la façade). + +Restent aussi au Go les aperçus de l'éditeur de preset (`/api/preset/cacheram`, +`/api/preset/moe`, « Dupliquer en placement auto »). Ils viendront avec le lot +des presets. + +## Écarts assumés + +- Le message d'un brouillon (`MODEL_DRAFT`) introuvable cite l'erreur système + au format Rust. +- Pas de cache de l'aide ni du build par binaire : `engine serve` ne compose + qu'une ligne par lancement, le cache du Go ne servait qu'au process web. +- Le jeton de tentative de SPEC=auto est lu puis réécrit par l'API interne, + sans transaction : le Go seul l'efface, une fois le moteur prêt. + +## Parité + +`internal/loki/parity_serve_test.go` tire du Go 1 872 lancements, chacun +avec sa ligne de commande, son environnement et ses notes. Les lancements +croisent : +- 12 machines (VRAM, RAM, GGUF dense, MoE, hybride, par couches, architecture + exclue, brouillons, mémoire unifiée, conteneur à l'étroit) ; +- 112 presets ; +- 3 aides de moteur (récent, ancien, inconnu) ; +- 13 jeux de variables `LLAMA_ARG_*`, et des variables `GGML_*`. + +Le journal comprend aussi les sondes pures : +- listes de CPU, cpuset et quotas, mémoire du cgroup ; +- sortie de nvidia-smi, `--list-devices`, build du moteur ; +- forme copiable de la commande ; +- verdicts de SPEC=auto ; +- drapeaux de chargement, taille d'état par jeton. + +`tests/serve.rs` rejoue le tout contre `serve::build_serve_args`, argument +par argument. Une mutation du calcul du cache, des files CUDA ou des bornes +des n-grammes fait échouer le rejeu. + +Deux essais de bout en bout lancent le vrai `loki-rs engine serve` contre un +faux Go et un faux llama-server. Ils vérifient : +- la ligne exécutée et le chemin des bibliothèques ; +- la commande gardée en base ; +- le refus d'un port pris, d'un modèle absent ; +- la sortie propre d'un preset externe ; +- la délégation au Go (sans API interne, essai de l'optimiseur, Strata). diff --git a/internal/loki/parity_serve_test.go b/internal/loki/parity_serve_test.go new file mode 100644 index 0000000..fbd69b8 --- /dev/null +++ b/internal/loki/parity_serve_test.go @@ -0,0 +1,350 @@ +package loki + +import ( + "os" + "path/filepath" + "strconv" + "testing" + "testing/fstest" +) + +// parity_serve_test.go — journal de référence de « loki serve » (lot 14 de la +// migration Rust) : la ligne de commande de llama-server, son environnement et +// ses notes, pour des centaines de lancements (machines × presets × aides de +// moteur × variables), plus les sondes pures (cgroup, cpuset, nvidia-smi, +// --list-devices, build du moteur). Rangé dans +// rust/loki-rs/tests/parity_serve/serve.json ; tests/serve.rs le rejoue contre +// serve::build_serve_args. +// +// LOKI_PARITY_UPDATE=1 go test ./internal/loki -run TestPariteServe + +const parityServeFile = "../../rust/loki-rs/tests/parity_serve/serve.json" + +type parityServeCase struct { + M int `json:"m"` // machine + C int `json:"c"` // preset + H int `json:"h"` // aide du moteur + E int `json:"e"` // variables du moteur + Args []string `json:"args"` + Env any `json:"env"` + Notes []string `json:"notes"` +} + +const ( + parityHelpModern = "-ngl, --gpu-layers, --n-gpu-layers N max. number of layers to store in VRAM, either an exact number, 'auto', or 'all' (default: auto)\n" + + "--reasoning [on|off|auto] --reasoning-budget N --load-mode {auto,mmap,none,mlock,mmap+mlock,dio}\n" + + "-fit, --fit [on|off] -fitt, --fit-target MiB0,MiB1 --no-kv-unified -cram, --cache-ram N --slot-save-path PATH\n" + + "-ctxcp, --ctx-checkpoints N -cms, --checkpoint-min-step N --reasoning-preserve, --no-reasoning-preserve\n" + + "--spec-type {none,draft-simple,draft-mtp,ngram-mod} --spec-draft-n-max N --spec-draft-sampling {greedy,probabilistic}\n" + + "--spec-ngram-mod-n-match N -sm, --split-mode {none,layer,row,tensor} --context-shift, --no-context-shift\n" + parityHelpOld = "-ngl, --gpu-layers, --n-gpu-layers N number of layers to store in VRAM\n" + + "--mlock --no-mmap --no-context-shift --cache-ram N\n" +) + +func TestPariteServe(t *testing.T) { + gDense := &GGUFInfo{Version: 3, Arch: "qwen3", BlockCount: 64, ContextLength: 262144, KeyLen: 128, ValLen: 128, + HeadCountKV: 8, EmbeddingLength: 5120, HeadCount: 40, TensorCount: 707} + gMoE := &GGUFInfo{Version: 3, Arch: "qwen3moe", BlockCount: 48, ContextLength: 262144, ExpertCount: 128, + KeyLen: 128, ValLen: 128, HeadCountKV: 4, EmbeddingLength: 2048, HeadCount: 32, TensorCount: 579} + gHybrid := &GGUFInfo{Version: 3, Arch: "qwen35", BlockCount: 64, NextN: 1, ContextLength: 262144, KeyLen: 256, + ValLen: 256, HeadCountKV: 4, FullAttnInterval: 4, Hybrid: true, EmbeddingLength: 5120, HeadCount: 24, + SSMConv: 4, SSMInner: 6144, SSMState: 128, SSMGroups: 16, HasNextNTensor: true, TensorCount: 900} + gLayers := &GGUFInfo{Version: 3, Arch: "gemma3", BlockCount: 6, KeyLen: 64, ValLen: 64, + HeadCountKVLayers: []int{2, 2, 0, 2, 2, 0}, HeadCountKV: 2, Hybrid: true, SSMConv: 4, SSMInner: 512, + SSMState: 16, SSMGroups: 1} + gDeny := &GGUFInfo{Version: 3, Arch: "deepseek2", BlockCount: 61, ExpertCount: 256, KeyLen: 192, ValLen: 128, + HeadCountKV: 128} + draftMTP := &GGUFInfo{Arch: "qwen35", BlockCount: 1, HasNextNTensor: true} + draftEagle := &GGUFInfo{Arch: "eagle3", BlockCount: 1} + draftSmall := &GGUFInfo{Arch: "qwen3", BlockCount: 28, KeyLen: 128, ValLen: 128, HeadCountKV: 8} + gib := int64(1 << 30) + devs2 := []splitDev{{ID: "CUDA0", TotalMiB: 16311}, {ID: "CUDA1", TotalMiB: 12288}} + + machines := []serveSysInfo{ + // 0 : une carte de 16 Go, 64 Go de RAM, dense de 16 Go. + {Model: "/m/dense.gguf", GGUF: gDense, ModelBytes: 16 * gib, RAMMiB: 65536, RAMAvailMiB: 49152, VRAMMiB: 16311, GPUs: 1}, + // 1 : deux cartes, 32 Go de RAM, MoE de 22 Go. + {Model: "/m/moe.gguf", GGUF: gMoE, ModelBytes: 22 * gib, RAMMiB: 32768, RAMAvailMiB: 20000, VRAMMiB: 28599, GPUs: 2, + SplitDevs: devs2}, + // 2 : rien de connu. + {Model: "/m/inconnu.gguf"}, + // 3 : hybride avec tête MTP, RAM serrée, build ancien. + {Model: "/m/hybrid.gguf", GGUF: gHybrid, ModelBytes: 20 * gib, RAMMiB: 24576, RAMAvailMiB: 6000, VRAMMiB: 24576, + GPUs: 1, EngineBuild: 9000}, + // 4 : hybride, build récent, deux cartes, beaucoup de RAM. + {Model: "/m/hybrid.gguf", GGUF: gHybrid, ModelBytes: 15 * gib, RAMMiB: 131072, RAMAvailMiB: 100000, VRAMMiB: 28599, + GPUs: 2, EngineBuild: 12000, SplitDevs: devs2, SlotDir: "/data/slots"}, + // 5 : architecture exclue du mode tensor, VRAM inconnue, clé d'API. + {Model: "/m/ds.gguf", GGUF: gDeny, ModelBytes: 200 * gib, RAMMiB: 262144, RAMAvailMiB: 200000, APIKey: "sk-loki-x", + SplitDevs: []splitDev{{ID: "Vulkan0", TotalMiB: 8000}, {ID: "CUDA0", TotalMiB: 8000}}}, + // 6 : couches par tableau, mémoire unifiée, conteneur à l'étroit, dossier des slots. + {Model: "/m/layers.gguf", GGUF: gLayers, ModelBytes: 3 * gib, RAMMiB: 16384, RAMAvailMiB: 4096, UnifiedMem: true, + CPU: cpuBudget{N: 4, Engine: 8, Why: "cpuset du conteneur, 4 cœurs physiques permis sur 8"}, SlotDir: "/data/slots", + MMProj: "/m/mmproj.gguf", EngineVRAMMiB: 0}, + // 7 : brouillon MTP à part, deux cartes, build récent, VRAM du moteur connue. + {Model: "/m/dense.gguf", GGUF: gDense, ModelBytes: 30 * gib, RAMMiB: 32768, RAMAvailMiB: 16000, VRAMMiB: 24000, + GPUs: 2, EngineBuild: 11500, Draft: "/m/mtp-head.gguf", DraftGGUF: draftMTP, EngineVRAMMiB: 24000, + SplitDevs: devs2, LaunchQueues: ""}, + } + machines = append(machines, + // 8 : brouillon EAGLE, puis petit modèle brouillon, puis le modèle lui-même. + serveSysInfo{Model: "/m/dense.gguf", GGUF: gDense, ModelBytes: 16 * gib, RAMMiB: 65536, RAMAvailMiB: 49152, + VRAMMiB: 16311, GPUs: 1, EngineBuild: 12000, Draft: "/m/eagle.gguf", DraftGGUF: draftEagle}, + serveSysInfo{Model: "/m/dense.gguf", GGUF: gDense, ModelBytes: 16 * gib, RAMMiB: 65536, RAMAvailMiB: 49152, + VRAMMiB: 16311, GPUs: 1, EngineBuild: 12000, Draft: "/m/small.gguf", DraftGGUF: draftSmall}, + serveSysInfo{Model: "/m/hybrid.gguf", GGUF: gHybrid, ModelBytes: 15 * gib, RAMMiB: 65536, RAMAvailMiB: 49152, + VRAMMiB: 24576, GPUs: 1, EngineBuild: 12000, Draft: "/m/hybrid.gguf", DraftGGUF: gHybrid, DraftIsModel: true, + SpecAutoBlocked: "le dernier lancement avec cette configuration n'a jamais répondu"}, + // 11 : brouillon introuvable. + serveSysInfo{Model: "/m/dense.gguf", GGUF: gDense, ModelBytes: 16 * gib, RAMMiB: 65536, RAMAvailMiB: 49152, + VRAMMiB: 16311, GPUs: 1, EngineBuild: 12000, DraftErr: "stat /m/absent.gguf: no such file or directory"}, + ) + + presets := []map[string]string{ + {}, + {"CTX": "65536", "NGL": "999", "BATCH": "4096", "UBATCH": "2048", "HOST": "127.0.0.1", "PORT": "8081"}, + {"NGL": "auto"}, {"NGL": "40"}, {"NGL": "all"}, {"NGL": " 999 "}, + {"THREADS": "8", "THREADS_BATCH": "4"}, {"THREADS": "abc", "THREADS_BATCH": "-2"}, {"THREADS": "0"}, + {"KV_TYPE": "q8_0"}, {"KV_TYPE_K": "q4_0", "KV_TYPE_V": "f16"}, {"KV_TYPE": "bf16"}, {"KV_TYPE": "q5_1"}, + {"REASONING": "on"}, {"REASONING": "off"}, {"REASONING": "auto", "REASONING_BUDGET": "2048"}, {"REASONING": "deepseek"}, + {"REASONING_PRESERVE": "on"}, {"REASONING_PRESERVE": "off"}, {"REASONING_PRESERVE": "peut-être"}, + {"CACHE_RAM": "auto"}, {"CACHE_RAM": "4096"}, {"CACHE_RAM": "0"}, {"CACHE_RAM": "-1"}, {"CACHE_RAM": "beaucoup"}, + {"CACHE_ISOLATE": "off"}, {"CACHE_RAM": "0", "CACHE_ISOLATE": "on"}, + {"SIDE_SLOT": "on"}, {"SIDE_SLOT": "on", "PARALLEL": "3"}, {"SIDE_SLOT": "on", "CTX": "0"}, {"SIDE_SLOT": "on", "CTX": "8192", "KV_TYPE": "q8_0"}, + {"SPLIT_MODE": "tensor"}, {"SPLIT_MODE": "tensor", "CTX": "16384"}, {"SPLIT_MODE": "bizarre"}, {"SPLIT_MODE": "layer"}, + {"SPLIT_MODE": "tensor", "KV_TYPE": "q8_0"}, {"SPLIT_MODE": "tensor", "NGL": "20"}, + {"SPEC": "auto"}, {"SPEC": "mtp"}, {"SPEC": "ngram"}, {"SPEC": "mtp+ngram"}, {"SPEC": "ngram+mtp"}, {"SPEC": "turbo"}, + {"SPEC": "off", "MODEL_DRAFT": "mtp-head.gguf"}, {"SPEC": "mtp", "MODEL_DRAFT": "mtp-head.gguf", "SPEC_N_MAX": "4"}, + {"SPEC": "mtp", "SPEC_N_MAX": "99", "SPEC_SAMPLING": "probabilistic"}, {"SPEC": "auto", "SPEC_SAMPLING": "probabilistic"}, + {"SPEC": "mtp", "SPEC_SAMPLING": "aléatoire"}, {"SPEC": "ngram", "MODEL_DRAFT": "x.gguf", "SPEC_N_MAX": "3"}, + {"SPEC": "auto", "MODEL_DRAFT": "small.gguf"}, {"SPEC": "mtp+ngram", "MODEL_DRAFT": "small.gguf"}, + {"CUDA_LAUNCH_QUEUES": "off"}, {"CUDA_LAUNCH_QUEUES": "2x"}, {"CUDA_LAUNCH_QUEUES": "8x"}, {"CUDA_LAUNCH_QUEUES": "auto"}, + {"CUDA_VISIBLE_DEVICES": "1,0"}, + {"FIT_TARGET": "1024,3072"}, {"FIT_TARGET": "512"}, {"FIT_TARGET": "1024,,3072"}, {"FIT_TARGET": "1,2,3,4,5,6,7,8,9"}, + {"FIT_TARGET": "2048/4096", "CTX": "0"}, {"FIT_TARGET": "2048", "NGL": "30"}, + {"OP_OFFLOAD_MIN_BATCH": "128"}, {"OP_OFFLOAD_MIN_BATCH": "0"}, + {"CUDA_GRAPH_OPT": "on"}, {"CUDA_GRAPH_OPT": "oui"}, {"CUDA_GRAPH_OPT": "off"}, + {"CTX_CHECKPOINTS": "8"}, {"CTX_CHECKPOINTS": "beaucoup"}, {"CKPT_MIN_STEP": "1024"}, {"CKPT_MIN_STEP": "0"}, + {"SLOT_PERSIST": "on"}, {"SLOT_PERSIST": "on", "HOST": "127.0.0.1"}, {"SLOT_PERSIST": "on", "HOST": "127.0.0.1", "SPEC": "mtp"}, + {"LOAD_GUARD": "off", "EXTRA_ARGS": "--load-mode none"}, {"EXTRA_ARGS": "--mlock --no-mmap"}, {"EXTRA_ARGS": "--mlock"}, + {"EXTRA_ARGS": "--no-mmap"}, {"EXTRA_ARGS": "--mmap"}, {"EXTRA_ARGS": "--load-mode dio"}, {"EXTRA_ARGS": "--load-mode=mlock"}, + {"EXTRA_ARGS": "-ngl 30 --parallel 4"}, {"EXTRA_ARGS": "-t 6 -tb 3"}, {"EXTRA_ARGS": "-Cb 0xff --poll-batch 1"}, + {"THREADS": "6", "EXTRA_ARGS": "--prio-batch 2"}, {"EXTRA_ARGS": "--cache-reuse 256 --context-shift"}, + {"EXTRA_ARGS": "-ctk q8_0 -ctv q8_0"}, {"EXTRA_ARGS": "-ot exps=CPU"}, {"EXTRA_ARGS": "-ot 'blk\\.(1|2)\\.ffn_.*_exps=CPU' -ub 512"}, + {"EXTRA_ARGS": "--n-cpu-moe 20"}, {"EXTRA_ARGS": "--cpu-moe"}, {"EXTRA_ARGS": "-sm row"}, {"EXTRA_ARGS": "-ts 1,1"}, + {"EXTRA_ARGS": "-dev CUDA0,CUDA1"}, {"EXTRA_ARGS": "-dev Vulkan0"}, {"EXTRA_ARGS": "--fit off"}, {"EXTRA_ARGS": "--mmproj /m/x.gguf"}, + {"EXTRA_ARGS": "--spec-synth-len 4"}, {"EXTRA_ARGS": "--slot-save-path /x"}, {"EXTRA_ARGS": "-c 0"}, + {"EXTRA_ARGS": "--host 127.0.0.1 --port 9000"}, {"EXTRA_ARGS": "--mirostat 2", "SPEC": "mtp", "SPEC_SAMPLING": "probabilistic"}, + {"EXTRA_ARGS": "--chat-template-kwargs '{\"preserve_reasoning\":true}'", "REASONING_PRESERVE": "on"}, + {"EXTRA_ARGS": "--spec-type ngram-mod", "SPEC": "auto"}, {"EXTRA_ARGS": "--spec-draft-n-max 8", "SPEC": "ngram"}, + {"EXTRA_ARGS": "-ot attn=CUDA1", "CUDA_GRAPH_OPT": "on"}, {"EXTRA_ARGS": "-fa off", "SPLIT_MODE": "tensor"}, + {"EXTRA_ARGS": "-nkvo"}, {"EXTRA_ARGS": "--no-context-shift"}, {"EXTRA_ARGS": "-c 8192 -np 2", "SIDE_SLOT": "on"}, + {"EXTERNAL": "1", "SIDE_SLOT": "on", "SPLIT_MODE": "tensor"}, + {"MMPROJ": "x", "NGL": "999", "CTX": "40000", "KV_TYPE": "q4_0", "SPEC": "auto", "CACHE_RAM": "auto"}, + } + helps := []string{parityHelpModern, parityHelpOld, ""} + argEnvs := []map[string]string{ + {}, + {"LLAMA_ARG_CACHE_TYPE_K": "q8_0"}, {"LLAMA_ARG_NO_KV_OFFLOAD": "1"}, {"LLAMA_ARG_N_CPU_MOE": "10"}, + {"LLAMA_ARG_CACHE_RAM": "1024"}, {"LLAMA_ARG_CONTEXT_SHIFT": "on"}, {"LLAMA_ARG_FIT": "off"}, + {"LLAMA_ARG_SPLIT_MODE": "row"}, {"LLAMA_ARG_DEVICE": "CUDA0"}, {"LLAMA_ARG_MLOCK": "1", "LLAMA_ARG_NO_MMAP": "1"}, + {"LLAMA_ARG_NO_CONTEXT_SHIFT": "true"}, {"LLAMA_ARG_SPEC_SYNTH_LEN": "3"}, {"LLAMA_ARG_TENSOR_SPLIT": "1,1"}, + } + userEnvs := []map[string]string{{}, {"GGML_CUDA_GRAPH_OPT": "1", "GGML_CUDA_ALLREDUCE": "nccl", "GGML_OP_OFFLOAD_MIN_BATCH": "64"}} + + var fx struct { + Machines []serveSysInfo `json:"machines"` + Presets []map[string]string `json:"presets"` + Helps []string `json:"helps"` + ArgEnvs []map[string]string `json:"arg_envs"` + UserEnvs []map[string]string `json:"user_envs"` + Cases []parityServeCase `json:"cases"` + + CPUList map[string]any `json:"cpu_list"` + Cpuset []map[string]any `json:"cpuset"` + Cgroup []map[string]any `json:"cgroup"` + SMI []map[string]any `json:"smi"` + Devices map[string]any `json:"devices"` + Builds map[string]int `json:"builds"` + Cmdlines []map[string]any `json:"cmdlines"` + Verdicts []map[string]any `json:"verdicts"` + Fit map[string]bool `json:"help_fit"` + Gib map[string]string `json:"gib"` + Load []map[string]any `json:"load_flags"` + State []map[string]any `json:"state_bytes"` + } + fx.Machines, fx.Presets, fx.Helps, fx.ArgEnvs, fx.UserEnvs = machines, presets, helps, argEnvs, userEnvs + add := func(m, c, h, e int) { + si := machines[m] + si.Help = helps[h] + si.ArgEnv = map[string]string{} + for k, v := range argEnvs[e] { + si.ArgEnv[k] = v + } + si.UserEnv = map[string]string{} + if (m+c)%3 == 1 { + for k, v := range userEnvs[1] { + si.UserEnv[k] = v + } + } + cfg := presets[c] + args, env, notes := buildServeArgs(cfg, splitArgs(cfg["EXTRA_ARGS"]), "/app/llama-server", si) + fx.Cases = append(fx.Cases, parityServeCase{M: m, C: c, H: h, E: e, Args: args, Env: env, Notes: notes}) + } + for m := range machines { + for c := range presets { + add(m, c, 0, 0) + } + } + for c := range presets { + add(0, c, 1, 0) + add(2, c, 2, 0) + add(4, c, 1, 0) + } + for e := 1; e < len(argEnvs); e++ { + for _, c := range []int{0, 1, 9, 20, 27, 31, 37, 39, 40, 57, 65, 69, 77, 84, 92, 93} { + add((e+c)%len(machines), c, 0, e) + } + } + + // Sondes pures. + fx.CPUList = map[string]any{} + for _, s := range []string{"0-3,8-11", " 5 ", "", "0,0,1", "3-1", "a-b", "0-70000", "2-2,1"} { + l, err := parseCPUList(s) + if err != nil { + fx.CPUList[s] = "erreur" + } else { + fx.CPUList[s] = l + } + } + topo := func(fs fstest.MapFS, cpus int, smt bool) { + for c := 0; c < cpus; c++ { + sib := c + if smt { + sib = c / 2 + } + fs["sys/devices/system/cpu/cpu"+strconv.Itoa(c)+"/topology/thread_siblings"] = &fstest.MapFile{Data: []byte(strconv.Itoa(sib) + "\n")} + } + } + for _, tc := range []struct { + name string + files map[string]string + cpus int + smt bool + }{ + {"sans restriction", map[string]string{"sys/devices/system/cpu/online": "0-7\n", "proc/self/status": "Name:\tx\nCpus_allowed_list:\t0-7\n"}, 8, true}, + {"cpuset", map[string]string{"sys/devices/system/cpu/online": "0-15\n", "proc/self/status": "Cpus_allowed_list:\t0-5\n"}, 16, true}, + {"quota v2", map[string]string{"sys/devices/system/cpu/online": "0-15\n", "proc/self/status": "Cpus_allowed_list:\t0-15\n", "sys/fs/cgroup/cpu.max": "250000 100000\n"}, 16, false}, + {"quota v1", map[string]string{"sys/devices/system/cpu/online": "0-15\n", "proc/self/status": "Cpus_allowed_list:\t0-15\n", "sys/fs/cgroup/cpu/cpu.cfs_quota_us": "150000\n", "sys/fs/cgroup/cpu/cpu.cfs_period_us": "100000\n"}, 16, false}, + {"cœurs E", map[string]string{"sys/devices/system/cpu/online": "0-11\n", "proc/self/status": "Cpus_allowed_list:\t0-3,8-11\n", "sys/devices/cpu_atom/cpus": "8-11\n"}, 12, false}, + {"max", map[string]string{"sys/devices/system/cpu/online": "0-3\n", "proc/self/status": "Cpus_allowed_list:\t0-3\n", "sys/fs/cgroup/cpu.max": "max 100000\n"}, 4, false}, + {"illisible", map[string]string{"sys/devices/system/cpu/online": "x\n"}, 0, false}, + } { + fs := fstest.MapFS{} + for p, d := range tc.files { + fs[p] = &fstest.MapFile{Data: []byte(d)} + } + topo(fs, tc.cpus, tc.smt) + files := map[string]string{} + for p, f := range fs { + files[p] = string(f.Data) + } + b := cpusetThreads(fs) + fx.Cpuset = append(fx.Cpuset, map[string]any{"name": tc.name, "files": files, "n": b.N, "engine": b.Engine, "why": b.Why}) + } + for _, files := range []map[string]string{ + {"sys/fs/cgroup/memory.max": "17179869184\n", "sys/fs/cgroup/memory.current": "4294967296\n"}, + {"sys/fs/cgroup/memory.max": "max\n"}, + {"sys/fs/cgroup/memory/memory.limit_in_bytes": "9223372036854771712\n"}, + {"sys/fs/cgroup/memory/memory.limit_in_bytes": "8589934592\n", "sys/fs/cgroup/memory/memory.usage_in_bytes": "1073741824\n"}, + {}, + } { + fs := fstest.MapFS{} + for p, d := range files { + fs[p] = &fstest.MapFile{Data: []byte(d)} + } + l, c := cgroupMemMiB(fs) + fx.Cgroup = append(fx.Cgroup, map[string]any{"files": files, "limit": l, "current": c}) + } + smiOut := "0, 16311\n1, 12288\n" + for _, tc := range []struct { + out, cvd string + pci bool + }{{smiOut, "", true}, {smiOut, "1", true}, {smiOut, "1,0", false}, {smiOut, "2", true}, {smiOut, "GPU-abc", true}, + {"0, 0\n", "", true}, {"", "", true}, {"0, 8192\n", "0", false}} { + fx.SMI = append(fx.SMI, map[string]any{"out": tc.out, "cvd": tc.cvd, "pci": tc.pci, "mib": vramFromSMI(tc.out, tc.cvd, tc.pci)}) + } + devOut := "Available devices:\n CUDA0: NVIDIA GeForce RTX 5060 Ti (16311 MiB, 15579 MiB free)\n CUDA1: NVIDIA GeForce RTX 3060 (12288 MiB, 11000 MiB free)\r\n Vulkan0: AMD (8000 MiB, 0 MiB free)\nautre ligne\n" + fx.Devices = map[string]any{"out": devOut, "devs": splitDevsFrom(parseListDevices(devOut))} + fx.Builds = map[string]int{} + for _, out := range []string{"version: 6543 (abcdef0)\nbuilt with gcc", "llama-server build 11009, commit 1a2b3c4", "rien", "build: 12 (ff)"} { + b, _ := parseEngineBuild(out) + fx.Builds[out] = b + } + for _, tc := range []struct { + env map[string]string + args []string + }{ + {map[string]string{"CUDA_VISIBLE_DEVICES": "0,1", "GGML_X": "a b"}, []string{"/app/llama-server", "-m", "/m/a b.gguf", "--api-key", "secret", "--chat-template-kwargs", `{"x":"y"}`, ""}}, + {map[string]string{}, []string{"/bin/x", "--api-key-file", "/k"}}, + } { + fx.Cmdlines = append(fx.Cmdlines, map[string]any{"env": tc.env, "args": tc.args, "out": engineCmdline(tc.env, tc.args)}) + } + cur := specAutoMark{FP: "abc", Bin: "/app/llama-server", Build: 11500} + for _, tc := range []struct { + attempt *specAutoMark + failed map[string]string + }{{nil, nil}, {&cur, nil}, {&specAutoMark{FP: "x"}, nil}, {nil, map[string]string{cur.key(): "déjà échoué"}}} { + why, rec := specAutoVerdict(cur, tc.attempt, tc.failed) + fx.Verdicts = append(fx.Verdicts, map[string]any{"attempt": tc.attempt, "failed": tc.failed, "why": why, "record": rec}) + } + fx.Fit = map[string]bool{} + for _, h := range []string{"--fit [on|off]", "-fit, --fit X", "--fit-target", "--fit=on", "x--fit", "--fit", "a,--fit,b", "--fitness"} { + fx.Fit[h] = helpSupportsFit(h) + } + fx.Gib = map[string]string{} + for _, m := range []int64{0, 512, 1280, 1536, 16311, 22528, 65536, 104857} { + fx.Gib[strconv.Itoa(int(m))] = gibText(m) + } + for _, tc := range []struct { + args []string + modern bool + }{ + {[]string{"--mlock"}, true}, {[]string{"--no-mmap", "--mlock"}, true}, {[]string{"--mmap", "-x"}, true}, + {[]string{"--load-mode", "none", "--mlock"}, true}, {[]string{"--load-mode", "mlock"}, false}, + {[]string{"--load-mode=none", "--no-mmap"}, false}, {[]string{"-lm", "dio"}, false}, {[]string{"-lm", "MMAP+MLOCK"}, false}, + {[]string{"--load-mode"}, false}, {[]string{"-x"}, false}, + } { + out, note := normalizeLoadFlags(tc.args, tc.modern) + fx.Load = append(fx.Load, map[string]any{"args": tc.args, "modern": tc.modern, "out": out, "note": note}) + } + for _, g := range []*GGUFInfo{gDense, gMoE, gHybrid, gLayers, gDeny, {BlockCount: 10}} { + for _, kv := range [][2]string{{"", ""}, {"q8_0", "q4_0"}, {"f32", "iq4_nl"}} { + pt, rec := ggufStateBytes(*g, kv[0], kv[1]) + fx.State = append(fx.State, map[string]any{"gguf": g, "k": kv[0], "v": kv[1], "per_token": pt, "recurrent": rec}) + } + } + + got, err := parityJSON(fx) + if err != nil { + t.Fatal(err) + } + if os.Getenv("LOKI_PARITY_UPDATE") == "1" { + if err := os.MkdirAll(filepath.Dir(parityServeFile), 0o755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(parityServeFile, got, 0o644); err != nil { + t.Fatal(err) + } + return + } + want, err := os.ReadFile(parityServeFile) + 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("loki serve ne produit plus le journal rangé dans %s.\n"+ + "Si c'est voulu : LOKI_PARITY_UPDATE=1 go test ./internal/loki -run TestPariteServe,\n"+ + "puis cargo test --test serve dit si le port Rust suit.", parityServeFile) + } +} diff --git a/rust/loki-rs/src/engine/mod.rs b/rust/loki-rs/src/engine/mod.rs index 098a7b3..0201c6a 100644 --- a/rust/loki-rs/src/engine/mod.rs +++ b/rust/loki-rs/src/engine/mod.rs @@ -41,6 +41,10 @@ impl Paths { pub struct Opts { /// Binaire Go à lancer en `serve`. pub go_bin: PathBuf, + /// Commande qui compose la ligne du moteur puis lui cède la place : + /// `loki serve` (Go) par défaut, `loki-rs engine serve` quand + /// `LOKI_SERVE=rust` (lot 14). + pub serve: (PathBuf, Vec), /// Délai avant de juger que le démarrage a tenu (2 s comme le Go). pub check_delay: Duration, /// SIGTERM → SIGKILL (4 s comme le Go). @@ -52,6 +56,7 @@ pub struct Opts { impl Opts { pub fn new(go_bin: PathBuf) -> Self { Opts { + serve: (go_bin.clone(), vec!["serve".into()]), go_bin, check_delay: Duration::from_secs(2), grace: Duration::from_secs(4), @@ -124,8 +129,8 @@ mod imp { .open(paths.log()) .map_err(|e| format!("ouverture du log {}: {e}", paths.log().display()))?; let log2 = log.try_clone().map_err(|e| e.to_string())?; - let mut cmd = Command::new(&opts.go_bin); - cmd.arg("serve") + let mut cmd = Command::new(&opts.serve.0); + cmd.args(&opts.serve.1) .stdin(Stdio::null()) .stdout(log) .stderr(log2) diff --git a/rust/loki-rs/src/lib.rs b/rust/loki-rs/src/lib.rs index 185dc36..b92ecef 100644 --- a/rust/loki-rs/src/lib.rs +++ b/rust/loki-rs/src/lib.rs @@ -30,6 +30,7 @@ pub mod paths; pub mod presets; pub mod proxy; pub mod push; +pub mod serve; pub mod settings; pub mod store; pub mod sysinfo; diff --git a/rust/loki-rs/src/main.rs b/rust/loki-rs/src/main.rs index e736fc5..343a2d4 100644 --- a/rust/loki-rs/src/main.rs +++ b/rust/loki-rs/src/main.rs @@ -21,8 +21,17 @@ fn main() -> ExitCode { "engine" => { let action = std::env::args().nth(2).unwrap_or_default(); let go_bin = child::go_bin(std::env::var("LOKI_GO_BIN").ok(), &exe()); - let opts = engine::Opts::new(go_bin); - match engine::run(&action, &engine::Paths::from_env(), &opts) { + let paths = engine::Paths::from_env(); + // La ligne de commande du moteur composée ici (lot 14) : lancée + // par le superviseur quand LOKI_SERVE=rust. + if action == "serve" { + return loki_rs::serve::run::run(&go_bin, &paths.home); + } + let mut opts = engine::Opts::new(go_bin); + if held_by_facade("LOKI_SERVE") { + opts.serve = (exe(), vec!["engine".into(), "serve".into()]); + } + match engine::run(&action, &paths, &opts) { Ok(()) => ExitCode::SUCCESS, Err(e) => { // Dernière ligne de stderr : le Go en fait son message d'erreur. @@ -136,7 +145,12 @@ fn serve() -> ExitCode { // LOKI_MEM=rust : la mémoire (pages, index, trackers, mode mémoire, // interrupteurs du mode agent). let mem_rust = held_by_facade("LOKI_MEM"); - let mut env = vec![(auth::INTERNAL_ENV.to_string(), token.clone())]; + let mut env = vec![ + (auth::INTERNAL_ENV.to_string(), token.clone()), + // Adresse de l'API interne du Go, héritée par ses enfants : + // « loki-rs engine serve » (LOKI_SERVE=rust) y lit la configuration. + ("LOKI_GO_ADDR".to_string(), backend.to_string()), + ]; let held = [ ("LOKI_CHAT", chat_rust), ("LOKI_MODELS", models_rust), diff --git a/rust/loki-rs/src/serve/cache.rs b/rust/loki-rs/src/serve/cache.rs new file mode 100644 index 0000000..5bfac2f --- /dev/null +++ b/rust/loki-rs/src/serve/cache.rs @@ -0,0 +1,374 @@ +//! Cache de prompts en RAM et dossier des slots (`backend_serve_cache.go`) : +//! --cache-ram agrandi quand le besoin se mesure et que la RAM est libre, +//! jamais sous le défaut du moteur ; --slot-save-path seulement pour un +//! moteur fermé aux autres. + +use std::path::Path; + +use super::{ + Cfg, SysInfo, atoi, effective_kv_types, env_truthy, flag_value, flag_values, get, has_any_flag, + kv_offloaded, slot_persist_plan, trim, +}; +use crate::gguf::GgufMeta; +use std::collections::HashMap; + +/// `engineCacheRAMDefault` : cache_ram_mib par défaut de llama.cpp. +pub const ENGINE_CACHE_RAM_DEFAULT: i64 = 8192; +/// `cacheArgEnv`. +pub const CACHE_ARG_ENV: &[&str] = &["LLAMA_ARG_CACHE_RAM"]; + +/// `cgroupMemMiB` : (limite, consommation) du conteneur en Mio ; 0 = aucune. +pub fn cgroup_mem_mib(root: &Path) -> (i64, i64) { + let read = |p: &str| -> i64 { + let Ok(b) = std::fs::read(root.join(p)) else { + return 0; + }; + match atoi(trim(&String::from_utf8_lossy(&b))) { + Some(n) if n > 0 && n < 1 << 60 => n >> 20, + _ => 0, + } + }; + let limit = read("sys/fs/cgroup/memory.max"); + if limit > 0 { + return (limit, read("sys/fs/cgroup/memory.current")); + } + let limit = read("sys/fs/cgroup/memory/memory.limit_in_bytes"); + if limit > 0 { + return (limit, read("sys/fs/cgroup/memory/memory.usage_in_bytes")); + } + (0, 0) +} + +/// `vramFromSMI` : VRAM des cartes choisies par CUDA_VISIBLE_DEVICES. +pub fn vram_from_smi(out: &str, cvd: &str, pci_order: bool) -> i64 { + let mut by_idx: HashMap = HashMap::new(); + let mut min_mib = 0i64; + for line in trim(out).split('\n') { + let f: Vec<&str> = line.split(',').collect(); + if f.len() < 2 { + continue; + } + match (atoi(trim(f[0])), atoi(trim(f[1]))) { + (Some(i), Some(m)) if m > 0 => { + by_idx.insert(i, m); + if min_mib == 0 || m < min_mib { + min_mib = m; + } + } + _ => return 0, + } + } + if by_idx.is_empty() { + return 0; + } + let cvd = trim(cvd); + if cvd.is_empty() { + return by_idx.values().sum(); + } + let (mut sum, mut n) = (0i64, 0i64); + for d in cvd.split(',') { + let d = trim(d); + if d.is_empty() { + continue; + } + let Some(i) = atoi(d) else { return 0 }; + let Some(m) = by_idx.get(&i) else { return 0 }; + sum += m; + n += 1; + } + if !pci_order { + return min_mib * n; + } + sum +} + +/// `kvTypeBytes` : octets par élément d'un type de cache. +pub fn kv_type_bytes(t: &str) -> f64 { + match trim(t).to_lowercase().as_str() { + "f32" => 4.0, + "q8_0" => 34.0 / 32.0, + "q5_1" => 24.0 / 32.0, + "q5_0" => 22.0 / 32.0, + "q4_1" => 20.0 / 32.0, + "q4_0" | "iq4_nl" => 18.0 / 32.0, + _ => 2.0, + } +} + +/// `ggufStateBytes` : (octets de cache KV par jeton, octets d'état +/// récurrent). +pub fn gguf_state_bytes(g: &GgufMeta, kt: &str, vt: &str) -> (f64, i64) { + let (mut kl, mut vl) = (g.key_len, g.val_len); + if g.embedding_length > 0 && g.head_count > 0 { + if kl == 0 { + kl = g.embedding_length / g.head_count; + } + if vl == 0 { + vl = g.embedding_length / g.head_count; + } + } + if kl == 0 || vl == 0 || g.block_count == 0 { + return (0.0, 0); + } + let (mut heads, mut rec_layers) = (0i64, 0i64); + match g.head_count_kv_layers.as_deref() { + Some(layers) if !layers.is_empty() => { + for &h in layers { + heads += h; + if h == 0 && g.hybrid { + rec_layers += 1; + } + } + } + _ if g.hybrid && g.full_attn_interval > 0 => { + let attn = g.block_count / g.full_attn_interval; + heads = attn * g.head_count_kv; + rec_layers = g.block_count - attn; + } + _ => { + heads = g.block_count * g.head_count_kv; + if heads == 0 && g.hybrid { + rec_layers = g.block_count; + } + } + } + let per_token = heads as f64 * (kl as f64 * kv_type_bytes(kt) + vl as f64 * kv_type_bytes(vt)); + let mut recurrent = 0i64; + if rec_layers > 0 { + let conv = if g.ssm_conv > 1 { + (g.ssm_conv - 1) * (g.ssm_inner + 2 * g.ssm_groups * g.ssm_state) + } else { + 0 + }; + recurrent = rec_layers * (conv + g.ssm_state * g.ssm_inner) * 4; + } + (per_token, recurrent) +} + +/// `serveCtx` : contexte que le moteur ouvrira (0 = celui d'entraînement). +pub fn serve_ctx(cfg: &Cfg, extra: &[String], g: Option<&GgufMeta>) -> i64 { + let mut sv = flag_value(extra, &["-c", "--ctx-size"]); + if sv.is_empty() { + sv = trim(get(cfg, "CTX")).to_string(); + } + if sv.is_empty() { + sv = "32768".into(); + } + let mut n = match atoi(&sv) { + Some(n) if n >= 0 => n, + _ => return 0, + }; + if n == 0 + && let Some(g) = g + { + n = g.context_length; + } + n +} + +/// `cpuWeights` : pourquoi des poids (ou le cache KV) vivent en RAM hôte. +pub fn cpu_weights( + cfg: &Cfg, + extra: &[String], + arg_env: &HashMap, + blocks: i64, +) -> String { + let env = |k: &str| arg_env.get(k).map_or("", String::as_str); + let mut ot = flag_values(extra, &["-ot", "--override-tensor"]); + if !env("LLAMA_ARG_OVERRIDE_TENSOR").is_empty() { + ot.push(env("LLAMA_ARG_OVERRIDE_TENSOR").into()); + } + for v in &ot { + for el in v.split(',') { + if let Some((_, buft)) = el.split_once('=') + && trim(buft).to_uppercase().starts_with("CPU") + { + return "tenseurs sur CPU (-ot)".into(); + } + } + } + if has_any_flag(extra, &["--cpu-moe", "-cmoe"]) || env_truthy(env("LLAMA_ARG_CPU_MOE")) { + return "experts sur CPU".into(); + } + for (e, short, long) in [ + ("LLAMA_ARG_N_CPU_MOE", "-ncmoe", "--n-cpu-moe"), + ("LLAMA_ARG_N_CPU_FFN", "-ncffn", "--n-cpu-ffn"), + ] { + let mut vals = flag_values(extra, &[short, long]); + vals.push(env(e).to_string()); + for v in vals { + let v = trim(&v); + if !v.is_empty() && v != "0" { + return format!("couches {long} sur CPU"); + } + } + } + if !kv_offloaded(extra, arg_env) { + return "cache KV sur CPU".into(); + } + let mut ngl = flag_value(extra, &["-ngl", "--n-gpu-layers", "--gpu-layers"]); + if ngl.is_empty() { + ngl = trim(get(cfg, "NGL")).to_string(); + if ngl.eq_ignore_ascii_case("auto") { + ngl = env("LLAMA_ARG_N_GPU_LAYERS").into(); + } + } + if let Some(n) = atoi(trim(&ngl)) + && n != 999 + && (blocks == 0 || n < blocks + 1) + { + return format!("couches GPU limitées à {n}"); + } + String::new() +} + +/// `cacheRAMAuto` : taille du cache de prompts (0 = défaut du moteur). +pub fn cache_ram_auto(cfg: &Cfg, extra: &[String], si: &SysInfo) -> (i64, String) { + if si.vram_mib <= 0 { + return (0, "VRAM NVIDIA inconnue".into()); + } + let Some(g) = si.gguf.as_ref() else { + return (0, "métadonnées GGUF illisibles".into()); + }; + if si.ram_mib <= 0 || si.ram_avail_mib <= 0 { + return (0, "RAM inconnue".into()); + } + if si.model_bytes <= 0 { + return (0, "taille du modèle inconnue".into()); + } + let r = cpu_weights(cfg, extra, &si.arg_env, g.block_count); + if !r.is_empty() { + return (0, r); + } + let (kt, vt, _) = effective_kv_types(cfg, extra, &si.arg_env); + let (per_tok, rec) = gguf_state_bytes(g, &kt, &vt); + let ctx = serve_ctx(cfg, extra, Some(g)); + if per_tok <= 0.0 || ctx <= 0 { + return (0, "taille d'état inconnue".into()); + } + let mib = 1048576.0; + let kv_bytes = per_tok * ctx as f64; + if si.model_bytes as f64 + kv_bytes > 0.9 * si.vram_mib as f64 * mib { + return ( + 0, + "modèle trop gros pour la VRAM : des poids restent en RAM".into(), + ); + } + let mut state = kv_bytes; + if rec > 0 { + state += rec as f64 * ((32i64.min(ctx / 8192 + 1) + 1) as f64); + } + let need = (2.5 * state / mib).ceil() as i64; + let ceiling = (si.ram_mib * 30 / 100).min(si.ram_avail_mib / 2); + if ceiling <= ENGINE_CACHE_RAM_DEFAULT { + return (0, format!("RAM libre trop juste (plafond {ceiling} Mio)")); + } + if need <= ENGINE_CACHE_RAM_DEFAULT { + return (0, format!("le défaut suffit (besoin estimé {need} Mio)")); + } + ( + need.min(ceiling), + format!("besoin estimé {need} Mio, plafond {ceiling} Mio"), + ) +} + +/// `cacheRAMSetting` : (valeur manuelle, auto, lisible). +pub fn cache_ram_setting(cfg: &Cfg) -> (String, bool, bool) { + let v = trim(get(cfg, "CACHE_RAM")); + if v.is_empty() || v.eq_ignore_ascii_case("auto") { + return (String::new(), true, true); + } + match atoi(v) { + Some(n) if n >= -1 => (n.to_string(), false, true), + _ => (String::new(), false, false), + } +} + +/// `cacheRAMArgs`. +pub fn cache_ram_args(cfg: &Cfg, extra: &[String], si: &SysInfo) -> (Vec, Vec) { + let (manual, auto, ok) = cache_ram_setting(cfg); + if !ok { + return ( + Vec::new(), + vec![format!( + "CACHE_RAM={} illisible (un nombre de Mio, -1, 0 ou auto) : ignoré", + get(cfg, "CACHE_RAM") + )], + ); + } + let mut notes = Vec::new(); + if !si.help.contains("--cache-ram") { + if !auto { + notes.push("ce moteur ne connaît pas --cache-ram : CACHE_RAM ignoré".into()); + } + return (Vec::new(), notes); + } + if has_any_flag(extra, &["-cram", "--cache-ram"]) { + return (Vec::new(), Vec::new()); + } + if !si.env("LLAMA_ARG_CACHE_RAM").is_empty() { + if !auto { + notes.push("LLAMA_ARG_CACHE_RAM est posée : CACHE_RAM ignoré".into()); + } + return (Vec::new(), notes); + } + if !auto { + return (vec!["--cache-ram".into(), manual], Vec::new()); + } + let (n, why) = cache_ram_auto(cfg, extra, si); + if n <= 0 { + return (Vec::new(), Vec::new()); + } + ( + vec!["--cache-ram".into(), n.to_string()], + vec![format!( + "cache de prompts : --cache-ram {n} (RAM hôte, copie exacte de l'état, jamais la VRAM ; {why}). CACHE_RAM= pour fixer, 0 pour couper." + )], + ) +} + +/// `cacheRAMOff`. +pub fn cache_ram_off(cfg: &Cfg, extra: &[String], arg_env: &HashMap) -> bool { + let mut v = flag_value(extra, &["-cram", "--cache-ram"]); + if v.is_empty() { + v = arg_env + .get("LLAMA_ARG_CACHE_RAM") + .cloned() + .unwrap_or_default(); + } + if v.is_empty() { + v = cache_ram_setting(cfg).0; + } + trim(&v) == "0" +} + +/// `cacheIsolationOn`. +pub fn cache_isolation_on(cfg: &Cfg) -> bool { + !trim(get(cfg, "CACHE_ISOLATE")).eq_ignore_ascii_case("off") +} + +/// `loopbackHost`. +pub fn loopback_host(h: &str) -> bool { + matches!( + trim(h).trim_matches(['[', ']']), + "127.0.0.1" | "::1" | "localhost" + ) +} + +/// `slotSaveArgs`. +pub fn slot_save_args(cfg: &Cfg, extra: &[String], si: &SysInfo) -> Vec { + if si.slot_dir.is_empty() + || !si.help.contains("--slot-save-path") + || has_any_flag(extra, &["--slot-save-path"]) + { + return Vec::new(); + } + let (persist, _) = slot_persist_plan(cfg, extra, si); + if !persist && (!cache_isolation_on(cfg) || cache_ram_off(cfg, extra, &si.arg_env)) { + return Vec::new(); + } + if !super::slot_save_safe(cfg, extra, si) { + return Vec::new(); + } + vec!["--slot-save-path".into(), si.slot_dir.clone()] +} diff --git a/rust/loki-rs/src/serve/ckpt.rs b/rust/loki-rs/src/serve/ckpt.rs new file mode 100644 index 0000000..fff20d1 --- /dev/null +++ b/rust/loki-rs/src/serve/ckpt.rs @@ -0,0 +1,257 @@ +//! Points de reprise des modèles hybrides et conservation du raisonnement +//! (`backend_serve_ckpt.go`) : --ctx-checkpoints, --checkpoint-min-step, +//! --reasoning-preserve. Le build n'est cru que pour un moteur officiel. + +use super::{ + Cfg, SysInfo, atoi, flag_value, flag_values, get, gguf_state_bytes, has_any_flag, trim, +}; +use crate::gguf::GgufMeta; + +pub const ENGINE_MIN_RECOMMENDED: i64 = 10864; +pub const ENGINE_BUILD_FLOOR: i64 = 5000; +pub const ENGINE_CKPT_DEFAULT: i64 = 32; +pub const CKPT_AUTO_MIN_STEP: i64 = 2048; +pub const CKPT_FALLBACK_MIB: i64 = 200; + +/// `ckptArgEnv`. +pub const CKPT_ARG_ENV: &[&str] = &[ + "LLAMA_ARG_CTX_CHECKPOINTS", + "LLAMA_ARG_CHECKPOINT_MIN_SPACING_NT", + "LLAMA_ARG_REASONING_PRESERVE", + "LLAMA_ARG_CHAT_TEMPLATE_KWARGS", +]; + +/// `ggufHybrid` : couches récurrentes (métadonnées, ou architecture connue). +pub fn gguf_hybrid(g: Option<&GgufMeta>) -> bool { + g.is_some_and(|g| { + g.hybrid + || matches!( + g.arch.to_lowercase().as_str(), + "qwen35" | "qwen35moe" | "qwen3next" | "qwen4exp" + ) + }) +} + +/// `engineBuildTrust` : build cru seulement pour un moteur officiel. +pub fn engine_build_trust(source: &str, build: i64) -> i64 { + if source == "custom" || build < ENGINE_BUILD_FLOOR { + 0 + } else { + build + } +} + +/// `engineBuildNotice`. +pub fn engine_build_notice(build: i64) -> String { + if build <= 0 || build >= ENGINE_MIN_RECOMMENDED { + return String::new(); + } + format!( + "moteur b{build} : les points de reprise des modèles hybrides (Qwen3.5/3.6, Qwen3-Next…) y sont évincés trop tôt — jusqu'à ~8 k jetons recalculés à chaque reprise. Corrigé à partir de b{ENGINE_MIN_RECOMMENDED}." + ) +} + +/// `ckptCount` : points de reprise par slot (-1 = illisible). +pub fn ckpt_count(cfg: &Cfg, extra: &[String], si: &SysInfo) -> i64 { + let mut v = flag_value(extra, &["-ctxcp", "--ctx-checkpoints", "--swa-checkpoints"]); + if v.is_empty() { + v = si.env("LLAMA_ARG_CTX_CHECKPOINTS").into(); + } + if v.is_empty() { + return match atoi(trim(get(cfg, "CTX_CHECKPOINTS"))) { + Some(n) if n >= 0 => n, + _ => ENGINE_CKPT_DEFAULT, + }; + } + match atoi(trim(&v)) { + Some(n) if n >= 0 => n, + _ => -1, + } +} + +/// `ckptSlots`. +pub fn ckpt_slots(cfg: &Cfg, extra: &[String]) -> i64 { + let mut v = flag_value(extra, &["-np", "--parallel"]); + if v.is_empty() { + v = trim(get(cfg, "PARALLEL")).to_string(); + } + if v.is_empty() { + return 1; + } + match atoi(trim(&v)) { + Some(n) if n > 0 => n, + _ => 4, + } +} + +/// `ckptMiB`. +pub fn ckpt_mib(g: Option<&GgufMeta>) -> i64 { + if let Some(g) = g { + let (_, rec) = gguf_state_bytes(g, "", ""); + if rec > 0 { + return (rec + (1 << 20) - 1) >> 20; + } + } + CKPT_FALLBACK_MIB +} + +/// `ramTight` : le modèle occupe plus de 70 % de la RAM. +pub fn ram_tight(si: &SysInfo) -> bool { + si.ram_mib > 0 + && si.model_bytes > 0 + && si.model_bytes as f64 > 0.7 * si.ram_mib as f64 * 1048576.0 +} + +/// `ckptArgs`. +pub fn ckpt_args(cfg: &Cfg, extra: &[String], si: &SysInfo) -> (Vec, Vec) { + let mut args: Vec = Vec::new(); + let mut notes: Vec = Vec::new(); + let hybrid = gguf_hybrid(si.gguf.as_ref()); + let cp_flags = ["-ctxcp", "--ctx-checkpoints", "--swa-checkpoints"]; + + let v = trim(get(cfg, "CTX_CHECKPOINTS")); + if !v.is_empty() { + match atoi(v) { + Some(n) if n >= 0 => { + if !si.help.contains("--ctx-checkpoints") { + notes.push( + "ce moteur ne connaît pas --ctx-checkpoints : CTX_CHECKPOINTS ignoré" + .into(), + ); + } else if has_any_flag(extra, &cp_flags) { + } else if !si.env("LLAMA_ARG_CTX_CHECKPOINTS").is_empty() { + notes.push( + "LLAMA_ARG_CTX_CHECKPOINTS est posée : CTX_CHECKPOINTS ignoré".into(), + ); + } else { + args.push("--ctx-checkpoints".into()); + args.push(n.to_string()); + } + } + _ => notes.push(format!( + "CTX_CHECKPOINTS={v} illisible (un nombre de points de reprise par slot, ≥ 0) : ignoré" + )), + } + } else if hybrid + && ram_tight(si) + && si.help.contains("--ctx-checkpoints") + && !has_any_flag(extra, &cp_flags) + && si.env("LLAMA_ARG_CTX_CHECKPOINTS").is_empty() + { + notes.push(format!( + "modèle hybride de {} Gio pour {} Gio de RAM : jusqu'à {ENGINE_CKPT_DEFAULT} points de reprise (~{} Mio chacun) prennent sur le cache de pages des poids. CTX_CHECKPOINTS=8 à 16 si le débit baisse.", + si.model_bytes >> 30, + si.ram_mib >> 10, + ckpt_mib(si.gguf.as_ref()) + )); + } + + let step_user = has_any_flag(extra, &["-cms", "--checkpoint-min-step"]) + || !si.env("LLAMA_ARG_CHECKPOINT_MIN_SPACING_NT").is_empty(); + let v = trim(get(cfg, "CKPT_MIN_STEP")); + if !v.is_empty() { + match atoi(v) { + Some(n) if n > 0 => { + if !si.help.contains("--checkpoint-min-step") { + notes.push( + "ce moteur ne connaît pas --checkpoint-min-step : CKPT_MIN_STEP ignoré" + .into(), + ); + } else if has_any_flag(extra, &["-cms", "--checkpoint-min-step"]) { + } else if !si.env("LLAMA_ARG_CHECKPOINT_MIN_SPACING_NT").is_empty() { + notes.push( + "LLAMA_ARG_CHECKPOINT_MIN_SPACING_NT est posée : CKPT_MIN_STEP ignoré" + .into(), + ); + } else { + args.push("--checkpoint-min-step".into()); + args.push(n.to_string()); + } + } + _ => notes.push(format!( + "CKPT_MIN_STEP={v} refusé (un nombre de jetons > 0 ; 0 laisserait le moteur jeter le point de reprise du début de la conversation) : défaut du moteur" + )), + } + return (args, notes); + } + + let notice = engine_build_notice(si.engine_build); + if !hybrid || notice.is_empty() { + return (args, notes); + } + let n = ckpt_count(cfg, extra, si); + if step_user || !si.help.contains("--checkpoint-min-step") || n <= 0 { + notes.push(notice); + return (args, notes); + } + if si.ram_mib <= 0 || si.model_bytes <= 0 || ram_tight(si) { + notes.push(notice + " Pas d'espacement resserré d'office : RAM inconnue ou déjà prise par le modèle (CKPT_MIN_STEP=2048 pour l'imposer)."); + return (args, notes); + } + let total = n * ckpt_slots(cfg, extra); + let worst = total * ckpt_mib(si.gguf.as_ref()); + if si.ram_avail_mib > 0 && worst > si.ram_avail_mib / 4 { + notes.push(format!( + "{notice} Pas d'espacement resserré d'office : {total} points de reprise (~{worst} Mio) dépasseraient le quart de la RAM libre (CKPT_MIN_STEP=2048 pour l'imposer)." + )); + return (args, notes); + } + args.push("--checkpoint-min-step".into()); + args.push(CKPT_AUTO_MIN_STEP.to_string()); + notes.push(format!( + "{notice} → --checkpoint-min-step {CKPT_AUTO_MIN_STEP} : points de reprise exacts plus rapprochés, ~{worst} Mio de RAM hôte au plus. CKPT_MIN_STEP= pour fixer." + )); + (args, notes) +} + +/// `reasoningPreserveArgs`. +pub fn reasoning_preserve_args( + cfg: &Cfg, + extra: &[String], + si: &SysInfo, +) -> (Vec, Vec) { + let v = trim(get(cfg, "REASONING_PRESERVE")).to_lowercase(); + if v.is_empty() { + return (Vec::new(), Vec::new()); + } + let flag = match v.as_str() { + "on" => "--reasoning-preserve", + "off" => "--no-reasoning-preserve", + _ => { + return ( + Vec::new(), + vec![format!( + "REASONING_PRESERVE={} illisible (on ou off) : ignoré", + get(cfg, "REASONING_PRESERVE") + )], + ); + } + }; + let one = |n: &str| (Vec::new(), vec![n.to_string()]); + if !si.help.contains("--no-reasoning-preserve") { + return one("ce moteur ne connaît pas --reasoning-preserve : REASONING_PRESERVE ignoré"); + } + if has_any_flag(extra, &["--reasoning-preserve", "--no-reasoning-preserve"]) { + return (Vec::new(), Vec::new()); + } + if flag_values(extra, &["--chat-template-kwargs"]) + .join(" ") + .contains("preserve_reasoning") + { + return one( + "--chat-template-kwargs fixe déjà preserve_reasoning : REASONING_PRESERVE ignoré", + ); + } + if !si.env("LLAMA_ARG_REASONING_PRESERVE").is_empty() { + return one("LLAMA_ARG_REASONING_PRESERVE est posée : REASONING_PRESERVE ignoré"); + } + if si + .env("LLAMA_ARG_CHAT_TEMPLATE_KWARGS") + .contains("preserve_reasoning") + { + return one( + "LLAMA_ARG_CHAT_TEMPLATE_KWARGS fixe déjà preserve_reasoning : REASONING_PRESERVE ignoré", + ); + } + (vec![flag.to_string()], Vec::new()) +} diff --git a/rust/loki-rs/src/serve/expert.rs b/rust/loki-rs/src/serve/expert.rs new file mode 100644 index 0000000..0a1443a --- /dev/null +++ b/rust/loki-rs/src/serve/expert.rs @@ -0,0 +1,209 @@ +//! Réglages d'expert du moteur local (`backend_serve_expert.go`) : marge de +//! --fit par carte, seuil d'envoi au GPU des poids restés en RAM, branches +//! Q/K/V en parallèle. Tous éteints tant que le preset ne les demande pas. + +use super::{ + Cfg, SysInfo, ctx_fixed, flag_value, flag_values, get, has_any_flag, help_fits_layers_itself, + ngl_args, tensor_override, trim, +}; +use std::collections::HashMap; + +/// `expertEnvKeys`. +pub const EXPERT_ENV_KEYS: &[&str] = &["GGML_CUDA_GRAPH_OPT", "GGML_OP_OFFLOAD_MIN_BATCH"]; +/// `fitArgEnv`. +pub const FIT_ARG_ENV: &[&str] = &[ + "LLAMA_ARG_FIT", + "LLAMA_ARG_FIT_TARGET", + "LLAMA_ARG_TENSOR_SPLIT", +]; + +pub const FIT_TARGET_MIN: i64 = 1024; +const FIT_TARGET_MAX_VALUES: usize = 8; + +/// `parseFitTarget` : (valeurs, remontées sous 1024, valide). +pub fn parse_fit_target(v: &str) -> (Vec, bool, bool) { + let parts: Vec<&str> = v.split([',', '/']).filter(|p| !p.is_empty()).collect(); + let seps = v.matches(',').count() + v.matches('/').count(); + if parts.is_empty() || parts.len() > FIT_TARGET_MAX_VALUES || seps != parts.len() - 1 { + return (Vec::new(), false, false); + } + let mut vals = Vec::new(); + let mut raised = false; + for p in parts { + let p = trim(p); + if p.is_empty() || !p.bytes().all(|b| b.is_ascii_digit()) || p.len() > 7 { + return (Vec::new(), false, false); + } + let mut n: i64 = p.parse().unwrap_or(0); + if n < FIT_TARGET_MIN { + n = FIT_TARGET_MIN; + raised = true; + } + vals.push(n); + } + (vals, raised, true) +} + +/// `fitTargetArgs`. +pub fn fit_target_args(cfg: &Cfg, extra: &[String], si: &SysInfo) -> (Vec, Vec) { + let raw = trim(get(cfg, "FIT_TARGET")); + if raw.is_empty() { + return (Vec::new(), Vec::new()); + } + let ignore = |why: &str| (Vec::new(), vec![format!("FIT_TARGET={raw} ignoré : {why}")]); + let (vals, raised, ok) = parse_fit_target(raw); + if !ok { + return ignore("des Mio par carte, ex. 1024,3072 (8 valeurs au plus)"); + } + if !si.help.contains("--fit-target") { + return ignore("ce moteur ne connaît pas --fit-target"); + } + if has_any_flag(extra, &["-fitt", "--fit-target"]) { + return ignore("--fit-target déjà dans EXTRA_ARGS"); + } + if !si.env("LLAMA_ARG_FIT_TARGET").is_empty() { + return ignore("LLAMA_ARG_FIT_TARGET déjà posé dans l'environnement"); + } + let (ctx, ok) = ctx_fixed(cfg, extra); + if !ok { + return ignore(&format!( + "contexte « {ctx} » non fixé — --fit pourrait le réduire pour tenir ; donne un CTX chiffré" + )); + } + let why = fit_blocker(cfg, extra, si); + if !why.is_empty() { + return ignore(&format!( + "--fit inactif ({why}), la marge ne servirait à rien" + )); + } + let v = vals + .iter() + .map(i64::to_string) + .collect::>() + .join(","); + let mut note = format!( + "--fit-target {v} (FIT_TARGET, Mio libres par carte, dans l'ordre vu par le moteur) — vérifie « offloaded N/N layers » dans le journal" + ); + if raised { + note.push_str(" ; marges sous 1024 Mio remontées à 1024"); + } + (vec!["--fit-target".into(), v], vec![note]) +} + +/// `fitBlocker` : pourquoi llama.cpp ne lancera PAS --fit (vide = il le +/// lancera). +pub fn fit_blocker(cfg: &Cfg, extra: &[String], si: &SysInfo) -> String { + let mut fit = flag_value(extra, &["-fit", "--fit"]); + if fit.is_empty() { + fit = si.env("LLAMA_ARG_FIT").into(); + } + if matches!( + trim(&fit).to_lowercase().as_str(), + "off" | "disabled" | "false" | "0" + ) { + return "--fit off".into(); + } + let mut ngl = flag_value(extra, &["-ngl", "--n-gpu-layers", "--gpu-layers"]); + if ngl.is_empty() { + let (a, _) = ngl_args(get(cfg, "NGL"), help_fits_layers_itself(&si.help)); + if a.len() == 2 { + ngl = a[1].clone(); + } else { + ngl = si.env("LLAMA_ARG_N_GPU_LAYERS").into(); + } + } + let ngl = trim(&ngl); + if !ngl.is_empty() && ngl != "-1" && !ngl.eq_ignore_ascii_case("auto") { + return format!("couches GPU fixées à {ngl}"); + } + if has_any_flag(extra, &["-ts", "--tensor-split"]) + || !si.env("LLAMA_ARG_TENSOR_SPLIT").is_empty() + { + return "--tensor-split".into(); + } + let r = tensor_override(extra, &si.arg_env); + if !r.is_empty() { + return r; + } + let mut sm = flag_value(extra, &["-sm", "--split-mode"]); + if sm.is_empty() { + sm = si.env("LLAMA_ARG_SPLIT_MODE").into(); + } + let sm = trim(&sm).to_lowercase(); + if sm == "row" || sm == "tensor" { + return format!("-sm {sm}"); + } + String::new() +} + +/// `opOffloadMinBatchEnv`. +pub fn op_offload_min_batch_env(cfg: &Cfg, si: &SysInfo) -> (String, Vec) { + let raw = trim(get(cfg, "OP_OFFLOAD_MIN_BATCH")); + if raw.is_empty() || !si.uenv("GGML_OP_OFFLOAD_MIN_BATCH").is_empty() { + return (String::new(), Vec::new()); + } + match super::atoi(raw) { + Some(n) if (1..=1 << 20).contains(&n) => { + let v = n.to_string(); + ( + v.clone(), + vec![format!( + "GGML_OP_OFFLOAD_MIN_BATCH={v} (OP_OFFLOAD_MIN_BATCH) : lots de moins de {v} jetons calculés sur CPU pour les poids restés en RAM" + )], + ) + } + _ => ( + String::new(), + vec![format!( + "OP_OFFLOAD_MIN_BATCH={raw} ignoré : un nombre de jetons, ex. 128" + )], + ), + } +} + +/// `graphOptEnv`. +pub fn graph_opt_env(cfg: &Cfg, extra: &[String], si: &SysInfo) -> (String, Vec) { + let raw = trim(get(cfg, "CUDA_GRAPH_OPT")); + match raw.to_lowercase().as_str() { + "" | "off" => return (String::new(), Vec::new()), + "on" => {} + _ => { + return ( + String::new(), + vec![format!("CUDA_GRAPH_OPT={raw} ignoré (on ou off)")], + ); + } + } + if !si.uenv("GGML_CUDA_GRAPH_OPT").is_empty() { + return (String::new(), Vec::new()); + } + let why = qkv_split_risk(extra, &si.arg_env); + if !why.is_empty() { + return ( + String::new(), + vec![format!( + "CUDA_GRAPH_OPT ignoré : {why} peut séparer Q, K et V d'une couche entre cartes" + )], + ); + } + ("1".into(), vec!["GGML_CUDA_GRAPH_OPT=1 (CUDA_GRAPH_OPT, expérimental) — si le moteur s'arrête sur un GGML_ASSERT, CUDA_GRAPH_OPT=off puis redémarre".into()]) +} + +/// `qkvSplitRisk`. +pub fn qkv_split_risk(extra: &[String], arg_env: &HashMap) -> String { + let env = |k: &str| arg_env.get(k).map_or("", String::as_str); + let mut sm = flag_value(extra, &["-sm", "--split-mode"]); + if sm.is_empty() { + sm = env("LLAMA_ARG_SPLIT_MODE").into(); + } + let sm = trim(&sm).to_lowercase(); + if sm == "row" || sm == "tensor" { + return format!("-sm {sm}"); + } + let mut vals = flag_values(extra, &["-ot", "--override-tensor"]); + vals.push(env("LLAMA_ARG_OVERRIDE_TENSOR").to_string()); + if vals.iter().any(|v| v.to_lowercase().contains("attn")) { + return "-ot sur l'attention".into(); + } + String::new() +} diff --git a/rust/loki-rs/src/serve/fidelity.rs b/rust/loki-rs/src/serve/fidelity.rs new file mode 100644 index 0000000..74049a4 --- /dev/null +++ b/rust/loki-rs/src/serve/fidelity.rs @@ -0,0 +1,172 @@ +//! Garde-fous de fidélité (`backend_serve_fidelity.go`) : Loki ne choisit +//! jamais un réglage qui change ce que le modèle calcule, mais il DIT ceux +//! que la ligne finale porte (cache KV quantifié, glissement de contexte, +//! réutilisation approchée du cache, acceptation synthétique). + +use super::{ + Cfg, SysInfo, env_truthy, flag_value, has_any_flag, has_flag_prefix, serve_kv_types, + tensor_override, trim, +}; +use std::collections::HashMap; + +/// `fidelityArgEnv`. +pub const FIDELITY_ARG_ENV: &[&str] = &[ + "LLAMA_ARG_CACHE_TYPE_K", + "LLAMA_ARG_CACHE_TYPE_V", + "LLAMA_ARG_CONTEXT_SHIFT", + "LLAMA_ARG_NO_CONTEXT_SHIFT", + "LLAMA_ARG_CACHE_REUSE", + "LLAMA_ARG_MMPROJ", + "LLAMA_ARG_MMPROJ_URL", + "LLAMA_ARG_SPEC_SYNTH_LEN", +]; + +/// `effectiveKVTypes` : (K, V, source la plus forte). +pub fn effective_kv_types( + cfg: &Cfg, + extra: &[String], + arg_env: &HashMap, +) -> (String, String, String) { + let env = |k: &str| arg_env.get(k).cloned().unwrap_or_default(); + let (mut k, mut v) = (env("LLAMA_ARG_CACHE_TYPE_K"), env("LLAMA_ARG_CACHE_TYPE_V")); + let mut src = String::new(); + if !k.is_empty() || !v.is_empty() { + src = "défini par LLAMA_ARG_CACHE_TYPE_K/V".into(); + } + let (ck, cv) = serve_kv_types(cfg); + if !ck.is_empty() || !cv.is_empty() { + src = "KV_TYPE".into(); + if !ck.is_empty() { + k = ck; + } + if !cv.is_empty() { + v = cv; + } + } + let sk = flag_value(extra, &["-ctk", "--cache-type-k"]); + if !sk.is_empty() { + k = sk; + src = "défini par EXTRA_ARGS".into(); + } + let sv = flag_value(extra, &["-ctv", "--cache-type-v"]); + if !sv.is_empty() { + v = sv; + src = "défini par EXTRA_ARGS".into(); + } + (k, v, src) +} + +/// `kvFidelity` : rang (0 = mêmes calculs que f16) et libellé. +pub fn kv_fidelity(t: &str) -> (i64, &'static str) { + match trim(t).to_lowercase().as_str() { + "" | "f16" | "f32" => (0, ""), + "bf16" => (1, "numérique différente de f16 (mantisse plus courte)"), + "q8_0" | "q5_0" | "q5_1" => (2, "modifie légèrement les sorties par rapport à f16"), + "q4_0" | "q4_1" | "iq4_nl" => (3, "perte mesurable par rapport à f16"), + _ => (2, "modifie les sorties par rapport à f16"), + } +} + +fn or_f16(t: &str) -> &str { + if t.is_empty() { "f16" } else { t } +} + +/// `kvFidelityNote`. +pub fn kv_fidelity_note( + k: &str, + v: &str, + src: &str, + extra: &[String], + arg_env: &HashMap, +) -> String { + let (rk, lk) = kv_fidelity(k); + let (rv, lv) = kv_fidelity(v); + if rk == 0 && rv == 0 { + return String::new(); + } + let label = if rv > rk { lv } else { lk }; + let mut note = format!( + "cache KV {}/{} ({src}) : {label} — choix de l'utilisateur, laissé tel quel.", + or_f16(k), + or_f16(v) + ); + if !tensor_override(extra, arg_env).is_empty() { + note.push_str(" Repasser en f16 demande plus de VRAM, et le placement fixé par -ot/--n-cpu-moe empêche --fit de compenser : il faudrait sans doute relever --n-cpu-moe."); + } + note +} + +/// `lossyCacheNotes`. +pub fn lossy_cache_notes(extra: &[String], si: &SysInfo) -> Vec { + let mut notes = Vec::new(); + let ignored = if !si.mmproj.is_empty() + || has_any_flag(extra, &["-mm", "--mmproj", "-mmu", "--mmproj-url"]) + || !si.env("LLAMA_ARG_MMPROJ").is_empty() + || !si.env("LLAMA_ARG_MMPROJ_URL").is_empty() + { + " (vision chargée : llama.cpp l'ignore de toute façon)" + } else { + "" + }; + let (on, src) = context_shift(extra, &si.arg_env, &si.help); + if on && src.is_empty() { + notes.push(format!("avertissement : ce llama-server ancien glisse le contexte par défaut — contexte plein, il jette des jetons anciens et le modèle perd une partie de la conversation sans le savoir{ignored}. --no-context-shift dans EXTRA_ARGS l'en empêche.")); + } else if on { + notes.push(format!("avertissement : --context-shift ({src}) — contexte plein, le moteur jette des jetons anciens et le modèle perd une partie de la conversation sans le savoir{ignored}.")); + } + let (mut sv, mut src) = (flag_value(extra, &["--cache-reuse"]), "EXTRA_ARGS"); + if sv.is_empty() { + sv = si.env("LLAMA_ARG_CACHE_REUSE").into(); + src = "LLAMA_ARG_CACHE_REUSE"; + } + if let Some(n) = super::atoi(trim(&sv)) + && n > 0 + { + notes.push(format!("avertissement : --cache-reuse {n} ({src}) — réutilise des morceaux de cache calculés sous un autre préfixe : les sorties ne sont plus exactement celles d'un calcul complet{ignored}.")); + } + let mut f = has_flag_prefix(extra, &["--spec-synth-"]); + if !f.is_empty() || !si.env("LLAMA_ARG_SPEC_SYNTH_LEN").is_empty() { + if f.is_empty() { + f = "LLAMA_ARG_SPEC_SYNTH_LEN".into(); + } + notes.push(format!("avertissement : {f} — le moteur accepte des jetons du brouillon AU HASARD, sans vérification : les réponses ne sont plus celles du modèle. Réservé aux mesures, retire-le.")); + } + notes +} + +/// `contextShift` : (glisse ?, qui l'a décidé — vide : son défaut). +pub fn context_shift( + extra: &[String], + arg_env: &HashMap, + help: &str, +) -> (bool, String) { + let env = |k: &str| arg_env.get(k).map_or("", String::as_str); + let (mut on, mut set) = (false, false); + for a in extra { + let name = a.split_once('=').map_or(a.as_str(), |(n, _)| n); + match name { + "--context-shift" => { + on = true; + set = true; + } + "--no-context-shift" => { + on = false; + set = true; + } + _ => {} + } + } + if set { + return (on, "EXTRA_ARGS".into()); + } + if help.contains("--no-context-shift") && !help.contains("--context-shift") { + if env_truthy(env("LLAMA_ARG_NO_CONTEXT_SHIFT")) { + return (false, "LLAMA_ARG_NO_CONTEXT_SHIFT".into()); + } + return (true, String::new()); + } + if env_truthy(env("LLAMA_ARG_CONTEXT_SHIFT")) { + return (true, "LLAMA_ARG_CONTEXT_SHIFT".into()); + } + (false, String::new()) +} diff --git a/rust/loki-rs/src/serve/mod.rs b/rust/loki-rs/src/serve/mod.rs new file mode 100644 index 0000000..1e5df4f --- /dev/null +++ b/rust/loki-rs/src/serve/mod.rs @@ -0,0 +1,600 @@ +//! `loki serve` — port de `backend_serve*.go` (lot 14 de la migration) : +//! composer la ligne de commande de llama-server à partir du preset, puis +//! céder la place au moteur (`exec`). +//! +//! Tout ce qui décide est PUR et vit ici ([`build_serve_args`] et ses +//! modules) : une table de lancements tirée du Go +//! (`internal/loki/parity_serve_test.go`) le vérifie argument par argument. +//! [`run`] fait les lectures du monde (aide du moteur, GGUF, RAM, VRAM, +//! cgroup, variables) avant d'appeler la partie pure, comme `cmdServe`. +//! +//! L'ordre des arguments fait partie du comportement : llama-server retient +//! la DERNIÈRE occurrence d'un drapeau. + +use std::collections::{BTreeMap, HashMap}; + +use crate::gguf::GgufMeta; + +pub mod cache; +pub mod ckpt; +pub mod expert; +pub mod fidelity; +pub mod moe; +pub mod persist; +pub mod queues; +pub mod run; +pub mod side; +pub mod spec; +pub mod split; +pub mod threads; + +pub use cache::*; +pub use ckpt::*; +pub use expert::*; +pub use fidelity::*; +pub use moe::*; +pub use persist::*; +pub use queues::*; +pub use side::*; +pub use spec::*; +pub use split::*; +pub use threads::*; + +/// Configuration d'un preset (`ReadConfig()`). +pub type Cfg = HashMap; + +/// Ce que `cmdServe` a dû demander au monde avant de composer la ligne de +/// commande (`serveSysInfo`). Zéro / vide = inconnu. +#[derive(Clone, Debug, Default)] +pub struct SysInfo { + /// Sortie de ` --help` ; vide = moteur inconnu, aucun risque pris. + pub help: String, + /// Chemin du .gguf principal, résolu et vérifié. + pub model: String, + /// Projecteur vision résolu ; vide = pas de vision. + pub mmproj: String, + /// Clé d'API effective ; vide = serveur ouvert. + pub api_key: String, + /// Sonde de conteneur (Linux). + pub cpu: CpuBudget, + /// `CUDA_SCALE_LAUNCH_QUEUES` déjà posée : intouchée. + pub launch_queues: String, + /// GPU visibles ; 0 = inconnu. + pub gpus: i64, + /// `LLAMA_ARG_*` de l'environnement qui décident de quelque chose. + pub arg_env: HashMap, + /// `GGML_*` déjà posées par l'utilisateur. + pub user_env: HashMap, + pub gguf: Option, + pub model_bytes: i64, + pub ram_mib: i64, + pub ram_avail_mib: i64, + pub vram_mib: i64, + pub unified_mem: bool, + pub engine_vram_mib: i64, + pub slot_dir: String, + pub engine_build: i64, + pub draft: String, + pub draft_err: String, + pub draft_gguf: Option, + pub draft_is_model: bool, + pub spec_auto_blocked: String, + pub split_devs: Vec, +} + +impl SysInfo { + pub(crate) fn env(&self, k: &str) -> &str { + self.arg_env.get(k).map_or("", String::as_str) + } + pub(crate) fn uenv(&self, k: &str) -> &str { + self.user_env.get(k).map_or("", String::as_str) + } + pub(crate) fn blocks(&self) -> i64 { + self.gguf.as_ref().map_or(0, |g| g.block_count) + } +} + +/// Une carte telle que la voit le moteur (`--list-devices`), dans son ordre. +#[derive(Clone, Debug, Default, PartialEq, Eq)] +pub struct SplitDev { + pub id: String, + pub total_mib: i64, +} + +// --------------------------------------------------------------------------- +// Petits outils au comportement du Go +// --------------------------------------------------------------------------- + +/// `strings.TrimSpace`. +pub(crate) fn trim(s: &str) -> &str { + s.trim_matches(char::is_whitespace) +} + +/// `strconv.Atoi`. +pub(crate) fn atoi(s: &str) -> Option { + s.parse::().ok() +} + +/// `cfg[k]` (vide si absent). +pub(crate) fn get<'a>(cfg: &'a Cfg, k: &str) -> &'a str { + cfg.get(k).map_or("", String::as_str) +} + +/// `hasAnyFlag` : l'un de ces drapeaux est-il écrit (forme `--x=v` comprise) ? +pub fn has_any_flag(args: &[String], flags: &[&str]) -> bool { + args.iter().any(|a| { + let name = match a.find('=') { + Some(i) if i > 0 => &a[..i], + _ => a.as_str(), + }; + flags.contains(&name) + }) +} + +/// `flagValue` : valeur de la DERNIÈRE occurrence (`-t 6` ou `-t=6`). +pub fn flag_value(args: &[String], flags: &[&str]) -> String { + let mut v = String::new(); + for (i, a) in args.iter().enumerate() { + let (name, val) = match a.split_once('=') { + Some((n, v)) => (n, Some(v)), + None => (a.as_str(), None), + }; + if !flags.contains(&name) { + continue; + } + match val { + Some(x) => v = x.to_string(), + None => { + if let Some(n) = args.get(i + 1) { + v = n.clone(); + } + } + } + } + v +} + +/// `flagValues` : les valeurs de TOUTES les occurrences. +pub fn flag_values(args: &[String], flags: &[&str]) -> Vec { + let mut vals = Vec::new(); + for (i, a) in args.iter().enumerate() { + let (name, val) = match a.split_once('=') { + Some((n, v)) => (n, Some(v)), + None => (a.as_str(), None), + }; + if !flags.contains(&name) { + continue; + } + match val { + Some(x) => vals.push(x.to_string()), + None => { + if let Some(n) = args.get(i + 1) { + vals.push(n.clone()); + } + } + } + } + vals +} + +/// `hasFlagPrefix` : le nom du premier drapeau qui commence par l'un des +/// préfixes, ou vide. +pub fn has_flag_prefix(args: &[String], prefixes: &[&str]) -> String { + for a in args { + for p in prefixes { + if a.starts_with(p) { + return a.split_once('=').map_or(a.as_str(), |(n, _)| n).to_string(); + } + } + } + String::new() +} + +/// `envTruthy` : on, enabled, true, 1. +pub fn env_truthy(v: &str) -> bool { + matches!(trim(v), "on" | "enabled" | "true" | "1") +} + +/// `argValue` : valeur de la dernière occurrence de `flag` (forme séparée). +pub fn arg_value(args: &[String], flag: &str) -> String { + let mut v = String::new(); + for i in 0..args.len().saturating_sub(1) { + if args[i] == flag { + v = args[i + 1].clone(); + } + } + v +} + +/// `firstNonEmpty`. +pub(crate) fn first_non_empty<'a>(vals: &[&'a str]) -> &'a str { + vals.iter() + .copied() + .find(|v| !trim(v).is_empty()) + .unwrap_or("") +} + +/// `isExternalConfig`. +pub fn is_external(cfg: &Cfg) -> bool { + trim(get(cfg, "EXTERNAL")) == "1" +} + +/// `isStrataConfig` (`engineOf`). +pub fn is_strata(cfg: &Cfg) -> bool { + matches!( + trim(get(cfg, "ENGINE")).to_lowercase().as_str(), + "strata" | "moe" + ) +} + +pub(crate) fn s(v: &[&str]) -> Vec { + v.iter().map(|x| x.to_string()).collect() +} + +// --------------------------------------------------------------------------- +// Aide du moteur +// --------------------------------------------------------------------------- + +/// `helpSupportsReasoningFlag`. +pub fn help_supports_reasoning_flag(help: &str) -> bool { + help.contains("--reasoning ") +} + +/// `helpSupportsLoadMode`. +pub fn help_supports_load_mode(help: &str) -> bool { + help.contains("--load-mode") +} + +/// `helpSupportsNGLAuto` : « 'auto' » dans les 400 octets qui suivent le +/// drapeau des couches GPU. +pub fn help_supports_ngl_auto(h: &str) -> bool { + let i = match h.find("--n-gpu-layers").or_else(|| h.find("-ngl")) { + Some(i) => i, + None => return false, + }; + let end = (i + 400).min(h.len()); + // Tranche d'octets (le Go découpe sans égard aux runes). + String::from_utf8_lossy(&h.as_bytes()[i..end]).contains("'auto'") +} + +/// `helpFitsLayersItself`. +pub fn help_fits_layers_itself(help: &str) -> bool { + help_supports_ngl_auto(help) || help_supports_load_mode(help) +} + +const NGL_999_NOTE: &str = "NGL=999 (sentinelle « toutes les couches ») → -ngl auto : ce moteur mesure lui-même la VRAM libre. NGL=all pour forcer l'ancien comportement."; + +/// `nglArgs`. +pub fn ngl_args(ngl: &str, fits_itself: bool) -> (Vec, String) { + let ngl = trim(ngl); + if ngl.eq_ignore_ascii_case("auto") { + (Vec::new(), String::new()) + } else if ngl == "999" && fits_itself { + (s(&["-ngl", "auto"]), NGL_999_NOTE.into()) + } else if !ngl.is_empty() { + (s(&["-ngl", ngl]), String::new()) + } else if fits_itself { + (s(&["-ngl", "auto"]), String::new()) + } else { + (s(&["-ngl", "999"]), String::new()) + } +} + +/// `normalizeLoadFlags`. +pub fn normalize_load_flags(args: &[String], supports_load_mode: bool) -> (Vec, String) { + if !supports_load_mode { + return downgrade_load_mode(args); + } + let explicit = has_any_flag(args, &["--load-mode", "-lm"]); + let (mut mlock, mut nommap, mut mmap) = (false, false, false); + let mut out = Vec::with_capacity(args.len() + 2); + for a in args { + match a.as_str() { + "--mlock" => mlock = true, + "--no-mmap" => nommap = true, + "--mmap" => mmap = true, + _ => out.push(a.clone()), + } + } + if explicit || !(mlock || nommap || mmap) { + return (out, String::new()); + } + let mode = if mlock && nommap { + "mlock" + } else if mlock { + "mmap+mlock" + } else if nommap { + "none" + } else { + "mmap" + }; + out.push("--load-mode".into()); + out.push(mode.into()); + (out, String::new()) +} + +/// `downgradeLoadMode`. +pub fn downgrade_load_mode(args: &[String]) -> (Vec, String) { + let (mut mode, mut found) = (String::new(), false); + let mut kept: Vec = Vec::with_capacity(args.len()); + let mut i = 0; + while i < args.len() { + let a = &args[i]; + if a == "--load-mode" || a == "-lm" { + found = true; + if i + 1 < args.len() { + mode = args[i + 1].to_lowercase(); + i += 1; + } + i += 1; + continue; + } + if let Some(v) = a.strip_prefix("--load-mode=") { + found = true; + mode = v.to_lowercase(); + i += 1; + continue; + } + kept.push(a.clone()); + i += 1; + } + if !found { + return (args.to_vec(), String::new()); + } + let add = |kept: &mut Vec, flag: &str| { + if !kept.iter().any(|a| a == flag) { + kept.push(flag.into()); + } + }; + match mode.as_str() { + "none" => add(&mut kept, "--no-mmap"), + "mlock" => { + add(&mut kept, "--mlock"); + add(&mut kept, "--no-mmap"); + } + "mmap+mlock" => add(&mut kept, "--mlock"), + "dio" => { + return ( + kept, + "ce moteur ne connaît pas --load-mode dio (DirectIO) : chargement par défaut" + .into(), + ); + } + _ => {} + } + (kept, String::new()) +} + +/// `cudaDeviceEnv`. +pub fn cuda_device_env(cfg: &Cfg) -> BTreeMap { + let mut env = BTreeMap::new(); + let v = get(cfg, "CUDA_VISIBLE_DEVICES"); + if !v.is_empty() { + env.insert("CUDA_VISIBLE_DEVICES".into(), v.into()); + env.insert("CUDA_DEVICE_ORDER".into(), "PCI_BUS_ID".into()); + } + env +} + +/// `serveKVTypes` : KV_TYPE pour les deux, KV_TYPE_K / KV_TYPE_V à part. +pub fn serve_kv_types(cfg: &Cfg) -> (String, String) { + let kv = get(cfg, "KV_TYPE"); + let (mut k, mut v) = (kv.to_string(), kv.to_string()); + let sk = get(cfg, "KV_TYPE_K"); + if !sk.is_empty() { + k = sk.into(); + } + let sv = get(cfg, "KV_TYPE_V"); + if !sv.is_empty() { + v = sv.into(); + } + (k, v) +} + +/// Ligne de commande, environnement et notes d'un lancement. +#[derive(Clone, Debug, Default, PartialEq, Eq)] +pub struct Launch { + pub args: Vec, + pub env: BTreeMap, + pub notes: Vec, +} + +/// `buildServeArgs` : la ligne de commande de llama-server (`bin` en tête), +/// l'environnement à poser et les notes. Fonction pure. +pub fn build_serve_args(cfg: &Cfg, extra: &[String], bin: &str, si: &SysInfo) -> Launch { + let g = |key: &str, fallback: &str| -> String { + match cfg.get(key) { + Some(v) if !v.is_empty() => v.clone(), + _ => fallback.to_string(), + } + }; + let (ktv, vtv) = serve_kv_types(cfg); + let mut env = cuda_device_env(cfg); + let mut notes: Vec = Vec::new(); + + let (extra, load_note) = normalize_load_flags(extra, help_supports_load_mode(&si.help)); + let extra = &extra[..]; + if !load_note.is_empty() { + notes.push(load_note); + } + + let st = split_tensor_args(cfg, extra, si); + let mut place: Vec = extra.to_vec(); + if st.on { + place.push("-sm".into()); + place.push("tensor".into()); + } + for (k, v) in &st.env { + env.insert(k.clone(), v.clone()); + } + notes.extend(st.notes.iter().cloned()); + + let (q, q_notes) = launch_queues_env(cfg, &place, si); + if !q.is_empty() { + env.insert("CUDA_SCALE_LAUNCH_QUEUES".into(), q); + } + notes.extend(q_notes); + let (gv, g_notes) = graph_opt_env(cfg, &place, si); + if !gv.is_empty() { + env.insert("GGML_CUDA_GRAPH_OPT".into(), gv); + } + let (ov, o_notes) = op_offload_min_batch_env(cfg, si); + if !ov.is_empty() { + env.insert("GGML_OP_OFFLOAD_MIN_BATCH".into(), ov); + } + notes.extend(g_notes); + notes.extend(o_notes); + + let (threads, thread_notes) = thread_args( + get(cfg, "THREADS"), + get(cfg, "THREADS_BATCH"), + extra, + &si.cpu, + ); + notes.extend(thread_notes); + let (side_ctx, side_mib, side_why) = side_slot_plan(cfg, extra, si); + let mut ctx_arg = g("CTX", "32768"); + if side_ctx > 0 { + ctx_arg = (2 * side_ctx).to_string(); + } + let mut args: Vec = vec![ + bin.to_string(), + "-m".into(), + si.model.clone(), + "-c".into(), + ctx_arg, + ]; + args.extend(threads); + args.extend([ + "-b".into(), + g("BATCH", "2048"), + "-ub".into(), + g("UBATCH", "512"), + "--host".into(), + g("HOST", "0.0.0.0"), + "--port".into(), + g("PORT", "8080"), + ]); + if side_ctx > 0 { + args.extend(s(&["--parallel", "2", "--no-kv-unified"])); + } else if !has_any_flag(extra, &["--parallel", "-np"]) { + args.push("--parallel".into()); + args.push(g("PARALLEL", "1")); + } + let n = side_slot_note(side_ctx, side_mib, &side_why, ngl_forced(cfg, extra)); + if !n.is_empty() { + notes.push(n); + } + if st.on { + args.extend(st.ngl.iter().cloned()); + } else if !has_any_flag(extra, &["-ngl", "--n-gpu-layers", "--gpu-layers"]) { + let (ngl, note) = ngl_args(&g("NGL", ""), help_fits_layers_itself(&si.help)); + if !note.is_empty() { + notes.push(note); + } + args.extend(ngl); + } + let (fitt, fit_notes) = fit_target_args(cfg, &place, si); + args.extend(fitt); + args.extend(st.args.iter().cloned()); + notes.extend(fit_notes); + if !ktv.is_empty() { + args.push("-ctk".into()); + args.push(ktv); + } + if !vtv.is_empty() { + args.push("-ctv".into()); + args.push(vtv); + } + if !si.mmproj.is_empty() { + args.push("--mmproj".into()); + args.push(si.mmproj.clone()); + } + let r = trim(get(cfg, "REASONING")); + if !r.is_empty() { + if crate::chat::wire::reasoning_active(r) { + args.extend([ + "--reasoning".into(), + r.to_string(), + "--reasoning-budget".into(), + g("REASONING_BUDGET", "-1"), + ]); + } else if help_supports_reasoning_flag(&si.help) { + args.extend(s(&["--reasoning", "off"])); + } else { + notes.push( + "ce moteur ne connaît pas --reasoning : impossible de désactiver le raisonnement" + .into(), + ); + } + } + let (rp, rp_notes) = reasoning_preserve_args(cfg, extra, si); + args.extend(rp); + notes.extend(rp_notes); + if !si.api_key.is_empty() { + args.push("--api-key".into()); + args.push(si.api_key.clone()); + } + let (cram, cram_notes) = cache_ram_args(cfg, extra, si); + args.extend(cram); + args.extend(slot_save_args(cfg, extra, si)); + notes.extend(cram_notes); + let (ok, why) = slot_persist_plan(cfg, extra, si); + let n = slot_persist_note(ok, &why); + if !n.is_empty() { + notes.push(n); + } + let (ck, ck_notes) = ckpt_args(cfg, extra, si); + args.extend(ck); + notes.extend(ck_notes); + let sp = spec_args(cfg, extra, si); + args.extend(sp.args); + notes.extend(sp.notes); + args.extend(extra.iter().cloned()); + let (ek, ev, src) = effective_kv_types(cfg, extra, &si.arg_env); + let n = kv_fidelity_note(&ek, &ev, &src, extra, &si.arg_env); + if !n.is_empty() { + notes.push(n); + } + notes.extend(lossy_cache_notes(extra, si)); + notes.extend(moe_notes(cfg, extra, si)); + let (w, _) = load_mode_risk(cfg, &args, si); + if !w.is_empty() { + notes.push(w); + } + Launch { args, env, notes } +} + +/// `fastKV` / `slowKV` : combinaisons accélérées en Flash-Attention CUDA. +pub fn slow_kv(k: &str, v: &str) -> bool { + let k = if k.is_empty() { "f16" } else { k }; + let v = if v.is_empty() { "f16" } else { v }; + !matches!( + (k, v), + ("f16", "f16") | ("q8_0", "q8_0") | ("q4_0", "q4_0") | ("bf16", "bf16") + ) +} + +/// `engineCmdline` : forme copiable (variables triées en tête, guillemets +/// autour de ce qui contient un blanc ou un guillemet, clé masquée). +pub fn engine_cmdline(env: &BTreeMap, args: &[String]) -> String { + let quote = |a: &str| -> String { + if a.is_empty() || a.contains([' ', '\t', '"', '\'']) { + format!("\"{}\"", a.replace('"', "\\\"")) + } else { + a.to_string() + } + }; + let mut out: Vec = env + .iter() + .map(|(k, v)| format!("{k}={}", quote(v))) + .collect(); + for (i, a) in args.iter().enumerate() { + if i > 0 && (args[i - 1] == "--api-key" || args[i - 1] == "--api-key-file") { + out.push(quote("")); + } else { + out.push(quote(a)); + } + } + out.join(" ") +} diff --git a/rust/loki-rs/src/serve/moe.rs b/rust/loki-rs/src/serve/moe.rs new file mode 100644 index 0000000..d99f58d --- /dev/null +++ b/rust/loki-rs/src/serve/moe.rs @@ -0,0 +1,309 @@ +//! Experts MoE et chargement du modèle (`backend_serve_moe.go`) : des avis et +//! un garde-fou (LOAD_GUARD), jamais de réécriture silencieuse du preset. + +use super::{ + Cfg, FIT_TARGET_MIN, SplitDev, SysInfo, atoi, env_truthy, first_non_empty, fit_blocker, + flag_value, flag_values, get, has_any_flag, help_supports_load_mode, is_external, trim, +}; +use std::collections::HashMap; + +/// `otPatterns`. +pub fn ot_patterns(extra: &[String], arg_env: &HashMap) -> Vec { + let mut vals = flag_values(extra, &["-ot", "--override-tensor"]); + if let Some(v) = arg_env + .get("LLAMA_ARG_OVERRIDE_TENSOR") + .filter(|v| !v.is_empty()) + { + vals.push(v.clone()); + } + let mut out = Vec::new(); + for v in vals { + for el in v.split(',') { + let el = trim(el); + if !el.is_empty() { + out.push(el.to_string()); + } + } + } + out +} + +/// `otTargetsExperts`. +pub fn ot_targets_experts(pattern: &str) -> bool { + pattern.to_lowercase().contains("exps") +} + +/// `otOnCPU`. +pub fn ot_on_cpu(el: &str) -> bool { + el.split_once('=') + .is_some_and(|(_, b)| trim(b).to_uppercase().starts_with("CPU")) +} + +/// `cpuExperts`. +pub fn cpu_experts(extra: &[String], arg_env: &HashMap) -> String { + for el in ot_patterns(extra, arg_env) { + let pat = el.split_once('=').map_or(el.as_str(), |(p, _)| p); + if ot_targets_experts(pat) && ot_on_cpu(&el) { + return "-ot experts=CPU".into(); + } + } + let env = |k: &str| arg_env.get(k).map_or("", String::as_str); + if has_any_flag(extra, &["--cpu-moe", "-cmoe"]) || env_truthy(env("LLAMA_ARG_CPU_MOE")) { + return "--cpu-moe".into(); + } + let mut vals = flag_values(extra, &["-ncmoe", "--n-cpu-moe"]); + vals.push(env("LLAMA_ARG_N_CPU_MOE").to_string()); + for v in vals { + let v = trim(&v); + if !v.is_empty() && v != "0" { + return format!("--n-cpu-moe {v}"); + } + } + String::new() +} + +/// `helpSupportsFit` : « --fit » lui-même, pas seulement --fit-target. +pub fn help_supports_fit(help: &str) -> bool { + let b = help.as_bytes(); + let pat = b"--fit"; + let mut i = 0; + while let Some(off) = help[i..].find("--fit") { + let start = i + off; + let end = start + pat.len(); + let before_ok = + start == 0 || matches!(b[start - 1], b',') || b[start - 1].is_ascii_whitespace(); + let after_ok = + end == b.len() || matches!(b[end], b',' | b'=' | b'[') || b[end].is_ascii_whitespace(); + if before_ok && after_ok { + return true; + } + i = start + 1; + } + false +} + +/// `serveUBatch` (0 = illisible). +pub fn serve_ubatch(cfg: &Cfg, extra: &[String]) -> i64 { + let mut sv = flag_value(extra, &["-ub", "--ubatch-size"]); + if sv.is_empty() { + sv = trim(get(cfg, "UBATCH")).to_string(); + } + if sv.is_empty() { + sv = "512".into(); + } + match atoi(trim(&sv)) { + Some(n) if n >= 0 => n, + _ => 0, + } +} + +/// `gibText` : des Mio en « 12,3 Gio ». +pub fn gib_text(mib: i64) -> String { + format!("{:.1}", mib as f64 / 1024.0).replacen('.', ",", 1) + " Gio" +} + +/// `moeNotes`. +pub fn moe_notes(cfg: &Cfg, extra: &[String], si: &SysInfo) -> Vec { + let Some(g) = si.gguf.as_ref() else { + return Vec::new(); + }; + if is_external(cfg) || g.expert_count <= 0 { + return Vec::new(); + } + let ub = serve_ubatch(cfg, extra); + let r = cpu_experts(extra, &si.arg_env); + if !r.is_empty() { + let mut n = format!("experts placés à la main ({r}) : --fit ne les place pas"); + if ub > 0 && ub <= 512 { + n.push_str(" ; UBATCH plus grand = tampon de calcul plus gros sur chaque GPU : monte --n-cpu-moe d'abord, ou passe au placement auto (--fit, « Dupliquer en placement auto » dans l'éditeur)"); + } + return vec![n]; + } + let model_mib = si.model_bytes >> 20; + if model_mib <= 0 || si.vram_mib <= 0 || si.unified_mem || trim(&si.help).is_empty() { + return Vec::new(); + } + let cards = si.gpus.max(1); + if model_mib <= si.vram_mib - FIT_TARGET_MIN * cards { + return Vec::new(); + } + let sizes = format!( + "modèle {}, VRAM {}", + gib_text(model_mib), + gib_text(si.vram_mib) + ); + if !help_supports_fit(&si.help) { + let ngl_s = flag_value(extra, &["-ngl", "--n-gpu-layers", "--gpu-layers"]); + if let Some(n) = atoi(trim(first_non_empty(&[&ngl_s, get(cfg, "NGL")]))) + && n < 999 + { + return Vec::new(); + } + return vec![format!( + "MoE plus gros que la VRAM ({sizes}) : ce moteur ne place pas les experts (pas de --fit) — garde --n-cpu-moe" + )]; + } + let why = fit_blocker(cfg, extra, si); + if !why.is_empty() { + if why == "surcharge de tenseurs" { + return vec![format!( + "MoE plus gros que la VRAM ({sizes}) et --fit coupé par un -ot qui ne vise pas les experts : rien ne les sort du GPU — --n-cpu-moe, ou retire ce -ot" + )]; + } + return Vec::new(); + } + if ub > 0 && ub <= 512 { + return vec![format!( + "UBATCH 2048+ recommandé : --fit laisse des experts en RAM ({sizes}), recopiés vers le GPU à chaque micro-lot du prefill ; BATCH 4096 avec — à mesurer (bench complet)" + )]; + } + Vec::new() +} + +/// `loadArgEnv`. +pub const LOAD_ARG_ENV: &[&str] = &[ + "LLAMA_ARG_LOAD_MODE", + "LLAMA_ARG_MLOCK", + "LLAMA_ARG_NO_MMAP", + "LLAMA_ARG_RPC", +]; + +/// `residentLoadMode`. +pub fn resident_load_mode(args: &[String], arg_env: &HashMap) -> String { + let env = |k: &str| arg_env.get(k).map_or("", String::as_str); + let mut mode = trim(&flag_value(args, &["--load-mode", "-lm"])).to_lowercase(); + let (mut mlock, mut nommap) = ( + has_any_flag(args, &["--mlock"]), + has_any_flag(args, &["--no-mmap"]), + ); + if mode.is_empty() && !mlock && !nommap { + mode = trim(env("LLAMA_ARG_LOAD_MODE")).to_lowercase(); + if mode.is_empty() { + mlock = env_truthy(env("LLAMA_ARG_MLOCK")); + nommap = env_truthy(env("LLAMA_ARG_NO_MMAP")); + } + } + if matches!(mode.as_str(), "none" | "mlock" | "dio" | "mmap+mlock") { + return format!("--load-mode {mode}"); + } + match (mlock, nommap) { + (true, true) => "--mlock --no-mmap".into(), + (true, false) => "--mlock".into(), + (false, true) => "--no-mmap".into(), + _ => String::new(), + } +} + +/// `loadGuardMarker`. +pub const LOAD_GUARD_MARKER: &str = "LOAD_GUARD=off pour lancer quand même"; +/// `loadGuardExitStatus` (EX_CONFIG). +pub const LOAD_GUARD_EXIT_STATUS: i32 = 78; + +/// `loadGuardOff`. +pub fn load_guard_off(cfg: &Cfg) -> bool { + matches!( + trim(get(cfg, "LOAD_GUARD")).to_lowercase().as_str(), + "off" | "0" | "false" | "non" | "no" + ) +} + +/// `loadModeRisk` : (avertissement, refus). +pub fn load_mode_risk(cfg: &Cfg, args: &[String], si: &SysInfo) -> (String, String) { + if is_external(cfg) { + return (String::new(), String::new()); + } + let restricted; + let env = if help_supports_load_mode(&si.help) + && (!si.env("LLAMA_ARG_MLOCK").is_empty() || !si.env("LLAMA_ARG_NO_MMAP").is_empty()) + { + let mut m = HashMap::new(); + m.insert( + "LLAMA_ARG_LOAD_MODE".to_string(), + si.env("LLAMA_ARG_LOAD_MODE").to_string(), + ); + restricted = m; + &restricted + } else { + &si.arg_env + }; + let mode = resident_load_mode(args, env); + let model_mib = si.model_bytes >> 20; + if mode.is_empty() || model_mib <= 0 || si.ram_mib <= 0 { + return (String::new(), String::new()); + } + let remote = has_any_flag(args, &["--rpc"]) || !trim(si.env("LLAMA_ARG_RPC")).is_empty(); + let (mut low, mut est, mut up_to, mut unsure) = (0i64, model_mib, true, false); + if remote { + } else if si.unified_mem { + low = model_mib; + est = model_mib; + up_to = false; + } else if si.engine_vram_mib > 0 { + low = (model_mib - si.engine_vram_mib).max(0); + est = low; + up_to = false; + } else if si.vram_mib > 0 { + est = (model_mib - si.vram_mib).max(0); + up_to = false; + unsure = true; + } + let ram = si.ram_mib; + let what = if up_to { "jusqu'à " } else { "environ " }; + if unsure && est * 10 > ram * 9 && !load_guard_off(cfg) { + return ( + format!( + "{mode} : environ {} de poids en RAM (modèle {}, VRAM NVIDIA {}) pour {} — échec probable ; refus seulement si les cartes listées par le moteur le confirment, lancement sinon ; --load-mode mmap est plus sûr", + gib_text(est), + gib_text(model_mib), + gib_text(si.vram_mib), + gib_text(ram) + ), + String::new(), + ); + } + if low * 10 > ram * 9 { + let msg = format!( + "{mode} : au moins {} de poids restent en RAM (modèle {}) pour {} de RAM, limite du conteneur comprise — échec certain : swap et décodage effondré, ou processus tué (llama.cpp #26110). --load-mode mmap laisse le système relire les pages au besoin", + gib_text(low), + gib_text(model_mib), + gib_text(ram) + ); + if load_guard_off(cfg) { + return (msg + " ; LOAD_GUARD=off : lancé quand même", String::new()); + } + return (String::new(), msg + " ; " + LOAD_GUARD_MARKER); + } + if est * 10 > ram * 8 { + return ( + format!( + "{mode} : {what}{} de poids en RAM pour {} — au-delà, swap (décodage de 25 à 7,5 t/s dans llama.cpp #26110) ou processus tué ; --load-mode mmap est plus sûr", + gib_text(est), + gib_text(ram) + ), + String::new(), + ); + } + (String::new(), String::new()) +} + +/// `loadGuardNeedsDevices`. +pub fn load_guard_needs_devices(cfg: &Cfg, args: &[String], si: &SysInfo) -> bool { + if si.engine_vram_mib > 0 || si.vram_mib <= 0 { + return false; + } + let mut s2 = si.clone(); + s2.engine_vram_mib = si.vram_mib; + !load_mode_risk(cfg, args, &s2).1.is_empty() +} + +/// `engineDevsVRAM`. +pub fn engine_devs_vram(devs: &[SplitDev]) -> i64 { + let mut total = 0; + for d in devs { + if d.total_mib <= 0 { + return 0; + } + total += d.total_mib; + } + total +} diff --git a/rust/loki-rs/src/serve/persist.rs b/rust/loki-rs/src/serve/persist.rs new file mode 100644 index 0000000..6a612cf --- /dev/null +++ b/rust/loki-rs/src/serve/persist.rs @@ -0,0 +1,282 @@ +//! État du slot gardé à travers une bascule de preset +//! (`backend_serve_persist.go`, `SLOT_PERSIST`, off par défaut) : la clé du +//! moteur (empreinte de tout ce qui décide du calcul du cache), le marqueur +//! lu par le process web, le ménage des états gardés. + +use std::path::{Path, PathBuf}; + +use sha2::{Digest, Sha256}; + +use super::{ + Cfg, SysInfo, cpu_weights, flag_value, get, has_any_flag, is_external, loopback_host, + spec_mode, spec_user_set, split_mode_key, trim, +}; + +/// `slotPersistOn`. +pub fn slot_persist_on(cfg: &Cfg) -> bool { + matches!( + trim(get(cfg, "SLOT_PERSIST")).to_lowercase().as_str(), + "on" | "1" | "true" | "yes" | "oui" + ) +} + +/// `slotPersistArgEnv`. +pub const SLOT_PERSIST_ARG_ENV: &[&str] = &[ + "LLAMA_ARG_SLOT_SAVE_PATH", + "LLAMA_ARG_SPEC_TYPE", + "LLAMA_ARG_SPEC_DRAFT_MODEL", + "LLAMA_ARG_SPEC_DRAFT_HF_REPO", +]; + +/// `slotPersistPlan` : (accepté, refus — vide = clé absente). +pub fn slot_persist_plan(cfg: &Cfg, extra: &[String], si: &SysInfo) -> (bool, String) { + if !slot_persist_on(cfg) { + return (false, String::new()); + } + let no = |w: &str| (false, w.to_string()); + if is_external(cfg) { + return no("preset externe"); + } + if !si.help.contains("--slot-save-path") { + return no("ce moteur ne connaît pas --slot-save-path"); + } + if has_any_flag(extra, &["--slot-save-path"]) || !si.env("LLAMA_ARG_SLOT_SAVE_PATH").is_empty() + { + return no("--slot-save-path est déjà réglé à la main"); + } + if si.slot_dir.is_empty() { + return no("dossier des slots indisponible"); + } + if spec_mode(cfg) != "off" + || spec_user_set(extra, &si.arg_env) + || !si.env("LLAMA_ARG_SPEC_TYPE").is_empty() + { + return no( + "décodage spéculatif : la sauvegarde du moteur ne garde pas l'état du brouillon", + ); + } + if !split_mode_key(cfg).is_empty() { + return no("SPLIT_MODE : état réparti entre cartes, sauvegarde non validée"); + } + if !slot_save_safe(cfg, extra, si) { + return no( + "moteur joignable par d'autres sans clé d'API (HOST=127.0.0.1 ou une clé d'API)", + ); + } + let r = cpu_weights(cfg, extra, &si.arg_env, si.blocks()); + if !r.is_empty() { + return (false, r); + } + (true, String::new()) +} + +/// `slotSaveSafe` : boucle locale, ou clé d'API exigée. +pub fn slot_save_safe(cfg: &Cfg, extra: &[String], si: &SysInfo) -> bool { + let mut host = flag_value(extra, &["--host"]); + if host.is_empty() { + host = get(cfg, "HOST").into(); + } + if host.is_empty() { + host = "0.0.0.0".into(); + } + loopback_host(&host) || !si.api_key.is_empty() +} + +/// `slotPersistNote`. +pub fn slot_persist_note(ok: bool, why: &str) -> String { + if ok || why.is_empty() { + return String::new(); + } + format!("SLOT_PERSIST refusé ({why}) : l'état du slot ne sera pas gardé à la bascule de preset") +} + +/// `slotPersistFileRe`. +pub fn slot_persist_file(name: &str) -> bool { + let Some(rest) = name.strip_prefix("persist-") else { + return false; + }; + let Some(rest) = rest.strip_suffix(".bin") else { + return false; + }; + let hex = |s: &str, n: usize| { + s.len() == n && s.bytes().all(|b| matches!(b, b'0'..=b'9' | b'a'..=b'f')) + }; + match rest.split_once('-') { + Some((a, b)) => hex(a, 16) && hex(b, 12), + None => false, + } +} + +pub const SLOT_PERSIST_MAX_FILES: usize = 2; + +fn mod_nanos(m: &std::fs::Metadata) -> i128 { + m.modified() + .ok() + .and_then(|t| t.duration_since(std::time::UNIX_EPOCH).ok()) + .map_or(0, |d| d.as_nanos() as i128) +} + +/// `slotPersistKey` : empreinte (16 hex) de la ligne finale (sans la clé +/// d'API), des variables LLAMA_ARG_/GGML_/CUDA_ (environnement puis `env`), +/// des fichiers (taille, date) et du build. +pub fn slot_persist_key( + args: &[String], + env: &std::collections::BTreeMap, + proc_env: &[(String, String)], + si: &SysInfo, + bin: &str, +) -> String { + let mut h = Sha256::new(); + let mut w = |s: &str| { + h.update(s.as_bytes()); + h.update([0u8]); + }; + let mut i = 0; + while i < args.len() { + if args[i] == "--api-key" { + i += 2; + continue; + } + w(&args[i]); + i += 1; + } + let mut vars = std::collections::BTreeMap::new(); + for (k, v) in proc_env { + if k.starts_with("LLAMA_ARG_") || k.starts_with("GGML_") || k.starts_with("CUDA_") { + vars.insert(k.clone(), v.clone()); + } + } + for (k, v) in env { + vars.insert(k.clone(), v.clone()); + } + vars.remove("LLAMA_ARG_API_KEY"); + for (k, v) in &vars { + w(&format!("{k}={v}")); + } + let mut stat = |p: &str| { + if p.is_empty() { + return; + } + match std::fs::metadata(p) { + Ok(m) => w(&format!("{p}|{}|{}", m.len(), mod_nanos(&m))), + Err(_) => w(&format!("{p}|?")), + } + }; + if !si.model.is_empty() { + let mp = Path::new(&si.model); + let dir = mp.parent().unwrap_or(Path::new(".")); + let base = mp + .file_name() + .map(|n| n.to_string_lossy().into_owned()) + .unwrap_or_default(); + for n in crate::gguf::shard_family(&base) { + stat(&dir.join(n).to_string_lossy()); + } + stat(&si.model); + } + stat(&si.mmproj); + stat(&si.draft); + stat(bin); + let bdir = Path::new(bin).parent().unwrap_or(Path::new(".")); + if let Ok(rd) = std::fs::read_dir(bdir) { + let mut names: Vec = rd + .flatten() + .map(|e| e.file_name().to_string_lossy().into_owned()) + .collect(); + names.sort(); + for name in names { + let n = name.to_lowercase(); + if n.ends_with(".so") + || n.contains(".so.") + || n.ends_with(".dll") + || n.ends_with(".dylib") + { + stat(&bdir.join(&name).to_string_lossy()); + } + } + } + w(&format!("build={}", si.engine_build)); + let d = h.finalize(); + d.iter().map(|b| format!("{b:02x}")).collect::()[..16].to_string() +} + +/// `slotPersistMarker`. +pub fn slot_persist_marker(home: &Path) -> PathBuf { + home.join("slots-engine.key") +} + +/// `writeSlotPersistMarker`. +pub fn write_slot_persist_marker(home: &Path, key: &str) { + let p = slot_persist_marker(home); + if key.is_empty() { + let _ = std::fs::remove_file(p); + return; + } + let _ = super::run::write_private(&p, format!("{key}\n").as_bytes()); +} + +/// `slotPersistPrune` : garde les deux états les plus récents. +pub fn slot_persist_prune(dir: &Path) { + let Ok(rd) = std::fs::read_dir(dir) else { + return; + }; + let mut fs: Vec<(String, i128)> = rd + .flatten() + .filter(|e| e.file_type().is_ok_and(|t| t.is_file())) + .filter_map(|e| { + let n = e.file_name().to_string_lossy().into_owned(); + if !slot_persist_file(&n) { + return None; + } + e.metadata().ok().map(|m| (n, mod_nanos(&m))) + }) + .collect(); + fs.sort_by_key(|f| std::cmp::Reverse(f.1)); + for (n, _) in fs.iter().skip(SLOT_PERSIST_MAX_FILES) { + let _ = std::fs::remove_file(dir.join(n)); + } +} + +/// `slotPersistPurge`. +pub fn slot_persist_purge(dir: &Path) { + let Ok(rd) = std::fs::read_dir(dir) else { + return; + }; + for e in rd.flatten() { + let n = e.file_name().to_string_lossy().into_owned(); + if e.file_type().is_ok_and(|t| t.is_file()) && slot_persist_file(&n) { + let _ = std::fs::remove_file(dir.join(n)); + } + } +} + +/// `prepareSlotDir` : LOKI_HOME/slots (0700) vidé, sauf les états gardés +/// quand SLOT_PERSIST est demandé ; vide = indisponible. +pub fn prepare_slot_dir(home: &Path, keep_persist: bool) -> String { + let dir = home.join("slots"); + let mut b = std::fs::DirBuilder::new(); + b.recursive(true); + #[cfg(unix)] + { + // MkdirAll(0700) : seuls les dossiers créés reçoivent le mode. + use std::os::unix::fs::DirBuilderExt; + b.mode(0o700); + } + if b.create(&dir).is_err() { + return String::new(); + } + match std::fs::metadata(&dir) { + Ok(m) if m.is_dir() => {} + _ => return String::new(), + } + if let Ok(rd) = std::fs::read_dir(&dir) { + for e in rd.flatten() { + let n = e.file_name().to_string_lossy().into_owned(); + if e.file_type().is_ok_and(|t| t.is_file()) && !(keep_persist && slot_persist_file(&n)) + { + let _ = std::fs::remove_file(dir.join(n)); + } + } + } + dir.to_string_lossy().into_owned() +} diff --git a/rust/loki-rs/src/serve/queues.rs b/rust/loki-rs/src/serve/queues.rs new file mode 100644 index 0000000..5c00023 --- /dev/null +++ b/rust/loki-rs/src/serve/queues.rs @@ -0,0 +1,168 @@ +//! Files de lancement CUDA sur deux GPU ou plus +//! (`backend_serve_queues.go`) : `CUDA_SCALE_LAUNCH_QUEUES=4x` là où le +//! pipeline entre cartes peut exister. + +use super::{Cfg, SysInfo, env_truthy, flag_value, flag_values, get, has_any_flag, trim}; +use std::collections::HashMap; + +pub const LAUNCH_QUEUES_AUTO: &str = "4x"; + +/// `serveArgEnv`. +pub const SERVE_ARG_ENV: &[&str] = &[ + "LLAMA_ARG_DEVICE", + "LLAMA_ARG_SPLIT_MODE", + "LLAMA_ARG_N_GPU_LAYERS", + "LLAMA_ARG_OVERRIDE_TENSOR", + "LLAMA_ARG_CPU_MOE", + "LLAMA_ARG_N_CPU_MOE", + "LLAMA_ARG_N_CPU_FFN", + "LLAMA_ARG_KV_OFFLOAD", + "LLAMA_ARG_NO_KV_OFFLOAD", +]; + +fn launch_queue_value(v: &str) -> bool { + matches!(v, "0.25x" | "0.5x" | "2x" | "4x") +} + +/// `launchQueuesEnv` : valeur vide = ne rien poser. +pub fn launch_queues_env(cfg: &Cfg, extra: &[String], si: &SysInfo) -> (String, Vec) { + if !si.launch_queues.is_empty() { + return (String::new(), Vec::new()); + } + let mut notes = Vec::new(); + let want = trim(get(cfg, "CUDA_LAUNCH_QUEUES")).to_lowercase(); + if want == "off" { + return (String::new(), notes); + } + if launch_queue_value(&want) { + return ( + want.clone(), + vec![format!( + "CUDA_SCALE_LAUNCH_QUEUES={want} (CUDA_LAUNCH_QUEUES)" + )], + ); + } + if !want.is_empty() && want != "auto" { + notes.push(format!( + "CUDA_LAUNCH_QUEUES={} ignoré (0.25x, 0.5x, 2x, 4x ou off) → automatique", + get(cfg, "CUDA_LAUNCH_QUEUES") + )); + } + let n = served_gpu_count(extra, si); + if n < 2 || !pipeline_blocker(cfg, extra, &si.arg_env).is_empty() { + return (String::new(), notes); + } + notes.push(format!( + "CUDA_SCALE_LAUNCH_QUEUES={LAUNCH_QUEUES_AUTO} ({n} GPU, parallélisme pipeline possible) — CUDA_LAUNCH_QUEUES=off pour s'en passer" + )); + (LAUNCH_QUEUES_AUTO.into(), notes) +} + +/// `servedGPUCount`. +pub fn served_gpu_count(extra: &[String], si: &SysInfo) -> i64 { + let mut dev = flag_value(extra, &["-dev", "--device"]); + if dev.is_empty() { + dev = si.env("LLAMA_ARG_DEVICE").to_string(); + } + if dev.is_empty() { + return si.gpus; + } + dev.split(',') + .filter(|d| trim(d).to_uppercase().starts_with("CUDA")) + .count() as i64 +} + +/// `pipelineBlocker` : pourquoi llama.cpp n'ouvrira PAS le pipeline (vide = +/// il peut). +pub fn pipeline_blocker(cfg: &Cfg, extra: &[String], arg_env: &HashMap) -> String { + let env = |k: &str| arg_env.get(k).map_or("", String::as_str); + let r = tensor_override(extra, arg_env); + if !r.is_empty() { + return r; + } + if !kv_offloaded(extra, arg_env) { + return "cache KV sur CPU".into(); + } + let mut sm = flag_value(extra, &["-sm", "--split-mode"]); + if sm.is_empty() { + sm = env("LLAMA_ARG_SPLIT_MODE").into(); + } + if !sm.is_empty() && sm != "layer" { + return format!("découpe {sm}"); + } + let mut ngl = flag_value(extra, &["-ngl", "--n-gpu-layers", "--gpu-layers"]); + if ngl.is_empty() { + ngl = trim(get(cfg, "NGL")).to_string(); + if ngl.eq_ignore_ascii_case("auto") { + ngl = env("LLAMA_ARG_N_GPU_LAYERS").into(); + } + } + let ngl = trim(&ngl).to_lowercase(); + if !ngl.is_empty() && ngl != "auto" && ngl != "all" && ngl != "999" { + return format!("couches GPU limitées à {ngl}"); + } + String::new() +} + +/// `tensorOverride` : tenseurs placés à la main (vide = aucun). +pub fn tensor_override(extra: &[String], arg_env: &HashMap) -> String { + let env = |k: &str| arg_env.get(k).map_or("", String::as_str); + if has_any_flag(extra, &["-ot", "--override-tensor", "--cpu-moe", "-cmoe"]) + || !env("LLAMA_ARG_OVERRIDE_TENSOR").is_empty() + || env_truthy(env("LLAMA_ARG_CPU_MOE")) + { + return "surcharge de tenseurs".into(); + } + for (e, short, long) in [ + ("LLAMA_ARG_N_CPU_MOE", "-ncmoe", "--n-cpu-moe"), + ("LLAMA_ARG_N_CPU_FFN", "-ncffn", "--n-cpu-ffn"), + ] { + let mut vals = flag_values(extra, &[short, long]); + vals.push(env(e).to_string()); + for v in vals { + let v = trim(&v); + if !v.is_empty() && v != "0" { + return format!("couches {long} sur CPU"); + } + } + } + String::new() +} + +/// `kvOffloaded` : le cache KV reste-t-il sur GPU ? +pub fn kv_offloaded(extra: &[String], arg_env: &HashMap) -> bool { + let (mut on, mut set) = (true, false); + for a in extra { + let name = a.split_once('=').map_or(a.as_str(), |(n, _)| n); + match name { + "-kvo" | "--kv-offload" => { + on = true; + set = true; + } + "-nkvo" | "--no-kv-offload" => { + on = false; + set = true; + } + _ => {} + } + } + if set { + return on; + } + if arg_env + .get("LLAMA_ARG_NO_KV_OFFLOAD") + .is_some_and(|v| !v.is_empty()) + { + return false; + } + !matches!( + trim( + arg_env + .get("LLAMA_ARG_KV_OFFLOAD") + .map_or("", String::as_str) + ) + .to_lowercase() + .as_str(), + "off" | "disabled" | "false" | "0" + ) +} diff --git a/rust/loki-rs/src/serve/run.rs b/rust/loki-rs/src/serve/run.rs new file mode 100644 index 0000000..800f95c --- /dev/null +++ b/rust/loki-rs/src/serve/run.rs @@ -0,0 +1,884 @@ +//! `loki-rs engine serve` — port de `cmdServe` : lit le monde (configuration +//! par l'API interne du Go, aide du moteur, GGUF, RAM, VRAM, cgroup, +//! variables), compose la ligne par [`super::build_serve_args`], puis cède la +//! place à llama-server (`exec`). +//! +//! Ce qui reste au Go : un preset Strata et le moteur d'essai de +//! l'optimiseur (`LOKI_TUNE_TRIAL`) — `loki serve` est alors lancé à notre +//! place, à l'identique. Sans accès à l'API interne (lancement à la main hors +//! de la façade), même repli. + +use std::collections::{BTreeMap, HashMap}; +use std::io::Write as _; +use std::net::{SocketAddr, TcpStream, ToSocketAddrs}; +use std::path::{Path, PathBuf}; +use std::process::{Command, ExitCode, Stdio}; +use std::time::{Duration, Instant}; + +use super::*; +use crate::gguf; +use crate::store::{BK_CONFIG, BK_STATE, Store}; + +/// Variables d'environnement du lancement : celles du process, plus ce que +/// `loki serve` pose (sélection GPU, bibliothèques, variables du moteur). +#[derive(Default)] +pub struct Env { + overlay: BTreeMap, +} + +impl Env { + pub fn get(&self, k: &str) -> Option { + self.overlay + .get(k) + .cloned() + .or_else(|| std::env::var(k).ok()) + } + /// `os.Getenv` : vide si absente. + pub fn getenv(&self, k: &str) -> String { + self.get(k).unwrap_or_default() + } + pub fn set(&mut self, k: &str, v: &str) { + self.overlay.insert(k.into(), v.into()); + } + /// L'environnement complet, trié. + pub fn all(&self) -> Vec<(String, String)> { + let mut m: BTreeMap = std::env::vars().collect(); + for (k, v) in &self.overlay { + m.insert(k.clone(), v.clone()); + } + m.into_iter().collect() + } + fn command(&self, bin: &str) -> Command { + let mut c = Command::new(bin); + c.envs(&self.overlay); + c + } +} + +/// Écrit un fichier privé (0600). +pub(crate) fn write_private(p: &Path, data: &[u8]) -> std::io::Result<()> { + let mut o = std::fs::OpenOptions::new(); + o.write(true).create(true).truncate(true); + #[cfg(unix)] + { + use std::os::unix::fs::OpenOptionsExt; + o.mode(0o600); + } + o.open(p)?.write_all(data) +} + +/// Sortie combinée d'une commande bornée dans le temps ; `None` si elle n'a +/// pas pu démarrer. (succès, sortie) +fn output_with_timeout(mut cmd: Command, timeout: Duration) -> Option<(bool, String)> { + let path = std::env::temp_dir().join(format!( + "loki-serve-{}-{}.out", + std::process::id(), + Instant::now().elapsed().as_nanos() ^ rand_u64() + )); + let f = std::fs::File::create(&path).ok()?; + let f2 = f.try_clone().ok()?; + let mut child = cmd.stdin(Stdio::null()).stdout(f).stderr(f2).spawn().ok()?; + let deadline = Instant::now() + timeout; + let ok = loop { + match child.try_wait() { + Ok(Some(st)) => break st.success(), + Ok(None) if Instant::now() < deadline => std::thread::sleep(Duration::from_millis(20)), + _ => { + let _ = child.kill(); + let _ = child.wait(); + break false; + } + } + }; + let out = std::fs::read(&path).unwrap_or_default(); + let _ = std::fs::remove_file(&path); + Some((ok, String::from_utf8_lossy(&out).into_owned())) +} + +fn rand_u64() -> u128 { + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .map_or(0, |d| d.as_nanos()) +} + +/// `binHelp` : sortie de ` --help` (vide si illisible). +pub fn bin_help(env: &Env, bin: &str) -> String { + let mut c = env.command(bin); + c.arg("--help"); + match output_with_timeout(c, Duration::from_secs(5)) { + Some((ok, out)) if ok || !out.is_empty() => out, + _ => String::new(), + } +} + +/// `libraryPathValue` (Linux) : dossier du binaire, CUDA, puis l'existant. +pub fn library_path_value(env: &Env, dir: &str) -> (String, String) { + let mut parts = vec![dir.to_string()]; + parts.extend(cuda_lib_dirs()); + let var = if cfg!(target_os = "macos") { + "DYLD_LIBRARY_PATH" + } else { + "LD_LIBRARY_PATH" + }; + let existing = env.getenv(var); + if !existing.is_empty() { + parts.push(existing); + } + (var.into(), parts.join(":")) +} + +fn is_dir(p: &str) -> bool { + std::fs::metadata(p).is_ok_and(|m| m.is_dir()) +} + +fn is_file(p: &str) -> bool { + std::fs::metadata(p).is_ok_and(|m| !m.is_dir()) +} + +/// `cudaLibDirs`. +fn cuda_lib_dirs() -> Vec { + let mut dirs: Vec = Vec::new(); + let mut add = |d: String| { + if !d.is_empty() && !dirs.contains(&d) && is_dir(&d) { + dirs.push(d); + } + }; + add("/usr/local/cuda/lib64".into()); + add("/usr/local/cuda/targets/x86_64-linux/lib".into()); + let mut versioned: Vec = std::fs::read_dir("/usr/local") + .map(|rd| { + rd.flatten() + .map(|e| e.file_name().to_string_lossy().into_owned()) + .filter(|n| n.starts_with("cuda-")) + .map(|n| format!("/usr/local/{n}/lib64")) + .filter(|p| Path::new(p).exists()) + .collect() + }) + .unwrap_or_default(); + versioned.sort(); + for v in versioned.into_iter().rev() { + add(v); + } + dirs +} + +/// `engineSource` / `prebuiltOwns` / `engineOwns` / `samePath`. +pub struct EnginePaths { + pub home: PathBuf, +} + +fn norm_path(s: &str) -> String { + crate::paths::go_clean(s).replace('\\', "/").to_lowercase() +} + +impl EnginePaths { + fn prebuilt_dir(&self) -> String { + self.home + .join("backends") + .join("llama.cpp-prebuilt") + .to_string_lossy() + .into_owned() + } + fn engine_dir(&self) -> String { + self.home.join("engine").to_string_lossy().into_owned() + } + pub fn prebuilt_owns(&self, p: &str) -> bool { + !p.is_empty() && norm_path(p).starts_with(&(norm_path(&self.prebuilt_dir()) + "/")) + } + pub fn engine_owns(&self, p: &str) -> bool { + !p.is_empty() && norm_path(p).starts_with(&(norm_path(&self.engine_dir()) + "/")) + } + /// `prebuiltServerBin` : le binaire du VERSION, sinon le dernier. + fn prebuilt_server_bin(&self) -> String { + let mut found = Vec::new(); + fn walk(d: &Path, out: &mut Vec) { + let Ok(rd) = std::fs::read_dir(d) else { return }; + for e in rd.flatten() { + let p = e.path(); + if e.file_type().is_ok_and(|t| t.is_dir()) { + walk(&p, out); + } else if e.file_name() == "llama-server" { + out.push(p.to_string_lossy().into_owned()); + } + } + } + walk(Path::new(&self.prebuilt_dir()), &mut found); + found.sort(); + if found.is_empty() { + return String::new(); + } + let tag = std::fs::read_to_string(Path::new(&self.prebuilt_dir()).join("VERSION")) + .ok() + .and_then(|s| s.split_whitespace().next().map(str::to_string)) + .unwrap_or_default(); + if !tag.is_empty() + && let Some(p) = found + .iter() + .find(|p| p.replace('\\', "/").contains(&format!("/llama-{tag}/"))) + { + return p.clone(); + } + found.last().cloned().unwrap_or_default() + } + /// `prebuiltResolveBin`. + pub fn prebuilt_resolve_bin(&self, bin: &str) -> String { + if !self.prebuilt_owns(bin) || std::fs::metadata(bin).is_ok() { + return bin.into(); + } + let cur = self.prebuilt_server_bin(); + if cur.is_empty() { bin.into() } else { cur } + } + /// `providedEngineBin`. + fn provided_engine_bin(&self) -> String { + let p = std::env::var("LOKI_ENGINE_BIN").unwrap_or_default(); + let p = trim(&p); + if p.is_empty() { + return String::new(); + } + if is_file(&self.prebuilt_resolve_bin(p)) { + p.into() + } else { + String::new() + } + } + /// `engineSource`. + pub fn engine_source(&self, bin: &str) -> &'static str { + let img = self.provided_engine_bin(); + if !img.is_empty() && same_path(bin, &img) { + "image" + } else if self.engine_owns(bin) { + "downloaded" + } else if self.prebuilt_owns(bin) { + "prebuilt" + } else { + "custom" + } + } +} + +/// `samePath` : symlinks résolus, casse ignorée. +pub fn same_path(a: &str, b: &str) -> bool { + if a.is_empty() || b.is_empty() { + return false; + } + let n = |p: &str| -> String { + let r = std::fs::canonicalize(p) + .map(|x| x.to_string_lossy().into_owned()) + .unwrap_or_else(|_| p.to_string()); + norm_path(&r) + }; + n(a) == n(b) +} + +/// `parseEngineBuild`. +pub fn parse_engine_build(out: &str) -> i64 { + let long = regex::Regex::new(r"build\s+(\d+),\s*commit\s+([0-9a-fA-F]+)").expect("motif"); + let short = regex::Regex::new(r"(?m)^\s*(?:version|build)\s*:\s*(\d+)\s*\(([0-9a-fA-F]+)\)") + .expect("motif"); + for re in [long, short] { + if let Some(m) = re.captures(out) { + return m[1].parse().unwrap_or(0); + } + } + 0 +} + +/// `engineBuildTrusted`. +fn engine_build_trusted(env: &Env, ep: &EnginePaths, bin: &str) -> i64 { + let src = ep.engine_source(bin); + if src == "custom" || !is_file(bin) { + return 0; + } + let mut c = env.command(bin); + c.arg("--version"); + let dir = Path::new(bin) + .parent() + .map(|d| d.to_string_lossy().into_owned()) + .unwrap_or_default(); + let (k, v) = library_path_value(env, &dir); + c.env(k, v); + let out = output_with_timeout(c, Duration::from_secs(15)) + .map(|(_, o)| o) + .unwrap_or_default(); + engine_build_trust(src, parse_engine_build(&out)) +} + +fn has_tool(name: &str) -> bool { + crate::paths::look_path(name).is_some() +} + +/// `nvidiaGPUCount`. +fn nvidia_gpu_count() -> i64 { + if !has_tool("nvidia-smi") { + return 0; + } + let mut c = Command::new("nvidia-smi"); + c.args(["--query-gpu=index", "--format=csv,noheader"]); + match output_with_timeout(c, Duration::from_secs(3)) { + Some((true, out)) => out.split('\n').filter(|l| !trim(l).is_empty()).count() as i64, + _ => 0, + } +} + +/// `nvidiaVRAMMiB`. +fn nvidia_vram_mib(cvd: &str, pci: bool) -> i64 { + if !has_tool("nvidia-smi") { + return 0; + } + let mut c = Command::new("nvidia-smi"); + c.args([ + "--query-gpu=index,memory.total", + "--format=csv,noheader,nounits", + ]); + match output_with_timeout(c, Duration::from_secs(3)) { + Some((true, out)) => vram_from_smi(&out, cvd, pci), + _ => 0, + } +} + +/// Lectures des variables du moteur (`probe*Env`). +fn probe_env(env: &Env, si: &mut SysInfo, keys: &[&str], trim_check: bool) { + for k in keys { + if let Some(v) = env.get(k) { + let keep = if trim_check { + !trim(&v).is_empty() + } else { + !v.is_empty() + }; + if keep { + si.arg_env.insert((*k).into(), v); + } + } + } +} + +/// `probeServeGPUs`. +fn probe_serve_gpus(env: &Env, cfg: &Cfg, extra: &[String], si: &mut SysInfo) { + si.launch_queues = env.getenv("CUDA_SCALE_LAUNCH_QUEUES"); + si.arg_env = HashMap::new(); + for k in SERVE_ARG_ENV { + match env.get(k) { + Some(v) if !v.is_empty() => { + si.arg_env.insert((*k).into(), v); + } + Some(_) if *k == "LLAMA_ARG_NO_KV_OFFLOAD" => { + si.arg_env.insert((*k).into(), "1".into()); + } + _ => {} + } + } + if !si.launch_queues.is_empty() + || trim(get(cfg, "CUDA_LAUNCH_QUEUES")).eq_ignore_ascii_case("off") + { + return; + } + if has_any_flag(extra, &["-dev", "--device"]) || !si.env("LLAMA_ARG_DEVICE").is_empty() { + return; + } + let cvd = trim(&env.getenv("CUDA_VISIBLE_DEVICES")).to_string(); + if !cvd.is_empty() { + si.gpus = cvd.split(',').filter(|d| !trim(d).is_empty()).count() as i64; + return; + } + si.gpus = nvidia_gpu_count(); +} + +/// `probeCacheRAM`. +fn probe_cache_ram(env: &Env, cfg: &Cfg, extra: &[String], si: &mut SysInfo) { + probe_env(env, si, CACHE_ARG_ENV, true); + if !si.model.is_empty() { + let mp = Path::new(&si.model); + let dir = mp.parent().unwrap_or(Path::new(".")); + let base = mp + .file_name() + .map(|n| n.to_string_lossy().into_owned()) + .unwrap_or_default(); + si.model_bytes = crate::engstatus::shard_family_size(dir, &base); + if let Ok(g) = gguf::gguf_meta(mp) { + si.gguf = Some(g); + } + } + let (used, total) = crate::sysinfo::ram_usage_mb(); + if total > 0 { + si.ram_mib = total; + si.ram_avail_mib = total - used; + } + if cfg!(target_os = "linux") { + let (limit, cur) = cgroup_mem_mib(Path::new("/")); + if limit > 0 { + if si.ram_mib == 0 || limit < si.ram_mib { + si.ram_mib = limit; + } + let free = limit - cur; + if free >= 0 && (si.ram_avail_mib == 0 || free < si.ram_avail_mib) { + si.ram_avail_mib = free; + } + } + } + si.unified_mem = cfg!(all(target_os = "macos", target_arch = "aarch64")); + if cfg!(target_os = "macos") + || has_any_flag(extra, &["-dev", "--device"]) + || !si.env("LLAMA_ARG_DEVICE").is_empty() + { + return; + } + let (cvd, pci) = match get(cfg, "CUDA_VISIBLE_DEVICES") { + "" => ( + env.getenv("CUDA_VISIBLE_DEVICES"), + env.getenv("CUDA_DEVICE_ORDER") == "PCI_BUS_ID", + ), + v => (v.to_string(), true), + }; + si.vram_mib = nvidia_vram_mib(&cvd, pci); +} + +/// `listEngineDevices`. +fn list_engine_devices(env: &Env, bin: &str) -> Vec { + let mut c = env.command(bin); + c.arg("--list-devices"); + match output_with_timeout(c, Duration::from_secs(20)) { + Some((true, out)) => parse_list_devices(&out), + _ => Vec::new(), + } +} + +/// `waitPortFree`. +pub fn wait_port_free(host: &str, port: &str, wait: Duration) -> Result<(), String> { + if port.is_empty() { + return Ok(()); + } + let mut h = host.trim_matches(['[', ']']).to_string(); + if h.is_empty() || h == "0.0.0.0" || h == "::" { + h = "127.0.0.1".into(); + } + let addr = if h.contains(':') { + format!("[{h}]:{port}") + } else { + format!("{h}:{port}") + }; + let deadline = Instant::now() + wait; + loop { + let reachable = addr + .to_socket_addrs() + .ok() + .and_then(|mut a| a.next()) + .is_some_and(|sa: SocketAddr| { + TcpStream::connect_timeout(&sa, Duration::from_millis(500)).is_ok() + }); + if !reachable { + return Ok(()); + } + if Instant::now() > deadline { + return Err(format!( + "le port {port} est déjà utilisé (sans doute un ancien llama-server encore actif) : « loki stop », docker restart loki, ou change PORT" + )); + } + std::thread::sleep(Duration::from_millis(500)); + } +} + +/// Erreur de `loki serve` et son code de sortie. +pub struct ServeError { + pub msg: String, + pub code: u8, +} + +impl From for ServeError { + fn from(msg: String) -> Self { + ServeError { msg, code: 1 } + } +} + +/// Accès à la base du Go pour ce lancement. +pub struct GoStore { + rt: tokio::runtime::Runtime, + store: Store, +} + +impl GoStore { + /// `LOKI_GO_ADDR` + `LOKI_INTERNAL_TOKEN`, posées par la façade pour le Go + /// et héritées par ses enfants. + pub fn from_env() -> Option { + let addr: SocketAddr = std::env::var("LOKI_GO_ADDR").ok()?.parse().ok()?; + let token = std::env::var(crate::auth::INTERNAL_ENV).ok()?; + if token.is_empty() { + return None; + } + let rt = tokio::runtime::Builder::new_current_thread() + .enable_all() + .build() + .ok()?; + Some(GoStore { + rt, + store: Store::new(addr, token), + }) + } + pub fn config(&self) -> Result { + self.rt + .block_on(self.store.all(BK_CONFIG)) + .map(|m| m.into_iter().collect()) + } + pub fn get(&self, bucket: &str, key: &str) -> Option> { + self.rt.block_on(self.store.get(bucket, key)).ok().flatten() + } + pub fn put(&self, bucket: &str, key: &str, v: Vec) { + let _ = self.rt.block_on(self.store.put(bucket, key, v)); + } + pub fn del(&self, bucket: &str, key: &str) { + let _ = self.rt.block_on(self.store.del(bucket, key)); + } +} + +/// Lit le jeton de tentative et les échecs inscrits. +fn read_spec_auto(st: &GoStore) -> (Option, HashMap) { + let attempt = st + .get(BK_STATE, SPEC_ATTEMPT_KEY) + .and_then(|b| serde_json::from_slice::(&b).ok()); + let failed = st + .get(BK_STATE, SPEC_FAILED_KEY) + .and_then(|b| serde_json::from_slice::>(&b).ok()) + .unwrap_or_default(); + (attempt, failed) +} + +/// `specAutoSettle`. +fn spec_auto_settle(st: &GoStore, cur: &SpecAutoMark, why: &str, record: bool, attempt: bool) { + let (_, mut failed) = read_spec_auto(st); + st.del(BK_STATE, SPEC_ATTEMPT_KEY); + if record { + if failed.len() >= SPEC_FAILED_MAX { + failed.clear(); + } + failed.insert(cur.key(), why.into()); + let sorted: BTreeMap<_, _> = failed.into_iter().collect(); + st.put( + BK_STATE, + SPEC_FAILED_KEY, + crate::gojson::marshal(&sorted).into_bytes(), + ); + } + if attempt { + st.put( + BK_STATE, + SPEC_ATTEMPT_KEY, + crate::gojson::marshal(cur).into_bytes(), + ); + } +} + +/// Ce que [`prepare`] rend : de quoi lancer le moteur. +pub struct Ready { + pub bin: String, + pub launch: Launch, + pub env: Env, + pub home: PathBuf, +} + +/// `cmdServe` jusqu'à la ligne de commande comprise (sans exec). `Ok(None)` : +/// rien à lancer (preset externe). +pub fn prepare(st: &GoStore, home: &Path) -> Result, ServeError> { + let cfg = st.config()?; + if is_external(&cfg) { + println!("[info] preset externe actif : pas de moteur local à lancer"); + return Ok(None); + } + let mut env = Env::default(); + let mut bin = get(&cfg, "BIN").to_string(); + if bin.is_empty() { + return Err("BIN non défini — lance « loki edit »".to_string().into()); + } + let model = get(&cfg, "MODEL").to_string(); + if model.is_empty() { + return Err("MODEL non défini — lance « loki edit »".to_string().into()); + } + let stored = + gguf::parse_stored_model_dirs(st.get(BK_STATE, gguf::STATE_MODEL_DIRS_KEY).as_deref()); + let env_dirs = std::env::var(gguf::MODEL_DIRS_ENV).ok(); + let resolve = |m: &str| gguf::resolve_serve_model_path(m, home, &stored, env_dirs.as_deref()); + let model = resolve(&model)?.to_string_lossy().into_owned(); + if std::fs::metadata(&model).is_err() { + return Err(format!("modèle introuvable : {model}").into()); + } + let mp = Path::new(&model); + let mdir = mp.parent().unwrap_or(Path::new(".")); + let mbase = mp + .file_name() + .map(|n| n.to_string_lossy().into_owned()) + .unwrap_or_default(); + let fam = gguf::shard_family(&mbase); + let missing: Vec = fam + .iter() + .filter(|n| !std::fs::metadata(mdir.join(n)).is_ok_and(|m| !m.is_dir())) + .cloned() + .collect(); + if !missing.is_empty() { + return Err(format!( + "modèle incomplet : il manque {} dans {} — ce modèle tient en {} fichiers, télécharge-les tous", + missing.join(", "), + mdir.display(), + fam.len() + ) + .into()); + } + if !crate::paths::go_is_abs(&bin) { + bin = home.join(&bin).to_string_lossy().into_owned(); + } + let ep = EnginePaths { + home: home.to_path_buf(), + }; + bin = ep.prebuilt_resolve_bin(&bin); + let bdir = Path::new(&bin) + .parent() + .map(|d| d.to_string_lossy().into_owned()) + .unwrap_or_default(); + let (lk, lv) = library_path_value(&env, &bdir); + env.set(&lk, &lv); + for (k, v) in cuda_device_env(&cfg) { + env.set(&k, &v); + } + + let mut si = SysInfo { + help: bin_help(&env, &bin), + model: model.clone(), + ..SysInfo::default() + }; + let mm = trim(get(&cfg, "MMPROJ")).to_string(); + if !mm.is_empty() { + let p = resolve(&mm) + .map_err(|e| format!("projecteur vision introuvable : {mm} ({e})"))? + .to_string_lossy() + .into_owned(); + if std::fs::metadata(&p).is_err() { + return Err(format!("projecteur vision introuvable : {p}").into()); + } + si.mmproj = p; + } + let key = st + .get(BK_STATE, "api_key") + .map(|b| String::from_utf8_lossy(&b).into_owned()) + .unwrap_or_default(); + si.api_key = if key.is_empty() { + trim(get(&cfg, "API_KEY")).to_string() + } else { + key + }; + if cfg!(target_os = "linux") && env.getenv("LLAMA_ARG_THREADS").is_empty() { + si.cpu = cpuset_threads(Path::new("/")); + } + + let extra = crate::models::split_args(get(&cfg, "EXTRA_ARGS")); + probe_serve_gpus(&env, &cfg, &extra, &mut si); + // probeExpertEnv + for k in EXPERT_ENV_KEYS { + let v = env.getenv(k); + if !v.is_empty() { + si.user_env.insert((*k).into(), v); + } + } + probe_env(&env, &mut si, FIT_ARG_ENV, false); + // probeLoadEnv + for k in LOAD_ARG_ENV { + let v = trim(&env.getenv(k)).to_string(); + if !v.is_empty() { + si.arg_env.insert((*k).into(), v); + } + } + probe_env(&env, &mut si, FIDELITY_ARG_ENV, false); + probe_cache_ram(&env, &cfg, &extra, &mut si); + probe_env(&env, &mut si, CKPT_ARG_ENV, true); + if side_slot_on(&cfg) { + probe_env(&env, &mut si, SIDE_SLOT_ARG_ENV, true); + } + if split_mode_key(&cfg) == "tensor" && help_supports_split_tensor(&si.help) { + probe_env(&env, &mut si, SPLIT_ARG_ENV, true); + let ar = env.getenv("GGML_CUDA_ALLREDUCE"); + if !ar.is_empty() { + si.user_env.insert("GGML_CUDA_ALLREDUCE".into(), ar); + } + si.split_devs = list_engine_devices(&env, &bin); + } + let (norm_extra, _) = normalize_load_flags(&extra, help_supports_load_mode(&si.help)); + if load_guard_needs_devices(&cfg, &norm_extra, &si) { + let devs = if si.split_devs.is_empty() { + list_engine_devices(&env, &bin) + } else { + si.split_devs.clone() + }; + si.engine_vram_mib = engine_devs_vram(&devs); + } + let spec = spec_mode(&cfg); + if spec != "off" && !spec.is_empty() { + probe_env(&env, &mut si, SPEC_ARG_ENV, true); + let d = trim(get(&cfg, "MODEL_DRAFT")).to_string(); + if !d.is_empty() { + match resolve(&d) + .map_err(|e| e.to_string()) + .and_then(|p| match std::fs::metadata(&p) { + Ok(_) => Ok(p), + Err(e) => Err(format!("stat {}: {e}", p.display())), + }) { + Ok(p) => { + si.draft = p.to_string_lossy().into_owned(); + si.draft_is_model = same_file(&si.draft, &si.model); + if let Ok(g) = gguf::gguf_meta(&p) { + si.draft_gguf = Some(g); + } + } + Err(e) => si.draft_err = e, + } + } + } + if gguf_hybrid(si.gguf.as_ref()) || spec == "auto" { + si.engine_build = engine_build_trusted(&env, &ep, &bin); + } + let mark = SpecAutoMark { + fp: crate::presets::config_fingerprint(&cfg), + bin: bin.clone(), + build: si.engine_build, + }; + let mut spec_record = false; + if spec == "auto" { + let (attempt, failed) = read_spec_auto(st); + let (why, rec) = spec_auto_verdict(&mark, attempt.as_ref(), &failed); + si.spec_auto_blocked = why; + spec_record = rec; + } + if slot_persist_on(&cfg) { + probe_env(&env, &mut si, SLOT_PERSIST_ARG_ENV, true); + } + if si.help.contains("--slot-save-path") && !has_any_flag(&extra, &["--slot-save-path"]) { + si.slot_dir = prepare_slot_dir(home, slot_persist_on(&cfg)); + } + + let launch = build_serve_args(&cfg, &extra, &bin, &si); + for (k, v) in &launch.env { + env.set(k, v); + } + let mut persist_key = String::new(); + let (persist_ok, _) = slot_persist_plan(&cfg, &extra, &si); + if persist_ok { + persist_key = slot_persist_key(&launch.args, &launch.env, &env.all(), &si, &bin); + slot_persist_prune(Path::new(&si.slot_dir)); + } else if !slot_persist_on(&cfg) { + slot_persist_purge(&home.join("slots")); + } + for n in &launch.notes { + eprintln!("[loki serve] {n}"); + } + let (_, refuse) = load_mode_risk(&cfg, &launch.args, &si); + if !refuse.is_empty() { + return Err(ServeError { + msg: refuse, + code: LOAD_GUARD_EXIT_STATUS as u8, + }); + } + let host = arg_value(&launch.args, "--host"); + let port = arg_value(&launch.args, "--port"); + wait_port_free(&host, &port, Duration::from_secs(5))?; + let (kt, vt, _) = effective_kv_types(&cfg, &extra, &si.arg_env); + if slow_kv(&kt, &vt) { + eprintln!( + "[loki serve] avertissement : cache KV {kt}/{vt} non accéléré par llama.cpp CUDA (converti en f16 à chaque pas, lent). Préfère q8_0/q8_0, q4_0/q4_0 ou f16." + ); + } + if spec == "auto" { + let auto = spec_args(&cfg, &extra, &si).auto; + spec_auto_settle(st, &mark, &si.spec_auto_blocked, spec_record, auto); + } + write_slot_persist_marker(home, &persist_key); + eprintln!( + "[loki serve] {bin} model={} port={port}", + crate::paths::base_name(&model) + ); + st.put( + BK_STATE, + "engine_cmdline", + engine_cmdline(&launch.env, &launch.args).into_bytes(), + ); + Ok(Some(Ready { + bin, + launch, + env, + home: home.to_path_buf(), + })) +} + +fn same_file(a: &str, b: &str) -> bool { + #[cfg(unix)] + { + use std::os::unix::fs::MetadataExt; + match (std::fs::metadata(a), std::fs::metadata(b)) { + (Ok(x), Ok(y)) => x.dev() == y.dev() && x.ino() == y.ino(), + _ => false, + } + } + #[cfg(not(unix))] + { + same_path(a, b) + } +} + +/// Lance `loki serve` (Go) à notre place : Strata, essai de l'optimiseur, +/// pas d'API interne. +fn delegate_to_go(go_bin: &Path) -> ExitCode { + #[cfg(unix)] + { + use std::os::unix::process::CommandExt; + let err = Command::new(go_bin).arg("serve").exec(); + eprintln!("[err] exec {} serve : {err}", go_bin.display()); + ExitCode::FAILURE + } + #[cfg(not(unix))] + { + match Command::new(go_bin).arg("serve").status() { + Ok(s) if s.success() => ExitCode::SUCCESS, + _ => ExitCode::FAILURE, + } + } +} + +/// `loki-rs engine serve`. +pub fn run(go_bin: &Path, home: &Path) -> ExitCode { + if std::env::var("LOKI_TUNE_TRIAL").is_ok_and(|v| !v.is_empty()) { + return delegate_to_go(go_bin); + } + let Some(st) = GoStore::from_env() else { + return delegate_to_go(go_bin); + }; + match st.config() { + Ok(cfg) if is_strata(&cfg) => return delegate_to_go(go_bin), + Ok(_) => {} + Err(_) => return delegate_to_go(go_bin), + } + let ready = match prepare(&st, home) { + Ok(Some(r)) => r, + Ok(None) => return ExitCode::SUCCESS, + Err(e) => { + eprintln!("[err] {}", e.msg); + return ExitCode::from(e.code); + } + }; + let _ = std::env::set_current_dir(&ready.home); + exec_engine(&ready) +} + +fn exec_engine(r: &Ready) -> ExitCode { + let args = &r.launch.args; + let mut c = Command::new(&r.bin); + c.args(&args[1..]).env_clear().envs(r.env.all()); + #[cfg(unix)] + { + use std::os::unix::process::CommandExt; + c.arg0(&args[0]); + let err = c.exec(); + eprintln!("[err] exec {} : {err}", r.bin); + ExitCode::FAILURE + } + #[cfg(not(unix))] + { + match c.status() { + Ok(s) if s.success() => ExitCode::SUCCESS, + _ => ExitCode::FAILURE, + } + } +} diff --git a/rust/loki-rs/src/serve/side.rs b/rust/loki-rs/src/serve/side.rs new file mode 100644 index 0000000..2791bfb --- /dev/null +++ b/rust/loki-rs/src/serve/side.rs @@ -0,0 +1,152 @@ +//! Second slot pour les travaux annexes (`backend_serve_side.go`, +//! `SIDE_SLOT`, off par défaut) : deux flux KV séparés de CTX jetons +//! (`-c 2×CTX --parallel 2 --no-kv-unified`), refusé quand le compte de VRAM +//! ne tient pas ou que l'utilisateur a réglé l'un des leviers. + +use super::{ + Cfg, SysInfo, atoi, cpu_weights, effective_kv_types, flag_value, get, gguf_state_bytes, + has_any_flag, is_external, trim, +}; + +/// `sideSlotOn`. +pub fn side_slot_on(cfg: &Cfg) -> bool { + matches!( + trim(get(cfg, "SIDE_SLOT")).to_lowercase().as_str(), + "on" | "1" | "true" | "yes" | "oui" + ) +} + +/// `sideSlotConflictFlags`. +pub const SIDE_SLOT_CONFLICT_FLAGS: &[&str] = &[ + "-c", + "--ctx-size", + "-np", + "--parallel", + "-kvu", + "--kv-unified", + "-no-kvu", + "--no-kv-unified", + "--kv-unified-per-slot", + "--cache-idle-slots", + "--no-cache-idle-slots", +]; + +/// `sideSlotArgEnv`. +pub const SIDE_SLOT_ARG_ENV: &[&str] = &[ + "LLAMA_ARG_N_PARALLEL", + "LLAMA_ARG_KV_UNIFIED", + "LLAMA_ARG_KV_UNIFIED_PER_SLOT", + "LLAMA_ARG_CACHE_IDLE_SLOTS", + "LLAMA_ARG_CTX_SIZE", +]; + +/// `sideSlotPlan` : (jetons par slot, Mio de VRAM en plus, refus). +pub fn side_slot_plan(cfg: &Cfg, extra: &[String], si: &SysInfo) -> (i64, i64, String) { + if !side_slot_on(cfg) { + return (0, 0, String::new()); + } + if is_external(cfg) { + return (0, 0, "preset externe".into()); + } + let p = trim(get(cfg, "PARALLEL")); + if !p.is_empty() && p != "2" { + return ( + 0, + 0, + format!("PARALLEL={p} (SIDE_SLOT ouvre exactement 2 slots)"), + ); + } + for f in SIDE_SLOT_CONFLICT_FLAGS { + if has_any_flag(extra, &[f]) { + return (0, 0, format!("{f} est réglé dans EXTRA_ARGS")); + } + } + for k in SIDE_SLOT_ARG_ENV { + if !si.env(k).is_empty() { + return (0, 0, format!("{k} est posée")); + } + } + if !si.help.contains("--no-kv-unified") { + return (0, 0, "ce moteur ne connaît pas --no-kv-unified".into()); + } + let raw = trim(get(cfg, "CTX")); + let n = if raw.is_empty() { + Some(32768) + } else { + atoi(raw) + }; + let n = match n { + Some(n) if n > 0 => n, + _ => return (0, 0, "CTX doit être un nombre de jetons explicite".into()), + }; + let (extra_mib, why) = side_slot_vram(cfg, extra, si, n); + if !why.is_empty() { + return (0, 0, why); + } + (n, extra_mib, String::new()) +} + +/// `sideSlotVRAM`. +pub fn side_slot_vram(cfg: &Cfg, extra: &[String], si: &SysInfo, ctx: i64) -> (i64, String) { + if si.vram_mib <= 0 { + return (0, "VRAM NVIDIA inconnue (macOS, AMD, --device…)".into()); + } + let Some(g) = si.gguf.as_ref() else { + return (0, "métadonnées GGUF illisibles".into()); + }; + if si.model_bytes <= 0 { + return (0, "taille du modèle inconnue".into()); + } + let r = cpu_weights(cfg, extra, &si.arg_env, g.block_count); + if !r.is_empty() { + return (0, r); + } + let (kt, vt, _) = effective_kv_types(cfg, extra, &si.arg_env); + let (per_tok, rec) = gguf_state_bytes(g, &kt, &vt); + if per_tok <= 0.0 { + return (0, "taille d'état inconnue".into()); + } + let state = per_tok * ctx as f64 + rec as f64; + let mib = 1048576.0; + if si.model_bytes as f64 + 2.0 * state > 0.9 * si.vram_mib as f64 * mib { + return ( + 0, + format!( + "VRAM insuffisante : modèle + 2 × {} Mio d'état dépassent 90 % de {} Mio", + (state / mib).ceil() as i64, + si.vram_mib + ), + ); + } + ((state / mib).ceil() as i64, String::new()) +} + +/// `sideSlotNote`. +pub fn side_slot_note(ctx: i64, extra_mib: i64, why: &str, ngl_forced: bool) -> String { + if ctx <= 0 { + if why.is_empty() { + return String::new(); + } + return format!("SIDE_SLOT refusé ({why}) : un seul slot, comme sans la clé"); + } + let n = format!( + "SIDE_SLOT : 2 slots de {ctx} jetons (-c {}, --no-kv-unified), ~{extra_mib} Mio de VRAM en plus pour le second (cache KV et état récurrent ; le brouillon MTP éventuel et les points de reprise en RAM hôte doublent aussi)", + 2 * ctx + ); + if ngl_forced { + return n + " ; NGL est imposé : surveille la VRAM au premier long prompt"; + } + n + " ; le placement automatique sortira des couches du GPU s'il manque de place" +} + +/// `nglForced` : le preset impose-t-il un nombre de couches GPU ? +pub fn ngl_forced(cfg: &Cfg, extra: &[String]) -> bool { + let mut v = flag_value(extra, &["-ngl", "--n-gpu-layers", "--gpu-layers"]); + if v.is_empty() { + v = trim(get(cfg, "NGL")).to_string(); + if v == "999" { + return false; + } + } + !v.is_empty() && !v.eq_ignore_ascii_case("auto") +} diff --git a/rust/loki-rs/src/serve/spec.rs b/rust/loki-rs/src/serve/spec.rs new file mode 100644 index 0000000..3d33db7 --- /dev/null +++ b/rust/loki-rs/src/serve/spec.rs @@ -0,0 +1,568 @@ +//! Décodage spéculatif géré par Loki (`backend_serve_spec.go`) : tête MTP, +//! modèle brouillon, n-grammes du contexte. Rien n'y touche au modèle : +//! chaque jeton émis est vérifié par le modèle cible. + +use super::{ + Cfg, SysInfo, atoi, cpu_weights, fit_blocker, flag_value, flag_values, get, has_any_flag, + has_flag_prefix, ngl_forced, op_offload_min_batch_env, trim, +}; +use std::collections::HashMap; + +pub const SPEC_AUTO_MIN_BUILD: i64 = 11009; +pub const SPEC_N_MAX_LIMIT: i64 = 64; +pub const SPEC_NGRAM_N_MATCH: i64 = 24; +pub const SPEC_NGRAM_N_MIN: i64 = 48; +pub const SPEC_NGRAM_N_MAX: i64 = 64; +pub const GGML_OFFLOAD_MIN_BATCH_DEFAULT: i64 = 32; + +/// `specArgEnv`. +pub const SPEC_ARG_ENV: &[&str] = &[ + "LLAMA_ARG_SPEC_TYPE", + "LLAMA_ARG_SPEC_DRAFT_MODEL", + "LLAMA_ARG_SPEC_DRAFT_HF_REPO", + "LLAMA_ARG_SPEC_DRAFT_N_MAX", + "LLAMA_ARG_SPEC_DRAFT_SAMPLING", +]; + +const SPEC_USER_FLAGS: &[&str] = &[ + "--spec-type", + "--spec_type", + "-md", + "--model-draft", + "--spec-draft-model", + "-hfd", + "-hfrd", + "--spec-draft-hf", + "--hf-repo-draft", + "--spec-default", +]; +const SPEC_USER_PREFIXES: &[&str] = &["--spec-ngram-", "--spec_ngram_"]; + +/// `specSidecarArch`. +pub fn spec_sidecar_arch(arch: &str) -> bool { + matches!(trim(arch).to_lowercase().as_str(), "eagle3" | "dflash") +} + +/// `specMode` : "off", "auto", "mtp", "ngram", "mtp+ngram", ou "" illisible. +pub fn spec_mode(cfg: &Cfg) -> &'static str { + match trim(get(cfg, "SPEC")).to_lowercase().as_str() { + "" | "off" | "non" | "no" | "0" => "off", + "auto" => "auto", + "mtp" => "mtp", + "ngram" => "ngram", + "mtp+ngram" | "ngram+mtp" => "mtp+ngram", + _ => "", + } +} + +/// `specUserSet`. +pub fn spec_user_set(extra: &[String], arg_env: &HashMap) -> bool { + if has_any_flag(extra, SPEC_USER_FLAGS) + || !has_flag_prefix(extra, SPEC_USER_PREFIXES).is_empty() + { + return true; + } + [ + "LLAMA_ARG_SPEC_TYPE", + "LLAMA_ARG_SPEC_DRAFT_MODEL", + "LLAMA_ARG_SPEC_DRAFT_HF_REPO", + ] + .iter() + .any(|k| arg_env.get(*k).is_some_and(|v| !v.is_empty())) +} + +/// `helpSupportsMTP`. +pub fn help_supports_mtp(help: &str) -> bool { + help.contains("draft-mtp") && help.contains("--spec-draft-n-max") +} + +/// `ctxFixed` : (contexte vu par le moteur, chiffré > 0 ?). +pub fn ctx_fixed(cfg: &Cfg, extra: &[String]) -> (String, bool) { + let mut c = flag_value(extra, &["-c", "--ctx-size"]); + if c.is_empty() { + c = trim(get(cfg, "CTX")).to_string(); + } + if c.is_empty() { + c = "32768".into(); + } + let ok = atoi(&c).is_some_and(|n| n > 0); + (c, ok) +} + +/// `statefulSampler`. +pub fn stateful_sampler(extra: &[String]) -> String { + for v in flag_values(extra, &["--mirostat"]) { + let v = trim(&v); + if !v.is_empty() && v != "0" { + return format!("--mirostat {v}"); + } + } + for v in flag_values(extra, &["--samplers"]) { + if v.replace('-', "_").to_lowercase().contains("adaptive_p") { + return "--samplers adaptive-p".into(); + } + } + for v in flag_values(extra, &["--sampling-seq", "--sampler-seq"]) { + if v.contains('a') { + return "--sampling-seq avec adaptive-p".into(); + } + } + String::new() +} + +/// `specAutoBlocker`. +pub fn spec_auto_blocker(cfg: &Cfg, extra: &[String], si: &SysInfo) -> String { + if si + .gguf + .as_ref() + .is_some_and(|g| g.arch.eq_ignore_ascii_case("qwen4exp")) + { + return "qwen4exp : MTP trop récent pour ce moteur, SPEC=mtp pour l'imposer".into(); + } + let why = fit_blocker(cfg, extra, si); + if !why.is_empty() { + return format!("placement manuel ({why}) — --fit ne compte pas la VRAM du brouillon"); + } + if has_any_flag(extra, &["-dev", "--device"]) || !si.env("LLAMA_ARG_DEVICE").is_empty() { + return "placement manuel (--device) — --fit ne compte pas la VRAM du brouillon".into(); + } + if !ctx_fixed(cfg, extra).1 { + return "contexte non chiffré — fit pourrait le réduire pour loger le brouillon".into(); + } + if !si.mmproj.is_empty() || has_any_flag(extra, &["--mmproj", "-mm"]) { + return "projecteur vision chargé".into(); + } + if si.engine_build < SPEC_AUTO_MIN_BUILD { + if si.engine_build <= 0 { + return "build du moteur inconnu (compilé ou fork)".into(); + } + return format!( + "moteur b{}, b{SPEC_AUTO_MIN_BUILD} au moins", + si.engine_build + ); + } + si.spec_auto_blocked.clone() +} + +/// Drapeaux, notes, et « vient de SPEC=auto ». +#[derive(Clone, Debug, Default)] +pub struct SpecOut { + pub args: Vec, + pub notes: Vec, + pub auto: bool, +} + +fn base_name(p: &str) -> String { + crate::paths::base_name(p) +} + +/// `specArgs`. +pub fn spec_args(cfg: &Cfg, extra: &[String], si: &SysInfo) -> SpecOut { + if si.draft_is_model && !trim(get(cfg, "MODEL_DRAFT")).is_empty() { + let mut c = cfg.clone(); + c.remove("MODEL_DRAFT"); + let mut out = spec_args(&c, extra, si); + out.notes.insert(0, "MODEL_DRAFT est le modèle principal lui-même : ignoré — la tête MTP intégrée sert de brouillon sans recharger les poids (retire MODEL_DRAFT du preset)".into()); + return out; + } + let mode = spec_mode(cfg); + let draft_key = trim(get(cfg, "MODEL_DRAFT")); + match mode { + "" => { + return SpecOut { + notes: vec![format!( + "SPEC={} illisible (off, auto, mtp, ngram ou mtp+ngram) : sans décodage spéculatif", + get(cfg, "SPEC") + )], + ..SpecOut::default() + }; + } + "off" => { + let mut out = SpecOut::default(); + if !draft_key.is_empty() { + out.notes + .push("MODEL_DRAFT ignoré : SPEC=off (auto ou mtp pour s'en servir)".into()); + } + return out; + } + _ => {} + } + if spec_user_set(extra, &si.arg_env) { + return SpecOut { + notes: vec![ + "décodage spéculatif déjà réglé dans EXTRA_ARGS ou LLAMA_ARG_* : SPEC ignoré" + .into(), + ], + ..SpecOut::default() + }; + } + let skip = |why: &str| SpecOut { + notes: vec![format!("SPEC={mode} : {why} — sans décodage spéculatif")], + ..SpecOut::default() + }; + + if mode == "ngram" || mode == "mtp+ngram" { + let why = ngram_blocker(cfg, extra, si, mode); + if !why.is_empty() { + return skip(&why); + } + } + if mode == "ngram" { + let (args, notes) = ngram_spec_args(cfg, extra, si, ""); + return SpecOut { + args, + notes, + auto: false, + }; + } + + let (src, mtp, why, quiet) = spec_source(mode, draft_key, si); + if mode == "mtp+ngram" && !mtp { + let why = if why.is_empty() { + "MODEL_DRAFT n'est pas une tête MTP".to_string() + } else { + why + }; + let (args, notes) = ngram_spec_args( + cfg, + extra, + si, + &format!("SPEC=mtp+ngram : sans MTP ({why}) → n-grammes seuls. "), + ); + return SpecOut { + args, + notes, + auto: false, + }; + } + if !why.is_empty() { + return skip(&why); + } + if quiet { + return SpecOut::default(); + } + let mut args = src; + if mode == "mtp+ngram" { + if let Some(last) = args.last_mut() { + *last = "draft-mtp,ngram-mod".into(); + } + args.extend(ngram_mod_params()); + } + let mut notes: Vec = Vec::new(); + let mut auto = false; + let label = if mtp { + "tête MTP".to_string() + } else { + format!("brouillon {}", base_name(&si.draft)) + }; + if mode == "auto" { + let why = spec_auto_blocker(cfg, extra, si); + if !why.is_empty() { + return skip(&why); + } + auto = true; + notes.push(format!("SPEC=auto → {label} : sortie inchangée (chaque jeton est vérifié par le modèle), ~1-2 Go de VRAM en plus. Mesure le prefill ; SPEC=off pour couper.")); + } else { + let mut note = + format!("SPEC={mode} → {label} imposé : sortie inchangée, ~1-2 Go de VRAM en plus"); + if mode == "mtp+ngram" { + note.push_str( + " ; n-grammes en plus (ngram-mod, prioritaires quand ils trouvent une répétition)", + ); + } + let mut why = fit_blocker(cfg, extra, si); + if !why.is_empty() || has_any_flag(extra, &["-dev", "--device"]) { + if why.is_empty() { + why = "--device".into(); + } + note.push_str(&format!(" ; placement manuel ({why}) : --fit ne compte pas le brouillon, place-le avec -devd / -ngld si la VRAM manque")); + } + if !ctx_fixed(cfg, extra).1 { + note.push_str( + " ; contexte non chiffré : fit pourrait le réduire pour loger le brouillon", + ); + } + if si + .gguf + .as_ref() + .is_some_and(|g| g.arch.eq_ignore_ascii_case("qwen4exp")) + { + note.push_str(" ; MTP qwen4exp très récent"); + } + notes.push(note); + if mode == "mtp+ngram" { + notes.extend(ngram_cost_notes(cfg, extra, si)); + } + } + + let v = trim(get(cfg, "SPEC_N_MAX")); + if !v.is_empty() { + match atoi(v) { + Some(n) if (1..=SPEC_N_MAX_LIMIT).contains(&n) => { + if !si.help.contains("--spec-draft-n-max") { + notes.push( + "ce moteur ne connaît pas --spec-draft-n-max : SPEC_N_MAX ignoré".into(), + ); + } else if has_any_flag(extra, &["--spec-draft-n-max"]) + || !si.env("LLAMA_ARG_SPEC_DRAFT_N_MAX").is_empty() + { + } else { + args.push("--spec-draft-n-max".into()); + args.push(n.to_string()); + } + } + _ => notes.push(format!( + "SPEC_N_MAX={v} illisible (1 à 64 jetons) : défaut du moteur" + )), + } + } + + let mut sampling = "greedy".to_string(); + let sv = trim(get(cfg, "SPEC_SAMPLING")).to_lowercase(); + match sv.as_str() { + "" | "greedy" => {} + "probabilistic" => { + let st = stateful_sampler(extra); + if mode == "auto" { + notes.push("SPEC_SAMPLING=probabilistic ignoré avec SPEC=auto (SPEC=mtp pour le choisir) : greedy".into()); + } else if !st.is_empty() { + notes.push(format!("SPEC_SAMPLING=probabilistic refusé avec {st} (cet échantillonneur avancerait sur des jetons rejetés) : greedy")); + } else if !si.help.contains("probabilistic") { + notes.push( + "ce moteur ne connaît pas --spec-draft-sampling probabilistic : greedy".into(), + ); + } else { + sampling = sv.clone(); + } + } + _ => notes.push(format!( + "SPEC_SAMPLING={sv} illisible (greedy ou probabilistic) : greedy" + )), + } + if si.help.contains("--spec-draft-sampling") + && !has_any_flag(extra, &["--spec-draft-sampling"]) + && si.env("LLAMA_ARG_SPEC_DRAFT_SAMPLING").is_empty() + { + args.push("--spec-draft-sampling".into()); + args.push(sampling); + } + SpecOut { args, notes, auto } +} + +/// `specSource` : (drapeaux, tête MTP ?, pourquoi aucun, rien à dire). +pub fn spec_source(mode: &str, draft_key: &str, si: &SysInfo) -> (Vec, bool, String, bool) { + let none = |w: String| (Vec::new(), false, w, false); + if !draft_key.is_empty() { + if si.draft.is_empty() { + let mut why = format!("MODEL_DRAFT={draft_key} introuvable"); + if !si.draft_err.is_empty() { + why.push_str(&format!(" ({})", si.draft_err)); + } + return none(why); + } + let Some(dg) = si.draft_gguf.as_ref() else { + return none( + "MODEL_DRAFT illisible (GGUF incomplet ou en cours de téléchargement ?)".into(), + ); + }; + if dg.has_next_n_tensor { + if !help_supports_mtp(&si.help) { + return none("ce moteur ne connaît pas draft-mtp".into()); + } + return ( + vec![ + "-md".into(), + si.draft.clone(), + "--spec-type".into(), + "draft-mtp".into(), + ], + true, + String::new(), + false, + ); + } + if spec_sidecar_arch(&dg.arch) { + return none(format!( + "MODEL_DRAFT est une tête {}, pas un modèle brouillon : règle -md et --spec-type dans EXTRA_ARGS", + dg.arch + )); + } + if mode == "mtp+ngram" { + return none(String::new()); + } + if !si.help.contains("--spec-type") || !si.help.contains("draft-simple") { + return none("ce moteur ne connaît pas --spec-type draft-simple".into()); + } + return ( + vec![ + "-md".into(), + si.draft.clone(), + "--spec-type".into(), + "draft-simple".into(), + ], + false, + String::new(), + false, + ); + } + let Some(g) = si.gguf.as_ref() else { + return none("métadonnées du modèle illisibles".into()); + }; + if !g.has_next_n_tensor { + if mode != "auto" || g.next_n > 0 { + return none( + "pas de tête MTP dans ce fichier (publiée à part ? MODEL_DRAFT=mtp-….gguf)".into(), + ); + } + return (Vec::new(), false, String::new(), true); + } + if !help_supports_mtp(&si.help) { + return none("ce moteur ne connaît pas draft-mtp".into()); + } + ( + vec!["--spec-type".into(), "draft-mtp".into()], + true, + String::new(), + false, + ) +} + +/// `ngramModParams`. +pub fn ngram_mod_params() -> Vec { + vec![ + "--spec-ngram-mod-n-match".into(), + SPEC_NGRAM_N_MATCH.to_string(), + "--spec-ngram-mod-n-min".into(), + SPEC_NGRAM_N_MIN.to_string(), + "--spec-ngram-mod-n-max".into(), + SPEC_NGRAM_N_MAX.to_string(), + ] +} + +/// `ngramBlocker`. +pub fn ngram_blocker(cfg: &Cfg, extra: &[String], si: &SysInfo, mode: &str) -> String { + if !si.help.contains("--spec-ngram-mod-n-match") { + return "ce moteur ne connaît pas --spec-ngram-mod-n-match".into(); + } + if mode == "ngram" { + let f = has_flag_prefix(extra, &["--spec-draft-", "--spec_draft_"]); + if !f.is_empty() { + return format!("{f} dans EXTRA_ARGS (brouillon réglé à la main)"); + } + } + let r = cpu_weights(cfg, extra, &si.arg_env, si.blocks()); + if !r.is_empty() { + let thr = op_offload_threshold(cfg, si); + if SPEC_NGRAM_N_MAX + 1 >= thr { + return format!( + "{r} : un lot de vérification de {} jetons atteint GGML_OP_OFFLOAD_MIN_BATCH ({thr}) et recopierait ces poids vers le GPU à chaque brouillon — OP_OFFLOAD_MIN_BATCH=128 pour l'essayer, à mesurer", + SPEC_NGRAM_N_MAX + 1 + ); + } + } + String::new() +} + +/// `opOffloadThreshold`. +pub fn op_offload_threshold(cfg: &Cfg, si: &SysInfo) -> i64 { + let mut v = trim(si.uenv("GGML_OP_OFFLOAD_MIN_BATCH")).to_string(); + if v.is_empty() { + v = op_offload_min_batch_env(cfg, si).0; + } + match atoi(&v) { + Some(n) if n > 0 => n, + _ => GGML_OFFLOAD_MIN_BATCH_DEFAULT, + } +} + +/// `ngramSpecArgs`. +pub fn ngram_spec_args( + cfg: &Cfg, + extra: &[String], + si: &SysInfo, + lead: &str, +) -> (Vec, Vec) { + let mut args = vec!["--spec-type".to_string(), "ngram-mod".to_string()]; + args.extend(ngram_mod_params()); + let mut notes = vec![format!( + "{lead}SPEC=ngram → n-grammes du contexte (ngram-mod, brouillons de {SPEC_NGRAM_N_MIN} à {SPEC_NGRAM_N_MAX} jetons) : distribution inchangée (chaque jeton vérifié), ~16 Mo de RAM ; utile surtout en mode code, à mesurer (acceptation par nature dans /api/perf/summary)" + )]; + if lead.is_empty() { + if !trim(get(cfg, "MODEL_DRAFT")).is_empty() { + notes.push("MODEL_DRAFT ignoré avec SPEC=ngram : SPEC=mtp+ngram pour les deux".into()); + } else if si.gguf.as_ref().is_some_and(|g| g.has_next_n_tensor) { + notes.push( + "tête MTP du modèle inutilisée avec SPEC=ngram : SPEC=mtp+ngram pour les deux" + .into(), + ); + } + } + if !trim(get(cfg, "SPEC_N_MAX")).is_empty() || !trim(get(cfg, "SPEC_SAMPLING")).is_empty() { + notes.push( + "SPEC_N_MAX et SPEC_SAMPLING ignorés : les n-grammes n'ont pas de brouillon à régler" + .into(), + ); + } + notes.extend(ngram_cost_notes(cfg, extra, si)); + (args, notes) +} + +/// `ngramCostNotes`. +pub fn ngram_cost_notes(cfg: &Cfg, extra: &[String], si: &SysInfo) -> Vec { + let mut notes = Vec::new(); + if si.gguf.as_ref().is_some_and(|g| g.hybrid) { + notes.push("modèle hybride : chaque brouillon n-gramme copie l'état récurrent (point de reprise en RAM hôte) et le recharge s'il est rejeté — compare le temps par tour, pas seulement le débit".into()); + } + if !cpu_weights(cfg, extra, &si.arg_env, si.blocks()).is_empty() + && si.model_bytes > 0 + && si.ram_mib > 0 + && si.model_bytes > si.ram_mib << 20 + { + notes.push("modèle plus gros que la RAM, poids sur CPU : un lot de vérification lit la plupart des experts de chaque couche — surveille les lectures disque, pas seulement les jetons/s".into()); + } + if ngl_forced(cfg, extra) { + notes.push(format!("NGL imposé (--fit inactif) : les logits de {} positions par slot prennent quelques dizaines de Mio de VRAM en plus", SPEC_NGRAM_N_MAX + 1)); + } + notes +} + +// --- Jeton de tentative ------------------------------------------------------ + +pub const SPEC_ATTEMPT_KEY: &str = "spec_auto_attempt"; +pub const SPEC_FAILED_KEY: &str = "spec_auto_failed"; +pub const SPEC_FAILED_MAX: usize = 32; + +/// `specAutoMark` : une combinaison preset × moteur. +#[derive(Clone, Debug, Default, PartialEq, Eq, serde::Serialize, serde::Deserialize)] +pub struct SpecAutoMark { + #[serde(default)] + pub fp: String, + #[serde(default)] + pub bin: String, + #[serde(default)] + pub build: i64, +} + +impl SpecAutoMark { + pub fn key(&self) -> String { + format!("{}|{}|{}", self.fp, self.bin, self.build) + } +} + +/// `specAutoVerdict` : (raison de couper l'auto, inscrire l'échec ?). +pub fn spec_auto_verdict( + cur: &SpecAutoMark, + attempt: Option<&SpecAutoMark>, + failed: &HashMap, +) -> (String, bool) { + if let Some(w) = failed.get(&cur.key()).filter(|w| !w.is_empty()) { + return (w.clone(), false); + } + if attempt.is_some_and(|a| a.key() == cur.key()) { + return ( + "le dernier lancement avec cette configuration n'a jamais répondu".into(), + true, + ); + } + (String::new(), false) +} diff --git a/rust/loki-rs/src/serve/split.rs b/rust/loki-rs/src/serve/split.rs new file mode 100644 index 0000000..c1e3004 --- /dev/null +++ b/rust/loki-rs/src/serve/split.rs @@ -0,0 +1,382 @@ +//! Parallélisme de tenseurs (`backend_serve_split.go`, `SPLIT_MODE=tensor`, +//! expérimental) : `-sm tensor` entre cartes CUDA, avec une longue liste de +//! refus propres et `GGML_CUDA_ALLREDUCE=internal` (sommes dans le type +//! natif, sans la compression BF16 de NCCL). + +use std::collections::BTreeMap; + +use super::{ + Cfg, SplitDev, SysInfo, atoi, ctx_fixed, effective_kv_types, env_truthy, flag_value, get, + gguf_state_bytes, has_any_flag, is_external, kv_offloaded, side_slot_on, spec_mode, + spec_user_set, tensor_override, trim, +}; + +/// `splitTensorDenyArch`. +pub fn split_tensor_deny_arch(arch: &str) -> bool { + matches!( + arch, + "grok" + | "mpt" + | "plamo2" + | "minicpm3" + | "gemma3n" + | "mamba" + | "mamba2" + | "jamba" + | "falcon-h1" + | "olmo2" + | "olmoe" + | "deepseek2" + | "deepseek32" + | "hy_v4" + | "dots3note" + | "glm-dsa" + | "bitnet" + | "t5" + | "nemotron_h" + | "nemotron_h_moe" + | "granitehybrid" + | "minimax-01" + | "minimax-m2" + | "minimax-m3" + | "mistral4" + | "kimi-linear" + | "bailingmoe3" + | "kimi-k3" + | "glm5-next" + | "qwen3tts" + | "qwen4exp" + ) +} + +/// `splitArgEnv`. +pub const SPLIT_ARG_ENV: &[&str] = &[ + "LLAMA_ARG_FLASH_ATTN", + "LLAMA_ARG_BACKEND_SAMPLING", + "LLAMA_ARG_TENSOR_SPLIT", +]; + +/// `splitModeKey` : "" (rien à poser), "tensor", ou "?" si illisible. +pub fn split_mode_key(cfg: &Cfg) -> &'static str { + match trim(get(cfg, "SPLIT_MODE")).to_lowercase().as_str() { + "" | "off" | "layer" | "non" | "no" | "0" => "", + "tensor" => "tensor", + _ => "?", + } +} + +/// `helpSupportsSplitTensor`. +pub fn help_supports_split_tensor(help: &str) -> bool { + help.contains("{none,layer,row,tensor}") +} + +/// Décision de SPLIT_MODE=tensor (`splitPlan`). +#[derive(Clone, Debug, Default)] +pub struct SplitPlan { + pub on: bool, + pub why: String, + pub devs: Vec, + pub need_mib: i64, +} + +fn refuse(why: impl Into) -> SplitPlan { + SplitPlan { + why: why.into(), + ..SplitPlan::default() + } +} + +fn split_kv_ok(t: &str) -> bool { + matches!(trim(t).to_lowercase().as_str(), "" | "f16" | "bf16" | "f32") +} + +fn kv_name(t: &str) -> &str { + if t.is_empty() { "f16" } else { t } +} + +/// `splitPlan`. +pub fn split_plan(cfg: &Cfg, extra: &[String], si: &SysInfo) -> SplitPlan { + match split_mode_key(cfg) { + "" => return SplitPlan::default(), + "?" => { + return refuse(format!( + "SPLIT_MODE={} illisible (tensor ou off)", + trim(get(cfg, "SPLIT_MODE")) + )); + } + _ => {} + } + if is_external(cfg) { + return refuse("preset externe"); + } + if !help_supports_split_tensor(&si.help) { + return refuse("ce moteur ne propose pas -sm tensor"); + } + let sm = flag_value(extra, &["-sm", "--split-mode"]); + if !sm.is_empty() { + return refuse(format!("-sm {sm} déjà dans EXTRA_ARGS")); + } + if !si.env("LLAMA_ARG_SPLIT_MODE").is_empty() { + return refuse("LLAMA_ARG_SPLIT_MODE déjà posée"); + } + let mut fa = flag_value(extra, &["-fa", "--flash-attn"]); + if fa.is_empty() { + fa = si.env("LLAMA_ARG_FLASH_ATTN").into(); + } + if matches!( + trim(&fa).to_lowercase().as_str(), + "off" | "disabled" | "false" | "0" + ) { + return refuse(format!( + "Flash Attention coupée (-fa {fa}) : le mode tensor l'exige" + )); + } + if has_any_flag(extra, &["-bs", "--backend-sampling"]) + || env_truthy(si.env("LLAMA_ARG_BACKEND_SAMPLING")) + { + return refuse( + "échantillonnage sur GPU (--backend-sampling) non pris en charge en mode tensor", + ); + } + let (k, v, _) = effective_kv_types(cfg, extra, &si.arg_env); + if !split_kv_ok(&k) || !split_kv_ok(&v) { + return refuse(format!( + "cache KV {}/{} : le mode tensor n'accepte que f16, bf16 ou f32 (jamais réécrit d'office : la VRAM changerait)", + kv_name(&k), + kv_name(&v) + )); + } + let r = tensor_override(extra, &si.arg_env); + if !r.is_empty() { + return refuse(format!("{r} : le mode tensor met tout sur les cartes")); + } + if !kv_offloaded(extra, &si.arg_env) { + return refuse("cache KV sur CPU"); + } + let g = match si.gguf.as_ref() { + Some(g) if si.model_bytes > 0 => g, + _ => return refuse("métadonnées ou taille du modèle illisibles"), + }; + if split_tensor_deny_arch(&g.arch.to_lowercase()) { + return refuse(format!("architecture {} exclue du mode tensor", g.arch)); + } + let (_, why) = split_ngl(cfg, extra, g.block_count); + if !why.is_empty() { + return refuse(why); + } + let (ctx_s, ok) = ctx_fixed(cfg, extra); + if !ok { + return refuse(format!( + "contexte « {ctx_s} » non chiffré : pas de --fit en mode tensor, donne un CTX explicite" + )); + } + if side_slot_on(cfg) { + return refuse("SIDE_SLOT : cache doublé, sans --fit pour rattraper"); + } + if spec_mode(cfg) != "off" || spec_user_set(extra, &si.arg_env) { + return refuse( + "décodage spéculatif : brouillon et vérification par lots non validés en mode tensor", + ); + } + let (devs, why) = split_devices(extra, si); + if !why.is_empty() { + return refuse(why); + } + let ctx = atoi(&ctx_s).unwrap_or(0); + let (per_tok, rec) = gguf_state_bytes(g, &k, &v); + if per_tok <= 0.0 { + return refuse("taille du cache KV inconnue pour ce modèle"); + } + let total: i64 = devs.iter().map(|d| d.total_mib).sum(); + let mm = if si.mmproj.is_empty() { + 0 + } else { + std::fs::metadata(&si.mmproj).map_or(0, |m| m.len() as i64) + }; + let mib = 1048576.0; + let need = (si.model_bytes + mm) as f64 + per_tok * ctx as f64 + rec as f64; + let need_mib = (need / mib).ceil() as i64; + if need > 0.9 * total as f64 * mib { + return refuse(format!( + "VRAM insuffisante sans --fit : ~{need_mib} Mio (poids + cache de {ctx} jetons) pour {total} Mio sur {} cartes — CTX n'est jamais réduit d'office", + devs.len() + )); + } + SplitPlan { + on: true, + why: String::new(), + devs, + need_mib, + } +} + +/// `splitNGL`. +pub fn split_ngl(cfg: &Cfg, extra: &[String], blocks: i64) -> (Vec, String) { + let all = |v: &str| -> bool { + let v = trim(v).to_lowercase(); + if v == "all" || v == "999" { + return true; + } + atoi(&v).is_some_and(|n| blocks > 0 && n > blocks) + }; + let v = flag_value(extra, &["-ngl", "--n-gpu-layers", "--gpu-layers"]); + if !v.is_empty() { + if all(&v) { + return (Vec::new(), String::new()); + } + return ( + Vec::new(), + format!( + "-ngl {v} dans EXTRA_ARGS : le mode tensor veut toutes les couches sur les cartes (all)" + ), + ); + } + let v = trim(get(cfg, "NGL")); + if v.is_empty() || v.eq_ignore_ascii_case("auto") || all(v) { + (vec!["-ngl".into(), "all".into()], String::new()) + } else { + ( + Vec::new(), + format!("NGL={v} : le mode tensor veut toutes les couches sur les cartes (all)"), + ) + } +} + +/// `splitDevices`. +pub fn split_devices(extra: &[String], si: &SysInfo) -> (Vec, String) { + if si.split_devs.is_empty() { + return ( + Vec::new(), + "cartes inconnues (--list-devices n'a rien répondu)".into(), + ); + } + let mut devs = si.split_devs.clone(); + let mut sel = flag_value(extra, &["-dev", "--device"]); + if sel.is_empty() { + sel = si.env("LLAMA_ARG_DEVICE").into(); + } + let sel = trim(&sel); + if !sel.is_empty() { + devs = Vec::new(); + for name in sel.split(',') { + let name = trim(name); + match si + .split_devs + .iter() + .find(|d| d.id.eq_ignore_ascii_case(name)) + { + Some(d) => devs.push(d.clone()), + None => { + return (Vec::new(), format!("--device {name} inconnu de ce moteur")); + } + } + } + } + for d in &devs { + if !d.id.to_uppercase().starts_with("CUDA") { + return (Vec::new(), format!("{} n'est pas une carte CUDA", d.id)); + } + } + if devs.len() < 2 { + return (Vec::new(), "une seule carte".into()); + } + (devs, String::new()) +} + +/// `splitTS` : répartition au prorata de la VRAM totale, en pour cent. +pub fn split_ts(devs: &[SplitDev]) -> String { + let total: i64 = devs.iter().map(|d| d.total_mib).sum(); + if total <= 0 { + return String::new(); + } + devs.iter() + .map(|d| go_round(100.0 * d.total_mib as f64 / total as f64).to_string()) + .collect::>() + .join(",") +} + +/// `math.Round` : moitié arrondie loin de zéro (comme `f64::round`). +fn go_round(x: f64) -> i64 { + x.round() as i64 +} + +/// Ce que SPLIT_MODE=tensor ajoute (`splitTensorArgs`). +#[derive(Clone, Debug, Default)] +pub struct SplitTensor { + pub on: bool, + pub args: Vec, + pub ngl: Vec, + pub env: BTreeMap, + pub notes: Vec, +} + +/// `splitTensorArgs`. +pub fn split_tensor_args(cfg: &Cfg, extra: &[String], si: &SysInfo) -> SplitTensor { + let plan = split_plan(cfg, extra, si); + let mut out = SplitTensor::default(); + if !plan.on { + if !plan.why.is_empty() { + out.notes.push(format!( + "SPLIT_MODE=tensor refusé ({}) : découpe par couches, comme sans la clé", + plan.why + )); + } + return out; + } + let g = si.gguf.as_ref().expect("plan accepté : GGUF lu"); + out.on = true; + out.ngl = split_ngl(cfg, extra, g.block_count).0; + out.args = vec!["-sm".into(), "tensor".into()]; + let mut ts = String::new(); + if !has_any_flag(extra, &["-ts", "--tensor-split"]) + && si.env("LLAMA_ARG_TENSOR_SPLIT").is_empty() + { + ts = split_ts(&plan.devs); + if !ts.is_empty() { + out.args.push("-ts".into()); + out.args.push(ts.clone()); + } + } + if !has_any_flag(extra, &["-fa", "--flash-attn"]) && si.env("LLAMA_ARG_FLASH_ATTN").is_empty() { + out.args.push("-fa".into()); + out.args.push("on".into()); + } + let mut note = format!( + "SPLIT_MODE=tensor (expérimental) : -sm tensor sur {} cartes", + plan.devs.len() + ); + if !ts.is_empty() { + note.push_str(&format!(", -ts {ts} (VRAM totale)")); + } + note.push_str(&format!(", ~{} Mio estimés sans --fit ; décodage plus rapide, prefill plus lent — compare le temps par tour", plan.need_mib)); + let ar = trim(si.uenv("GGML_CUDA_ALLREDUCE")).to_lowercase(); + match ar.as_str() { + "" => { + out.env.insert("GGML_CUDA_ALLREDUCE".into(), "internal".into()); + note.push_str(" ; GGML_CUDA_ALLREDUCE=internal : sommes dans le type natif (NCCL les compresserait en BF16, avec perte). « NCCL not compiled in » ou « NCCL is unavailable » au journal : sans effet ici. GeForce sans P2P : les sommes passent par l'hôte ; GGML_CUDA_P2P=1 à poser soi-même si le pilote le permet"); + } + "nccl" => note.push_str(" ; avertissement : GGML_CUDA_ALLREDUCE=nccl (environnement) — NCCL compresse en BF16 les grosses réductions du prefill : calcul avec perte. « NCCL not compiled in » au journal = repli sur la réduction interne"), + _ => note.push_str(&format!(" ; GGML_CUDA_ALLREDUCE={ar} (environnement) gardé")), + } + if g.hybrid { + note.push_str(" ; modèle hybride : état récurrent et points de reprise répartis entre cartes, non validés en amont"); + } + out.notes.push(note); + out +} + +/// `parseListDevices` : les cartes de `llama-server --list-devices`. +pub fn parse_list_devices(out: &str) -> Vec { + let re = + regex::Regex::new(r"^\s*([A-Za-z]+\d+):\s*(.+?)\s*\((\d+)\s*MiB,\s*(\d+)\s*MiB free\)\s*$") + .expect("motif"); + out.split('\n') + .filter_map(|line| { + let m = re.captures(line.trim_end_matches('\r'))?; + Some(SplitDev { + id: m[1].to_string(), + total_mib: m[3].parse().unwrap_or(0), + }) + }) + .collect() +} diff --git a/rust/loki-rs/src/serve/threads.rs b/rust/loki-rs/src/serve/threads.rs new file mode 100644 index 0000000..0d8bbcf --- /dev/null +++ b/rust/loki-rs/src/serve/threads.rs @@ -0,0 +1,256 @@ +//! Threads CPU de llama-server (`backend_serve_threads.go`) : sans réglage, +//! AUCUN drapeau — llama.cpp prend ses cœurs physiques. Un conteneur à +//! l'étroit (cpuset, quota CFS) est sondé : on impose alors -t. + +use std::collections::BTreeSet; +use std::path::Path; + +use super::{atoi, flag_value, has_any_flag, trim}; + +/// Ce que la sonde de conteneur a conclu (`cpuBudget`). `n` = 0 : rien. +#[derive(Clone, Debug, Default, PartialEq, Eq)] +pub struct CpuBudget { + pub n: i64, + pub engine: i64, + pub why: String, +} + +/// `threadCount` : vide ou 0 = auto ; illisible ou négatif ignoré, en le +/// disant. +pub fn thread_count(key: &str, v: &str) -> (i64, String) { + let v = trim(v); + if v.is_empty() { + return (0, String::new()); + } + match atoi(v) { + Some(n) if n >= 0 => (n, String::new()), + _ => ( + 0, + format!("{key}={v} ignoré (pas un nombre de threads ≥ 0) → automatique"), + ), + } +} + +/// `batchCPUFlags`. +pub const BATCH_CPU_FLAGS: &[&str] = &[ + "-Cb", + "--cpu-mask-batch", + "-Crb", + "--cpu-range-batch", + "--cpu-strict-batch", + "--prio-batch", + "--poll-batch", +]; + +/// `threadArgs` : -t / -tb. +pub fn thread_args( + threads: &str, + threads_batch: &str, + extra: &[String], + auto: &CpuBudget, +) -> (Vec, Vec) { + let mut args = Vec::new(); + let mut notes = Vec::new(); + let (t, note) = thread_count("THREADS", threads); + if !note.is_empty() { + notes.push(note); + } + let mut t_val = String::new(); + if has_any_flag(extra, &["-t", "--threads"]) { + t_val = flag_value(extra, &["-t", "--threads"]); + } else if t > 0 { + t_val = t.to_string(); + args.push("-t".into()); + args.push(t_val.clone()); + } else if auto.n > 0 + && !has_any_flag( + extra, + &["-C", "--cpu-mask", "-Cr", "--cpu-range", "--cpu-strict"], + ) + { + t_val = auto.n.to_string(); + args.push("-t".into()); + args.push(t_val.clone()); + notes.push(format!( + "threads auto → -t {} : {} (llama.cpp seul en lancerait {})", + auto.n, auto.why, auto.engine + )); + } + let (tb, note) = thread_count("THREADS_BATCH", threads_batch); + if !note.is_empty() { + notes.push(note); + } + if has_any_flag(extra, &["-tb", "--threads-batch"]) { + } else if tb > 0 { + args.push("-tb".into()); + args.push(tb.to_string()); + } else if has_any_flag(extra, BATCH_CPU_FLAGS) { + if t_val.is_empty() { + t_val = "0".into(); + notes.push("EXTRA_ARGS règle le CPU du batch (-Cb, --poll-batch…) : -tb 0 posé pour que llama.cpp le garde (tous les threads logiques) ; THREADS_BATCH pour choisir le nombre".into()); + } + args.push("-tb".into()); + args.push(t_val); + } + (args, notes) +} + +fn read(root: &Path, rel: &str) -> Option { + std::fs::read(root.join(rel)) + .ok() + .map(|b| String::from_utf8_lossy(&b).into_owned()) +} + +/// `cpusetThreads` : sonde de conteneur (Linux) ; `root` = la racine « / ». +pub fn cpuset_threads(root: &Path) -> CpuBudget { + let Some(online) = read_cpu_list(root, "sys/devices/system/cpu/online") else { + return CpuBudget::default(); + }; + if online.is_empty() { + return CpuBudget::default(); + } + let Some(allowed) = proc_allowed_cpus(root) else { + return CpuBudget::default(); + }; + let allowed = intersect_cpus(&allowed, &online); + if allowed.is_empty() { + return CpuBudget::default(); + } + let restricted = allowed.len() < online.len(); + let quota = cgroup_cpu_quota(root); + if !restricted && quota == 0 { + return CpuBudget::default(); + } + let atom = read_cpu_list(root, "sys/devices/cpu_atom/cpus").unwrap_or_default(); + let Some(host_all) = physical_cores(root, &online, &[]) else { + return CpuBudget::default(); + }; + let Some(mut host_math) = physical_cores(root, &online, &atom) else { + return CpuBudget::default(); + }; + if host_math == 0 { + host_math = host_all; + } + let (mut n, engine, mut why); + if restricted { + engine = host_all; + let Some(x) = physical_cores(root, &allowed, &atom) else { + return CpuBudget::default(); + }; + n = x; + if n == 0 { + let Some(x) = physical_cores(root, &allowed, &[]) else { + return CpuBudget::default(); + }; + n = x; + } + why = format!("cpuset du conteneur, {n} cœurs physiques permis sur {host_all}"); + if !atom.is_empty() { + why.push_str(" (cœurs E écartés)"); + } + } else { + engine = host_math; + n = host_math; + why = String::new(); + } + if quota > 0 && quota < n { + n = quota; + why = format!("quota cgroup de {quota} CPU"); + } + if n <= 0 || n >= engine { + return CpuBudget::default(); + } + CpuBudget { n, engine, why } +} + +/// `physicalCores` : une chaîne `thread_siblings` distincte par cœur. +pub fn physical_cores(root: &Path, cpus: &[i64], exclude: &[i64]) -> Option { + let mut seen = BTreeSet::new(); + for c in cpus { + if exclude.contains(c) { + continue; + } + let b = read( + root, + &format!("sys/devices/system/cpu/cpu{c}/topology/thread_siblings"), + )?; + seen.insert(trim(&b).to_string()); + } + Some(seen.len() as i64) +} + +/// `procAllowedCPUs` : `Cpus_allowed_list` de /proc/self/status. +fn proc_allowed_cpus(root: &Path) -> Option> { + let b = read(root, "proc/self/status")?; + for line in b.split('\n') { + if let Some(v) = line.strip_prefix("Cpus_allowed_list:") { + return parse_cpu_list(v); + } + } + None +} + +/// `cgroupCPUQuota` : quota arrondi au CPU supérieur, 0 sans quota. +pub fn cgroup_cpu_quota(root: &Path) -> i64 { + if let Some(b) = read(root, "sys/fs/cgroup/cpu.max") { + let f: Vec<&str> = b.split_whitespace().collect(); + if f.is_empty() || f[0] == "max" { + return 0; + } + let period = f.get(1).copied().unwrap_or("100000"); + return ceil_quota(f[0], period); + } + for dir in ["sys/fs/cgroup/cpu", "sys/fs/cgroup/cpu,cpuacct"] { + let Some(q) = read(root, &format!("{dir}/cpu.cfs_quota_us")) else { + continue; + }; + let Some(p) = read(root, &format!("{dir}/cpu.cfs_period_us")) else { + return 0; + }; + return ceil_quota(trim(&q), trim(&p)); + } + 0 +} + +fn ceil_quota(quota: &str, period: &str) -> i64 { + match (atoi(quota), atoi(period)) { + (Some(q), Some(p)) if q > 0 && p > 0 => ((q + p - 1) / p).max(1), + _ => 0, + } +} + +fn read_cpu_list(root: &Path, rel: &str) -> Option> { + parse_cpu_list(&read(root, rel)?) +} + +/// `parseCPUList` : « 0-3,8-11 » → liste triée sans doublon ; syntaxe +/// inconnue → `None`. +pub fn parse_cpu_list(s: &str) -> Option> { + let mut set = BTreeSet::new(); + for part in trim(s).split(',') { + let part = trim(part); + if part.is_empty() { + continue; + } + let (lo, hi) = match part.split_once('-') { + Some((l, h)) => (l, Some(h)), + None => (part, None), + }; + let a = atoi(lo)?; + let b = match hi { + Some(h) => atoi(h)?, + None => a, + }; + if a < 0 || b < a || b - a > 1 << 16 { + return None; + } + for c in a..=b { + set.insert(c); + } + } + Some(set.into_iter().collect()) +} + +fn intersect_cpus(a: &[i64], b: &[i64]) -> Vec { + a.iter().copied().filter(|c| b.contains(c)).collect() +} diff --git a/rust/loki-rs/tests/engine.rs b/rust/loki-rs/tests/engine.rs index 77e608d..112a527 100644 --- a/rust/loki-rs/tests/engine.rs +++ b/rust/loki-rs/tests/engine.rs @@ -30,6 +30,7 @@ fn faux_go(dir: &Path, corps: &str) -> PathBuf { fn opts(go: PathBuf) -> Opts { Opts { + serve: (go.clone(), vec!["serve".into()]), go_bin: go, check_delay: Duration::from_millis(500), grace: Duration::from_secs(2), diff --git a/rust/loki-rs/tests/parity_serve/serve.json b/rust/loki-rs/tests/parity_serve/serve.json new file mode 100644 index 0000000..f9a9c83 --- /dev/null +++ b/rust/loki-rs/tests/parity_serve/serve.json @@ -0,0 +1,61321 @@ +{ + "machines": [ + { + "Help": "", + "Model": "/m/dense.gguf", + "MMProj": "", + "APIKey": "", + "CPU": { + "N": 0, + "Engine": 0, + "Why": "" + }, + "LaunchQueues": "", + "GPUs": 1, + "ArgEnv": null, + "UserEnv": null, + "GGUF": { + "Version": 3, + "Arch": "qwen3", + "BlockCount": 64, + "NextN": 0, + "ContextLength": 262144, + "ExpertCount": 0, + "KeyLen": 128, + "ValLen": 128, + "HeadCountKV": 8, + "HeadCountKVLayers": null, + "FullAttnInterval": 0, + "Hybrid": false, + "EmbeddingLength": 5120, + "HeadCount": 40, + "SSMConv": 0, + "SSMInner": 0, + "SSMState": 0, + "SSMGroups": 0, + "HasNextNTensor": false, + "TensorCount": 707 + }, + "ModelBytes": 17179869184, + "RAMMiB": 65536, + "RAMAvailMiB": 49152, + "VRAMMiB": 16311, + "UnifiedMem": false, + "EngineVRAMMiB": 0, + "SlotDir": "", + "EngineBuild": 0, + "Draft": "", + "DraftErr": "", + "DraftGGUF": null, + "DraftIsModel": false, + "SpecAutoBlocked": "", + "SplitDevs": null + }, + { + "Help": "", + "Model": "/m/moe.gguf", + "MMProj": "", + "APIKey": "", + "CPU": { + "N": 0, + "Engine": 0, + "Why": "" + }, + "LaunchQueues": "", + "GPUs": 2, + "ArgEnv": null, + "UserEnv": null, + "GGUF": { + "Version": 3, + "Arch": "qwen3moe", + "BlockCount": 48, + "NextN": 0, + "ContextLength": 262144, + "ExpertCount": 128, + "KeyLen": 128, + "ValLen": 128, + "HeadCountKV": 4, + "HeadCountKVLayers": null, + "FullAttnInterval": 0, + "Hybrid": false, + "EmbeddingLength": 2048, + "HeadCount": 32, + "SSMConv": 0, + "SSMInner": 0, + "SSMState": 0, + "SSMGroups": 0, + "HasNextNTensor": false, + "TensorCount": 579 + }, + "ModelBytes": 23622320128, + "RAMMiB": 32768, + "RAMAvailMiB": 20000, + "VRAMMiB": 28599, + "UnifiedMem": false, + "EngineVRAMMiB": 0, + "SlotDir": "", + "EngineBuild": 0, + "Draft": "", + "DraftErr": "", + "DraftGGUF": null, + "DraftIsModel": false, + "SpecAutoBlocked": "", + "SplitDevs": [ + { + "ID": "CUDA0", + "TotalMiB": 16311 + }, + { + "ID": "CUDA1", + "TotalMiB": 12288 + } + ] + }, + { + "Help": "", + "Model": "/m/inconnu.gguf", + "MMProj": "", + "APIKey": "", + "CPU": { + "N": 0, + "Engine": 0, + "Why": "" + }, + "LaunchQueues": "", + "GPUs": 0, + "ArgEnv": null, + "UserEnv": null, + "GGUF": null, + "ModelBytes": 0, + "RAMMiB": 0, + "RAMAvailMiB": 0, + "VRAMMiB": 0, + "UnifiedMem": false, + "EngineVRAMMiB": 0, + "SlotDir": "", + "EngineBuild": 0, + "Draft": "", + "DraftErr": "", + "DraftGGUF": null, + "DraftIsModel": false, + "SpecAutoBlocked": "", + "SplitDevs": null + }, + { + "Help": "", + "Model": "/m/hybrid.gguf", + "MMProj": "", + "APIKey": "", + "CPU": { + "N": 0, + "Engine": 0, + "Why": "" + }, + "LaunchQueues": "", + "GPUs": 1, + "ArgEnv": null, + "UserEnv": null, + "GGUF": { + "Version": 3, + "Arch": "qwen35", + "BlockCount": 64, + "NextN": 1, + "ContextLength": 262144, + "ExpertCount": 0, + "KeyLen": 256, + "ValLen": 256, + "HeadCountKV": 4, + "HeadCountKVLayers": null, + "FullAttnInterval": 4, + "Hybrid": true, + "EmbeddingLength": 5120, + "HeadCount": 24, + "SSMConv": 4, + "SSMInner": 6144, + "SSMState": 128, + "SSMGroups": 16, + "HasNextNTensor": true, + "TensorCount": 900 + }, + "ModelBytes": 21474836480, + "RAMMiB": 24576, + "RAMAvailMiB": 6000, + "VRAMMiB": 24576, + "UnifiedMem": false, + "EngineVRAMMiB": 0, + "SlotDir": "", + "EngineBuild": 9000, + "Draft": "", + "DraftErr": "", + "DraftGGUF": null, + "DraftIsModel": false, + "SpecAutoBlocked": "", + "SplitDevs": null + }, + { + "Help": "", + "Model": "/m/hybrid.gguf", + "MMProj": "", + "APIKey": "", + "CPU": { + "N": 0, + "Engine": 0, + "Why": "" + }, + "LaunchQueues": "", + "GPUs": 2, + "ArgEnv": null, + "UserEnv": null, + "GGUF": { + "Version": 3, + "Arch": "qwen35", + "BlockCount": 64, + "NextN": 1, + "ContextLength": 262144, + "ExpertCount": 0, + "KeyLen": 256, + "ValLen": 256, + "HeadCountKV": 4, + "HeadCountKVLayers": null, + "FullAttnInterval": 4, + "Hybrid": true, + "EmbeddingLength": 5120, + "HeadCount": 24, + "SSMConv": 4, + "SSMInner": 6144, + "SSMState": 128, + "SSMGroups": 16, + "HasNextNTensor": true, + "TensorCount": 900 + }, + "ModelBytes": 16106127360, + "RAMMiB": 131072, + "RAMAvailMiB": 100000, + "VRAMMiB": 28599, + "UnifiedMem": false, + "EngineVRAMMiB": 0, + "SlotDir": "/data/slots", + "EngineBuild": 12000, + "Draft": "", + "DraftErr": "", + "DraftGGUF": null, + "DraftIsModel": false, + "SpecAutoBlocked": "", + "SplitDevs": [ + { + "ID": "CUDA0", + "TotalMiB": 16311 + }, + { + "ID": "CUDA1", + "TotalMiB": 12288 + } + ] + }, + { + "Help": "", + "Model": "/m/ds.gguf", + "MMProj": "", + "APIKey": "sk-loki-x", + "CPU": { + "N": 0, + "Engine": 0, + "Why": "" + }, + "LaunchQueues": "", + "GPUs": 0, + "ArgEnv": null, + "UserEnv": null, + "GGUF": { + "Version": 3, + "Arch": "deepseek2", + "BlockCount": 61, + "NextN": 0, + "ContextLength": 0, + "ExpertCount": 256, + "KeyLen": 192, + "ValLen": 128, + "HeadCountKV": 128, + "HeadCountKVLayers": null, + "FullAttnInterval": 0, + "Hybrid": false, + "EmbeddingLength": 0, + "HeadCount": 0, + "SSMConv": 0, + "SSMInner": 0, + "SSMState": 0, + "SSMGroups": 0, + "HasNextNTensor": false, + "TensorCount": 0 + }, + "ModelBytes": 214748364800, + "RAMMiB": 262144, + "RAMAvailMiB": 200000, + "VRAMMiB": 0, + "UnifiedMem": false, + "EngineVRAMMiB": 0, + "SlotDir": "", + "EngineBuild": 0, + "Draft": "", + "DraftErr": "", + "DraftGGUF": null, + "DraftIsModel": false, + "SpecAutoBlocked": "", + "SplitDevs": [ + { + "ID": "Vulkan0", + "TotalMiB": 8000 + }, + { + "ID": "CUDA0", + "TotalMiB": 8000 + } + ] + }, + { + "Help": "", + "Model": "/m/layers.gguf", + "MMProj": "/m/mmproj.gguf", + "APIKey": "", + "CPU": { + "N": 4, + "Engine": 8, + "Why": "cpuset du conteneur, 4 cœurs physiques permis sur 8" + }, + "LaunchQueues": "", + "GPUs": 0, + "ArgEnv": null, + "UserEnv": null, + "GGUF": { + "Version": 3, + "Arch": "gemma3", + "BlockCount": 6, + "NextN": 0, + "ContextLength": 0, + "ExpertCount": 0, + "KeyLen": 64, + "ValLen": 64, + "HeadCountKV": 2, + "HeadCountKVLayers": [ + 2, + 2, + 0, + 2, + 2, + 0 + ], + "FullAttnInterval": 0, + "Hybrid": true, + "EmbeddingLength": 0, + "HeadCount": 0, + "SSMConv": 4, + "SSMInner": 512, + "SSMState": 16, + "SSMGroups": 1, + "HasNextNTensor": false, + "TensorCount": 0 + }, + "ModelBytes": 3221225472, + "RAMMiB": 16384, + "RAMAvailMiB": 4096, + "VRAMMiB": 0, + "UnifiedMem": true, + "EngineVRAMMiB": 0, + "SlotDir": "/data/slots", + "EngineBuild": 0, + "Draft": "", + "DraftErr": "", + "DraftGGUF": null, + "DraftIsModel": false, + "SpecAutoBlocked": "", + "SplitDevs": null + }, + { + "Help": "", + "Model": "/m/dense.gguf", + "MMProj": "", + "APIKey": "", + "CPU": { + "N": 0, + "Engine": 0, + "Why": "" + }, + "LaunchQueues": "", + "GPUs": 2, + "ArgEnv": null, + "UserEnv": null, + "GGUF": { + "Version": 3, + "Arch": "qwen3", + "BlockCount": 64, + "NextN": 0, + "ContextLength": 262144, + "ExpertCount": 0, + "KeyLen": 128, + "ValLen": 128, + "HeadCountKV": 8, + "HeadCountKVLayers": null, + "FullAttnInterval": 0, + "Hybrid": false, + "EmbeddingLength": 5120, + "HeadCount": 40, + "SSMConv": 0, + "SSMInner": 0, + "SSMState": 0, + "SSMGroups": 0, + "HasNextNTensor": false, + "TensorCount": 707 + }, + "ModelBytes": 32212254720, + "RAMMiB": 32768, + "RAMAvailMiB": 16000, + "VRAMMiB": 24000, + "UnifiedMem": false, + "EngineVRAMMiB": 24000, + "SlotDir": "", + "EngineBuild": 11500, + "Draft": "/m/mtp-head.gguf", + "DraftErr": "", + "DraftGGUF": { + "Version": 0, + "Arch": "qwen35", + "BlockCount": 1, + "NextN": 0, + "ContextLength": 0, + "ExpertCount": 0, + "KeyLen": 0, + "ValLen": 0, + "HeadCountKV": 0, + "HeadCountKVLayers": null, + "FullAttnInterval": 0, + "Hybrid": false, + "EmbeddingLength": 0, + "HeadCount": 0, + "SSMConv": 0, + "SSMInner": 0, + "SSMState": 0, + "SSMGroups": 0, + "HasNextNTensor": true, + "TensorCount": 0 + }, + "DraftIsModel": false, + "SpecAutoBlocked": "", + "SplitDevs": [ + { + "ID": "CUDA0", + "TotalMiB": 16311 + }, + { + "ID": "CUDA1", + "TotalMiB": 12288 + } + ] + }, + { + "Help": "", + "Model": "/m/dense.gguf", + "MMProj": "", + "APIKey": "", + "CPU": { + "N": 0, + "Engine": 0, + "Why": "" + }, + "LaunchQueues": "", + "GPUs": 1, + "ArgEnv": null, + "UserEnv": null, + "GGUF": { + "Version": 3, + "Arch": "qwen3", + "BlockCount": 64, + "NextN": 0, + "ContextLength": 262144, + "ExpertCount": 0, + "KeyLen": 128, + "ValLen": 128, + "HeadCountKV": 8, + "HeadCountKVLayers": null, + "FullAttnInterval": 0, + "Hybrid": false, + "EmbeddingLength": 5120, + "HeadCount": 40, + "SSMConv": 0, + "SSMInner": 0, + "SSMState": 0, + "SSMGroups": 0, + "HasNextNTensor": false, + "TensorCount": 707 + }, + "ModelBytes": 17179869184, + "RAMMiB": 65536, + "RAMAvailMiB": 49152, + "VRAMMiB": 16311, + "UnifiedMem": false, + "EngineVRAMMiB": 0, + "SlotDir": "", + "EngineBuild": 12000, + "Draft": "/m/eagle.gguf", + "DraftErr": "", + "DraftGGUF": { + "Version": 0, + "Arch": "eagle3", + "BlockCount": 1, + "NextN": 0, + "ContextLength": 0, + "ExpertCount": 0, + "KeyLen": 0, + "ValLen": 0, + "HeadCountKV": 0, + "HeadCountKVLayers": null, + "FullAttnInterval": 0, + "Hybrid": false, + "EmbeddingLength": 0, + "HeadCount": 0, + "SSMConv": 0, + "SSMInner": 0, + "SSMState": 0, + "SSMGroups": 0, + "HasNextNTensor": false, + "TensorCount": 0 + }, + "DraftIsModel": false, + "SpecAutoBlocked": "", + "SplitDevs": null + }, + { + "Help": "", + "Model": "/m/dense.gguf", + "MMProj": "", + "APIKey": "", + "CPU": { + "N": 0, + "Engine": 0, + "Why": "" + }, + "LaunchQueues": "", + "GPUs": 1, + "ArgEnv": null, + "UserEnv": null, + "GGUF": { + "Version": 3, + "Arch": "qwen3", + "BlockCount": 64, + "NextN": 0, + "ContextLength": 262144, + "ExpertCount": 0, + "KeyLen": 128, + "ValLen": 128, + "HeadCountKV": 8, + "HeadCountKVLayers": null, + "FullAttnInterval": 0, + "Hybrid": false, + "EmbeddingLength": 5120, + "HeadCount": 40, + "SSMConv": 0, + "SSMInner": 0, + "SSMState": 0, + "SSMGroups": 0, + "HasNextNTensor": false, + "TensorCount": 707 + }, + "ModelBytes": 17179869184, + "RAMMiB": 65536, + "RAMAvailMiB": 49152, + "VRAMMiB": 16311, + "UnifiedMem": false, + "EngineVRAMMiB": 0, + "SlotDir": "", + "EngineBuild": 12000, + "Draft": "/m/small.gguf", + "DraftErr": "", + "DraftGGUF": { + "Version": 0, + "Arch": "qwen3", + "BlockCount": 28, + "NextN": 0, + "ContextLength": 0, + "ExpertCount": 0, + "KeyLen": 128, + "ValLen": 128, + "HeadCountKV": 8, + "HeadCountKVLayers": null, + "FullAttnInterval": 0, + "Hybrid": false, + "EmbeddingLength": 0, + "HeadCount": 0, + "SSMConv": 0, + "SSMInner": 0, + "SSMState": 0, + "SSMGroups": 0, + "HasNextNTensor": false, + "TensorCount": 0 + }, + "DraftIsModel": false, + "SpecAutoBlocked": "", + "SplitDevs": null + }, + { + "Help": "", + "Model": "/m/hybrid.gguf", + "MMProj": "", + "APIKey": "", + "CPU": { + "N": 0, + "Engine": 0, + "Why": "" + }, + "LaunchQueues": "", + "GPUs": 1, + "ArgEnv": null, + "UserEnv": null, + "GGUF": { + "Version": 3, + "Arch": "qwen35", + "BlockCount": 64, + "NextN": 1, + "ContextLength": 262144, + "ExpertCount": 0, + "KeyLen": 256, + "ValLen": 256, + "HeadCountKV": 4, + "HeadCountKVLayers": null, + "FullAttnInterval": 4, + "Hybrid": true, + "EmbeddingLength": 5120, + "HeadCount": 24, + "SSMConv": 4, + "SSMInner": 6144, + "SSMState": 128, + "SSMGroups": 16, + "HasNextNTensor": true, + "TensorCount": 900 + }, + "ModelBytes": 16106127360, + "RAMMiB": 65536, + "RAMAvailMiB": 49152, + "VRAMMiB": 24576, + "UnifiedMem": false, + "EngineVRAMMiB": 0, + "SlotDir": "", + "EngineBuild": 12000, + "Draft": "/m/hybrid.gguf", + "DraftErr": "", + "DraftGGUF": { + "Version": 3, + "Arch": "qwen35", + "BlockCount": 64, + "NextN": 1, + "ContextLength": 262144, + "ExpertCount": 0, + "KeyLen": 256, + "ValLen": 256, + "HeadCountKV": 4, + "HeadCountKVLayers": null, + "FullAttnInterval": 4, + "Hybrid": true, + "EmbeddingLength": 5120, + "HeadCount": 24, + "SSMConv": 4, + "SSMInner": 6144, + "SSMState": 128, + "SSMGroups": 16, + "HasNextNTensor": true, + "TensorCount": 900 + }, + "DraftIsModel": true, + "SpecAutoBlocked": "le dernier lancement avec cette configuration n'a jamais répondu", + "SplitDevs": null + }, + { + "Help": "", + "Model": "/m/dense.gguf", + "MMProj": "", + "APIKey": "", + "CPU": { + "N": 0, + "Engine": 0, + "Why": "" + }, + "LaunchQueues": "", + "GPUs": 1, + "ArgEnv": null, + "UserEnv": null, + "GGUF": { + "Version": 3, + "Arch": "qwen3", + "BlockCount": 64, + "NextN": 0, + "ContextLength": 262144, + "ExpertCount": 0, + "KeyLen": 128, + "ValLen": 128, + "HeadCountKV": 8, + "HeadCountKVLayers": null, + "FullAttnInterval": 0, + "Hybrid": false, + "EmbeddingLength": 5120, + "HeadCount": 40, + "SSMConv": 0, + "SSMInner": 0, + "SSMState": 0, + "SSMGroups": 0, + "HasNextNTensor": false, + "TensorCount": 707 + }, + "ModelBytes": 17179869184, + "RAMMiB": 65536, + "RAMAvailMiB": 49152, + "VRAMMiB": 16311, + "UnifiedMem": false, + "EngineVRAMMiB": 0, + "SlotDir": "", + "EngineBuild": 12000, + "Draft": "", + "DraftErr": "stat /m/absent.gguf: no such file or directory", + "DraftGGUF": null, + "DraftIsModel": false, + "SpecAutoBlocked": "", + "SplitDevs": null + } + ], + "presets": [ + {}, + { + "BATCH": "4096", + "CTX": "65536", + "HOST": "127.0.0.1", + "NGL": "999", + "PORT": "8081", + "UBATCH": "2048" + }, + { + "NGL": "auto" + }, + { + "NGL": "40" + }, + { + "NGL": "all" + }, + { + "NGL": " 999 " + }, + { + "THREADS": "8", + "THREADS_BATCH": "4" + }, + { + "THREADS": "abc", + "THREADS_BATCH": "-2" + }, + { + "THREADS": "0" + }, + { + "KV_TYPE": "q8_0" + }, + { + "KV_TYPE_K": "q4_0", + "KV_TYPE_V": "f16" + }, + { + "KV_TYPE": "bf16" + }, + { + "KV_TYPE": "q5_1" + }, + { + "REASONING": "on" + }, + { + "REASONING": "off" + }, + { + "REASONING": "auto", + "REASONING_BUDGET": "2048" + }, + { + "REASONING": "deepseek" + }, + { + "REASONING_PRESERVE": "on" + }, + { + "REASONING_PRESERVE": "off" + }, + { + "REASONING_PRESERVE": "peut-être" + }, + { + "CACHE_RAM": "auto" + }, + { + "CACHE_RAM": "4096" + }, + { + "CACHE_RAM": "0" + }, + { + "CACHE_RAM": "-1" + }, + { + "CACHE_RAM": "beaucoup" + }, + { + "CACHE_ISOLATE": "off" + }, + { + "CACHE_ISOLATE": "on", + "CACHE_RAM": "0" + }, + { + "SIDE_SLOT": "on" + }, + { + "PARALLEL": "3", + "SIDE_SLOT": "on" + }, + { + "CTX": "0", + "SIDE_SLOT": "on" + }, + { + "CTX": "8192", + "KV_TYPE": "q8_0", + "SIDE_SLOT": "on" + }, + { + "SPLIT_MODE": "tensor" + }, + { + "CTX": "16384", + "SPLIT_MODE": "tensor" + }, + { + "SPLIT_MODE": "bizarre" + }, + { + "SPLIT_MODE": "layer" + }, + { + "KV_TYPE": "q8_0", + "SPLIT_MODE": "tensor" + }, + { + "NGL": "20", + "SPLIT_MODE": "tensor" + }, + { + "SPEC": "auto" + }, + { + "SPEC": "mtp" + }, + { + "SPEC": "ngram" + }, + { + "SPEC": "mtp+ngram" + }, + { + "SPEC": "ngram+mtp" + }, + { + "SPEC": "turbo" + }, + { + "MODEL_DRAFT": "mtp-head.gguf", + "SPEC": "off" + }, + { + "MODEL_DRAFT": "mtp-head.gguf", + "SPEC": "mtp", + "SPEC_N_MAX": "4" + }, + { + "SPEC": "mtp", + "SPEC_N_MAX": "99", + "SPEC_SAMPLING": "probabilistic" + }, + { + "SPEC": "auto", + "SPEC_SAMPLING": "probabilistic" + }, + { + "SPEC": "mtp", + "SPEC_SAMPLING": "aléatoire" + }, + { + "MODEL_DRAFT": "x.gguf", + "SPEC": "ngram", + "SPEC_N_MAX": "3" + }, + { + "MODEL_DRAFT": "small.gguf", + "SPEC": "auto" + }, + { + "MODEL_DRAFT": "small.gguf", + "SPEC": "mtp+ngram" + }, + { + "CUDA_LAUNCH_QUEUES": "off" + }, + { + "CUDA_LAUNCH_QUEUES": "2x" + }, + { + "CUDA_LAUNCH_QUEUES": "8x" + }, + { + "CUDA_LAUNCH_QUEUES": "auto" + }, + { + "CUDA_VISIBLE_DEVICES": "1,0" + }, + { + "FIT_TARGET": "1024,3072" + }, + { + "FIT_TARGET": "512" + }, + { + "FIT_TARGET": "1024,,3072" + }, + { + "FIT_TARGET": "1,2,3,4,5,6,7,8,9" + }, + { + "CTX": "0", + "FIT_TARGET": "2048/4096" + }, + { + "FIT_TARGET": "2048", + "NGL": "30" + }, + { + "OP_OFFLOAD_MIN_BATCH": "128" + }, + { + "OP_OFFLOAD_MIN_BATCH": "0" + }, + { + "CUDA_GRAPH_OPT": "on" + }, + { + "CUDA_GRAPH_OPT": "oui" + }, + { + "CUDA_GRAPH_OPT": "off" + }, + { + "CTX_CHECKPOINTS": "8" + }, + { + "CTX_CHECKPOINTS": "beaucoup" + }, + { + "CKPT_MIN_STEP": "1024" + }, + { + "CKPT_MIN_STEP": "0" + }, + { + "SLOT_PERSIST": "on" + }, + { + "HOST": "127.0.0.1", + "SLOT_PERSIST": "on" + }, + { + "HOST": "127.0.0.1", + "SLOT_PERSIST": "on", + "SPEC": "mtp" + }, + { + "EXTRA_ARGS": "--load-mode none", + "LOAD_GUARD": "off" + }, + { + "EXTRA_ARGS": "--mlock --no-mmap" + }, + { + "EXTRA_ARGS": "--mlock" + }, + { + "EXTRA_ARGS": "--no-mmap" + }, + { + "EXTRA_ARGS": "--mmap" + }, + { + "EXTRA_ARGS": "--load-mode dio" + }, + { + "EXTRA_ARGS": "--load-mode=mlock" + }, + { + "EXTRA_ARGS": "-ngl 30 --parallel 4" + }, + { + "EXTRA_ARGS": "-t 6 -tb 3" + }, + { + "EXTRA_ARGS": "-Cb 0xff --poll-batch 1" + }, + { + "EXTRA_ARGS": "--prio-batch 2", + "THREADS": "6" + }, + { + "EXTRA_ARGS": "--cache-reuse 256 --context-shift" + }, + { + "EXTRA_ARGS": "-ctk q8_0 -ctv q8_0" + }, + { + "EXTRA_ARGS": "-ot exps=CPU" + }, + { + "EXTRA_ARGS": "-ot 'blk\\.(1|2)\\.ffn_.*_exps=CPU' -ub 512" + }, + { + "EXTRA_ARGS": "--n-cpu-moe 20" + }, + { + "EXTRA_ARGS": "--cpu-moe" + }, + { + "EXTRA_ARGS": "-sm row" + }, + { + "EXTRA_ARGS": "-ts 1,1" + }, + { + "EXTRA_ARGS": "-dev CUDA0,CUDA1" + }, + { + "EXTRA_ARGS": "-dev Vulkan0" + }, + { + "EXTRA_ARGS": "--fit off" + }, + { + "EXTRA_ARGS": "--mmproj /m/x.gguf" + }, + { + "EXTRA_ARGS": "--spec-synth-len 4" + }, + { + "EXTRA_ARGS": "--slot-save-path /x" + }, + { + "EXTRA_ARGS": "-c 0" + }, + { + "EXTRA_ARGS": "--host 127.0.0.1 --port 9000" + }, + { + "EXTRA_ARGS": "--mirostat 2", + "SPEC": "mtp", + "SPEC_SAMPLING": "probabilistic" + }, + { + "EXTRA_ARGS": "--chat-template-kwargs '{\"preserve_reasoning\":true}'", + "REASONING_PRESERVE": "on" + }, + { + "EXTRA_ARGS": "--spec-type ngram-mod", + "SPEC": "auto" + }, + { + "EXTRA_ARGS": "--spec-draft-n-max 8", + "SPEC": "ngram" + }, + { + "CUDA_GRAPH_OPT": "on", + "EXTRA_ARGS": "-ot attn=CUDA1" + }, + { + "EXTRA_ARGS": "-fa off", + "SPLIT_MODE": "tensor" + }, + { + "EXTRA_ARGS": "-nkvo" + }, + { + "EXTRA_ARGS": "--no-context-shift" + }, + { + "EXTRA_ARGS": "-c 8192 -np 2", + "SIDE_SLOT": "on" + }, + { + "EXTERNAL": "1", + "SIDE_SLOT": "on", + "SPLIT_MODE": "tensor" + }, + { + "CACHE_RAM": "auto", + "CTX": "40000", + "KV_TYPE": "q4_0", + "MMPROJ": "x", + "NGL": "999", + "SPEC": "auto" + } + ], + "helps": [ + "-ngl, --gpu-layers, --n-gpu-layers N max. number of layers to store in VRAM, either an exact number, 'auto', or 'all' (default: auto)\n--reasoning [on|off|auto] --reasoning-budget N --load-mode {auto,mmap,none,mlock,mmap+mlock,dio}\n-fit, --fit [on|off] -fitt, --fit-target MiB0,MiB1 --no-kv-unified -cram, --cache-ram N --slot-save-path PATH\n-ctxcp, --ctx-checkpoints N -cms, --checkpoint-min-step N --reasoning-preserve, --no-reasoning-preserve\n--spec-type {none,draft-simple,draft-mtp,ngram-mod} --spec-draft-n-max N --spec-draft-sampling {greedy,probabilistic}\n--spec-ngram-mod-n-match N -sm, --split-mode {none,layer,row,tensor} --context-shift, --no-context-shift\n", + "-ngl, --gpu-layers, --n-gpu-layers N number of layers to store in VRAM\n--mlock --no-mmap --no-context-shift --cache-ram N\n", + "" + ], + "arg_envs": [ + {}, + { + "LLAMA_ARG_CACHE_TYPE_K": "q8_0" + }, + { + "LLAMA_ARG_NO_KV_OFFLOAD": "1" + }, + { + "LLAMA_ARG_N_CPU_MOE": "10" + }, + { + "LLAMA_ARG_CACHE_RAM": "1024" + }, + { + "LLAMA_ARG_CONTEXT_SHIFT": "on" + }, + { + "LLAMA_ARG_FIT": "off" + }, + { + "LLAMA_ARG_SPLIT_MODE": "row" + }, + { + "LLAMA_ARG_DEVICE": "CUDA0" + }, + { + "LLAMA_ARG_MLOCK": "1", + "LLAMA_ARG_NO_MMAP": "1" + }, + { + "LLAMA_ARG_NO_CONTEXT_SHIFT": "true" + }, + { + "LLAMA_ARG_SPEC_SYNTH_LEN": "3" + }, + { + "LLAMA_ARG_TENSOR_SPLIT": "1,1" + } + ], + "user_envs": [ + {}, + { + "GGML_CUDA_ALLREDUCE": "nccl", + "GGML_CUDA_GRAPH_OPT": "1", + "GGML_OP_OFFLOAD_MIN_BATCH": "64" + } + ], + "cases": [ + { + "m": 0, + "c": 0, + "h": 0, + "e": 0, + "args": [ + "/app/llama-server", + "-m", + "/m/dense.gguf", + "-c", + "32768", + "-b", + "2048", + "-ub", + "512", + "--host", + "0.0.0.0", + "--port", + "8080", + "--parallel", + "1", + "-ngl", + "auto" + ], + "env": {}, + "notes": null + }, + { + "m": 0, + "c": 1, + "h": 0, + "e": 0, + "args": [ + "/app/llama-server", + "-m", + "/m/dense.gguf", + "-c", + "65536", + "-b", + "4096", + "-ub", + "2048", + "--host", + "127.0.0.1", + "--port", + "8081", + "--parallel", + "1", + "-ngl", + "auto" + ], + "env": {}, + "notes": [ + "NGL=999 (sentinelle « toutes les couches ») → -ngl auto : ce moteur mesure lui-même la VRAM libre. NGL=all pour forcer l'ancien comportement." + ] + }, + { + "m": 0, + "c": 2, + "h": 0, + "e": 0, + "args": [ + "/app/llama-server", + "-m", + "/m/dense.gguf", + "-c", + "32768", + "-b", + "2048", + "-ub", + "512", + "--host", + "0.0.0.0", + "--port", + "8080", + "--parallel", + "1" + ], + "env": {}, + "notes": null + }, + { + "m": 0, + "c": 3, + "h": 0, + "e": 0, + "args": [ + "/app/llama-server", + "-m", + "/m/dense.gguf", + "-c", + "32768", + "-b", + "2048", + "-ub", + "512", + "--host", + "0.0.0.0", + "--port", + "8080", + "--parallel", + "1", + "-ngl", + "40" + ], + "env": {}, + "notes": null + }, + { + "m": 0, + "c": 4, + "h": 0, + "e": 0, + "args": [ + "/app/llama-server", + "-m", + "/m/dense.gguf", + "-c", + "32768", + "-b", + "2048", + "-ub", + "512", + "--host", + "0.0.0.0", + "--port", + "8080", + "--parallel", + "1", + "-ngl", + "all" + ], + "env": {}, + "notes": null + }, + { + "m": 0, + "c": 5, + "h": 0, + "e": 0, + "args": [ + "/app/llama-server", + "-m", + "/m/dense.gguf", + "-c", + "32768", + "-b", + "2048", + "-ub", + "512", + "--host", + "0.0.0.0", + "--port", + "8080", + "--parallel", + "1", + "-ngl", + "auto" + ], + "env": {}, + "notes": [ + "NGL=999 (sentinelle « toutes les couches ») → -ngl auto : ce moteur mesure lui-même la VRAM libre. NGL=all pour forcer l'ancien comportement." + ] + }, + { + "m": 0, + "c": 6, + "h": 0, + "e": 0, + "args": [ + "/app/llama-server", + "-m", + "/m/dense.gguf", + "-c", + "32768", + "-t", + "8", + "-tb", + "4", + "-b", + "2048", + "-ub", + "512", + "--host", + "0.0.0.0", + "--port", + "8080", + "--parallel", + "1", + "-ngl", + "auto" + ], + "env": {}, + "notes": null + }, + { + "m": 0, + "c": 7, + "h": 0, + "e": 0, + "args": [ + "/app/llama-server", + "-m", + "/m/dense.gguf", + "-c", + "32768", + "-b", + "2048", + "-ub", + "512", + "--host", + "0.0.0.0", + "--port", + "8080", + "--parallel", + "1", + "-ngl", + "auto" + ], + "env": {}, + "notes": [ + "THREADS=abc ignoré (pas un nombre de threads ≥ 0) → automatique", + "THREADS_BATCH=-2 ignoré (pas un nombre de threads ≥ 0) → automatique" + ] + }, + { + "m": 0, + "c": 8, + "h": 0, + "e": 0, + "args": [ + "/app/llama-server", + "-m", + "/m/dense.gguf", + "-c", + "32768", + "-b", + "2048", + "-ub", + "512", + "--host", + "0.0.0.0", + "--port", + "8080", + "--parallel", + "1", + "-ngl", + "auto" + ], + "env": {}, + "notes": null + }, + { + "m": 0, + "c": 9, + "h": 0, + "e": 0, + "args": [ + "/app/llama-server", + "-m", + "/m/dense.gguf", + "-c", + "32768", + "-b", + "2048", + "-ub", + "512", + "--host", + "0.0.0.0", + "--port", + "8080", + "--parallel", + "1", + "-ngl", + "auto", + "-ctk", + "q8_0", + "-ctv", + "q8_0" + ], + "env": {}, + "notes": [ + "cache KV q8_0/q8_0 (KV_TYPE) : modifie légèrement les sorties par rapport à f16 — choix de l'utilisateur, laissé tel quel." + ] + }, + { + "m": 0, + "c": 10, + "h": 0, + "e": 0, + "args": [ + "/app/llama-server", + "-m", + "/m/dense.gguf", + "-c", + "32768", + "-b", + "2048", + "-ub", + "512", + "--host", + "0.0.0.0", + "--port", + "8080", + "--parallel", + "1", + "-ngl", + "auto", + "-ctk", + "q4_0", + "-ctv", + "f16" + ], + "env": {}, + "notes": [ + "cache KV q4_0/f16 (KV_TYPE) : perte mesurable par rapport à f16 — choix de l'utilisateur, laissé tel quel." + ] + }, + { + "m": 0, + "c": 11, + "h": 0, + "e": 0, + "args": [ + "/app/llama-server", + "-m", + "/m/dense.gguf", + "-c", + "32768", + "-b", + "2048", + "-ub", + "512", + "--host", + "0.0.0.0", + "--port", + "8080", + "--parallel", + "1", + "-ngl", + "auto", + "-ctk", + "bf16", + "-ctv", + "bf16" + ], + "env": {}, + "notes": [ + "cache KV bf16/bf16 (KV_TYPE) : numérique différente de f16 (mantisse plus courte) — choix de l'utilisateur, laissé tel quel." + ] + }, + { + "m": 0, + "c": 12, + "h": 0, + "e": 0, + "args": [ + "/app/llama-server", + "-m", + "/m/dense.gguf", + "-c", + "32768", + "-b", + "2048", + "-ub", + "512", + "--host", + "0.0.0.0", + "--port", + "8080", + "--parallel", + "1", + "-ngl", + "auto", + "-ctk", + "q5_1", + "-ctv", + "q5_1" + ], + "env": {}, + "notes": [ + "cache KV q5_1/q5_1 (KV_TYPE) : modifie légèrement les sorties par rapport à f16 — choix de l'utilisateur, laissé tel quel." + ] + }, + { + "m": 0, + "c": 13, + "h": 0, + "e": 0, + "args": [ + "/app/llama-server", + "-m", + "/m/dense.gguf", + "-c", + "32768", + "-b", + "2048", + "-ub", + "512", + "--host", + "0.0.0.0", + "--port", + "8080", + "--parallel", + "1", + "-ngl", + "auto", + "--reasoning", + "on", + "--reasoning-budget", + "-1" + ], + "env": {}, + "notes": null + }, + { + "m": 0, + "c": 14, + "h": 0, + "e": 0, + "args": [ + "/app/llama-server", + "-m", + "/m/dense.gguf", + "-c", + "32768", + "-b", + "2048", + "-ub", + "512", + "--host", + "0.0.0.0", + "--port", + "8080", + "--parallel", + "1", + "-ngl", + "auto", + "--reasoning", + "off" + ], + "env": {}, + "notes": null + }, + { + "m": 0, + "c": 15, + "h": 0, + "e": 0, + "args": [ + "/app/llama-server", + "-m", + "/m/dense.gguf", + "-c", + "32768", + "-b", + "2048", + "-ub", + "512", + "--host", + "0.0.0.0", + "--port", + "8080", + "--parallel", + "1", + "-ngl", + "auto", + "--reasoning", + "auto", + "--reasoning-budget", + "2048" + ], + "env": {}, + "notes": null + }, + { + "m": 0, + "c": 16, + "h": 0, + "e": 0, + "args": [ + "/app/llama-server", + "-m", + "/m/dense.gguf", + "-c", + "32768", + "-b", + "2048", + "-ub", + "512", + "--host", + "0.0.0.0", + "--port", + "8080", + "--parallel", + "1", + "-ngl", + "auto", + "--reasoning", + "deepseek", + "--reasoning-budget", + "-1" + ], + "env": {}, + "notes": null + }, + { + "m": 0, + "c": 17, + "h": 0, + "e": 0, + "args": [ + "/app/llama-server", + "-m", + "/m/dense.gguf", + "-c", + "32768", + "-b", + "2048", + "-ub", + "512", + "--host", + "0.0.0.0", + "--port", + "8080", + "--parallel", + "1", + "-ngl", + "auto", + "--reasoning-preserve" + ], + "env": {}, + "notes": null + }, + { + "m": 0, + "c": 18, + "h": 0, + "e": 0, + "args": [ + "/app/llama-server", + "-m", + "/m/dense.gguf", + "-c", + "32768", + "-b", + "2048", + "-ub", + "512", + "--host", + "0.0.0.0", + "--port", + "8080", + "--parallel", + "1", + "-ngl", + "auto", + "--no-reasoning-preserve" + ], + "env": {}, + "notes": null + }, + { + "m": 0, + "c": 19, + "h": 0, + "e": 0, + "args": [ + "/app/llama-server", + "-m", + "/m/dense.gguf", + "-c", + "32768", + "-b", + "2048", + "-ub", + "512", + "--host", + "0.0.0.0", + "--port", + "8080", + "--parallel", + "1", + "-ngl", + "auto" + ], + "env": {}, + "notes": [ + "REASONING_PRESERVE=peut-être illisible (on ou off) : ignoré" + ] + }, + { + "m": 0, + "c": 20, + "h": 0, + "e": 0, + "args": [ + "/app/llama-server", + "-m", + "/m/dense.gguf", + "-c", + "32768", + "-b", + "2048", + "-ub", + "512", + "--host", + "0.0.0.0", + "--port", + "8080", + "--parallel", + "1", + "-ngl", + "auto" + ], + "env": {}, + "notes": null + }, + { + "m": 0, + "c": 21, + "h": 0, + "e": 0, + "args": [ + "/app/llama-server", + "-m", + "/m/dense.gguf", + "-c", + "32768", + "-b", + "2048", + "-ub", + "512", + "--host", + "0.0.0.0", + "--port", + "8080", + "--parallel", + "1", + "-ngl", + "auto", + "--cache-ram", + "4096" + ], + "env": {}, + "notes": null + }, + { + "m": 0, + "c": 22, + "h": 0, + "e": 0, + "args": [ + "/app/llama-server", + "-m", + "/m/dense.gguf", + "-c", + "32768", + "-b", + "2048", + "-ub", + "512", + "--host", + "0.0.0.0", + "--port", + "8080", + "--parallel", + "1", + "-ngl", + "auto", + "--cache-ram", + "0" + ], + "env": {}, + "notes": null + }, + { + "m": 0, + "c": 23, + "h": 0, + "e": 0, + "args": [ + "/app/llama-server", + "-m", + "/m/dense.gguf", + "-c", + "32768", + "-b", + "2048", + "-ub", + "512", + "--host", + "0.0.0.0", + "--port", + "8080", + "--parallel", + "1", + "-ngl", + "auto", + "--cache-ram", + "-1" + ], + "env": {}, + "notes": null + }, + { + "m": 0, + "c": 24, + "h": 0, + "e": 0, + "args": [ + "/app/llama-server", + "-m", + "/m/dense.gguf", + "-c", + "32768", + "-b", + "2048", + "-ub", + "512", + "--host", + "0.0.0.0", + "--port", + "8080", + "--parallel", + "1", + "-ngl", + "auto" + ], + "env": {}, + "notes": [ + "CACHE_RAM=beaucoup illisible (un nombre de Mio, -1, 0 ou auto) : ignoré" + ] + }, + { + "m": 0, + "c": 25, + "h": 0, + "e": 0, + "args": [ + "/app/llama-server", + "-m", + "/m/dense.gguf", + "-c", + "32768", + "-b", + "2048", + "-ub", + "512", + "--host", + "0.0.0.0", + "--port", + "8080", + "--parallel", + "1", + "-ngl", + "auto" + ], + "env": {}, + "notes": null + }, + { + "m": 0, + "c": 26, + "h": 0, + "e": 0, + "args": [ + "/app/llama-server", + "-m", + "/m/dense.gguf", + "-c", + "32768", + "-b", + "2048", + "-ub", + "512", + "--host", + "0.0.0.0", + "--port", + "8080", + "--parallel", + "1", + "-ngl", + "auto", + "--cache-ram", + "0" + ], + "env": {}, + "notes": null + }, + { + "m": 0, + "c": 27, + "h": 0, + "e": 0, + "args": [ + "/app/llama-server", + "-m", + "/m/dense.gguf", + "-c", + "32768", + "-b", + "2048", + "-ub", + "512", + "--host", + "0.0.0.0", + "--port", + "8080", + "--parallel", + "1", + "-ngl", + "auto" + ], + "env": {}, + "notes": [ + "SIDE_SLOT refusé (VRAM insuffisante : modèle + 2 × 8192 Mio d'état dépassent 90 % de 16311 Mio) : un seul slot, comme sans la clé" + ] + }, + { + "m": 0, + "c": 28, + "h": 0, + "e": 0, + "args": [ + "/app/llama-server", + "-m", + "/m/dense.gguf", + "-c", + "32768", + "-b", + "2048", + "-ub", + "512", + "--host", + "0.0.0.0", + "--port", + "8080", + "--parallel", + "3", + "-ngl", + "auto" + ], + "env": {}, + "notes": [ + "SIDE_SLOT refusé (PARALLEL=3 (SIDE_SLOT ouvre exactement 2 slots)) : un seul slot, comme sans la clé" + ] + }, + { + "m": 0, + "c": 29, + "h": 0, + "e": 0, + "args": [ + "/app/llama-server", + "-m", + "/m/dense.gguf", + "-c", + "0", + "-b", + "2048", + "-ub", + "512", + "--host", + "0.0.0.0", + "--port", + "8080", + "--parallel", + "1", + "-ngl", + "auto" + ], + "env": {}, + "notes": [ + "SIDE_SLOT refusé (CTX doit être un nombre de jetons explicite) : un seul slot, comme sans la clé" + ] + }, + { + "m": 0, + "c": 30, + "h": 0, + "e": 0, + "args": [ + "/app/llama-server", + "-m", + "/m/dense.gguf", + "-c", + "8192", + "-b", + "2048", + "-ub", + "512", + "--host", + "0.0.0.0", + "--port", + "8080", + "--parallel", + "1", + "-ngl", + "auto", + "-ctk", + "q8_0", + "-ctv", + "q8_0" + ], + "env": {}, + "notes": [ + "SIDE_SLOT refusé (VRAM insuffisante : modèle + 2 × 1088 Mio d'état dépassent 90 % de 16311 Mio) : un seul slot, comme sans la clé", + "cache KV q8_0/q8_0 (KV_TYPE) : modifie légèrement les sorties par rapport à f16 — choix de l'utilisateur, laissé tel quel." + ] + }, + { + "m": 0, + "c": 31, + "h": 0, + "e": 0, + "args": [ + "/app/llama-server", + "-m", + "/m/dense.gguf", + "-c", + "32768", + "-b", + "2048", + "-ub", + "512", + "--host", + "0.0.0.0", + "--port", + "8080", + "--parallel", + "1", + "-ngl", + "auto" + ], + "env": {}, + "notes": [ + "SPLIT_MODE=tensor refusé (cartes inconnues (--list-devices n'a rien répondu)) : découpe par couches, comme sans la clé" + ] + }, + { + "m": 0, + "c": 32, + "h": 0, + "e": 0, + "args": [ + "/app/llama-server", + "-m", + "/m/dense.gguf", + "-c", + "16384", + "-b", + "2048", + "-ub", + "512", + "--host", + "0.0.0.0", + "--port", + "8080", + "--parallel", + "1", + "-ngl", + "auto" + ], + "env": {}, + "notes": [ + "SPLIT_MODE=tensor refusé (cartes inconnues (--list-devices n'a rien répondu)) : découpe par couches, comme sans la clé" + ] + }, + { + "m": 0, + "c": 33, + "h": 0, + "e": 0, + "args": [ + "/app/llama-server", + "-m", + "/m/dense.gguf", + "-c", + "32768", + "-b", + "2048", + "-ub", + "512", + "--host", + "0.0.0.0", + "--port", + "8080", + "--parallel", + "1", + "-ngl", + "auto" + ], + "env": {}, + "notes": [ + "SPLIT_MODE=tensor refusé (SPLIT_MODE=bizarre illisible (tensor ou off)) : découpe par couches, comme sans la clé" + ] + }, + { + "m": 0, + "c": 34, + "h": 0, + "e": 0, + "args": [ + "/app/llama-server", + "-m", + "/m/dense.gguf", + "-c", + "32768", + "-b", + "2048", + "-ub", + "512", + "--host", + "0.0.0.0", + "--port", + "8080", + "--parallel", + "1", + "-ngl", + "auto" + ], + "env": {}, + "notes": null + }, + { + "m": 0, + "c": 35, + "h": 0, + "e": 0, + "args": [ + "/app/llama-server", + "-m", + "/m/dense.gguf", + "-c", + "32768", + "-b", + "2048", + "-ub", + "512", + "--host", + "0.0.0.0", + "--port", + "8080", + "--parallel", + "1", + "-ngl", + "auto", + "-ctk", + "q8_0", + "-ctv", + "q8_0" + ], + "env": {}, + "notes": [ + "SPLIT_MODE=tensor refusé (cache KV q8_0/q8_0 : le mode tensor n'accepte que f16, bf16 ou f32 (jamais réécrit d'office : la VRAM changerait)) : découpe par couches, comme sans la clé", + "cache KV q8_0/q8_0 (KV_TYPE) : modifie légèrement les sorties par rapport à f16 — choix de l'utilisateur, laissé tel quel." + ] + }, + { + "m": 0, + "c": 36, + "h": 0, + "e": 0, + "args": [ + "/app/llama-server", + "-m", + "/m/dense.gguf", + "-c", + "32768", + "-b", + "2048", + "-ub", + "512", + "--host", + "0.0.0.0", + "--port", + "8080", + "--parallel", + "1", + "-ngl", + "20" + ], + "env": {}, + "notes": [ + "SPLIT_MODE=tensor refusé (NGL=20 : le mode tensor veut toutes les couches sur les cartes (all)) : découpe par couches, comme sans la clé" + ] + }, + { + "m": 0, + "c": 37, + "h": 0, + "e": 0, + "args": [ + "/app/llama-server", + "-m", + "/m/dense.gguf", + "-c", + "32768", + "-b", + "2048", + "-ub", + "512", + "--host", + "0.0.0.0", + "--port", + "8080", + "--parallel", + "1", + "-ngl", + "auto" + ], + "env": {}, + "notes": null + }, + { + "m": 0, + "c": 38, + "h": 0, + "e": 0, + "args": [ + "/app/llama-server", + "-m", + "/m/dense.gguf", + "-c", + "32768", + "-b", + "2048", + "-ub", + "512", + "--host", + "0.0.0.0", + "--port", + "8080", + "--parallel", + "1", + "-ngl", + "auto" + ], + "env": {}, + "notes": [ + "SPEC=mtp : pas de tête MTP dans ce fichier (publiée à part ? MODEL_DRAFT=mtp-….gguf) — sans décodage spéculatif" + ] + }, + { + "m": 0, + "c": 39, + "h": 0, + "e": 0, + "args": [ + "/app/llama-server", + "-m", + "/m/dense.gguf", + "-c", + "32768", + "-b", + "2048", + "-ub", + "512", + "--host", + "0.0.0.0", + "--port", + "8080", + "--parallel", + "1", + "-ngl", + "auto", + "--spec-type", + "ngram-mod", + "--spec-ngram-mod-n-match", + "24", + "--spec-ngram-mod-n-min", + "48", + "--spec-ngram-mod-n-max", + "64" + ], + "env": {}, + "notes": [ + "SPEC=ngram → n-grammes du contexte (ngram-mod, brouillons de 48 à 64 jetons) : distribution inchangée (chaque jeton vérifié), ~16 Mo de RAM ; utile surtout en mode code, à mesurer (acceptation par nature dans /api/perf/summary)" + ] + }, + { + "m": 0, + "c": 40, + "h": 0, + "e": 0, + "args": [ + "/app/llama-server", + "-m", + "/m/dense.gguf", + "-c", + "32768", + "-b", + "2048", + "-ub", + "512", + "--host", + "0.0.0.0", + "--port", + "8080", + "--parallel", + "1", + "-ngl", + "auto", + "--spec-type", + "ngram-mod", + "--spec-ngram-mod-n-match", + "24", + "--spec-ngram-mod-n-min", + "48", + "--spec-ngram-mod-n-max", + "64" + ], + "env": {}, + "notes": [ + "SPEC=mtp+ngram : sans MTP (pas de tête MTP dans ce fichier (publiée à part ? MODEL_DRAFT=mtp-….gguf)) → n-grammes seuls. SPEC=ngram → n-grammes du contexte (ngram-mod, brouillons de 48 à 64 jetons) : distribution inchangée (chaque jeton vérifié), ~16 Mo de RAM ; utile surtout en mode code, à mesurer (acceptation par nature dans /api/perf/summary)" + ] + }, + { + "m": 0, + "c": 41, + "h": 0, + "e": 0, + "args": [ + "/app/llama-server", + "-m", + "/m/dense.gguf", + "-c", + "32768", + "-b", + "2048", + "-ub", + "512", + "--host", + "0.0.0.0", + "--port", + "8080", + "--parallel", + "1", + "-ngl", + "auto", + "--spec-type", + "ngram-mod", + "--spec-ngram-mod-n-match", + "24", + "--spec-ngram-mod-n-min", + "48", + "--spec-ngram-mod-n-max", + "64" + ], + "env": {}, + "notes": [ + "SPEC=mtp+ngram : sans MTP (pas de tête MTP dans ce fichier (publiée à part ? MODEL_DRAFT=mtp-….gguf)) → n-grammes seuls. SPEC=ngram → n-grammes du contexte (ngram-mod, brouillons de 48 à 64 jetons) : distribution inchangée (chaque jeton vérifié), ~16 Mo de RAM ; utile surtout en mode code, à mesurer (acceptation par nature dans /api/perf/summary)" + ] + }, + { + "m": 0, + "c": 42, + "h": 0, + "e": 0, + "args": [ + "/app/llama-server", + "-m", + "/m/dense.gguf", + "-c", + "32768", + "-b", + "2048", + "-ub", + "512", + "--host", + "0.0.0.0", + "--port", + "8080", + "--parallel", + "1", + "-ngl", + "auto" + ], + "env": {}, + "notes": [ + "SPEC=turbo illisible (off, auto, mtp, ngram ou mtp+ngram) : sans décodage spéculatif" + ] + }, + { + "m": 0, + "c": 43, + "h": 0, + "e": 0, + "args": [ + "/app/llama-server", + "-m", + "/m/dense.gguf", + "-c", + "32768", + "-b", + "2048", + "-ub", + "512", + "--host", + "0.0.0.0", + "--port", + "8080", + "--parallel", + "1", + "-ngl", + "auto" + ], + "env": {}, + "notes": [ + "MODEL_DRAFT ignoré : SPEC=off (auto ou mtp pour s'en servir)" + ] + }, + { + "m": 0, + "c": 44, + "h": 0, + "e": 0, + "args": [ + "/app/llama-server", + "-m", + "/m/dense.gguf", + "-c", + "32768", + "-b", + "2048", + "-ub", + "512", + "--host", + "0.0.0.0", + "--port", + "8080", + "--parallel", + "1", + "-ngl", + "auto" + ], + "env": {}, + "notes": [ + "SPEC=mtp : MODEL_DRAFT=mtp-head.gguf introuvable — sans décodage spéculatif" + ] + }, + { + "m": 0, + "c": 45, + "h": 0, + "e": 0, + "args": [ + "/app/llama-server", + "-m", + "/m/dense.gguf", + "-c", + "32768", + "-b", + "2048", + "-ub", + "512", + "--host", + "0.0.0.0", + "--port", + "8080", + "--parallel", + "1", + "-ngl", + "auto" + ], + "env": {}, + "notes": [ + "SPEC=mtp : pas de tête MTP dans ce fichier (publiée à part ? MODEL_DRAFT=mtp-….gguf) — sans décodage spéculatif" + ] + }, + { + "m": 0, + "c": 46, + "h": 0, + "e": 0, + "args": [ + "/app/llama-server", + "-m", + "/m/dense.gguf", + "-c", + "32768", + "-b", + "2048", + "-ub", + "512", + "--host", + "0.0.0.0", + "--port", + "8080", + "--parallel", + "1", + "-ngl", + "auto" + ], + "env": {}, + "notes": null + }, + { + "m": 0, + "c": 47, + "h": 0, + "e": 0, + "args": [ + "/app/llama-server", + "-m", + "/m/dense.gguf", + "-c", + "32768", + "-b", + "2048", + "-ub", + "512", + "--host", + "0.0.0.0", + "--port", + "8080", + "--parallel", + "1", + "-ngl", + "auto" + ], + "env": {}, + "notes": [ + "SPEC=mtp : pas de tête MTP dans ce fichier (publiée à part ? MODEL_DRAFT=mtp-….gguf) — sans décodage spéculatif" + ] + }, + { + "m": 0, + "c": 48, + "h": 0, + "e": 0, + "args": [ + "/app/llama-server", + "-m", + "/m/dense.gguf", + "-c", + "32768", + "-b", + "2048", + "-ub", + "512", + "--host", + "0.0.0.0", + "--port", + "8080", + "--parallel", + "1", + "-ngl", + "auto", + "--spec-type", + "ngram-mod", + "--spec-ngram-mod-n-match", + "24", + "--spec-ngram-mod-n-min", + "48", + "--spec-ngram-mod-n-max", + "64" + ], + "env": {}, + "notes": [ + "SPEC=ngram → n-grammes du contexte (ngram-mod, brouillons de 48 à 64 jetons) : distribution inchangée (chaque jeton vérifié), ~16 Mo de RAM ; utile surtout en mode code, à mesurer (acceptation par nature dans /api/perf/summary)", + "MODEL_DRAFT ignoré avec SPEC=ngram : SPEC=mtp+ngram pour les deux", + "SPEC_N_MAX et SPEC_SAMPLING ignorés : les n-grammes n'ont pas de brouillon à régler" + ] + }, + { + "m": 0, + "c": 49, + "h": 0, + "e": 0, + "args": [ + "/app/llama-server", + "-m", + "/m/dense.gguf", + "-c", + "32768", + "-b", + "2048", + "-ub", + "512", + "--host", + "0.0.0.0", + "--port", + "8080", + "--parallel", + "1", + "-ngl", + "auto" + ], + "env": {}, + "notes": [ + "SPEC=auto : MODEL_DRAFT=small.gguf introuvable — sans décodage spéculatif" + ] + }, + { + "m": 0, + "c": 50, + "h": 0, + "e": 0, + "args": [ + "/app/llama-server", + "-m", + "/m/dense.gguf", + "-c", + "32768", + "-b", + "2048", + "-ub", + "512", + "--host", + "0.0.0.0", + "--port", + "8080", + "--parallel", + "1", + "-ngl", + "auto", + "--spec-type", + "ngram-mod", + "--spec-ngram-mod-n-match", + "24", + "--spec-ngram-mod-n-min", + "48", + "--spec-ngram-mod-n-max", + "64" + ], + "env": {}, + "notes": [ + "SPEC=mtp+ngram : sans MTP (MODEL_DRAFT=small.gguf introuvable) → n-grammes seuls. SPEC=ngram → n-grammes du contexte (ngram-mod, brouillons de 48 à 64 jetons) : distribution inchangée (chaque jeton vérifié), ~16 Mo de RAM ; utile surtout en mode code, à mesurer (acceptation par nature dans /api/perf/summary)" + ] + }, + { + "m": 0, + "c": 51, + "h": 0, + "e": 0, + "args": [ + "/app/llama-server", + "-m", + "/m/dense.gguf", + "-c", + "32768", + "-b", + "2048", + "-ub", + "512", + "--host", + "0.0.0.0", + "--port", + "8080", + "--parallel", + "1", + "-ngl", + "auto" + ], + "env": {}, + "notes": null + }, + { + "m": 0, + "c": 52, + "h": 0, + "e": 0, + "args": [ + "/app/llama-server", + "-m", + "/m/dense.gguf", + "-c", + "32768", + "-b", + "2048", + "-ub", + "512", + "--host", + "0.0.0.0", + "--port", + "8080", + "--parallel", + "1", + "-ngl", + "auto" + ], + "env": { + "CUDA_SCALE_LAUNCH_QUEUES": "2x" + }, + "notes": [ + "CUDA_SCALE_LAUNCH_QUEUES=2x (CUDA_LAUNCH_QUEUES)" + ] + }, + { + "m": 0, + "c": 53, + "h": 0, + "e": 0, + "args": [ + "/app/llama-server", + "-m", + "/m/dense.gguf", + "-c", + "32768", + "-b", + "2048", + "-ub", + "512", + "--host", + "0.0.0.0", + "--port", + "8080", + "--parallel", + "1", + "-ngl", + "auto" + ], + "env": {}, + "notes": [ + "CUDA_LAUNCH_QUEUES=8x ignoré (0.25x, 0.5x, 2x, 4x ou off) → automatique" + ] + }, + { + "m": 0, + "c": 54, + "h": 0, + "e": 0, + "args": [ + "/app/llama-server", + "-m", + "/m/dense.gguf", + "-c", + "32768", + "-b", + "2048", + "-ub", + "512", + "--host", + "0.0.0.0", + "--port", + "8080", + "--parallel", + "1", + "-ngl", + "auto" + ], + "env": {}, + "notes": null + }, + { + "m": 0, + "c": 55, + "h": 0, + "e": 0, + "args": [ + "/app/llama-server", + "-m", + "/m/dense.gguf", + "-c", + "32768", + "-b", + "2048", + "-ub", + "512", + "--host", + "0.0.0.0", + "--port", + "8080", + "--parallel", + "1", + "-ngl", + "auto" + ], + "env": { + "CUDA_DEVICE_ORDER": "PCI_BUS_ID", + "CUDA_VISIBLE_DEVICES": "1,0" + }, + "notes": null + }, + { + "m": 0, + "c": 56, + "h": 0, + "e": 0, + "args": [ + "/app/llama-server", + "-m", + "/m/dense.gguf", + "-c", + "32768", + "-b", + "2048", + "-ub", + "512", + "--host", + "0.0.0.0", + "--port", + "8080", + "--parallel", + "1", + "-ngl", + "auto", + "--fit-target", + "1024,3072" + ], + "env": {}, + "notes": [ + "--fit-target 1024,3072 (FIT_TARGET, Mio libres par carte, dans l'ordre vu par le moteur) — vérifie « offloaded N/N layers » dans le journal" + ] + }, + { + "m": 0, + "c": 57, + "h": 0, + "e": 0, + "args": [ + "/app/llama-server", + "-m", + "/m/dense.gguf", + "-c", + "32768", + "-b", + "2048", + "-ub", + "512", + "--host", + "0.0.0.0", + "--port", + "8080", + "--parallel", + "1", + "-ngl", + "auto", + "--fit-target", + "1024" + ], + "env": {}, + "notes": [ + "--fit-target 1024 (FIT_TARGET, Mio libres par carte, dans l'ordre vu par le moteur) — vérifie « offloaded N/N layers » dans le journal ; marges sous 1024 Mio remontées à 1024" + ] + }, + { + "m": 0, + "c": 58, + "h": 0, + "e": 0, + "args": [ + "/app/llama-server", + "-m", + "/m/dense.gguf", + "-c", + "32768", + "-b", + "2048", + "-ub", + "512", + "--host", + "0.0.0.0", + "--port", + "8080", + "--parallel", + "1", + "-ngl", + "auto" + ], + "env": {}, + "notes": [ + "FIT_TARGET=1024,,3072 ignoré : des Mio par carte, ex. 1024,3072 (8 valeurs au plus)" + ] + }, + { + "m": 0, + "c": 59, + "h": 0, + "e": 0, + "args": [ + "/app/llama-server", + "-m", + "/m/dense.gguf", + "-c", + "32768", + "-b", + "2048", + "-ub", + "512", + "--host", + "0.0.0.0", + "--port", + "8080", + "--parallel", + "1", + "-ngl", + "auto" + ], + "env": {}, + "notes": [ + "FIT_TARGET=1,2,3,4,5,6,7,8,9 ignoré : des Mio par carte, ex. 1024,3072 (8 valeurs au plus)" + ] + }, + { + "m": 0, + "c": 60, + "h": 0, + "e": 0, + "args": [ + "/app/llama-server", + "-m", + "/m/dense.gguf", + "-c", + "0", + "-b", + "2048", + "-ub", + "512", + "--host", + "0.0.0.0", + "--port", + "8080", + "--parallel", + "1", + "-ngl", + "auto" + ], + "env": {}, + "notes": [ + "FIT_TARGET=2048/4096 ignoré : contexte « 0 » non fixé — --fit pourrait le réduire pour tenir ; donne un CTX chiffré" + ] + }, + { + "m": 0, + "c": 61, + "h": 0, + "e": 0, + "args": [ + "/app/llama-server", + "-m", + "/m/dense.gguf", + "-c", + "32768", + "-b", + "2048", + "-ub", + "512", + "--host", + "0.0.0.0", + "--port", + "8080", + "--parallel", + "1", + "-ngl", + "30" + ], + "env": {}, + "notes": [ + "FIT_TARGET=2048 ignoré : --fit inactif (couches GPU fixées à 30), la marge ne servirait à rien" + ] + }, + { + "m": 0, + "c": 62, + "h": 0, + "e": 0, + "args": [ + "/app/llama-server", + "-m", + "/m/dense.gguf", + "-c", + "32768", + "-b", + "2048", + "-ub", + "512", + "--host", + "0.0.0.0", + "--port", + "8080", + "--parallel", + "1", + "-ngl", + "auto" + ], + "env": { + "GGML_OP_OFFLOAD_MIN_BATCH": "128" + }, + "notes": [ + "GGML_OP_OFFLOAD_MIN_BATCH=128 (OP_OFFLOAD_MIN_BATCH) : lots de moins de 128 jetons calculés sur CPU pour les poids restés en RAM" + ] + }, + { + "m": 0, + "c": 63, + "h": 0, + "e": 0, + "args": [ + "/app/llama-server", + "-m", + "/m/dense.gguf", + "-c", + "32768", + "-b", + "2048", + "-ub", + "512", + "--host", + "0.0.0.0", + "--port", + "8080", + "--parallel", + "1", + "-ngl", + "auto" + ], + "env": {}, + "notes": [ + "OP_OFFLOAD_MIN_BATCH=0 ignoré : un nombre de jetons, ex. 128" + ] + }, + { + "m": 0, + "c": 64, + "h": 0, + "e": 0, + "args": [ + "/app/llama-server", + "-m", + "/m/dense.gguf", + "-c", + "32768", + "-b", + "2048", + "-ub", + "512", + "--host", + "0.0.0.0", + "--port", + "8080", + "--parallel", + "1", + "-ngl", + "auto" + ], + "env": {}, + "notes": null + }, + { + "m": 0, + "c": 65, + "h": 0, + "e": 0, + "args": [ + "/app/llama-server", + "-m", + "/m/dense.gguf", + "-c", + "32768", + "-b", + "2048", + "-ub", + "512", + "--host", + "0.0.0.0", + "--port", + "8080", + "--parallel", + "1", + "-ngl", + "auto" + ], + "env": {}, + "notes": [ + "CUDA_GRAPH_OPT=oui ignoré (on ou off)" + ] + }, + { + "m": 0, + "c": 66, + "h": 0, + "e": 0, + "args": [ + "/app/llama-server", + "-m", + "/m/dense.gguf", + "-c", + "32768", + "-b", + "2048", + "-ub", + "512", + "--host", + "0.0.0.0", + "--port", + "8080", + "--parallel", + "1", + "-ngl", + "auto" + ], + "env": {}, + "notes": null + }, + { + "m": 0, + "c": 67, + "h": 0, + "e": 0, + "args": [ + "/app/llama-server", + "-m", + "/m/dense.gguf", + "-c", + "32768", + "-b", + "2048", + "-ub", + "512", + "--host", + "0.0.0.0", + "--port", + "8080", + "--parallel", + "1", + "-ngl", + "auto", + "--ctx-checkpoints", + "8" + ], + "env": {}, + "notes": null + }, + { + "m": 0, + "c": 68, + "h": 0, + "e": 0, + "args": [ + "/app/llama-server", + "-m", + "/m/dense.gguf", + "-c", + "32768", + "-b", + "2048", + "-ub", + "512", + "--host", + "0.0.0.0", + "--port", + "8080", + "--parallel", + "1", + "-ngl", + "auto" + ], + "env": {}, + "notes": [ + "CTX_CHECKPOINTS=beaucoup illisible (un nombre de points de reprise par slot, ≥ 0) : ignoré" + ] + }, + { + "m": 0, + "c": 69, + "h": 0, + "e": 0, + "args": [ + "/app/llama-server", + "-m", + "/m/dense.gguf", + "-c", + "32768", + "-b", + "2048", + "-ub", + "512", + "--host", + "0.0.0.0", + "--port", + "8080", + "--parallel", + "1", + "-ngl", + "auto", + "--checkpoint-min-step", + "1024" + ], + "env": {}, + "notes": null + }, + { + "m": 0, + "c": 70, + "h": 0, + "e": 0, + "args": [ + "/app/llama-server", + "-m", + "/m/dense.gguf", + "-c", + "32768", + "-b", + "2048", + "-ub", + "512", + "--host", + "0.0.0.0", + "--port", + "8080", + "--parallel", + "1", + "-ngl", + "auto" + ], + "env": {}, + "notes": [ + "CKPT_MIN_STEP=0 refusé (un nombre de jetons > 0 ; 0 laisserait le moteur jeter le point de reprise du début de la conversation) : défaut du moteur" + ] + }, + { + "m": 0, + "c": 71, + "h": 0, + "e": 0, + "args": [ + "/app/llama-server", + "-m", + "/m/dense.gguf", + "-c", + "32768", + "-b", + "2048", + "-ub", + "512", + "--host", + "0.0.0.0", + "--port", + "8080", + "--parallel", + "1", + "-ngl", + "auto" + ], + "env": {}, + "notes": [ + "SLOT_PERSIST refusé (dossier des slots indisponible) : l'état du slot ne sera pas gardé à la bascule de preset" + ] + }, + { + "m": 0, + "c": 72, + "h": 0, + "e": 0, + "args": [ + "/app/llama-server", + "-m", + "/m/dense.gguf", + "-c", + "32768", + "-b", + "2048", + "-ub", + "512", + "--host", + "127.0.0.1", + "--port", + "8080", + "--parallel", + "1", + "-ngl", + "auto" + ], + "env": {}, + "notes": [ + "SLOT_PERSIST refusé (dossier des slots indisponible) : l'état du slot ne sera pas gardé à la bascule de preset" + ] + }, + { + "m": 0, + "c": 73, + "h": 0, + "e": 0, + "args": [ + "/app/llama-server", + "-m", + "/m/dense.gguf", + "-c", + "32768", + "-b", + "2048", + "-ub", + "512", + "--host", + "127.0.0.1", + "--port", + "8080", + "--parallel", + "1", + "-ngl", + "auto" + ], + "env": {}, + "notes": [ + "SLOT_PERSIST refusé (dossier des slots indisponible) : l'état du slot ne sera pas gardé à la bascule de preset", + "SPEC=mtp : pas de tête MTP dans ce fichier (publiée à part ? MODEL_DRAFT=mtp-….gguf) — sans décodage spéculatif" + ] + }, + { + "m": 0, + "c": 74, + "h": 0, + "e": 0, + "args": [ + "/app/llama-server", + "-m", + "/m/dense.gguf", + "-c", + "32768", + "-b", + "2048", + "-ub", + "512", + "--host", + "0.0.0.0", + "--port", + "8080", + "--parallel", + "1", + "-ngl", + "auto", + "--load-mode", + "none" + ], + "env": {}, + "notes": null + }, + { + "m": 0, + "c": 75, + "h": 0, + "e": 0, + "args": [ + "/app/llama-server", + "-m", + "/m/dense.gguf", + "-c", + "32768", + "-b", + "2048", + "-ub", + "512", + "--host", + "0.0.0.0", + "--port", + "8080", + "--parallel", + "1", + "-ngl", + "auto", + "--load-mode", + "mlock" + ], + "env": {}, + "notes": null + }, + { + "m": 0, + "c": 76, + "h": 0, + "e": 0, + "args": [ + "/app/llama-server", + "-m", + "/m/dense.gguf", + "-c", + "32768", + "-b", + "2048", + "-ub", + "512", + "--host", + "0.0.0.0", + "--port", + "8080", + "--parallel", + "1", + "-ngl", + "auto", + "--load-mode", + "mmap+mlock" + ], + "env": {}, + "notes": null + }, + { + "m": 0, + "c": 77, + "h": 0, + "e": 0, + "args": [ + "/app/llama-server", + "-m", + "/m/dense.gguf", + "-c", + "32768", + "-b", + "2048", + "-ub", + "512", + "--host", + "0.0.0.0", + "--port", + "8080", + "--parallel", + "1", + "-ngl", + "auto", + "--load-mode", + "none" + ], + "env": {}, + "notes": null + }, + { + "m": 0, + "c": 78, + "h": 0, + "e": 0, + "args": [ + "/app/llama-server", + "-m", + "/m/dense.gguf", + "-c", + "32768", + "-b", + "2048", + "-ub", + "512", + "--host", + "0.0.0.0", + "--port", + "8080", + "--parallel", + "1", + "-ngl", + "auto", + "--load-mode", + "mmap" + ], + "env": {}, + "notes": null + }, + { + "m": 0, + "c": 79, + "h": 0, + "e": 0, + "args": [ + "/app/llama-server", + "-m", + "/m/dense.gguf", + "-c", + "32768", + "-b", + "2048", + "-ub", + "512", + "--host", + "0.0.0.0", + "--port", + "8080", + "--parallel", + "1", + "-ngl", + "auto", + "--load-mode", + "dio" + ], + "env": {}, + "notes": null + }, + { + "m": 0, + "c": 80, + "h": 0, + "e": 0, + "args": [ + "/app/llama-server", + "-m", + "/m/dense.gguf", + "-c", + "32768", + "-b", + "2048", + "-ub", + "512", + "--host", + "0.0.0.0", + "--port", + "8080", + "--parallel", + "1", + "-ngl", + "auto", + "--load-mode=mlock" + ], + "env": {}, + "notes": null + }, + { + "m": 0, + "c": 81, + "h": 0, + "e": 0, + "args": [ + "/app/llama-server", + "-m", + "/m/dense.gguf", + "-c", + "32768", + "-b", + "2048", + "-ub", + "512", + "--host", + "0.0.0.0", + "--port", + "8080", + "-ngl", + "30", + "--parallel", + "4" + ], + "env": {}, + "notes": null + }, + { + "m": 0, + "c": 82, + "h": 0, + "e": 0, + "args": [ + "/app/llama-server", + "-m", + "/m/dense.gguf", + "-c", + "32768", + "-b", + "2048", + "-ub", + "512", + "--host", + "0.0.0.0", + "--port", + "8080", + "--parallel", + "1", + "-ngl", + "auto", + "-t", + "6", + "-tb", + "3" + ], + "env": {}, + "notes": null + }, + { + "m": 0, + "c": 83, + "h": 0, + "e": 0, + "args": [ + "/app/llama-server", + "-m", + "/m/dense.gguf", + "-c", + "32768", + "-tb", + "0", + "-b", + "2048", + "-ub", + "512", + "--host", + "0.0.0.0", + "--port", + "8080", + "--parallel", + "1", + "-ngl", + "auto", + "-Cb", + "0xff", + "--poll-batch", + "1" + ], + "env": {}, + "notes": [ + "EXTRA_ARGS règle le CPU du batch (-Cb, --poll-batch…) : -tb 0 posé pour que llama.cpp le garde (tous les threads logiques) ; THREADS_BATCH pour choisir le nombre" + ] + }, + { + "m": 0, + "c": 84, + "h": 0, + "e": 0, + "args": [ + "/app/llama-server", + "-m", + "/m/dense.gguf", + "-c", + "32768", + "-t", + "6", + "-tb", + "6", + "-b", + "2048", + "-ub", + "512", + "--host", + "0.0.0.0", + "--port", + "8080", + "--parallel", + "1", + "-ngl", + "auto", + "--prio-batch", + "2" + ], + "env": {}, + "notes": null + }, + { + "m": 0, + "c": 85, + "h": 0, + "e": 0, + "args": [ + "/app/llama-server", + "-m", + "/m/dense.gguf", + "-c", + "32768", + "-b", + "2048", + "-ub", + "512", + "--host", + "0.0.0.0", + "--port", + "8080", + "--parallel", + "1", + "-ngl", + "auto", + "--cache-reuse", + "256", + "--context-shift" + ], + "env": {}, + "notes": [ + "avertissement : --context-shift (EXTRA_ARGS) — contexte plein, le moteur jette des jetons anciens et le modèle perd une partie de la conversation sans le savoir.", + "avertissement : --cache-reuse 256 (EXTRA_ARGS) — réutilise des morceaux de cache calculés sous un autre préfixe : les sorties ne sont plus exactement celles d'un calcul complet." + ] + }, + { + "m": 0, + "c": 86, + "h": 0, + "e": 0, + "args": [ + "/app/llama-server", + "-m", + "/m/dense.gguf", + "-c", + "32768", + "-b", + "2048", + "-ub", + "512", + "--host", + "0.0.0.0", + "--port", + "8080", + "--parallel", + "1", + "-ngl", + "auto", + "-ctk", + "q8_0", + "-ctv", + "q8_0" + ], + "env": {}, + "notes": [ + "cache KV q8_0/q8_0 (défini par EXTRA_ARGS) : modifie légèrement les sorties par rapport à f16 — choix de l'utilisateur, laissé tel quel." + ] + }, + { + "m": 0, + "c": 87, + "h": 0, + "e": 0, + "args": [ + "/app/llama-server", + "-m", + "/m/dense.gguf", + "-c", + "32768", + "-b", + "2048", + "-ub", + "512", + "--host", + "0.0.0.0", + "--port", + "8080", + "--parallel", + "1", + "-ngl", + "auto", + "-ot", + "exps=CPU" + ], + "env": {}, + "notes": null + }, + { + "m": 0, + "c": 88, + "h": 0, + "e": 0, + "args": [ + "/app/llama-server", + "-m", + "/m/dense.gguf", + "-c", + "32768", + "-b", + "2048", + "-ub", + "512", + "--host", + "0.0.0.0", + "--port", + "8080", + "--parallel", + "1", + "-ngl", + "auto", + "-ot", + "blk\\.(1|2)\\.ffn_.*_exps=CPU", + "-ub", + "512" + ], + "env": {}, + "notes": null + }, + { + "m": 0, + "c": 89, + "h": 0, + "e": 0, + "args": [ + "/app/llama-server", + "-m", + "/m/dense.gguf", + "-c", + "32768", + "-b", + "2048", + "-ub", + "512", + "--host", + "0.0.0.0", + "--port", + "8080", + "--parallel", + "1", + "-ngl", + "auto", + "--n-cpu-moe", + "20" + ], + "env": {}, + "notes": null + }, + { + "m": 0, + "c": 90, + "h": 0, + "e": 0, + "args": [ + "/app/llama-server", + "-m", + "/m/dense.gguf", + "-c", + "32768", + "-b", + "2048", + "-ub", + "512", + "--host", + "0.0.0.0", + "--port", + "8080", + "--parallel", + "1", + "-ngl", + "auto", + "--cpu-moe" + ], + "env": {}, + "notes": null + }, + { + "m": 0, + "c": 91, + "h": 0, + "e": 0, + "args": [ + "/app/llama-server", + "-m", + "/m/dense.gguf", + "-c", + "32768", + "-b", + "2048", + "-ub", + "512", + "--host", + "0.0.0.0", + "--port", + "8080", + "--parallel", + "1", + "-ngl", + "auto", + "-sm", + "row" + ], + "env": {}, + "notes": null + }, + { + "m": 0, + "c": 92, + "h": 0, + "e": 0, + "args": [ + "/app/llama-server", + "-m", + "/m/dense.gguf", + "-c", + "32768", + "-b", + "2048", + "-ub", + "512", + "--host", + "0.0.0.0", + "--port", + "8080", + "--parallel", + "1", + "-ngl", + "auto", + "-ts", + "1,1" + ], + "env": {}, + "notes": null + }, + { + "m": 0, + "c": 93, + "h": 0, + "e": 0, + "args": [ + "/app/llama-server", + "-m", + "/m/dense.gguf", + "-c", + "32768", + "-b", + "2048", + "-ub", + "512", + "--host", + "0.0.0.0", + "--port", + "8080", + "--parallel", + "1", + "-ngl", + "auto", + "-dev", + "CUDA0,CUDA1" + ], + "env": { + "CUDA_SCALE_LAUNCH_QUEUES": "4x" + }, + "notes": [ + "CUDA_SCALE_LAUNCH_QUEUES=4x (2 GPU, parallélisme pipeline possible) — CUDA_LAUNCH_QUEUES=off pour s'en passer" + ] + }, + { + "m": 0, + "c": 94, + "h": 0, + "e": 0, + "args": [ + "/app/llama-server", + "-m", + "/m/dense.gguf", + "-c", + "32768", + "-b", + "2048", + "-ub", + "512", + "--host", + "0.0.0.0", + "--port", + "8080", + "--parallel", + "1", + "-ngl", + "auto", + "-dev", + "Vulkan0" + ], + "env": {}, + "notes": null + }, + { + "m": 0, + "c": 95, + "h": 0, + "e": 0, + "args": [ + "/app/llama-server", + "-m", + "/m/dense.gguf", + "-c", + "32768", + "-b", + "2048", + "-ub", + "512", + "--host", + "0.0.0.0", + "--port", + "8080", + "--parallel", + "1", + "-ngl", + "auto", + "--fit", + "off" + ], + "env": {}, + "notes": null + }, + { + "m": 0, + "c": 96, + "h": 0, + "e": 0, + "args": [ + "/app/llama-server", + "-m", + "/m/dense.gguf", + "-c", + "32768", + "-b", + "2048", + "-ub", + "512", + "--host", + "0.0.0.0", + "--port", + "8080", + "--parallel", + "1", + "-ngl", + "auto", + "--mmproj", + "/m/x.gguf" + ], + "env": {}, + "notes": null + }, + { + "m": 0, + "c": 97, + "h": 0, + "e": 0, + "args": [ + "/app/llama-server", + "-m", + "/m/dense.gguf", + "-c", + "32768", + "-b", + "2048", + "-ub", + "512", + "--host", + "0.0.0.0", + "--port", + "8080", + "--parallel", + "1", + "-ngl", + "auto", + "--spec-synth-len", + "4" + ], + "env": {}, + "notes": [ + "avertissement : --spec-synth-len — le moteur accepte des jetons du brouillon AU HASARD, sans vérification : les réponses ne sont plus celles du modèle. Réservé aux mesures, retire-le." + ] + }, + { + "m": 0, + "c": 98, + "h": 0, + "e": 0, + "args": [ + "/app/llama-server", + "-m", + "/m/dense.gguf", + "-c", + "32768", + "-b", + "2048", + "-ub", + "512", + "--host", + "0.0.0.0", + "--port", + "8080", + "--parallel", + "1", + "-ngl", + "auto", + "--slot-save-path", + "/x" + ], + "env": {}, + "notes": null + }, + { + "m": 0, + "c": 99, + "h": 0, + "e": 0, + "args": [ + "/app/llama-server", + "-m", + "/m/dense.gguf", + "-c", + "32768", + "-b", + "2048", + "-ub", + "512", + "--host", + "0.0.0.0", + "--port", + "8080", + "--parallel", + "1", + "-ngl", + "auto", + "-c", + "0" + ], + "env": {}, + "notes": null + }, + { + "m": 0, + "c": 100, + "h": 0, + "e": 0, + "args": [ + "/app/llama-server", + "-m", + "/m/dense.gguf", + "-c", + "32768", + "-b", + "2048", + "-ub", + "512", + "--host", + "0.0.0.0", + "--port", + "8080", + "--parallel", + "1", + "-ngl", + "auto", + "--host", + "127.0.0.1", + "--port", + "9000" + ], + "env": {}, + "notes": null + }, + { + "m": 0, + "c": 101, + "h": 0, + "e": 0, + "args": [ + "/app/llama-server", + "-m", + "/m/dense.gguf", + "-c", + "32768", + "-b", + "2048", + "-ub", + "512", + "--host", + "0.0.0.0", + "--port", + "8080", + "--parallel", + "1", + "-ngl", + "auto", + "--mirostat", + "2" + ], + "env": {}, + "notes": [ + "SPEC=mtp : pas de tête MTP dans ce fichier (publiée à part ? MODEL_DRAFT=mtp-….gguf) — sans décodage spéculatif" + ] + }, + { + "m": 0, + "c": 102, + "h": 0, + "e": 0, + "args": [ + "/app/llama-server", + "-m", + "/m/dense.gguf", + "-c", + "32768", + "-b", + "2048", + "-ub", + "512", + "--host", + "0.0.0.0", + "--port", + "8080", + "--parallel", + "1", + "-ngl", + "auto", + "--chat-template-kwargs", + "{\"preserve_reasoning\":true}" + ], + "env": {}, + "notes": [ + "--chat-template-kwargs fixe déjà preserve_reasoning : REASONING_PRESERVE ignoré" + ] + }, + { + "m": 0, + "c": 103, + "h": 0, + "e": 0, + "args": [ + "/app/llama-server", + "-m", + "/m/dense.gguf", + "-c", + "32768", + "-b", + "2048", + "-ub", + "512", + "--host", + "0.0.0.0", + "--port", + "8080", + "--parallel", + "1", + "-ngl", + "auto", + "--spec-type", + "ngram-mod" + ], + "env": {}, + "notes": [ + "décodage spéculatif déjà réglé dans EXTRA_ARGS ou LLAMA_ARG_* : SPEC ignoré" + ] + }, + { + "m": 0, + "c": 104, + "h": 0, + "e": 0, + "args": [ + "/app/llama-server", + "-m", + "/m/dense.gguf", + "-c", + "32768", + "-b", + "2048", + "-ub", + "512", + "--host", + "0.0.0.0", + "--port", + "8080", + "--parallel", + "1", + "-ngl", + "auto", + "--spec-draft-n-max", + "8" + ], + "env": {}, + "notes": [ + "SPEC=ngram : --spec-draft-n-max dans EXTRA_ARGS (brouillon réglé à la main) — sans décodage spéculatif" + ] + }, + { + "m": 0, + "c": 105, + "h": 0, + "e": 0, + "args": [ + "/app/llama-server", + "-m", + "/m/dense.gguf", + "-c", + "32768", + "-b", + "2048", + "-ub", + "512", + "--host", + "0.0.0.0", + "--port", + "8080", + "--parallel", + "1", + "-ngl", + "auto", + "-ot", + "attn=CUDA1" + ], + "env": {}, + "notes": [ + "CUDA_GRAPH_OPT ignoré : -ot sur l'attention peut séparer Q, K et V d'une couche entre cartes" + ] + }, + { + "m": 0, + "c": 106, + "h": 0, + "e": 0, + "args": [ + "/app/llama-server", + "-m", + "/m/dense.gguf", + "-c", + "32768", + "-b", + "2048", + "-ub", + "512", + "--host", + "0.0.0.0", + "--port", + "8080", + "--parallel", + "1", + "-ngl", + "auto", + "-fa", + "off" + ], + "env": {}, + "notes": [ + "SPLIT_MODE=tensor refusé (Flash Attention coupée (-fa off) : le mode tensor l'exige) : découpe par couches, comme sans la clé" + ] + }, + { + "m": 0, + "c": 107, + "h": 0, + "e": 0, + "args": [ + "/app/llama-server", + "-m", + "/m/dense.gguf", + "-c", + "32768", + "-b", + "2048", + "-ub", + "512", + "--host", + "0.0.0.0", + "--port", + "8080", + "--parallel", + "1", + "-ngl", + "auto", + "-nkvo" + ], + "env": {}, + "notes": null + }, + { + "m": 0, + "c": 108, + "h": 0, + "e": 0, + "args": [ + "/app/llama-server", + "-m", + "/m/dense.gguf", + "-c", + "32768", + "-b", + "2048", + "-ub", + "512", + "--host", + "0.0.0.0", + "--port", + "8080", + "--parallel", + "1", + "-ngl", + "auto", + "--no-context-shift" + ], + "env": {}, + "notes": null + }, + { + "m": 0, + "c": 109, + "h": 0, + "e": 0, + "args": [ + "/app/llama-server", + "-m", + "/m/dense.gguf", + "-c", + "32768", + "-b", + "2048", + "-ub", + "512", + "--host", + "0.0.0.0", + "--port", + "8080", + "-ngl", + "auto", + "-c", + "8192", + "-np", + "2" + ], + "env": {}, + "notes": [ + "SIDE_SLOT refusé (-c est réglé dans EXTRA_ARGS) : un seul slot, comme sans la clé" + ] + }, + { + "m": 0, + "c": 110, + "h": 0, + "e": 0, + "args": [ + "/app/llama-server", + "-m", + "/m/dense.gguf", + "-c", + "32768", + "-b", + "2048", + "-ub", + "512", + "--host", + "0.0.0.0", + "--port", + "8080", + "--parallel", + "1", + "-ngl", + "auto" + ], + "env": {}, + "notes": [ + "SPLIT_MODE=tensor refusé (preset externe) : découpe par couches, comme sans la clé", + "SIDE_SLOT refusé (preset externe) : un seul slot, comme sans la clé" + ] + }, + { + "m": 0, + "c": 111, + "h": 0, + "e": 0, + "args": [ + "/app/llama-server", + "-m", + "/m/dense.gguf", + "-c", + "40000", + "-b", + "2048", + "-ub", + "512", + "--host", + "0.0.0.0", + "--port", + "8080", + "--parallel", + "1", + "-ngl", + "auto", + "-ctk", + "q4_0", + "-ctv", + "q4_0" + ], + "env": {}, + "notes": [ + "NGL=999 (sentinelle « toutes les couches ») → -ngl auto : ce moteur mesure lui-même la VRAM libre. NGL=all pour forcer l'ancien comportement.", + "cache KV q4_0/q4_0 (KV_TYPE) : perte mesurable par rapport à f16 — choix de l'utilisateur, laissé tel quel." + ] + }, + { + "m": 1, + "c": 0, + "h": 0, + "e": 0, + "args": [ + "/app/llama-server", + "-m", + "/m/moe.gguf", + "-c", + "32768", + "-b", + "2048", + "-ub", + "512", + "--host", + "0.0.0.0", + "--port", + "8080", + "--parallel", + "1", + "-ngl", + "auto" + ], + "env": { + "CUDA_SCALE_LAUNCH_QUEUES": "4x" + }, + "notes": [ + "CUDA_SCALE_LAUNCH_QUEUES=4x (2 GPU, parallélisme pipeline possible) — CUDA_LAUNCH_QUEUES=off pour s'en passer" + ] + }, + { + "m": 1, + "c": 1, + "h": 0, + "e": 0, + "args": [ + "/app/llama-server", + "-m", + "/m/moe.gguf", + "-c", + "65536", + "-b", + "4096", + "-ub", + "2048", + "--host", + "127.0.0.1", + "--port", + "8081", + "--parallel", + "1", + "-ngl", + "auto" + ], + "env": { + "CUDA_SCALE_LAUNCH_QUEUES": "4x" + }, + "notes": [ + "CUDA_SCALE_LAUNCH_QUEUES=4x (2 GPU, parallélisme pipeline possible) — CUDA_LAUNCH_QUEUES=off pour s'en passer", + "NGL=999 (sentinelle « toutes les couches ») → -ngl auto : ce moteur mesure lui-même la VRAM libre. NGL=all pour forcer l'ancien comportement." + ] + }, + { + "m": 1, + "c": 2, + "h": 0, + "e": 0, + "args": [ + "/app/llama-server", + "-m", + "/m/moe.gguf", + "-c", + "32768", + "-b", + "2048", + "-ub", + "512", + "--host", + "0.0.0.0", + "--port", + "8080", + "--parallel", + "1" + ], + "env": { + "CUDA_SCALE_LAUNCH_QUEUES": "4x" + }, + "notes": [ + "CUDA_SCALE_LAUNCH_QUEUES=4x (2 GPU, parallélisme pipeline possible) — CUDA_LAUNCH_QUEUES=off pour s'en passer" + ] + }, + { + "m": 1, + "c": 3, + "h": 0, + "e": 0, + "args": [ + "/app/llama-server", + "-m", + "/m/moe.gguf", + "-c", + "32768", + "-b", + "2048", + "-ub", + "512", + "--host", + "0.0.0.0", + "--port", + "8080", + "--parallel", + "1", + "-ngl", + "40" + ], + "env": {}, + "notes": null + }, + { + "m": 1, + "c": 4, + "h": 0, + "e": 0, + "args": [ + "/app/llama-server", + "-m", + "/m/moe.gguf", + "-c", + "32768", + "-b", + "2048", + "-ub", + "512", + "--host", + "0.0.0.0", + "--port", + "8080", + "--parallel", + "1", + "-ngl", + "all" + ], + "env": { + "CUDA_SCALE_LAUNCH_QUEUES": "4x" + }, + "notes": [ + "CUDA_SCALE_LAUNCH_QUEUES=4x (2 GPU, parallélisme pipeline possible) — CUDA_LAUNCH_QUEUES=off pour s'en passer" + ] + }, + { + "m": 1, + "c": 5, + "h": 0, + "e": 0, + "args": [ + "/app/llama-server", + "-m", + "/m/moe.gguf", + "-c", + "32768", + "-b", + "2048", + "-ub", + "512", + "--host", + "0.0.0.0", + "--port", + "8080", + "--parallel", + "1", + "-ngl", + "auto" + ], + "env": { + "CUDA_SCALE_LAUNCH_QUEUES": "4x" + }, + "notes": [ + "CUDA_SCALE_LAUNCH_QUEUES=4x (2 GPU, parallélisme pipeline possible) — CUDA_LAUNCH_QUEUES=off pour s'en passer", + "NGL=999 (sentinelle « toutes les couches ») → -ngl auto : ce moteur mesure lui-même la VRAM libre. NGL=all pour forcer l'ancien comportement." + ] + }, + { + "m": 1, + "c": 6, + "h": 0, + "e": 0, + "args": [ + "/app/llama-server", + "-m", + "/m/moe.gguf", + "-c", + "32768", + "-t", + "8", + "-tb", + "4", + "-b", + "2048", + "-ub", + "512", + "--host", + "0.0.0.0", + "--port", + "8080", + "--parallel", + "1", + "-ngl", + "auto" + ], + "env": { + "CUDA_SCALE_LAUNCH_QUEUES": "4x" + }, + "notes": [ + "CUDA_SCALE_LAUNCH_QUEUES=4x (2 GPU, parallélisme pipeline possible) — CUDA_LAUNCH_QUEUES=off pour s'en passer" + ] + }, + { + "m": 1, + "c": 7, + "h": 0, + "e": 0, + "args": [ + "/app/llama-server", + "-m", + "/m/moe.gguf", + "-c", + "32768", + "-b", + "2048", + "-ub", + "512", + "--host", + "0.0.0.0", + "--port", + "8080", + "--parallel", + "1", + "-ngl", + "auto" + ], + "env": { + "CUDA_SCALE_LAUNCH_QUEUES": "4x" + }, + "notes": [ + "CUDA_SCALE_LAUNCH_QUEUES=4x (2 GPU, parallélisme pipeline possible) — CUDA_LAUNCH_QUEUES=off pour s'en passer", + "THREADS=abc ignoré (pas un nombre de threads ≥ 0) → automatique", + "THREADS_BATCH=-2 ignoré (pas un nombre de threads ≥ 0) → automatique" + ] + }, + { + "m": 1, + "c": 8, + "h": 0, + "e": 0, + "args": [ + "/app/llama-server", + "-m", + "/m/moe.gguf", + "-c", + "32768", + "-b", + "2048", + "-ub", + "512", + "--host", + "0.0.0.0", + "--port", + "8080", + "--parallel", + "1", + "-ngl", + "auto" + ], + "env": { + "CUDA_SCALE_LAUNCH_QUEUES": "4x" + }, + "notes": [ + "CUDA_SCALE_LAUNCH_QUEUES=4x (2 GPU, parallélisme pipeline possible) — CUDA_LAUNCH_QUEUES=off pour s'en passer" + ] + }, + { + "m": 1, + "c": 9, + "h": 0, + "e": 0, + "args": [ + "/app/llama-server", + "-m", + "/m/moe.gguf", + "-c", + "32768", + "-b", + "2048", + "-ub", + "512", + "--host", + "0.0.0.0", + "--port", + "8080", + "--parallel", + "1", + "-ngl", + "auto", + "-ctk", + "q8_0", + "-ctv", + "q8_0" + ], + "env": { + "CUDA_SCALE_LAUNCH_QUEUES": "4x" + }, + "notes": [ + "CUDA_SCALE_LAUNCH_QUEUES=4x (2 GPU, parallélisme pipeline possible) — CUDA_LAUNCH_QUEUES=off pour s'en passer", + "cache KV q8_0/q8_0 (KV_TYPE) : modifie légèrement les sorties par rapport à f16 — choix de l'utilisateur, laissé tel quel." + ] + }, + { + "m": 1, + "c": 10, + "h": 0, + "e": 0, + "args": [ + "/app/llama-server", + "-m", + "/m/moe.gguf", + "-c", + "32768", + "-b", + "2048", + "-ub", + "512", + "--host", + "0.0.0.0", + "--port", + "8080", + "--parallel", + "1", + "-ngl", + "auto", + "-ctk", + "q4_0", + "-ctv", + "f16" + ], + "env": { + "CUDA_SCALE_LAUNCH_QUEUES": "4x" + }, + "notes": [ + "CUDA_SCALE_LAUNCH_QUEUES=4x (2 GPU, parallélisme pipeline possible) — CUDA_LAUNCH_QUEUES=off pour s'en passer", + "cache KV q4_0/f16 (KV_TYPE) : perte mesurable par rapport à f16 — choix de l'utilisateur, laissé tel quel." + ] + }, + { + "m": 1, + "c": 11, + "h": 0, + "e": 0, + "args": [ + "/app/llama-server", + "-m", + "/m/moe.gguf", + "-c", + "32768", + "-b", + "2048", + "-ub", + "512", + "--host", + "0.0.0.0", + "--port", + "8080", + "--parallel", + "1", + "-ngl", + "auto", + "-ctk", + "bf16", + "-ctv", + "bf16" + ], + "env": { + "CUDA_SCALE_LAUNCH_QUEUES": "4x" + }, + "notes": [ + "CUDA_SCALE_LAUNCH_QUEUES=4x (2 GPU, parallélisme pipeline possible) — CUDA_LAUNCH_QUEUES=off pour s'en passer", + "cache KV bf16/bf16 (KV_TYPE) : numérique différente de f16 (mantisse plus courte) — choix de l'utilisateur, laissé tel quel." + ] + }, + { + "m": 1, + "c": 12, + "h": 0, + "e": 0, + "args": [ + "/app/llama-server", + "-m", + "/m/moe.gguf", + "-c", + "32768", + "-b", + "2048", + "-ub", + "512", + "--host", + "0.0.0.0", + "--port", + "8080", + "--parallel", + "1", + "-ngl", + "auto", + "-ctk", + "q5_1", + "-ctv", + "q5_1" + ], + "env": { + "CUDA_SCALE_LAUNCH_QUEUES": "4x" + }, + "notes": [ + "CUDA_SCALE_LAUNCH_QUEUES=4x (2 GPU, parallélisme pipeline possible) — CUDA_LAUNCH_QUEUES=off pour s'en passer", + "cache KV q5_1/q5_1 (KV_TYPE) : modifie légèrement les sorties par rapport à f16 — choix de l'utilisateur, laissé tel quel." + ] + }, + { + "m": 1, + "c": 13, + "h": 0, + "e": 0, + "args": [ + "/app/llama-server", + "-m", + "/m/moe.gguf", + "-c", + "32768", + "-b", + "2048", + "-ub", + "512", + "--host", + "0.0.0.0", + "--port", + "8080", + "--parallel", + "1", + "-ngl", + "auto", + "--reasoning", + "on", + "--reasoning-budget", + "-1" + ], + "env": { + "CUDA_SCALE_LAUNCH_QUEUES": "4x" + }, + "notes": [ + "CUDA_SCALE_LAUNCH_QUEUES=4x (2 GPU, parallélisme pipeline possible) — CUDA_LAUNCH_QUEUES=off pour s'en passer" + ] + }, + { + "m": 1, + "c": 14, + "h": 0, + "e": 0, + "args": [ + "/app/llama-server", + "-m", + "/m/moe.gguf", + "-c", + "32768", + "-b", + "2048", + "-ub", + "512", + "--host", + "0.0.0.0", + "--port", + "8080", + "--parallel", + "1", + "-ngl", + "auto", + "--reasoning", + "off" + ], + "env": { + "CUDA_SCALE_LAUNCH_QUEUES": "4x" + }, + "notes": [ + "CUDA_SCALE_LAUNCH_QUEUES=4x (2 GPU, parallélisme pipeline possible) — CUDA_LAUNCH_QUEUES=off pour s'en passer" + ] + }, + { + "m": 1, + "c": 15, + "h": 0, + "e": 0, + "args": [ + "/app/llama-server", + "-m", + "/m/moe.gguf", + "-c", + "32768", + "-b", + "2048", + "-ub", + "512", + "--host", + "0.0.0.0", + "--port", + "8080", + "--parallel", + "1", + "-ngl", + "auto", + "--reasoning", + "auto", + "--reasoning-budget", + "2048" + ], + "env": { + "CUDA_SCALE_LAUNCH_QUEUES": "4x" + }, + "notes": [ + "CUDA_SCALE_LAUNCH_QUEUES=4x (2 GPU, parallélisme pipeline possible) — CUDA_LAUNCH_QUEUES=off pour s'en passer" + ] + }, + { + "m": 1, + "c": 16, + "h": 0, + "e": 0, + "args": [ + "/app/llama-server", + "-m", + "/m/moe.gguf", + "-c", + "32768", + "-b", + "2048", + "-ub", + "512", + "--host", + "0.0.0.0", + "--port", + "8080", + "--parallel", + "1", + "-ngl", + "auto", + "--reasoning", + "deepseek", + "--reasoning-budget", + "-1" + ], + "env": { + "CUDA_SCALE_LAUNCH_QUEUES": "4x" + }, + "notes": [ + "CUDA_SCALE_LAUNCH_QUEUES=4x (2 GPU, parallélisme pipeline possible) — CUDA_LAUNCH_QUEUES=off pour s'en passer" + ] + }, + { + "m": 1, + "c": 17, + "h": 0, + "e": 0, + "args": [ + "/app/llama-server", + "-m", + "/m/moe.gguf", + "-c", + "32768", + "-b", + "2048", + "-ub", + "512", + "--host", + "0.0.0.0", + "--port", + "8080", + "--parallel", + "1", + "-ngl", + "auto", + "--reasoning-preserve" + ], + "env": { + "CUDA_SCALE_LAUNCH_QUEUES": "4x" + }, + "notes": [ + "CUDA_SCALE_LAUNCH_QUEUES=4x (2 GPU, parallélisme pipeline possible) — CUDA_LAUNCH_QUEUES=off pour s'en passer" + ] + }, + { + "m": 1, + "c": 18, + "h": 0, + "e": 0, + "args": [ + "/app/llama-server", + "-m", + "/m/moe.gguf", + "-c", + "32768", + "-b", + "2048", + "-ub", + "512", + "--host", + "0.0.0.0", + "--port", + "8080", + "--parallel", + "1", + "-ngl", + "auto", + "--no-reasoning-preserve" + ], + "env": { + "CUDA_SCALE_LAUNCH_QUEUES": "4x" + }, + "notes": [ + "CUDA_SCALE_LAUNCH_QUEUES=4x (2 GPU, parallélisme pipeline possible) — CUDA_LAUNCH_QUEUES=off pour s'en passer" + ] + }, + { + "m": 1, + "c": 19, + "h": 0, + "e": 0, + "args": [ + "/app/llama-server", + "-m", + "/m/moe.gguf", + "-c", + "32768", + "-b", + "2048", + "-ub", + "512", + "--host", + "0.0.0.0", + "--port", + "8080", + "--parallel", + "1", + "-ngl", + "auto" + ], + "env": { + "CUDA_SCALE_LAUNCH_QUEUES": "4x" + }, + "notes": [ + "CUDA_SCALE_LAUNCH_QUEUES=4x (2 GPU, parallélisme pipeline possible) — CUDA_LAUNCH_QUEUES=off pour s'en passer", + "REASONING_PRESERVE=peut-être illisible (on ou off) : ignoré" + ] + }, + { + "m": 1, + "c": 20, + "h": 0, + "e": 0, + "args": [ + "/app/llama-server", + "-m", + "/m/moe.gguf", + "-c", + "32768", + "-b", + "2048", + "-ub", + "512", + "--host", + "0.0.0.0", + "--port", + "8080", + "--parallel", + "1", + "-ngl", + "auto" + ], + "env": { + "CUDA_SCALE_LAUNCH_QUEUES": "4x" + }, + "notes": [ + "CUDA_SCALE_LAUNCH_QUEUES=4x (2 GPU, parallélisme pipeline possible) — CUDA_LAUNCH_QUEUES=off pour s'en passer" + ] + }, + { + "m": 1, + "c": 21, + "h": 0, + "e": 0, + "args": [ + "/app/llama-server", + "-m", + "/m/moe.gguf", + "-c", + "32768", + "-b", + "2048", + "-ub", + "512", + "--host", + "0.0.0.0", + "--port", + "8080", + "--parallel", + "1", + "-ngl", + "auto", + "--cache-ram", + "4096" + ], + "env": { + "CUDA_SCALE_LAUNCH_QUEUES": "4x" + }, + "notes": [ + "CUDA_SCALE_LAUNCH_QUEUES=4x (2 GPU, parallélisme pipeline possible) — CUDA_LAUNCH_QUEUES=off pour s'en passer" + ] + }, + { + "m": 1, + "c": 22, + "h": 0, + "e": 0, + "args": [ + "/app/llama-server", + "-m", + "/m/moe.gguf", + "-c", + "32768", + "-b", + "2048", + "-ub", + "512", + "--host", + "0.0.0.0", + "--port", + "8080", + "--parallel", + "1", + "-ngl", + "auto", + "--cache-ram", + "0" + ], + "env": { + "CUDA_SCALE_LAUNCH_QUEUES": "4x" + }, + "notes": [ + "CUDA_SCALE_LAUNCH_QUEUES=4x (2 GPU, parallélisme pipeline possible) — CUDA_LAUNCH_QUEUES=off pour s'en passer" + ] + }, + { + "m": 1, + "c": 23, + "h": 0, + "e": 0, + "args": [ + "/app/llama-server", + "-m", + "/m/moe.gguf", + "-c", + "32768", + "-b", + "2048", + "-ub", + "512", + "--host", + "0.0.0.0", + "--port", + "8080", + "--parallel", + "1", + "-ngl", + "auto", + "--cache-ram", + "-1" + ], + "env": { + "CUDA_SCALE_LAUNCH_QUEUES": "4x" + }, + "notes": [ + "CUDA_SCALE_LAUNCH_QUEUES=4x (2 GPU, parallélisme pipeline possible) — CUDA_LAUNCH_QUEUES=off pour s'en passer" + ] + }, + { + "m": 1, + "c": 24, + "h": 0, + "e": 0, + "args": [ + "/app/llama-server", + "-m", + "/m/moe.gguf", + "-c", + "32768", + "-b", + "2048", + "-ub", + "512", + "--host", + "0.0.0.0", + "--port", + "8080", + "--parallel", + "1", + "-ngl", + "auto" + ], + "env": { + "CUDA_SCALE_LAUNCH_QUEUES": "4x" + }, + "notes": [ + "CUDA_SCALE_LAUNCH_QUEUES=4x (2 GPU, parallélisme pipeline possible) — CUDA_LAUNCH_QUEUES=off pour s'en passer", + "CACHE_RAM=beaucoup illisible (un nombre de Mio, -1, 0 ou auto) : ignoré" + ] + }, + { + "m": 1, + "c": 25, + "h": 0, + "e": 0, + "args": [ + "/app/llama-server", + "-m", + "/m/moe.gguf", + "-c", + "32768", + "-b", + "2048", + "-ub", + "512", + "--host", + "0.0.0.0", + "--port", + "8080", + "--parallel", + "1", + "-ngl", + "auto" + ], + "env": { + "CUDA_SCALE_LAUNCH_QUEUES": "4x" + }, + "notes": [ + "CUDA_SCALE_LAUNCH_QUEUES=4x (2 GPU, parallélisme pipeline possible) — CUDA_LAUNCH_QUEUES=off pour s'en passer" + ] + }, + { + "m": 1, + "c": 26, + "h": 0, + "e": 0, + "args": [ + "/app/llama-server", + "-m", + "/m/moe.gguf", + "-c", + "32768", + "-b", + "2048", + "-ub", + "512", + "--host", + "0.0.0.0", + "--port", + "8080", + "--parallel", + "1", + "-ngl", + "auto", + "--cache-ram", + "0" + ], + "env": { + "CUDA_SCALE_LAUNCH_QUEUES": "4x" + }, + "notes": [ + "CUDA_SCALE_LAUNCH_QUEUES=4x (2 GPU, parallélisme pipeline possible) — CUDA_LAUNCH_QUEUES=off pour s'en passer" + ] + }, + { + "m": 1, + "c": 27, + "h": 0, + "e": 0, + "args": [ + "/app/llama-server", + "-m", + "/m/moe.gguf", + "-c", + "32768", + "-b", + "2048", + "-ub", + "512", + "--host", + "0.0.0.0", + "--port", + "8080", + "--parallel", + "1", + "-ngl", + "auto" + ], + "env": { + "CUDA_SCALE_LAUNCH_QUEUES": "4x" + }, + "notes": [ + "CUDA_SCALE_LAUNCH_QUEUES=4x (2 GPU, parallélisme pipeline possible) — CUDA_LAUNCH_QUEUES=off pour s'en passer", + "SIDE_SLOT refusé (VRAM insuffisante : modèle + 2 × 3072 Mio d'état dépassent 90 % de 28599 Mio) : un seul slot, comme sans la clé" + ] + }, + { + "m": 1, + "c": 28, + "h": 0, + "e": 0, + "args": [ + "/app/llama-server", + "-m", + "/m/moe.gguf", + "-c", + "32768", + "-b", + "2048", + "-ub", + "512", + "--host", + "0.0.0.0", + "--port", + "8080", + "--parallel", + "3", + "-ngl", + "auto" + ], + "env": { + "CUDA_SCALE_LAUNCH_QUEUES": "4x" + }, + "notes": [ + "CUDA_SCALE_LAUNCH_QUEUES=4x (2 GPU, parallélisme pipeline possible) — CUDA_LAUNCH_QUEUES=off pour s'en passer", + "SIDE_SLOT refusé (PARALLEL=3 (SIDE_SLOT ouvre exactement 2 slots)) : un seul slot, comme sans la clé" + ] + }, + { + "m": 1, + "c": 29, + "h": 0, + "e": 0, + "args": [ + "/app/llama-server", + "-m", + "/m/moe.gguf", + "-c", + "0", + "-b", + "2048", + "-ub", + "512", + "--host", + "0.0.0.0", + "--port", + "8080", + "--parallel", + "1", + "-ngl", + "auto" + ], + "env": { + "CUDA_SCALE_LAUNCH_QUEUES": "4x" + }, + "notes": [ + "CUDA_SCALE_LAUNCH_QUEUES=4x (2 GPU, parallélisme pipeline possible) — CUDA_LAUNCH_QUEUES=off pour s'en passer", + "SIDE_SLOT refusé (CTX doit être un nombre de jetons explicite) : un seul slot, comme sans la clé" + ] + }, + { + "m": 1, + "c": 30, + "h": 0, + "e": 0, + "args": [ + "/app/llama-server", + "-m", + "/m/moe.gguf", + "-c", + "16384", + "-b", + "2048", + "-ub", + "512", + "--host", + "0.0.0.0", + "--port", + "8080", + "--parallel", + "2", + "--no-kv-unified", + "-ngl", + "auto", + "-ctk", + "q8_0", + "-ctv", + "q8_0" + ], + "env": { + "CUDA_SCALE_LAUNCH_QUEUES": "4x" + }, + "notes": [ + "CUDA_SCALE_LAUNCH_QUEUES=4x (2 GPU, parallélisme pipeline possible) — CUDA_LAUNCH_QUEUES=off pour s'en passer", + "SIDE_SLOT : 2 slots de 8192 jetons (-c 16384, --no-kv-unified), ~408 Mio de VRAM en plus pour le second (cache KV et état récurrent ; le brouillon MTP éventuel et les points de reprise en RAM hôte doublent aussi) ; le placement automatique sortira des couches du GPU s'il manque de place", + "cache KV q8_0/q8_0 (KV_TYPE) : modifie légèrement les sorties par rapport à f16 — choix de l'utilisateur, laissé tel quel." + ] + }, + { + "m": 1, + "c": 31, + "h": 0, + "e": 0, + "args": [ + "/app/llama-server", + "-m", + "/m/moe.gguf", + "-c", + "32768", + "-b", + "2048", + "-ub", + "512", + "--host", + "0.0.0.0", + "--port", + "8080", + "--parallel", + "1", + "-ngl", + "all", + "-sm", + "tensor", + "-ts", + "57,43", + "-fa", + "on" + ], + "env": { + "GGML_CUDA_ALLREDUCE": "internal" + }, + "notes": [ + "SPLIT_MODE=tensor (expérimental) : -sm tensor sur 2 cartes, -ts 57,43 (VRAM totale), ~25600 Mio estimés sans --fit ; décodage plus rapide, prefill plus lent — compare le temps par tour ; GGML_CUDA_ALLREDUCE=internal : sommes dans le type natif (NCCL les compresserait en BF16, avec perte). « NCCL not compiled in » ou « NCCL is unavailable » au journal : sans effet ici. GeForce sans P2P : les sommes passent par l'hôte ; GGML_CUDA_P2P=1 à poser soi-même si le pilote le permet" + ] + }, + { + "m": 1, + "c": 32, + "h": 0, + "e": 0, + "args": [ + "/app/llama-server", + "-m", + "/m/moe.gguf", + "-c", + "16384", + "-b", + "2048", + "-ub", + "512", + "--host", + "0.0.0.0", + "--port", + "8080", + "--parallel", + "1", + "-ngl", + "all", + "-sm", + "tensor", + "-ts", + "57,43", + "-fa", + "on" + ], + "env": { + "GGML_CUDA_ALLREDUCE": "internal" + }, + "notes": [ + "SPLIT_MODE=tensor (expérimental) : -sm tensor sur 2 cartes, -ts 57,43 (VRAM totale), ~24064 Mio estimés sans --fit ; décodage plus rapide, prefill plus lent — compare le temps par tour ; GGML_CUDA_ALLREDUCE=internal : sommes dans le type natif (NCCL les compresserait en BF16, avec perte). « NCCL not compiled in » ou « NCCL is unavailable » au journal : sans effet ici. GeForce sans P2P : les sommes passent par l'hôte ; GGML_CUDA_P2P=1 à poser soi-même si le pilote le permet" + ] + }, + { + "m": 1, + "c": 33, + "h": 0, + "e": 0, + "args": [ + "/app/llama-server", + "-m", + "/m/moe.gguf", + "-c", + "32768", + "-b", + "2048", + "-ub", + "512", + "--host", + "0.0.0.0", + "--port", + "8080", + "--parallel", + "1", + "-ngl", + "auto" + ], + "env": { + "CUDA_SCALE_LAUNCH_QUEUES": "4x" + }, + "notes": [ + "SPLIT_MODE=tensor refusé (SPLIT_MODE=bizarre illisible (tensor ou off)) : découpe par couches, comme sans la clé", + "CUDA_SCALE_LAUNCH_QUEUES=4x (2 GPU, parallélisme pipeline possible) — CUDA_LAUNCH_QUEUES=off pour s'en passer" + ] + }, + { + "m": 1, + "c": 34, + "h": 0, + "e": 0, + "args": [ + "/app/llama-server", + "-m", + "/m/moe.gguf", + "-c", + "32768", + "-b", + "2048", + "-ub", + "512", + "--host", + "0.0.0.0", + "--port", + "8080", + "--parallel", + "1", + "-ngl", + "auto" + ], + "env": { + "CUDA_SCALE_LAUNCH_QUEUES": "4x" + }, + "notes": [ + "CUDA_SCALE_LAUNCH_QUEUES=4x (2 GPU, parallélisme pipeline possible) — CUDA_LAUNCH_QUEUES=off pour s'en passer" + ] + }, + { + "m": 1, + "c": 35, + "h": 0, + "e": 0, + "args": [ + "/app/llama-server", + "-m", + "/m/moe.gguf", + "-c", + "32768", + "-b", + "2048", + "-ub", + "512", + "--host", + "0.0.0.0", + "--port", + "8080", + "--parallel", + "1", + "-ngl", + "auto", + "-ctk", + "q8_0", + "-ctv", + "q8_0" + ], + "env": { + "CUDA_SCALE_LAUNCH_QUEUES": "4x" + }, + "notes": [ + "SPLIT_MODE=tensor refusé (cache KV q8_0/q8_0 : le mode tensor n'accepte que f16, bf16 ou f32 (jamais réécrit d'office : la VRAM changerait)) : découpe par couches, comme sans la clé", + "CUDA_SCALE_LAUNCH_QUEUES=4x (2 GPU, parallélisme pipeline possible) — CUDA_LAUNCH_QUEUES=off pour s'en passer", + "cache KV q8_0/q8_0 (KV_TYPE) : modifie légèrement les sorties par rapport à f16 — choix de l'utilisateur, laissé tel quel." + ] + }, + { + "m": 1, + "c": 36, + "h": 0, + "e": 0, + "args": [ + "/app/llama-server", + "-m", + "/m/moe.gguf", + "-c", + "32768", + "-b", + "2048", + "-ub", + "512", + "--host", + "0.0.0.0", + "--port", + "8080", + "--parallel", + "1", + "-ngl", + "20" + ], + "env": {}, + "notes": [ + "SPLIT_MODE=tensor refusé (NGL=20 : le mode tensor veut toutes les couches sur les cartes (all)) : découpe par couches, comme sans la clé" + ] + }, + { + "m": 1, + "c": 37, + "h": 0, + "e": 0, + "args": [ + "/app/llama-server", + "-m", + "/m/moe.gguf", + "-c", + "32768", + "-b", + "2048", + "-ub", + "512", + "--host", + "0.0.0.0", + "--port", + "8080", + "--parallel", + "1", + "-ngl", + "auto" + ], + "env": { + "CUDA_SCALE_LAUNCH_QUEUES": "4x" + }, + "notes": [ + "CUDA_SCALE_LAUNCH_QUEUES=4x (2 GPU, parallélisme pipeline possible) — CUDA_LAUNCH_QUEUES=off pour s'en passer" + ] + }, + { + "m": 1, + "c": 38, + "h": 0, + "e": 0, + "args": [ + "/app/llama-server", + "-m", + "/m/moe.gguf", + "-c", + "32768", + "-b", + "2048", + "-ub", + "512", + "--host", + "0.0.0.0", + "--port", + "8080", + "--parallel", + "1", + "-ngl", + "auto" + ], + "env": { + "CUDA_SCALE_LAUNCH_QUEUES": "4x" + }, + "notes": [ + "CUDA_SCALE_LAUNCH_QUEUES=4x (2 GPU, parallélisme pipeline possible) — CUDA_LAUNCH_QUEUES=off pour s'en passer", + "SPEC=mtp : pas de tête MTP dans ce fichier (publiée à part ? MODEL_DRAFT=mtp-….gguf) — sans décodage spéculatif" + ] + }, + { + "m": 1, + "c": 39, + "h": 0, + "e": 0, + "args": [ + "/app/llama-server", + "-m", + "/m/moe.gguf", + "-c", + "32768", + "-b", + "2048", + "-ub", + "512", + "--host", + "0.0.0.0", + "--port", + "8080", + "--parallel", + "1", + "-ngl", + "auto", + "--spec-type", + "ngram-mod", + "--spec-ngram-mod-n-match", + "24", + "--spec-ngram-mod-n-min", + "48", + "--spec-ngram-mod-n-max", + "64" + ], + "env": { + "CUDA_SCALE_LAUNCH_QUEUES": "4x" + }, + "notes": [ + "CUDA_SCALE_LAUNCH_QUEUES=4x (2 GPU, parallélisme pipeline possible) — CUDA_LAUNCH_QUEUES=off pour s'en passer", + "SPEC=ngram → n-grammes du contexte (ngram-mod, brouillons de 48 à 64 jetons) : distribution inchangée (chaque jeton vérifié), ~16 Mo de RAM ; utile surtout en mode code, à mesurer (acceptation par nature dans /api/perf/summary)" + ] + }, + { + "m": 1, + "c": 40, + "h": 0, + "e": 0, + "args": [ + "/app/llama-server", + "-m", + "/m/moe.gguf", + "-c", + "32768", + "-b", + "2048", + "-ub", + "512", + "--host", + "0.0.0.0", + "--port", + "8080", + "--parallel", + "1", + "-ngl", + "auto", + "--spec-type", + "ngram-mod", + "--spec-ngram-mod-n-match", + "24", + "--spec-ngram-mod-n-min", + "48", + "--spec-ngram-mod-n-max", + "64" + ], + "env": { + "CUDA_SCALE_LAUNCH_QUEUES": "4x" + }, + "notes": [ + "CUDA_SCALE_LAUNCH_QUEUES=4x (2 GPU, parallélisme pipeline possible) — CUDA_LAUNCH_QUEUES=off pour s'en passer", + "SPEC=mtp+ngram : sans MTP (pas de tête MTP dans ce fichier (publiée à part ? MODEL_DRAFT=mtp-….gguf)) → n-grammes seuls. SPEC=ngram → n-grammes du contexte (ngram-mod, brouillons de 48 à 64 jetons) : distribution inchangée (chaque jeton vérifié), ~16 Mo de RAM ; utile surtout en mode code, à mesurer (acceptation par nature dans /api/perf/summary)" + ] + }, + { + "m": 1, + "c": 41, + "h": 0, + "e": 0, + "args": [ + "/app/llama-server", + "-m", + "/m/moe.gguf", + "-c", + "32768", + "-b", + "2048", + "-ub", + "512", + "--host", + "0.0.0.0", + "--port", + "8080", + "--parallel", + "1", + "-ngl", + "auto", + "--spec-type", + "ngram-mod", + "--spec-ngram-mod-n-match", + "24", + "--spec-ngram-mod-n-min", + "48", + "--spec-ngram-mod-n-max", + "64" + ], + "env": { + "CUDA_SCALE_LAUNCH_QUEUES": "4x" + }, + "notes": [ + "CUDA_SCALE_LAUNCH_QUEUES=4x (2 GPU, parallélisme pipeline possible) — CUDA_LAUNCH_QUEUES=off pour s'en passer", + "SPEC=mtp+ngram : sans MTP (pas de tête MTP dans ce fichier (publiée à part ? MODEL_DRAFT=mtp-….gguf)) → n-grammes seuls. SPEC=ngram → n-grammes du contexte (ngram-mod, brouillons de 48 à 64 jetons) : distribution inchangée (chaque jeton vérifié), ~16 Mo de RAM ; utile surtout en mode code, à mesurer (acceptation par nature dans /api/perf/summary)" + ] + }, + { + "m": 1, + "c": 42, + "h": 0, + "e": 0, + "args": [ + "/app/llama-server", + "-m", + "/m/moe.gguf", + "-c", + "32768", + "-b", + "2048", + "-ub", + "512", + "--host", + "0.0.0.0", + "--port", + "8080", + "--parallel", + "1", + "-ngl", + "auto" + ], + "env": { + "CUDA_SCALE_LAUNCH_QUEUES": "4x" + }, + "notes": [ + "CUDA_SCALE_LAUNCH_QUEUES=4x (2 GPU, parallélisme pipeline possible) — CUDA_LAUNCH_QUEUES=off pour s'en passer", + "SPEC=turbo illisible (off, auto, mtp, ngram ou mtp+ngram) : sans décodage spéculatif" + ] + }, + { + "m": 1, + "c": 43, + "h": 0, + "e": 0, + "args": [ + "/app/llama-server", + "-m", + "/m/moe.gguf", + "-c", + "32768", + "-b", + "2048", + "-ub", + "512", + "--host", + "0.0.0.0", + "--port", + "8080", + "--parallel", + "1", + "-ngl", + "auto" + ], + "env": { + "CUDA_SCALE_LAUNCH_QUEUES": "4x" + }, + "notes": [ + "CUDA_SCALE_LAUNCH_QUEUES=4x (2 GPU, parallélisme pipeline possible) — CUDA_LAUNCH_QUEUES=off pour s'en passer", + "MODEL_DRAFT ignoré : SPEC=off (auto ou mtp pour s'en servir)" + ] + }, + { + "m": 1, + "c": 44, + "h": 0, + "e": 0, + "args": [ + "/app/llama-server", + "-m", + "/m/moe.gguf", + "-c", + "32768", + "-b", + "2048", + "-ub", + "512", + "--host", + "0.0.0.0", + "--port", + "8080", + "--parallel", + "1", + "-ngl", + "auto" + ], + "env": { + "CUDA_SCALE_LAUNCH_QUEUES": "4x" + }, + "notes": [ + "CUDA_SCALE_LAUNCH_QUEUES=4x (2 GPU, parallélisme pipeline possible) — CUDA_LAUNCH_QUEUES=off pour s'en passer", + "SPEC=mtp : MODEL_DRAFT=mtp-head.gguf introuvable — sans décodage spéculatif" + ] + }, + { + "m": 1, + "c": 45, + "h": 0, + "e": 0, + "args": [ + "/app/llama-server", + "-m", + "/m/moe.gguf", + "-c", + "32768", + "-b", + "2048", + "-ub", + "512", + "--host", + "0.0.0.0", + "--port", + "8080", + "--parallel", + "1", + "-ngl", + "auto" + ], + "env": { + "CUDA_SCALE_LAUNCH_QUEUES": "4x" + }, + "notes": [ + "CUDA_SCALE_LAUNCH_QUEUES=4x (2 GPU, parallélisme pipeline possible) — CUDA_LAUNCH_QUEUES=off pour s'en passer", + "SPEC=mtp : pas de tête MTP dans ce fichier (publiée à part ? MODEL_DRAFT=mtp-….gguf) — sans décodage spéculatif" + ] + }, + { + "m": 1, + "c": 46, + "h": 0, + "e": 0, + "args": [ + "/app/llama-server", + "-m", + "/m/moe.gguf", + "-c", + "32768", + "-b", + "2048", + "-ub", + "512", + "--host", + "0.0.0.0", + "--port", + "8080", + "--parallel", + "1", + "-ngl", + "auto" + ], + "env": { + "CUDA_SCALE_LAUNCH_QUEUES": "4x" + }, + "notes": [ + "CUDA_SCALE_LAUNCH_QUEUES=4x (2 GPU, parallélisme pipeline possible) — CUDA_LAUNCH_QUEUES=off pour s'en passer" + ] + }, + { + "m": 1, + "c": 47, + "h": 0, + "e": 0, + "args": [ + "/app/llama-server", + "-m", + "/m/moe.gguf", + "-c", + "32768", + "-b", + "2048", + "-ub", + "512", + "--host", + "0.0.0.0", + "--port", + "8080", + "--parallel", + "1", + "-ngl", + "auto" + ], + "env": { + "CUDA_SCALE_LAUNCH_QUEUES": "4x" + }, + "notes": [ + "CUDA_SCALE_LAUNCH_QUEUES=4x (2 GPU, parallélisme pipeline possible) — CUDA_LAUNCH_QUEUES=off pour s'en passer", + "SPEC=mtp : pas de tête MTP dans ce fichier (publiée à part ? MODEL_DRAFT=mtp-….gguf) — sans décodage spéculatif" + ] + }, + { + "m": 1, + "c": 48, + "h": 0, + "e": 0, + "args": [ + "/app/llama-server", + "-m", + "/m/moe.gguf", + "-c", + "32768", + "-b", + "2048", + "-ub", + "512", + "--host", + "0.0.0.0", + "--port", + "8080", + "--parallel", + "1", + "-ngl", + "auto", + "--spec-type", + "ngram-mod", + "--spec-ngram-mod-n-match", + "24", + "--spec-ngram-mod-n-min", + "48", + "--spec-ngram-mod-n-max", + "64" + ], + "env": { + "CUDA_SCALE_LAUNCH_QUEUES": "4x" + }, + "notes": [ + "CUDA_SCALE_LAUNCH_QUEUES=4x (2 GPU, parallélisme pipeline possible) — CUDA_LAUNCH_QUEUES=off pour s'en passer", + "SPEC=ngram → n-grammes du contexte (ngram-mod, brouillons de 48 à 64 jetons) : distribution inchangée (chaque jeton vérifié), ~16 Mo de RAM ; utile surtout en mode code, à mesurer (acceptation par nature dans /api/perf/summary)", + "MODEL_DRAFT ignoré avec SPEC=ngram : SPEC=mtp+ngram pour les deux", + "SPEC_N_MAX et SPEC_SAMPLING ignorés : les n-grammes n'ont pas de brouillon à régler" + ] + }, + { + "m": 1, + "c": 49, + "h": 0, + "e": 0, + "args": [ + "/app/llama-server", + "-m", + "/m/moe.gguf", + "-c", + "32768", + "-b", + "2048", + "-ub", + "512", + "--host", + "0.0.0.0", + "--port", + "8080", + "--parallel", + "1", + "-ngl", + "auto" + ], + "env": { + "CUDA_SCALE_LAUNCH_QUEUES": "4x" + }, + "notes": [ + "CUDA_SCALE_LAUNCH_QUEUES=4x (2 GPU, parallélisme pipeline possible) — CUDA_LAUNCH_QUEUES=off pour s'en passer", + "SPEC=auto : MODEL_DRAFT=small.gguf introuvable — sans décodage spéculatif" + ] + }, + { + "m": 1, + "c": 50, + "h": 0, + "e": 0, + "args": [ + "/app/llama-server", + "-m", + "/m/moe.gguf", + "-c", + "32768", + "-b", + "2048", + "-ub", + "512", + "--host", + "0.0.0.0", + "--port", + "8080", + "--parallel", + "1", + "-ngl", + "auto", + "--spec-type", + "ngram-mod", + "--spec-ngram-mod-n-match", + "24", + "--spec-ngram-mod-n-min", + "48", + "--spec-ngram-mod-n-max", + "64" + ], + "env": { + "CUDA_SCALE_LAUNCH_QUEUES": "4x" + }, + "notes": [ + "CUDA_SCALE_LAUNCH_QUEUES=4x (2 GPU, parallélisme pipeline possible) — CUDA_LAUNCH_QUEUES=off pour s'en passer", + "SPEC=mtp+ngram : sans MTP (MODEL_DRAFT=small.gguf introuvable) → n-grammes seuls. SPEC=ngram → n-grammes du contexte (ngram-mod, brouillons de 48 à 64 jetons) : distribution inchangée (chaque jeton vérifié), ~16 Mo de RAM ; utile surtout en mode code, à mesurer (acceptation par nature dans /api/perf/summary)" + ] + }, + { + "m": 1, + "c": 51, + "h": 0, + "e": 0, + "args": [ + "/app/llama-server", + "-m", + "/m/moe.gguf", + "-c", + "32768", + "-b", + "2048", + "-ub", + "512", + "--host", + "0.0.0.0", + "--port", + "8080", + "--parallel", + "1", + "-ngl", + "auto" + ], + "env": {}, + "notes": null + }, + { + "m": 1, + "c": 52, + "h": 0, + "e": 0, + "args": [ + "/app/llama-server", + "-m", + "/m/moe.gguf", + "-c", + "32768", + "-b", + "2048", + "-ub", + "512", + "--host", + "0.0.0.0", + "--port", + "8080", + "--parallel", + "1", + "-ngl", + "auto" + ], + "env": { + "CUDA_SCALE_LAUNCH_QUEUES": "2x" + }, + "notes": [ + "CUDA_SCALE_LAUNCH_QUEUES=2x (CUDA_LAUNCH_QUEUES)" + ] + }, + { + "m": 1, + "c": 53, + "h": 0, + "e": 0, + "args": [ + "/app/llama-server", + "-m", + "/m/moe.gguf", + "-c", + "32768", + "-b", + "2048", + "-ub", + "512", + "--host", + "0.0.0.0", + "--port", + "8080", + "--parallel", + "1", + "-ngl", + "auto" + ], + "env": { + "CUDA_SCALE_LAUNCH_QUEUES": "4x" + }, + "notes": [ + "CUDA_LAUNCH_QUEUES=8x ignoré (0.25x, 0.5x, 2x, 4x ou off) → automatique", + "CUDA_SCALE_LAUNCH_QUEUES=4x (2 GPU, parallélisme pipeline possible) — CUDA_LAUNCH_QUEUES=off pour s'en passer" + ] + }, + { + "m": 1, + "c": 54, + "h": 0, + "e": 0, + "args": [ + "/app/llama-server", + "-m", + "/m/moe.gguf", + "-c", + "32768", + "-b", + "2048", + "-ub", + "512", + "--host", + "0.0.0.0", + "--port", + "8080", + "--parallel", + "1", + "-ngl", + "auto" + ], + "env": { + "CUDA_SCALE_LAUNCH_QUEUES": "4x" + }, + "notes": [ + "CUDA_SCALE_LAUNCH_QUEUES=4x (2 GPU, parallélisme pipeline possible) — CUDA_LAUNCH_QUEUES=off pour s'en passer" + ] + }, + { + "m": 1, + "c": 55, + "h": 0, + "e": 0, + "args": [ + "/app/llama-server", + "-m", + "/m/moe.gguf", + "-c", + "32768", + "-b", + "2048", + "-ub", + "512", + "--host", + "0.0.0.0", + "--port", + "8080", + "--parallel", + "1", + "-ngl", + "auto" + ], + "env": { + "CUDA_DEVICE_ORDER": "PCI_BUS_ID", + "CUDA_SCALE_LAUNCH_QUEUES": "4x", + "CUDA_VISIBLE_DEVICES": "1,0" + }, + "notes": [ + "CUDA_SCALE_LAUNCH_QUEUES=4x (2 GPU, parallélisme pipeline possible) — CUDA_LAUNCH_QUEUES=off pour s'en passer" + ] + }, + { + "m": 1, + "c": 56, + "h": 0, + "e": 0, + "args": [ + "/app/llama-server", + "-m", + "/m/moe.gguf", + "-c", + "32768", + "-b", + "2048", + "-ub", + "512", + "--host", + "0.0.0.0", + "--port", + "8080", + "--parallel", + "1", + "-ngl", + "auto", + "--fit-target", + "1024,3072" + ], + "env": { + "CUDA_SCALE_LAUNCH_QUEUES": "4x" + }, + "notes": [ + "CUDA_SCALE_LAUNCH_QUEUES=4x (2 GPU, parallélisme pipeline possible) — CUDA_LAUNCH_QUEUES=off pour s'en passer", + "--fit-target 1024,3072 (FIT_TARGET, Mio libres par carte, dans l'ordre vu par le moteur) — vérifie « offloaded N/N layers » dans le journal" + ] + }, + { + "m": 1, + "c": 57, + "h": 0, + "e": 0, + "args": [ + "/app/llama-server", + "-m", + "/m/moe.gguf", + "-c", + "32768", + "-b", + "2048", + "-ub", + "512", + "--host", + "0.0.0.0", + "--port", + "8080", + "--parallel", + "1", + "-ngl", + "auto", + "--fit-target", + "1024" + ], + "env": { + "CUDA_SCALE_LAUNCH_QUEUES": "4x" + }, + "notes": [ + "CUDA_SCALE_LAUNCH_QUEUES=4x (2 GPU, parallélisme pipeline possible) — CUDA_LAUNCH_QUEUES=off pour s'en passer", + "--fit-target 1024 (FIT_TARGET, Mio libres par carte, dans l'ordre vu par le moteur) — vérifie « offloaded N/N layers » dans le journal ; marges sous 1024 Mio remontées à 1024" + ] + }, + { + "m": 1, + "c": 58, + "h": 0, + "e": 0, + "args": [ + "/app/llama-server", + "-m", + "/m/moe.gguf", + "-c", + "32768", + "-b", + "2048", + "-ub", + "512", + "--host", + "0.0.0.0", + "--port", + "8080", + "--parallel", + "1", + "-ngl", + "auto" + ], + "env": { + "CUDA_SCALE_LAUNCH_QUEUES": "4x" + }, + "notes": [ + "CUDA_SCALE_LAUNCH_QUEUES=4x (2 GPU, parallélisme pipeline possible) — CUDA_LAUNCH_QUEUES=off pour s'en passer", + "FIT_TARGET=1024,,3072 ignoré : des Mio par carte, ex. 1024,3072 (8 valeurs au plus)" + ] + }, + { + "m": 1, + "c": 59, + "h": 0, + "e": 0, + "args": [ + "/app/llama-server", + "-m", + "/m/moe.gguf", + "-c", + "32768", + "-b", + "2048", + "-ub", + "512", + "--host", + "0.0.0.0", + "--port", + "8080", + "--parallel", + "1", + "-ngl", + "auto" + ], + "env": { + "CUDA_SCALE_LAUNCH_QUEUES": "4x" + }, + "notes": [ + "CUDA_SCALE_LAUNCH_QUEUES=4x (2 GPU, parallélisme pipeline possible) — CUDA_LAUNCH_QUEUES=off pour s'en passer", + "FIT_TARGET=1,2,3,4,5,6,7,8,9 ignoré : des Mio par carte, ex. 1024,3072 (8 valeurs au plus)" + ] + }, + { + "m": 1, + "c": 60, + "h": 0, + "e": 0, + "args": [ + "/app/llama-server", + "-m", + "/m/moe.gguf", + "-c", + "0", + "-b", + "2048", + "-ub", + "512", + "--host", + "0.0.0.0", + "--port", + "8080", + "--parallel", + "1", + "-ngl", + "auto" + ], + "env": { + "CUDA_SCALE_LAUNCH_QUEUES": "4x" + }, + "notes": [ + "CUDA_SCALE_LAUNCH_QUEUES=4x (2 GPU, parallélisme pipeline possible) — CUDA_LAUNCH_QUEUES=off pour s'en passer", + "FIT_TARGET=2048/4096 ignoré : contexte « 0 » non fixé — --fit pourrait le réduire pour tenir ; donne un CTX chiffré" + ] + }, + { + "m": 1, + "c": 61, + "h": 0, + "e": 0, + "args": [ + "/app/llama-server", + "-m", + "/m/moe.gguf", + "-c", + "32768", + "-b", + "2048", + "-ub", + "512", + "--host", + "0.0.0.0", + "--port", + "8080", + "--parallel", + "1", + "-ngl", + "30" + ], + "env": {}, + "notes": [ + "FIT_TARGET=2048 ignoré : --fit inactif (couches GPU fixées à 30), la marge ne servirait à rien" + ] + }, + { + "m": 1, + "c": 62, + "h": 0, + "e": 0, + "args": [ + "/app/llama-server", + "-m", + "/m/moe.gguf", + "-c", + "32768", + "-b", + "2048", + "-ub", + "512", + "--host", + "0.0.0.0", + "--port", + "8080", + "--parallel", + "1", + "-ngl", + "auto" + ], + "env": { + "CUDA_SCALE_LAUNCH_QUEUES": "4x", + "GGML_OP_OFFLOAD_MIN_BATCH": "128" + }, + "notes": [ + "CUDA_SCALE_LAUNCH_QUEUES=4x (2 GPU, parallélisme pipeline possible) — CUDA_LAUNCH_QUEUES=off pour s'en passer", + "GGML_OP_OFFLOAD_MIN_BATCH=128 (OP_OFFLOAD_MIN_BATCH) : lots de moins de 128 jetons calculés sur CPU pour les poids restés en RAM" + ] + }, + { + "m": 1, + "c": 63, + "h": 0, + "e": 0, + "args": [ + "/app/llama-server", + "-m", + "/m/moe.gguf", + "-c", + "32768", + "-b", + "2048", + "-ub", + "512", + "--host", + "0.0.0.0", + "--port", + "8080", + "--parallel", + "1", + "-ngl", + "auto" + ], + "env": { + "CUDA_SCALE_LAUNCH_QUEUES": "4x" + }, + "notes": [ + "CUDA_SCALE_LAUNCH_QUEUES=4x (2 GPU, parallélisme pipeline possible) — CUDA_LAUNCH_QUEUES=off pour s'en passer" + ] + }, + { + "m": 1, + "c": 64, + "h": 0, + "e": 0, + "args": [ + "/app/llama-server", + "-m", + "/m/moe.gguf", + "-c", + "32768", + "-b", + "2048", + "-ub", + "512", + "--host", + "0.0.0.0", + "--port", + "8080", + "--parallel", + "1", + "-ngl", + "auto" + ], + "env": { + "CUDA_SCALE_LAUNCH_QUEUES": "4x", + "GGML_CUDA_GRAPH_OPT": "1" + }, + "notes": [ + "CUDA_SCALE_LAUNCH_QUEUES=4x (2 GPU, parallélisme pipeline possible) — CUDA_LAUNCH_QUEUES=off pour s'en passer", + "GGML_CUDA_GRAPH_OPT=1 (CUDA_GRAPH_OPT, expérimental) — si le moteur s'arrête sur un GGML_ASSERT, CUDA_GRAPH_OPT=off puis redémarre" + ] + }, + { + "m": 1, + "c": 65, + "h": 0, + "e": 0, + "args": [ + "/app/llama-server", + "-m", + "/m/moe.gguf", + "-c", + "32768", + "-b", + "2048", + "-ub", + "512", + "--host", + "0.0.0.0", + "--port", + "8080", + "--parallel", + "1", + "-ngl", + "auto" + ], + "env": { + "CUDA_SCALE_LAUNCH_QUEUES": "4x" + }, + "notes": [ + "CUDA_SCALE_LAUNCH_QUEUES=4x (2 GPU, parallélisme pipeline possible) — CUDA_LAUNCH_QUEUES=off pour s'en passer", + "CUDA_GRAPH_OPT=oui ignoré (on ou off)" + ] + }, + { + "m": 1, + "c": 66, + "h": 0, + "e": 0, + "args": [ + "/app/llama-server", + "-m", + "/m/moe.gguf", + "-c", + "32768", + "-b", + "2048", + "-ub", + "512", + "--host", + "0.0.0.0", + "--port", + "8080", + "--parallel", + "1", + "-ngl", + "auto" + ], + "env": { + "CUDA_SCALE_LAUNCH_QUEUES": "4x" + }, + "notes": [ + "CUDA_SCALE_LAUNCH_QUEUES=4x (2 GPU, parallélisme pipeline possible) — CUDA_LAUNCH_QUEUES=off pour s'en passer" + ] + }, + { + "m": 1, + "c": 67, + "h": 0, + "e": 0, + "args": [ + "/app/llama-server", + "-m", + "/m/moe.gguf", + "-c", + "32768", + "-b", + "2048", + "-ub", + "512", + "--host", + "0.0.0.0", + "--port", + "8080", + "--parallel", + "1", + "-ngl", + "auto", + "--ctx-checkpoints", + "8" + ], + "env": { + "CUDA_SCALE_LAUNCH_QUEUES": "4x" + }, + "notes": [ + "CUDA_SCALE_LAUNCH_QUEUES=4x (2 GPU, parallélisme pipeline possible) — CUDA_LAUNCH_QUEUES=off pour s'en passer" + ] + }, + { + "m": 1, + "c": 68, + "h": 0, + "e": 0, + "args": [ + "/app/llama-server", + "-m", + "/m/moe.gguf", + "-c", + "32768", + "-b", + "2048", + "-ub", + "512", + "--host", + "0.0.0.0", + "--port", + "8080", + "--parallel", + "1", + "-ngl", + "auto" + ], + "env": { + "CUDA_SCALE_LAUNCH_QUEUES": "4x" + }, + "notes": [ + "CUDA_SCALE_LAUNCH_QUEUES=4x (2 GPU, parallélisme pipeline possible) — CUDA_LAUNCH_QUEUES=off pour s'en passer", + "CTX_CHECKPOINTS=beaucoup illisible (un nombre de points de reprise par slot, ≥ 0) : ignoré" + ] + }, + { + "m": 1, + "c": 69, + "h": 0, + "e": 0, + "args": [ + "/app/llama-server", + "-m", + "/m/moe.gguf", + "-c", + "32768", + "-b", + "2048", + "-ub", + "512", + "--host", + "0.0.0.0", + "--port", + "8080", + "--parallel", + "1", + "-ngl", + "auto", + "--checkpoint-min-step", + "1024" + ], + "env": { + "CUDA_SCALE_LAUNCH_QUEUES": "4x" + }, + "notes": [ + "CUDA_SCALE_LAUNCH_QUEUES=4x (2 GPU, parallélisme pipeline possible) — CUDA_LAUNCH_QUEUES=off pour s'en passer" + ] + }, + { + "m": 1, + "c": 70, + "h": 0, + "e": 0, + "args": [ + "/app/llama-server", + "-m", + "/m/moe.gguf", + "-c", + "32768", + "-b", + "2048", + "-ub", + "512", + "--host", + "0.0.0.0", + "--port", + "8080", + "--parallel", + "1", + "-ngl", + "auto" + ], + "env": { + "CUDA_SCALE_LAUNCH_QUEUES": "4x" + }, + "notes": [ + "CUDA_SCALE_LAUNCH_QUEUES=4x (2 GPU, parallélisme pipeline possible) — CUDA_LAUNCH_QUEUES=off pour s'en passer", + "CKPT_MIN_STEP=0 refusé (un nombre de jetons > 0 ; 0 laisserait le moteur jeter le point de reprise du début de la conversation) : défaut du moteur" + ] + }, + { + "m": 1, + "c": 71, + "h": 0, + "e": 0, + "args": [ + "/app/llama-server", + "-m", + "/m/moe.gguf", + "-c", + "32768", + "-b", + "2048", + "-ub", + "512", + "--host", + "0.0.0.0", + "--port", + "8080", + "--parallel", + "1", + "-ngl", + "auto" + ], + "env": { + "CUDA_SCALE_LAUNCH_QUEUES": "4x" + }, + "notes": [ + "CUDA_SCALE_LAUNCH_QUEUES=4x (2 GPU, parallélisme pipeline possible) — CUDA_LAUNCH_QUEUES=off pour s'en passer", + "SLOT_PERSIST refusé (dossier des slots indisponible) : l'état du slot ne sera pas gardé à la bascule de preset" + ] + }, + { + "m": 1, + "c": 72, + "h": 0, + "e": 0, + "args": [ + "/app/llama-server", + "-m", + "/m/moe.gguf", + "-c", + "32768", + "-b", + "2048", + "-ub", + "512", + "--host", + "127.0.0.1", + "--port", + "8080", + "--parallel", + "1", + "-ngl", + "auto" + ], + "env": { + "CUDA_SCALE_LAUNCH_QUEUES": "4x" + }, + "notes": [ + "CUDA_SCALE_LAUNCH_QUEUES=4x (2 GPU, parallélisme pipeline possible) — CUDA_LAUNCH_QUEUES=off pour s'en passer", + "SLOT_PERSIST refusé (dossier des slots indisponible) : l'état du slot ne sera pas gardé à la bascule de preset" + ] + }, + { + "m": 1, + "c": 73, + "h": 0, + "e": 0, + "args": [ + "/app/llama-server", + "-m", + "/m/moe.gguf", + "-c", + "32768", + "-b", + "2048", + "-ub", + "512", + "--host", + "127.0.0.1", + "--port", + "8080", + "--parallel", + "1", + "-ngl", + "auto" + ], + "env": { + "CUDA_SCALE_LAUNCH_QUEUES": "4x" + }, + "notes": [ + "CUDA_SCALE_LAUNCH_QUEUES=4x (2 GPU, parallélisme pipeline possible) — CUDA_LAUNCH_QUEUES=off pour s'en passer", + "SLOT_PERSIST refusé (dossier des slots indisponible) : l'état du slot ne sera pas gardé à la bascule de preset", + "SPEC=mtp : pas de tête MTP dans ce fichier (publiée à part ? MODEL_DRAFT=mtp-….gguf) — sans décodage spéculatif" + ] + }, + { + "m": 1, + "c": 74, + "h": 0, + "e": 0, + "args": [ + "/app/llama-server", + "-m", + "/m/moe.gguf", + "-c", + "32768", + "-b", + "2048", + "-ub", + "512", + "--host", + "0.0.0.0", + "--port", + "8080", + "--parallel", + "1", + "-ngl", + "auto", + "--load-mode", + "none" + ], + "env": { + "CUDA_SCALE_LAUNCH_QUEUES": "4x" + }, + "notes": [ + "CUDA_SCALE_LAUNCH_QUEUES=4x (2 GPU, parallélisme pipeline possible) — CUDA_LAUNCH_QUEUES=off pour s'en passer" + ] + }, + { + "m": 1, + "c": 75, + "h": 0, + "e": 0, + "args": [ + "/app/llama-server", + "-m", + "/m/moe.gguf", + "-c", + "32768", + "-b", + "2048", + "-ub", + "512", + "--host", + "0.0.0.0", + "--port", + "8080", + "--parallel", + "1", + "-ngl", + "auto", + "--load-mode", + "mlock" + ], + "env": { + "CUDA_SCALE_LAUNCH_QUEUES": "4x" + }, + "notes": [ + "CUDA_SCALE_LAUNCH_QUEUES=4x (2 GPU, parallélisme pipeline possible) — CUDA_LAUNCH_QUEUES=off pour s'en passer" + ] + }, + { + "m": 1, + "c": 76, + "h": 0, + "e": 0, + "args": [ + "/app/llama-server", + "-m", + "/m/moe.gguf", + "-c", + "32768", + "-b", + "2048", + "-ub", + "512", + "--host", + "0.0.0.0", + "--port", + "8080", + "--parallel", + "1", + "-ngl", + "auto", + "--load-mode", + "mmap+mlock" + ], + "env": { + "CUDA_SCALE_LAUNCH_QUEUES": "4x" + }, + "notes": [ + "CUDA_SCALE_LAUNCH_QUEUES=4x (2 GPU, parallélisme pipeline possible) — CUDA_LAUNCH_QUEUES=off pour s'en passer" + ] + }, + { + "m": 1, + "c": 77, + "h": 0, + "e": 0, + "args": [ + "/app/llama-server", + "-m", + "/m/moe.gguf", + "-c", + "32768", + "-b", + "2048", + "-ub", + "512", + "--host", + "0.0.0.0", + "--port", + "8080", + "--parallel", + "1", + "-ngl", + "auto", + "--load-mode", + "none" + ], + "env": { + "CUDA_SCALE_LAUNCH_QUEUES": "4x" + }, + "notes": [ + "CUDA_SCALE_LAUNCH_QUEUES=4x (2 GPU, parallélisme pipeline possible) — CUDA_LAUNCH_QUEUES=off pour s'en passer" + ] + }, + { + "m": 1, + "c": 78, + "h": 0, + "e": 0, + "args": [ + "/app/llama-server", + "-m", + "/m/moe.gguf", + "-c", + "32768", + "-b", + "2048", + "-ub", + "512", + "--host", + "0.0.0.0", + "--port", + "8080", + "--parallel", + "1", + "-ngl", + "auto", + "--load-mode", + "mmap" + ], + "env": { + "CUDA_SCALE_LAUNCH_QUEUES": "4x" + }, + "notes": [ + "CUDA_SCALE_LAUNCH_QUEUES=4x (2 GPU, parallélisme pipeline possible) — CUDA_LAUNCH_QUEUES=off pour s'en passer" + ] + }, + { + "m": 1, + "c": 79, + "h": 0, + "e": 0, + "args": [ + "/app/llama-server", + "-m", + "/m/moe.gguf", + "-c", + "32768", + "-b", + "2048", + "-ub", + "512", + "--host", + "0.0.0.0", + "--port", + "8080", + "--parallel", + "1", + "-ngl", + "auto", + "--load-mode", + "dio" + ], + "env": { + "CUDA_SCALE_LAUNCH_QUEUES": "4x" + }, + "notes": [ + "CUDA_SCALE_LAUNCH_QUEUES=4x (2 GPU, parallélisme pipeline possible) — CUDA_LAUNCH_QUEUES=off pour s'en passer" + ] + }, + { + "m": 1, + "c": 80, + "h": 0, + "e": 0, + "args": [ + "/app/llama-server", + "-m", + "/m/moe.gguf", + "-c", + "32768", + "-b", + "2048", + "-ub", + "512", + "--host", + "0.0.0.0", + "--port", + "8080", + "--parallel", + "1", + "-ngl", + "auto", + "--load-mode=mlock" + ], + "env": { + "CUDA_SCALE_LAUNCH_QUEUES": "4x" + }, + "notes": [ + "CUDA_SCALE_LAUNCH_QUEUES=4x (2 GPU, parallélisme pipeline possible) — CUDA_LAUNCH_QUEUES=off pour s'en passer" + ] + }, + { + "m": 1, + "c": 81, + "h": 0, + "e": 0, + "args": [ + "/app/llama-server", + "-m", + "/m/moe.gguf", + "-c", + "32768", + "-b", + "2048", + "-ub", + "512", + "--host", + "0.0.0.0", + "--port", + "8080", + "-ngl", + "30", + "--parallel", + "4" + ], + "env": {}, + "notes": null + }, + { + "m": 1, + "c": 82, + "h": 0, + "e": 0, + "args": [ + "/app/llama-server", + "-m", + "/m/moe.gguf", + "-c", + "32768", + "-b", + "2048", + "-ub", + "512", + "--host", + "0.0.0.0", + "--port", + "8080", + "--parallel", + "1", + "-ngl", + "auto", + "-t", + "6", + "-tb", + "3" + ], + "env": { + "CUDA_SCALE_LAUNCH_QUEUES": "4x" + }, + "notes": [ + "CUDA_SCALE_LAUNCH_QUEUES=4x (2 GPU, parallélisme pipeline possible) — CUDA_LAUNCH_QUEUES=off pour s'en passer" + ] + }, + { + "m": 1, + "c": 83, + "h": 0, + "e": 0, + "args": [ + "/app/llama-server", + "-m", + "/m/moe.gguf", + "-c", + "32768", + "-tb", + "0", + "-b", + "2048", + "-ub", + "512", + "--host", + "0.0.0.0", + "--port", + "8080", + "--parallel", + "1", + "-ngl", + "auto", + "-Cb", + "0xff", + "--poll-batch", + "1" + ], + "env": { + "CUDA_SCALE_LAUNCH_QUEUES": "4x" + }, + "notes": [ + "CUDA_SCALE_LAUNCH_QUEUES=4x (2 GPU, parallélisme pipeline possible) — CUDA_LAUNCH_QUEUES=off pour s'en passer", + "EXTRA_ARGS règle le CPU du batch (-Cb, --poll-batch…) : -tb 0 posé pour que llama.cpp le garde (tous les threads logiques) ; THREADS_BATCH pour choisir le nombre" + ] + }, + { + "m": 1, + "c": 84, + "h": 0, + "e": 0, + "args": [ + "/app/llama-server", + "-m", + "/m/moe.gguf", + "-c", + "32768", + "-t", + "6", + "-tb", + "6", + "-b", + "2048", + "-ub", + "512", + "--host", + "0.0.0.0", + "--port", + "8080", + "--parallel", + "1", + "-ngl", + "auto", + "--prio-batch", + "2" + ], + "env": { + "CUDA_SCALE_LAUNCH_QUEUES": "4x" + }, + "notes": [ + "CUDA_SCALE_LAUNCH_QUEUES=4x (2 GPU, parallélisme pipeline possible) — CUDA_LAUNCH_QUEUES=off pour s'en passer" + ] + }, + { + "m": 1, + "c": 85, + "h": 0, + "e": 0, + "args": [ + "/app/llama-server", + "-m", + "/m/moe.gguf", + "-c", + "32768", + "-b", + "2048", + "-ub", + "512", + "--host", + "0.0.0.0", + "--port", + "8080", + "--parallel", + "1", + "-ngl", + "auto", + "--cache-reuse", + "256", + "--context-shift" + ], + "env": { + "CUDA_SCALE_LAUNCH_QUEUES": "4x" + }, + "notes": [ + "CUDA_SCALE_LAUNCH_QUEUES=4x (2 GPU, parallélisme pipeline possible) — CUDA_LAUNCH_QUEUES=off pour s'en passer", + "avertissement : --context-shift (EXTRA_ARGS) — contexte plein, le moteur jette des jetons anciens et le modèle perd une partie de la conversation sans le savoir.", + "avertissement : --cache-reuse 256 (EXTRA_ARGS) — réutilise des morceaux de cache calculés sous un autre préfixe : les sorties ne sont plus exactement celles d'un calcul complet." + ] + }, + { + "m": 1, + "c": 86, + "h": 0, + "e": 0, + "args": [ + "/app/llama-server", + "-m", + "/m/moe.gguf", + "-c", + "32768", + "-b", + "2048", + "-ub", + "512", + "--host", + "0.0.0.0", + "--port", + "8080", + "--parallel", + "1", + "-ngl", + "auto", + "-ctk", + "q8_0", + "-ctv", + "q8_0" + ], + "env": { + "CUDA_SCALE_LAUNCH_QUEUES": "4x" + }, + "notes": [ + "CUDA_SCALE_LAUNCH_QUEUES=4x (2 GPU, parallélisme pipeline possible) — CUDA_LAUNCH_QUEUES=off pour s'en passer", + "cache KV q8_0/q8_0 (défini par EXTRA_ARGS) : modifie légèrement les sorties par rapport à f16 — choix de l'utilisateur, laissé tel quel." + ] + }, + { + "m": 1, + "c": 87, + "h": 0, + "e": 0, + "args": [ + "/app/llama-server", + "-m", + "/m/moe.gguf", + "-c", + "32768", + "-b", + "2048", + "-ub", + "512", + "--host", + "0.0.0.0", + "--port", + "8080", + "--parallel", + "1", + "-ngl", + "auto", + "-ot", + "exps=CPU" + ], + "env": {}, + "notes": [ + "experts placés à la main (-ot experts=CPU) : --fit ne les place pas ; UBATCH plus grand = tampon de calcul plus gros sur chaque GPU : monte --n-cpu-moe d'abord, ou passe au placement auto (--fit, « Dupliquer en placement auto » dans l'éditeur)" + ] + }, + { + "m": 1, + "c": 88, + "h": 0, + "e": 0, + "args": [ + "/app/llama-server", + "-m", + "/m/moe.gguf", + "-c", + "32768", + "-b", + "2048", + "-ub", + "512", + "--host", + "0.0.0.0", + "--port", + "8080", + "--parallel", + "1", + "-ngl", + "auto", + "-ot", + "blk\\.(1|2)\\.ffn_.*_exps=CPU", + "-ub", + "512" + ], + "env": {}, + "notes": [ + "experts placés à la main (-ot experts=CPU) : --fit ne les place pas ; UBATCH plus grand = tampon de calcul plus gros sur chaque GPU : monte --n-cpu-moe d'abord, ou passe au placement auto (--fit, « Dupliquer en placement auto » dans l'éditeur)" + ] + }, + { + "m": 1, + "c": 89, + "h": 0, + "e": 0, + "args": [ + "/app/llama-server", + "-m", + "/m/moe.gguf", + "-c", + "32768", + "-b", + "2048", + "-ub", + "512", + "--host", + "0.0.0.0", + "--port", + "8080", + "--parallel", + "1", + "-ngl", + "auto", + "--n-cpu-moe", + "20" + ], + "env": {}, + "notes": [ + "experts placés à la main (--n-cpu-moe 20) : --fit ne les place pas ; UBATCH plus grand = tampon de calcul plus gros sur chaque GPU : monte --n-cpu-moe d'abord, ou passe au placement auto (--fit, « Dupliquer en placement auto » dans l'éditeur)" + ] + }, + { + "m": 1, + "c": 90, + "h": 0, + "e": 0, + "args": [ + "/app/llama-server", + "-m", + "/m/moe.gguf", + "-c", + "32768", + "-b", + "2048", + "-ub", + "512", + "--host", + "0.0.0.0", + "--port", + "8080", + "--parallel", + "1", + "-ngl", + "auto", + "--cpu-moe" + ], + "env": {}, + "notes": [ + "experts placés à la main (--cpu-moe) : --fit ne les place pas ; UBATCH plus grand = tampon de calcul plus gros sur chaque GPU : monte --n-cpu-moe d'abord, ou passe au placement auto (--fit, « Dupliquer en placement auto » dans l'éditeur)" + ] + }, + { + "m": 1, + "c": 91, + "h": 0, + "e": 0, + "args": [ + "/app/llama-server", + "-m", + "/m/moe.gguf", + "-c", + "32768", + "-b", + "2048", + "-ub", + "512", + "--host", + "0.0.0.0", + "--port", + "8080", + "--parallel", + "1", + "-ngl", + "auto", + "-sm", + "row" + ], + "env": {}, + "notes": null + }, + { + "m": 1, + "c": 92, + "h": 0, + "e": 0, + "args": [ + "/app/llama-server", + "-m", + "/m/moe.gguf", + "-c", + "32768", + "-b", + "2048", + "-ub", + "512", + "--host", + "0.0.0.0", + "--port", + "8080", + "--parallel", + "1", + "-ngl", + "auto", + "-ts", + "1,1" + ], + "env": { + "CUDA_SCALE_LAUNCH_QUEUES": "4x" + }, + "notes": [ + "CUDA_SCALE_LAUNCH_QUEUES=4x (2 GPU, parallélisme pipeline possible) — CUDA_LAUNCH_QUEUES=off pour s'en passer" + ] + }, + { + "m": 1, + "c": 93, + "h": 0, + "e": 0, + "args": [ + "/app/llama-server", + "-m", + "/m/moe.gguf", + "-c", + "32768", + "-b", + "2048", + "-ub", + "512", + "--host", + "0.0.0.0", + "--port", + "8080", + "--parallel", + "1", + "-ngl", + "auto", + "-dev", + "CUDA0,CUDA1" + ], + "env": { + "CUDA_SCALE_LAUNCH_QUEUES": "4x" + }, + "notes": [ + "CUDA_SCALE_LAUNCH_QUEUES=4x (2 GPU, parallélisme pipeline possible) — CUDA_LAUNCH_QUEUES=off pour s'en passer" + ] + }, + { + "m": 1, + "c": 94, + "h": 0, + "e": 0, + "args": [ + "/app/llama-server", + "-m", + "/m/moe.gguf", + "-c", + "32768", + "-b", + "2048", + "-ub", + "512", + "--host", + "0.0.0.0", + "--port", + "8080", + "--parallel", + "1", + "-ngl", + "auto", + "-dev", + "Vulkan0" + ], + "env": {}, + "notes": null + }, + { + "m": 1, + "c": 95, + "h": 0, + "e": 0, + "args": [ + "/app/llama-server", + "-m", + "/m/moe.gguf", + "-c", + "32768", + "-b", + "2048", + "-ub", + "512", + "--host", + "0.0.0.0", + "--port", + "8080", + "--parallel", + "1", + "-ngl", + "auto", + "--fit", + "off" + ], + "env": { + "CUDA_SCALE_LAUNCH_QUEUES": "4x" + }, + "notes": [ + "CUDA_SCALE_LAUNCH_QUEUES=4x (2 GPU, parallélisme pipeline possible) — CUDA_LAUNCH_QUEUES=off pour s'en passer" + ] + }, + { + "m": 1, + "c": 96, + "h": 0, + "e": 0, + "args": [ + "/app/llama-server", + "-m", + "/m/moe.gguf", + "-c", + "32768", + "-b", + "2048", + "-ub", + "512", + "--host", + "0.0.0.0", + "--port", + "8080", + "--parallel", + "1", + "-ngl", + "auto", + "--mmproj", + "/m/x.gguf" + ], + "env": { + "CUDA_SCALE_LAUNCH_QUEUES": "4x" + }, + "notes": [ + "CUDA_SCALE_LAUNCH_QUEUES=4x (2 GPU, parallélisme pipeline possible) — CUDA_LAUNCH_QUEUES=off pour s'en passer" + ] + }, + { + "m": 1, + "c": 97, + "h": 0, + "e": 0, + "args": [ + "/app/llama-server", + "-m", + "/m/moe.gguf", + "-c", + "32768", + "-b", + "2048", + "-ub", + "512", + "--host", + "0.0.0.0", + "--port", + "8080", + "--parallel", + "1", + "-ngl", + "auto", + "--spec-synth-len", + "4" + ], + "env": { + "CUDA_SCALE_LAUNCH_QUEUES": "4x" + }, + "notes": [ + "CUDA_SCALE_LAUNCH_QUEUES=4x (2 GPU, parallélisme pipeline possible) — CUDA_LAUNCH_QUEUES=off pour s'en passer", + "avertissement : --spec-synth-len — le moteur accepte des jetons du brouillon AU HASARD, sans vérification : les réponses ne sont plus celles du modèle. Réservé aux mesures, retire-le." + ] + }, + { + "m": 1, + "c": 98, + "h": 0, + "e": 0, + "args": [ + "/app/llama-server", + "-m", + "/m/moe.gguf", + "-c", + "32768", + "-b", + "2048", + "-ub", + "512", + "--host", + "0.0.0.0", + "--port", + "8080", + "--parallel", + "1", + "-ngl", + "auto", + "--slot-save-path", + "/x" + ], + "env": { + "CUDA_SCALE_LAUNCH_QUEUES": "4x" + }, + "notes": [ + "CUDA_SCALE_LAUNCH_QUEUES=4x (2 GPU, parallélisme pipeline possible) — CUDA_LAUNCH_QUEUES=off pour s'en passer" + ] + }, + { + "m": 1, + "c": 99, + "h": 0, + "e": 0, + "args": [ + "/app/llama-server", + "-m", + "/m/moe.gguf", + "-c", + "32768", + "-b", + "2048", + "-ub", + "512", + "--host", + "0.0.0.0", + "--port", + "8080", + "--parallel", + "1", + "-ngl", + "auto", + "-c", + "0" + ], + "env": { + "CUDA_SCALE_LAUNCH_QUEUES": "4x" + }, + "notes": [ + "CUDA_SCALE_LAUNCH_QUEUES=4x (2 GPU, parallélisme pipeline possible) — CUDA_LAUNCH_QUEUES=off pour s'en passer" + ] + }, + { + "m": 1, + "c": 100, + "h": 0, + "e": 0, + "args": [ + "/app/llama-server", + "-m", + "/m/moe.gguf", + "-c", + "32768", + "-b", + "2048", + "-ub", + "512", + "--host", + "0.0.0.0", + "--port", + "8080", + "--parallel", + "1", + "-ngl", + "auto", + "--host", + "127.0.0.1", + "--port", + "9000" + ], + "env": { + "CUDA_SCALE_LAUNCH_QUEUES": "4x" + }, + "notes": [ + "CUDA_SCALE_LAUNCH_QUEUES=4x (2 GPU, parallélisme pipeline possible) — CUDA_LAUNCH_QUEUES=off pour s'en passer" + ] + }, + { + "m": 1, + "c": 101, + "h": 0, + "e": 0, + "args": [ + "/app/llama-server", + "-m", + "/m/moe.gguf", + "-c", + "32768", + "-b", + "2048", + "-ub", + "512", + "--host", + "0.0.0.0", + "--port", + "8080", + "--parallel", + "1", + "-ngl", + "auto", + "--mirostat", + "2" + ], + "env": { + "CUDA_SCALE_LAUNCH_QUEUES": "4x" + }, + "notes": [ + "CUDA_SCALE_LAUNCH_QUEUES=4x (2 GPU, parallélisme pipeline possible) — CUDA_LAUNCH_QUEUES=off pour s'en passer", + "SPEC=mtp : pas de tête MTP dans ce fichier (publiée à part ? MODEL_DRAFT=mtp-….gguf) — sans décodage spéculatif" + ] + }, + { + "m": 1, + "c": 102, + "h": 0, + "e": 0, + "args": [ + "/app/llama-server", + "-m", + "/m/moe.gguf", + "-c", + "32768", + "-b", + "2048", + "-ub", + "512", + "--host", + "0.0.0.0", + "--port", + "8080", + "--parallel", + "1", + "-ngl", + "auto", + "--chat-template-kwargs", + "{\"preserve_reasoning\":true}" + ], + "env": { + "CUDA_SCALE_LAUNCH_QUEUES": "4x" + }, + "notes": [ + "CUDA_SCALE_LAUNCH_QUEUES=4x (2 GPU, parallélisme pipeline possible) — CUDA_LAUNCH_QUEUES=off pour s'en passer", + "--chat-template-kwargs fixe déjà preserve_reasoning : REASONING_PRESERVE ignoré" + ] + }, + { + "m": 1, + "c": 103, + "h": 0, + "e": 0, + "args": [ + "/app/llama-server", + "-m", + "/m/moe.gguf", + "-c", + "32768", + "-b", + "2048", + "-ub", + "512", + "--host", + "0.0.0.0", + "--port", + "8080", + "--parallel", + "1", + "-ngl", + "auto", + "--spec-type", + "ngram-mod" + ], + "env": { + "CUDA_SCALE_LAUNCH_QUEUES": "4x" + }, + "notes": [ + "CUDA_SCALE_LAUNCH_QUEUES=4x (2 GPU, parallélisme pipeline possible) — CUDA_LAUNCH_QUEUES=off pour s'en passer", + "décodage spéculatif déjà réglé dans EXTRA_ARGS ou LLAMA_ARG_* : SPEC ignoré" + ] + }, + { + "m": 1, + "c": 104, + "h": 0, + "e": 0, + "args": [ + "/app/llama-server", + "-m", + "/m/moe.gguf", + "-c", + "32768", + "-b", + "2048", + "-ub", + "512", + "--host", + "0.0.0.0", + "--port", + "8080", + "--parallel", + "1", + "-ngl", + "auto", + "--spec-draft-n-max", + "8" + ], + "env": { + "CUDA_SCALE_LAUNCH_QUEUES": "4x" + }, + "notes": [ + "CUDA_SCALE_LAUNCH_QUEUES=4x (2 GPU, parallélisme pipeline possible) — CUDA_LAUNCH_QUEUES=off pour s'en passer", + "SPEC=ngram : --spec-draft-n-max dans EXTRA_ARGS (brouillon réglé à la main) — sans décodage spéculatif" + ] + }, + { + "m": 1, + "c": 105, + "h": 0, + "e": 0, + "args": [ + "/app/llama-server", + "-m", + "/m/moe.gguf", + "-c", + "32768", + "-b", + "2048", + "-ub", + "512", + "--host", + "0.0.0.0", + "--port", + "8080", + "--parallel", + "1", + "-ngl", + "auto", + "-ot", + "attn=CUDA1" + ], + "env": {}, + "notes": null + }, + { + "m": 1, + "c": 106, + "h": 0, + "e": 0, + "args": [ + "/app/llama-server", + "-m", + "/m/moe.gguf", + "-c", + "32768", + "-b", + "2048", + "-ub", + "512", + "--host", + "0.0.0.0", + "--port", + "8080", + "--parallel", + "1", + "-ngl", + "auto", + "-fa", + "off" + ], + "env": { + "CUDA_SCALE_LAUNCH_QUEUES": "4x" + }, + "notes": [ + "SPLIT_MODE=tensor refusé (Flash Attention coupée (-fa off) : le mode tensor l'exige) : découpe par couches, comme sans la clé", + "CUDA_SCALE_LAUNCH_QUEUES=4x (2 GPU, parallélisme pipeline possible) — CUDA_LAUNCH_QUEUES=off pour s'en passer" + ] + }, + { + "m": 1, + "c": 107, + "h": 0, + "e": 0, + "args": [ + "/app/llama-server", + "-m", + "/m/moe.gguf", + "-c", + "32768", + "-b", + "2048", + "-ub", + "512", + "--host", + "0.0.0.0", + "--port", + "8080", + "--parallel", + "1", + "-ngl", + "auto", + "-nkvo" + ], + "env": {}, + "notes": null + }, + { + "m": 1, + "c": 108, + "h": 0, + "e": 0, + "args": [ + "/app/llama-server", + "-m", + "/m/moe.gguf", + "-c", + "32768", + "-b", + "2048", + "-ub", + "512", + "--host", + "0.0.0.0", + "--port", + "8080", + "--parallel", + "1", + "-ngl", + "auto", + "--no-context-shift" + ], + "env": { + "CUDA_SCALE_LAUNCH_QUEUES": "4x" + }, + "notes": [ + "CUDA_SCALE_LAUNCH_QUEUES=4x (2 GPU, parallélisme pipeline possible) — CUDA_LAUNCH_QUEUES=off pour s'en passer" + ] + }, + { + "m": 1, + "c": 109, + "h": 0, + "e": 0, + "args": [ + "/app/llama-server", + "-m", + "/m/moe.gguf", + "-c", + "32768", + "-b", + "2048", + "-ub", + "512", + "--host", + "0.0.0.0", + "--port", + "8080", + "-ngl", + "auto", + "-c", + "8192", + "-np", + "2" + ], + "env": { + "CUDA_SCALE_LAUNCH_QUEUES": "4x" + }, + "notes": [ + "CUDA_SCALE_LAUNCH_QUEUES=4x (2 GPU, parallélisme pipeline possible) — CUDA_LAUNCH_QUEUES=off pour s'en passer", + "SIDE_SLOT refusé (-c est réglé dans EXTRA_ARGS) : un seul slot, comme sans la clé" + ] + }, + { + "m": 1, + "c": 110, + "h": 0, + "e": 0, + "args": [ + "/app/llama-server", + "-m", + "/m/moe.gguf", + "-c", + "32768", + "-b", + "2048", + "-ub", + "512", + "--host", + "0.0.0.0", + "--port", + "8080", + "--parallel", + "1", + "-ngl", + "auto" + ], + "env": { + "CUDA_SCALE_LAUNCH_QUEUES": "4x" + }, + "notes": [ + "SPLIT_MODE=tensor refusé (preset externe) : découpe par couches, comme sans la clé", + "CUDA_SCALE_LAUNCH_QUEUES=4x (2 GPU, parallélisme pipeline possible) — CUDA_LAUNCH_QUEUES=off pour s'en passer", + "SIDE_SLOT refusé (preset externe) : un seul slot, comme sans la clé" + ] + }, + { + "m": 1, + "c": 111, + "h": 0, + "e": 0, + "args": [ + "/app/llama-server", + "-m", + "/m/moe.gguf", + "-c", + "40000", + "-b", + "2048", + "-ub", + "512", + "--host", + "0.0.0.0", + "--port", + "8080", + "--parallel", + "1", + "-ngl", + "auto", + "-ctk", + "q4_0", + "-ctv", + "q4_0" + ], + "env": { + "CUDA_SCALE_LAUNCH_QUEUES": "4x" + }, + "notes": [ + "CUDA_SCALE_LAUNCH_QUEUES=4x (2 GPU, parallélisme pipeline possible) — CUDA_LAUNCH_QUEUES=off pour s'en passer", + "NGL=999 (sentinelle « toutes les couches ») → -ngl auto : ce moteur mesure lui-même la VRAM libre. NGL=all pour forcer l'ancien comportement.", + "cache KV q4_0/q4_0 (KV_TYPE) : perte mesurable par rapport à f16 — choix de l'utilisateur, laissé tel quel." + ] + }, + { + "m": 2, + "c": 0, + "h": 0, + "e": 0, + "args": [ + "/app/llama-server", + "-m", + "/m/inconnu.gguf", + "-c", + "32768", + "-b", + "2048", + "-ub", + "512", + "--host", + "0.0.0.0", + "--port", + "8080", + "--parallel", + "1", + "-ngl", + "auto" + ], + "env": {}, + "notes": null + }, + { + "m": 2, + "c": 1, + "h": 0, + "e": 0, + "args": [ + "/app/llama-server", + "-m", + "/m/inconnu.gguf", + "-c", + "65536", + "-b", + "4096", + "-ub", + "2048", + "--host", + "127.0.0.1", + "--port", + "8081", + "--parallel", + "1", + "-ngl", + "auto" + ], + "env": {}, + "notes": [ + "NGL=999 (sentinelle « toutes les couches ») → -ngl auto : ce moteur mesure lui-même la VRAM libre. NGL=all pour forcer l'ancien comportement." + ] + }, + { + "m": 2, + "c": 2, + "h": 0, + "e": 0, + "args": [ + "/app/llama-server", + "-m", + "/m/inconnu.gguf", + "-c", + "32768", + "-b", + "2048", + "-ub", + "512", + "--host", + "0.0.0.0", + "--port", + "8080", + "--parallel", + "1" + ], + "env": {}, + "notes": null + }, + { + "m": 2, + "c": 3, + "h": 0, + "e": 0, + "args": [ + "/app/llama-server", + "-m", + "/m/inconnu.gguf", + "-c", + "32768", + "-b", + "2048", + "-ub", + "512", + "--host", + "0.0.0.0", + "--port", + "8080", + "--parallel", + "1", + "-ngl", + "40" + ], + "env": {}, + "notes": null + }, + { + "m": 2, + "c": 4, + "h": 0, + "e": 0, + "args": [ + "/app/llama-server", + "-m", + "/m/inconnu.gguf", + "-c", + "32768", + "-b", + "2048", + "-ub", + "512", + "--host", + "0.0.0.0", + "--port", + "8080", + "--parallel", + "1", + "-ngl", + "all" + ], + "env": {}, + "notes": null + }, + { + "m": 2, + "c": 5, + "h": 0, + "e": 0, + "args": [ + "/app/llama-server", + "-m", + "/m/inconnu.gguf", + "-c", + "32768", + "-b", + "2048", + "-ub", + "512", + "--host", + "0.0.0.0", + "--port", + "8080", + "--parallel", + "1", + "-ngl", + "auto" + ], + "env": {}, + "notes": [ + "NGL=999 (sentinelle « toutes les couches ») → -ngl auto : ce moteur mesure lui-même la VRAM libre. NGL=all pour forcer l'ancien comportement." + ] + }, + { + "m": 2, + "c": 6, + "h": 0, + "e": 0, + "args": [ + "/app/llama-server", + "-m", + "/m/inconnu.gguf", + "-c", + "32768", + "-t", + "8", + "-tb", + "4", + "-b", + "2048", + "-ub", + "512", + "--host", + "0.0.0.0", + "--port", + "8080", + "--parallel", + "1", + "-ngl", + "auto" + ], + "env": {}, + "notes": null + }, + { + "m": 2, + "c": 7, + "h": 0, + "e": 0, + "args": [ + "/app/llama-server", + "-m", + "/m/inconnu.gguf", + "-c", + "32768", + "-b", + "2048", + "-ub", + "512", + "--host", + "0.0.0.0", + "--port", + "8080", + "--parallel", + "1", + "-ngl", + "auto" + ], + "env": {}, + "notes": [ + "THREADS=abc ignoré (pas un nombre de threads ≥ 0) → automatique", + "THREADS_BATCH=-2 ignoré (pas un nombre de threads ≥ 0) → automatique" + ] + }, + { + "m": 2, + "c": 8, + "h": 0, + "e": 0, + "args": [ + "/app/llama-server", + "-m", + "/m/inconnu.gguf", + "-c", + "32768", + "-b", + "2048", + "-ub", + "512", + "--host", + "0.0.0.0", + "--port", + "8080", + "--parallel", + "1", + "-ngl", + "auto" + ], + "env": {}, + "notes": null + }, + { + "m": 2, + "c": 9, + "h": 0, + "e": 0, + "args": [ + "/app/llama-server", + "-m", + "/m/inconnu.gguf", + "-c", + "32768", + "-b", + "2048", + "-ub", + "512", + "--host", + "0.0.0.0", + "--port", + "8080", + "--parallel", + "1", + "-ngl", + "auto", + "-ctk", + "q8_0", + "-ctv", + "q8_0" + ], + "env": {}, + "notes": [ + "cache KV q8_0/q8_0 (KV_TYPE) : modifie légèrement les sorties par rapport à f16 — choix de l'utilisateur, laissé tel quel." + ] + }, + { + "m": 2, + "c": 10, + "h": 0, + "e": 0, + "args": [ + "/app/llama-server", + "-m", + "/m/inconnu.gguf", + "-c", + "32768", + "-b", + "2048", + "-ub", + "512", + "--host", + "0.0.0.0", + "--port", + "8080", + "--parallel", + "1", + "-ngl", + "auto", + "-ctk", + "q4_0", + "-ctv", + "f16" + ], + "env": {}, + "notes": [ + "cache KV q4_0/f16 (KV_TYPE) : perte mesurable par rapport à f16 — choix de l'utilisateur, laissé tel quel." + ] + }, + { + "m": 2, + "c": 11, + "h": 0, + "e": 0, + "args": [ + "/app/llama-server", + "-m", + "/m/inconnu.gguf", + "-c", + "32768", + "-b", + "2048", + "-ub", + "512", + "--host", + "0.0.0.0", + "--port", + "8080", + "--parallel", + "1", + "-ngl", + "auto", + "-ctk", + "bf16", + "-ctv", + "bf16" + ], + "env": {}, + "notes": [ + "cache KV bf16/bf16 (KV_TYPE) : numérique différente de f16 (mantisse plus courte) — choix de l'utilisateur, laissé tel quel." + ] + }, + { + "m": 2, + "c": 12, + "h": 0, + "e": 0, + "args": [ + "/app/llama-server", + "-m", + "/m/inconnu.gguf", + "-c", + "32768", + "-b", + "2048", + "-ub", + "512", + "--host", + "0.0.0.0", + "--port", + "8080", + "--parallel", + "1", + "-ngl", + "auto", + "-ctk", + "q5_1", + "-ctv", + "q5_1" + ], + "env": {}, + "notes": [ + "cache KV q5_1/q5_1 (KV_TYPE) : modifie légèrement les sorties par rapport à f16 — choix de l'utilisateur, laissé tel quel." + ] + }, + { + "m": 2, + "c": 13, + "h": 0, + "e": 0, + "args": [ + "/app/llama-server", + "-m", + "/m/inconnu.gguf", + "-c", + "32768", + "-b", + "2048", + "-ub", + "512", + "--host", + "0.0.0.0", + "--port", + "8080", + "--parallel", + "1", + "-ngl", + "auto", + "--reasoning", + "on", + "--reasoning-budget", + "-1" + ], + "env": {}, + "notes": null + }, + { + "m": 2, + "c": 14, + "h": 0, + "e": 0, + "args": [ + "/app/llama-server", + "-m", + "/m/inconnu.gguf", + "-c", + "32768", + "-b", + "2048", + "-ub", + "512", + "--host", + "0.0.0.0", + "--port", + "8080", + "--parallel", + "1", + "-ngl", + "auto", + "--reasoning", + "off" + ], + "env": {}, + "notes": null + }, + { + "m": 2, + "c": 15, + "h": 0, + "e": 0, + "args": [ + "/app/llama-server", + "-m", + "/m/inconnu.gguf", + "-c", + "32768", + "-b", + "2048", + "-ub", + "512", + "--host", + "0.0.0.0", + "--port", + "8080", + "--parallel", + "1", + "-ngl", + "auto", + "--reasoning", + "auto", + "--reasoning-budget", + "2048" + ], + "env": {}, + "notes": null + }, + { + "m": 2, + "c": 16, + "h": 0, + "e": 0, + "args": [ + "/app/llama-server", + "-m", + "/m/inconnu.gguf", + "-c", + "32768", + "-b", + "2048", + "-ub", + "512", + "--host", + "0.0.0.0", + "--port", + "8080", + "--parallel", + "1", + "-ngl", + "auto", + "--reasoning", + "deepseek", + "--reasoning-budget", + "-1" + ], + "env": {}, + "notes": null + }, + { + "m": 2, + "c": 17, + "h": 0, + "e": 0, + "args": [ + "/app/llama-server", + "-m", + "/m/inconnu.gguf", + "-c", + "32768", + "-b", + "2048", + "-ub", + "512", + "--host", + "0.0.0.0", + "--port", + "8080", + "--parallel", + "1", + "-ngl", + "auto", + "--reasoning-preserve" + ], + "env": {}, + "notes": null + }, + { + "m": 2, + "c": 18, + "h": 0, + "e": 0, + "args": [ + "/app/llama-server", + "-m", + "/m/inconnu.gguf", + "-c", + "32768", + "-b", + "2048", + "-ub", + "512", + "--host", + "0.0.0.0", + "--port", + "8080", + "--parallel", + "1", + "-ngl", + "auto", + "--no-reasoning-preserve" + ], + "env": {}, + "notes": null + }, + { + "m": 2, + "c": 19, + "h": 0, + "e": 0, + "args": [ + "/app/llama-server", + "-m", + "/m/inconnu.gguf", + "-c", + "32768", + "-b", + "2048", + "-ub", + "512", + "--host", + "0.0.0.0", + "--port", + "8080", + "--parallel", + "1", + "-ngl", + "auto" + ], + "env": {}, + "notes": [ + "REASONING_PRESERVE=peut-être illisible (on ou off) : ignoré" + ] + }, + { + "m": 2, + "c": 20, + "h": 0, + "e": 0, + "args": [ + "/app/llama-server", + "-m", + "/m/inconnu.gguf", + "-c", + "32768", + "-b", + "2048", + "-ub", + "512", + "--host", + "0.0.0.0", + "--port", + "8080", + "--parallel", + "1", + "-ngl", + "auto" + ], + "env": {}, + "notes": null + }, + { + "m": 2, + "c": 21, + "h": 0, + "e": 0, + "args": [ + "/app/llama-server", + "-m", + "/m/inconnu.gguf", + "-c", + "32768", + "-b", + "2048", + "-ub", + "512", + "--host", + "0.0.0.0", + "--port", + "8080", + "--parallel", + "1", + "-ngl", + "auto", + "--cache-ram", + "4096" + ], + "env": {}, + "notes": null + }, + { + "m": 2, + "c": 22, + "h": 0, + "e": 0, + "args": [ + "/app/llama-server", + "-m", + "/m/inconnu.gguf", + "-c", + "32768", + "-b", + "2048", + "-ub", + "512", + "--host", + "0.0.0.0", + "--port", + "8080", + "--parallel", + "1", + "-ngl", + "auto", + "--cache-ram", + "0" + ], + "env": {}, + "notes": null + }, + { + "m": 2, + "c": 23, + "h": 0, + "e": 0, + "args": [ + "/app/llama-server", + "-m", + "/m/inconnu.gguf", + "-c", + "32768", + "-b", + "2048", + "-ub", + "512", + "--host", + "0.0.0.0", + "--port", + "8080", + "--parallel", + "1", + "-ngl", + "auto", + "--cache-ram", + "-1" + ], + "env": {}, + "notes": null + }, + { + "m": 2, + "c": 24, + "h": 0, + "e": 0, + "args": [ + "/app/llama-server", + "-m", + "/m/inconnu.gguf", + "-c", + "32768", + "-b", + "2048", + "-ub", + "512", + "--host", + "0.0.0.0", + "--port", + "8080", + "--parallel", + "1", + "-ngl", + "auto" + ], + "env": {}, + "notes": [ + "CACHE_RAM=beaucoup illisible (un nombre de Mio, -1, 0 ou auto) : ignoré" + ] + }, + { + "m": 2, + "c": 25, + "h": 0, + "e": 0, + "args": [ + "/app/llama-server", + "-m", + "/m/inconnu.gguf", + "-c", + "32768", + "-b", + "2048", + "-ub", + "512", + "--host", + "0.0.0.0", + "--port", + "8080", + "--parallel", + "1", + "-ngl", + "auto" + ], + "env": {}, + "notes": null + }, + { + "m": 2, + "c": 26, + "h": 0, + "e": 0, + "args": [ + "/app/llama-server", + "-m", + "/m/inconnu.gguf", + "-c", + "32768", + "-b", + "2048", + "-ub", + "512", + "--host", + "0.0.0.0", + "--port", + "8080", + "--parallel", + "1", + "-ngl", + "auto", + "--cache-ram", + "0" + ], + "env": {}, + "notes": null + }, + { + "m": 2, + "c": 27, + "h": 0, + "e": 0, + "args": [ + "/app/llama-server", + "-m", + "/m/inconnu.gguf", + "-c", + "32768", + "-b", + "2048", + "-ub", + "512", + "--host", + "0.0.0.0", + "--port", + "8080", + "--parallel", + "1", + "-ngl", + "auto" + ], + "env": {}, + "notes": [ + "SIDE_SLOT refusé (VRAM NVIDIA inconnue (macOS, AMD, --device…)) : un seul slot, comme sans la clé" + ] + }, + { + "m": 2, + "c": 28, + "h": 0, + "e": 0, + "args": [ + "/app/llama-server", + "-m", + "/m/inconnu.gguf", + "-c", + "32768", + "-b", + "2048", + "-ub", + "512", + "--host", + "0.0.0.0", + "--port", + "8080", + "--parallel", + "3", + "-ngl", + "auto" + ], + "env": {}, + "notes": [ + "SIDE_SLOT refusé (PARALLEL=3 (SIDE_SLOT ouvre exactement 2 slots)) : un seul slot, comme sans la clé" + ] + }, + { + "m": 2, + "c": 29, + "h": 0, + "e": 0, + "args": [ + "/app/llama-server", + "-m", + "/m/inconnu.gguf", + "-c", + "0", + "-b", + "2048", + "-ub", + "512", + "--host", + "0.0.0.0", + "--port", + "8080", + "--parallel", + "1", + "-ngl", + "auto" + ], + "env": {}, + "notes": [ + "SIDE_SLOT refusé (CTX doit être un nombre de jetons explicite) : un seul slot, comme sans la clé" + ] + }, + { + "m": 2, + "c": 30, + "h": 0, + "e": 0, + "args": [ + "/app/llama-server", + "-m", + "/m/inconnu.gguf", + "-c", + "8192", + "-b", + "2048", + "-ub", + "512", + "--host", + "0.0.0.0", + "--port", + "8080", + "--parallel", + "1", + "-ngl", + "auto", + "-ctk", + "q8_0", + "-ctv", + "q8_0" + ], + "env": {}, + "notes": [ + "SIDE_SLOT refusé (VRAM NVIDIA inconnue (macOS, AMD, --device…)) : un seul slot, comme sans la clé", + "cache KV q8_0/q8_0 (KV_TYPE) : modifie légèrement les sorties par rapport à f16 — choix de l'utilisateur, laissé tel quel." + ] + }, + { + "m": 2, + "c": 31, + "h": 0, + "e": 0, + "args": [ + "/app/llama-server", + "-m", + "/m/inconnu.gguf", + "-c", + "32768", + "-b", + "2048", + "-ub", + "512", + "--host", + "0.0.0.0", + "--port", + "8080", + "--parallel", + "1", + "-ngl", + "auto" + ], + "env": {}, + "notes": [ + "SPLIT_MODE=tensor refusé (métadonnées ou taille du modèle illisibles) : découpe par couches, comme sans la clé" + ] + }, + { + "m": 2, + "c": 32, + "h": 0, + "e": 0, + "args": [ + "/app/llama-server", + "-m", + "/m/inconnu.gguf", + "-c", + "16384", + "-b", + "2048", + "-ub", + "512", + "--host", + "0.0.0.0", + "--port", + "8080", + "--parallel", + "1", + "-ngl", + "auto" + ], + "env": {}, + "notes": [ + "SPLIT_MODE=tensor refusé (métadonnées ou taille du modèle illisibles) : découpe par couches, comme sans la clé" + ] + }, + { + "m": 2, + "c": 33, + "h": 0, + "e": 0, + "args": [ + "/app/llama-server", + "-m", + "/m/inconnu.gguf", + "-c", + "32768", + "-b", + "2048", + "-ub", + "512", + "--host", + "0.0.0.0", + "--port", + "8080", + "--parallel", + "1", + "-ngl", + "auto" + ], + "env": {}, + "notes": [ + "SPLIT_MODE=tensor refusé (SPLIT_MODE=bizarre illisible (tensor ou off)) : découpe par couches, comme sans la clé" + ] + }, + { + "m": 2, + "c": 34, + "h": 0, + "e": 0, + "args": [ + "/app/llama-server", + "-m", + "/m/inconnu.gguf", + "-c", + "32768", + "-b", + "2048", + "-ub", + "512", + "--host", + "0.0.0.0", + "--port", + "8080", + "--parallel", + "1", + "-ngl", + "auto" + ], + "env": {}, + "notes": null + }, + { + "m": 2, + "c": 35, + "h": 0, + "e": 0, + "args": [ + "/app/llama-server", + "-m", + "/m/inconnu.gguf", + "-c", + "32768", + "-b", + "2048", + "-ub", + "512", + "--host", + "0.0.0.0", + "--port", + "8080", + "--parallel", + "1", + "-ngl", + "auto", + "-ctk", + "q8_0", + "-ctv", + "q8_0" + ], + "env": {}, + "notes": [ + "SPLIT_MODE=tensor refusé (cache KV q8_0/q8_0 : le mode tensor n'accepte que f16, bf16 ou f32 (jamais réécrit d'office : la VRAM changerait)) : découpe par couches, comme sans la clé", + "cache KV q8_0/q8_0 (KV_TYPE) : modifie légèrement les sorties par rapport à f16 — choix de l'utilisateur, laissé tel quel." + ] + }, + { + "m": 2, + "c": 36, + "h": 0, + "e": 0, + "args": [ + "/app/llama-server", + "-m", + "/m/inconnu.gguf", + "-c", + "32768", + "-b", + "2048", + "-ub", + "512", + "--host", + "0.0.0.0", + "--port", + "8080", + "--parallel", + "1", + "-ngl", + "20" + ], + "env": {}, + "notes": [ + "SPLIT_MODE=tensor refusé (métadonnées ou taille du modèle illisibles) : découpe par couches, comme sans la clé" + ] + }, + { + "m": 2, + "c": 37, + "h": 0, + "e": 0, + "args": [ + "/app/llama-server", + "-m", + "/m/inconnu.gguf", + "-c", + "32768", + "-b", + "2048", + "-ub", + "512", + "--host", + "0.0.0.0", + "--port", + "8080", + "--parallel", + "1", + "-ngl", + "auto" + ], + "env": {}, + "notes": [ + "SPEC=auto : métadonnées du modèle illisibles — sans décodage spéculatif" + ] + }, + { + "m": 2, + "c": 38, + "h": 0, + "e": 0, + "args": [ + "/app/llama-server", + "-m", + "/m/inconnu.gguf", + "-c", + "32768", + "-b", + "2048", + "-ub", + "512", + "--host", + "0.0.0.0", + "--port", + "8080", + "--parallel", + "1", + "-ngl", + "auto" + ], + "env": {}, + "notes": [ + "SPEC=mtp : métadonnées du modèle illisibles — sans décodage spéculatif" + ] + }, + { + "m": 2, + "c": 39, + "h": 0, + "e": 0, + "args": [ + "/app/llama-server", + "-m", + "/m/inconnu.gguf", + "-c", + "32768", + "-b", + "2048", + "-ub", + "512", + "--host", + "0.0.0.0", + "--port", + "8080", + "--parallel", + "1", + "-ngl", + "auto", + "--spec-type", + "ngram-mod", + "--spec-ngram-mod-n-match", + "24", + "--spec-ngram-mod-n-min", + "48", + "--spec-ngram-mod-n-max", + "64" + ], + "env": {}, + "notes": [ + "SPEC=ngram → n-grammes du contexte (ngram-mod, brouillons de 48 à 64 jetons) : distribution inchangée (chaque jeton vérifié), ~16 Mo de RAM ; utile surtout en mode code, à mesurer (acceptation par nature dans /api/perf/summary)" + ] + }, + { + "m": 2, + "c": 40, + "h": 0, + "e": 0, + "args": [ + "/app/llama-server", + "-m", + "/m/inconnu.gguf", + "-c", + "32768", + "-b", + "2048", + "-ub", + "512", + "--host", + "0.0.0.0", + "--port", + "8080", + "--parallel", + "1", + "-ngl", + "auto", + "--spec-type", + "ngram-mod", + "--spec-ngram-mod-n-match", + "24", + "--spec-ngram-mod-n-min", + "48", + "--spec-ngram-mod-n-max", + "64" + ], + "env": {}, + "notes": [ + "SPEC=mtp+ngram : sans MTP (métadonnées du modèle illisibles) → n-grammes seuls. SPEC=ngram → n-grammes du contexte (ngram-mod, brouillons de 48 à 64 jetons) : distribution inchangée (chaque jeton vérifié), ~16 Mo de RAM ; utile surtout en mode code, à mesurer (acceptation par nature dans /api/perf/summary)" + ] + }, + { + "m": 2, + "c": 41, + "h": 0, + "e": 0, + "args": [ + "/app/llama-server", + "-m", + "/m/inconnu.gguf", + "-c", + "32768", + "-b", + "2048", + "-ub", + "512", + "--host", + "0.0.0.0", + "--port", + "8080", + "--parallel", + "1", + "-ngl", + "auto", + "--spec-type", + "ngram-mod", + "--spec-ngram-mod-n-match", + "24", + "--spec-ngram-mod-n-min", + "48", + "--spec-ngram-mod-n-max", + "64" + ], + "env": {}, + "notes": [ + "SPEC=mtp+ngram : sans MTP (métadonnées du modèle illisibles) → n-grammes seuls. SPEC=ngram → n-grammes du contexte (ngram-mod, brouillons de 48 à 64 jetons) : distribution inchangée (chaque jeton vérifié), ~16 Mo de RAM ; utile surtout en mode code, à mesurer (acceptation par nature dans /api/perf/summary)" + ] + }, + { + "m": 2, + "c": 42, + "h": 0, + "e": 0, + "args": [ + "/app/llama-server", + "-m", + "/m/inconnu.gguf", + "-c", + "32768", + "-b", + "2048", + "-ub", + "512", + "--host", + "0.0.0.0", + "--port", + "8080", + "--parallel", + "1", + "-ngl", + "auto" + ], + "env": {}, + "notes": [ + "SPEC=turbo illisible (off, auto, mtp, ngram ou mtp+ngram) : sans décodage spéculatif" + ] + }, + { + "m": 2, + "c": 43, + "h": 0, + "e": 0, + "args": [ + "/app/llama-server", + "-m", + "/m/inconnu.gguf", + "-c", + "32768", + "-b", + "2048", + "-ub", + "512", + "--host", + "0.0.0.0", + "--port", + "8080", + "--parallel", + "1", + "-ngl", + "auto" + ], + "env": {}, + "notes": [ + "MODEL_DRAFT ignoré : SPEC=off (auto ou mtp pour s'en servir)" + ] + }, + { + "m": 2, + "c": 44, + "h": 0, + "e": 0, + "args": [ + "/app/llama-server", + "-m", + "/m/inconnu.gguf", + "-c", + "32768", + "-b", + "2048", + "-ub", + "512", + "--host", + "0.0.0.0", + "--port", + "8080", + "--parallel", + "1", + "-ngl", + "auto" + ], + "env": {}, + "notes": [ + "SPEC=mtp : MODEL_DRAFT=mtp-head.gguf introuvable — sans décodage spéculatif" + ] + }, + { + "m": 2, + "c": 45, + "h": 0, + "e": 0, + "args": [ + "/app/llama-server", + "-m", + "/m/inconnu.gguf", + "-c", + "32768", + "-b", + "2048", + "-ub", + "512", + "--host", + "0.0.0.0", + "--port", + "8080", + "--parallel", + "1", + "-ngl", + "auto" + ], + "env": {}, + "notes": [ + "SPEC=mtp : métadonnées du modèle illisibles — sans décodage spéculatif" + ] + }, + { + "m": 2, + "c": 46, + "h": 0, + "e": 0, + "args": [ + "/app/llama-server", + "-m", + "/m/inconnu.gguf", + "-c", + "32768", + "-b", + "2048", + "-ub", + "512", + "--host", + "0.0.0.0", + "--port", + "8080", + "--parallel", + "1", + "-ngl", + "auto" + ], + "env": {}, + "notes": [ + "SPEC=auto : métadonnées du modèle illisibles — sans décodage spéculatif" + ] + }, + { + "m": 2, + "c": 47, + "h": 0, + "e": 0, + "args": [ + "/app/llama-server", + "-m", + "/m/inconnu.gguf", + "-c", + "32768", + "-b", + "2048", + "-ub", + "512", + "--host", + "0.0.0.0", + "--port", + "8080", + "--parallel", + "1", + "-ngl", + "auto" + ], + "env": {}, + "notes": [ + "SPEC=mtp : métadonnées du modèle illisibles — sans décodage spéculatif" + ] + }, + { + "m": 2, + "c": 48, + "h": 0, + "e": 0, + "args": [ + "/app/llama-server", + "-m", + "/m/inconnu.gguf", + "-c", + "32768", + "-b", + "2048", + "-ub", + "512", + "--host", + "0.0.0.0", + "--port", + "8080", + "--parallel", + "1", + "-ngl", + "auto", + "--spec-type", + "ngram-mod", + "--spec-ngram-mod-n-match", + "24", + "--spec-ngram-mod-n-min", + "48", + "--spec-ngram-mod-n-max", + "64" + ], + "env": {}, + "notes": [ + "SPEC=ngram → n-grammes du contexte (ngram-mod, brouillons de 48 à 64 jetons) : distribution inchangée (chaque jeton vérifié), ~16 Mo de RAM ; utile surtout en mode code, à mesurer (acceptation par nature dans /api/perf/summary)", + "MODEL_DRAFT ignoré avec SPEC=ngram : SPEC=mtp+ngram pour les deux", + "SPEC_N_MAX et SPEC_SAMPLING ignorés : les n-grammes n'ont pas de brouillon à régler" + ] + }, + { + "m": 2, + "c": 49, + "h": 0, + "e": 0, + "args": [ + "/app/llama-server", + "-m", + "/m/inconnu.gguf", + "-c", + "32768", + "-b", + "2048", + "-ub", + "512", + "--host", + "0.0.0.0", + "--port", + "8080", + "--parallel", + "1", + "-ngl", + "auto" + ], + "env": {}, + "notes": [ + "SPEC=auto : MODEL_DRAFT=small.gguf introuvable — sans décodage spéculatif" + ] + }, + { + "m": 2, + "c": 50, + "h": 0, + "e": 0, + "args": [ + "/app/llama-server", + "-m", + "/m/inconnu.gguf", + "-c", + "32768", + "-b", + "2048", + "-ub", + "512", + "--host", + "0.0.0.0", + "--port", + "8080", + "--parallel", + "1", + "-ngl", + "auto", + "--spec-type", + "ngram-mod", + "--spec-ngram-mod-n-match", + "24", + "--spec-ngram-mod-n-min", + "48", + "--spec-ngram-mod-n-max", + "64" + ], + "env": {}, + "notes": [ + "SPEC=mtp+ngram : sans MTP (MODEL_DRAFT=small.gguf introuvable) → n-grammes seuls. SPEC=ngram → n-grammes du contexte (ngram-mod, brouillons de 48 à 64 jetons) : distribution inchangée (chaque jeton vérifié), ~16 Mo de RAM ; utile surtout en mode code, à mesurer (acceptation par nature dans /api/perf/summary)" + ] + }, + { + "m": 2, + "c": 51, + "h": 0, + "e": 0, + "args": [ + "/app/llama-server", + "-m", + "/m/inconnu.gguf", + "-c", + "32768", + "-b", + "2048", + "-ub", + "512", + "--host", + "0.0.0.0", + "--port", + "8080", + "--parallel", + "1", + "-ngl", + "auto" + ], + "env": {}, + "notes": null + }, + { + "m": 2, + "c": 52, + "h": 0, + "e": 0, + "args": [ + "/app/llama-server", + "-m", + "/m/inconnu.gguf", + "-c", + "32768", + "-b", + "2048", + "-ub", + "512", + "--host", + "0.0.0.0", + "--port", + "8080", + "--parallel", + "1", + "-ngl", + "auto" + ], + "env": { + "CUDA_SCALE_LAUNCH_QUEUES": "2x" + }, + "notes": [ + "CUDA_SCALE_LAUNCH_QUEUES=2x (CUDA_LAUNCH_QUEUES)" + ] + }, + { + "m": 2, + "c": 53, + "h": 0, + "e": 0, + "args": [ + "/app/llama-server", + "-m", + "/m/inconnu.gguf", + "-c", + "32768", + "-b", + "2048", + "-ub", + "512", + "--host", + "0.0.0.0", + "--port", + "8080", + "--parallel", + "1", + "-ngl", + "auto" + ], + "env": {}, + "notes": [ + "CUDA_LAUNCH_QUEUES=8x ignoré (0.25x, 0.5x, 2x, 4x ou off) → automatique" + ] + }, + { + "m": 2, + "c": 54, + "h": 0, + "e": 0, + "args": [ + "/app/llama-server", + "-m", + "/m/inconnu.gguf", + "-c", + "32768", + "-b", + "2048", + "-ub", + "512", + "--host", + "0.0.0.0", + "--port", + "8080", + "--parallel", + "1", + "-ngl", + "auto" + ], + "env": {}, + "notes": null + }, + { + "m": 2, + "c": 55, + "h": 0, + "e": 0, + "args": [ + "/app/llama-server", + "-m", + "/m/inconnu.gguf", + "-c", + "32768", + "-b", + "2048", + "-ub", + "512", + "--host", + "0.0.0.0", + "--port", + "8080", + "--parallel", + "1", + "-ngl", + "auto" + ], + "env": { + "CUDA_DEVICE_ORDER": "PCI_BUS_ID", + "CUDA_VISIBLE_DEVICES": "1,0" + }, + "notes": null + }, + { + "m": 2, + "c": 56, + "h": 0, + "e": 0, + "args": [ + "/app/llama-server", + "-m", + "/m/inconnu.gguf", + "-c", + "32768", + "-b", + "2048", + "-ub", + "512", + "--host", + "0.0.0.0", + "--port", + "8080", + "--parallel", + "1", + "-ngl", + "auto", + "--fit-target", + "1024,3072" + ], + "env": {}, + "notes": [ + "--fit-target 1024,3072 (FIT_TARGET, Mio libres par carte, dans l'ordre vu par le moteur) — vérifie « offloaded N/N layers » dans le journal" + ] + }, + { + "m": 2, + "c": 57, + "h": 0, + "e": 0, + "args": [ + "/app/llama-server", + "-m", + "/m/inconnu.gguf", + "-c", + "32768", + "-b", + "2048", + "-ub", + "512", + "--host", + "0.0.0.0", + "--port", + "8080", + "--parallel", + "1", + "-ngl", + "auto", + "--fit-target", + "1024" + ], + "env": {}, + "notes": [ + "--fit-target 1024 (FIT_TARGET, Mio libres par carte, dans l'ordre vu par le moteur) — vérifie « offloaded N/N layers » dans le journal ; marges sous 1024 Mio remontées à 1024" + ] + }, + { + "m": 2, + "c": 58, + "h": 0, + "e": 0, + "args": [ + "/app/llama-server", + "-m", + "/m/inconnu.gguf", + "-c", + "32768", + "-b", + "2048", + "-ub", + "512", + "--host", + "0.0.0.0", + "--port", + "8080", + "--parallel", + "1", + "-ngl", + "auto" + ], + "env": {}, + "notes": [ + "FIT_TARGET=1024,,3072 ignoré : des Mio par carte, ex. 1024,3072 (8 valeurs au plus)" + ] + }, + { + "m": 2, + "c": 59, + "h": 0, + "e": 0, + "args": [ + "/app/llama-server", + "-m", + "/m/inconnu.gguf", + "-c", + "32768", + "-b", + "2048", + "-ub", + "512", + "--host", + "0.0.0.0", + "--port", + "8080", + "--parallel", + "1", + "-ngl", + "auto" + ], + "env": {}, + "notes": [ + "FIT_TARGET=1,2,3,4,5,6,7,8,9 ignoré : des Mio par carte, ex. 1024,3072 (8 valeurs au plus)" + ] + }, + { + "m": 2, + "c": 60, + "h": 0, + "e": 0, + "args": [ + "/app/llama-server", + "-m", + "/m/inconnu.gguf", + "-c", + "0", + "-b", + "2048", + "-ub", + "512", + "--host", + "0.0.0.0", + "--port", + "8080", + "--parallel", + "1", + "-ngl", + "auto" + ], + "env": {}, + "notes": [ + "FIT_TARGET=2048/4096 ignoré : contexte « 0 » non fixé — --fit pourrait le réduire pour tenir ; donne un CTX chiffré" + ] + }, + { + "m": 2, + "c": 61, + "h": 0, + "e": 0, + "args": [ + "/app/llama-server", + "-m", + "/m/inconnu.gguf", + "-c", + "32768", + "-b", + "2048", + "-ub", + "512", + "--host", + "0.0.0.0", + "--port", + "8080", + "--parallel", + "1", + "-ngl", + "30" + ], + "env": {}, + "notes": [ + "FIT_TARGET=2048 ignoré : --fit inactif (couches GPU fixées à 30), la marge ne servirait à rien" + ] + }, + { + "m": 2, + "c": 62, + "h": 0, + "e": 0, + "args": [ + "/app/llama-server", + "-m", + "/m/inconnu.gguf", + "-c", + "32768", + "-b", + "2048", + "-ub", + "512", + "--host", + "0.0.0.0", + "--port", + "8080", + "--parallel", + "1", + "-ngl", + "auto" + ], + "env": {}, + "notes": null + }, + { + "m": 2, + "c": 63, + "h": 0, + "e": 0, + "args": [ + "/app/llama-server", + "-m", + "/m/inconnu.gguf", + "-c", + "32768", + "-b", + "2048", + "-ub", + "512", + "--host", + "0.0.0.0", + "--port", + "8080", + "--parallel", + "1", + "-ngl", + "auto" + ], + "env": {}, + "notes": [ + "OP_OFFLOAD_MIN_BATCH=0 ignoré : un nombre de jetons, ex. 128" + ] + }, + { + "m": 2, + "c": 64, + "h": 0, + "e": 0, + "args": [ + "/app/llama-server", + "-m", + "/m/inconnu.gguf", + "-c", + "32768", + "-b", + "2048", + "-ub", + "512", + "--host", + "0.0.0.0", + "--port", + "8080", + "--parallel", + "1", + "-ngl", + "auto" + ], + "env": { + "GGML_CUDA_GRAPH_OPT": "1" + }, + "notes": [ + "GGML_CUDA_GRAPH_OPT=1 (CUDA_GRAPH_OPT, expérimental) — si le moteur s'arrête sur un GGML_ASSERT, CUDA_GRAPH_OPT=off puis redémarre" + ] + }, + { + "m": 2, + "c": 65, + "h": 0, + "e": 0, + "args": [ + "/app/llama-server", + "-m", + "/m/inconnu.gguf", + "-c", + "32768", + "-b", + "2048", + "-ub", + "512", + "--host", + "0.0.0.0", + "--port", + "8080", + "--parallel", + "1", + "-ngl", + "auto" + ], + "env": {}, + "notes": [ + "CUDA_GRAPH_OPT=oui ignoré (on ou off)" + ] + }, + { + "m": 2, + "c": 66, + "h": 0, + "e": 0, + "args": [ + "/app/llama-server", + "-m", + "/m/inconnu.gguf", + "-c", + "32768", + "-b", + "2048", + "-ub", + "512", + "--host", + "0.0.0.0", + "--port", + "8080", + "--parallel", + "1", + "-ngl", + "auto" + ], + "env": {}, + "notes": null + }, + { + "m": 2, + "c": 67, + "h": 0, + "e": 0, + "args": [ + "/app/llama-server", + "-m", + "/m/inconnu.gguf", + "-c", + "32768", + "-b", + "2048", + "-ub", + "512", + "--host", + "0.0.0.0", + "--port", + "8080", + "--parallel", + "1", + "-ngl", + "auto", + "--ctx-checkpoints", + "8" + ], + "env": {}, + "notes": null + }, + { + "m": 2, + "c": 68, + "h": 0, + "e": 0, + "args": [ + "/app/llama-server", + "-m", + "/m/inconnu.gguf", + "-c", + "32768", + "-b", + "2048", + "-ub", + "512", + "--host", + "0.0.0.0", + "--port", + "8080", + "--parallel", + "1", + "-ngl", + "auto" + ], + "env": {}, + "notes": [ + "CTX_CHECKPOINTS=beaucoup illisible (un nombre de points de reprise par slot, ≥ 0) : ignoré" + ] + }, + { + "m": 2, + "c": 69, + "h": 0, + "e": 0, + "args": [ + "/app/llama-server", + "-m", + "/m/inconnu.gguf", + "-c", + "32768", + "-b", + "2048", + "-ub", + "512", + "--host", + "0.0.0.0", + "--port", + "8080", + "--parallel", + "1", + "-ngl", + "auto", + "--checkpoint-min-step", + "1024" + ], + "env": {}, + "notes": null + }, + { + "m": 2, + "c": 70, + "h": 0, + "e": 0, + "args": [ + "/app/llama-server", + "-m", + "/m/inconnu.gguf", + "-c", + "32768", + "-b", + "2048", + "-ub", + "512", + "--host", + "0.0.0.0", + "--port", + "8080", + "--parallel", + "1", + "-ngl", + "auto" + ], + "env": {}, + "notes": [ + "CKPT_MIN_STEP=0 refusé (un nombre de jetons > 0 ; 0 laisserait le moteur jeter le point de reprise du début de la conversation) : défaut du moteur" + ] + }, + { + "m": 2, + "c": 71, + "h": 0, + "e": 0, + "args": [ + "/app/llama-server", + "-m", + "/m/inconnu.gguf", + "-c", + "32768", + "-b", + "2048", + "-ub", + "512", + "--host", + "0.0.0.0", + "--port", + "8080", + "--parallel", + "1", + "-ngl", + "auto" + ], + "env": {}, + "notes": [ + "SLOT_PERSIST refusé (dossier des slots indisponible) : l'état du slot ne sera pas gardé à la bascule de preset" + ] + }, + { + "m": 2, + "c": 72, + "h": 0, + "e": 0, + "args": [ + "/app/llama-server", + "-m", + "/m/inconnu.gguf", + "-c", + "32768", + "-b", + "2048", + "-ub", + "512", + "--host", + "127.0.0.1", + "--port", + "8080", + "--parallel", + "1", + "-ngl", + "auto" + ], + "env": {}, + "notes": [ + "SLOT_PERSIST refusé (dossier des slots indisponible) : l'état du slot ne sera pas gardé à la bascule de preset" + ] + }, + { + "m": 2, + "c": 73, + "h": 0, + "e": 0, + "args": [ + "/app/llama-server", + "-m", + "/m/inconnu.gguf", + "-c", + "32768", + "-b", + "2048", + "-ub", + "512", + "--host", + "127.0.0.1", + "--port", + "8080", + "--parallel", + "1", + "-ngl", + "auto" + ], + "env": {}, + "notes": [ + "SLOT_PERSIST refusé (dossier des slots indisponible) : l'état du slot ne sera pas gardé à la bascule de preset", + "SPEC=mtp : métadonnées du modèle illisibles — sans décodage spéculatif" + ] + }, + { + "m": 2, + "c": 74, + "h": 0, + "e": 0, + "args": [ + "/app/llama-server", + "-m", + "/m/inconnu.gguf", + "-c", + "32768", + "-b", + "2048", + "-ub", + "512", + "--host", + "0.0.0.0", + "--port", + "8080", + "--parallel", + "1", + "-ngl", + "auto", + "--load-mode", + "none" + ], + "env": {}, + "notes": null + }, + { + "m": 2, + "c": 75, + "h": 0, + "e": 0, + "args": [ + "/app/llama-server", + "-m", + "/m/inconnu.gguf", + "-c", + "32768", + "-b", + "2048", + "-ub", + "512", + "--host", + "0.0.0.0", + "--port", + "8080", + "--parallel", + "1", + "-ngl", + "auto", + "--load-mode", + "mlock" + ], + "env": {}, + "notes": null + }, + { + "m": 2, + "c": 76, + "h": 0, + "e": 0, + "args": [ + "/app/llama-server", + "-m", + "/m/inconnu.gguf", + "-c", + "32768", + "-b", + "2048", + "-ub", + "512", + "--host", + "0.0.0.0", + "--port", + "8080", + "--parallel", + "1", + "-ngl", + "auto", + "--load-mode", + "mmap+mlock" + ], + "env": {}, + "notes": null + }, + { + "m": 2, + "c": 77, + "h": 0, + "e": 0, + "args": [ + "/app/llama-server", + "-m", + "/m/inconnu.gguf", + "-c", + "32768", + "-b", + "2048", + "-ub", + "512", + "--host", + "0.0.0.0", + "--port", + "8080", + "--parallel", + "1", + "-ngl", + "auto", + "--load-mode", + "none" + ], + "env": {}, + "notes": null + }, + { + "m": 2, + "c": 78, + "h": 0, + "e": 0, + "args": [ + "/app/llama-server", + "-m", + "/m/inconnu.gguf", + "-c", + "32768", + "-b", + "2048", + "-ub", + "512", + "--host", + "0.0.0.0", + "--port", + "8080", + "--parallel", + "1", + "-ngl", + "auto", + "--load-mode", + "mmap" + ], + "env": {}, + "notes": null + }, + { + "m": 2, + "c": 79, + "h": 0, + "e": 0, + "args": [ + "/app/llama-server", + "-m", + "/m/inconnu.gguf", + "-c", + "32768", + "-b", + "2048", + "-ub", + "512", + "--host", + "0.0.0.0", + "--port", + "8080", + "--parallel", + "1", + "-ngl", + "auto", + "--load-mode", + "dio" + ], + "env": {}, + "notes": null + }, + { + "m": 2, + "c": 80, + "h": 0, + "e": 0, + "args": [ + "/app/llama-server", + "-m", + "/m/inconnu.gguf", + "-c", + "32768", + "-b", + "2048", + "-ub", + "512", + "--host", + "0.0.0.0", + "--port", + "8080", + "--parallel", + "1", + "-ngl", + "auto", + "--load-mode=mlock" + ], + "env": {}, + "notes": null + }, + { + "m": 2, + "c": 81, + "h": 0, + "e": 0, + "args": [ + "/app/llama-server", + "-m", + "/m/inconnu.gguf", + "-c", + "32768", + "-b", + "2048", + "-ub", + "512", + "--host", + "0.0.0.0", + "--port", + "8080", + "-ngl", + "30", + "--parallel", + "4" + ], + "env": {}, + "notes": null + }, + { + "m": 2, + "c": 82, + "h": 0, + "e": 0, + "args": [ + "/app/llama-server", + "-m", + "/m/inconnu.gguf", + "-c", + "32768", + "-b", + "2048", + "-ub", + "512", + "--host", + "0.0.0.0", + "--port", + "8080", + "--parallel", + "1", + "-ngl", + "auto", + "-t", + "6", + "-tb", + "3" + ], + "env": {}, + "notes": null + }, + { + "m": 2, + "c": 83, + "h": 0, + "e": 0, + "args": [ + "/app/llama-server", + "-m", + "/m/inconnu.gguf", + "-c", + "32768", + "-tb", + "0", + "-b", + "2048", + "-ub", + "512", + "--host", + "0.0.0.0", + "--port", + "8080", + "--parallel", + "1", + "-ngl", + "auto", + "-Cb", + "0xff", + "--poll-batch", + "1" + ], + "env": {}, + "notes": [ + "EXTRA_ARGS règle le CPU du batch (-Cb, --poll-batch…) : -tb 0 posé pour que llama.cpp le garde (tous les threads logiques) ; THREADS_BATCH pour choisir le nombre" + ] + }, + { + "m": 2, + "c": 84, + "h": 0, + "e": 0, + "args": [ + "/app/llama-server", + "-m", + "/m/inconnu.gguf", + "-c", + "32768", + "-t", + "6", + "-tb", + "6", + "-b", + "2048", + "-ub", + "512", + "--host", + "0.0.0.0", + "--port", + "8080", + "--parallel", + "1", + "-ngl", + "auto", + "--prio-batch", + "2" + ], + "env": {}, + "notes": null + }, + { + "m": 2, + "c": 85, + "h": 0, + "e": 0, + "args": [ + "/app/llama-server", + "-m", + "/m/inconnu.gguf", + "-c", + "32768", + "-b", + "2048", + "-ub", + "512", + "--host", + "0.0.0.0", + "--port", + "8080", + "--parallel", + "1", + "-ngl", + "auto", + "--cache-reuse", + "256", + "--context-shift" + ], + "env": {}, + "notes": [ + "avertissement : --context-shift (EXTRA_ARGS) — contexte plein, le moteur jette des jetons anciens et le modèle perd une partie de la conversation sans le savoir.", + "avertissement : --cache-reuse 256 (EXTRA_ARGS) — réutilise des morceaux de cache calculés sous un autre préfixe : les sorties ne sont plus exactement celles d'un calcul complet." + ] + }, + { + "m": 2, + "c": 86, + "h": 0, + "e": 0, + "args": [ + "/app/llama-server", + "-m", + "/m/inconnu.gguf", + "-c", + "32768", + "-b", + "2048", + "-ub", + "512", + "--host", + "0.0.0.0", + "--port", + "8080", + "--parallel", + "1", + "-ngl", + "auto", + "-ctk", + "q8_0", + "-ctv", + "q8_0" + ], + "env": {}, + "notes": [ + "cache KV q8_0/q8_0 (défini par EXTRA_ARGS) : modifie légèrement les sorties par rapport à f16 — choix de l'utilisateur, laissé tel quel." + ] + }, + { + "m": 2, + "c": 87, + "h": 0, + "e": 0, + "args": [ + "/app/llama-server", + "-m", + "/m/inconnu.gguf", + "-c", + "32768", + "-b", + "2048", + "-ub", + "512", + "--host", + "0.0.0.0", + "--port", + "8080", + "--parallel", + "1", + "-ngl", + "auto", + "-ot", + "exps=CPU" + ], + "env": {}, + "notes": null + }, + { + "m": 2, + "c": 88, + "h": 0, + "e": 0, + "args": [ + "/app/llama-server", + "-m", + "/m/inconnu.gguf", + "-c", + "32768", + "-b", + "2048", + "-ub", + "512", + "--host", + "0.0.0.0", + "--port", + "8080", + "--parallel", + "1", + "-ngl", + "auto", + "-ot", + "blk\\.(1|2)\\.ffn_.*_exps=CPU", + "-ub", + "512" + ], + "env": {}, + "notes": null + }, + { + "m": 2, + "c": 89, + "h": 0, + "e": 0, + "args": [ + "/app/llama-server", + "-m", + "/m/inconnu.gguf", + "-c", + "32768", + "-b", + "2048", + "-ub", + "512", + "--host", + "0.0.0.0", + "--port", + "8080", + "--parallel", + "1", + "-ngl", + "auto", + "--n-cpu-moe", + "20" + ], + "env": {}, + "notes": null + }, + { + "m": 2, + "c": 90, + "h": 0, + "e": 0, + "args": [ + "/app/llama-server", + "-m", + "/m/inconnu.gguf", + "-c", + "32768", + "-b", + "2048", + "-ub", + "512", + "--host", + "0.0.0.0", + "--port", + "8080", + "--parallel", + "1", + "-ngl", + "auto", + "--cpu-moe" + ], + "env": {}, + "notes": null + }, + { + "m": 2, + "c": 91, + "h": 0, + "e": 0, + "args": [ + "/app/llama-server", + "-m", + "/m/inconnu.gguf", + "-c", + "32768", + "-b", + "2048", + "-ub", + "512", + "--host", + "0.0.0.0", + "--port", + "8080", + "--parallel", + "1", + "-ngl", + "auto", + "-sm", + "row" + ], + "env": {}, + "notes": null + }, + { + "m": 2, + "c": 92, + "h": 0, + "e": 0, + "args": [ + "/app/llama-server", + "-m", + "/m/inconnu.gguf", + "-c", + "32768", + "-b", + "2048", + "-ub", + "512", + "--host", + "0.0.0.0", + "--port", + "8080", + "--parallel", + "1", + "-ngl", + "auto", + "-ts", + "1,1" + ], + "env": {}, + "notes": null + }, + { + "m": 2, + "c": 93, + "h": 0, + "e": 0, + "args": [ + "/app/llama-server", + "-m", + "/m/inconnu.gguf", + "-c", + "32768", + "-b", + "2048", + "-ub", + "512", + "--host", + "0.0.0.0", + "--port", + "8080", + "--parallel", + "1", + "-ngl", + "auto", + "-dev", + "CUDA0,CUDA1" + ], + "env": { + "CUDA_SCALE_LAUNCH_QUEUES": "4x" + }, + "notes": [ + "CUDA_SCALE_LAUNCH_QUEUES=4x (2 GPU, parallélisme pipeline possible) — CUDA_LAUNCH_QUEUES=off pour s'en passer" + ] + }, + { + "m": 2, + "c": 94, + "h": 0, + "e": 0, + "args": [ + "/app/llama-server", + "-m", + "/m/inconnu.gguf", + "-c", + "32768", + "-b", + "2048", + "-ub", + "512", + "--host", + "0.0.0.0", + "--port", + "8080", + "--parallel", + "1", + "-ngl", + "auto", + "-dev", + "Vulkan0" + ], + "env": {}, + "notes": null + }, + { + "m": 2, + "c": 95, + "h": 0, + "e": 0, + "args": [ + "/app/llama-server", + "-m", + "/m/inconnu.gguf", + "-c", + "32768", + "-b", + "2048", + "-ub", + "512", + "--host", + "0.0.0.0", + "--port", + "8080", + "--parallel", + "1", + "-ngl", + "auto", + "--fit", + "off" + ], + "env": {}, + "notes": null + }, + { + "m": 2, + "c": 96, + "h": 0, + "e": 0, + "args": [ + "/app/llama-server", + "-m", + "/m/inconnu.gguf", + "-c", + "32768", + "-b", + "2048", + "-ub", + "512", + "--host", + "0.0.0.0", + "--port", + "8080", + "--parallel", + "1", + "-ngl", + "auto", + "--mmproj", + "/m/x.gguf" + ], + "env": {}, + "notes": null + }, + { + "m": 2, + "c": 97, + "h": 0, + "e": 0, + "args": [ + "/app/llama-server", + "-m", + "/m/inconnu.gguf", + "-c", + "32768", + "-b", + "2048", + "-ub", + "512", + "--host", + "0.0.0.0", + "--port", + "8080", + "--parallel", + "1", + "-ngl", + "auto", + "--spec-synth-len", + "4" + ], + "env": {}, + "notes": [ + "avertissement : --spec-synth-len — le moteur accepte des jetons du brouillon AU HASARD, sans vérification : les réponses ne sont plus celles du modèle. Réservé aux mesures, retire-le." + ] + }, + { + "m": 2, + "c": 98, + "h": 0, + "e": 0, + "args": [ + "/app/llama-server", + "-m", + "/m/inconnu.gguf", + "-c", + "32768", + "-b", + "2048", + "-ub", + "512", + "--host", + "0.0.0.0", + "--port", + "8080", + "--parallel", + "1", + "-ngl", + "auto", + "--slot-save-path", + "/x" + ], + "env": {}, + "notes": null + }, + { + "m": 2, + "c": 99, + "h": 0, + "e": 0, + "args": [ + "/app/llama-server", + "-m", + "/m/inconnu.gguf", + "-c", + "32768", + "-b", + "2048", + "-ub", + "512", + "--host", + "0.0.0.0", + "--port", + "8080", + "--parallel", + "1", + "-ngl", + "auto", + "-c", + "0" + ], + "env": {}, + "notes": null + }, + { + "m": 2, + "c": 100, + "h": 0, + "e": 0, + "args": [ + "/app/llama-server", + "-m", + "/m/inconnu.gguf", + "-c", + "32768", + "-b", + "2048", + "-ub", + "512", + "--host", + "0.0.0.0", + "--port", + "8080", + "--parallel", + "1", + "-ngl", + "auto", + "--host", + "127.0.0.1", + "--port", + "9000" + ], + "env": {}, + "notes": null + }, + { + "m": 2, + "c": 101, + "h": 0, + "e": 0, + "args": [ + "/app/llama-server", + "-m", + "/m/inconnu.gguf", + "-c", + "32768", + "-b", + "2048", + "-ub", + "512", + "--host", + "0.0.0.0", + "--port", + "8080", + "--parallel", + "1", + "-ngl", + "auto", + "--mirostat", + "2" + ], + "env": {}, + "notes": [ + "SPEC=mtp : métadonnées du modèle illisibles — sans décodage spéculatif" + ] + }, + { + "m": 2, + "c": 102, + "h": 0, + "e": 0, + "args": [ + "/app/llama-server", + "-m", + "/m/inconnu.gguf", + "-c", + "32768", + "-b", + "2048", + "-ub", + "512", + "--host", + "0.0.0.0", + "--port", + "8080", + "--parallel", + "1", + "-ngl", + "auto", + "--chat-template-kwargs", + "{\"preserve_reasoning\":true}" + ], + "env": {}, + "notes": [ + "--chat-template-kwargs fixe déjà preserve_reasoning : REASONING_PRESERVE ignoré" + ] + }, + { + "m": 2, + "c": 103, + "h": 0, + "e": 0, + "args": [ + "/app/llama-server", + "-m", + "/m/inconnu.gguf", + "-c", + "32768", + "-b", + "2048", + "-ub", + "512", + "--host", + "0.0.0.0", + "--port", + "8080", + "--parallel", + "1", + "-ngl", + "auto", + "--spec-type", + "ngram-mod" + ], + "env": {}, + "notes": [ + "décodage spéculatif déjà réglé dans EXTRA_ARGS ou LLAMA_ARG_* : SPEC ignoré" + ] + }, + { + "m": 2, + "c": 104, + "h": 0, + "e": 0, + "args": [ + "/app/llama-server", + "-m", + "/m/inconnu.gguf", + "-c", + "32768", + "-b", + "2048", + "-ub", + "512", + "--host", + "0.0.0.0", + "--port", + "8080", + "--parallel", + "1", + "-ngl", + "auto", + "--spec-draft-n-max", + "8" + ], + "env": {}, + "notes": [ + "SPEC=ngram : --spec-draft-n-max dans EXTRA_ARGS (brouillon réglé à la main) — sans décodage spéculatif" + ] + }, + { + "m": 2, + "c": 105, + "h": 0, + "e": 0, + "args": [ + "/app/llama-server", + "-m", + "/m/inconnu.gguf", + "-c", + "32768", + "-b", + "2048", + "-ub", + "512", + "--host", + "0.0.0.0", + "--port", + "8080", + "--parallel", + "1", + "-ngl", + "auto", + "-ot", + "attn=CUDA1" + ], + "env": {}, + "notes": [ + "CUDA_GRAPH_OPT ignoré : -ot sur l'attention peut séparer Q, K et V d'une couche entre cartes" + ] + }, + { + "m": 2, + "c": 106, + "h": 0, + "e": 0, + "args": [ + "/app/llama-server", + "-m", + "/m/inconnu.gguf", + "-c", + "32768", + "-b", + "2048", + "-ub", + "512", + "--host", + "0.0.0.0", + "--port", + "8080", + "--parallel", + "1", + "-ngl", + "auto", + "-fa", + "off" + ], + "env": {}, + "notes": [ + "SPLIT_MODE=tensor refusé (Flash Attention coupée (-fa off) : le mode tensor l'exige) : découpe par couches, comme sans la clé" + ] + }, + { + "m": 2, + "c": 107, + "h": 0, + "e": 0, + "args": [ + "/app/llama-server", + "-m", + "/m/inconnu.gguf", + "-c", + "32768", + "-b", + "2048", + "-ub", + "512", + "--host", + "0.0.0.0", + "--port", + "8080", + "--parallel", + "1", + "-ngl", + "auto", + "-nkvo" + ], + "env": {}, + "notes": null + }, + { + "m": 2, + "c": 108, + "h": 0, + "e": 0, + "args": [ + "/app/llama-server", + "-m", + "/m/inconnu.gguf", + "-c", + "32768", + "-b", + "2048", + "-ub", + "512", + "--host", + "0.0.0.0", + "--port", + "8080", + "--parallel", + "1", + "-ngl", + "auto", + "--no-context-shift" + ], + "env": {}, + "notes": null + }, + { + "m": 2, + "c": 109, + "h": 0, + "e": 0, + "args": [ + "/app/llama-server", + "-m", + "/m/inconnu.gguf", + "-c", + "32768", + "-b", + "2048", + "-ub", + "512", + "--host", + "0.0.0.0", + "--port", + "8080", + "-ngl", + "auto", + "-c", + "8192", + "-np", + "2" + ], + "env": {}, + "notes": [ + "SIDE_SLOT refusé (-c est réglé dans EXTRA_ARGS) : un seul slot, comme sans la clé" + ] + }, + { + "m": 2, + "c": 110, + "h": 0, + "e": 0, + "args": [ + "/app/llama-server", + "-m", + "/m/inconnu.gguf", + "-c", + "32768", + "-b", + "2048", + "-ub", + "512", + "--host", + "0.0.0.0", + "--port", + "8080", + "--parallel", + "1", + "-ngl", + "auto" + ], + "env": {}, + "notes": [ + "SPLIT_MODE=tensor refusé (preset externe) : découpe par couches, comme sans la clé", + "SIDE_SLOT refusé (preset externe) : un seul slot, comme sans la clé" + ] + }, + { + "m": 2, + "c": 111, + "h": 0, + "e": 0, + "args": [ + "/app/llama-server", + "-m", + "/m/inconnu.gguf", + "-c", + "40000", + "-b", + "2048", + "-ub", + "512", + "--host", + "0.0.0.0", + "--port", + "8080", + "--parallel", + "1", + "-ngl", + "auto", + "-ctk", + "q4_0", + "-ctv", + "q4_0" + ], + "env": {}, + "notes": [ + "NGL=999 (sentinelle « toutes les couches ») → -ngl auto : ce moteur mesure lui-même la VRAM libre. NGL=all pour forcer l'ancien comportement.", + "SPEC=auto : métadonnées du modèle illisibles — sans décodage spéculatif", + "cache KV q4_0/q4_0 (KV_TYPE) : perte mesurable par rapport à f16 — choix de l'utilisateur, laissé tel quel." + ] + }, + { + "m": 3, + "c": 0, + "h": 0, + "e": 0, + "args": [ + "/app/llama-server", + "-m", + "/m/hybrid.gguf", + "-c", + "32768", + "-b", + "2048", + "-ub", + "512", + "--host", + "0.0.0.0", + "--port", + "8080", + "--parallel", + "1", + "-ngl", + "auto" + ], + "env": {}, + "notes": [ + "modèle hybride de 20 Gio pour 24 Gio de RAM : jusqu'à 32 points de reprise (~150 Mio chacun) prennent sur le cache de pages des poids. CTX_CHECKPOINTS=8 à 16 si le débit baisse.", + "moteur b9000 : les points de reprise des modèles hybrides (Qwen3.5/3.6, Qwen3-Next…) y sont évincés trop tôt — jusqu'à ~8 k jetons recalculés à chaque reprise. Corrigé à partir de b10864. Pas d'espacement resserré d'office : RAM inconnue ou déjà prise par le modèle (CKPT_MIN_STEP=2048 pour l'imposer)." + ] + }, + { + "m": 3, + "c": 1, + "h": 0, + "e": 0, + "args": [ + "/app/llama-server", + "-m", + "/m/hybrid.gguf", + "-c", + "65536", + "-b", + "4096", + "-ub", + "2048", + "--host", + "127.0.0.1", + "--port", + "8081", + "--parallel", + "1", + "-ngl", + "auto" + ], + "env": {}, + "notes": [ + "NGL=999 (sentinelle « toutes les couches ») → -ngl auto : ce moteur mesure lui-même la VRAM libre. NGL=all pour forcer l'ancien comportement.", + "modèle hybride de 20 Gio pour 24 Gio de RAM : jusqu'à 32 points de reprise (~150 Mio chacun) prennent sur le cache de pages des poids. CTX_CHECKPOINTS=8 à 16 si le débit baisse.", + "moteur b9000 : les points de reprise des modèles hybrides (Qwen3.5/3.6, Qwen3-Next…) y sont évincés trop tôt — jusqu'à ~8 k jetons recalculés à chaque reprise. Corrigé à partir de b10864. Pas d'espacement resserré d'office : RAM inconnue ou déjà prise par le modèle (CKPT_MIN_STEP=2048 pour l'imposer)." + ] + }, + { + "m": 3, + "c": 2, + "h": 0, + "e": 0, + "args": [ + "/app/llama-server", + "-m", + "/m/hybrid.gguf", + "-c", + "32768", + "-b", + "2048", + "-ub", + "512", + "--host", + "0.0.0.0", + "--port", + "8080", + "--parallel", + "1" + ], + "env": {}, + "notes": [ + "modèle hybride de 20 Gio pour 24 Gio de RAM : jusqu'à 32 points de reprise (~150 Mio chacun) prennent sur le cache de pages des poids. CTX_CHECKPOINTS=8 à 16 si le débit baisse.", + "moteur b9000 : les points de reprise des modèles hybrides (Qwen3.5/3.6, Qwen3-Next…) y sont évincés trop tôt — jusqu'à ~8 k jetons recalculés à chaque reprise. Corrigé à partir de b10864. Pas d'espacement resserré d'office : RAM inconnue ou déjà prise par le modèle (CKPT_MIN_STEP=2048 pour l'imposer)." + ] + }, + { + "m": 3, + "c": 3, + "h": 0, + "e": 0, + "args": [ + "/app/llama-server", + "-m", + "/m/hybrid.gguf", + "-c", + "32768", + "-b", + "2048", + "-ub", + "512", + "--host", + "0.0.0.0", + "--port", + "8080", + "--parallel", + "1", + "-ngl", + "40" + ], + "env": {}, + "notes": [ + "modèle hybride de 20 Gio pour 24 Gio de RAM : jusqu'à 32 points de reprise (~150 Mio chacun) prennent sur le cache de pages des poids. CTX_CHECKPOINTS=8 à 16 si le débit baisse.", + "moteur b9000 : les points de reprise des modèles hybrides (Qwen3.5/3.6, Qwen3-Next…) y sont évincés trop tôt — jusqu'à ~8 k jetons recalculés à chaque reprise. Corrigé à partir de b10864. Pas d'espacement resserré d'office : RAM inconnue ou déjà prise par le modèle (CKPT_MIN_STEP=2048 pour l'imposer)." + ] + }, + { + "m": 3, + "c": 4, + "h": 0, + "e": 0, + "args": [ + "/app/llama-server", + "-m", + "/m/hybrid.gguf", + "-c", + "32768", + "-b", + "2048", + "-ub", + "512", + "--host", + "0.0.0.0", + "--port", + "8080", + "--parallel", + "1", + "-ngl", + "all" + ], + "env": {}, + "notes": [ + "modèle hybride de 20 Gio pour 24 Gio de RAM : jusqu'à 32 points de reprise (~150 Mio chacun) prennent sur le cache de pages des poids. CTX_CHECKPOINTS=8 à 16 si le débit baisse.", + "moteur b9000 : les points de reprise des modèles hybrides (Qwen3.5/3.6, Qwen3-Next…) y sont évincés trop tôt — jusqu'à ~8 k jetons recalculés à chaque reprise. Corrigé à partir de b10864. Pas d'espacement resserré d'office : RAM inconnue ou déjà prise par le modèle (CKPT_MIN_STEP=2048 pour l'imposer)." + ] + }, + { + "m": 3, + "c": 5, + "h": 0, + "e": 0, + "args": [ + "/app/llama-server", + "-m", + "/m/hybrid.gguf", + "-c", + "32768", + "-b", + "2048", + "-ub", + "512", + "--host", + "0.0.0.0", + "--port", + "8080", + "--parallel", + "1", + "-ngl", + "auto" + ], + "env": {}, + "notes": [ + "NGL=999 (sentinelle « toutes les couches ») → -ngl auto : ce moteur mesure lui-même la VRAM libre. NGL=all pour forcer l'ancien comportement.", + "modèle hybride de 20 Gio pour 24 Gio de RAM : jusqu'à 32 points de reprise (~150 Mio chacun) prennent sur le cache de pages des poids. CTX_CHECKPOINTS=8 à 16 si le débit baisse.", + "moteur b9000 : les points de reprise des modèles hybrides (Qwen3.5/3.6, Qwen3-Next…) y sont évincés trop tôt — jusqu'à ~8 k jetons recalculés à chaque reprise. Corrigé à partir de b10864. Pas d'espacement resserré d'office : RAM inconnue ou déjà prise par le modèle (CKPT_MIN_STEP=2048 pour l'imposer)." + ] + }, + { + "m": 3, + "c": 6, + "h": 0, + "e": 0, + "args": [ + "/app/llama-server", + "-m", + "/m/hybrid.gguf", + "-c", + "32768", + "-t", + "8", + "-tb", + "4", + "-b", + "2048", + "-ub", + "512", + "--host", + "0.0.0.0", + "--port", + "8080", + "--parallel", + "1", + "-ngl", + "auto" + ], + "env": {}, + "notes": [ + "modèle hybride de 20 Gio pour 24 Gio de RAM : jusqu'à 32 points de reprise (~150 Mio chacun) prennent sur le cache de pages des poids. CTX_CHECKPOINTS=8 à 16 si le débit baisse.", + "moteur b9000 : les points de reprise des modèles hybrides (Qwen3.5/3.6, Qwen3-Next…) y sont évincés trop tôt — jusqu'à ~8 k jetons recalculés à chaque reprise. Corrigé à partir de b10864. Pas d'espacement resserré d'office : RAM inconnue ou déjà prise par le modèle (CKPT_MIN_STEP=2048 pour l'imposer)." + ] + }, + { + "m": 3, + "c": 7, + "h": 0, + "e": 0, + "args": [ + "/app/llama-server", + "-m", + "/m/hybrid.gguf", + "-c", + "32768", + "-b", + "2048", + "-ub", + "512", + "--host", + "0.0.0.0", + "--port", + "8080", + "--parallel", + "1", + "-ngl", + "auto" + ], + "env": {}, + "notes": [ + "THREADS=abc ignoré (pas un nombre de threads ≥ 0) → automatique", + "THREADS_BATCH=-2 ignoré (pas un nombre de threads ≥ 0) → automatique", + "modèle hybride de 20 Gio pour 24 Gio de RAM : jusqu'à 32 points de reprise (~150 Mio chacun) prennent sur le cache de pages des poids. CTX_CHECKPOINTS=8 à 16 si le débit baisse.", + "moteur b9000 : les points de reprise des modèles hybrides (Qwen3.5/3.6, Qwen3-Next…) y sont évincés trop tôt — jusqu'à ~8 k jetons recalculés à chaque reprise. Corrigé à partir de b10864. Pas d'espacement resserré d'office : RAM inconnue ou déjà prise par le modèle (CKPT_MIN_STEP=2048 pour l'imposer)." + ] + }, + { + "m": 3, + "c": 8, + "h": 0, + "e": 0, + "args": [ + "/app/llama-server", + "-m", + "/m/hybrid.gguf", + "-c", + "32768", + "-b", + "2048", + "-ub", + "512", + "--host", + "0.0.0.0", + "--port", + "8080", + "--parallel", + "1", + "-ngl", + "auto" + ], + "env": {}, + "notes": [ + "modèle hybride de 20 Gio pour 24 Gio de RAM : jusqu'à 32 points de reprise (~150 Mio chacun) prennent sur le cache de pages des poids. CTX_CHECKPOINTS=8 à 16 si le débit baisse.", + "moteur b9000 : les points de reprise des modèles hybrides (Qwen3.5/3.6, Qwen3-Next…) y sont évincés trop tôt — jusqu'à ~8 k jetons recalculés à chaque reprise. Corrigé à partir de b10864. Pas d'espacement resserré d'office : RAM inconnue ou déjà prise par le modèle (CKPT_MIN_STEP=2048 pour l'imposer)." + ] + }, + { + "m": 3, + "c": 9, + "h": 0, + "e": 0, + "args": [ + "/app/llama-server", + "-m", + "/m/hybrid.gguf", + "-c", + "32768", + "-b", + "2048", + "-ub", + "512", + "--host", + "0.0.0.0", + "--port", + "8080", + "--parallel", + "1", + "-ngl", + "auto", + "-ctk", + "q8_0", + "-ctv", + "q8_0" + ], + "env": {}, + "notes": [ + "modèle hybride de 20 Gio pour 24 Gio de RAM : jusqu'à 32 points de reprise (~150 Mio chacun) prennent sur le cache de pages des poids. CTX_CHECKPOINTS=8 à 16 si le débit baisse.", + "moteur b9000 : les points de reprise des modèles hybrides (Qwen3.5/3.6, Qwen3-Next…) y sont évincés trop tôt — jusqu'à ~8 k jetons recalculés à chaque reprise. Corrigé à partir de b10864. Pas d'espacement resserré d'office : RAM inconnue ou déjà prise par le modèle (CKPT_MIN_STEP=2048 pour l'imposer).", + "cache KV q8_0/q8_0 (KV_TYPE) : modifie légèrement les sorties par rapport à f16 — choix de l'utilisateur, laissé tel quel." + ] + }, + { + "m": 3, + "c": 10, + "h": 0, + "e": 0, + "args": [ + "/app/llama-server", + "-m", + "/m/hybrid.gguf", + "-c", + "32768", + "-b", + "2048", + "-ub", + "512", + "--host", + "0.0.0.0", + "--port", + "8080", + "--parallel", + "1", + "-ngl", + "auto", + "-ctk", + "q4_0", + "-ctv", + "f16" + ], + "env": {}, + "notes": [ + "modèle hybride de 20 Gio pour 24 Gio de RAM : jusqu'à 32 points de reprise (~150 Mio chacun) prennent sur le cache de pages des poids. CTX_CHECKPOINTS=8 à 16 si le débit baisse.", + "moteur b9000 : les points de reprise des modèles hybrides (Qwen3.5/3.6, Qwen3-Next…) y sont évincés trop tôt — jusqu'à ~8 k jetons recalculés à chaque reprise. Corrigé à partir de b10864. Pas d'espacement resserré d'office : RAM inconnue ou déjà prise par le modèle (CKPT_MIN_STEP=2048 pour l'imposer).", + "cache KV q4_0/f16 (KV_TYPE) : perte mesurable par rapport à f16 — choix de l'utilisateur, laissé tel quel." + ] + }, + { + "m": 3, + "c": 11, + "h": 0, + "e": 0, + "args": [ + "/app/llama-server", + "-m", + "/m/hybrid.gguf", + "-c", + "32768", + "-b", + "2048", + "-ub", + "512", + "--host", + "0.0.0.0", + "--port", + "8080", + "--parallel", + "1", + "-ngl", + "auto", + "-ctk", + "bf16", + "-ctv", + "bf16" + ], + "env": {}, + "notes": [ + "modèle hybride de 20 Gio pour 24 Gio de RAM : jusqu'à 32 points de reprise (~150 Mio chacun) prennent sur le cache de pages des poids. CTX_CHECKPOINTS=8 à 16 si le débit baisse.", + "moteur b9000 : les points de reprise des modèles hybrides (Qwen3.5/3.6, Qwen3-Next…) y sont évincés trop tôt — jusqu'à ~8 k jetons recalculés à chaque reprise. Corrigé à partir de b10864. Pas d'espacement resserré d'office : RAM inconnue ou déjà prise par le modèle (CKPT_MIN_STEP=2048 pour l'imposer).", + "cache KV bf16/bf16 (KV_TYPE) : numérique différente de f16 (mantisse plus courte) — choix de l'utilisateur, laissé tel quel." + ] + }, + { + "m": 3, + "c": 12, + "h": 0, + "e": 0, + "args": [ + "/app/llama-server", + "-m", + "/m/hybrid.gguf", + "-c", + "32768", + "-b", + "2048", + "-ub", + "512", + "--host", + "0.0.0.0", + "--port", + "8080", + "--parallel", + "1", + "-ngl", + "auto", + "-ctk", + "q5_1", + "-ctv", + "q5_1" + ], + "env": {}, + "notes": [ + "modèle hybride de 20 Gio pour 24 Gio de RAM : jusqu'à 32 points de reprise (~150 Mio chacun) prennent sur le cache de pages des poids. CTX_CHECKPOINTS=8 à 16 si le débit baisse.", + "moteur b9000 : les points de reprise des modèles hybrides (Qwen3.5/3.6, Qwen3-Next…) y sont évincés trop tôt — jusqu'à ~8 k jetons recalculés à chaque reprise. Corrigé à partir de b10864. Pas d'espacement resserré d'office : RAM inconnue ou déjà prise par le modèle (CKPT_MIN_STEP=2048 pour l'imposer).", + "cache KV q5_1/q5_1 (KV_TYPE) : modifie légèrement les sorties par rapport à f16 — choix de l'utilisateur, laissé tel quel." + ] + }, + { + "m": 3, + "c": 13, + "h": 0, + "e": 0, + "args": [ + "/app/llama-server", + "-m", + "/m/hybrid.gguf", + "-c", + "32768", + "-b", + "2048", + "-ub", + "512", + "--host", + "0.0.0.0", + "--port", + "8080", + "--parallel", + "1", + "-ngl", + "auto", + "--reasoning", + "on", + "--reasoning-budget", + "-1" + ], + "env": {}, + "notes": [ + "modèle hybride de 20 Gio pour 24 Gio de RAM : jusqu'à 32 points de reprise (~150 Mio chacun) prennent sur le cache de pages des poids. CTX_CHECKPOINTS=8 à 16 si le débit baisse.", + "moteur b9000 : les points de reprise des modèles hybrides (Qwen3.5/3.6, Qwen3-Next…) y sont évincés trop tôt — jusqu'à ~8 k jetons recalculés à chaque reprise. Corrigé à partir de b10864. Pas d'espacement resserré d'office : RAM inconnue ou déjà prise par le modèle (CKPT_MIN_STEP=2048 pour l'imposer)." + ] + }, + { + "m": 3, + "c": 14, + "h": 0, + "e": 0, + "args": [ + "/app/llama-server", + "-m", + "/m/hybrid.gguf", + "-c", + "32768", + "-b", + "2048", + "-ub", + "512", + "--host", + "0.0.0.0", + "--port", + "8080", + "--parallel", + "1", + "-ngl", + "auto", + "--reasoning", + "off" + ], + "env": {}, + "notes": [ + "modèle hybride de 20 Gio pour 24 Gio de RAM : jusqu'à 32 points de reprise (~150 Mio chacun) prennent sur le cache de pages des poids. CTX_CHECKPOINTS=8 à 16 si le débit baisse.", + "moteur b9000 : les points de reprise des modèles hybrides (Qwen3.5/3.6, Qwen3-Next…) y sont évincés trop tôt — jusqu'à ~8 k jetons recalculés à chaque reprise. Corrigé à partir de b10864. Pas d'espacement resserré d'office : RAM inconnue ou déjà prise par le modèle (CKPT_MIN_STEP=2048 pour l'imposer)." + ] + }, + { + "m": 3, + "c": 15, + "h": 0, + "e": 0, + "args": [ + "/app/llama-server", + "-m", + "/m/hybrid.gguf", + "-c", + "32768", + "-b", + "2048", + "-ub", + "512", + "--host", + "0.0.0.0", + "--port", + "8080", + "--parallel", + "1", + "-ngl", + "auto", + "--reasoning", + "auto", + "--reasoning-budget", + "2048" + ], + "env": {}, + "notes": [ + "modèle hybride de 20 Gio pour 24 Gio de RAM : jusqu'à 32 points de reprise (~150 Mio chacun) prennent sur le cache de pages des poids. CTX_CHECKPOINTS=8 à 16 si le débit baisse.", + "moteur b9000 : les points de reprise des modèles hybrides (Qwen3.5/3.6, Qwen3-Next…) y sont évincés trop tôt — jusqu'à ~8 k jetons recalculés à chaque reprise. Corrigé à partir de b10864. Pas d'espacement resserré d'office : RAM inconnue ou déjà prise par le modèle (CKPT_MIN_STEP=2048 pour l'imposer)." + ] + }, + { + "m": 3, + "c": 16, + "h": 0, + "e": 0, + "args": [ + "/app/llama-server", + "-m", + "/m/hybrid.gguf", + "-c", + "32768", + "-b", + "2048", + "-ub", + "512", + "--host", + "0.0.0.0", + "--port", + "8080", + "--parallel", + "1", + "-ngl", + "auto", + "--reasoning", + "deepseek", + "--reasoning-budget", + "-1" + ], + "env": {}, + "notes": [ + "modèle hybride de 20 Gio pour 24 Gio de RAM : jusqu'à 32 points de reprise (~150 Mio chacun) prennent sur le cache de pages des poids. CTX_CHECKPOINTS=8 à 16 si le débit baisse.", + "moteur b9000 : les points de reprise des modèles hybrides (Qwen3.5/3.6, Qwen3-Next…) y sont évincés trop tôt — jusqu'à ~8 k jetons recalculés à chaque reprise. Corrigé à partir de b10864. Pas d'espacement resserré d'office : RAM inconnue ou déjà prise par le modèle (CKPT_MIN_STEP=2048 pour l'imposer)." + ] + }, + { + "m": 3, + "c": 17, + "h": 0, + "e": 0, + "args": [ + "/app/llama-server", + "-m", + "/m/hybrid.gguf", + "-c", + "32768", + "-b", + "2048", + "-ub", + "512", + "--host", + "0.0.0.0", + "--port", + "8080", + "--parallel", + "1", + "-ngl", + "auto", + "--reasoning-preserve" + ], + "env": {}, + "notes": [ + "modèle hybride de 20 Gio pour 24 Gio de RAM : jusqu'à 32 points de reprise (~150 Mio chacun) prennent sur le cache de pages des poids. CTX_CHECKPOINTS=8 à 16 si le débit baisse.", + "moteur b9000 : les points de reprise des modèles hybrides (Qwen3.5/3.6, Qwen3-Next…) y sont évincés trop tôt — jusqu'à ~8 k jetons recalculés à chaque reprise. Corrigé à partir de b10864. Pas d'espacement resserré d'office : RAM inconnue ou déjà prise par le modèle (CKPT_MIN_STEP=2048 pour l'imposer)." + ] + }, + { + "m": 3, + "c": 18, + "h": 0, + "e": 0, + "args": [ + "/app/llama-server", + "-m", + "/m/hybrid.gguf", + "-c", + "32768", + "-b", + "2048", + "-ub", + "512", + "--host", + "0.0.0.0", + "--port", + "8080", + "--parallel", + "1", + "-ngl", + "auto", + "--no-reasoning-preserve" + ], + "env": {}, + "notes": [ + "modèle hybride de 20 Gio pour 24 Gio de RAM : jusqu'à 32 points de reprise (~150 Mio chacun) prennent sur le cache de pages des poids. CTX_CHECKPOINTS=8 à 16 si le débit baisse.", + "moteur b9000 : les points de reprise des modèles hybrides (Qwen3.5/3.6, Qwen3-Next…) y sont évincés trop tôt — jusqu'à ~8 k jetons recalculés à chaque reprise. Corrigé à partir de b10864. Pas d'espacement resserré d'office : RAM inconnue ou déjà prise par le modèle (CKPT_MIN_STEP=2048 pour l'imposer)." + ] + }, + { + "m": 3, + "c": 19, + "h": 0, + "e": 0, + "args": [ + "/app/llama-server", + "-m", + "/m/hybrid.gguf", + "-c", + "32768", + "-b", + "2048", + "-ub", + "512", + "--host", + "0.0.0.0", + "--port", + "8080", + "--parallel", + "1", + "-ngl", + "auto" + ], + "env": {}, + "notes": [ + "REASONING_PRESERVE=peut-être illisible (on ou off) : ignoré", + "modèle hybride de 20 Gio pour 24 Gio de RAM : jusqu'à 32 points de reprise (~150 Mio chacun) prennent sur le cache de pages des poids. CTX_CHECKPOINTS=8 à 16 si le débit baisse.", + "moteur b9000 : les points de reprise des modèles hybrides (Qwen3.5/3.6, Qwen3-Next…) y sont évincés trop tôt — jusqu'à ~8 k jetons recalculés à chaque reprise. Corrigé à partir de b10864. Pas d'espacement resserré d'office : RAM inconnue ou déjà prise par le modèle (CKPT_MIN_STEP=2048 pour l'imposer)." + ] + }, + { + "m": 3, + "c": 20, + "h": 0, + "e": 0, + "args": [ + "/app/llama-server", + "-m", + "/m/hybrid.gguf", + "-c", + "32768", + "-b", + "2048", + "-ub", + "512", + "--host", + "0.0.0.0", + "--port", + "8080", + "--parallel", + "1", + "-ngl", + "auto" + ], + "env": {}, + "notes": [ + "modèle hybride de 20 Gio pour 24 Gio de RAM : jusqu'à 32 points de reprise (~150 Mio chacun) prennent sur le cache de pages des poids. CTX_CHECKPOINTS=8 à 16 si le débit baisse.", + "moteur b9000 : les points de reprise des modèles hybrides (Qwen3.5/3.6, Qwen3-Next…) y sont évincés trop tôt — jusqu'à ~8 k jetons recalculés à chaque reprise. Corrigé à partir de b10864. Pas d'espacement resserré d'office : RAM inconnue ou déjà prise par le modèle (CKPT_MIN_STEP=2048 pour l'imposer)." + ] + }, + { + "m": 3, + "c": 21, + "h": 0, + "e": 0, + "args": [ + "/app/llama-server", + "-m", + "/m/hybrid.gguf", + "-c", + "32768", + "-b", + "2048", + "-ub", + "512", + "--host", + "0.0.0.0", + "--port", + "8080", + "--parallel", + "1", + "-ngl", + "auto", + "--cache-ram", + "4096" + ], + "env": {}, + "notes": [ + "modèle hybride de 20 Gio pour 24 Gio de RAM : jusqu'à 32 points de reprise (~150 Mio chacun) prennent sur le cache de pages des poids. CTX_CHECKPOINTS=8 à 16 si le débit baisse.", + "moteur b9000 : les points de reprise des modèles hybrides (Qwen3.5/3.6, Qwen3-Next…) y sont évincés trop tôt — jusqu'à ~8 k jetons recalculés à chaque reprise. Corrigé à partir de b10864. Pas d'espacement resserré d'office : RAM inconnue ou déjà prise par le modèle (CKPT_MIN_STEP=2048 pour l'imposer)." + ] + }, + { + "m": 3, + "c": 22, + "h": 0, + "e": 0, + "args": [ + "/app/llama-server", + "-m", + "/m/hybrid.gguf", + "-c", + "32768", + "-b", + "2048", + "-ub", + "512", + "--host", + "0.0.0.0", + "--port", + "8080", + "--parallel", + "1", + "-ngl", + "auto", + "--cache-ram", + "0" + ], + "env": {}, + "notes": [ + "modèle hybride de 20 Gio pour 24 Gio de RAM : jusqu'à 32 points de reprise (~150 Mio chacun) prennent sur le cache de pages des poids. CTX_CHECKPOINTS=8 à 16 si le débit baisse.", + "moteur b9000 : les points de reprise des modèles hybrides (Qwen3.5/3.6, Qwen3-Next…) y sont évincés trop tôt — jusqu'à ~8 k jetons recalculés à chaque reprise. Corrigé à partir de b10864. Pas d'espacement resserré d'office : RAM inconnue ou déjà prise par le modèle (CKPT_MIN_STEP=2048 pour l'imposer)." + ] + }, + { + "m": 3, + "c": 23, + "h": 0, + "e": 0, + "args": [ + "/app/llama-server", + "-m", + "/m/hybrid.gguf", + "-c", + "32768", + "-b", + "2048", + "-ub", + "512", + "--host", + "0.0.0.0", + "--port", + "8080", + "--parallel", + "1", + "-ngl", + "auto", + "--cache-ram", + "-1" + ], + "env": {}, + "notes": [ + "modèle hybride de 20 Gio pour 24 Gio de RAM : jusqu'à 32 points de reprise (~150 Mio chacun) prennent sur le cache de pages des poids. CTX_CHECKPOINTS=8 à 16 si le débit baisse.", + "moteur b9000 : les points de reprise des modèles hybrides (Qwen3.5/3.6, Qwen3-Next…) y sont évincés trop tôt — jusqu'à ~8 k jetons recalculés à chaque reprise. Corrigé à partir de b10864. Pas d'espacement resserré d'office : RAM inconnue ou déjà prise par le modèle (CKPT_MIN_STEP=2048 pour l'imposer)." + ] + }, + { + "m": 3, + "c": 24, + "h": 0, + "e": 0, + "args": [ + "/app/llama-server", + "-m", + "/m/hybrid.gguf", + "-c", + "32768", + "-b", + "2048", + "-ub", + "512", + "--host", + "0.0.0.0", + "--port", + "8080", + "--parallel", + "1", + "-ngl", + "auto" + ], + "env": {}, + "notes": [ + "CACHE_RAM=beaucoup illisible (un nombre de Mio, -1, 0 ou auto) : ignoré", + "modèle hybride de 20 Gio pour 24 Gio de RAM : jusqu'à 32 points de reprise (~150 Mio chacun) prennent sur le cache de pages des poids. CTX_CHECKPOINTS=8 à 16 si le débit baisse.", + "moteur b9000 : les points de reprise des modèles hybrides (Qwen3.5/3.6, Qwen3-Next…) y sont évincés trop tôt — jusqu'à ~8 k jetons recalculés à chaque reprise. Corrigé à partir de b10864. Pas d'espacement resserré d'office : RAM inconnue ou déjà prise par le modèle (CKPT_MIN_STEP=2048 pour l'imposer)." + ] + }, + { + "m": 3, + "c": 25, + "h": 0, + "e": 0, + "args": [ + "/app/llama-server", + "-m", + "/m/hybrid.gguf", + "-c", + "32768", + "-b", + "2048", + "-ub", + "512", + "--host", + "0.0.0.0", + "--port", + "8080", + "--parallel", + "1", + "-ngl", + "auto" + ], + "env": {}, + "notes": [ + "modèle hybride de 20 Gio pour 24 Gio de RAM : jusqu'à 32 points de reprise (~150 Mio chacun) prennent sur le cache de pages des poids. CTX_CHECKPOINTS=8 à 16 si le débit baisse.", + "moteur b9000 : les points de reprise des modèles hybrides (Qwen3.5/3.6, Qwen3-Next…) y sont évincés trop tôt — jusqu'à ~8 k jetons recalculés à chaque reprise. Corrigé à partir de b10864. Pas d'espacement resserré d'office : RAM inconnue ou déjà prise par le modèle (CKPT_MIN_STEP=2048 pour l'imposer)." + ] + }, + { + "m": 3, + "c": 26, + "h": 0, + "e": 0, + "args": [ + "/app/llama-server", + "-m", + "/m/hybrid.gguf", + "-c", + "32768", + "-b", + "2048", + "-ub", + "512", + "--host", + "0.0.0.0", + "--port", + "8080", + "--parallel", + "1", + "-ngl", + "auto", + "--cache-ram", + "0" + ], + "env": {}, + "notes": [ + "modèle hybride de 20 Gio pour 24 Gio de RAM : jusqu'à 32 points de reprise (~150 Mio chacun) prennent sur le cache de pages des poids. CTX_CHECKPOINTS=8 à 16 si le débit baisse.", + "moteur b9000 : les points de reprise des modèles hybrides (Qwen3.5/3.6, Qwen3-Next…) y sont évincés trop tôt — jusqu'à ~8 k jetons recalculés à chaque reprise. Corrigé à partir de b10864. Pas d'espacement resserré d'office : RAM inconnue ou déjà prise par le modèle (CKPT_MIN_STEP=2048 pour l'imposer)." + ] + }, + { + "m": 3, + "c": 27, + "h": 0, + "e": 0, + "args": [ + "/app/llama-server", + "-m", + "/m/hybrid.gguf", + "-c", + "32768", + "-b", + "2048", + "-ub", + "512", + "--host", + "0.0.0.0", + "--port", + "8080", + "--parallel", + "1", + "-ngl", + "auto" + ], + "env": {}, + "notes": [ + "SIDE_SLOT refusé (VRAM insuffisante : modèle + 2 × 2198 Mio d'état dépassent 90 % de 24576 Mio) : un seul slot, comme sans la clé", + "modèle hybride de 20 Gio pour 24 Gio de RAM : jusqu'à 32 points de reprise (~150 Mio chacun) prennent sur le cache de pages des poids. CTX_CHECKPOINTS=8 à 16 si le débit baisse.", + "moteur b9000 : les points de reprise des modèles hybrides (Qwen3.5/3.6, Qwen3-Next…) y sont évincés trop tôt — jusqu'à ~8 k jetons recalculés à chaque reprise. Corrigé à partir de b10864. Pas d'espacement resserré d'office : RAM inconnue ou déjà prise par le modèle (CKPT_MIN_STEP=2048 pour l'imposer)." + ] + }, + { + "m": 3, + "c": 28, + "h": 0, + "e": 0, + "args": [ + "/app/llama-server", + "-m", + "/m/hybrid.gguf", + "-c", + "32768", + "-b", + "2048", + "-ub", + "512", + "--host", + "0.0.0.0", + "--port", + "8080", + "--parallel", + "3", + "-ngl", + "auto" + ], + "env": {}, + "notes": [ + "SIDE_SLOT refusé (PARALLEL=3 (SIDE_SLOT ouvre exactement 2 slots)) : un seul slot, comme sans la clé", + "modèle hybride de 20 Gio pour 24 Gio de RAM : jusqu'à 32 points de reprise (~150 Mio chacun) prennent sur le cache de pages des poids. CTX_CHECKPOINTS=8 à 16 si le débit baisse.", + "moteur b9000 : les points de reprise des modèles hybrides (Qwen3.5/3.6, Qwen3-Next…) y sont évincés trop tôt — jusqu'à ~8 k jetons recalculés à chaque reprise. Corrigé à partir de b10864. Pas d'espacement resserré d'office : RAM inconnue ou déjà prise par le modèle (CKPT_MIN_STEP=2048 pour l'imposer)." + ] + }, + { + "m": 3, + "c": 29, + "h": 0, + "e": 0, + "args": [ + "/app/llama-server", + "-m", + "/m/hybrid.gguf", + "-c", + "0", + "-b", + "2048", + "-ub", + "512", + "--host", + "0.0.0.0", + "--port", + "8080", + "--parallel", + "1", + "-ngl", + "auto" + ], + "env": {}, + "notes": [ + "SIDE_SLOT refusé (CTX doit être un nombre de jetons explicite) : un seul slot, comme sans la clé", + "modèle hybride de 20 Gio pour 24 Gio de RAM : jusqu'à 32 points de reprise (~150 Mio chacun) prennent sur le cache de pages des poids. CTX_CHECKPOINTS=8 à 16 si le débit baisse.", + "moteur b9000 : les points de reprise des modèles hybrides (Qwen3.5/3.6, Qwen3-Next…) y sont évincés trop tôt — jusqu'à ~8 k jetons recalculés à chaque reprise. Corrigé à partir de b10864. Pas d'espacement resserré d'office : RAM inconnue ou déjà prise par le modèle (CKPT_MIN_STEP=2048 pour l'imposer)." + ] + }, + { + "m": 3, + "c": 30, + "h": 0, + "e": 0, + "args": [ + "/app/llama-server", + "-m", + "/m/hybrid.gguf", + "-c", + "16384", + "-b", + "2048", + "-ub", + "512", + "--host", + "0.0.0.0", + "--port", + "8080", + "--parallel", + "2", + "--no-kv-unified", + "-ngl", + "auto", + "-ctk", + "q8_0", + "-ctv", + "q8_0" + ], + "env": {}, + "notes": [ + "SIDE_SLOT : 2 slots de 8192 jetons (-c 16384, --no-kv-unified), ~422 Mio de VRAM en plus pour le second (cache KV et état récurrent ; le brouillon MTP éventuel et les points de reprise en RAM hôte doublent aussi) ; le placement automatique sortira des couches du GPU s'il manque de place", + "modèle hybride de 20 Gio pour 24 Gio de RAM : jusqu'à 32 points de reprise (~150 Mio chacun) prennent sur le cache de pages des poids. CTX_CHECKPOINTS=8 à 16 si le débit baisse.", + "moteur b9000 : les points de reprise des modèles hybrides (Qwen3.5/3.6, Qwen3-Next…) y sont évincés trop tôt — jusqu'à ~8 k jetons recalculés à chaque reprise. Corrigé à partir de b10864. Pas d'espacement resserré d'office : RAM inconnue ou déjà prise par le modèle (CKPT_MIN_STEP=2048 pour l'imposer).", + "cache KV q8_0/q8_0 (KV_TYPE) : modifie légèrement les sorties par rapport à f16 — choix de l'utilisateur, laissé tel quel." + ] + }, + { + "m": 3, + "c": 31, + "h": 0, + "e": 0, + "args": [ + "/app/llama-server", + "-m", + "/m/hybrid.gguf", + "-c", + "32768", + "-b", + "2048", + "-ub", + "512", + "--host", + "0.0.0.0", + "--port", + "8080", + "--parallel", + "1", + "-ngl", + "auto" + ], + "env": {}, + "notes": [ + "SPLIT_MODE=tensor refusé (cartes inconnues (--list-devices n'a rien répondu)) : découpe par couches, comme sans la clé", + "modèle hybride de 20 Gio pour 24 Gio de RAM : jusqu'à 32 points de reprise (~150 Mio chacun) prennent sur le cache de pages des poids. CTX_CHECKPOINTS=8 à 16 si le débit baisse.", + "moteur b9000 : les points de reprise des modèles hybrides (Qwen3.5/3.6, Qwen3-Next…) y sont évincés trop tôt — jusqu'à ~8 k jetons recalculés à chaque reprise. Corrigé à partir de b10864. Pas d'espacement resserré d'office : RAM inconnue ou déjà prise par le modèle (CKPT_MIN_STEP=2048 pour l'imposer)." + ] + }, + { + "m": 3, + "c": 32, + "h": 0, + "e": 0, + "args": [ + "/app/llama-server", + "-m", + "/m/hybrid.gguf", + "-c", + "16384", + "-b", + "2048", + "-ub", + "512", + "--host", + "0.0.0.0", + "--port", + "8080", + "--parallel", + "1", + "-ngl", + "auto" + ], + "env": {}, + "notes": [ + "SPLIT_MODE=tensor refusé (cartes inconnues (--list-devices n'a rien répondu)) : découpe par couches, comme sans la clé", + "modèle hybride de 20 Gio pour 24 Gio de RAM : jusqu'à 32 points de reprise (~150 Mio chacun) prennent sur le cache de pages des poids. CTX_CHECKPOINTS=8 à 16 si le débit baisse.", + "moteur b9000 : les points de reprise des modèles hybrides (Qwen3.5/3.6, Qwen3-Next…) y sont évincés trop tôt — jusqu'à ~8 k jetons recalculés à chaque reprise. Corrigé à partir de b10864. Pas d'espacement resserré d'office : RAM inconnue ou déjà prise par le modèle (CKPT_MIN_STEP=2048 pour l'imposer)." + ] + }, + { + "m": 3, + "c": 33, + "h": 0, + "e": 0, + "args": [ + "/app/llama-server", + "-m", + "/m/hybrid.gguf", + "-c", + "32768", + "-b", + "2048", + "-ub", + "512", + "--host", + "0.0.0.0", + "--port", + "8080", + "--parallel", + "1", + "-ngl", + "auto" + ], + "env": {}, + "notes": [ + "SPLIT_MODE=tensor refusé (SPLIT_MODE=bizarre illisible (tensor ou off)) : découpe par couches, comme sans la clé", + "modèle hybride de 20 Gio pour 24 Gio de RAM : jusqu'à 32 points de reprise (~150 Mio chacun) prennent sur le cache de pages des poids. CTX_CHECKPOINTS=8 à 16 si le débit baisse.", + "moteur b9000 : les points de reprise des modèles hybrides (Qwen3.5/3.6, Qwen3-Next…) y sont évincés trop tôt — jusqu'à ~8 k jetons recalculés à chaque reprise. Corrigé à partir de b10864. Pas d'espacement resserré d'office : RAM inconnue ou déjà prise par le modèle (CKPT_MIN_STEP=2048 pour l'imposer)." + ] + }, + { + "m": 3, + "c": 34, + "h": 0, + "e": 0, + "args": [ + "/app/llama-server", + "-m", + "/m/hybrid.gguf", + "-c", + "32768", + "-b", + "2048", + "-ub", + "512", + "--host", + "0.0.0.0", + "--port", + "8080", + "--parallel", + "1", + "-ngl", + "auto" + ], + "env": {}, + "notes": [ + "modèle hybride de 20 Gio pour 24 Gio de RAM : jusqu'à 32 points de reprise (~150 Mio chacun) prennent sur le cache de pages des poids. CTX_CHECKPOINTS=8 à 16 si le débit baisse.", + "moteur b9000 : les points de reprise des modèles hybrides (Qwen3.5/3.6, Qwen3-Next…) y sont évincés trop tôt — jusqu'à ~8 k jetons recalculés à chaque reprise. Corrigé à partir de b10864. Pas d'espacement resserré d'office : RAM inconnue ou déjà prise par le modèle (CKPT_MIN_STEP=2048 pour l'imposer)." + ] + }, + { + "m": 3, + "c": 35, + "h": 0, + "e": 0, + "args": [ + "/app/llama-server", + "-m", + "/m/hybrid.gguf", + "-c", + "32768", + "-b", + "2048", + "-ub", + "512", + "--host", + "0.0.0.0", + "--port", + "8080", + "--parallel", + "1", + "-ngl", + "auto", + "-ctk", + "q8_0", + "-ctv", + "q8_0" + ], + "env": {}, + "notes": [ + "SPLIT_MODE=tensor refusé (cache KV q8_0/q8_0 : le mode tensor n'accepte que f16, bf16 ou f32 (jamais réécrit d'office : la VRAM changerait)) : découpe par couches, comme sans la clé", + "modèle hybride de 20 Gio pour 24 Gio de RAM : jusqu'à 32 points de reprise (~150 Mio chacun) prennent sur le cache de pages des poids. CTX_CHECKPOINTS=8 à 16 si le débit baisse.", + "moteur b9000 : les points de reprise des modèles hybrides (Qwen3.5/3.6, Qwen3-Next…) y sont évincés trop tôt — jusqu'à ~8 k jetons recalculés à chaque reprise. Corrigé à partir de b10864. Pas d'espacement resserré d'office : RAM inconnue ou déjà prise par le modèle (CKPT_MIN_STEP=2048 pour l'imposer).", + "cache KV q8_0/q8_0 (KV_TYPE) : modifie légèrement les sorties par rapport à f16 — choix de l'utilisateur, laissé tel quel." + ] + }, + { + "m": 3, + "c": 36, + "h": 0, + "e": 0, + "args": [ + "/app/llama-server", + "-m", + "/m/hybrid.gguf", + "-c", + "32768", + "-b", + "2048", + "-ub", + "512", + "--host", + "0.0.0.0", + "--port", + "8080", + "--parallel", + "1", + "-ngl", + "20" + ], + "env": {}, + "notes": [ + "SPLIT_MODE=tensor refusé (NGL=20 : le mode tensor veut toutes les couches sur les cartes (all)) : découpe par couches, comme sans la clé", + "modèle hybride de 20 Gio pour 24 Gio de RAM : jusqu'à 32 points de reprise (~150 Mio chacun) prennent sur le cache de pages des poids. CTX_CHECKPOINTS=8 à 16 si le débit baisse.", + "moteur b9000 : les points de reprise des modèles hybrides (Qwen3.5/3.6, Qwen3-Next…) y sont évincés trop tôt — jusqu'à ~8 k jetons recalculés à chaque reprise. Corrigé à partir de b10864. Pas d'espacement resserré d'office : RAM inconnue ou déjà prise par le modèle (CKPT_MIN_STEP=2048 pour l'imposer)." + ] + }, + { + "m": 3, + "c": 37, + "h": 0, + "e": 0, + "args": [ + "/app/llama-server", + "-m", + "/m/hybrid.gguf", + "-c", + "32768", + "-b", + "2048", + "-ub", + "512", + "--host", + "0.0.0.0", + "--port", + "8080", + "--parallel", + "1", + "-ngl", + "auto" + ], + "env": {}, + "notes": [ + "modèle hybride de 20 Gio pour 24 Gio de RAM : jusqu'à 32 points de reprise (~150 Mio chacun) prennent sur le cache de pages des poids. CTX_CHECKPOINTS=8 à 16 si le débit baisse.", + "moteur b9000 : les points de reprise des modèles hybrides (Qwen3.5/3.6, Qwen3-Next…) y sont évincés trop tôt — jusqu'à ~8 k jetons recalculés à chaque reprise. Corrigé à partir de b10864. Pas d'espacement resserré d'office : RAM inconnue ou déjà prise par le modèle (CKPT_MIN_STEP=2048 pour l'imposer).", + "SPEC=auto : moteur b9000, b11009 au moins — sans décodage spéculatif" + ] + }, + { + "m": 3, + "c": 38, + "h": 0, + "e": 0, + "args": [ + "/app/llama-server", + "-m", + "/m/hybrid.gguf", + "-c", + "32768", + "-b", + "2048", + "-ub", + "512", + "--host", + "0.0.0.0", + "--port", + "8080", + "--parallel", + "1", + "-ngl", + "auto", + "--spec-type", + "draft-mtp", + "--spec-draft-sampling", + "greedy" + ], + "env": {}, + "notes": [ + "modèle hybride de 20 Gio pour 24 Gio de RAM : jusqu'à 32 points de reprise (~150 Mio chacun) prennent sur le cache de pages des poids. CTX_CHECKPOINTS=8 à 16 si le débit baisse.", + "moteur b9000 : les points de reprise des modèles hybrides (Qwen3.5/3.6, Qwen3-Next…) y sont évincés trop tôt — jusqu'à ~8 k jetons recalculés à chaque reprise. Corrigé à partir de b10864. Pas d'espacement resserré d'office : RAM inconnue ou déjà prise par le modèle (CKPT_MIN_STEP=2048 pour l'imposer).", + "SPEC=mtp → tête MTP imposé : sortie inchangée, ~1-2 Go de VRAM en plus" + ] + }, + { + "m": 3, + "c": 39, + "h": 0, + "e": 0, + "args": [ + "/app/llama-server", + "-m", + "/m/hybrid.gguf", + "-c", + "32768", + "-b", + "2048", + "-ub", + "512", + "--host", + "0.0.0.0", + "--port", + "8080", + "--parallel", + "1", + "-ngl", + "auto", + "--spec-type", + "ngram-mod", + "--spec-ngram-mod-n-match", + "24", + "--spec-ngram-mod-n-min", + "48", + "--spec-ngram-mod-n-max", + "64" + ], + "env": {}, + "notes": [ + "modèle hybride de 20 Gio pour 24 Gio de RAM : jusqu'à 32 points de reprise (~150 Mio chacun) prennent sur le cache de pages des poids. CTX_CHECKPOINTS=8 à 16 si le débit baisse.", + "moteur b9000 : les points de reprise des modèles hybrides (Qwen3.5/3.6, Qwen3-Next…) y sont évincés trop tôt — jusqu'à ~8 k jetons recalculés à chaque reprise. Corrigé à partir de b10864. Pas d'espacement resserré d'office : RAM inconnue ou déjà prise par le modèle (CKPT_MIN_STEP=2048 pour l'imposer).", + "SPEC=ngram → n-grammes du contexte (ngram-mod, brouillons de 48 à 64 jetons) : distribution inchangée (chaque jeton vérifié), ~16 Mo de RAM ; utile surtout en mode code, à mesurer (acceptation par nature dans /api/perf/summary)", + "tête MTP du modèle inutilisée avec SPEC=ngram : SPEC=mtp+ngram pour les deux", + "modèle hybride : chaque brouillon n-gramme copie l'état récurrent (point de reprise en RAM hôte) et le recharge s'il est rejeté — compare le temps par tour, pas seulement le débit" + ] + }, + { + "m": 3, + "c": 40, + "h": 0, + "e": 0, + "args": [ + "/app/llama-server", + "-m", + "/m/hybrid.gguf", + "-c", + "32768", + "-b", + "2048", + "-ub", + "512", + "--host", + "0.0.0.0", + "--port", + "8080", + "--parallel", + "1", + "-ngl", + "auto", + "--spec-type", + "draft-mtp,ngram-mod", + "--spec-ngram-mod-n-match", + "24", + "--spec-ngram-mod-n-min", + "48", + "--spec-ngram-mod-n-max", + "64", + "--spec-draft-sampling", + "greedy" + ], + "env": {}, + "notes": [ + "modèle hybride de 20 Gio pour 24 Gio de RAM : jusqu'à 32 points de reprise (~150 Mio chacun) prennent sur le cache de pages des poids. CTX_CHECKPOINTS=8 à 16 si le débit baisse.", + "moteur b9000 : les points de reprise des modèles hybrides (Qwen3.5/3.6, Qwen3-Next…) y sont évincés trop tôt — jusqu'à ~8 k jetons recalculés à chaque reprise. Corrigé à partir de b10864. Pas d'espacement resserré d'office : RAM inconnue ou déjà prise par le modèle (CKPT_MIN_STEP=2048 pour l'imposer).", + "SPEC=mtp+ngram → tête MTP imposé : sortie inchangée, ~1-2 Go de VRAM en plus ; n-grammes en plus (ngram-mod, prioritaires quand ils trouvent une répétition)", + "modèle hybride : chaque brouillon n-gramme copie l'état récurrent (point de reprise en RAM hôte) et le recharge s'il est rejeté — compare le temps par tour, pas seulement le débit" + ] + }, + { + "m": 3, + "c": 41, + "h": 0, + "e": 0, + "args": [ + "/app/llama-server", + "-m", + "/m/hybrid.gguf", + "-c", + "32768", + "-b", + "2048", + "-ub", + "512", + "--host", + "0.0.0.0", + "--port", + "8080", + "--parallel", + "1", + "-ngl", + "auto", + "--spec-type", + "draft-mtp,ngram-mod", + "--spec-ngram-mod-n-match", + "24", + "--spec-ngram-mod-n-min", + "48", + "--spec-ngram-mod-n-max", + "64", + "--spec-draft-sampling", + "greedy" + ], + "env": {}, + "notes": [ + "modèle hybride de 20 Gio pour 24 Gio de RAM : jusqu'à 32 points de reprise (~150 Mio chacun) prennent sur le cache de pages des poids. CTX_CHECKPOINTS=8 à 16 si le débit baisse.", + "moteur b9000 : les points de reprise des modèles hybrides (Qwen3.5/3.6, Qwen3-Next…) y sont évincés trop tôt — jusqu'à ~8 k jetons recalculés à chaque reprise. Corrigé à partir de b10864. Pas d'espacement resserré d'office : RAM inconnue ou déjà prise par le modèle (CKPT_MIN_STEP=2048 pour l'imposer).", + "SPEC=mtp+ngram → tête MTP imposé : sortie inchangée, ~1-2 Go de VRAM en plus ; n-grammes en plus (ngram-mod, prioritaires quand ils trouvent une répétition)", + "modèle hybride : chaque brouillon n-gramme copie l'état récurrent (point de reprise en RAM hôte) et le recharge s'il est rejeté — compare le temps par tour, pas seulement le débit" + ] + }, + { + "m": 3, + "c": 42, + "h": 0, + "e": 0, + "args": [ + "/app/llama-server", + "-m", + "/m/hybrid.gguf", + "-c", + "32768", + "-b", + "2048", + "-ub", + "512", + "--host", + "0.0.0.0", + "--port", + "8080", + "--parallel", + "1", + "-ngl", + "auto" + ], + "env": {}, + "notes": [ + "modèle hybride de 20 Gio pour 24 Gio de RAM : jusqu'à 32 points de reprise (~150 Mio chacun) prennent sur le cache de pages des poids. CTX_CHECKPOINTS=8 à 16 si le débit baisse.", + "moteur b9000 : les points de reprise des modèles hybrides (Qwen3.5/3.6, Qwen3-Next…) y sont évincés trop tôt — jusqu'à ~8 k jetons recalculés à chaque reprise. Corrigé à partir de b10864. Pas d'espacement resserré d'office : RAM inconnue ou déjà prise par le modèle (CKPT_MIN_STEP=2048 pour l'imposer).", + "SPEC=turbo illisible (off, auto, mtp, ngram ou mtp+ngram) : sans décodage spéculatif" + ] + }, + { + "m": 3, + "c": 43, + "h": 0, + "e": 0, + "args": [ + "/app/llama-server", + "-m", + "/m/hybrid.gguf", + "-c", + "32768", + "-b", + "2048", + "-ub", + "512", + "--host", + "0.0.0.0", + "--port", + "8080", + "--parallel", + "1", + "-ngl", + "auto" + ], + "env": {}, + "notes": [ + "modèle hybride de 20 Gio pour 24 Gio de RAM : jusqu'à 32 points de reprise (~150 Mio chacun) prennent sur le cache de pages des poids. CTX_CHECKPOINTS=8 à 16 si le débit baisse.", + "moteur b9000 : les points de reprise des modèles hybrides (Qwen3.5/3.6, Qwen3-Next…) y sont évincés trop tôt — jusqu'à ~8 k jetons recalculés à chaque reprise. Corrigé à partir de b10864. Pas d'espacement resserré d'office : RAM inconnue ou déjà prise par le modèle (CKPT_MIN_STEP=2048 pour l'imposer).", + "MODEL_DRAFT ignoré : SPEC=off (auto ou mtp pour s'en servir)" + ] + }, + { + "m": 3, + "c": 44, + "h": 0, + "e": 0, + "args": [ + "/app/llama-server", + "-m", + "/m/hybrid.gguf", + "-c", + "32768", + "-b", + "2048", + "-ub", + "512", + "--host", + "0.0.0.0", + "--port", + "8080", + "--parallel", + "1", + "-ngl", + "auto" + ], + "env": {}, + "notes": [ + "modèle hybride de 20 Gio pour 24 Gio de RAM : jusqu'à 32 points de reprise (~150 Mio chacun) prennent sur le cache de pages des poids. CTX_CHECKPOINTS=8 à 16 si le débit baisse.", + "moteur b9000 : les points de reprise des modèles hybrides (Qwen3.5/3.6, Qwen3-Next…) y sont évincés trop tôt — jusqu'à ~8 k jetons recalculés à chaque reprise. Corrigé à partir de b10864. Pas d'espacement resserré d'office : RAM inconnue ou déjà prise par le modèle (CKPT_MIN_STEP=2048 pour l'imposer).", + "SPEC=mtp : MODEL_DRAFT=mtp-head.gguf introuvable — sans décodage spéculatif" + ] + }, + { + "m": 3, + "c": 45, + "h": 0, + "e": 0, + "args": [ + "/app/llama-server", + "-m", + "/m/hybrid.gguf", + "-c", + "32768", + "-b", + "2048", + "-ub", + "512", + "--host", + "0.0.0.0", + "--port", + "8080", + "--parallel", + "1", + "-ngl", + "auto", + "--spec-type", + "draft-mtp", + "--spec-draft-sampling", + "probabilistic" + ], + "env": {}, + "notes": [ + "modèle hybride de 20 Gio pour 24 Gio de RAM : jusqu'à 32 points de reprise (~150 Mio chacun) prennent sur le cache de pages des poids. CTX_CHECKPOINTS=8 à 16 si le débit baisse.", + "moteur b9000 : les points de reprise des modèles hybrides (Qwen3.5/3.6, Qwen3-Next…) y sont évincés trop tôt — jusqu'à ~8 k jetons recalculés à chaque reprise. Corrigé à partir de b10864. Pas d'espacement resserré d'office : RAM inconnue ou déjà prise par le modèle (CKPT_MIN_STEP=2048 pour l'imposer).", + "SPEC=mtp → tête MTP imposé : sortie inchangée, ~1-2 Go de VRAM en plus", + "SPEC_N_MAX=99 illisible (1 à 64 jetons) : défaut du moteur" + ] + }, + { + "m": 3, + "c": 46, + "h": 0, + "e": 0, + "args": [ + "/app/llama-server", + "-m", + "/m/hybrid.gguf", + "-c", + "32768", + "-b", + "2048", + "-ub", + "512", + "--host", + "0.0.0.0", + "--port", + "8080", + "--parallel", + "1", + "-ngl", + "auto" + ], + "env": {}, + "notes": [ + "modèle hybride de 20 Gio pour 24 Gio de RAM : jusqu'à 32 points de reprise (~150 Mio chacun) prennent sur le cache de pages des poids. CTX_CHECKPOINTS=8 à 16 si le débit baisse.", + "moteur b9000 : les points de reprise des modèles hybrides (Qwen3.5/3.6, Qwen3-Next…) y sont évincés trop tôt — jusqu'à ~8 k jetons recalculés à chaque reprise. Corrigé à partir de b10864. Pas d'espacement resserré d'office : RAM inconnue ou déjà prise par le modèle (CKPT_MIN_STEP=2048 pour l'imposer).", + "SPEC=auto : moteur b9000, b11009 au moins — sans décodage spéculatif" + ] + }, + { + "m": 3, + "c": 47, + "h": 0, + "e": 0, + "args": [ + "/app/llama-server", + "-m", + "/m/hybrid.gguf", + "-c", + "32768", + "-b", + "2048", + "-ub", + "512", + "--host", + "0.0.0.0", + "--port", + "8080", + "--parallel", + "1", + "-ngl", + "auto", + "--spec-type", + "draft-mtp", + "--spec-draft-sampling", + "greedy" + ], + "env": {}, + "notes": [ + "modèle hybride de 20 Gio pour 24 Gio de RAM : jusqu'à 32 points de reprise (~150 Mio chacun) prennent sur le cache de pages des poids. CTX_CHECKPOINTS=8 à 16 si le débit baisse.", + "moteur b9000 : les points de reprise des modèles hybrides (Qwen3.5/3.6, Qwen3-Next…) y sont évincés trop tôt — jusqu'à ~8 k jetons recalculés à chaque reprise. Corrigé à partir de b10864. Pas d'espacement resserré d'office : RAM inconnue ou déjà prise par le modèle (CKPT_MIN_STEP=2048 pour l'imposer).", + "SPEC=mtp → tête MTP imposé : sortie inchangée, ~1-2 Go de VRAM en plus", + "SPEC_SAMPLING=aléatoire illisible (greedy ou probabilistic) : greedy" + ] + }, + { + "m": 3, + "c": 48, + "h": 0, + "e": 0, + "args": [ + "/app/llama-server", + "-m", + "/m/hybrid.gguf", + "-c", + "32768", + "-b", + "2048", + "-ub", + "512", + "--host", + "0.0.0.0", + "--port", + "8080", + "--parallel", + "1", + "-ngl", + "auto", + "--spec-type", + "ngram-mod", + "--spec-ngram-mod-n-match", + "24", + "--spec-ngram-mod-n-min", + "48", + "--spec-ngram-mod-n-max", + "64" + ], + "env": {}, + "notes": [ + "modèle hybride de 20 Gio pour 24 Gio de RAM : jusqu'à 32 points de reprise (~150 Mio chacun) prennent sur le cache de pages des poids. CTX_CHECKPOINTS=8 à 16 si le débit baisse.", + "moteur b9000 : les points de reprise des modèles hybrides (Qwen3.5/3.6, Qwen3-Next…) y sont évincés trop tôt — jusqu'à ~8 k jetons recalculés à chaque reprise. Corrigé à partir de b10864. Pas d'espacement resserré d'office : RAM inconnue ou déjà prise par le modèle (CKPT_MIN_STEP=2048 pour l'imposer).", + "SPEC=ngram → n-grammes du contexte (ngram-mod, brouillons de 48 à 64 jetons) : distribution inchangée (chaque jeton vérifié), ~16 Mo de RAM ; utile surtout en mode code, à mesurer (acceptation par nature dans /api/perf/summary)", + "MODEL_DRAFT ignoré avec SPEC=ngram : SPEC=mtp+ngram pour les deux", + "SPEC_N_MAX et SPEC_SAMPLING ignorés : les n-grammes n'ont pas de brouillon à régler", + "modèle hybride : chaque brouillon n-gramme copie l'état récurrent (point de reprise en RAM hôte) et le recharge s'il est rejeté — compare le temps par tour, pas seulement le débit" + ] + }, + { + "m": 3, + "c": 49, + "h": 0, + "e": 0, + "args": [ + "/app/llama-server", + "-m", + "/m/hybrid.gguf", + "-c", + "32768", + "-b", + "2048", + "-ub", + "512", + "--host", + "0.0.0.0", + "--port", + "8080", + "--parallel", + "1", + "-ngl", + "auto" + ], + "env": {}, + "notes": [ + "modèle hybride de 20 Gio pour 24 Gio de RAM : jusqu'à 32 points de reprise (~150 Mio chacun) prennent sur le cache de pages des poids. CTX_CHECKPOINTS=8 à 16 si le débit baisse.", + "moteur b9000 : les points de reprise des modèles hybrides (Qwen3.5/3.6, Qwen3-Next…) y sont évincés trop tôt — jusqu'à ~8 k jetons recalculés à chaque reprise. Corrigé à partir de b10864. Pas d'espacement resserré d'office : RAM inconnue ou déjà prise par le modèle (CKPT_MIN_STEP=2048 pour l'imposer).", + "SPEC=auto : MODEL_DRAFT=small.gguf introuvable — sans décodage spéculatif" + ] + }, + { + "m": 3, + "c": 50, + "h": 0, + "e": 0, + "args": [ + "/app/llama-server", + "-m", + "/m/hybrid.gguf", + "-c", + "32768", + "-b", + "2048", + "-ub", + "512", + "--host", + "0.0.0.0", + "--port", + "8080", + "--parallel", + "1", + "-ngl", + "auto", + "--spec-type", + "ngram-mod", + "--spec-ngram-mod-n-match", + "24", + "--spec-ngram-mod-n-min", + "48", + "--spec-ngram-mod-n-max", + "64" + ], + "env": {}, + "notes": [ + "modèle hybride de 20 Gio pour 24 Gio de RAM : jusqu'à 32 points de reprise (~150 Mio chacun) prennent sur le cache de pages des poids. CTX_CHECKPOINTS=8 à 16 si le débit baisse.", + "moteur b9000 : les points de reprise des modèles hybrides (Qwen3.5/3.6, Qwen3-Next…) y sont évincés trop tôt — jusqu'à ~8 k jetons recalculés à chaque reprise. Corrigé à partir de b10864. Pas d'espacement resserré d'office : RAM inconnue ou déjà prise par le modèle (CKPT_MIN_STEP=2048 pour l'imposer).", + "SPEC=mtp+ngram : sans MTP (MODEL_DRAFT=small.gguf introuvable) → n-grammes seuls. SPEC=ngram → n-grammes du contexte (ngram-mod, brouillons de 48 à 64 jetons) : distribution inchangée (chaque jeton vérifié), ~16 Mo de RAM ; utile surtout en mode code, à mesurer (acceptation par nature dans /api/perf/summary)", + "modèle hybride : chaque brouillon n-gramme copie l'état récurrent (point de reprise en RAM hôte) et le recharge s'il est rejeté — compare le temps par tour, pas seulement le débit" + ] + }, + { + "m": 3, + "c": 51, + "h": 0, + "e": 0, + "args": [ + "/app/llama-server", + "-m", + "/m/hybrid.gguf", + "-c", + "32768", + "-b", + "2048", + "-ub", + "512", + "--host", + "0.0.0.0", + "--port", + "8080", + "--parallel", + "1", + "-ngl", + "auto" + ], + "env": {}, + "notes": [ + "modèle hybride de 20 Gio pour 24 Gio de RAM : jusqu'à 32 points de reprise (~150 Mio chacun) prennent sur le cache de pages des poids. CTX_CHECKPOINTS=8 à 16 si le débit baisse.", + "moteur b9000 : les points de reprise des modèles hybrides (Qwen3.5/3.6, Qwen3-Next…) y sont évincés trop tôt — jusqu'à ~8 k jetons recalculés à chaque reprise. Corrigé à partir de b10864. Pas d'espacement resserré d'office : RAM inconnue ou déjà prise par le modèle (CKPT_MIN_STEP=2048 pour l'imposer)." + ] + }, + { + "m": 3, + "c": 52, + "h": 0, + "e": 0, + "args": [ + "/app/llama-server", + "-m", + "/m/hybrid.gguf", + "-c", + "32768", + "-b", + "2048", + "-ub", + "512", + "--host", + "0.0.0.0", + "--port", + "8080", + "--parallel", + "1", + "-ngl", + "auto" + ], + "env": { + "CUDA_SCALE_LAUNCH_QUEUES": "2x" + }, + "notes": [ + "CUDA_SCALE_LAUNCH_QUEUES=2x (CUDA_LAUNCH_QUEUES)", + "modèle hybride de 20 Gio pour 24 Gio de RAM : jusqu'à 32 points de reprise (~150 Mio chacun) prennent sur le cache de pages des poids. CTX_CHECKPOINTS=8 à 16 si le débit baisse.", + "moteur b9000 : les points de reprise des modèles hybrides (Qwen3.5/3.6, Qwen3-Next…) y sont évincés trop tôt — jusqu'à ~8 k jetons recalculés à chaque reprise. Corrigé à partir de b10864. Pas d'espacement resserré d'office : RAM inconnue ou déjà prise par le modèle (CKPT_MIN_STEP=2048 pour l'imposer)." + ] + }, + { + "m": 3, + "c": 53, + "h": 0, + "e": 0, + "args": [ + "/app/llama-server", + "-m", + "/m/hybrid.gguf", + "-c", + "32768", + "-b", + "2048", + "-ub", + "512", + "--host", + "0.0.0.0", + "--port", + "8080", + "--parallel", + "1", + "-ngl", + "auto" + ], + "env": {}, + "notes": [ + "CUDA_LAUNCH_QUEUES=8x ignoré (0.25x, 0.5x, 2x, 4x ou off) → automatique", + "modèle hybride de 20 Gio pour 24 Gio de RAM : jusqu'à 32 points de reprise (~150 Mio chacun) prennent sur le cache de pages des poids. CTX_CHECKPOINTS=8 à 16 si le débit baisse.", + "moteur b9000 : les points de reprise des modèles hybrides (Qwen3.5/3.6, Qwen3-Next…) y sont évincés trop tôt — jusqu'à ~8 k jetons recalculés à chaque reprise. Corrigé à partir de b10864. Pas d'espacement resserré d'office : RAM inconnue ou déjà prise par le modèle (CKPT_MIN_STEP=2048 pour l'imposer)." + ] + }, + { + "m": 3, + "c": 54, + "h": 0, + "e": 0, + "args": [ + "/app/llama-server", + "-m", + "/m/hybrid.gguf", + "-c", + "32768", + "-b", + "2048", + "-ub", + "512", + "--host", + "0.0.0.0", + "--port", + "8080", + "--parallel", + "1", + "-ngl", + "auto" + ], + "env": {}, + "notes": [ + "modèle hybride de 20 Gio pour 24 Gio de RAM : jusqu'à 32 points de reprise (~150 Mio chacun) prennent sur le cache de pages des poids. CTX_CHECKPOINTS=8 à 16 si le débit baisse.", + "moteur b9000 : les points de reprise des modèles hybrides (Qwen3.5/3.6, Qwen3-Next…) y sont évincés trop tôt — jusqu'à ~8 k jetons recalculés à chaque reprise. Corrigé à partir de b10864. Pas d'espacement resserré d'office : RAM inconnue ou déjà prise par le modèle (CKPT_MIN_STEP=2048 pour l'imposer)." + ] + }, + { + "m": 3, + "c": 55, + "h": 0, + "e": 0, + "args": [ + "/app/llama-server", + "-m", + "/m/hybrid.gguf", + "-c", + "32768", + "-b", + "2048", + "-ub", + "512", + "--host", + "0.0.0.0", + "--port", + "8080", + "--parallel", + "1", + "-ngl", + "auto" + ], + "env": { + "CUDA_DEVICE_ORDER": "PCI_BUS_ID", + "CUDA_VISIBLE_DEVICES": "1,0" + }, + "notes": [ + "modèle hybride de 20 Gio pour 24 Gio de RAM : jusqu'à 32 points de reprise (~150 Mio chacun) prennent sur le cache de pages des poids. CTX_CHECKPOINTS=8 à 16 si le débit baisse.", + "moteur b9000 : les points de reprise des modèles hybrides (Qwen3.5/3.6, Qwen3-Next…) y sont évincés trop tôt — jusqu'à ~8 k jetons recalculés à chaque reprise. Corrigé à partir de b10864. Pas d'espacement resserré d'office : RAM inconnue ou déjà prise par le modèle (CKPT_MIN_STEP=2048 pour l'imposer)." + ] + }, + { + "m": 3, + "c": 56, + "h": 0, + "e": 0, + "args": [ + "/app/llama-server", + "-m", + "/m/hybrid.gguf", + "-c", + "32768", + "-b", + "2048", + "-ub", + "512", + "--host", + "0.0.0.0", + "--port", + "8080", + "--parallel", + "1", + "-ngl", + "auto", + "--fit-target", + "1024,3072" + ], + "env": {}, + "notes": [ + "--fit-target 1024,3072 (FIT_TARGET, Mio libres par carte, dans l'ordre vu par le moteur) — vérifie « offloaded N/N layers » dans le journal", + "modèle hybride de 20 Gio pour 24 Gio de RAM : jusqu'à 32 points de reprise (~150 Mio chacun) prennent sur le cache de pages des poids. CTX_CHECKPOINTS=8 à 16 si le débit baisse.", + "moteur b9000 : les points de reprise des modèles hybrides (Qwen3.5/3.6, Qwen3-Next…) y sont évincés trop tôt — jusqu'à ~8 k jetons recalculés à chaque reprise. Corrigé à partir de b10864. Pas d'espacement resserré d'office : RAM inconnue ou déjà prise par le modèle (CKPT_MIN_STEP=2048 pour l'imposer)." + ] + }, + { + "m": 3, + "c": 57, + "h": 0, + "e": 0, + "args": [ + "/app/llama-server", + "-m", + "/m/hybrid.gguf", + "-c", + "32768", + "-b", + "2048", + "-ub", + "512", + "--host", + "0.0.0.0", + "--port", + "8080", + "--parallel", + "1", + "-ngl", + "auto", + "--fit-target", + "1024" + ], + "env": {}, + "notes": [ + "--fit-target 1024 (FIT_TARGET, Mio libres par carte, dans l'ordre vu par le moteur) — vérifie « offloaded N/N layers » dans le journal ; marges sous 1024 Mio remontées à 1024", + "modèle hybride de 20 Gio pour 24 Gio de RAM : jusqu'à 32 points de reprise (~150 Mio chacun) prennent sur le cache de pages des poids. CTX_CHECKPOINTS=8 à 16 si le débit baisse.", + "moteur b9000 : les points de reprise des modèles hybrides (Qwen3.5/3.6, Qwen3-Next…) y sont évincés trop tôt — jusqu'à ~8 k jetons recalculés à chaque reprise. Corrigé à partir de b10864. Pas d'espacement resserré d'office : RAM inconnue ou déjà prise par le modèle (CKPT_MIN_STEP=2048 pour l'imposer)." + ] + }, + { + "m": 3, + "c": 58, + "h": 0, + "e": 0, + "args": [ + "/app/llama-server", + "-m", + "/m/hybrid.gguf", + "-c", + "32768", + "-b", + "2048", + "-ub", + "512", + "--host", + "0.0.0.0", + "--port", + "8080", + "--parallel", + "1", + "-ngl", + "auto" + ], + "env": {}, + "notes": [ + "FIT_TARGET=1024,,3072 ignoré : des Mio par carte, ex. 1024,3072 (8 valeurs au plus)", + "modèle hybride de 20 Gio pour 24 Gio de RAM : jusqu'à 32 points de reprise (~150 Mio chacun) prennent sur le cache de pages des poids. CTX_CHECKPOINTS=8 à 16 si le débit baisse.", + "moteur b9000 : les points de reprise des modèles hybrides (Qwen3.5/3.6, Qwen3-Next…) y sont évincés trop tôt — jusqu'à ~8 k jetons recalculés à chaque reprise. Corrigé à partir de b10864. Pas d'espacement resserré d'office : RAM inconnue ou déjà prise par le modèle (CKPT_MIN_STEP=2048 pour l'imposer)." + ] + }, + { + "m": 3, + "c": 59, + "h": 0, + "e": 0, + "args": [ + "/app/llama-server", + "-m", + "/m/hybrid.gguf", + "-c", + "32768", + "-b", + "2048", + "-ub", + "512", + "--host", + "0.0.0.0", + "--port", + "8080", + "--parallel", + "1", + "-ngl", + "auto" + ], + "env": {}, + "notes": [ + "FIT_TARGET=1,2,3,4,5,6,7,8,9 ignoré : des Mio par carte, ex. 1024,3072 (8 valeurs au plus)", + "modèle hybride de 20 Gio pour 24 Gio de RAM : jusqu'à 32 points de reprise (~150 Mio chacun) prennent sur le cache de pages des poids. CTX_CHECKPOINTS=8 à 16 si le débit baisse.", + "moteur b9000 : les points de reprise des modèles hybrides (Qwen3.5/3.6, Qwen3-Next…) y sont évincés trop tôt — jusqu'à ~8 k jetons recalculés à chaque reprise. Corrigé à partir de b10864. Pas d'espacement resserré d'office : RAM inconnue ou déjà prise par le modèle (CKPT_MIN_STEP=2048 pour l'imposer)." + ] + }, + { + "m": 3, + "c": 60, + "h": 0, + "e": 0, + "args": [ + "/app/llama-server", + "-m", + "/m/hybrid.gguf", + "-c", + "0", + "-b", + "2048", + "-ub", + "512", + "--host", + "0.0.0.0", + "--port", + "8080", + "--parallel", + "1", + "-ngl", + "auto" + ], + "env": {}, + "notes": [ + "FIT_TARGET=2048/4096 ignoré : contexte « 0 » non fixé — --fit pourrait le réduire pour tenir ; donne un CTX chiffré", + "modèle hybride de 20 Gio pour 24 Gio de RAM : jusqu'à 32 points de reprise (~150 Mio chacun) prennent sur le cache de pages des poids. CTX_CHECKPOINTS=8 à 16 si le débit baisse.", + "moteur b9000 : les points de reprise des modèles hybrides (Qwen3.5/3.6, Qwen3-Next…) y sont évincés trop tôt — jusqu'à ~8 k jetons recalculés à chaque reprise. Corrigé à partir de b10864. Pas d'espacement resserré d'office : RAM inconnue ou déjà prise par le modèle (CKPT_MIN_STEP=2048 pour l'imposer)." + ] + }, + { + "m": 3, + "c": 61, + "h": 0, + "e": 0, + "args": [ + "/app/llama-server", + "-m", + "/m/hybrid.gguf", + "-c", + "32768", + "-b", + "2048", + "-ub", + "512", + "--host", + "0.0.0.0", + "--port", + "8080", + "--parallel", + "1", + "-ngl", + "30" + ], + "env": {}, + "notes": [ + "FIT_TARGET=2048 ignoré : --fit inactif (couches GPU fixées à 30), la marge ne servirait à rien", + "modèle hybride de 20 Gio pour 24 Gio de RAM : jusqu'à 32 points de reprise (~150 Mio chacun) prennent sur le cache de pages des poids. CTX_CHECKPOINTS=8 à 16 si le débit baisse.", + "moteur b9000 : les points de reprise des modèles hybrides (Qwen3.5/3.6, Qwen3-Next…) y sont évincés trop tôt — jusqu'à ~8 k jetons recalculés à chaque reprise. Corrigé à partir de b10864. Pas d'espacement resserré d'office : RAM inconnue ou déjà prise par le modèle (CKPT_MIN_STEP=2048 pour l'imposer)." + ] + }, + { + "m": 3, + "c": 62, + "h": 0, + "e": 0, + "args": [ + "/app/llama-server", + "-m", + "/m/hybrid.gguf", + "-c", + "32768", + "-b", + "2048", + "-ub", + "512", + "--host", + "0.0.0.0", + "--port", + "8080", + "--parallel", + "1", + "-ngl", + "auto" + ], + "env": { + "GGML_OP_OFFLOAD_MIN_BATCH": "128" + }, + "notes": [ + "GGML_OP_OFFLOAD_MIN_BATCH=128 (OP_OFFLOAD_MIN_BATCH) : lots de moins de 128 jetons calculés sur CPU pour les poids restés en RAM", + "modèle hybride de 20 Gio pour 24 Gio de RAM : jusqu'à 32 points de reprise (~150 Mio chacun) prennent sur le cache de pages des poids. CTX_CHECKPOINTS=8 à 16 si le débit baisse.", + "moteur b9000 : les points de reprise des modèles hybrides (Qwen3.5/3.6, Qwen3-Next…) y sont évincés trop tôt — jusqu'à ~8 k jetons recalculés à chaque reprise. Corrigé à partir de b10864. Pas d'espacement resserré d'office : RAM inconnue ou déjà prise par le modèle (CKPT_MIN_STEP=2048 pour l'imposer)." + ] + }, + { + "m": 3, + "c": 63, + "h": 0, + "e": 0, + "args": [ + "/app/llama-server", + "-m", + "/m/hybrid.gguf", + "-c", + "32768", + "-b", + "2048", + "-ub", + "512", + "--host", + "0.0.0.0", + "--port", + "8080", + "--parallel", + "1", + "-ngl", + "auto" + ], + "env": {}, + "notes": [ + "OP_OFFLOAD_MIN_BATCH=0 ignoré : un nombre de jetons, ex. 128", + "modèle hybride de 20 Gio pour 24 Gio de RAM : jusqu'à 32 points de reprise (~150 Mio chacun) prennent sur le cache de pages des poids. CTX_CHECKPOINTS=8 à 16 si le débit baisse.", + "moteur b9000 : les points de reprise des modèles hybrides (Qwen3.5/3.6, Qwen3-Next…) y sont évincés trop tôt — jusqu'à ~8 k jetons recalculés à chaque reprise. Corrigé à partir de b10864. Pas d'espacement resserré d'office : RAM inconnue ou déjà prise par le modèle (CKPT_MIN_STEP=2048 pour l'imposer)." + ] + }, + { + "m": 3, + "c": 64, + "h": 0, + "e": 0, + "args": [ + "/app/llama-server", + "-m", + "/m/hybrid.gguf", + "-c", + "32768", + "-b", + "2048", + "-ub", + "512", + "--host", + "0.0.0.0", + "--port", + "8080", + "--parallel", + "1", + "-ngl", + "auto" + ], + "env": {}, + "notes": [ + "modèle hybride de 20 Gio pour 24 Gio de RAM : jusqu'à 32 points de reprise (~150 Mio chacun) prennent sur le cache de pages des poids. CTX_CHECKPOINTS=8 à 16 si le débit baisse.", + "moteur b9000 : les points de reprise des modèles hybrides (Qwen3.5/3.6, Qwen3-Next…) y sont évincés trop tôt — jusqu'à ~8 k jetons recalculés à chaque reprise. Corrigé à partir de b10864. Pas d'espacement resserré d'office : RAM inconnue ou déjà prise par le modèle (CKPT_MIN_STEP=2048 pour l'imposer)." + ] + }, + { + "m": 3, + "c": 65, + "h": 0, + "e": 0, + "args": [ + "/app/llama-server", + "-m", + "/m/hybrid.gguf", + "-c", + "32768", + "-b", + "2048", + "-ub", + "512", + "--host", + "0.0.0.0", + "--port", + "8080", + "--parallel", + "1", + "-ngl", + "auto" + ], + "env": {}, + "notes": [ + "CUDA_GRAPH_OPT=oui ignoré (on ou off)", + "modèle hybride de 20 Gio pour 24 Gio de RAM : jusqu'à 32 points de reprise (~150 Mio chacun) prennent sur le cache de pages des poids. CTX_CHECKPOINTS=8 à 16 si le débit baisse.", + "moteur b9000 : les points de reprise des modèles hybrides (Qwen3.5/3.6, Qwen3-Next…) y sont évincés trop tôt — jusqu'à ~8 k jetons recalculés à chaque reprise. Corrigé à partir de b10864. Pas d'espacement resserré d'office : RAM inconnue ou déjà prise par le modèle (CKPT_MIN_STEP=2048 pour l'imposer)." + ] + }, + { + "m": 3, + "c": 66, + "h": 0, + "e": 0, + "args": [ + "/app/llama-server", + "-m", + "/m/hybrid.gguf", + "-c", + "32768", + "-b", + "2048", + "-ub", + "512", + "--host", + "0.0.0.0", + "--port", + "8080", + "--parallel", + "1", + "-ngl", + "auto" + ], + "env": {}, + "notes": [ + "modèle hybride de 20 Gio pour 24 Gio de RAM : jusqu'à 32 points de reprise (~150 Mio chacun) prennent sur le cache de pages des poids. CTX_CHECKPOINTS=8 à 16 si le débit baisse.", + "moteur b9000 : les points de reprise des modèles hybrides (Qwen3.5/3.6, Qwen3-Next…) y sont évincés trop tôt — jusqu'à ~8 k jetons recalculés à chaque reprise. Corrigé à partir de b10864. Pas d'espacement resserré d'office : RAM inconnue ou déjà prise par le modèle (CKPT_MIN_STEP=2048 pour l'imposer)." + ] + }, + { + "m": 3, + "c": 67, + "h": 0, + "e": 0, + "args": [ + "/app/llama-server", + "-m", + "/m/hybrid.gguf", + "-c", + "32768", + "-b", + "2048", + "-ub", + "512", + "--host", + "0.0.0.0", + "--port", + "8080", + "--parallel", + "1", + "-ngl", + "auto", + "--ctx-checkpoints", + "8" + ], + "env": {}, + "notes": [ + "moteur b9000 : les points de reprise des modèles hybrides (Qwen3.5/3.6, Qwen3-Next…) y sont évincés trop tôt — jusqu'à ~8 k jetons recalculés à chaque reprise. Corrigé à partir de b10864. Pas d'espacement resserré d'office : RAM inconnue ou déjà prise par le modèle (CKPT_MIN_STEP=2048 pour l'imposer)." + ] + }, + { + "m": 3, + "c": 68, + "h": 0, + "e": 0, + "args": [ + "/app/llama-server", + "-m", + "/m/hybrid.gguf", + "-c", + "32768", + "-b", + "2048", + "-ub", + "512", + "--host", + "0.0.0.0", + "--port", + "8080", + "--parallel", + "1", + "-ngl", + "auto" + ], + "env": {}, + "notes": [ + "CTX_CHECKPOINTS=beaucoup illisible (un nombre de points de reprise par slot, ≥ 0) : ignoré", + "moteur b9000 : les points de reprise des modèles hybrides (Qwen3.5/3.6, Qwen3-Next…) y sont évincés trop tôt — jusqu'à ~8 k jetons recalculés à chaque reprise. Corrigé à partir de b10864. Pas d'espacement resserré d'office : RAM inconnue ou déjà prise par le modèle (CKPT_MIN_STEP=2048 pour l'imposer)." + ] + }, + { + "m": 3, + "c": 69, + "h": 0, + "e": 0, + "args": [ + "/app/llama-server", + "-m", + "/m/hybrid.gguf", + "-c", + "32768", + "-b", + "2048", + "-ub", + "512", + "--host", + "0.0.0.0", + "--port", + "8080", + "--parallel", + "1", + "-ngl", + "auto", + "--checkpoint-min-step", + "1024" + ], + "env": {}, + "notes": [ + "modèle hybride de 20 Gio pour 24 Gio de RAM : jusqu'à 32 points de reprise (~150 Mio chacun) prennent sur le cache de pages des poids. CTX_CHECKPOINTS=8 à 16 si le débit baisse." + ] + }, + { + "m": 3, + "c": 70, + "h": 0, + "e": 0, + "args": [ + "/app/llama-server", + "-m", + "/m/hybrid.gguf", + "-c", + "32768", + "-b", + "2048", + "-ub", + "512", + "--host", + "0.0.0.0", + "--port", + "8080", + "--parallel", + "1", + "-ngl", + "auto" + ], + "env": {}, + "notes": [ + "modèle hybride de 20 Gio pour 24 Gio de RAM : jusqu'à 32 points de reprise (~150 Mio chacun) prennent sur le cache de pages des poids. CTX_CHECKPOINTS=8 à 16 si le débit baisse.", + "CKPT_MIN_STEP=0 refusé (un nombre de jetons > 0 ; 0 laisserait le moteur jeter le point de reprise du début de la conversation) : défaut du moteur" + ] + }, + { + "m": 3, + "c": 71, + "h": 0, + "e": 0, + "args": [ + "/app/llama-server", + "-m", + "/m/hybrid.gguf", + "-c", + "32768", + "-b", + "2048", + "-ub", + "512", + "--host", + "0.0.0.0", + "--port", + "8080", + "--parallel", + "1", + "-ngl", + "auto" + ], + "env": {}, + "notes": [ + "SLOT_PERSIST refusé (dossier des slots indisponible) : l'état du slot ne sera pas gardé à la bascule de preset", + "modèle hybride de 20 Gio pour 24 Gio de RAM : jusqu'à 32 points de reprise (~150 Mio chacun) prennent sur le cache de pages des poids. CTX_CHECKPOINTS=8 à 16 si le débit baisse.", + "moteur b9000 : les points de reprise des modèles hybrides (Qwen3.5/3.6, Qwen3-Next…) y sont évincés trop tôt — jusqu'à ~8 k jetons recalculés à chaque reprise. Corrigé à partir de b10864. Pas d'espacement resserré d'office : RAM inconnue ou déjà prise par le modèle (CKPT_MIN_STEP=2048 pour l'imposer)." + ] + }, + { + "m": 3, + "c": 72, + "h": 0, + "e": 0, + "args": [ + "/app/llama-server", + "-m", + "/m/hybrid.gguf", + "-c", + "32768", + "-b", + "2048", + "-ub", + "512", + "--host", + "127.0.0.1", + "--port", + "8080", + "--parallel", + "1", + "-ngl", + "auto" + ], + "env": {}, + "notes": [ + "SLOT_PERSIST refusé (dossier des slots indisponible) : l'état du slot ne sera pas gardé à la bascule de preset", + "modèle hybride de 20 Gio pour 24 Gio de RAM : jusqu'à 32 points de reprise (~150 Mio chacun) prennent sur le cache de pages des poids. CTX_CHECKPOINTS=8 à 16 si le débit baisse.", + "moteur b9000 : les points de reprise des modèles hybrides (Qwen3.5/3.6, Qwen3-Next…) y sont évincés trop tôt — jusqu'à ~8 k jetons recalculés à chaque reprise. Corrigé à partir de b10864. Pas d'espacement resserré d'office : RAM inconnue ou déjà prise par le modèle (CKPT_MIN_STEP=2048 pour l'imposer)." + ] + }, + { + "m": 3, + "c": 73, + "h": 0, + "e": 0, + "args": [ + "/app/llama-server", + "-m", + "/m/hybrid.gguf", + "-c", + "32768", + "-b", + "2048", + "-ub", + "512", + "--host", + "127.0.0.1", + "--port", + "8080", + "--parallel", + "1", + "-ngl", + "auto", + "--spec-type", + "draft-mtp", + "--spec-draft-sampling", + "greedy" + ], + "env": {}, + "notes": [ + "SLOT_PERSIST refusé (dossier des slots indisponible) : l'état du slot ne sera pas gardé à la bascule de preset", + "modèle hybride de 20 Gio pour 24 Gio de RAM : jusqu'à 32 points de reprise (~150 Mio chacun) prennent sur le cache de pages des poids. CTX_CHECKPOINTS=8 à 16 si le débit baisse.", + "moteur b9000 : les points de reprise des modèles hybrides (Qwen3.5/3.6, Qwen3-Next…) y sont évincés trop tôt — jusqu'à ~8 k jetons recalculés à chaque reprise. Corrigé à partir de b10864. Pas d'espacement resserré d'office : RAM inconnue ou déjà prise par le modèle (CKPT_MIN_STEP=2048 pour l'imposer).", + "SPEC=mtp → tête MTP imposé : sortie inchangée, ~1-2 Go de VRAM en plus" + ] + }, + { + "m": 3, + "c": 74, + "h": 0, + "e": 0, + "args": [ + "/app/llama-server", + "-m", + "/m/hybrid.gguf", + "-c", + "32768", + "-b", + "2048", + "-ub", + "512", + "--host", + "0.0.0.0", + "--port", + "8080", + "--parallel", + "1", + "-ngl", + "auto", + "--load-mode", + "none" + ], + "env": {}, + "notes": [ + "modèle hybride de 20 Gio pour 24 Gio de RAM : jusqu'à 32 points de reprise (~150 Mio chacun) prennent sur le cache de pages des poids. CTX_CHECKPOINTS=8 à 16 si le débit baisse.", + "moteur b9000 : les points de reprise des modèles hybrides (Qwen3.5/3.6, Qwen3-Next…) y sont évincés trop tôt — jusqu'à ~8 k jetons recalculés à chaque reprise. Corrigé à partir de b10864. Pas d'espacement resserré d'office : RAM inconnue ou déjà prise par le modèle (CKPT_MIN_STEP=2048 pour l'imposer)." + ] + }, + { + "m": 3, + "c": 75, + "h": 0, + "e": 0, + "args": [ + "/app/llama-server", + "-m", + "/m/hybrid.gguf", + "-c", + "32768", + "-b", + "2048", + "-ub", + "512", + "--host", + "0.0.0.0", + "--port", + "8080", + "--parallel", + "1", + "-ngl", + "auto", + "--load-mode", + "mlock" + ], + "env": {}, + "notes": [ + "modèle hybride de 20 Gio pour 24 Gio de RAM : jusqu'à 32 points de reprise (~150 Mio chacun) prennent sur le cache de pages des poids. CTX_CHECKPOINTS=8 à 16 si le débit baisse.", + "moteur b9000 : les points de reprise des modèles hybrides (Qwen3.5/3.6, Qwen3-Next…) y sont évincés trop tôt — jusqu'à ~8 k jetons recalculés à chaque reprise. Corrigé à partir de b10864. Pas d'espacement resserré d'office : RAM inconnue ou déjà prise par le modèle (CKPT_MIN_STEP=2048 pour l'imposer)." + ] + }, + { + "m": 3, + "c": 76, + "h": 0, + "e": 0, + "args": [ + "/app/llama-server", + "-m", + "/m/hybrid.gguf", + "-c", + "32768", + "-b", + "2048", + "-ub", + "512", + "--host", + "0.0.0.0", + "--port", + "8080", + "--parallel", + "1", + "-ngl", + "auto", + "--load-mode", + "mmap+mlock" + ], + "env": {}, + "notes": [ + "modèle hybride de 20 Gio pour 24 Gio de RAM : jusqu'à 32 points de reprise (~150 Mio chacun) prennent sur le cache de pages des poids. CTX_CHECKPOINTS=8 à 16 si le débit baisse.", + "moteur b9000 : les points de reprise des modèles hybrides (Qwen3.5/3.6, Qwen3-Next…) y sont évincés trop tôt — jusqu'à ~8 k jetons recalculés à chaque reprise. Corrigé à partir de b10864. Pas d'espacement resserré d'office : RAM inconnue ou déjà prise par le modèle (CKPT_MIN_STEP=2048 pour l'imposer)." + ] + }, + { + "m": 3, + "c": 77, + "h": 0, + "e": 0, + "args": [ + "/app/llama-server", + "-m", + "/m/hybrid.gguf", + "-c", + "32768", + "-b", + "2048", + "-ub", + "512", + "--host", + "0.0.0.0", + "--port", + "8080", + "--parallel", + "1", + "-ngl", + "auto", + "--load-mode", + "none" + ], + "env": {}, + "notes": [ + "modèle hybride de 20 Gio pour 24 Gio de RAM : jusqu'à 32 points de reprise (~150 Mio chacun) prennent sur le cache de pages des poids. CTX_CHECKPOINTS=8 à 16 si le débit baisse.", + "moteur b9000 : les points de reprise des modèles hybrides (Qwen3.5/3.6, Qwen3-Next…) y sont évincés trop tôt — jusqu'à ~8 k jetons recalculés à chaque reprise. Corrigé à partir de b10864. Pas d'espacement resserré d'office : RAM inconnue ou déjà prise par le modèle (CKPT_MIN_STEP=2048 pour l'imposer)." + ] + }, + { + "m": 3, + "c": 78, + "h": 0, + "e": 0, + "args": [ + "/app/llama-server", + "-m", + "/m/hybrid.gguf", + "-c", + "32768", + "-b", + "2048", + "-ub", + "512", + "--host", + "0.0.0.0", + "--port", + "8080", + "--parallel", + "1", + "-ngl", + "auto", + "--load-mode", + "mmap" + ], + "env": {}, + "notes": [ + "modèle hybride de 20 Gio pour 24 Gio de RAM : jusqu'à 32 points de reprise (~150 Mio chacun) prennent sur le cache de pages des poids. CTX_CHECKPOINTS=8 à 16 si le débit baisse.", + "moteur b9000 : les points de reprise des modèles hybrides (Qwen3.5/3.6, Qwen3-Next…) y sont évincés trop tôt — jusqu'à ~8 k jetons recalculés à chaque reprise. Corrigé à partir de b10864. Pas d'espacement resserré d'office : RAM inconnue ou déjà prise par le modèle (CKPT_MIN_STEP=2048 pour l'imposer)." + ] + }, + { + "m": 3, + "c": 79, + "h": 0, + "e": 0, + "args": [ + "/app/llama-server", + "-m", + "/m/hybrid.gguf", + "-c", + "32768", + "-b", + "2048", + "-ub", + "512", + "--host", + "0.0.0.0", + "--port", + "8080", + "--parallel", + "1", + "-ngl", + "auto", + "--load-mode", + "dio" + ], + "env": {}, + "notes": [ + "modèle hybride de 20 Gio pour 24 Gio de RAM : jusqu'à 32 points de reprise (~150 Mio chacun) prennent sur le cache de pages des poids. CTX_CHECKPOINTS=8 à 16 si le débit baisse.", + "moteur b9000 : les points de reprise des modèles hybrides (Qwen3.5/3.6, Qwen3-Next…) y sont évincés trop tôt — jusqu'à ~8 k jetons recalculés à chaque reprise. Corrigé à partir de b10864. Pas d'espacement resserré d'office : RAM inconnue ou déjà prise par le modèle (CKPT_MIN_STEP=2048 pour l'imposer)." + ] + }, + { + "m": 3, + "c": 80, + "h": 0, + "e": 0, + "args": [ + "/app/llama-server", + "-m", + "/m/hybrid.gguf", + "-c", + "32768", + "-b", + "2048", + "-ub", + "512", + "--host", + "0.0.0.0", + "--port", + "8080", + "--parallel", + "1", + "-ngl", + "auto", + "--load-mode=mlock" + ], + "env": {}, + "notes": [ + "modèle hybride de 20 Gio pour 24 Gio de RAM : jusqu'à 32 points de reprise (~150 Mio chacun) prennent sur le cache de pages des poids. CTX_CHECKPOINTS=8 à 16 si le débit baisse.", + "moteur b9000 : les points de reprise des modèles hybrides (Qwen3.5/3.6, Qwen3-Next…) y sont évincés trop tôt — jusqu'à ~8 k jetons recalculés à chaque reprise. Corrigé à partir de b10864. Pas d'espacement resserré d'office : RAM inconnue ou déjà prise par le modèle (CKPT_MIN_STEP=2048 pour l'imposer)." + ] + }, + { + "m": 3, + "c": 81, + "h": 0, + "e": 0, + "args": [ + "/app/llama-server", + "-m", + "/m/hybrid.gguf", + "-c", + "32768", + "-b", + "2048", + "-ub", + "512", + "--host", + "0.0.0.0", + "--port", + "8080", + "-ngl", + "30", + "--parallel", + "4" + ], + "env": {}, + "notes": [ + "modèle hybride de 20 Gio pour 24 Gio de RAM : jusqu'à 32 points de reprise (~150 Mio chacun) prennent sur le cache de pages des poids. CTX_CHECKPOINTS=8 à 16 si le débit baisse.", + "moteur b9000 : les points de reprise des modèles hybrides (Qwen3.5/3.6, Qwen3-Next…) y sont évincés trop tôt — jusqu'à ~8 k jetons recalculés à chaque reprise. Corrigé à partir de b10864. Pas d'espacement resserré d'office : RAM inconnue ou déjà prise par le modèle (CKPT_MIN_STEP=2048 pour l'imposer)." + ] + }, + { + "m": 3, + "c": 82, + "h": 0, + "e": 0, + "args": [ + "/app/llama-server", + "-m", + "/m/hybrid.gguf", + "-c", + "32768", + "-b", + "2048", + "-ub", + "512", + "--host", + "0.0.0.0", + "--port", + "8080", + "--parallel", + "1", + "-ngl", + "auto", + "-t", + "6", + "-tb", + "3" + ], + "env": {}, + "notes": [ + "modèle hybride de 20 Gio pour 24 Gio de RAM : jusqu'à 32 points de reprise (~150 Mio chacun) prennent sur le cache de pages des poids. CTX_CHECKPOINTS=8 à 16 si le débit baisse.", + "moteur b9000 : les points de reprise des modèles hybrides (Qwen3.5/3.6, Qwen3-Next…) y sont évincés trop tôt — jusqu'à ~8 k jetons recalculés à chaque reprise. Corrigé à partir de b10864. Pas d'espacement resserré d'office : RAM inconnue ou déjà prise par le modèle (CKPT_MIN_STEP=2048 pour l'imposer)." + ] + }, + { + "m": 3, + "c": 83, + "h": 0, + "e": 0, + "args": [ + "/app/llama-server", + "-m", + "/m/hybrid.gguf", + "-c", + "32768", + "-tb", + "0", + "-b", + "2048", + "-ub", + "512", + "--host", + "0.0.0.0", + "--port", + "8080", + "--parallel", + "1", + "-ngl", + "auto", + "-Cb", + "0xff", + "--poll-batch", + "1" + ], + "env": {}, + "notes": [ + "EXTRA_ARGS règle le CPU du batch (-Cb, --poll-batch…) : -tb 0 posé pour que llama.cpp le garde (tous les threads logiques) ; THREADS_BATCH pour choisir le nombre", + "modèle hybride de 20 Gio pour 24 Gio de RAM : jusqu'à 32 points de reprise (~150 Mio chacun) prennent sur le cache de pages des poids. CTX_CHECKPOINTS=8 à 16 si le débit baisse.", + "moteur b9000 : les points de reprise des modèles hybrides (Qwen3.5/3.6, Qwen3-Next…) y sont évincés trop tôt — jusqu'à ~8 k jetons recalculés à chaque reprise. Corrigé à partir de b10864. Pas d'espacement resserré d'office : RAM inconnue ou déjà prise par le modèle (CKPT_MIN_STEP=2048 pour l'imposer)." + ] + }, + { + "m": 3, + "c": 84, + "h": 0, + "e": 0, + "args": [ + "/app/llama-server", + "-m", + "/m/hybrid.gguf", + "-c", + "32768", + "-t", + "6", + "-tb", + "6", + "-b", + "2048", + "-ub", + "512", + "--host", + "0.0.0.0", + "--port", + "8080", + "--parallel", + "1", + "-ngl", + "auto", + "--prio-batch", + "2" + ], + "env": {}, + "notes": [ + "modèle hybride de 20 Gio pour 24 Gio de RAM : jusqu'à 32 points de reprise (~150 Mio chacun) prennent sur le cache de pages des poids. CTX_CHECKPOINTS=8 à 16 si le débit baisse.", + "moteur b9000 : les points de reprise des modèles hybrides (Qwen3.5/3.6, Qwen3-Next…) y sont évincés trop tôt — jusqu'à ~8 k jetons recalculés à chaque reprise. Corrigé à partir de b10864. Pas d'espacement resserré d'office : RAM inconnue ou déjà prise par le modèle (CKPT_MIN_STEP=2048 pour l'imposer)." + ] + }, + { + "m": 3, + "c": 85, + "h": 0, + "e": 0, + "args": [ + "/app/llama-server", + "-m", + "/m/hybrid.gguf", + "-c", + "32768", + "-b", + "2048", + "-ub", + "512", + "--host", + "0.0.0.0", + "--port", + "8080", + "--parallel", + "1", + "-ngl", + "auto", + "--cache-reuse", + "256", + "--context-shift" + ], + "env": {}, + "notes": [ + "modèle hybride de 20 Gio pour 24 Gio de RAM : jusqu'à 32 points de reprise (~150 Mio chacun) prennent sur le cache de pages des poids. CTX_CHECKPOINTS=8 à 16 si le débit baisse.", + "moteur b9000 : les points de reprise des modèles hybrides (Qwen3.5/3.6, Qwen3-Next…) y sont évincés trop tôt — jusqu'à ~8 k jetons recalculés à chaque reprise. Corrigé à partir de b10864. Pas d'espacement resserré d'office : RAM inconnue ou déjà prise par le modèle (CKPT_MIN_STEP=2048 pour l'imposer).", + "avertissement : --context-shift (EXTRA_ARGS) — contexte plein, le moteur jette des jetons anciens et le modèle perd une partie de la conversation sans le savoir.", + "avertissement : --cache-reuse 256 (EXTRA_ARGS) — réutilise des morceaux de cache calculés sous un autre préfixe : les sorties ne sont plus exactement celles d'un calcul complet." + ] + }, + { + "m": 3, + "c": 86, + "h": 0, + "e": 0, + "args": [ + "/app/llama-server", + "-m", + "/m/hybrid.gguf", + "-c", + "32768", + "-b", + "2048", + "-ub", + "512", + "--host", + "0.0.0.0", + "--port", + "8080", + "--parallel", + "1", + "-ngl", + "auto", + "-ctk", + "q8_0", + "-ctv", + "q8_0" + ], + "env": {}, + "notes": [ + "modèle hybride de 20 Gio pour 24 Gio de RAM : jusqu'à 32 points de reprise (~150 Mio chacun) prennent sur le cache de pages des poids. CTX_CHECKPOINTS=8 à 16 si le débit baisse.", + "moteur b9000 : les points de reprise des modèles hybrides (Qwen3.5/3.6, Qwen3-Next…) y sont évincés trop tôt — jusqu'à ~8 k jetons recalculés à chaque reprise. Corrigé à partir de b10864. Pas d'espacement resserré d'office : RAM inconnue ou déjà prise par le modèle (CKPT_MIN_STEP=2048 pour l'imposer).", + "cache KV q8_0/q8_0 (défini par EXTRA_ARGS) : modifie légèrement les sorties par rapport à f16 — choix de l'utilisateur, laissé tel quel." + ] + }, + { + "m": 3, + "c": 87, + "h": 0, + "e": 0, + "args": [ + "/app/llama-server", + "-m", + "/m/hybrid.gguf", + "-c", + "32768", + "-b", + "2048", + "-ub", + "512", + "--host", + "0.0.0.0", + "--port", + "8080", + "--parallel", + "1", + "-ngl", + "auto", + "-ot", + "exps=CPU" + ], + "env": {}, + "notes": [ + "modèle hybride de 20 Gio pour 24 Gio de RAM : jusqu'à 32 points de reprise (~150 Mio chacun) prennent sur le cache de pages des poids. CTX_CHECKPOINTS=8 à 16 si le débit baisse.", + "moteur b9000 : les points de reprise des modèles hybrides (Qwen3.5/3.6, Qwen3-Next…) y sont évincés trop tôt — jusqu'à ~8 k jetons recalculés à chaque reprise. Corrigé à partir de b10864. Pas d'espacement resserré d'office : RAM inconnue ou déjà prise par le modèle (CKPT_MIN_STEP=2048 pour l'imposer)." + ] + }, + { + "m": 3, + "c": 88, + "h": 0, + "e": 0, + "args": [ + "/app/llama-server", + "-m", + "/m/hybrid.gguf", + "-c", + "32768", + "-b", + "2048", + "-ub", + "512", + "--host", + "0.0.0.0", + "--port", + "8080", + "--parallel", + "1", + "-ngl", + "auto", + "-ot", + "blk\\.(1|2)\\.ffn_.*_exps=CPU", + "-ub", + "512" + ], + "env": {}, + "notes": [ + "modèle hybride de 20 Gio pour 24 Gio de RAM : jusqu'à 32 points de reprise (~150 Mio chacun) prennent sur le cache de pages des poids. CTX_CHECKPOINTS=8 à 16 si le débit baisse.", + "moteur b9000 : les points de reprise des modèles hybrides (Qwen3.5/3.6, Qwen3-Next…) y sont évincés trop tôt — jusqu'à ~8 k jetons recalculés à chaque reprise. Corrigé à partir de b10864. Pas d'espacement resserré d'office : RAM inconnue ou déjà prise par le modèle (CKPT_MIN_STEP=2048 pour l'imposer)." + ] + }, + { + "m": 3, + "c": 89, + "h": 0, + "e": 0, + "args": [ + "/app/llama-server", + "-m", + "/m/hybrid.gguf", + "-c", + "32768", + "-b", + "2048", + "-ub", + "512", + "--host", + "0.0.0.0", + "--port", + "8080", + "--parallel", + "1", + "-ngl", + "auto", + "--n-cpu-moe", + "20" + ], + "env": {}, + "notes": [ + "modèle hybride de 20 Gio pour 24 Gio de RAM : jusqu'à 32 points de reprise (~150 Mio chacun) prennent sur le cache de pages des poids. CTX_CHECKPOINTS=8 à 16 si le débit baisse.", + "moteur b9000 : les points de reprise des modèles hybrides (Qwen3.5/3.6, Qwen3-Next…) y sont évincés trop tôt — jusqu'à ~8 k jetons recalculés à chaque reprise. Corrigé à partir de b10864. Pas d'espacement resserré d'office : RAM inconnue ou déjà prise par le modèle (CKPT_MIN_STEP=2048 pour l'imposer)." + ] + }, + { + "m": 3, + "c": 90, + "h": 0, + "e": 0, + "args": [ + "/app/llama-server", + "-m", + "/m/hybrid.gguf", + "-c", + "32768", + "-b", + "2048", + "-ub", + "512", + "--host", + "0.0.0.0", + "--port", + "8080", + "--parallel", + "1", + "-ngl", + "auto", + "--cpu-moe" + ], + "env": {}, + "notes": [ + "modèle hybride de 20 Gio pour 24 Gio de RAM : jusqu'à 32 points de reprise (~150 Mio chacun) prennent sur le cache de pages des poids. CTX_CHECKPOINTS=8 à 16 si le débit baisse.", + "moteur b9000 : les points de reprise des modèles hybrides (Qwen3.5/3.6, Qwen3-Next…) y sont évincés trop tôt — jusqu'à ~8 k jetons recalculés à chaque reprise. Corrigé à partir de b10864. Pas d'espacement resserré d'office : RAM inconnue ou déjà prise par le modèle (CKPT_MIN_STEP=2048 pour l'imposer)." + ] + }, + { + "m": 3, + "c": 91, + "h": 0, + "e": 0, + "args": [ + "/app/llama-server", + "-m", + "/m/hybrid.gguf", + "-c", + "32768", + "-b", + "2048", + "-ub", + "512", + "--host", + "0.0.0.0", + "--port", + "8080", + "--parallel", + "1", + "-ngl", + "auto", + "-sm", + "row" + ], + "env": {}, + "notes": [ + "modèle hybride de 20 Gio pour 24 Gio de RAM : jusqu'à 32 points de reprise (~150 Mio chacun) prennent sur le cache de pages des poids. CTX_CHECKPOINTS=8 à 16 si le débit baisse.", + "moteur b9000 : les points de reprise des modèles hybrides (Qwen3.5/3.6, Qwen3-Next…) y sont évincés trop tôt — jusqu'à ~8 k jetons recalculés à chaque reprise. Corrigé à partir de b10864. Pas d'espacement resserré d'office : RAM inconnue ou déjà prise par le modèle (CKPT_MIN_STEP=2048 pour l'imposer)." + ] + }, + { + "m": 3, + "c": 92, + "h": 0, + "e": 0, + "args": [ + "/app/llama-server", + "-m", + "/m/hybrid.gguf", + "-c", + "32768", + "-b", + "2048", + "-ub", + "512", + "--host", + "0.0.0.0", + "--port", + "8080", + "--parallel", + "1", + "-ngl", + "auto", + "-ts", + "1,1" + ], + "env": {}, + "notes": [ + "modèle hybride de 20 Gio pour 24 Gio de RAM : jusqu'à 32 points de reprise (~150 Mio chacun) prennent sur le cache de pages des poids. CTX_CHECKPOINTS=8 à 16 si le débit baisse.", + "moteur b9000 : les points de reprise des modèles hybrides (Qwen3.5/3.6, Qwen3-Next…) y sont évincés trop tôt — jusqu'à ~8 k jetons recalculés à chaque reprise. Corrigé à partir de b10864. Pas d'espacement resserré d'office : RAM inconnue ou déjà prise par le modèle (CKPT_MIN_STEP=2048 pour l'imposer)." + ] + }, + { + "m": 3, + "c": 93, + "h": 0, + "e": 0, + "args": [ + "/app/llama-server", + "-m", + "/m/hybrid.gguf", + "-c", + "32768", + "-b", + "2048", + "-ub", + "512", + "--host", + "0.0.0.0", + "--port", + "8080", + "--parallel", + "1", + "-ngl", + "auto", + "-dev", + "CUDA0,CUDA1" + ], + "env": { + "CUDA_SCALE_LAUNCH_QUEUES": "4x" + }, + "notes": [ + "CUDA_SCALE_LAUNCH_QUEUES=4x (2 GPU, parallélisme pipeline possible) — CUDA_LAUNCH_QUEUES=off pour s'en passer", + "modèle hybride de 20 Gio pour 24 Gio de RAM : jusqu'à 32 points de reprise (~150 Mio chacun) prennent sur le cache de pages des poids. CTX_CHECKPOINTS=8 à 16 si le débit baisse.", + "moteur b9000 : les points de reprise des modèles hybrides (Qwen3.5/3.6, Qwen3-Next…) y sont évincés trop tôt — jusqu'à ~8 k jetons recalculés à chaque reprise. Corrigé à partir de b10864. Pas d'espacement resserré d'office : RAM inconnue ou déjà prise par le modèle (CKPT_MIN_STEP=2048 pour l'imposer)." + ] + }, + { + "m": 3, + "c": 94, + "h": 0, + "e": 0, + "args": [ + "/app/llama-server", + "-m", + "/m/hybrid.gguf", + "-c", + "32768", + "-b", + "2048", + "-ub", + "512", + "--host", + "0.0.0.0", + "--port", + "8080", + "--parallel", + "1", + "-ngl", + "auto", + "-dev", + "Vulkan0" + ], + "env": {}, + "notes": [ + "modèle hybride de 20 Gio pour 24 Gio de RAM : jusqu'à 32 points de reprise (~150 Mio chacun) prennent sur le cache de pages des poids. CTX_CHECKPOINTS=8 à 16 si le débit baisse.", + "moteur b9000 : les points de reprise des modèles hybrides (Qwen3.5/3.6, Qwen3-Next…) y sont évincés trop tôt — jusqu'à ~8 k jetons recalculés à chaque reprise. Corrigé à partir de b10864. Pas d'espacement resserré d'office : RAM inconnue ou déjà prise par le modèle (CKPT_MIN_STEP=2048 pour l'imposer)." + ] + }, + { + "m": 3, + "c": 95, + "h": 0, + "e": 0, + "args": [ + "/app/llama-server", + "-m", + "/m/hybrid.gguf", + "-c", + "32768", + "-b", + "2048", + "-ub", + "512", + "--host", + "0.0.0.0", + "--port", + "8080", + "--parallel", + "1", + "-ngl", + "auto", + "--fit", + "off" + ], + "env": {}, + "notes": [ + "modèle hybride de 20 Gio pour 24 Gio de RAM : jusqu'à 32 points de reprise (~150 Mio chacun) prennent sur le cache de pages des poids. CTX_CHECKPOINTS=8 à 16 si le débit baisse.", + "moteur b9000 : les points de reprise des modèles hybrides (Qwen3.5/3.6, Qwen3-Next…) y sont évincés trop tôt — jusqu'à ~8 k jetons recalculés à chaque reprise. Corrigé à partir de b10864. Pas d'espacement resserré d'office : RAM inconnue ou déjà prise par le modèle (CKPT_MIN_STEP=2048 pour l'imposer)." + ] + }, + { + "m": 3, + "c": 96, + "h": 0, + "e": 0, + "args": [ + "/app/llama-server", + "-m", + "/m/hybrid.gguf", + "-c", + "32768", + "-b", + "2048", + "-ub", + "512", + "--host", + "0.0.0.0", + "--port", + "8080", + "--parallel", + "1", + "-ngl", + "auto", + "--mmproj", + "/m/x.gguf" + ], + "env": {}, + "notes": [ + "modèle hybride de 20 Gio pour 24 Gio de RAM : jusqu'à 32 points de reprise (~150 Mio chacun) prennent sur le cache de pages des poids. CTX_CHECKPOINTS=8 à 16 si le débit baisse.", + "moteur b9000 : les points de reprise des modèles hybrides (Qwen3.5/3.6, Qwen3-Next…) y sont évincés trop tôt — jusqu'à ~8 k jetons recalculés à chaque reprise. Corrigé à partir de b10864. Pas d'espacement resserré d'office : RAM inconnue ou déjà prise par le modèle (CKPT_MIN_STEP=2048 pour l'imposer)." + ] + }, + { + "m": 3, + "c": 97, + "h": 0, + "e": 0, + "args": [ + "/app/llama-server", + "-m", + "/m/hybrid.gguf", + "-c", + "32768", + "-b", + "2048", + "-ub", + "512", + "--host", + "0.0.0.0", + "--port", + "8080", + "--parallel", + "1", + "-ngl", + "auto", + "--spec-synth-len", + "4" + ], + "env": {}, + "notes": [ + "modèle hybride de 20 Gio pour 24 Gio de RAM : jusqu'à 32 points de reprise (~150 Mio chacun) prennent sur le cache de pages des poids. CTX_CHECKPOINTS=8 à 16 si le débit baisse.", + "moteur b9000 : les points de reprise des modèles hybrides (Qwen3.5/3.6, Qwen3-Next…) y sont évincés trop tôt — jusqu'à ~8 k jetons recalculés à chaque reprise. Corrigé à partir de b10864. Pas d'espacement resserré d'office : RAM inconnue ou déjà prise par le modèle (CKPT_MIN_STEP=2048 pour l'imposer).", + "avertissement : --spec-synth-len — le moteur accepte des jetons du brouillon AU HASARD, sans vérification : les réponses ne sont plus celles du modèle. Réservé aux mesures, retire-le." + ] + }, + { + "m": 3, + "c": 98, + "h": 0, + "e": 0, + "args": [ + "/app/llama-server", + "-m", + "/m/hybrid.gguf", + "-c", + "32768", + "-b", + "2048", + "-ub", + "512", + "--host", + "0.0.0.0", + "--port", + "8080", + "--parallel", + "1", + "-ngl", + "auto", + "--slot-save-path", + "/x" + ], + "env": {}, + "notes": [ + "modèle hybride de 20 Gio pour 24 Gio de RAM : jusqu'à 32 points de reprise (~150 Mio chacun) prennent sur le cache de pages des poids. CTX_CHECKPOINTS=8 à 16 si le débit baisse.", + "moteur b9000 : les points de reprise des modèles hybrides (Qwen3.5/3.6, Qwen3-Next…) y sont évincés trop tôt — jusqu'à ~8 k jetons recalculés à chaque reprise. Corrigé à partir de b10864. Pas d'espacement resserré d'office : RAM inconnue ou déjà prise par le modèle (CKPT_MIN_STEP=2048 pour l'imposer)." + ] + }, + { + "m": 3, + "c": 99, + "h": 0, + "e": 0, + "args": [ + "/app/llama-server", + "-m", + "/m/hybrid.gguf", + "-c", + "32768", + "-b", + "2048", + "-ub", + "512", + "--host", + "0.0.0.0", + "--port", + "8080", + "--parallel", + "1", + "-ngl", + "auto", + "-c", + "0" + ], + "env": {}, + "notes": [ + "modèle hybride de 20 Gio pour 24 Gio de RAM : jusqu'à 32 points de reprise (~150 Mio chacun) prennent sur le cache de pages des poids. CTX_CHECKPOINTS=8 à 16 si le débit baisse.", + "moteur b9000 : les points de reprise des modèles hybrides (Qwen3.5/3.6, Qwen3-Next…) y sont évincés trop tôt — jusqu'à ~8 k jetons recalculés à chaque reprise. Corrigé à partir de b10864. Pas d'espacement resserré d'office : RAM inconnue ou déjà prise par le modèle (CKPT_MIN_STEP=2048 pour l'imposer)." + ] + }, + { + "m": 3, + "c": 100, + "h": 0, + "e": 0, + "args": [ + "/app/llama-server", + "-m", + "/m/hybrid.gguf", + "-c", + "32768", + "-b", + "2048", + "-ub", + "512", + "--host", + "0.0.0.0", + "--port", + "8080", + "--parallel", + "1", + "-ngl", + "auto", + "--host", + "127.0.0.1", + "--port", + "9000" + ], + "env": {}, + "notes": [ + "modèle hybride de 20 Gio pour 24 Gio de RAM : jusqu'à 32 points de reprise (~150 Mio chacun) prennent sur le cache de pages des poids. CTX_CHECKPOINTS=8 à 16 si le débit baisse.", + "moteur b9000 : les points de reprise des modèles hybrides (Qwen3.5/3.6, Qwen3-Next…) y sont évincés trop tôt — jusqu'à ~8 k jetons recalculés à chaque reprise. Corrigé à partir de b10864. Pas d'espacement resserré d'office : RAM inconnue ou déjà prise par le modèle (CKPT_MIN_STEP=2048 pour l'imposer)." + ] + }, + { + "m": 3, + "c": 101, + "h": 0, + "e": 0, + "args": [ + "/app/llama-server", + "-m", + "/m/hybrid.gguf", + "-c", + "32768", + "-b", + "2048", + "-ub", + "512", + "--host", + "0.0.0.0", + "--port", + "8080", + "--parallel", + "1", + "-ngl", + "auto", + "--spec-type", + "draft-mtp", + "--spec-draft-sampling", + "greedy", + "--mirostat", + "2" + ], + "env": {}, + "notes": [ + "modèle hybride de 20 Gio pour 24 Gio de RAM : jusqu'à 32 points de reprise (~150 Mio chacun) prennent sur le cache de pages des poids. CTX_CHECKPOINTS=8 à 16 si le débit baisse.", + "moteur b9000 : les points de reprise des modèles hybrides (Qwen3.5/3.6, Qwen3-Next…) y sont évincés trop tôt — jusqu'à ~8 k jetons recalculés à chaque reprise. Corrigé à partir de b10864. Pas d'espacement resserré d'office : RAM inconnue ou déjà prise par le modèle (CKPT_MIN_STEP=2048 pour l'imposer).", + "SPEC=mtp → tête MTP imposé : sortie inchangée, ~1-2 Go de VRAM en plus", + "SPEC_SAMPLING=probabilistic refusé avec --mirostat 2 (cet échantillonneur avancerait sur des jetons rejetés) : greedy" + ] + }, + { + "m": 3, + "c": 102, + "h": 0, + "e": 0, + "args": [ + "/app/llama-server", + "-m", + "/m/hybrid.gguf", + "-c", + "32768", + "-b", + "2048", + "-ub", + "512", + "--host", + "0.0.0.0", + "--port", + "8080", + "--parallel", + "1", + "-ngl", + "auto", + "--chat-template-kwargs", + "{\"preserve_reasoning\":true}" + ], + "env": {}, + "notes": [ + "--chat-template-kwargs fixe déjà preserve_reasoning : REASONING_PRESERVE ignoré", + "modèle hybride de 20 Gio pour 24 Gio de RAM : jusqu'à 32 points de reprise (~150 Mio chacun) prennent sur le cache de pages des poids. CTX_CHECKPOINTS=8 à 16 si le débit baisse.", + "moteur b9000 : les points de reprise des modèles hybrides (Qwen3.5/3.6, Qwen3-Next…) y sont évincés trop tôt — jusqu'à ~8 k jetons recalculés à chaque reprise. Corrigé à partir de b10864. Pas d'espacement resserré d'office : RAM inconnue ou déjà prise par le modèle (CKPT_MIN_STEP=2048 pour l'imposer)." + ] + }, + { + "m": 3, + "c": 103, + "h": 0, + "e": 0, + "args": [ + "/app/llama-server", + "-m", + "/m/hybrid.gguf", + "-c", + "32768", + "-b", + "2048", + "-ub", + "512", + "--host", + "0.0.0.0", + "--port", + "8080", + "--parallel", + "1", + "-ngl", + "auto", + "--spec-type", + "ngram-mod" + ], + "env": {}, + "notes": [ + "modèle hybride de 20 Gio pour 24 Gio de RAM : jusqu'à 32 points de reprise (~150 Mio chacun) prennent sur le cache de pages des poids. CTX_CHECKPOINTS=8 à 16 si le débit baisse.", + "moteur b9000 : les points de reprise des modèles hybrides (Qwen3.5/3.6, Qwen3-Next…) y sont évincés trop tôt — jusqu'à ~8 k jetons recalculés à chaque reprise. Corrigé à partir de b10864. Pas d'espacement resserré d'office : RAM inconnue ou déjà prise par le modèle (CKPT_MIN_STEP=2048 pour l'imposer).", + "décodage spéculatif déjà réglé dans EXTRA_ARGS ou LLAMA_ARG_* : SPEC ignoré" + ] + }, + { + "m": 3, + "c": 104, + "h": 0, + "e": 0, + "args": [ + "/app/llama-server", + "-m", + "/m/hybrid.gguf", + "-c", + "32768", + "-b", + "2048", + "-ub", + "512", + "--host", + "0.0.0.0", + "--port", + "8080", + "--parallel", + "1", + "-ngl", + "auto", + "--spec-draft-n-max", + "8" + ], + "env": {}, + "notes": [ + "modèle hybride de 20 Gio pour 24 Gio de RAM : jusqu'à 32 points de reprise (~150 Mio chacun) prennent sur le cache de pages des poids. CTX_CHECKPOINTS=8 à 16 si le débit baisse.", + "moteur b9000 : les points de reprise des modèles hybrides (Qwen3.5/3.6, Qwen3-Next…) y sont évincés trop tôt — jusqu'à ~8 k jetons recalculés à chaque reprise. Corrigé à partir de b10864. Pas d'espacement resserré d'office : RAM inconnue ou déjà prise par le modèle (CKPT_MIN_STEP=2048 pour l'imposer).", + "SPEC=ngram : --spec-draft-n-max dans EXTRA_ARGS (brouillon réglé à la main) — sans décodage spéculatif" + ] + }, + { + "m": 3, + "c": 105, + "h": 0, + "e": 0, + "args": [ + "/app/llama-server", + "-m", + "/m/hybrid.gguf", + "-c", + "32768", + "-b", + "2048", + "-ub", + "512", + "--host", + "0.0.0.0", + "--port", + "8080", + "--parallel", + "1", + "-ngl", + "auto", + "-ot", + "attn=CUDA1" + ], + "env": {}, + "notes": [ + "CUDA_GRAPH_OPT ignoré : -ot sur l'attention peut séparer Q, K et V d'une couche entre cartes", + "modèle hybride de 20 Gio pour 24 Gio de RAM : jusqu'à 32 points de reprise (~150 Mio chacun) prennent sur le cache de pages des poids. CTX_CHECKPOINTS=8 à 16 si le débit baisse.", + "moteur b9000 : les points de reprise des modèles hybrides (Qwen3.5/3.6, Qwen3-Next…) y sont évincés trop tôt — jusqu'à ~8 k jetons recalculés à chaque reprise. Corrigé à partir de b10864. Pas d'espacement resserré d'office : RAM inconnue ou déjà prise par le modèle (CKPT_MIN_STEP=2048 pour l'imposer)." + ] + }, + { + "m": 3, + "c": 106, + "h": 0, + "e": 0, + "args": [ + "/app/llama-server", + "-m", + "/m/hybrid.gguf", + "-c", + "32768", + "-b", + "2048", + "-ub", + "512", + "--host", + "0.0.0.0", + "--port", + "8080", + "--parallel", + "1", + "-ngl", + "auto", + "-fa", + "off" + ], + "env": {}, + "notes": [ + "SPLIT_MODE=tensor refusé (Flash Attention coupée (-fa off) : le mode tensor l'exige) : découpe par couches, comme sans la clé", + "modèle hybride de 20 Gio pour 24 Gio de RAM : jusqu'à 32 points de reprise (~150 Mio chacun) prennent sur le cache de pages des poids. CTX_CHECKPOINTS=8 à 16 si le débit baisse.", + "moteur b9000 : les points de reprise des modèles hybrides (Qwen3.5/3.6, Qwen3-Next…) y sont évincés trop tôt — jusqu'à ~8 k jetons recalculés à chaque reprise. Corrigé à partir de b10864. Pas d'espacement resserré d'office : RAM inconnue ou déjà prise par le modèle (CKPT_MIN_STEP=2048 pour l'imposer)." + ] + }, + { + "m": 3, + "c": 107, + "h": 0, + "e": 0, + "args": [ + "/app/llama-server", + "-m", + "/m/hybrid.gguf", + "-c", + "32768", + "-b", + "2048", + "-ub", + "512", + "--host", + "0.0.0.0", + "--port", + "8080", + "--parallel", + "1", + "-ngl", + "auto", + "-nkvo" + ], + "env": {}, + "notes": [ + "modèle hybride de 20 Gio pour 24 Gio de RAM : jusqu'à 32 points de reprise (~150 Mio chacun) prennent sur le cache de pages des poids. CTX_CHECKPOINTS=8 à 16 si le débit baisse.", + "moteur b9000 : les points de reprise des modèles hybrides (Qwen3.5/3.6, Qwen3-Next…) y sont évincés trop tôt — jusqu'à ~8 k jetons recalculés à chaque reprise. Corrigé à partir de b10864. Pas d'espacement resserré d'office : RAM inconnue ou déjà prise par le modèle (CKPT_MIN_STEP=2048 pour l'imposer)." + ] + }, + { + "m": 3, + "c": 108, + "h": 0, + "e": 0, + "args": [ + "/app/llama-server", + "-m", + "/m/hybrid.gguf", + "-c", + "32768", + "-b", + "2048", + "-ub", + "512", + "--host", + "0.0.0.0", + "--port", + "8080", + "--parallel", + "1", + "-ngl", + "auto", + "--no-context-shift" + ], + "env": {}, + "notes": [ + "modèle hybride de 20 Gio pour 24 Gio de RAM : jusqu'à 32 points de reprise (~150 Mio chacun) prennent sur le cache de pages des poids. CTX_CHECKPOINTS=8 à 16 si le débit baisse.", + "moteur b9000 : les points de reprise des modèles hybrides (Qwen3.5/3.6, Qwen3-Next…) y sont évincés trop tôt — jusqu'à ~8 k jetons recalculés à chaque reprise. Corrigé à partir de b10864. Pas d'espacement resserré d'office : RAM inconnue ou déjà prise par le modèle (CKPT_MIN_STEP=2048 pour l'imposer)." + ] + }, + { + "m": 3, + "c": 109, + "h": 0, + "e": 0, + "args": [ + "/app/llama-server", + "-m", + "/m/hybrid.gguf", + "-c", + "32768", + "-b", + "2048", + "-ub", + "512", + "--host", + "0.0.0.0", + "--port", + "8080", + "-ngl", + "auto", + "-c", + "8192", + "-np", + "2" + ], + "env": {}, + "notes": [ + "SIDE_SLOT refusé (-c est réglé dans EXTRA_ARGS) : un seul slot, comme sans la clé", + "modèle hybride de 20 Gio pour 24 Gio de RAM : jusqu'à 32 points de reprise (~150 Mio chacun) prennent sur le cache de pages des poids. CTX_CHECKPOINTS=8 à 16 si le débit baisse.", + "moteur b9000 : les points de reprise des modèles hybrides (Qwen3.5/3.6, Qwen3-Next…) y sont évincés trop tôt — jusqu'à ~8 k jetons recalculés à chaque reprise. Corrigé à partir de b10864. Pas d'espacement resserré d'office : RAM inconnue ou déjà prise par le modèle (CKPT_MIN_STEP=2048 pour l'imposer)." + ] + }, + { + "m": 3, + "c": 110, + "h": 0, + "e": 0, + "args": [ + "/app/llama-server", + "-m", + "/m/hybrid.gguf", + "-c", + "32768", + "-b", + "2048", + "-ub", + "512", + "--host", + "0.0.0.0", + "--port", + "8080", + "--parallel", + "1", + "-ngl", + "auto" + ], + "env": {}, + "notes": [ + "SPLIT_MODE=tensor refusé (preset externe) : découpe par couches, comme sans la clé", + "SIDE_SLOT refusé (preset externe) : un seul slot, comme sans la clé", + "modèle hybride de 20 Gio pour 24 Gio de RAM : jusqu'à 32 points de reprise (~150 Mio chacun) prennent sur le cache de pages des poids. CTX_CHECKPOINTS=8 à 16 si le débit baisse.", + "moteur b9000 : les points de reprise des modèles hybrides (Qwen3.5/3.6, Qwen3-Next…) y sont évincés trop tôt — jusqu'à ~8 k jetons recalculés à chaque reprise. Corrigé à partir de b10864. Pas d'espacement resserré d'office : RAM inconnue ou déjà prise par le modèle (CKPT_MIN_STEP=2048 pour l'imposer)." + ] + }, + { + "m": 3, + "c": 111, + "h": 0, + "e": 0, + "args": [ + "/app/llama-server", + "-m", + "/m/hybrid.gguf", + "-c", + "40000", + "-b", + "2048", + "-ub", + "512", + "--host", + "0.0.0.0", + "--port", + "8080", + "--parallel", + "1", + "-ngl", + "auto", + "-ctk", + "q4_0", + "-ctv", + "q4_0" + ], + "env": {}, + "notes": [ + "NGL=999 (sentinelle « toutes les couches ») → -ngl auto : ce moteur mesure lui-même la VRAM libre. NGL=all pour forcer l'ancien comportement.", + "modèle hybride de 20 Gio pour 24 Gio de RAM : jusqu'à 32 points de reprise (~150 Mio chacun) prennent sur le cache de pages des poids. CTX_CHECKPOINTS=8 à 16 si le débit baisse.", + "moteur b9000 : les points de reprise des modèles hybrides (Qwen3.5/3.6, Qwen3-Next…) y sont évincés trop tôt — jusqu'à ~8 k jetons recalculés à chaque reprise. Corrigé à partir de b10864. Pas d'espacement resserré d'office : RAM inconnue ou déjà prise par le modèle (CKPT_MIN_STEP=2048 pour l'imposer).", + "SPEC=auto : moteur b9000, b11009 au moins — sans décodage spéculatif", + "cache KV q4_0/q4_0 (KV_TYPE) : perte mesurable par rapport à f16 — choix de l'utilisateur, laissé tel quel." + ] + }, + { + "m": 4, + "c": 0, + "h": 0, + "e": 0, + "args": [ + "/app/llama-server", + "-m", + "/m/hybrid.gguf", + "-c", + "32768", + "-b", + "2048", + "-ub", + "512", + "--host", + "0.0.0.0", + "--port", + "8080", + "--parallel", + "1", + "-ngl", + "auto" + ], + "env": { + "CUDA_SCALE_LAUNCH_QUEUES": "4x" + }, + "notes": [ + "CUDA_SCALE_LAUNCH_QUEUES=4x (2 GPU, parallélisme pipeline possible) — CUDA_LAUNCH_QUEUES=off pour s'en passer" + ] + }, + { + "m": 4, + "c": 1, + "h": 0, + "e": 0, + "args": [ + "/app/llama-server", + "-m", + "/m/hybrid.gguf", + "-c", + "65536", + "-b", + "4096", + "-ub", + "2048", + "--host", + "127.0.0.1", + "--port", + "8081", + "--parallel", + "1", + "-ngl", + "auto", + "--cache-ram", + "13981", + "--slot-save-path", + "/data/slots" + ], + "env": { + "CUDA_SCALE_LAUNCH_QUEUES": "4x" + }, + "notes": [ + "CUDA_SCALE_LAUNCH_QUEUES=4x (2 GPU, parallélisme pipeline possible) — CUDA_LAUNCH_QUEUES=off pour s'en passer", + "NGL=999 (sentinelle « toutes les couches ») → -ngl auto : ce moteur mesure lui-même la VRAM libre. NGL=all pour forcer l'ancien comportement.", + "cache de prompts : --cache-ram 13981 (RAM hôte, copie exacte de l'état, jamais la VRAM ; besoin estimé 13981 Mio, plafond 39321 Mio). CACHE_RAM= pour fixer, 0 pour couper." + ] + }, + { + "m": 4, + "c": 2, + "h": 0, + "e": 0, + "args": [ + "/app/llama-server", + "-m", + "/m/hybrid.gguf", + "-c", + "32768", + "-b", + "2048", + "-ub", + "512", + "--host", + "0.0.0.0", + "--port", + "8080", + "--parallel", + "1" + ], + "env": { + "CUDA_SCALE_LAUNCH_QUEUES": "4x" + }, + "notes": [ + "CUDA_SCALE_LAUNCH_QUEUES=4x (2 GPU, parallélisme pipeline possible) — CUDA_LAUNCH_QUEUES=off pour s'en passer" + ] + }, + { + "m": 4, + "c": 3, + "h": 0, + "e": 0, + "args": [ + "/app/llama-server", + "-m", + "/m/hybrid.gguf", + "-c", + "32768", + "-b", + "2048", + "-ub", + "512", + "--host", + "0.0.0.0", + "--port", + "8080", + "--parallel", + "1", + "-ngl", + "40" + ], + "env": {}, + "notes": null + }, + { + "m": 4, + "c": 4, + "h": 0, + "e": 0, + "args": [ + "/app/llama-server", + "-m", + "/m/hybrid.gguf", + "-c", + "32768", + "-b", + "2048", + "-ub", + "512", + "--host", + "0.0.0.0", + "--port", + "8080", + "--parallel", + "1", + "-ngl", + "all" + ], + "env": { + "CUDA_SCALE_LAUNCH_QUEUES": "4x" + }, + "notes": [ + "CUDA_SCALE_LAUNCH_QUEUES=4x (2 GPU, parallélisme pipeline possible) — CUDA_LAUNCH_QUEUES=off pour s'en passer" + ] + }, + { + "m": 4, + "c": 5, + "h": 0, + "e": 0, + "args": [ + "/app/llama-server", + "-m", + "/m/hybrid.gguf", + "-c", + "32768", + "-b", + "2048", + "-ub", + "512", + "--host", + "0.0.0.0", + "--port", + "8080", + "--parallel", + "1", + "-ngl", + "auto" + ], + "env": { + "CUDA_SCALE_LAUNCH_QUEUES": "4x" + }, + "notes": [ + "CUDA_SCALE_LAUNCH_QUEUES=4x (2 GPU, parallélisme pipeline possible) — CUDA_LAUNCH_QUEUES=off pour s'en passer", + "NGL=999 (sentinelle « toutes les couches ») → -ngl auto : ce moteur mesure lui-même la VRAM libre. NGL=all pour forcer l'ancien comportement." + ] + }, + { + "m": 4, + "c": 6, + "h": 0, + "e": 0, + "args": [ + "/app/llama-server", + "-m", + "/m/hybrid.gguf", + "-c", + "32768", + "-t", + "8", + "-tb", + "4", + "-b", + "2048", + "-ub", + "512", + "--host", + "0.0.0.0", + "--port", + "8080", + "--parallel", + "1", + "-ngl", + "auto" + ], + "env": { + "CUDA_SCALE_LAUNCH_QUEUES": "4x" + }, + "notes": [ + "CUDA_SCALE_LAUNCH_QUEUES=4x (2 GPU, parallélisme pipeline possible) — CUDA_LAUNCH_QUEUES=off pour s'en passer" + ] + }, + { + "m": 4, + "c": 7, + "h": 0, + "e": 0, + "args": [ + "/app/llama-server", + "-m", + "/m/hybrid.gguf", + "-c", + "32768", + "-b", + "2048", + "-ub", + "512", + "--host", + "0.0.0.0", + "--port", + "8080", + "--parallel", + "1", + "-ngl", + "auto" + ], + "env": { + "CUDA_SCALE_LAUNCH_QUEUES": "4x" + }, + "notes": [ + "CUDA_SCALE_LAUNCH_QUEUES=4x (2 GPU, parallélisme pipeline possible) — CUDA_LAUNCH_QUEUES=off pour s'en passer", + "THREADS=abc ignoré (pas un nombre de threads ≥ 0) → automatique", + "THREADS_BATCH=-2 ignoré (pas un nombre de threads ≥ 0) → automatique" + ] + }, + { + "m": 4, + "c": 8, + "h": 0, + "e": 0, + "args": [ + "/app/llama-server", + "-m", + "/m/hybrid.gguf", + "-c", + "32768", + "-b", + "2048", + "-ub", + "512", + "--host", + "0.0.0.0", + "--port", + "8080", + "--parallel", + "1", + "-ngl", + "auto" + ], + "env": { + "CUDA_SCALE_LAUNCH_QUEUES": "4x" + }, + "notes": [ + "CUDA_SCALE_LAUNCH_QUEUES=4x (2 GPU, parallélisme pipeline possible) — CUDA_LAUNCH_QUEUES=off pour s'en passer" + ] + }, + { + "m": 4, + "c": 9, + "h": 0, + "e": 0, + "args": [ + "/app/llama-server", + "-m", + "/m/hybrid.gguf", + "-c", + "32768", + "-b", + "2048", + "-ub", + "512", + "--host", + "0.0.0.0", + "--port", + "8080", + "--parallel", + "1", + "-ngl", + "auto", + "-ctk", + "q8_0", + "-ctv", + "q8_0" + ], + "env": { + "CUDA_SCALE_LAUNCH_QUEUES": "4x" + }, + "notes": [ + "CUDA_SCALE_LAUNCH_QUEUES=4x (2 GPU, parallélisme pipeline possible) — CUDA_LAUNCH_QUEUES=off pour s'en passer", + "cache KV q8_0/q8_0 (KV_TYPE) : modifie légèrement les sorties par rapport à f16 — choix de l'utilisateur, laissé tel quel." + ] + }, + { + "m": 4, + "c": 10, + "h": 0, + "e": 0, + "args": [ + "/app/llama-server", + "-m", + "/m/hybrid.gguf", + "-c", + "32768", + "-b", + "2048", + "-ub", + "512", + "--host", + "0.0.0.0", + "--port", + "8080", + "--parallel", + "1", + "-ngl", + "auto", + "-ctk", + "q4_0", + "-ctv", + "f16" + ], + "env": { + "CUDA_SCALE_LAUNCH_QUEUES": "4x" + }, + "notes": [ + "CUDA_SCALE_LAUNCH_QUEUES=4x (2 GPU, parallélisme pipeline possible) — CUDA_LAUNCH_QUEUES=off pour s'en passer", + "cache KV q4_0/f16 (KV_TYPE) : perte mesurable par rapport à f16 — choix de l'utilisateur, laissé tel quel." + ] + }, + { + "m": 4, + "c": 11, + "h": 0, + "e": 0, + "args": [ + "/app/llama-server", + "-m", + "/m/hybrid.gguf", + "-c", + "32768", + "-b", + "2048", + "-ub", + "512", + "--host", + "0.0.0.0", + "--port", + "8080", + "--parallel", + "1", + "-ngl", + "auto", + "-ctk", + "bf16", + "-ctv", + "bf16" + ], + "env": { + "CUDA_SCALE_LAUNCH_QUEUES": "4x" + }, + "notes": [ + "CUDA_SCALE_LAUNCH_QUEUES=4x (2 GPU, parallélisme pipeline possible) — CUDA_LAUNCH_QUEUES=off pour s'en passer", + "cache KV bf16/bf16 (KV_TYPE) : numérique différente de f16 (mantisse plus courte) — choix de l'utilisateur, laissé tel quel." + ] + }, + { + "m": 4, + "c": 12, + "h": 0, + "e": 0, + "args": [ + "/app/llama-server", + "-m", + "/m/hybrid.gguf", + "-c", + "32768", + "-b", + "2048", + "-ub", + "512", + "--host", + "0.0.0.0", + "--port", + "8080", + "--parallel", + "1", + "-ngl", + "auto", + "-ctk", + "q5_1", + "-ctv", + "q5_1" + ], + "env": { + "CUDA_SCALE_LAUNCH_QUEUES": "4x" + }, + "notes": [ + "CUDA_SCALE_LAUNCH_QUEUES=4x (2 GPU, parallélisme pipeline possible) — CUDA_LAUNCH_QUEUES=off pour s'en passer", + "cache KV q5_1/q5_1 (KV_TYPE) : modifie légèrement les sorties par rapport à f16 — choix de l'utilisateur, laissé tel quel." + ] + }, + { + "m": 4, + "c": 13, + "h": 0, + "e": 0, + "args": [ + "/app/llama-server", + "-m", + "/m/hybrid.gguf", + "-c", + "32768", + "-b", + "2048", + "-ub", + "512", + "--host", + "0.0.0.0", + "--port", + "8080", + "--parallel", + "1", + "-ngl", + "auto", + "--reasoning", + "on", + "--reasoning-budget", + "-1" + ], + "env": { + "CUDA_SCALE_LAUNCH_QUEUES": "4x" + }, + "notes": [ + "CUDA_SCALE_LAUNCH_QUEUES=4x (2 GPU, parallélisme pipeline possible) — CUDA_LAUNCH_QUEUES=off pour s'en passer" + ] + }, + { + "m": 4, + "c": 14, + "h": 0, + "e": 0, + "args": [ + "/app/llama-server", + "-m", + "/m/hybrid.gguf", + "-c", + "32768", + "-b", + "2048", + "-ub", + "512", + "--host", + "0.0.0.0", + "--port", + "8080", + "--parallel", + "1", + "-ngl", + "auto", + "--reasoning", + "off" + ], + "env": { + "CUDA_SCALE_LAUNCH_QUEUES": "4x" + }, + "notes": [ + "CUDA_SCALE_LAUNCH_QUEUES=4x (2 GPU, parallélisme pipeline possible) — CUDA_LAUNCH_QUEUES=off pour s'en passer" + ] + }, + { + "m": 4, + "c": 15, + "h": 0, + "e": 0, + "args": [ + "/app/llama-server", + "-m", + "/m/hybrid.gguf", + "-c", + "32768", + "-b", + "2048", + "-ub", + "512", + "--host", + "0.0.0.0", + "--port", + "8080", + "--parallel", + "1", + "-ngl", + "auto", + "--reasoning", + "auto", + "--reasoning-budget", + "2048" + ], + "env": { + "CUDA_SCALE_LAUNCH_QUEUES": "4x" + }, + "notes": [ + "CUDA_SCALE_LAUNCH_QUEUES=4x (2 GPU, parallélisme pipeline possible) — CUDA_LAUNCH_QUEUES=off pour s'en passer" + ] + }, + { + "m": 4, + "c": 16, + "h": 0, + "e": 0, + "args": [ + "/app/llama-server", + "-m", + "/m/hybrid.gguf", + "-c", + "32768", + "-b", + "2048", + "-ub", + "512", + "--host", + "0.0.0.0", + "--port", + "8080", + "--parallel", + "1", + "-ngl", + "auto", + "--reasoning", + "deepseek", + "--reasoning-budget", + "-1" + ], + "env": { + "CUDA_SCALE_LAUNCH_QUEUES": "4x" + }, + "notes": [ + "CUDA_SCALE_LAUNCH_QUEUES=4x (2 GPU, parallélisme pipeline possible) — CUDA_LAUNCH_QUEUES=off pour s'en passer" + ] + }, + { + "m": 4, + "c": 17, + "h": 0, + "e": 0, + "args": [ + "/app/llama-server", + "-m", + "/m/hybrid.gguf", + "-c", + "32768", + "-b", + "2048", + "-ub", + "512", + "--host", + "0.0.0.0", + "--port", + "8080", + "--parallel", + "1", + "-ngl", + "auto", + "--reasoning-preserve" + ], + "env": { + "CUDA_SCALE_LAUNCH_QUEUES": "4x" + }, + "notes": [ + "CUDA_SCALE_LAUNCH_QUEUES=4x (2 GPU, parallélisme pipeline possible) — CUDA_LAUNCH_QUEUES=off pour s'en passer" + ] + }, + { + "m": 4, + "c": 18, + "h": 0, + "e": 0, + "args": [ + "/app/llama-server", + "-m", + "/m/hybrid.gguf", + "-c", + "32768", + "-b", + "2048", + "-ub", + "512", + "--host", + "0.0.0.0", + "--port", + "8080", + "--parallel", + "1", + "-ngl", + "auto", + "--no-reasoning-preserve" + ], + "env": { + "CUDA_SCALE_LAUNCH_QUEUES": "4x" + }, + "notes": [ + "CUDA_SCALE_LAUNCH_QUEUES=4x (2 GPU, parallélisme pipeline possible) — CUDA_LAUNCH_QUEUES=off pour s'en passer" + ] + }, + { + "m": 4, + "c": 19, + "h": 0, + "e": 0, + "args": [ + "/app/llama-server", + "-m", + "/m/hybrid.gguf", + "-c", + "32768", + "-b", + "2048", + "-ub", + "512", + "--host", + "0.0.0.0", + "--port", + "8080", + "--parallel", + "1", + "-ngl", + "auto" + ], + "env": { + "CUDA_SCALE_LAUNCH_QUEUES": "4x" + }, + "notes": [ + "CUDA_SCALE_LAUNCH_QUEUES=4x (2 GPU, parallélisme pipeline possible) — CUDA_LAUNCH_QUEUES=off pour s'en passer", + "REASONING_PRESERVE=peut-être illisible (on ou off) : ignoré" + ] + }, + { + "m": 4, + "c": 20, + "h": 0, + "e": 0, + "args": [ + "/app/llama-server", + "-m", + "/m/hybrid.gguf", + "-c", + "32768", + "-b", + "2048", + "-ub", + "512", + "--host", + "0.0.0.0", + "--port", + "8080", + "--parallel", + "1", + "-ngl", + "auto" + ], + "env": { + "CUDA_SCALE_LAUNCH_QUEUES": "4x" + }, + "notes": [ + "CUDA_SCALE_LAUNCH_QUEUES=4x (2 GPU, parallélisme pipeline possible) — CUDA_LAUNCH_QUEUES=off pour s'en passer" + ] + }, + { + "m": 4, + "c": 21, + "h": 0, + "e": 0, + "args": [ + "/app/llama-server", + "-m", + "/m/hybrid.gguf", + "-c", + "32768", + "-b", + "2048", + "-ub", + "512", + "--host", + "0.0.0.0", + "--port", + "8080", + "--parallel", + "1", + "-ngl", + "auto", + "--cache-ram", + "4096" + ], + "env": { + "CUDA_SCALE_LAUNCH_QUEUES": "4x" + }, + "notes": [ + "CUDA_SCALE_LAUNCH_QUEUES=4x (2 GPU, parallélisme pipeline possible) — CUDA_LAUNCH_QUEUES=off pour s'en passer" + ] + }, + { + "m": 4, + "c": 22, + "h": 0, + "e": 0, + "args": [ + "/app/llama-server", + "-m", + "/m/hybrid.gguf", + "-c", + "32768", + "-b", + "2048", + "-ub", + "512", + "--host", + "0.0.0.0", + "--port", + "8080", + "--parallel", + "1", + "-ngl", + "auto", + "--cache-ram", + "0" + ], + "env": { + "CUDA_SCALE_LAUNCH_QUEUES": "4x" + }, + "notes": [ + "CUDA_SCALE_LAUNCH_QUEUES=4x (2 GPU, parallélisme pipeline possible) — CUDA_LAUNCH_QUEUES=off pour s'en passer" + ] + }, + { + "m": 4, + "c": 23, + "h": 0, + "e": 0, + "args": [ + "/app/llama-server", + "-m", + "/m/hybrid.gguf", + "-c", + "32768", + "-b", + "2048", + "-ub", + "512", + "--host", + "0.0.0.0", + "--port", + "8080", + "--parallel", + "1", + "-ngl", + "auto", + "--cache-ram", + "-1" + ], + "env": { + "CUDA_SCALE_LAUNCH_QUEUES": "4x" + }, + "notes": [ + "CUDA_SCALE_LAUNCH_QUEUES=4x (2 GPU, parallélisme pipeline possible) — CUDA_LAUNCH_QUEUES=off pour s'en passer" + ] + }, + { + "m": 4, + "c": 24, + "h": 0, + "e": 0, + "args": [ + "/app/llama-server", + "-m", + "/m/hybrid.gguf", + "-c", + "32768", + "-b", + "2048", + "-ub", + "512", + "--host", + "0.0.0.0", + "--port", + "8080", + "--parallel", + "1", + "-ngl", + "auto" + ], + "env": { + "CUDA_SCALE_LAUNCH_QUEUES": "4x" + }, + "notes": [ + "CUDA_SCALE_LAUNCH_QUEUES=4x (2 GPU, parallélisme pipeline possible) — CUDA_LAUNCH_QUEUES=off pour s'en passer", + "CACHE_RAM=beaucoup illisible (un nombre de Mio, -1, 0 ou auto) : ignoré" + ] + }, + { + "m": 4, + "c": 25, + "h": 0, + "e": 0, + "args": [ + "/app/llama-server", + "-m", + "/m/hybrid.gguf", + "-c", + "32768", + "-b", + "2048", + "-ub", + "512", + "--host", + "0.0.0.0", + "--port", + "8080", + "--parallel", + "1", + "-ngl", + "auto" + ], + "env": { + "CUDA_SCALE_LAUNCH_QUEUES": "4x" + }, + "notes": [ + "CUDA_SCALE_LAUNCH_QUEUES=4x (2 GPU, parallélisme pipeline possible) — CUDA_LAUNCH_QUEUES=off pour s'en passer" + ] + }, + { + "m": 4, + "c": 26, + "h": 0, + "e": 0, + "args": [ + "/app/llama-server", + "-m", + "/m/hybrid.gguf", + "-c", + "32768", + "-b", + "2048", + "-ub", + "512", + "--host", + "0.0.0.0", + "--port", + "8080", + "--parallel", + "1", + "-ngl", + "auto", + "--cache-ram", + "0" + ], + "env": { + "CUDA_SCALE_LAUNCH_QUEUES": "4x" + }, + "notes": [ + "CUDA_SCALE_LAUNCH_QUEUES=4x (2 GPU, parallélisme pipeline possible) — CUDA_LAUNCH_QUEUES=off pour s'en passer" + ] + }, + { + "m": 4, + "c": 27, + "h": 0, + "e": 0, + "args": [ + "/app/llama-server", + "-m", + "/m/hybrid.gguf", + "-c", + "65536", + "-b", + "2048", + "-ub", + "512", + "--host", + "0.0.0.0", + "--port", + "8080", + "--parallel", + "2", + "--no-kv-unified", + "-ngl", + "auto" + ], + "env": { + "CUDA_SCALE_LAUNCH_QUEUES": "4x" + }, + "notes": [ + "CUDA_SCALE_LAUNCH_QUEUES=4x (2 GPU, parallélisme pipeline possible) — CUDA_LAUNCH_QUEUES=off pour s'en passer", + "SIDE_SLOT : 2 slots de 32768 jetons (-c 65536, --no-kv-unified), ~2198 Mio de VRAM en plus pour le second (cache KV et état récurrent ; le brouillon MTP éventuel et les points de reprise en RAM hôte doublent aussi) ; le placement automatique sortira des couches du GPU s'il manque de place" + ] + }, + { + "m": 4, + "c": 28, + "h": 0, + "e": 0, + "args": [ + "/app/llama-server", + "-m", + "/m/hybrid.gguf", + "-c", + "32768", + "-b", + "2048", + "-ub", + "512", + "--host", + "0.0.0.0", + "--port", + "8080", + "--parallel", + "3", + "-ngl", + "auto" + ], + "env": { + "CUDA_SCALE_LAUNCH_QUEUES": "4x" + }, + "notes": [ + "CUDA_SCALE_LAUNCH_QUEUES=4x (2 GPU, parallélisme pipeline possible) — CUDA_LAUNCH_QUEUES=off pour s'en passer", + "SIDE_SLOT refusé (PARALLEL=3 (SIDE_SLOT ouvre exactement 2 slots)) : un seul slot, comme sans la clé" + ] + }, + { + "m": 4, + "c": 29, + "h": 0, + "e": 0, + "args": [ + "/app/llama-server", + "-m", + "/m/hybrid.gguf", + "-c", + "0", + "-b", + "2048", + "-ub", + "512", + "--host", + "0.0.0.0", + "--port", + "8080", + "--parallel", + "1", + "-ngl", + "auto" + ], + "env": { + "CUDA_SCALE_LAUNCH_QUEUES": "4x" + }, + "notes": [ + "CUDA_SCALE_LAUNCH_QUEUES=4x (2 GPU, parallélisme pipeline possible) — CUDA_LAUNCH_QUEUES=off pour s'en passer", + "SIDE_SLOT refusé (CTX doit être un nombre de jetons explicite) : un seul slot, comme sans la clé" + ] + }, + { + "m": 4, + "c": 30, + "h": 0, + "e": 0, + "args": [ + "/app/llama-server", + "-m", + "/m/hybrid.gguf", + "-c", + "16384", + "-b", + "2048", + "-ub", + "512", + "--host", + "0.0.0.0", + "--port", + "8080", + "--parallel", + "2", + "--no-kv-unified", + "-ngl", + "auto", + "-ctk", + "q8_0", + "-ctv", + "q8_0" + ], + "env": { + "CUDA_SCALE_LAUNCH_QUEUES": "4x" + }, + "notes": [ + "CUDA_SCALE_LAUNCH_QUEUES=4x (2 GPU, parallélisme pipeline possible) — CUDA_LAUNCH_QUEUES=off pour s'en passer", + "SIDE_SLOT : 2 slots de 8192 jetons (-c 16384, --no-kv-unified), ~422 Mio de VRAM en plus pour le second (cache KV et état récurrent ; le brouillon MTP éventuel et les points de reprise en RAM hôte doublent aussi) ; le placement automatique sortira des couches du GPU s'il manque de place", + "cache KV q8_0/q8_0 (KV_TYPE) : modifie légèrement les sorties par rapport à f16 — choix de l'utilisateur, laissé tel quel." + ] + }, + { + "m": 4, + "c": 31, + "h": 0, + "e": 0, + "args": [ + "/app/llama-server", + "-m", + "/m/hybrid.gguf", + "-c", + "32768", + "-b", + "2048", + "-ub", + "512", + "--host", + "0.0.0.0", + "--port", + "8080", + "--parallel", + "1", + "-ngl", + "all", + "-sm", + "tensor", + "-ts", + "57,43", + "-fa", + "on" + ], + "env": { + "GGML_CUDA_ALLREDUCE": "internal" + }, + "notes": [ + "SPLIT_MODE=tensor (expérimental) : -sm tensor sur 2 cartes, -ts 57,43 (VRAM totale), ~17558 Mio estimés sans --fit ; décodage plus rapide, prefill plus lent — compare le temps par tour ; GGML_CUDA_ALLREDUCE=internal : sommes dans le type natif (NCCL les compresserait en BF16, avec perte). « NCCL not compiled in » ou « NCCL is unavailable » au journal : sans effet ici. GeForce sans P2P : les sommes passent par l'hôte ; GGML_CUDA_P2P=1 à poser soi-même si le pilote le permet ; modèle hybride : état récurrent et points de reprise répartis entre cartes, non validés en amont" + ] + }, + { + "m": 4, + "c": 32, + "h": 0, + "e": 0, + "args": [ + "/app/llama-server", + "-m", + "/m/hybrid.gguf", + "-c", + "16384", + "-b", + "2048", + "-ub", + "512", + "--host", + "0.0.0.0", + "--port", + "8080", + "--parallel", + "1", + "-ngl", + "all", + "-sm", + "tensor", + "-ts", + "57,43", + "-fa", + "on" + ], + "env": { + "GGML_CUDA_ALLREDUCE": "internal" + }, + "notes": [ + "SPLIT_MODE=tensor (expérimental) : -sm tensor sur 2 cartes, -ts 57,43 (VRAM totale), ~16534 Mio estimés sans --fit ; décodage plus rapide, prefill plus lent — compare le temps par tour ; GGML_CUDA_ALLREDUCE=internal : sommes dans le type natif (NCCL les compresserait en BF16, avec perte). « NCCL not compiled in » ou « NCCL is unavailable » au journal : sans effet ici. GeForce sans P2P : les sommes passent par l'hôte ; GGML_CUDA_P2P=1 à poser soi-même si le pilote le permet ; modèle hybride : état récurrent et points de reprise répartis entre cartes, non validés en amont" + ] + }, + { + "m": 4, + "c": 33, + "h": 0, + "e": 0, + "args": [ + "/app/llama-server", + "-m", + "/m/hybrid.gguf", + "-c", + "32768", + "-b", + "2048", + "-ub", + "512", + "--host", + "0.0.0.0", + "--port", + "8080", + "--parallel", + "1", + "-ngl", + "auto" + ], + "env": { + "CUDA_SCALE_LAUNCH_QUEUES": "4x" + }, + "notes": [ + "SPLIT_MODE=tensor refusé (SPLIT_MODE=bizarre illisible (tensor ou off)) : découpe par couches, comme sans la clé", + "CUDA_SCALE_LAUNCH_QUEUES=4x (2 GPU, parallélisme pipeline possible) — CUDA_LAUNCH_QUEUES=off pour s'en passer" + ] + }, + { + "m": 4, + "c": 34, + "h": 0, + "e": 0, + "args": [ + "/app/llama-server", + "-m", + "/m/hybrid.gguf", + "-c", + "32768", + "-b", + "2048", + "-ub", + "512", + "--host", + "0.0.0.0", + "--port", + "8080", + "--parallel", + "1", + "-ngl", + "auto" + ], + "env": { + "CUDA_SCALE_LAUNCH_QUEUES": "4x" + }, + "notes": [ + "CUDA_SCALE_LAUNCH_QUEUES=4x (2 GPU, parallélisme pipeline possible) — CUDA_LAUNCH_QUEUES=off pour s'en passer" + ] + }, + { + "m": 4, + "c": 35, + "h": 0, + "e": 0, + "args": [ + "/app/llama-server", + "-m", + "/m/hybrid.gguf", + "-c", + "32768", + "-b", + "2048", + "-ub", + "512", + "--host", + "0.0.0.0", + "--port", + "8080", + "--parallel", + "1", + "-ngl", + "auto", + "-ctk", + "q8_0", + "-ctv", + "q8_0" + ], + "env": { + "CUDA_SCALE_LAUNCH_QUEUES": "4x" + }, + "notes": [ + "SPLIT_MODE=tensor refusé (cache KV q8_0/q8_0 : le mode tensor n'accepte que f16, bf16 ou f32 (jamais réécrit d'office : la VRAM changerait)) : découpe par couches, comme sans la clé", + "CUDA_SCALE_LAUNCH_QUEUES=4x (2 GPU, parallélisme pipeline possible) — CUDA_LAUNCH_QUEUES=off pour s'en passer", + "cache KV q8_0/q8_0 (KV_TYPE) : modifie légèrement les sorties par rapport à f16 — choix de l'utilisateur, laissé tel quel." + ] + }, + { + "m": 4, + "c": 36, + "h": 0, + "e": 0, + "args": [ + "/app/llama-server", + "-m", + "/m/hybrid.gguf", + "-c", + "32768", + "-b", + "2048", + "-ub", + "512", + "--host", + "0.0.0.0", + "--port", + "8080", + "--parallel", + "1", + "-ngl", + "20" + ], + "env": {}, + "notes": [ + "SPLIT_MODE=tensor refusé (NGL=20 : le mode tensor veut toutes les couches sur les cartes (all)) : découpe par couches, comme sans la clé" + ] + }, + { + "m": 4, + "c": 37, + "h": 0, + "e": 0, + "args": [ + "/app/llama-server", + "-m", + "/m/hybrid.gguf", + "-c", + "32768", + "-b", + "2048", + "-ub", + "512", + "--host", + "0.0.0.0", + "--port", + "8080", + "--parallel", + "1", + "-ngl", + "auto", + "--spec-type", + "draft-mtp", + "--spec-draft-sampling", + "greedy" + ], + "env": { + "CUDA_SCALE_LAUNCH_QUEUES": "4x" + }, + "notes": [ + "CUDA_SCALE_LAUNCH_QUEUES=4x (2 GPU, parallélisme pipeline possible) — CUDA_LAUNCH_QUEUES=off pour s'en passer", + "SPEC=auto → tête MTP : sortie inchangée (chaque jeton est vérifié par le modèle), ~1-2 Go de VRAM en plus. Mesure le prefill ; SPEC=off pour couper." + ] + }, + { + "m": 4, + "c": 38, + "h": 0, + "e": 0, + "args": [ + "/app/llama-server", + "-m", + "/m/hybrid.gguf", + "-c", + "32768", + "-b", + "2048", + "-ub", + "512", + "--host", + "0.0.0.0", + "--port", + "8080", + "--parallel", + "1", + "-ngl", + "auto", + "--spec-type", + "draft-mtp", + "--spec-draft-sampling", + "greedy" + ], + "env": { + "CUDA_SCALE_LAUNCH_QUEUES": "4x" + }, + "notes": [ + "CUDA_SCALE_LAUNCH_QUEUES=4x (2 GPU, parallélisme pipeline possible) — CUDA_LAUNCH_QUEUES=off pour s'en passer", + "SPEC=mtp → tête MTP imposé : sortie inchangée, ~1-2 Go de VRAM en plus" + ] + }, + { + "m": 4, + "c": 39, + "h": 0, + "e": 0, + "args": [ + "/app/llama-server", + "-m", + "/m/hybrid.gguf", + "-c", + "32768", + "-b", + "2048", + "-ub", + "512", + "--host", + "0.0.0.0", + "--port", + "8080", + "--parallel", + "1", + "-ngl", + "auto", + "--spec-type", + "ngram-mod", + "--spec-ngram-mod-n-match", + "24", + "--spec-ngram-mod-n-min", + "48", + "--spec-ngram-mod-n-max", + "64" + ], + "env": { + "CUDA_SCALE_LAUNCH_QUEUES": "4x" + }, + "notes": [ + "CUDA_SCALE_LAUNCH_QUEUES=4x (2 GPU, parallélisme pipeline possible) — CUDA_LAUNCH_QUEUES=off pour s'en passer", + "SPEC=ngram → n-grammes du contexte (ngram-mod, brouillons de 48 à 64 jetons) : distribution inchangée (chaque jeton vérifié), ~16 Mo de RAM ; utile surtout en mode code, à mesurer (acceptation par nature dans /api/perf/summary)", + "tête MTP du modèle inutilisée avec SPEC=ngram : SPEC=mtp+ngram pour les deux", + "modèle hybride : chaque brouillon n-gramme copie l'état récurrent (point de reprise en RAM hôte) et le recharge s'il est rejeté — compare le temps par tour, pas seulement le débit" + ] + }, + { + "m": 4, + "c": 40, + "h": 0, + "e": 0, + "args": [ + "/app/llama-server", + "-m", + "/m/hybrid.gguf", + "-c", + "32768", + "-b", + "2048", + "-ub", + "512", + "--host", + "0.0.0.0", + "--port", + "8080", + "--parallel", + "1", + "-ngl", + "auto", + "--spec-type", + "draft-mtp,ngram-mod", + "--spec-ngram-mod-n-match", + "24", + "--spec-ngram-mod-n-min", + "48", + "--spec-ngram-mod-n-max", + "64", + "--spec-draft-sampling", + "greedy" + ], + "env": { + "CUDA_SCALE_LAUNCH_QUEUES": "4x" + }, + "notes": [ + "CUDA_SCALE_LAUNCH_QUEUES=4x (2 GPU, parallélisme pipeline possible) — CUDA_LAUNCH_QUEUES=off pour s'en passer", + "SPEC=mtp+ngram → tête MTP imposé : sortie inchangée, ~1-2 Go de VRAM en plus ; n-grammes en plus (ngram-mod, prioritaires quand ils trouvent une répétition)", + "modèle hybride : chaque brouillon n-gramme copie l'état récurrent (point de reprise en RAM hôte) et le recharge s'il est rejeté — compare le temps par tour, pas seulement le débit" + ] + }, + { + "m": 4, + "c": 41, + "h": 0, + "e": 0, + "args": [ + "/app/llama-server", + "-m", + "/m/hybrid.gguf", + "-c", + "32768", + "-b", + "2048", + "-ub", + "512", + "--host", + "0.0.0.0", + "--port", + "8080", + "--parallel", + "1", + "-ngl", + "auto", + "--spec-type", + "draft-mtp,ngram-mod", + "--spec-ngram-mod-n-match", + "24", + "--spec-ngram-mod-n-min", + "48", + "--spec-ngram-mod-n-max", + "64", + "--spec-draft-sampling", + "greedy" + ], + "env": { + "CUDA_SCALE_LAUNCH_QUEUES": "4x" + }, + "notes": [ + "CUDA_SCALE_LAUNCH_QUEUES=4x (2 GPU, parallélisme pipeline possible) — CUDA_LAUNCH_QUEUES=off pour s'en passer", + "SPEC=mtp+ngram → tête MTP imposé : sortie inchangée, ~1-2 Go de VRAM en plus ; n-grammes en plus (ngram-mod, prioritaires quand ils trouvent une répétition)", + "modèle hybride : chaque brouillon n-gramme copie l'état récurrent (point de reprise en RAM hôte) et le recharge s'il est rejeté — compare le temps par tour, pas seulement le débit" + ] + }, + { + "m": 4, + "c": 42, + "h": 0, + "e": 0, + "args": [ + "/app/llama-server", + "-m", + "/m/hybrid.gguf", + "-c", + "32768", + "-b", + "2048", + "-ub", + "512", + "--host", + "0.0.0.0", + "--port", + "8080", + "--parallel", + "1", + "-ngl", + "auto" + ], + "env": { + "CUDA_SCALE_LAUNCH_QUEUES": "4x" + }, + "notes": [ + "CUDA_SCALE_LAUNCH_QUEUES=4x (2 GPU, parallélisme pipeline possible) — CUDA_LAUNCH_QUEUES=off pour s'en passer", + "SPEC=turbo illisible (off, auto, mtp, ngram ou mtp+ngram) : sans décodage spéculatif" + ] + }, + { + "m": 4, + "c": 43, + "h": 0, + "e": 0, + "args": [ + "/app/llama-server", + "-m", + "/m/hybrid.gguf", + "-c", + "32768", + "-b", + "2048", + "-ub", + "512", + "--host", + "0.0.0.0", + "--port", + "8080", + "--parallel", + "1", + "-ngl", + "auto" + ], + "env": { + "CUDA_SCALE_LAUNCH_QUEUES": "4x" + }, + "notes": [ + "CUDA_SCALE_LAUNCH_QUEUES=4x (2 GPU, parallélisme pipeline possible) — CUDA_LAUNCH_QUEUES=off pour s'en passer", + "MODEL_DRAFT ignoré : SPEC=off (auto ou mtp pour s'en servir)" + ] + }, + { + "m": 4, + "c": 44, + "h": 0, + "e": 0, + "args": [ + "/app/llama-server", + "-m", + "/m/hybrid.gguf", + "-c", + "32768", + "-b", + "2048", + "-ub", + "512", + "--host", + "0.0.0.0", + "--port", + "8080", + "--parallel", + "1", + "-ngl", + "auto" + ], + "env": { + "CUDA_SCALE_LAUNCH_QUEUES": "4x" + }, + "notes": [ + "CUDA_SCALE_LAUNCH_QUEUES=4x (2 GPU, parallélisme pipeline possible) — CUDA_LAUNCH_QUEUES=off pour s'en passer", + "SPEC=mtp : MODEL_DRAFT=mtp-head.gguf introuvable — sans décodage spéculatif" + ] + }, + { + "m": 4, + "c": 45, + "h": 0, + "e": 0, + "args": [ + "/app/llama-server", + "-m", + "/m/hybrid.gguf", + "-c", + "32768", + "-b", + "2048", + "-ub", + "512", + "--host", + "0.0.0.0", + "--port", + "8080", + "--parallel", + "1", + "-ngl", + "auto", + "--spec-type", + "draft-mtp", + "--spec-draft-sampling", + "probabilistic" + ], + "env": { + "CUDA_SCALE_LAUNCH_QUEUES": "4x" + }, + "notes": [ + "CUDA_SCALE_LAUNCH_QUEUES=4x (2 GPU, parallélisme pipeline possible) — CUDA_LAUNCH_QUEUES=off pour s'en passer", + "SPEC=mtp → tête MTP imposé : sortie inchangée, ~1-2 Go de VRAM en plus", + "SPEC_N_MAX=99 illisible (1 à 64 jetons) : défaut du moteur" + ] + }, + { + "m": 4, + "c": 46, + "h": 0, + "e": 0, + "args": [ + "/app/llama-server", + "-m", + "/m/hybrid.gguf", + "-c", + "32768", + "-b", + "2048", + "-ub", + "512", + "--host", + "0.0.0.0", + "--port", + "8080", + "--parallel", + "1", + "-ngl", + "auto", + "--spec-type", + "draft-mtp", + "--spec-draft-sampling", + "greedy" + ], + "env": { + "CUDA_SCALE_LAUNCH_QUEUES": "4x" + }, + "notes": [ + "CUDA_SCALE_LAUNCH_QUEUES=4x (2 GPU, parallélisme pipeline possible) — CUDA_LAUNCH_QUEUES=off pour s'en passer", + "SPEC=auto → tête MTP : sortie inchangée (chaque jeton est vérifié par le modèle), ~1-2 Go de VRAM en plus. Mesure le prefill ; SPEC=off pour couper.", + "SPEC_SAMPLING=probabilistic ignoré avec SPEC=auto (SPEC=mtp pour le choisir) : greedy" + ] + }, + { + "m": 4, + "c": 47, + "h": 0, + "e": 0, + "args": [ + "/app/llama-server", + "-m", + "/m/hybrid.gguf", + "-c", + "32768", + "-b", + "2048", + "-ub", + "512", + "--host", + "0.0.0.0", + "--port", + "8080", + "--parallel", + "1", + "-ngl", + "auto", + "--spec-type", + "draft-mtp", + "--spec-draft-sampling", + "greedy" + ], + "env": { + "CUDA_SCALE_LAUNCH_QUEUES": "4x" + }, + "notes": [ + "CUDA_SCALE_LAUNCH_QUEUES=4x (2 GPU, parallélisme pipeline possible) — CUDA_LAUNCH_QUEUES=off pour s'en passer", + "SPEC=mtp → tête MTP imposé : sortie inchangée, ~1-2 Go de VRAM en plus", + "SPEC_SAMPLING=aléatoire illisible (greedy ou probabilistic) : greedy" + ] + }, + { + "m": 4, + "c": 48, + "h": 0, + "e": 0, + "args": [ + "/app/llama-server", + "-m", + "/m/hybrid.gguf", + "-c", + "32768", + "-b", + "2048", + "-ub", + "512", + "--host", + "0.0.0.0", + "--port", + "8080", + "--parallel", + "1", + "-ngl", + "auto", + "--spec-type", + "ngram-mod", + "--spec-ngram-mod-n-match", + "24", + "--spec-ngram-mod-n-min", + "48", + "--spec-ngram-mod-n-max", + "64" + ], + "env": { + "CUDA_SCALE_LAUNCH_QUEUES": "4x" + }, + "notes": [ + "CUDA_SCALE_LAUNCH_QUEUES=4x (2 GPU, parallélisme pipeline possible) — CUDA_LAUNCH_QUEUES=off pour s'en passer", + "SPEC=ngram → n-grammes du contexte (ngram-mod, brouillons de 48 à 64 jetons) : distribution inchangée (chaque jeton vérifié), ~16 Mo de RAM ; utile surtout en mode code, à mesurer (acceptation par nature dans /api/perf/summary)", + "MODEL_DRAFT ignoré avec SPEC=ngram : SPEC=mtp+ngram pour les deux", + "SPEC_N_MAX et SPEC_SAMPLING ignorés : les n-grammes n'ont pas de brouillon à régler", + "modèle hybride : chaque brouillon n-gramme copie l'état récurrent (point de reprise en RAM hôte) et le recharge s'il est rejeté — compare le temps par tour, pas seulement le débit" + ] + }, + { + "m": 4, + "c": 49, + "h": 0, + "e": 0, + "args": [ + "/app/llama-server", + "-m", + "/m/hybrid.gguf", + "-c", + "32768", + "-b", + "2048", + "-ub", + "512", + "--host", + "0.0.0.0", + "--port", + "8080", + "--parallel", + "1", + "-ngl", + "auto" + ], + "env": { + "CUDA_SCALE_LAUNCH_QUEUES": "4x" + }, + "notes": [ + "CUDA_SCALE_LAUNCH_QUEUES=4x (2 GPU, parallélisme pipeline possible) — CUDA_LAUNCH_QUEUES=off pour s'en passer", + "SPEC=auto : MODEL_DRAFT=small.gguf introuvable — sans décodage spéculatif" + ] + }, + { + "m": 4, + "c": 50, + "h": 0, + "e": 0, + "args": [ + "/app/llama-server", + "-m", + "/m/hybrid.gguf", + "-c", + "32768", + "-b", + "2048", + "-ub", + "512", + "--host", + "0.0.0.0", + "--port", + "8080", + "--parallel", + "1", + "-ngl", + "auto", + "--spec-type", + "ngram-mod", + "--spec-ngram-mod-n-match", + "24", + "--spec-ngram-mod-n-min", + "48", + "--spec-ngram-mod-n-max", + "64" + ], + "env": { + "CUDA_SCALE_LAUNCH_QUEUES": "4x" + }, + "notes": [ + "CUDA_SCALE_LAUNCH_QUEUES=4x (2 GPU, parallélisme pipeline possible) — CUDA_LAUNCH_QUEUES=off pour s'en passer", + "SPEC=mtp+ngram : sans MTP (MODEL_DRAFT=small.gguf introuvable) → n-grammes seuls. SPEC=ngram → n-grammes du contexte (ngram-mod, brouillons de 48 à 64 jetons) : distribution inchangée (chaque jeton vérifié), ~16 Mo de RAM ; utile surtout en mode code, à mesurer (acceptation par nature dans /api/perf/summary)", + "modèle hybride : chaque brouillon n-gramme copie l'état récurrent (point de reprise en RAM hôte) et le recharge s'il est rejeté — compare le temps par tour, pas seulement le débit" + ] + }, + { + "m": 4, + "c": 51, + "h": 0, + "e": 0, + "args": [ + "/app/llama-server", + "-m", + "/m/hybrid.gguf", + "-c", + "32768", + "-b", + "2048", + "-ub", + "512", + "--host", + "0.0.0.0", + "--port", + "8080", + "--parallel", + "1", + "-ngl", + "auto" + ], + "env": {}, + "notes": null + }, + { + "m": 4, + "c": 52, + "h": 0, + "e": 0, + "args": [ + "/app/llama-server", + "-m", + "/m/hybrid.gguf", + "-c", + "32768", + "-b", + "2048", + "-ub", + "512", + "--host", + "0.0.0.0", + "--port", + "8080", + "--parallel", + "1", + "-ngl", + "auto" + ], + "env": { + "CUDA_SCALE_LAUNCH_QUEUES": "2x" + }, + "notes": [ + "CUDA_SCALE_LAUNCH_QUEUES=2x (CUDA_LAUNCH_QUEUES)" + ] + }, + { + "m": 4, + "c": 53, + "h": 0, + "e": 0, + "args": [ + "/app/llama-server", + "-m", + "/m/hybrid.gguf", + "-c", + "32768", + "-b", + "2048", + "-ub", + "512", + "--host", + "0.0.0.0", + "--port", + "8080", + "--parallel", + "1", + "-ngl", + "auto" + ], + "env": { + "CUDA_SCALE_LAUNCH_QUEUES": "4x" + }, + "notes": [ + "CUDA_LAUNCH_QUEUES=8x ignoré (0.25x, 0.5x, 2x, 4x ou off) → automatique", + "CUDA_SCALE_LAUNCH_QUEUES=4x (2 GPU, parallélisme pipeline possible) — CUDA_LAUNCH_QUEUES=off pour s'en passer" + ] + }, + { + "m": 4, + "c": 54, + "h": 0, + "e": 0, + "args": [ + "/app/llama-server", + "-m", + "/m/hybrid.gguf", + "-c", + "32768", + "-b", + "2048", + "-ub", + "512", + "--host", + "0.0.0.0", + "--port", + "8080", + "--parallel", + "1", + "-ngl", + "auto" + ], + "env": { + "CUDA_SCALE_LAUNCH_QUEUES": "4x" + }, + "notes": [ + "CUDA_SCALE_LAUNCH_QUEUES=4x (2 GPU, parallélisme pipeline possible) — CUDA_LAUNCH_QUEUES=off pour s'en passer" + ] + }, + { + "m": 4, + "c": 55, + "h": 0, + "e": 0, + "args": [ + "/app/llama-server", + "-m", + "/m/hybrid.gguf", + "-c", + "32768", + "-b", + "2048", + "-ub", + "512", + "--host", + "0.0.0.0", + "--port", + "8080", + "--parallel", + "1", + "-ngl", + "auto" + ], + "env": { + "CUDA_DEVICE_ORDER": "PCI_BUS_ID", + "CUDA_SCALE_LAUNCH_QUEUES": "4x", + "CUDA_VISIBLE_DEVICES": "1,0" + }, + "notes": [ + "CUDA_SCALE_LAUNCH_QUEUES=4x (2 GPU, parallélisme pipeline possible) — CUDA_LAUNCH_QUEUES=off pour s'en passer" + ] + }, + { + "m": 4, + "c": 56, + "h": 0, + "e": 0, + "args": [ + "/app/llama-server", + "-m", + "/m/hybrid.gguf", + "-c", + "32768", + "-b", + "2048", + "-ub", + "512", + "--host", + "0.0.0.0", + "--port", + "8080", + "--parallel", + "1", + "-ngl", + "auto", + "--fit-target", + "1024,3072" + ], + "env": { + "CUDA_SCALE_LAUNCH_QUEUES": "4x" + }, + "notes": [ + "CUDA_SCALE_LAUNCH_QUEUES=4x (2 GPU, parallélisme pipeline possible) — CUDA_LAUNCH_QUEUES=off pour s'en passer", + "--fit-target 1024,3072 (FIT_TARGET, Mio libres par carte, dans l'ordre vu par le moteur) — vérifie « offloaded N/N layers » dans le journal" + ] + }, + { + "m": 4, + "c": 57, + "h": 0, + "e": 0, + "args": [ + "/app/llama-server", + "-m", + "/m/hybrid.gguf", + "-c", + "32768", + "-b", + "2048", + "-ub", + "512", + "--host", + "0.0.0.0", + "--port", + "8080", + "--parallel", + "1", + "-ngl", + "auto", + "--fit-target", + "1024" + ], + "env": { + "CUDA_SCALE_LAUNCH_QUEUES": "4x" + }, + "notes": [ + "CUDA_SCALE_LAUNCH_QUEUES=4x (2 GPU, parallélisme pipeline possible) — CUDA_LAUNCH_QUEUES=off pour s'en passer", + "--fit-target 1024 (FIT_TARGET, Mio libres par carte, dans l'ordre vu par le moteur) — vérifie « offloaded N/N layers » dans le journal ; marges sous 1024 Mio remontées à 1024" + ] + }, + { + "m": 4, + "c": 58, + "h": 0, + "e": 0, + "args": [ + "/app/llama-server", + "-m", + "/m/hybrid.gguf", + "-c", + "32768", + "-b", + "2048", + "-ub", + "512", + "--host", + "0.0.0.0", + "--port", + "8080", + "--parallel", + "1", + "-ngl", + "auto" + ], + "env": { + "CUDA_SCALE_LAUNCH_QUEUES": "4x" + }, + "notes": [ + "CUDA_SCALE_LAUNCH_QUEUES=4x (2 GPU, parallélisme pipeline possible) — CUDA_LAUNCH_QUEUES=off pour s'en passer", + "FIT_TARGET=1024,,3072 ignoré : des Mio par carte, ex. 1024,3072 (8 valeurs au plus)" + ] + }, + { + "m": 4, + "c": 59, + "h": 0, + "e": 0, + "args": [ + "/app/llama-server", + "-m", + "/m/hybrid.gguf", + "-c", + "32768", + "-b", + "2048", + "-ub", + "512", + "--host", + "0.0.0.0", + "--port", + "8080", + "--parallel", + "1", + "-ngl", + "auto" + ], + "env": { + "CUDA_SCALE_LAUNCH_QUEUES": "4x" + }, + "notes": [ + "CUDA_SCALE_LAUNCH_QUEUES=4x (2 GPU, parallélisme pipeline possible) — CUDA_LAUNCH_QUEUES=off pour s'en passer", + "FIT_TARGET=1,2,3,4,5,6,7,8,9 ignoré : des Mio par carte, ex. 1024,3072 (8 valeurs au plus)" + ] + }, + { + "m": 4, + "c": 60, + "h": 0, + "e": 0, + "args": [ + "/app/llama-server", + "-m", + "/m/hybrid.gguf", + "-c", + "0", + "-b", + "2048", + "-ub", + "512", + "--host", + "0.0.0.0", + "--port", + "8080", + "--parallel", + "1", + "-ngl", + "auto" + ], + "env": { + "CUDA_SCALE_LAUNCH_QUEUES": "4x" + }, + "notes": [ + "CUDA_SCALE_LAUNCH_QUEUES=4x (2 GPU, parallélisme pipeline possible) — CUDA_LAUNCH_QUEUES=off pour s'en passer", + "FIT_TARGET=2048/4096 ignoré : contexte « 0 » non fixé — --fit pourrait le réduire pour tenir ; donne un CTX chiffré" + ] + }, + { + "m": 4, + "c": 61, + "h": 0, + "e": 0, + "args": [ + "/app/llama-server", + "-m", + "/m/hybrid.gguf", + "-c", + "32768", + "-b", + "2048", + "-ub", + "512", + "--host", + "0.0.0.0", + "--port", + "8080", + "--parallel", + "1", + "-ngl", + "30" + ], + "env": {}, + "notes": [ + "FIT_TARGET=2048 ignoré : --fit inactif (couches GPU fixées à 30), la marge ne servirait à rien" + ] + }, + { + "m": 4, + "c": 62, + "h": 0, + "e": 0, + "args": [ + "/app/llama-server", + "-m", + "/m/hybrid.gguf", + "-c", + "32768", + "-b", + "2048", + "-ub", + "512", + "--host", + "0.0.0.0", + "--port", + "8080", + "--parallel", + "1", + "-ngl", + "auto" + ], + "env": { + "CUDA_SCALE_LAUNCH_QUEUES": "4x", + "GGML_OP_OFFLOAD_MIN_BATCH": "128" + }, + "notes": [ + "CUDA_SCALE_LAUNCH_QUEUES=4x (2 GPU, parallélisme pipeline possible) — CUDA_LAUNCH_QUEUES=off pour s'en passer", + "GGML_OP_OFFLOAD_MIN_BATCH=128 (OP_OFFLOAD_MIN_BATCH) : lots de moins de 128 jetons calculés sur CPU pour les poids restés en RAM" + ] + }, + { + "m": 4, + "c": 63, + "h": 0, + "e": 0, + "args": [ + "/app/llama-server", + "-m", + "/m/hybrid.gguf", + "-c", + "32768", + "-b", + "2048", + "-ub", + "512", + "--host", + "0.0.0.0", + "--port", + "8080", + "--parallel", + "1", + "-ngl", + "auto" + ], + "env": { + "CUDA_SCALE_LAUNCH_QUEUES": "4x" + }, + "notes": [ + "CUDA_SCALE_LAUNCH_QUEUES=4x (2 GPU, parallélisme pipeline possible) — CUDA_LAUNCH_QUEUES=off pour s'en passer" + ] + }, + { + "m": 4, + "c": 64, + "h": 0, + "e": 0, + "args": [ + "/app/llama-server", + "-m", + "/m/hybrid.gguf", + "-c", + "32768", + "-b", + "2048", + "-ub", + "512", + "--host", + "0.0.0.0", + "--port", + "8080", + "--parallel", + "1", + "-ngl", + "auto" + ], + "env": { + "CUDA_SCALE_LAUNCH_QUEUES": "4x", + "GGML_CUDA_GRAPH_OPT": "1" + }, + "notes": [ + "CUDA_SCALE_LAUNCH_QUEUES=4x (2 GPU, parallélisme pipeline possible) — CUDA_LAUNCH_QUEUES=off pour s'en passer", + "GGML_CUDA_GRAPH_OPT=1 (CUDA_GRAPH_OPT, expérimental) — si le moteur s'arrête sur un GGML_ASSERT, CUDA_GRAPH_OPT=off puis redémarre" + ] + }, + { + "m": 4, + "c": 65, + "h": 0, + "e": 0, + "args": [ + "/app/llama-server", + "-m", + "/m/hybrid.gguf", + "-c", + "32768", + "-b", + "2048", + "-ub", + "512", + "--host", + "0.0.0.0", + "--port", + "8080", + "--parallel", + "1", + "-ngl", + "auto" + ], + "env": { + "CUDA_SCALE_LAUNCH_QUEUES": "4x" + }, + "notes": [ + "CUDA_SCALE_LAUNCH_QUEUES=4x (2 GPU, parallélisme pipeline possible) — CUDA_LAUNCH_QUEUES=off pour s'en passer", + "CUDA_GRAPH_OPT=oui ignoré (on ou off)" + ] + }, + { + "m": 4, + "c": 66, + "h": 0, + "e": 0, + "args": [ + "/app/llama-server", + "-m", + "/m/hybrid.gguf", + "-c", + "32768", + "-b", + "2048", + "-ub", + "512", + "--host", + "0.0.0.0", + "--port", + "8080", + "--parallel", + "1", + "-ngl", + "auto" + ], + "env": { + "CUDA_SCALE_LAUNCH_QUEUES": "4x" + }, + "notes": [ + "CUDA_SCALE_LAUNCH_QUEUES=4x (2 GPU, parallélisme pipeline possible) — CUDA_LAUNCH_QUEUES=off pour s'en passer" + ] + }, + { + "m": 4, + "c": 67, + "h": 0, + "e": 0, + "args": [ + "/app/llama-server", + "-m", + "/m/hybrid.gguf", + "-c", + "32768", + "-b", + "2048", + "-ub", + "512", + "--host", + "0.0.0.0", + "--port", + "8080", + "--parallel", + "1", + "-ngl", + "auto", + "--ctx-checkpoints", + "8" + ], + "env": { + "CUDA_SCALE_LAUNCH_QUEUES": "4x" + }, + "notes": [ + "CUDA_SCALE_LAUNCH_QUEUES=4x (2 GPU, parallélisme pipeline possible) — CUDA_LAUNCH_QUEUES=off pour s'en passer" + ] + }, + { + "m": 4, + "c": 68, + "h": 0, + "e": 0, + "args": [ + "/app/llama-server", + "-m", + "/m/hybrid.gguf", + "-c", + "32768", + "-b", + "2048", + "-ub", + "512", + "--host", + "0.0.0.0", + "--port", + "8080", + "--parallel", + "1", + "-ngl", + "auto" + ], + "env": { + "CUDA_SCALE_LAUNCH_QUEUES": "4x" + }, + "notes": [ + "CUDA_SCALE_LAUNCH_QUEUES=4x (2 GPU, parallélisme pipeline possible) — CUDA_LAUNCH_QUEUES=off pour s'en passer", + "CTX_CHECKPOINTS=beaucoup illisible (un nombre de points de reprise par slot, ≥ 0) : ignoré" + ] + }, + { + "m": 4, + "c": 69, + "h": 0, + "e": 0, + "args": [ + "/app/llama-server", + "-m", + "/m/hybrid.gguf", + "-c", + "32768", + "-b", + "2048", + "-ub", + "512", + "--host", + "0.0.0.0", + "--port", + "8080", + "--parallel", + "1", + "-ngl", + "auto", + "--checkpoint-min-step", + "1024" + ], + "env": { + "CUDA_SCALE_LAUNCH_QUEUES": "4x" + }, + "notes": [ + "CUDA_SCALE_LAUNCH_QUEUES=4x (2 GPU, parallélisme pipeline possible) — CUDA_LAUNCH_QUEUES=off pour s'en passer" + ] + }, + { + "m": 4, + "c": 70, + "h": 0, + "e": 0, + "args": [ + "/app/llama-server", + "-m", + "/m/hybrid.gguf", + "-c", + "32768", + "-b", + "2048", + "-ub", + "512", + "--host", + "0.0.0.0", + "--port", + "8080", + "--parallel", + "1", + "-ngl", + "auto" + ], + "env": { + "CUDA_SCALE_LAUNCH_QUEUES": "4x" + }, + "notes": [ + "CUDA_SCALE_LAUNCH_QUEUES=4x (2 GPU, parallélisme pipeline possible) — CUDA_LAUNCH_QUEUES=off pour s'en passer", + "CKPT_MIN_STEP=0 refusé (un nombre de jetons > 0 ; 0 laisserait le moteur jeter le point de reprise du début de la conversation) : défaut du moteur" + ] + }, + { + "m": 4, + "c": 71, + "h": 0, + "e": 0, + "args": [ + "/app/llama-server", + "-m", + "/m/hybrid.gguf", + "-c", + "32768", + "-b", + "2048", + "-ub", + "512", + "--host", + "0.0.0.0", + "--port", + "8080", + "--parallel", + "1", + "-ngl", + "auto" + ], + "env": { + "CUDA_SCALE_LAUNCH_QUEUES": "4x" + }, + "notes": [ + "CUDA_SCALE_LAUNCH_QUEUES=4x (2 GPU, parallélisme pipeline possible) — CUDA_LAUNCH_QUEUES=off pour s'en passer", + "SLOT_PERSIST refusé (moteur joignable par d'autres sans clé d'API (HOST=127.0.0.1 ou une clé d'API)) : l'état du slot ne sera pas gardé à la bascule de preset" + ] + }, + { + "m": 4, + "c": 72, + "h": 0, + "e": 0, + "args": [ + "/app/llama-server", + "-m", + "/m/hybrid.gguf", + "-c", + "32768", + "-b", + "2048", + "-ub", + "512", + "--host", + "127.0.0.1", + "--port", + "8080", + "--parallel", + "1", + "-ngl", + "auto", + "--slot-save-path", + "/data/slots" + ], + "env": { + "CUDA_SCALE_LAUNCH_QUEUES": "4x" + }, + "notes": [ + "CUDA_SCALE_LAUNCH_QUEUES=4x (2 GPU, parallélisme pipeline possible) — CUDA_LAUNCH_QUEUES=off pour s'en passer" + ] + }, + { + "m": 4, + "c": 73, + "h": 0, + "e": 0, + "args": [ + "/app/llama-server", + "-m", + "/m/hybrid.gguf", + "-c", + "32768", + "-b", + "2048", + "-ub", + "512", + "--host", + "127.0.0.1", + "--port", + "8080", + "--parallel", + "1", + "-ngl", + "auto", + "--slot-save-path", + "/data/slots", + "--spec-type", + "draft-mtp", + "--spec-draft-sampling", + "greedy" + ], + "env": { + "CUDA_SCALE_LAUNCH_QUEUES": "4x" + }, + "notes": [ + "CUDA_SCALE_LAUNCH_QUEUES=4x (2 GPU, parallélisme pipeline possible) — CUDA_LAUNCH_QUEUES=off pour s'en passer", + "SLOT_PERSIST refusé (décodage spéculatif : la sauvegarde du moteur ne garde pas l'état du brouillon) : l'état du slot ne sera pas gardé à la bascule de preset", + "SPEC=mtp → tête MTP imposé : sortie inchangée, ~1-2 Go de VRAM en plus" + ] + }, + { + "m": 4, + "c": 74, + "h": 0, + "e": 0, + "args": [ + "/app/llama-server", + "-m", + "/m/hybrid.gguf", + "-c", + "32768", + "-b", + "2048", + "-ub", + "512", + "--host", + "0.0.0.0", + "--port", + "8080", + "--parallel", + "1", + "-ngl", + "auto", + "--load-mode", + "none" + ], + "env": { + "CUDA_SCALE_LAUNCH_QUEUES": "4x" + }, + "notes": [ + "CUDA_SCALE_LAUNCH_QUEUES=4x (2 GPU, parallélisme pipeline possible) — CUDA_LAUNCH_QUEUES=off pour s'en passer" + ] + }, + { + "m": 4, + "c": 75, + "h": 0, + "e": 0, + "args": [ + "/app/llama-server", + "-m", + "/m/hybrid.gguf", + "-c", + "32768", + "-b", + "2048", + "-ub", + "512", + "--host", + "0.0.0.0", + "--port", + "8080", + "--parallel", + "1", + "-ngl", + "auto", + "--load-mode", + "mlock" + ], + "env": { + "CUDA_SCALE_LAUNCH_QUEUES": "4x" + }, + "notes": [ + "CUDA_SCALE_LAUNCH_QUEUES=4x (2 GPU, parallélisme pipeline possible) — CUDA_LAUNCH_QUEUES=off pour s'en passer" + ] + }, + { + "m": 4, + "c": 76, + "h": 0, + "e": 0, + "args": [ + "/app/llama-server", + "-m", + "/m/hybrid.gguf", + "-c", + "32768", + "-b", + "2048", + "-ub", + "512", + "--host", + "0.0.0.0", + "--port", + "8080", + "--parallel", + "1", + "-ngl", + "auto", + "--load-mode", + "mmap+mlock" + ], + "env": { + "CUDA_SCALE_LAUNCH_QUEUES": "4x" + }, + "notes": [ + "CUDA_SCALE_LAUNCH_QUEUES=4x (2 GPU, parallélisme pipeline possible) — CUDA_LAUNCH_QUEUES=off pour s'en passer" + ] + }, + { + "m": 4, + "c": 77, + "h": 0, + "e": 0, + "args": [ + "/app/llama-server", + "-m", + "/m/hybrid.gguf", + "-c", + "32768", + "-b", + "2048", + "-ub", + "512", + "--host", + "0.0.0.0", + "--port", + "8080", + "--parallel", + "1", + "-ngl", + "auto", + "--load-mode", + "none" + ], + "env": { + "CUDA_SCALE_LAUNCH_QUEUES": "4x" + }, + "notes": [ + "CUDA_SCALE_LAUNCH_QUEUES=4x (2 GPU, parallélisme pipeline possible) — CUDA_LAUNCH_QUEUES=off pour s'en passer" + ] + }, + { + "m": 4, + "c": 78, + "h": 0, + "e": 0, + "args": [ + "/app/llama-server", + "-m", + "/m/hybrid.gguf", + "-c", + "32768", + "-b", + "2048", + "-ub", + "512", + "--host", + "0.0.0.0", + "--port", + "8080", + "--parallel", + "1", + "-ngl", + "auto", + "--load-mode", + "mmap" + ], + "env": { + "CUDA_SCALE_LAUNCH_QUEUES": "4x" + }, + "notes": [ + "CUDA_SCALE_LAUNCH_QUEUES=4x (2 GPU, parallélisme pipeline possible) — CUDA_LAUNCH_QUEUES=off pour s'en passer" + ] + }, + { + "m": 4, + "c": 79, + "h": 0, + "e": 0, + "args": [ + "/app/llama-server", + "-m", + "/m/hybrid.gguf", + "-c", + "32768", + "-b", + "2048", + "-ub", + "512", + "--host", + "0.0.0.0", + "--port", + "8080", + "--parallel", + "1", + "-ngl", + "auto", + "--load-mode", + "dio" + ], + "env": { + "CUDA_SCALE_LAUNCH_QUEUES": "4x" + }, + "notes": [ + "CUDA_SCALE_LAUNCH_QUEUES=4x (2 GPU, parallélisme pipeline possible) — CUDA_LAUNCH_QUEUES=off pour s'en passer" + ] + }, + { + "m": 4, + "c": 80, + "h": 0, + "e": 0, + "args": [ + "/app/llama-server", + "-m", + "/m/hybrid.gguf", + "-c", + "32768", + "-b", + "2048", + "-ub", + "512", + "--host", + "0.0.0.0", + "--port", + "8080", + "--parallel", + "1", + "-ngl", + "auto", + "--load-mode=mlock" + ], + "env": { + "CUDA_SCALE_LAUNCH_QUEUES": "4x" + }, + "notes": [ + "CUDA_SCALE_LAUNCH_QUEUES=4x (2 GPU, parallélisme pipeline possible) — CUDA_LAUNCH_QUEUES=off pour s'en passer" + ] + }, + { + "m": 4, + "c": 81, + "h": 0, + "e": 0, + "args": [ + "/app/llama-server", + "-m", + "/m/hybrid.gguf", + "-c", + "32768", + "-b", + "2048", + "-ub", + "512", + "--host", + "0.0.0.0", + "--port", + "8080", + "-ngl", + "30", + "--parallel", + "4" + ], + "env": {}, + "notes": null + }, + { + "m": 4, + "c": 82, + "h": 0, + "e": 0, + "args": [ + "/app/llama-server", + "-m", + "/m/hybrid.gguf", + "-c", + "32768", + "-b", + "2048", + "-ub", + "512", + "--host", + "0.0.0.0", + "--port", + "8080", + "--parallel", + "1", + "-ngl", + "auto", + "-t", + "6", + "-tb", + "3" + ], + "env": { + "CUDA_SCALE_LAUNCH_QUEUES": "4x" + }, + "notes": [ + "CUDA_SCALE_LAUNCH_QUEUES=4x (2 GPU, parallélisme pipeline possible) — CUDA_LAUNCH_QUEUES=off pour s'en passer" + ] + }, + { + "m": 4, + "c": 83, + "h": 0, + "e": 0, + "args": [ + "/app/llama-server", + "-m", + "/m/hybrid.gguf", + "-c", + "32768", + "-tb", + "0", + "-b", + "2048", + "-ub", + "512", + "--host", + "0.0.0.0", + "--port", + "8080", + "--parallel", + "1", + "-ngl", + "auto", + "-Cb", + "0xff", + "--poll-batch", + "1" + ], + "env": { + "CUDA_SCALE_LAUNCH_QUEUES": "4x" + }, + "notes": [ + "CUDA_SCALE_LAUNCH_QUEUES=4x (2 GPU, parallélisme pipeline possible) — CUDA_LAUNCH_QUEUES=off pour s'en passer", + "EXTRA_ARGS règle le CPU du batch (-Cb, --poll-batch…) : -tb 0 posé pour que llama.cpp le garde (tous les threads logiques) ; THREADS_BATCH pour choisir le nombre" + ] + }, + { + "m": 4, + "c": 84, + "h": 0, + "e": 0, + "args": [ + "/app/llama-server", + "-m", + "/m/hybrid.gguf", + "-c", + "32768", + "-t", + "6", + "-tb", + "6", + "-b", + "2048", + "-ub", + "512", + "--host", + "0.0.0.0", + "--port", + "8080", + "--parallel", + "1", + "-ngl", + "auto", + "--prio-batch", + "2" + ], + "env": { + "CUDA_SCALE_LAUNCH_QUEUES": "4x" + }, + "notes": [ + "CUDA_SCALE_LAUNCH_QUEUES=4x (2 GPU, parallélisme pipeline possible) — CUDA_LAUNCH_QUEUES=off pour s'en passer" + ] + }, + { + "m": 4, + "c": 85, + "h": 0, + "e": 0, + "args": [ + "/app/llama-server", + "-m", + "/m/hybrid.gguf", + "-c", + "32768", + "-b", + "2048", + "-ub", + "512", + "--host", + "0.0.0.0", + "--port", + "8080", + "--parallel", + "1", + "-ngl", + "auto", + "--cache-reuse", + "256", + "--context-shift" + ], + "env": { + "CUDA_SCALE_LAUNCH_QUEUES": "4x" + }, + "notes": [ + "CUDA_SCALE_LAUNCH_QUEUES=4x (2 GPU, parallélisme pipeline possible) — CUDA_LAUNCH_QUEUES=off pour s'en passer", + "avertissement : --context-shift (EXTRA_ARGS) — contexte plein, le moteur jette des jetons anciens et le modèle perd une partie de la conversation sans le savoir.", + "avertissement : --cache-reuse 256 (EXTRA_ARGS) — réutilise des morceaux de cache calculés sous un autre préfixe : les sorties ne sont plus exactement celles d'un calcul complet." + ] + }, + { + "m": 4, + "c": 86, + "h": 0, + "e": 0, + "args": [ + "/app/llama-server", + "-m", + "/m/hybrid.gguf", + "-c", + "32768", + "-b", + "2048", + "-ub", + "512", + "--host", + "0.0.0.0", + "--port", + "8080", + "--parallel", + "1", + "-ngl", + "auto", + "-ctk", + "q8_0", + "-ctv", + "q8_0" + ], + "env": { + "CUDA_SCALE_LAUNCH_QUEUES": "4x" + }, + "notes": [ + "CUDA_SCALE_LAUNCH_QUEUES=4x (2 GPU, parallélisme pipeline possible) — CUDA_LAUNCH_QUEUES=off pour s'en passer", + "cache KV q8_0/q8_0 (défini par EXTRA_ARGS) : modifie légèrement les sorties par rapport à f16 — choix de l'utilisateur, laissé tel quel." + ] + }, + { + "m": 4, + "c": 87, + "h": 0, + "e": 0, + "args": [ + "/app/llama-server", + "-m", + "/m/hybrid.gguf", + "-c", + "32768", + "-b", + "2048", + "-ub", + "512", + "--host", + "0.0.0.0", + "--port", + "8080", + "--parallel", + "1", + "-ngl", + "auto", + "-ot", + "exps=CPU" + ], + "env": {}, + "notes": null + }, + { + "m": 4, + "c": 88, + "h": 0, + "e": 0, + "args": [ + "/app/llama-server", + "-m", + "/m/hybrid.gguf", + "-c", + "32768", + "-b", + "2048", + "-ub", + "512", + "--host", + "0.0.0.0", + "--port", + "8080", + "--parallel", + "1", + "-ngl", + "auto", + "-ot", + "blk\\.(1|2)\\.ffn_.*_exps=CPU", + "-ub", + "512" + ], + "env": {}, + "notes": null + }, + { + "m": 4, + "c": 89, + "h": 0, + "e": 0, + "args": [ + "/app/llama-server", + "-m", + "/m/hybrid.gguf", + "-c", + "32768", + "-b", + "2048", + "-ub", + "512", + "--host", + "0.0.0.0", + "--port", + "8080", + "--parallel", + "1", + "-ngl", + "auto", + "--n-cpu-moe", + "20" + ], + "env": {}, + "notes": null + }, + { + "m": 4, + "c": 90, + "h": 0, + "e": 0, + "args": [ + "/app/llama-server", + "-m", + "/m/hybrid.gguf", + "-c", + "32768", + "-b", + "2048", + "-ub", + "512", + "--host", + "0.0.0.0", + "--port", + "8080", + "--parallel", + "1", + "-ngl", + "auto", + "--cpu-moe" + ], + "env": {}, + "notes": null + }, + { + "m": 4, + "c": 91, + "h": 0, + "e": 0, + "args": [ + "/app/llama-server", + "-m", + "/m/hybrid.gguf", + "-c", + "32768", + "-b", + "2048", + "-ub", + "512", + "--host", + "0.0.0.0", + "--port", + "8080", + "--parallel", + "1", + "-ngl", + "auto", + "-sm", + "row" + ], + "env": {}, + "notes": null + }, + { + "m": 4, + "c": 92, + "h": 0, + "e": 0, + "args": [ + "/app/llama-server", + "-m", + "/m/hybrid.gguf", + "-c", + "32768", + "-b", + "2048", + "-ub", + "512", + "--host", + "0.0.0.0", + "--port", + "8080", + "--parallel", + "1", + "-ngl", + "auto", + "-ts", + "1,1" + ], + "env": { + "CUDA_SCALE_LAUNCH_QUEUES": "4x" + }, + "notes": [ + "CUDA_SCALE_LAUNCH_QUEUES=4x (2 GPU, parallélisme pipeline possible) — CUDA_LAUNCH_QUEUES=off pour s'en passer" + ] + }, + { + "m": 4, + "c": 93, + "h": 0, + "e": 0, + "args": [ + "/app/llama-server", + "-m", + "/m/hybrid.gguf", + "-c", + "32768", + "-b", + "2048", + "-ub", + "512", + "--host", + "0.0.0.0", + "--port", + "8080", + "--parallel", + "1", + "-ngl", + "auto", + "-dev", + "CUDA0,CUDA1" + ], + "env": { + "CUDA_SCALE_LAUNCH_QUEUES": "4x" + }, + "notes": [ + "CUDA_SCALE_LAUNCH_QUEUES=4x (2 GPU, parallélisme pipeline possible) — CUDA_LAUNCH_QUEUES=off pour s'en passer" + ] + }, + { + "m": 4, + "c": 94, + "h": 0, + "e": 0, + "args": [ + "/app/llama-server", + "-m", + "/m/hybrid.gguf", + "-c", + "32768", + "-b", + "2048", + "-ub", + "512", + "--host", + "0.0.0.0", + "--port", + "8080", + "--parallel", + "1", + "-ngl", + "auto", + "-dev", + "Vulkan0" + ], + "env": {}, + "notes": null + }, + { + "m": 4, + "c": 95, + "h": 0, + "e": 0, + "args": [ + "/app/llama-server", + "-m", + "/m/hybrid.gguf", + "-c", + "32768", + "-b", + "2048", + "-ub", + "512", + "--host", + "0.0.0.0", + "--port", + "8080", + "--parallel", + "1", + "-ngl", + "auto", + "--fit", + "off" + ], + "env": { + "CUDA_SCALE_LAUNCH_QUEUES": "4x" + }, + "notes": [ + "CUDA_SCALE_LAUNCH_QUEUES=4x (2 GPU, parallélisme pipeline possible) — CUDA_LAUNCH_QUEUES=off pour s'en passer" + ] + }, + { + "m": 4, + "c": 96, + "h": 0, + "e": 0, + "args": [ + "/app/llama-server", + "-m", + "/m/hybrid.gguf", + "-c", + "32768", + "-b", + "2048", + "-ub", + "512", + "--host", + "0.0.0.0", + "--port", + "8080", + "--parallel", + "1", + "-ngl", + "auto", + "--mmproj", + "/m/x.gguf" + ], + "env": { + "CUDA_SCALE_LAUNCH_QUEUES": "4x" + }, + "notes": [ + "CUDA_SCALE_LAUNCH_QUEUES=4x (2 GPU, parallélisme pipeline possible) — CUDA_LAUNCH_QUEUES=off pour s'en passer" + ] + }, + { + "m": 4, + "c": 97, + "h": 0, + "e": 0, + "args": [ + "/app/llama-server", + "-m", + "/m/hybrid.gguf", + "-c", + "32768", + "-b", + "2048", + "-ub", + "512", + "--host", + "0.0.0.0", + "--port", + "8080", + "--parallel", + "1", + "-ngl", + "auto", + "--spec-synth-len", + "4" + ], + "env": { + "CUDA_SCALE_LAUNCH_QUEUES": "4x" + }, + "notes": [ + "CUDA_SCALE_LAUNCH_QUEUES=4x (2 GPU, parallélisme pipeline possible) — CUDA_LAUNCH_QUEUES=off pour s'en passer", + "avertissement : --spec-synth-len — le moteur accepte des jetons du brouillon AU HASARD, sans vérification : les réponses ne sont plus celles du modèle. Réservé aux mesures, retire-le." + ] + }, + { + "m": 4, + "c": 98, + "h": 0, + "e": 0, + "args": [ + "/app/llama-server", + "-m", + "/m/hybrid.gguf", + "-c", + "32768", + "-b", + "2048", + "-ub", + "512", + "--host", + "0.0.0.0", + "--port", + "8080", + "--parallel", + "1", + "-ngl", + "auto", + "--slot-save-path", + "/x" + ], + "env": { + "CUDA_SCALE_LAUNCH_QUEUES": "4x" + }, + "notes": [ + "CUDA_SCALE_LAUNCH_QUEUES=4x (2 GPU, parallélisme pipeline possible) — CUDA_LAUNCH_QUEUES=off pour s'en passer" + ] + }, + { + "m": 4, + "c": 99, + "h": 0, + "e": 0, + "args": [ + "/app/llama-server", + "-m", + "/m/hybrid.gguf", + "-c", + "32768", + "-b", + "2048", + "-ub", + "512", + "--host", + "0.0.0.0", + "--port", + "8080", + "--parallel", + "1", + "-ngl", + "auto", + "-c", + "0" + ], + "env": { + "CUDA_SCALE_LAUNCH_QUEUES": "4x" + }, + "notes": [ + "CUDA_SCALE_LAUNCH_QUEUES=4x (2 GPU, parallélisme pipeline possible) — CUDA_LAUNCH_QUEUES=off pour s'en passer" + ] + }, + { + "m": 4, + "c": 100, + "h": 0, + "e": 0, + "args": [ + "/app/llama-server", + "-m", + "/m/hybrid.gguf", + "-c", + "32768", + "-b", + "2048", + "-ub", + "512", + "--host", + "0.0.0.0", + "--port", + "8080", + "--parallel", + "1", + "-ngl", + "auto", + "--slot-save-path", + "/data/slots", + "--host", + "127.0.0.1", + "--port", + "9000" + ], + "env": { + "CUDA_SCALE_LAUNCH_QUEUES": "4x" + }, + "notes": [ + "CUDA_SCALE_LAUNCH_QUEUES=4x (2 GPU, parallélisme pipeline possible) — CUDA_LAUNCH_QUEUES=off pour s'en passer" + ] + }, + { + "m": 4, + "c": 101, + "h": 0, + "e": 0, + "args": [ + "/app/llama-server", + "-m", + "/m/hybrid.gguf", + "-c", + "32768", + "-b", + "2048", + "-ub", + "512", + "--host", + "0.0.0.0", + "--port", + "8080", + "--parallel", + "1", + "-ngl", + "auto", + "--spec-type", + "draft-mtp", + "--spec-draft-sampling", + "greedy", + "--mirostat", + "2" + ], + "env": { + "CUDA_SCALE_LAUNCH_QUEUES": "4x" + }, + "notes": [ + "CUDA_SCALE_LAUNCH_QUEUES=4x (2 GPU, parallélisme pipeline possible) — CUDA_LAUNCH_QUEUES=off pour s'en passer", + "SPEC=mtp → tête MTP imposé : sortie inchangée, ~1-2 Go de VRAM en plus", + "SPEC_SAMPLING=probabilistic refusé avec --mirostat 2 (cet échantillonneur avancerait sur des jetons rejetés) : greedy" + ] + }, + { + "m": 4, + "c": 102, + "h": 0, + "e": 0, + "args": [ + "/app/llama-server", + "-m", + "/m/hybrid.gguf", + "-c", + "32768", + "-b", + "2048", + "-ub", + "512", + "--host", + "0.0.0.0", + "--port", + "8080", + "--parallel", + "1", + "-ngl", + "auto", + "--chat-template-kwargs", + "{\"preserve_reasoning\":true}" + ], + "env": { + "CUDA_SCALE_LAUNCH_QUEUES": "4x" + }, + "notes": [ + "CUDA_SCALE_LAUNCH_QUEUES=4x (2 GPU, parallélisme pipeline possible) — CUDA_LAUNCH_QUEUES=off pour s'en passer", + "--chat-template-kwargs fixe déjà preserve_reasoning : REASONING_PRESERVE ignoré" + ] + }, + { + "m": 4, + "c": 103, + "h": 0, + "e": 0, + "args": [ + "/app/llama-server", + "-m", + "/m/hybrid.gguf", + "-c", + "32768", + "-b", + "2048", + "-ub", + "512", + "--host", + "0.0.0.0", + "--port", + "8080", + "--parallel", + "1", + "-ngl", + "auto", + "--spec-type", + "ngram-mod" + ], + "env": { + "CUDA_SCALE_LAUNCH_QUEUES": "4x" + }, + "notes": [ + "CUDA_SCALE_LAUNCH_QUEUES=4x (2 GPU, parallélisme pipeline possible) — CUDA_LAUNCH_QUEUES=off pour s'en passer", + "décodage spéculatif déjà réglé dans EXTRA_ARGS ou LLAMA_ARG_* : SPEC ignoré" + ] + }, + { + "m": 4, + "c": 104, + "h": 0, + "e": 0, + "args": [ + "/app/llama-server", + "-m", + "/m/hybrid.gguf", + "-c", + "32768", + "-b", + "2048", + "-ub", + "512", + "--host", + "0.0.0.0", + "--port", + "8080", + "--parallel", + "1", + "-ngl", + "auto", + "--spec-draft-n-max", + "8" + ], + "env": { + "CUDA_SCALE_LAUNCH_QUEUES": "4x" + }, + "notes": [ + "CUDA_SCALE_LAUNCH_QUEUES=4x (2 GPU, parallélisme pipeline possible) — CUDA_LAUNCH_QUEUES=off pour s'en passer", + "SPEC=ngram : --spec-draft-n-max dans EXTRA_ARGS (brouillon réglé à la main) — sans décodage spéculatif" + ] + }, + { + "m": 4, + "c": 105, + "h": 0, + "e": 0, + "args": [ + "/app/llama-server", + "-m", + "/m/hybrid.gguf", + "-c", + "32768", + "-b", + "2048", + "-ub", + "512", + "--host", + "0.0.0.0", + "--port", + "8080", + "--parallel", + "1", + "-ngl", + "auto", + "-ot", + "attn=CUDA1" + ], + "env": {}, + "notes": null + }, + { + "m": 4, + "c": 106, + "h": 0, + "e": 0, + "args": [ + "/app/llama-server", + "-m", + "/m/hybrid.gguf", + "-c", + "32768", + "-b", + "2048", + "-ub", + "512", + "--host", + "0.0.0.0", + "--port", + "8080", + "--parallel", + "1", + "-ngl", + "auto", + "-fa", + "off" + ], + "env": { + "CUDA_SCALE_LAUNCH_QUEUES": "4x" + }, + "notes": [ + "SPLIT_MODE=tensor refusé (Flash Attention coupée (-fa off) : le mode tensor l'exige) : découpe par couches, comme sans la clé", + "CUDA_SCALE_LAUNCH_QUEUES=4x (2 GPU, parallélisme pipeline possible) — CUDA_LAUNCH_QUEUES=off pour s'en passer" + ] + }, + { + "m": 4, + "c": 107, + "h": 0, + "e": 0, + "args": [ + "/app/llama-server", + "-m", + "/m/hybrid.gguf", + "-c", + "32768", + "-b", + "2048", + "-ub", + "512", + "--host", + "0.0.0.0", + "--port", + "8080", + "--parallel", + "1", + "-ngl", + "auto", + "-nkvo" + ], + "env": {}, + "notes": null + }, + { + "m": 4, + "c": 108, + "h": 0, + "e": 0, + "args": [ + "/app/llama-server", + "-m", + "/m/hybrid.gguf", + "-c", + "32768", + "-b", + "2048", + "-ub", + "512", + "--host", + "0.0.0.0", + "--port", + "8080", + "--parallel", + "1", + "-ngl", + "auto", + "--no-context-shift" + ], + "env": { + "CUDA_SCALE_LAUNCH_QUEUES": "4x" + }, + "notes": [ + "CUDA_SCALE_LAUNCH_QUEUES=4x (2 GPU, parallélisme pipeline possible) — CUDA_LAUNCH_QUEUES=off pour s'en passer" + ] + }, + { + "m": 4, + "c": 109, + "h": 0, + "e": 0, + "args": [ + "/app/llama-server", + "-m", + "/m/hybrid.gguf", + "-c", + "32768", + "-b", + "2048", + "-ub", + "512", + "--host", + "0.0.0.0", + "--port", + "8080", + "-ngl", + "auto", + "-c", + "8192", + "-np", + "2" + ], + "env": { + "CUDA_SCALE_LAUNCH_QUEUES": "4x" + }, + "notes": [ + "CUDA_SCALE_LAUNCH_QUEUES=4x (2 GPU, parallélisme pipeline possible) — CUDA_LAUNCH_QUEUES=off pour s'en passer", + "SIDE_SLOT refusé (-c est réglé dans EXTRA_ARGS) : un seul slot, comme sans la clé" + ] + }, + { + "m": 4, + "c": 110, + "h": 0, + "e": 0, + "args": [ + "/app/llama-server", + "-m", + "/m/hybrid.gguf", + "-c", + "32768", + "-b", + "2048", + "-ub", + "512", + "--host", + "0.0.0.0", + "--port", + "8080", + "--parallel", + "1", + "-ngl", + "auto" + ], + "env": { + "CUDA_SCALE_LAUNCH_QUEUES": "4x" + }, + "notes": [ + "SPLIT_MODE=tensor refusé (preset externe) : découpe par couches, comme sans la clé", + "CUDA_SCALE_LAUNCH_QUEUES=4x (2 GPU, parallélisme pipeline possible) — CUDA_LAUNCH_QUEUES=off pour s'en passer", + "SIDE_SLOT refusé (preset externe) : un seul slot, comme sans la clé" + ] + }, + { + "m": 4, + "c": 111, + "h": 0, + "e": 0, + "args": [ + "/app/llama-server", + "-m", + "/m/hybrid.gguf", + "-c", + "40000", + "-b", + "2048", + "-ub", + "512", + "--host", + "0.0.0.0", + "--port", + "8080", + "--parallel", + "1", + "-ngl", + "auto", + "-ctk", + "q4_0", + "-ctv", + "q4_0", + "--spec-type", + "draft-mtp", + "--spec-draft-sampling", + "greedy" + ], + "env": { + "CUDA_SCALE_LAUNCH_QUEUES": "4x" + }, + "notes": [ + "CUDA_SCALE_LAUNCH_QUEUES=4x (2 GPU, parallélisme pipeline possible) — CUDA_LAUNCH_QUEUES=off pour s'en passer", + "NGL=999 (sentinelle « toutes les couches ») → -ngl auto : ce moteur mesure lui-même la VRAM libre. NGL=all pour forcer l'ancien comportement.", + "SPEC=auto → tête MTP : sortie inchangée (chaque jeton est vérifié par le modèle), ~1-2 Go de VRAM en plus. Mesure le prefill ; SPEC=off pour couper.", + "cache KV q4_0/q4_0 (KV_TYPE) : perte mesurable par rapport à f16 — choix de l'utilisateur, laissé tel quel." + ] + }, + { + "m": 5, + "c": 0, + "h": 0, + "e": 0, + "args": [ + "/app/llama-server", + "-m", + "/m/ds.gguf", + "-c", + "32768", + "-b", + "2048", + "-ub", + "512", + "--host", + "0.0.0.0", + "--port", + "8080", + "--parallel", + "1", + "-ngl", + "auto", + "--api-key", + "sk-loki-x" + ], + "env": {}, + "notes": null + }, + { + "m": 5, + "c": 1, + "h": 0, + "e": 0, + "args": [ + "/app/llama-server", + "-m", + "/m/ds.gguf", + "-c", + "65536", + "-b", + "4096", + "-ub", + "2048", + "--host", + "127.0.0.1", + "--port", + "8081", + "--parallel", + "1", + "-ngl", + "auto", + "--api-key", + "sk-loki-x" + ], + "env": {}, + "notes": [ + "NGL=999 (sentinelle « toutes les couches ») → -ngl auto : ce moteur mesure lui-même la VRAM libre. NGL=all pour forcer l'ancien comportement." + ] + }, + { + "m": 5, + "c": 2, + "h": 0, + "e": 0, + "args": [ + "/app/llama-server", + "-m", + "/m/ds.gguf", + "-c", + "32768", + "-b", + "2048", + "-ub", + "512", + "--host", + "0.0.0.0", + "--port", + "8080", + "--parallel", + "1", + "--api-key", + "sk-loki-x" + ], + "env": {}, + "notes": null + }, + { + "m": 5, + "c": 3, + "h": 0, + "e": 0, + "args": [ + "/app/llama-server", + "-m", + "/m/ds.gguf", + "-c", + "32768", + "-b", + "2048", + "-ub", + "512", + "--host", + "0.0.0.0", + "--port", + "8080", + "--parallel", + "1", + "-ngl", + "40", + "--api-key", + "sk-loki-x" + ], + "env": {}, + "notes": null + }, + { + "m": 5, + "c": 4, + "h": 0, + "e": 0, + "args": [ + "/app/llama-server", + "-m", + "/m/ds.gguf", + "-c", + "32768", + "-b", + "2048", + "-ub", + "512", + "--host", + "0.0.0.0", + "--port", + "8080", + "--parallel", + "1", + "-ngl", + "all", + "--api-key", + "sk-loki-x" + ], + "env": {}, + "notes": null + }, + { + "m": 5, + "c": 5, + "h": 0, + "e": 0, + "args": [ + "/app/llama-server", + "-m", + "/m/ds.gguf", + "-c", + "32768", + "-b", + "2048", + "-ub", + "512", + "--host", + "0.0.0.0", + "--port", + "8080", + "--parallel", + "1", + "-ngl", + "auto", + "--api-key", + "sk-loki-x" + ], + "env": {}, + "notes": [ + "NGL=999 (sentinelle « toutes les couches ») → -ngl auto : ce moteur mesure lui-même la VRAM libre. NGL=all pour forcer l'ancien comportement." + ] + }, + { + "m": 5, + "c": 6, + "h": 0, + "e": 0, + "args": [ + "/app/llama-server", + "-m", + "/m/ds.gguf", + "-c", + "32768", + "-t", + "8", + "-tb", + "4", + "-b", + "2048", + "-ub", + "512", + "--host", + "0.0.0.0", + "--port", + "8080", + "--parallel", + "1", + "-ngl", + "auto", + "--api-key", + "sk-loki-x" + ], + "env": {}, + "notes": null + }, + { + "m": 5, + "c": 7, + "h": 0, + "e": 0, + "args": [ + "/app/llama-server", + "-m", + "/m/ds.gguf", + "-c", + "32768", + "-b", + "2048", + "-ub", + "512", + "--host", + "0.0.0.0", + "--port", + "8080", + "--parallel", + "1", + "-ngl", + "auto", + "--api-key", + "sk-loki-x" + ], + "env": {}, + "notes": [ + "THREADS=abc ignoré (pas un nombre de threads ≥ 0) → automatique", + "THREADS_BATCH=-2 ignoré (pas un nombre de threads ≥ 0) → automatique" + ] + }, + { + "m": 5, + "c": 8, + "h": 0, + "e": 0, + "args": [ + "/app/llama-server", + "-m", + "/m/ds.gguf", + "-c", + "32768", + "-b", + "2048", + "-ub", + "512", + "--host", + "0.0.0.0", + "--port", + "8080", + "--parallel", + "1", + "-ngl", + "auto", + "--api-key", + "sk-loki-x" + ], + "env": {}, + "notes": null + }, + { + "m": 5, + "c": 9, + "h": 0, + "e": 0, + "args": [ + "/app/llama-server", + "-m", + "/m/ds.gguf", + "-c", + "32768", + "-b", + "2048", + "-ub", + "512", + "--host", + "0.0.0.0", + "--port", + "8080", + "--parallel", + "1", + "-ngl", + "auto", + "-ctk", + "q8_0", + "-ctv", + "q8_0", + "--api-key", + "sk-loki-x" + ], + "env": {}, + "notes": [ + "cache KV q8_0/q8_0 (KV_TYPE) : modifie légèrement les sorties par rapport à f16 — choix de l'utilisateur, laissé tel quel." + ] + }, + { + "m": 5, + "c": 10, + "h": 0, + "e": 0, + "args": [ + "/app/llama-server", + "-m", + "/m/ds.gguf", + "-c", + "32768", + "-b", + "2048", + "-ub", + "512", + "--host", + "0.0.0.0", + "--port", + "8080", + "--parallel", + "1", + "-ngl", + "auto", + "-ctk", + "q4_0", + "-ctv", + "f16", + "--api-key", + "sk-loki-x" + ], + "env": {}, + "notes": [ + "cache KV q4_0/f16 (KV_TYPE) : perte mesurable par rapport à f16 — choix de l'utilisateur, laissé tel quel." + ] + }, + { + "m": 5, + "c": 11, + "h": 0, + "e": 0, + "args": [ + "/app/llama-server", + "-m", + "/m/ds.gguf", + "-c", + "32768", + "-b", + "2048", + "-ub", + "512", + "--host", + "0.0.0.0", + "--port", + "8080", + "--parallel", + "1", + "-ngl", + "auto", + "-ctk", + "bf16", + "-ctv", + "bf16", + "--api-key", + "sk-loki-x" + ], + "env": {}, + "notes": [ + "cache KV bf16/bf16 (KV_TYPE) : numérique différente de f16 (mantisse plus courte) — choix de l'utilisateur, laissé tel quel." + ] + }, + { + "m": 5, + "c": 12, + "h": 0, + "e": 0, + "args": [ + "/app/llama-server", + "-m", + "/m/ds.gguf", + "-c", + "32768", + "-b", + "2048", + "-ub", + "512", + "--host", + "0.0.0.0", + "--port", + "8080", + "--parallel", + "1", + "-ngl", + "auto", + "-ctk", + "q5_1", + "-ctv", + "q5_1", + "--api-key", + "sk-loki-x" + ], + "env": {}, + "notes": [ + "cache KV q5_1/q5_1 (KV_TYPE) : modifie légèrement les sorties par rapport à f16 — choix de l'utilisateur, laissé tel quel." + ] + }, + { + "m": 5, + "c": 13, + "h": 0, + "e": 0, + "args": [ + "/app/llama-server", + "-m", + "/m/ds.gguf", + "-c", + "32768", + "-b", + "2048", + "-ub", + "512", + "--host", + "0.0.0.0", + "--port", + "8080", + "--parallel", + "1", + "-ngl", + "auto", + "--reasoning", + "on", + "--reasoning-budget", + "-1", + "--api-key", + "sk-loki-x" + ], + "env": {}, + "notes": null + }, + { + "m": 5, + "c": 14, + "h": 0, + "e": 0, + "args": [ + "/app/llama-server", + "-m", + "/m/ds.gguf", + "-c", + "32768", + "-b", + "2048", + "-ub", + "512", + "--host", + "0.0.0.0", + "--port", + "8080", + "--parallel", + "1", + "-ngl", + "auto", + "--reasoning", + "off", + "--api-key", + "sk-loki-x" + ], + "env": {}, + "notes": null + }, + { + "m": 5, + "c": 15, + "h": 0, + "e": 0, + "args": [ + "/app/llama-server", + "-m", + "/m/ds.gguf", + "-c", + "32768", + "-b", + "2048", + "-ub", + "512", + "--host", + "0.0.0.0", + "--port", + "8080", + "--parallel", + "1", + "-ngl", + "auto", + "--reasoning", + "auto", + "--reasoning-budget", + "2048", + "--api-key", + "sk-loki-x" + ], + "env": {}, + "notes": null + }, + { + "m": 5, + "c": 16, + "h": 0, + "e": 0, + "args": [ + "/app/llama-server", + "-m", + "/m/ds.gguf", + "-c", + "32768", + "-b", + "2048", + "-ub", + "512", + "--host", + "0.0.0.0", + "--port", + "8080", + "--parallel", + "1", + "-ngl", + "auto", + "--reasoning", + "deepseek", + "--reasoning-budget", + "-1", + "--api-key", + "sk-loki-x" + ], + "env": {}, + "notes": null + }, + { + "m": 5, + "c": 17, + "h": 0, + "e": 0, + "args": [ + "/app/llama-server", + "-m", + "/m/ds.gguf", + "-c", + "32768", + "-b", + "2048", + "-ub", + "512", + "--host", + "0.0.0.0", + "--port", + "8080", + "--parallel", + "1", + "-ngl", + "auto", + "--reasoning-preserve", + "--api-key", + "sk-loki-x" + ], + "env": {}, + "notes": null + }, + { + "m": 5, + "c": 18, + "h": 0, + "e": 0, + "args": [ + "/app/llama-server", + "-m", + "/m/ds.gguf", + "-c", + "32768", + "-b", + "2048", + "-ub", + "512", + "--host", + "0.0.0.0", + "--port", + "8080", + "--parallel", + "1", + "-ngl", + "auto", + "--no-reasoning-preserve", + "--api-key", + "sk-loki-x" + ], + "env": {}, + "notes": null + }, + { + "m": 5, + "c": 19, + "h": 0, + "e": 0, + "args": [ + "/app/llama-server", + "-m", + "/m/ds.gguf", + "-c", + "32768", + "-b", + "2048", + "-ub", + "512", + "--host", + "0.0.0.0", + "--port", + "8080", + "--parallel", + "1", + "-ngl", + "auto", + "--api-key", + "sk-loki-x" + ], + "env": {}, + "notes": [ + "REASONING_PRESERVE=peut-être illisible (on ou off) : ignoré" + ] + }, + { + "m": 5, + "c": 20, + "h": 0, + "e": 0, + "args": [ + "/app/llama-server", + "-m", + "/m/ds.gguf", + "-c", + "32768", + "-b", + "2048", + "-ub", + "512", + "--host", + "0.0.0.0", + "--port", + "8080", + "--parallel", + "1", + "-ngl", + "auto", + "--api-key", + "sk-loki-x" + ], + "env": {}, + "notes": null + }, + { + "m": 5, + "c": 21, + "h": 0, + "e": 0, + "args": [ + "/app/llama-server", + "-m", + "/m/ds.gguf", + "-c", + "32768", + "-b", + "2048", + "-ub", + "512", + "--host", + "0.0.0.0", + "--port", + "8080", + "--parallel", + "1", + "-ngl", + "auto", + "--api-key", + "sk-loki-x", + "--cache-ram", + "4096" + ], + "env": {}, + "notes": null + }, + { + "m": 5, + "c": 22, + "h": 0, + "e": 0, + "args": [ + "/app/llama-server", + "-m", + "/m/ds.gguf", + "-c", + "32768", + "-b", + "2048", + "-ub", + "512", + "--host", + "0.0.0.0", + "--port", + "8080", + "--parallel", + "1", + "-ngl", + "auto", + "--api-key", + "sk-loki-x", + "--cache-ram", + "0" + ], + "env": {}, + "notes": null + }, + { + "m": 5, + "c": 23, + "h": 0, + "e": 0, + "args": [ + "/app/llama-server", + "-m", + "/m/ds.gguf", + "-c", + "32768", + "-b", + "2048", + "-ub", + "512", + "--host", + "0.0.0.0", + "--port", + "8080", + "--parallel", + "1", + "-ngl", + "auto", + "--api-key", + "sk-loki-x", + "--cache-ram", + "-1" + ], + "env": {}, + "notes": null + }, + { + "m": 5, + "c": 24, + "h": 0, + "e": 0, + "args": [ + "/app/llama-server", + "-m", + "/m/ds.gguf", + "-c", + "32768", + "-b", + "2048", + "-ub", + "512", + "--host", + "0.0.0.0", + "--port", + "8080", + "--parallel", + "1", + "-ngl", + "auto", + "--api-key", + "sk-loki-x" + ], + "env": {}, + "notes": [ + "CACHE_RAM=beaucoup illisible (un nombre de Mio, -1, 0 ou auto) : ignoré" + ] + }, + { + "m": 5, + "c": 25, + "h": 0, + "e": 0, + "args": [ + "/app/llama-server", + "-m", + "/m/ds.gguf", + "-c", + "32768", + "-b", + "2048", + "-ub", + "512", + "--host", + "0.0.0.0", + "--port", + "8080", + "--parallel", + "1", + "-ngl", + "auto", + "--api-key", + "sk-loki-x" + ], + "env": {}, + "notes": null + }, + { + "m": 5, + "c": 26, + "h": 0, + "e": 0, + "args": [ + "/app/llama-server", + "-m", + "/m/ds.gguf", + "-c", + "32768", + "-b", + "2048", + "-ub", + "512", + "--host", + "0.0.0.0", + "--port", + "8080", + "--parallel", + "1", + "-ngl", + "auto", + "--api-key", + "sk-loki-x", + "--cache-ram", + "0" + ], + "env": {}, + "notes": null + }, + { + "m": 5, + "c": 27, + "h": 0, + "e": 0, + "args": [ + "/app/llama-server", + "-m", + "/m/ds.gguf", + "-c", + "32768", + "-b", + "2048", + "-ub", + "512", + "--host", + "0.0.0.0", + "--port", + "8080", + "--parallel", + "1", + "-ngl", + "auto", + "--api-key", + "sk-loki-x" + ], + "env": {}, + "notes": [ + "SIDE_SLOT refusé (VRAM NVIDIA inconnue (macOS, AMD, --device…)) : un seul slot, comme sans la clé" + ] + }, + { + "m": 5, + "c": 28, + "h": 0, + "e": 0, + "args": [ + "/app/llama-server", + "-m", + "/m/ds.gguf", + "-c", + "32768", + "-b", + "2048", + "-ub", + "512", + "--host", + "0.0.0.0", + "--port", + "8080", + "--parallel", + "3", + "-ngl", + "auto", + "--api-key", + "sk-loki-x" + ], + "env": {}, + "notes": [ + "SIDE_SLOT refusé (PARALLEL=3 (SIDE_SLOT ouvre exactement 2 slots)) : un seul slot, comme sans la clé" + ] + }, + { + "m": 5, + "c": 29, + "h": 0, + "e": 0, + "args": [ + "/app/llama-server", + "-m", + "/m/ds.gguf", + "-c", + "0", + "-b", + "2048", + "-ub", + "512", + "--host", + "0.0.0.0", + "--port", + "8080", + "--parallel", + "1", + "-ngl", + "auto", + "--api-key", + "sk-loki-x" + ], + "env": {}, + "notes": [ + "SIDE_SLOT refusé (CTX doit être un nombre de jetons explicite) : un seul slot, comme sans la clé" + ] + }, + { + "m": 5, + "c": 30, + "h": 0, + "e": 0, + "args": [ + "/app/llama-server", + "-m", + "/m/ds.gguf", + "-c", + "8192", + "-b", + "2048", + "-ub", + "512", + "--host", + "0.0.0.0", + "--port", + "8080", + "--parallel", + "1", + "-ngl", + "auto", + "-ctk", + "q8_0", + "-ctv", + "q8_0", + "--api-key", + "sk-loki-x" + ], + "env": {}, + "notes": [ + "SIDE_SLOT refusé (VRAM NVIDIA inconnue (macOS, AMD, --device…)) : un seul slot, comme sans la clé", + "cache KV q8_0/q8_0 (KV_TYPE) : modifie légèrement les sorties par rapport à f16 — choix de l'utilisateur, laissé tel quel." + ] + }, + { + "m": 5, + "c": 31, + "h": 0, + "e": 0, + "args": [ + "/app/llama-server", + "-m", + "/m/ds.gguf", + "-c", + "32768", + "-b", + "2048", + "-ub", + "512", + "--host", + "0.0.0.0", + "--port", + "8080", + "--parallel", + "1", + "-ngl", + "auto", + "--api-key", + "sk-loki-x" + ], + "env": {}, + "notes": [ + "SPLIT_MODE=tensor refusé (architecture deepseek2 exclue du mode tensor) : découpe par couches, comme sans la clé" + ] + }, + { + "m": 5, + "c": 32, + "h": 0, + "e": 0, + "args": [ + "/app/llama-server", + "-m", + "/m/ds.gguf", + "-c", + "16384", + "-b", + "2048", + "-ub", + "512", + "--host", + "0.0.0.0", + "--port", + "8080", + "--parallel", + "1", + "-ngl", + "auto", + "--api-key", + "sk-loki-x" + ], + "env": {}, + "notes": [ + "SPLIT_MODE=tensor refusé (architecture deepseek2 exclue du mode tensor) : découpe par couches, comme sans la clé" + ] + }, + { + "m": 5, + "c": 33, + "h": 0, + "e": 0, + "args": [ + "/app/llama-server", + "-m", + "/m/ds.gguf", + "-c", + "32768", + "-b", + "2048", + "-ub", + "512", + "--host", + "0.0.0.0", + "--port", + "8080", + "--parallel", + "1", + "-ngl", + "auto", + "--api-key", + "sk-loki-x" + ], + "env": {}, + "notes": [ + "SPLIT_MODE=tensor refusé (SPLIT_MODE=bizarre illisible (tensor ou off)) : découpe par couches, comme sans la clé" + ] + }, + { + "m": 5, + "c": 34, + "h": 0, + "e": 0, + "args": [ + "/app/llama-server", + "-m", + "/m/ds.gguf", + "-c", + "32768", + "-b", + "2048", + "-ub", + "512", + "--host", + "0.0.0.0", + "--port", + "8080", + "--parallel", + "1", + "-ngl", + "auto", + "--api-key", + "sk-loki-x" + ], + "env": {}, + "notes": null + }, + { + "m": 5, + "c": 35, + "h": 0, + "e": 0, + "args": [ + "/app/llama-server", + "-m", + "/m/ds.gguf", + "-c", + "32768", + "-b", + "2048", + "-ub", + "512", + "--host", + "0.0.0.0", + "--port", + "8080", + "--parallel", + "1", + "-ngl", + "auto", + "-ctk", + "q8_0", + "-ctv", + "q8_0", + "--api-key", + "sk-loki-x" + ], + "env": {}, + "notes": [ + "SPLIT_MODE=tensor refusé (cache KV q8_0/q8_0 : le mode tensor n'accepte que f16, bf16 ou f32 (jamais réécrit d'office : la VRAM changerait)) : découpe par couches, comme sans la clé", + "cache KV q8_0/q8_0 (KV_TYPE) : modifie légèrement les sorties par rapport à f16 — choix de l'utilisateur, laissé tel quel." + ] + }, + { + "m": 5, + "c": 36, + "h": 0, + "e": 0, + "args": [ + "/app/llama-server", + "-m", + "/m/ds.gguf", + "-c", + "32768", + "-b", + "2048", + "-ub", + "512", + "--host", + "0.0.0.0", + "--port", + "8080", + "--parallel", + "1", + "-ngl", + "20", + "--api-key", + "sk-loki-x" + ], + "env": {}, + "notes": [ + "SPLIT_MODE=tensor refusé (architecture deepseek2 exclue du mode tensor) : découpe par couches, comme sans la clé" + ] + }, + { + "m": 5, + "c": 37, + "h": 0, + "e": 0, + "args": [ + "/app/llama-server", + "-m", + "/m/ds.gguf", + "-c", + "32768", + "-b", + "2048", + "-ub", + "512", + "--host", + "0.0.0.0", + "--port", + "8080", + "--parallel", + "1", + "-ngl", + "auto", + "--api-key", + "sk-loki-x" + ], + "env": {}, + "notes": null + }, + { + "m": 5, + "c": 38, + "h": 0, + "e": 0, + "args": [ + "/app/llama-server", + "-m", + "/m/ds.gguf", + "-c", + "32768", + "-b", + "2048", + "-ub", + "512", + "--host", + "0.0.0.0", + "--port", + "8080", + "--parallel", + "1", + "-ngl", + "auto", + "--api-key", + "sk-loki-x" + ], + "env": {}, + "notes": [ + "SPEC=mtp : pas de tête MTP dans ce fichier (publiée à part ? MODEL_DRAFT=mtp-….gguf) — sans décodage spéculatif" + ] + }, + { + "m": 5, + "c": 39, + "h": 0, + "e": 0, + "args": [ + "/app/llama-server", + "-m", + "/m/ds.gguf", + "-c", + "32768", + "-b", + "2048", + "-ub", + "512", + "--host", + "0.0.0.0", + "--port", + "8080", + "--parallel", + "1", + "-ngl", + "auto", + "--api-key", + "sk-loki-x", + "--spec-type", + "ngram-mod", + "--spec-ngram-mod-n-match", + "24", + "--spec-ngram-mod-n-min", + "48", + "--spec-ngram-mod-n-max", + "64" + ], + "env": {}, + "notes": [ + "SPEC=ngram → n-grammes du contexte (ngram-mod, brouillons de 48 à 64 jetons) : distribution inchangée (chaque jeton vérifié), ~16 Mo de RAM ; utile surtout en mode code, à mesurer (acceptation par nature dans /api/perf/summary)" + ] + }, + { + "m": 5, + "c": 40, + "h": 0, + "e": 0, + "args": [ + "/app/llama-server", + "-m", + "/m/ds.gguf", + "-c", + "32768", + "-b", + "2048", + "-ub", + "512", + "--host", + "0.0.0.0", + "--port", + "8080", + "--parallel", + "1", + "-ngl", + "auto", + "--api-key", + "sk-loki-x", + "--spec-type", + "ngram-mod", + "--spec-ngram-mod-n-match", + "24", + "--spec-ngram-mod-n-min", + "48", + "--spec-ngram-mod-n-max", + "64" + ], + "env": {}, + "notes": [ + "SPEC=mtp+ngram : sans MTP (pas de tête MTP dans ce fichier (publiée à part ? MODEL_DRAFT=mtp-….gguf)) → n-grammes seuls. SPEC=ngram → n-grammes du contexte (ngram-mod, brouillons de 48 à 64 jetons) : distribution inchangée (chaque jeton vérifié), ~16 Mo de RAM ; utile surtout en mode code, à mesurer (acceptation par nature dans /api/perf/summary)" + ] + }, + { + "m": 5, + "c": 41, + "h": 0, + "e": 0, + "args": [ + "/app/llama-server", + "-m", + "/m/ds.gguf", + "-c", + "32768", + "-b", + "2048", + "-ub", + "512", + "--host", + "0.0.0.0", + "--port", + "8080", + "--parallel", + "1", + "-ngl", + "auto", + "--api-key", + "sk-loki-x", + "--spec-type", + "ngram-mod", + "--spec-ngram-mod-n-match", + "24", + "--spec-ngram-mod-n-min", + "48", + "--spec-ngram-mod-n-max", + "64" + ], + "env": {}, + "notes": [ + "SPEC=mtp+ngram : sans MTP (pas de tête MTP dans ce fichier (publiée à part ? MODEL_DRAFT=mtp-….gguf)) → n-grammes seuls. SPEC=ngram → n-grammes du contexte (ngram-mod, brouillons de 48 à 64 jetons) : distribution inchangée (chaque jeton vérifié), ~16 Mo de RAM ; utile surtout en mode code, à mesurer (acceptation par nature dans /api/perf/summary)" + ] + }, + { + "m": 5, + "c": 42, + "h": 0, + "e": 0, + "args": [ + "/app/llama-server", + "-m", + "/m/ds.gguf", + "-c", + "32768", + "-b", + "2048", + "-ub", + "512", + "--host", + "0.0.0.0", + "--port", + "8080", + "--parallel", + "1", + "-ngl", + "auto", + "--api-key", + "sk-loki-x" + ], + "env": {}, + "notes": [ + "SPEC=turbo illisible (off, auto, mtp, ngram ou mtp+ngram) : sans décodage spéculatif" + ] + }, + { + "m": 5, + "c": 43, + "h": 0, + "e": 0, + "args": [ + "/app/llama-server", + "-m", + "/m/ds.gguf", + "-c", + "32768", + "-b", + "2048", + "-ub", + "512", + "--host", + "0.0.0.0", + "--port", + "8080", + "--parallel", + "1", + "-ngl", + "auto", + "--api-key", + "sk-loki-x" + ], + "env": {}, + "notes": [ + "MODEL_DRAFT ignoré : SPEC=off (auto ou mtp pour s'en servir)" + ] + }, + { + "m": 5, + "c": 44, + "h": 0, + "e": 0, + "args": [ + "/app/llama-server", + "-m", + "/m/ds.gguf", + "-c", + "32768", + "-b", + "2048", + "-ub", + "512", + "--host", + "0.0.0.0", + "--port", + "8080", + "--parallel", + "1", + "-ngl", + "auto", + "--api-key", + "sk-loki-x" + ], + "env": {}, + "notes": [ + "SPEC=mtp : MODEL_DRAFT=mtp-head.gguf introuvable — sans décodage spéculatif" + ] + }, + { + "m": 5, + "c": 45, + "h": 0, + "e": 0, + "args": [ + "/app/llama-server", + "-m", + "/m/ds.gguf", + "-c", + "32768", + "-b", + "2048", + "-ub", + "512", + "--host", + "0.0.0.0", + "--port", + "8080", + "--parallel", + "1", + "-ngl", + "auto", + "--api-key", + "sk-loki-x" + ], + "env": {}, + "notes": [ + "SPEC=mtp : pas de tête MTP dans ce fichier (publiée à part ? MODEL_DRAFT=mtp-….gguf) — sans décodage spéculatif" + ] + }, + { + "m": 5, + "c": 46, + "h": 0, + "e": 0, + "args": [ + "/app/llama-server", + "-m", + "/m/ds.gguf", + "-c", + "32768", + "-b", + "2048", + "-ub", + "512", + "--host", + "0.0.0.0", + "--port", + "8080", + "--parallel", + "1", + "-ngl", + "auto", + "--api-key", + "sk-loki-x" + ], + "env": {}, + "notes": null + }, + { + "m": 5, + "c": 47, + "h": 0, + "e": 0, + "args": [ + "/app/llama-server", + "-m", + "/m/ds.gguf", + "-c", + "32768", + "-b", + "2048", + "-ub", + "512", + "--host", + "0.0.0.0", + "--port", + "8080", + "--parallel", + "1", + "-ngl", + "auto", + "--api-key", + "sk-loki-x" + ], + "env": {}, + "notes": [ + "SPEC=mtp : pas de tête MTP dans ce fichier (publiée à part ? MODEL_DRAFT=mtp-….gguf) — sans décodage spéculatif" + ] + }, + { + "m": 5, + "c": 48, + "h": 0, + "e": 0, + "args": [ + "/app/llama-server", + "-m", + "/m/ds.gguf", + "-c", + "32768", + "-b", + "2048", + "-ub", + "512", + "--host", + "0.0.0.0", + "--port", + "8080", + "--parallel", + "1", + "-ngl", + "auto", + "--api-key", + "sk-loki-x", + "--spec-type", + "ngram-mod", + "--spec-ngram-mod-n-match", + "24", + "--spec-ngram-mod-n-min", + "48", + "--spec-ngram-mod-n-max", + "64" + ], + "env": {}, + "notes": [ + "SPEC=ngram → n-grammes du contexte (ngram-mod, brouillons de 48 à 64 jetons) : distribution inchangée (chaque jeton vérifié), ~16 Mo de RAM ; utile surtout en mode code, à mesurer (acceptation par nature dans /api/perf/summary)", + "MODEL_DRAFT ignoré avec SPEC=ngram : SPEC=mtp+ngram pour les deux", + "SPEC_N_MAX et SPEC_SAMPLING ignorés : les n-grammes n'ont pas de brouillon à régler" + ] + }, + { + "m": 5, + "c": 49, + "h": 0, + "e": 0, + "args": [ + "/app/llama-server", + "-m", + "/m/ds.gguf", + "-c", + "32768", + "-b", + "2048", + "-ub", + "512", + "--host", + "0.0.0.0", + "--port", + "8080", + "--parallel", + "1", + "-ngl", + "auto", + "--api-key", + "sk-loki-x" + ], + "env": {}, + "notes": [ + "SPEC=auto : MODEL_DRAFT=small.gguf introuvable — sans décodage spéculatif" + ] + }, + { + "m": 5, + "c": 50, + "h": 0, + "e": 0, + "args": [ + "/app/llama-server", + "-m", + "/m/ds.gguf", + "-c", + "32768", + "-b", + "2048", + "-ub", + "512", + "--host", + "0.0.0.0", + "--port", + "8080", + "--parallel", + "1", + "-ngl", + "auto", + "--api-key", + "sk-loki-x", + "--spec-type", + "ngram-mod", + "--spec-ngram-mod-n-match", + "24", + "--spec-ngram-mod-n-min", + "48", + "--spec-ngram-mod-n-max", + "64" + ], + "env": {}, + "notes": [ + "SPEC=mtp+ngram : sans MTP (MODEL_DRAFT=small.gguf introuvable) → n-grammes seuls. SPEC=ngram → n-grammes du contexte (ngram-mod, brouillons de 48 à 64 jetons) : distribution inchangée (chaque jeton vérifié), ~16 Mo de RAM ; utile surtout en mode code, à mesurer (acceptation par nature dans /api/perf/summary)" + ] + }, + { + "m": 5, + "c": 51, + "h": 0, + "e": 0, + "args": [ + "/app/llama-server", + "-m", + "/m/ds.gguf", + "-c", + "32768", + "-b", + "2048", + "-ub", + "512", + "--host", + "0.0.0.0", + "--port", + "8080", + "--parallel", + "1", + "-ngl", + "auto", + "--api-key", + "sk-loki-x" + ], + "env": {}, + "notes": null + }, + { + "m": 5, + "c": 52, + "h": 0, + "e": 0, + "args": [ + "/app/llama-server", + "-m", + "/m/ds.gguf", + "-c", + "32768", + "-b", + "2048", + "-ub", + "512", + "--host", + "0.0.0.0", + "--port", + "8080", + "--parallel", + "1", + "-ngl", + "auto", + "--api-key", + "sk-loki-x" + ], + "env": { + "CUDA_SCALE_LAUNCH_QUEUES": "2x" + }, + "notes": [ + "CUDA_SCALE_LAUNCH_QUEUES=2x (CUDA_LAUNCH_QUEUES)" + ] + }, + { + "m": 5, + "c": 53, + "h": 0, + "e": 0, + "args": [ + "/app/llama-server", + "-m", + "/m/ds.gguf", + "-c", + "32768", + "-b", + "2048", + "-ub", + "512", + "--host", + "0.0.0.0", + "--port", + "8080", + "--parallel", + "1", + "-ngl", + "auto", + "--api-key", + "sk-loki-x" + ], + "env": {}, + "notes": [ + "CUDA_LAUNCH_QUEUES=8x ignoré (0.25x, 0.5x, 2x, 4x ou off) → automatique" + ] + }, + { + "m": 5, + "c": 54, + "h": 0, + "e": 0, + "args": [ + "/app/llama-server", + "-m", + "/m/ds.gguf", + "-c", + "32768", + "-b", + "2048", + "-ub", + "512", + "--host", + "0.0.0.0", + "--port", + "8080", + "--parallel", + "1", + "-ngl", + "auto", + "--api-key", + "sk-loki-x" + ], + "env": {}, + "notes": null + }, + { + "m": 5, + "c": 55, + "h": 0, + "e": 0, + "args": [ + "/app/llama-server", + "-m", + "/m/ds.gguf", + "-c", + "32768", + "-b", + "2048", + "-ub", + "512", + "--host", + "0.0.0.0", + "--port", + "8080", + "--parallel", + "1", + "-ngl", + "auto", + "--api-key", + "sk-loki-x" + ], + "env": { + "CUDA_DEVICE_ORDER": "PCI_BUS_ID", + "CUDA_VISIBLE_DEVICES": "1,0" + }, + "notes": null + }, + { + "m": 5, + "c": 56, + "h": 0, + "e": 0, + "args": [ + "/app/llama-server", + "-m", + "/m/ds.gguf", + "-c", + "32768", + "-b", + "2048", + "-ub", + "512", + "--host", + "0.0.0.0", + "--port", + "8080", + "--parallel", + "1", + "-ngl", + "auto", + "--fit-target", + "1024,3072", + "--api-key", + "sk-loki-x" + ], + "env": {}, + "notes": [ + "--fit-target 1024,3072 (FIT_TARGET, Mio libres par carte, dans l'ordre vu par le moteur) — vérifie « offloaded N/N layers » dans le journal" + ] + }, + { + "m": 5, + "c": 57, + "h": 0, + "e": 0, + "args": [ + "/app/llama-server", + "-m", + "/m/ds.gguf", + "-c", + "32768", + "-b", + "2048", + "-ub", + "512", + "--host", + "0.0.0.0", + "--port", + "8080", + "--parallel", + "1", + "-ngl", + "auto", + "--fit-target", + "1024", + "--api-key", + "sk-loki-x" + ], + "env": {}, + "notes": [ + "--fit-target 1024 (FIT_TARGET, Mio libres par carte, dans l'ordre vu par le moteur) — vérifie « offloaded N/N layers » dans le journal ; marges sous 1024 Mio remontées à 1024" + ] + }, + { + "m": 5, + "c": 58, + "h": 0, + "e": 0, + "args": [ + "/app/llama-server", + "-m", + "/m/ds.gguf", + "-c", + "32768", + "-b", + "2048", + "-ub", + "512", + "--host", + "0.0.0.0", + "--port", + "8080", + "--parallel", + "1", + "-ngl", + "auto", + "--api-key", + "sk-loki-x" + ], + "env": {}, + "notes": [ + "FIT_TARGET=1024,,3072 ignoré : des Mio par carte, ex. 1024,3072 (8 valeurs au plus)" + ] + }, + { + "m": 5, + "c": 59, + "h": 0, + "e": 0, + "args": [ + "/app/llama-server", + "-m", + "/m/ds.gguf", + "-c", + "32768", + "-b", + "2048", + "-ub", + "512", + "--host", + "0.0.0.0", + "--port", + "8080", + "--parallel", + "1", + "-ngl", + "auto", + "--api-key", + "sk-loki-x" + ], + "env": {}, + "notes": [ + "FIT_TARGET=1,2,3,4,5,6,7,8,9 ignoré : des Mio par carte, ex. 1024,3072 (8 valeurs au plus)" + ] + }, + { + "m": 5, + "c": 60, + "h": 0, + "e": 0, + "args": [ + "/app/llama-server", + "-m", + "/m/ds.gguf", + "-c", + "0", + "-b", + "2048", + "-ub", + "512", + "--host", + "0.0.0.0", + "--port", + "8080", + "--parallel", + "1", + "-ngl", + "auto", + "--api-key", + "sk-loki-x" + ], + "env": {}, + "notes": [ + "FIT_TARGET=2048/4096 ignoré : contexte « 0 » non fixé — --fit pourrait le réduire pour tenir ; donne un CTX chiffré" + ] + }, + { + "m": 5, + "c": 61, + "h": 0, + "e": 0, + "args": [ + "/app/llama-server", + "-m", + "/m/ds.gguf", + "-c", + "32768", + "-b", + "2048", + "-ub", + "512", + "--host", + "0.0.0.0", + "--port", + "8080", + "--parallel", + "1", + "-ngl", + "30", + "--api-key", + "sk-loki-x" + ], + "env": {}, + "notes": [ + "FIT_TARGET=2048 ignoré : --fit inactif (couches GPU fixées à 30), la marge ne servirait à rien" + ] + }, + { + "m": 5, + "c": 62, + "h": 0, + "e": 0, + "args": [ + "/app/llama-server", + "-m", + "/m/ds.gguf", + "-c", + "32768", + "-b", + "2048", + "-ub", + "512", + "--host", + "0.0.0.0", + "--port", + "8080", + "--parallel", + "1", + "-ngl", + "auto", + "--api-key", + "sk-loki-x" + ], + "env": {}, + "notes": null + }, + { + "m": 5, + "c": 63, + "h": 0, + "e": 0, + "args": [ + "/app/llama-server", + "-m", + "/m/ds.gguf", + "-c", + "32768", + "-b", + "2048", + "-ub", + "512", + "--host", + "0.0.0.0", + "--port", + "8080", + "--parallel", + "1", + "-ngl", + "auto", + "--api-key", + "sk-loki-x" + ], + "env": {}, + "notes": [ + "OP_OFFLOAD_MIN_BATCH=0 ignoré : un nombre de jetons, ex. 128" + ] + }, + { + "m": 5, + "c": 64, + "h": 0, + "e": 0, + "args": [ + "/app/llama-server", + "-m", + "/m/ds.gguf", + "-c", + "32768", + "-b", + "2048", + "-ub", + "512", + "--host", + "0.0.0.0", + "--port", + "8080", + "--parallel", + "1", + "-ngl", + "auto", + "--api-key", + "sk-loki-x" + ], + "env": { + "GGML_CUDA_GRAPH_OPT": "1" + }, + "notes": [ + "GGML_CUDA_GRAPH_OPT=1 (CUDA_GRAPH_OPT, expérimental) — si le moteur s'arrête sur un GGML_ASSERT, CUDA_GRAPH_OPT=off puis redémarre" + ] + }, + { + "m": 5, + "c": 65, + "h": 0, + "e": 0, + "args": [ + "/app/llama-server", + "-m", + "/m/ds.gguf", + "-c", + "32768", + "-b", + "2048", + "-ub", + "512", + "--host", + "0.0.0.0", + "--port", + "8080", + "--parallel", + "1", + "-ngl", + "auto", + "--api-key", + "sk-loki-x" + ], + "env": {}, + "notes": [ + "CUDA_GRAPH_OPT=oui ignoré (on ou off)" + ] + }, + { + "m": 5, + "c": 66, + "h": 0, + "e": 0, + "args": [ + "/app/llama-server", + "-m", + "/m/ds.gguf", + "-c", + "32768", + "-b", + "2048", + "-ub", + "512", + "--host", + "0.0.0.0", + "--port", + "8080", + "--parallel", + "1", + "-ngl", + "auto", + "--api-key", + "sk-loki-x" + ], + "env": {}, + "notes": null + }, + { + "m": 5, + "c": 67, + "h": 0, + "e": 0, + "args": [ + "/app/llama-server", + "-m", + "/m/ds.gguf", + "-c", + "32768", + "-b", + "2048", + "-ub", + "512", + "--host", + "0.0.0.0", + "--port", + "8080", + "--parallel", + "1", + "-ngl", + "auto", + "--api-key", + "sk-loki-x", + "--ctx-checkpoints", + "8" + ], + "env": {}, + "notes": null + }, + { + "m": 5, + "c": 68, + "h": 0, + "e": 0, + "args": [ + "/app/llama-server", + "-m", + "/m/ds.gguf", + "-c", + "32768", + "-b", + "2048", + "-ub", + "512", + "--host", + "0.0.0.0", + "--port", + "8080", + "--parallel", + "1", + "-ngl", + "auto", + "--api-key", + "sk-loki-x" + ], + "env": {}, + "notes": [ + "CTX_CHECKPOINTS=beaucoup illisible (un nombre de points de reprise par slot, ≥ 0) : ignoré" + ] + }, + { + "m": 5, + "c": 69, + "h": 0, + "e": 0, + "args": [ + "/app/llama-server", + "-m", + "/m/ds.gguf", + "-c", + "32768", + "-b", + "2048", + "-ub", + "512", + "--host", + "0.0.0.0", + "--port", + "8080", + "--parallel", + "1", + "-ngl", + "auto", + "--api-key", + "sk-loki-x", + "--checkpoint-min-step", + "1024" + ], + "env": {}, + "notes": null + }, + { + "m": 5, + "c": 70, + "h": 0, + "e": 0, + "args": [ + "/app/llama-server", + "-m", + "/m/ds.gguf", + "-c", + "32768", + "-b", + "2048", + "-ub", + "512", + "--host", + "0.0.0.0", + "--port", + "8080", + "--parallel", + "1", + "-ngl", + "auto", + "--api-key", + "sk-loki-x" + ], + "env": {}, + "notes": [ + "CKPT_MIN_STEP=0 refusé (un nombre de jetons > 0 ; 0 laisserait le moteur jeter le point de reprise du début de la conversation) : défaut du moteur" + ] + }, + { + "m": 5, + "c": 71, + "h": 0, + "e": 0, + "args": [ + "/app/llama-server", + "-m", + "/m/ds.gguf", + "-c", + "32768", + "-b", + "2048", + "-ub", + "512", + "--host", + "0.0.0.0", + "--port", + "8080", + "--parallel", + "1", + "-ngl", + "auto", + "--api-key", + "sk-loki-x" + ], + "env": {}, + "notes": [ + "SLOT_PERSIST refusé (dossier des slots indisponible) : l'état du slot ne sera pas gardé à la bascule de preset" + ] + }, + { + "m": 5, + "c": 72, + "h": 0, + "e": 0, + "args": [ + "/app/llama-server", + "-m", + "/m/ds.gguf", + "-c", + "32768", + "-b", + "2048", + "-ub", + "512", + "--host", + "127.0.0.1", + "--port", + "8080", + "--parallel", + "1", + "-ngl", + "auto", + "--api-key", + "sk-loki-x" + ], + "env": {}, + "notes": [ + "SLOT_PERSIST refusé (dossier des slots indisponible) : l'état du slot ne sera pas gardé à la bascule de preset" + ] + }, + { + "m": 5, + "c": 73, + "h": 0, + "e": 0, + "args": [ + "/app/llama-server", + "-m", + "/m/ds.gguf", + "-c", + "32768", + "-b", + "2048", + "-ub", + "512", + "--host", + "127.0.0.1", + "--port", + "8080", + "--parallel", + "1", + "-ngl", + "auto", + "--api-key", + "sk-loki-x" + ], + "env": {}, + "notes": [ + "SLOT_PERSIST refusé (dossier des slots indisponible) : l'état du slot ne sera pas gardé à la bascule de preset", + "SPEC=mtp : pas de tête MTP dans ce fichier (publiée à part ? MODEL_DRAFT=mtp-….gguf) — sans décodage spéculatif" + ] + }, + { + "m": 5, + "c": 74, + "h": 0, + "e": 0, + "args": [ + "/app/llama-server", + "-m", + "/m/ds.gguf", + "-c", + "32768", + "-b", + "2048", + "-ub", + "512", + "--host", + "0.0.0.0", + "--port", + "8080", + "--parallel", + "1", + "-ngl", + "auto", + "--api-key", + "sk-loki-x", + "--load-mode", + "none" + ], + "env": {}, + "notes": null + }, + { + "m": 5, + "c": 75, + "h": 0, + "e": 0, + "args": [ + "/app/llama-server", + "-m", + "/m/ds.gguf", + "-c", + "32768", + "-b", + "2048", + "-ub", + "512", + "--host", + "0.0.0.0", + "--port", + "8080", + "--parallel", + "1", + "-ngl", + "auto", + "--api-key", + "sk-loki-x", + "--load-mode", + "mlock" + ], + "env": {}, + "notes": null + }, + { + "m": 5, + "c": 76, + "h": 0, + "e": 0, + "args": [ + "/app/llama-server", + "-m", + "/m/ds.gguf", + "-c", + "32768", + "-b", + "2048", + "-ub", + "512", + "--host", + "0.0.0.0", + "--port", + "8080", + "--parallel", + "1", + "-ngl", + "auto", + "--api-key", + "sk-loki-x", + "--load-mode", + "mmap+mlock" + ], + "env": {}, + "notes": null + }, + { + "m": 5, + "c": 77, + "h": 0, + "e": 0, + "args": [ + "/app/llama-server", + "-m", + "/m/ds.gguf", + "-c", + "32768", + "-b", + "2048", + "-ub", + "512", + "--host", + "0.0.0.0", + "--port", + "8080", + "--parallel", + "1", + "-ngl", + "auto", + "--api-key", + "sk-loki-x", + "--load-mode", + "none" + ], + "env": {}, + "notes": null + }, + { + "m": 5, + "c": 78, + "h": 0, + "e": 0, + "args": [ + "/app/llama-server", + "-m", + "/m/ds.gguf", + "-c", + "32768", + "-b", + "2048", + "-ub", + "512", + "--host", + "0.0.0.0", + "--port", + "8080", + "--parallel", + "1", + "-ngl", + "auto", + "--api-key", + "sk-loki-x", + "--load-mode", + "mmap" + ], + "env": {}, + "notes": null + }, + { + "m": 5, + "c": 79, + "h": 0, + "e": 0, + "args": [ + "/app/llama-server", + "-m", + "/m/ds.gguf", + "-c", + "32768", + "-b", + "2048", + "-ub", + "512", + "--host", + "0.0.0.0", + "--port", + "8080", + "--parallel", + "1", + "-ngl", + "auto", + "--api-key", + "sk-loki-x", + "--load-mode", + "dio" + ], + "env": {}, + "notes": null + }, + { + "m": 5, + "c": 80, + "h": 0, + "e": 0, + "args": [ + "/app/llama-server", + "-m", + "/m/ds.gguf", + "-c", + "32768", + "-b", + "2048", + "-ub", + "512", + "--host", + "0.0.0.0", + "--port", + "8080", + "--parallel", + "1", + "-ngl", + "auto", + "--api-key", + "sk-loki-x", + "--load-mode=mlock" + ], + "env": {}, + "notes": null + }, + { + "m": 5, + "c": 81, + "h": 0, + "e": 0, + "args": [ + "/app/llama-server", + "-m", + "/m/ds.gguf", + "-c", + "32768", + "-b", + "2048", + "-ub", + "512", + "--host", + "0.0.0.0", + "--port", + "8080", + "--api-key", + "sk-loki-x", + "-ngl", + "30", + "--parallel", + "4" + ], + "env": {}, + "notes": null + }, + { + "m": 5, + "c": 82, + "h": 0, + "e": 0, + "args": [ + "/app/llama-server", + "-m", + "/m/ds.gguf", + "-c", + "32768", + "-b", + "2048", + "-ub", + "512", + "--host", + "0.0.0.0", + "--port", + "8080", + "--parallel", + "1", + "-ngl", + "auto", + "--api-key", + "sk-loki-x", + "-t", + "6", + "-tb", + "3" + ], + "env": {}, + "notes": null + }, + { + "m": 5, + "c": 83, + "h": 0, + "e": 0, + "args": [ + "/app/llama-server", + "-m", + "/m/ds.gguf", + "-c", + "32768", + "-tb", + "0", + "-b", + "2048", + "-ub", + "512", + "--host", + "0.0.0.0", + "--port", + "8080", + "--parallel", + "1", + "-ngl", + "auto", + "--api-key", + "sk-loki-x", + "-Cb", + "0xff", + "--poll-batch", + "1" + ], + "env": {}, + "notes": [ + "EXTRA_ARGS règle le CPU du batch (-Cb, --poll-batch…) : -tb 0 posé pour que llama.cpp le garde (tous les threads logiques) ; THREADS_BATCH pour choisir le nombre" + ] + }, + { + "m": 5, + "c": 84, + "h": 0, + "e": 0, + "args": [ + "/app/llama-server", + "-m", + "/m/ds.gguf", + "-c", + "32768", + "-t", + "6", + "-tb", + "6", + "-b", + "2048", + "-ub", + "512", + "--host", + "0.0.0.0", + "--port", + "8080", + "--parallel", + "1", + "-ngl", + "auto", + "--api-key", + "sk-loki-x", + "--prio-batch", + "2" + ], + "env": {}, + "notes": null + }, + { + "m": 5, + "c": 85, + "h": 0, + "e": 0, + "args": [ + "/app/llama-server", + "-m", + "/m/ds.gguf", + "-c", + "32768", + "-b", + "2048", + "-ub", + "512", + "--host", + "0.0.0.0", + "--port", + "8080", + "--parallel", + "1", + "-ngl", + "auto", + "--api-key", + "sk-loki-x", + "--cache-reuse", + "256", + "--context-shift" + ], + "env": {}, + "notes": [ + "avertissement : --context-shift (EXTRA_ARGS) — contexte plein, le moteur jette des jetons anciens et le modèle perd une partie de la conversation sans le savoir.", + "avertissement : --cache-reuse 256 (EXTRA_ARGS) — réutilise des morceaux de cache calculés sous un autre préfixe : les sorties ne sont plus exactement celles d'un calcul complet." + ] + }, + { + "m": 5, + "c": 86, + "h": 0, + "e": 0, + "args": [ + "/app/llama-server", + "-m", + "/m/ds.gguf", + "-c", + "32768", + "-b", + "2048", + "-ub", + "512", + "--host", + "0.0.0.0", + "--port", + "8080", + "--parallel", + "1", + "-ngl", + "auto", + "--api-key", + "sk-loki-x", + "-ctk", + "q8_0", + "-ctv", + "q8_0" + ], + "env": {}, + "notes": [ + "cache KV q8_0/q8_0 (défini par EXTRA_ARGS) : modifie légèrement les sorties par rapport à f16 — choix de l'utilisateur, laissé tel quel." + ] + }, + { + "m": 5, + "c": 87, + "h": 0, + "e": 0, + "args": [ + "/app/llama-server", + "-m", + "/m/ds.gguf", + "-c", + "32768", + "-b", + "2048", + "-ub", + "512", + "--host", + "0.0.0.0", + "--port", + "8080", + "--parallel", + "1", + "-ngl", + "auto", + "--api-key", + "sk-loki-x", + "-ot", + "exps=CPU" + ], + "env": {}, + "notes": [ + "experts placés à la main (-ot experts=CPU) : --fit ne les place pas ; UBATCH plus grand = tampon de calcul plus gros sur chaque GPU : monte --n-cpu-moe d'abord, ou passe au placement auto (--fit, « Dupliquer en placement auto » dans l'éditeur)" + ] + }, + { + "m": 5, + "c": 88, + "h": 0, + "e": 0, + "args": [ + "/app/llama-server", + "-m", + "/m/ds.gguf", + "-c", + "32768", + "-b", + "2048", + "-ub", + "512", + "--host", + "0.0.0.0", + "--port", + "8080", + "--parallel", + "1", + "-ngl", + "auto", + "--api-key", + "sk-loki-x", + "-ot", + "blk\\.(1|2)\\.ffn_.*_exps=CPU", + "-ub", + "512" + ], + "env": {}, + "notes": [ + "experts placés à la main (-ot experts=CPU) : --fit ne les place pas ; UBATCH plus grand = tampon de calcul plus gros sur chaque GPU : monte --n-cpu-moe d'abord, ou passe au placement auto (--fit, « Dupliquer en placement auto » dans l'éditeur)" + ] + }, + { + "m": 5, + "c": 89, + "h": 0, + "e": 0, + "args": [ + "/app/llama-server", + "-m", + "/m/ds.gguf", + "-c", + "32768", + "-b", + "2048", + "-ub", + "512", + "--host", + "0.0.0.0", + "--port", + "8080", + "--parallel", + "1", + "-ngl", + "auto", + "--api-key", + "sk-loki-x", + "--n-cpu-moe", + "20" + ], + "env": {}, + "notes": [ + "experts placés à la main (--n-cpu-moe 20) : --fit ne les place pas ; UBATCH plus grand = tampon de calcul plus gros sur chaque GPU : monte --n-cpu-moe d'abord, ou passe au placement auto (--fit, « Dupliquer en placement auto » dans l'éditeur)" + ] + }, + { + "m": 5, + "c": 90, + "h": 0, + "e": 0, + "args": [ + "/app/llama-server", + "-m", + "/m/ds.gguf", + "-c", + "32768", + "-b", + "2048", + "-ub", + "512", + "--host", + "0.0.0.0", + "--port", + "8080", + "--parallel", + "1", + "-ngl", + "auto", + "--api-key", + "sk-loki-x", + "--cpu-moe" + ], + "env": {}, + "notes": [ + "experts placés à la main (--cpu-moe) : --fit ne les place pas ; UBATCH plus grand = tampon de calcul plus gros sur chaque GPU : monte --n-cpu-moe d'abord, ou passe au placement auto (--fit, « Dupliquer en placement auto » dans l'éditeur)" + ] + }, + { + "m": 5, + "c": 91, + "h": 0, + "e": 0, + "args": [ + "/app/llama-server", + "-m", + "/m/ds.gguf", + "-c", + "32768", + "-b", + "2048", + "-ub", + "512", + "--host", + "0.0.0.0", + "--port", + "8080", + "--parallel", + "1", + "-ngl", + "auto", + "--api-key", + "sk-loki-x", + "-sm", + "row" + ], + "env": {}, + "notes": null + }, + { + "m": 5, + "c": 92, + "h": 0, + "e": 0, + "args": [ + "/app/llama-server", + "-m", + "/m/ds.gguf", + "-c", + "32768", + "-b", + "2048", + "-ub", + "512", + "--host", + "0.0.0.0", + "--port", + "8080", + "--parallel", + "1", + "-ngl", + "auto", + "--api-key", + "sk-loki-x", + "-ts", + "1,1" + ], + "env": {}, + "notes": null + }, + { + "m": 5, + "c": 93, + "h": 0, + "e": 0, + "args": [ + "/app/llama-server", + "-m", + "/m/ds.gguf", + "-c", + "32768", + "-b", + "2048", + "-ub", + "512", + "--host", + "0.0.0.0", + "--port", + "8080", + "--parallel", + "1", + "-ngl", + "auto", + "--api-key", + "sk-loki-x", + "-dev", + "CUDA0,CUDA1" + ], + "env": { + "CUDA_SCALE_LAUNCH_QUEUES": "4x" + }, + "notes": [ + "CUDA_SCALE_LAUNCH_QUEUES=4x (2 GPU, parallélisme pipeline possible) — CUDA_LAUNCH_QUEUES=off pour s'en passer" + ] + }, + { + "m": 5, + "c": 94, + "h": 0, + "e": 0, + "args": [ + "/app/llama-server", + "-m", + "/m/ds.gguf", + "-c", + "32768", + "-b", + "2048", + "-ub", + "512", + "--host", + "0.0.0.0", + "--port", + "8080", + "--parallel", + "1", + "-ngl", + "auto", + "--api-key", + "sk-loki-x", + "-dev", + "Vulkan0" + ], + "env": {}, + "notes": null + }, + { + "m": 5, + "c": 95, + "h": 0, + "e": 0, + "args": [ + "/app/llama-server", + "-m", + "/m/ds.gguf", + "-c", + "32768", + "-b", + "2048", + "-ub", + "512", + "--host", + "0.0.0.0", + "--port", + "8080", + "--parallel", + "1", + "-ngl", + "auto", + "--api-key", + "sk-loki-x", + "--fit", + "off" + ], + "env": {}, + "notes": null + }, + { + "m": 5, + "c": 96, + "h": 0, + "e": 0, + "args": [ + "/app/llama-server", + "-m", + "/m/ds.gguf", + "-c", + "32768", + "-b", + "2048", + "-ub", + "512", + "--host", + "0.0.0.0", + "--port", + "8080", + "--parallel", + "1", + "-ngl", + "auto", + "--api-key", + "sk-loki-x", + "--mmproj", + "/m/x.gguf" + ], + "env": {}, + "notes": null + }, + { + "m": 5, + "c": 97, + "h": 0, + "e": 0, + "args": [ + "/app/llama-server", + "-m", + "/m/ds.gguf", + "-c", + "32768", + "-b", + "2048", + "-ub", + "512", + "--host", + "0.0.0.0", + "--port", + "8080", + "--parallel", + "1", + "-ngl", + "auto", + "--api-key", + "sk-loki-x", + "--spec-synth-len", + "4" + ], + "env": {}, + "notes": [ + "avertissement : --spec-synth-len — le moteur accepte des jetons du brouillon AU HASARD, sans vérification : les réponses ne sont plus celles du modèle. Réservé aux mesures, retire-le." + ] + }, + { + "m": 5, + "c": 98, + "h": 0, + "e": 0, + "args": [ + "/app/llama-server", + "-m", + "/m/ds.gguf", + "-c", + "32768", + "-b", + "2048", + "-ub", + "512", + "--host", + "0.0.0.0", + "--port", + "8080", + "--parallel", + "1", + "-ngl", + "auto", + "--api-key", + "sk-loki-x", + "--slot-save-path", + "/x" + ], + "env": {}, + "notes": null + }, + { + "m": 5, + "c": 99, + "h": 0, + "e": 0, + "args": [ + "/app/llama-server", + "-m", + "/m/ds.gguf", + "-c", + "32768", + "-b", + "2048", + "-ub", + "512", + "--host", + "0.0.0.0", + "--port", + "8080", + "--parallel", + "1", + "-ngl", + "auto", + "--api-key", + "sk-loki-x", + "-c", + "0" + ], + "env": {}, + "notes": null + }, + { + "m": 5, + "c": 100, + "h": 0, + "e": 0, + "args": [ + "/app/llama-server", + "-m", + "/m/ds.gguf", + "-c", + "32768", + "-b", + "2048", + "-ub", + "512", + "--host", + "0.0.0.0", + "--port", + "8080", + "--parallel", + "1", + "-ngl", + "auto", + "--api-key", + "sk-loki-x", + "--host", + "127.0.0.1", + "--port", + "9000" + ], + "env": {}, + "notes": null + }, + { + "m": 5, + "c": 101, + "h": 0, + "e": 0, + "args": [ + "/app/llama-server", + "-m", + "/m/ds.gguf", + "-c", + "32768", + "-b", + "2048", + "-ub", + "512", + "--host", + "0.0.0.0", + "--port", + "8080", + "--parallel", + "1", + "-ngl", + "auto", + "--api-key", + "sk-loki-x", + "--mirostat", + "2" + ], + "env": {}, + "notes": [ + "SPEC=mtp : pas de tête MTP dans ce fichier (publiée à part ? MODEL_DRAFT=mtp-….gguf) — sans décodage spéculatif" + ] + }, + { + "m": 5, + "c": 102, + "h": 0, + "e": 0, + "args": [ + "/app/llama-server", + "-m", + "/m/ds.gguf", + "-c", + "32768", + "-b", + "2048", + "-ub", + "512", + "--host", + "0.0.0.0", + "--port", + "8080", + "--parallel", + "1", + "-ngl", + "auto", + "--api-key", + "sk-loki-x", + "--chat-template-kwargs", + "{\"preserve_reasoning\":true}" + ], + "env": {}, + "notes": [ + "--chat-template-kwargs fixe déjà preserve_reasoning : REASONING_PRESERVE ignoré" + ] + }, + { + "m": 5, + "c": 103, + "h": 0, + "e": 0, + "args": [ + "/app/llama-server", + "-m", + "/m/ds.gguf", + "-c", + "32768", + "-b", + "2048", + "-ub", + "512", + "--host", + "0.0.0.0", + "--port", + "8080", + "--parallel", + "1", + "-ngl", + "auto", + "--api-key", + "sk-loki-x", + "--spec-type", + "ngram-mod" + ], + "env": {}, + "notes": [ + "décodage spéculatif déjà réglé dans EXTRA_ARGS ou LLAMA_ARG_* : SPEC ignoré" + ] + }, + { + "m": 5, + "c": 104, + "h": 0, + "e": 0, + "args": [ + "/app/llama-server", + "-m", + "/m/ds.gguf", + "-c", + "32768", + "-b", + "2048", + "-ub", + "512", + "--host", + "0.0.0.0", + "--port", + "8080", + "--parallel", + "1", + "-ngl", + "auto", + "--api-key", + "sk-loki-x", + "--spec-draft-n-max", + "8" + ], + "env": {}, + "notes": [ + "SPEC=ngram : --spec-draft-n-max dans EXTRA_ARGS (brouillon réglé à la main) — sans décodage spéculatif" + ] + }, + { + "m": 5, + "c": 105, + "h": 0, + "e": 0, + "args": [ + "/app/llama-server", + "-m", + "/m/ds.gguf", + "-c", + "32768", + "-b", + "2048", + "-ub", + "512", + "--host", + "0.0.0.0", + "--port", + "8080", + "--parallel", + "1", + "-ngl", + "auto", + "--api-key", + "sk-loki-x", + "-ot", + "attn=CUDA1" + ], + "env": {}, + "notes": [ + "CUDA_GRAPH_OPT ignoré : -ot sur l'attention peut séparer Q, K et V d'une couche entre cartes" + ] + }, + { + "m": 5, + "c": 106, + "h": 0, + "e": 0, + "args": [ + "/app/llama-server", + "-m", + "/m/ds.gguf", + "-c", + "32768", + "-b", + "2048", + "-ub", + "512", + "--host", + "0.0.0.0", + "--port", + "8080", + "--parallel", + "1", + "-ngl", + "auto", + "--api-key", + "sk-loki-x", + "-fa", + "off" + ], + "env": {}, + "notes": [ + "SPLIT_MODE=tensor refusé (Flash Attention coupée (-fa off) : le mode tensor l'exige) : découpe par couches, comme sans la clé" + ] + }, + { + "m": 5, + "c": 107, + "h": 0, + "e": 0, + "args": [ + "/app/llama-server", + "-m", + "/m/ds.gguf", + "-c", + "32768", + "-b", + "2048", + "-ub", + "512", + "--host", + "0.0.0.0", + "--port", + "8080", + "--parallel", + "1", + "-ngl", + "auto", + "--api-key", + "sk-loki-x", + "-nkvo" + ], + "env": {}, + "notes": null + }, + { + "m": 5, + "c": 108, + "h": 0, + "e": 0, + "args": [ + "/app/llama-server", + "-m", + "/m/ds.gguf", + "-c", + "32768", + "-b", + "2048", + "-ub", + "512", + "--host", + "0.0.0.0", + "--port", + "8080", + "--parallel", + "1", + "-ngl", + "auto", + "--api-key", + "sk-loki-x", + "--no-context-shift" + ], + "env": {}, + "notes": null + }, + { + "m": 5, + "c": 109, + "h": 0, + "e": 0, + "args": [ + "/app/llama-server", + "-m", + "/m/ds.gguf", + "-c", + "32768", + "-b", + "2048", + "-ub", + "512", + "--host", + "0.0.0.0", + "--port", + "8080", + "-ngl", + "auto", + "--api-key", + "sk-loki-x", + "-c", + "8192", + "-np", + "2" + ], + "env": {}, + "notes": [ + "SIDE_SLOT refusé (-c est réglé dans EXTRA_ARGS) : un seul slot, comme sans la clé" + ] + }, + { + "m": 5, + "c": 110, + "h": 0, + "e": 0, + "args": [ + "/app/llama-server", + "-m", + "/m/ds.gguf", + "-c", + "32768", + "-b", + "2048", + "-ub", + "512", + "--host", + "0.0.0.0", + "--port", + "8080", + "--parallel", + "1", + "-ngl", + "auto", + "--api-key", + "sk-loki-x" + ], + "env": {}, + "notes": [ + "SPLIT_MODE=tensor refusé (preset externe) : découpe par couches, comme sans la clé", + "SIDE_SLOT refusé (preset externe) : un seul slot, comme sans la clé" + ] + }, + { + "m": 5, + "c": 111, + "h": 0, + "e": 0, + "args": [ + "/app/llama-server", + "-m", + "/m/ds.gguf", + "-c", + "40000", + "-b", + "2048", + "-ub", + "512", + "--host", + "0.0.0.0", + "--port", + "8080", + "--parallel", + "1", + "-ngl", + "auto", + "-ctk", + "q4_0", + "-ctv", + "q4_0", + "--api-key", + "sk-loki-x" + ], + "env": {}, + "notes": [ + "NGL=999 (sentinelle « toutes les couches ») → -ngl auto : ce moteur mesure lui-même la VRAM libre. NGL=all pour forcer l'ancien comportement.", + "cache KV q4_0/q4_0 (KV_TYPE) : perte mesurable par rapport à f16 — choix de l'utilisateur, laissé tel quel." + ] + }, + { + "m": 6, + "c": 0, + "h": 0, + "e": 0, + "args": [ + "/app/llama-server", + "-m", + "/m/layers.gguf", + "-c", + "32768", + "-t", + "4", + "-b", + "2048", + "-ub", + "512", + "--host", + "0.0.0.0", + "--port", + "8080", + "--parallel", + "1", + "-ngl", + "auto", + "--mmproj", + "/m/mmproj.gguf" + ], + "env": {}, + "notes": [ + "threads auto → -t 4 : cpuset du conteneur, 4 cœurs physiques permis sur 8 (llama.cpp seul en lancerait 8)" + ] + }, + { + "m": 6, + "c": 1, + "h": 0, + "e": 0, + "args": [ + "/app/llama-server", + "-m", + "/m/layers.gguf", + "-c", + "65536", + "-t", + "4", + "-b", + "4096", + "-ub", + "2048", + "--host", + "127.0.0.1", + "--port", + "8081", + "--parallel", + "1", + "-ngl", + "auto", + "--mmproj", + "/m/mmproj.gguf", + "--slot-save-path", + "/data/slots" + ], + "env": {}, + "notes": [ + "threads auto → -t 4 : cpuset du conteneur, 4 cœurs physiques permis sur 8 (llama.cpp seul en lancerait 8)", + "NGL=999 (sentinelle « toutes les couches ») → -ngl auto : ce moteur mesure lui-même la VRAM libre. NGL=all pour forcer l'ancien comportement." + ] + }, + { + "m": 6, + "c": 2, + "h": 0, + "e": 0, + "args": [ + "/app/llama-server", + "-m", + "/m/layers.gguf", + "-c", + "32768", + "-t", + "4", + "-b", + "2048", + "-ub", + "512", + "--host", + "0.0.0.0", + "--port", + "8080", + "--parallel", + "1", + "--mmproj", + "/m/mmproj.gguf" + ], + "env": {}, + "notes": [ + "threads auto → -t 4 : cpuset du conteneur, 4 cœurs physiques permis sur 8 (llama.cpp seul en lancerait 8)" + ] + }, + { + "m": 6, + "c": 3, + "h": 0, + "e": 0, + "args": [ + "/app/llama-server", + "-m", + "/m/layers.gguf", + "-c", + "32768", + "-t", + "4", + "-b", + "2048", + "-ub", + "512", + "--host", + "0.0.0.0", + "--port", + "8080", + "--parallel", + "1", + "-ngl", + "40", + "--mmproj", + "/m/mmproj.gguf" + ], + "env": {}, + "notes": [ + "threads auto → -t 4 : cpuset du conteneur, 4 cœurs physiques permis sur 8 (llama.cpp seul en lancerait 8)" + ] + }, + { + "m": 6, + "c": 4, + "h": 0, + "e": 0, + "args": [ + "/app/llama-server", + "-m", + "/m/layers.gguf", + "-c", + "32768", + "-t", + "4", + "-b", + "2048", + "-ub", + "512", + "--host", + "0.0.0.0", + "--port", + "8080", + "--parallel", + "1", + "-ngl", + "all", + "--mmproj", + "/m/mmproj.gguf" + ], + "env": {}, + "notes": [ + "threads auto → -t 4 : cpuset du conteneur, 4 cœurs physiques permis sur 8 (llama.cpp seul en lancerait 8)" + ] + }, + { + "m": 6, + "c": 5, + "h": 0, + "e": 0, + "args": [ + "/app/llama-server", + "-m", + "/m/layers.gguf", + "-c", + "32768", + "-t", + "4", + "-b", + "2048", + "-ub", + "512", + "--host", + "0.0.0.0", + "--port", + "8080", + "--parallel", + "1", + "-ngl", + "auto", + "--mmproj", + "/m/mmproj.gguf" + ], + "env": {}, + "notes": [ + "threads auto → -t 4 : cpuset du conteneur, 4 cœurs physiques permis sur 8 (llama.cpp seul en lancerait 8)", + "NGL=999 (sentinelle « toutes les couches ») → -ngl auto : ce moteur mesure lui-même la VRAM libre. NGL=all pour forcer l'ancien comportement." + ] + }, + { + "m": 6, + "c": 6, + "h": 0, + "e": 0, + "args": [ + "/app/llama-server", + "-m", + "/m/layers.gguf", + "-c", + "32768", + "-t", + "8", + "-tb", + "4", + "-b", + "2048", + "-ub", + "512", + "--host", + "0.0.0.0", + "--port", + "8080", + "--parallel", + "1", + "-ngl", + "auto", + "--mmproj", + "/m/mmproj.gguf" + ], + "env": {}, + "notes": null + }, + { + "m": 6, + "c": 7, + "h": 0, + "e": 0, + "args": [ + "/app/llama-server", + "-m", + "/m/layers.gguf", + "-c", + "32768", + "-t", + "4", + "-b", + "2048", + "-ub", + "512", + "--host", + "0.0.0.0", + "--port", + "8080", + "--parallel", + "1", + "-ngl", + "auto", + "--mmproj", + "/m/mmproj.gguf" + ], + "env": {}, + "notes": [ + "THREADS=abc ignoré (pas un nombre de threads ≥ 0) → automatique", + "threads auto → -t 4 : cpuset du conteneur, 4 cœurs physiques permis sur 8 (llama.cpp seul en lancerait 8)", + "THREADS_BATCH=-2 ignoré (pas un nombre de threads ≥ 0) → automatique" + ] + }, + { + "m": 6, + "c": 8, + "h": 0, + "e": 0, + "args": [ + "/app/llama-server", + "-m", + "/m/layers.gguf", + "-c", + "32768", + "-t", + "4", + "-b", + "2048", + "-ub", + "512", + "--host", + "0.0.0.0", + "--port", + "8080", + "--parallel", + "1", + "-ngl", + "auto", + "--mmproj", + "/m/mmproj.gguf" + ], + "env": {}, + "notes": [ + "threads auto → -t 4 : cpuset du conteneur, 4 cœurs physiques permis sur 8 (llama.cpp seul en lancerait 8)" + ] + }, + { + "m": 6, + "c": 9, + "h": 0, + "e": 0, + "args": [ + "/app/llama-server", + "-m", + "/m/layers.gguf", + "-c", + "32768", + "-t", + "4", + "-b", + "2048", + "-ub", + "512", + "--host", + "0.0.0.0", + "--port", + "8080", + "--parallel", + "1", + "-ngl", + "auto", + "-ctk", + "q8_0", + "-ctv", + "q8_0", + "--mmproj", + "/m/mmproj.gguf" + ], + "env": {}, + "notes": [ + "threads auto → -t 4 : cpuset du conteneur, 4 cœurs physiques permis sur 8 (llama.cpp seul en lancerait 8)", + "cache KV q8_0/q8_0 (KV_TYPE) : modifie légèrement les sorties par rapport à f16 — choix de l'utilisateur, laissé tel quel." + ] + }, + { + "m": 6, + "c": 10, + "h": 0, + "e": 0, + "args": [ + "/app/llama-server", + "-m", + "/m/layers.gguf", + "-c", + "32768", + "-t", + "4", + "-b", + "2048", + "-ub", + "512", + "--host", + "0.0.0.0", + "--port", + "8080", + "--parallel", + "1", + "-ngl", + "auto", + "-ctk", + "q4_0", + "-ctv", + "f16", + "--mmproj", + "/m/mmproj.gguf" + ], + "env": {}, + "notes": [ + "threads auto → -t 4 : cpuset du conteneur, 4 cœurs physiques permis sur 8 (llama.cpp seul en lancerait 8)", + "cache KV q4_0/f16 (KV_TYPE) : perte mesurable par rapport à f16 — choix de l'utilisateur, laissé tel quel." + ] + }, + { + "m": 6, + "c": 11, + "h": 0, + "e": 0, + "args": [ + "/app/llama-server", + "-m", + "/m/layers.gguf", + "-c", + "32768", + "-t", + "4", + "-b", + "2048", + "-ub", + "512", + "--host", + "0.0.0.0", + "--port", + "8080", + "--parallel", + "1", + "-ngl", + "auto", + "-ctk", + "bf16", + "-ctv", + "bf16", + "--mmproj", + "/m/mmproj.gguf" + ], + "env": {}, + "notes": [ + "threads auto → -t 4 : cpuset du conteneur, 4 cœurs physiques permis sur 8 (llama.cpp seul en lancerait 8)", + "cache KV bf16/bf16 (KV_TYPE) : numérique différente de f16 (mantisse plus courte) — choix de l'utilisateur, laissé tel quel." + ] + }, + { + "m": 6, + "c": 12, + "h": 0, + "e": 0, + "args": [ + "/app/llama-server", + "-m", + "/m/layers.gguf", + "-c", + "32768", + "-t", + "4", + "-b", + "2048", + "-ub", + "512", + "--host", + "0.0.0.0", + "--port", + "8080", + "--parallel", + "1", + "-ngl", + "auto", + "-ctk", + "q5_1", + "-ctv", + "q5_1", + "--mmproj", + "/m/mmproj.gguf" + ], + "env": {}, + "notes": [ + "threads auto → -t 4 : cpuset du conteneur, 4 cœurs physiques permis sur 8 (llama.cpp seul en lancerait 8)", + "cache KV q5_1/q5_1 (KV_TYPE) : modifie légèrement les sorties par rapport à f16 — choix de l'utilisateur, laissé tel quel." + ] + }, + { + "m": 6, + "c": 13, + "h": 0, + "e": 0, + "args": [ + "/app/llama-server", + "-m", + "/m/layers.gguf", + "-c", + "32768", + "-t", + "4", + "-b", + "2048", + "-ub", + "512", + "--host", + "0.0.0.0", + "--port", + "8080", + "--parallel", + "1", + "-ngl", + "auto", + "--mmproj", + "/m/mmproj.gguf", + "--reasoning", + "on", + "--reasoning-budget", + "-1" + ], + "env": {}, + "notes": [ + "threads auto → -t 4 : cpuset du conteneur, 4 cœurs physiques permis sur 8 (llama.cpp seul en lancerait 8)" + ] + }, + { + "m": 6, + "c": 14, + "h": 0, + "e": 0, + "args": [ + "/app/llama-server", + "-m", + "/m/layers.gguf", + "-c", + "32768", + "-t", + "4", + "-b", + "2048", + "-ub", + "512", + "--host", + "0.0.0.0", + "--port", + "8080", + "--parallel", + "1", + "-ngl", + "auto", + "--mmproj", + "/m/mmproj.gguf", + "--reasoning", + "off" + ], + "env": {}, + "notes": [ + "threads auto → -t 4 : cpuset du conteneur, 4 cœurs physiques permis sur 8 (llama.cpp seul en lancerait 8)" + ] + }, + { + "m": 6, + "c": 15, + "h": 0, + "e": 0, + "args": [ + "/app/llama-server", + "-m", + "/m/layers.gguf", + "-c", + "32768", + "-t", + "4", + "-b", + "2048", + "-ub", + "512", + "--host", + "0.0.0.0", + "--port", + "8080", + "--parallel", + "1", + "-ngl", + "auto", + "--mmproj", + "/m/mmproj.gguf", + "--reasoning", + "auto", + "--reasoning-budget", + "2048" + ], + "env": {}, + "notes": [ + "threads auto → -t 4 : cpuset du conteneur, 4 cœurs physiques permis sur 8 (llama.cpp seul en lancerait 8)" + ] + }, + { + "m": 6, + "c": 16, + "h": 0, + "e": 0, + "args": [ + "/app/llama-server", + "-m", + "/m/layers.gguf", + "-c", + "32768", + "-t", + "4", + "-b", + "2048", + "-ub", + "512", + "--host", + "0.0.0.0", + "--port", + "8080", + "--parallel", + "1", + "-ngl", + "auto", + "--mmproj", + "/m/mmproj.gguf", + "--reasoning", + "deepseek", + "--reasoning-budget", + "-1" + ], + "env": {}, + "notes": [ + "threads auto → -t 4 : cpuset du conteneur, 4 cœurs physiques permis sur 8 (llama.cpp seul en lancerait 8)" + ] + }, + { + "m": 6, + "c": 17, + "h": 0, + "e": 0, + "args": [ + "/app/llama-server", + "-m", + "/m/layers.gguf", + "-c", + "32768", + "-t", + "4", + "-b", + "2048", + "-ub", + "512", + "--host", + "0.0.0.0", + "--port", + "8080", + "--parallel", + "1", + "-ngl", + "auto", + "--mmproj", + "/m/mmproj.gguf", + "--reasoning-preserve" + ], + "env": {}, + "notes": [ + "threads auto → -t 4 : cpuset du conteneur, 4 cœurs physiques permis sur 8 (llama.cpp seul en lancerait 8)" + ] + }, + { + "m": 6, + "c": 18, + "h": 0, + "e": 0, + "args": [ + "/app/llama-server", + "-m", + "/m/layers.gguf", + "-c", + "32768", + "-t", + "4", + "-b", + "2048", + "-ub", + "512", + "--host", + "0.0.0.0", + "--port", + "8080", + "--parallel", + "1", + "-ngl", + "auto", + "--mmproj", + "/m/mmproj.gguf", + "--no-reasoning-preserve" + ], + "env": {}, + "notes": [ + "threads auto → -t 4 : cpuset du conteneur, 4 cœurs physiques permis sur 8 (llama.cpp seul en lancerait 8)" + ] + }, + { + "m": 6, + "c": 19, + "h": 0, + "e": 0, + "args": [ + "/app/llama-server", + "-m", + "/m/layers.gguf", + "-c", + "32768", + "-t", + "4", + "-b", + "2048", + "-ub", + "512", + "--host", + "0.0.0.0", + "--port", + "8080", + "--parallel", + "1", + "-ngl", + "auto", + "--mmproj", + "/m/mmproj.gguf" + ], + "env": {}, + "notes": [ + "threads auto → -t 4 : cpuset du conteneur, 4 cœurs physiques permis sur 8 (llama.cpp seul en lancerait 8)", + "REASONING_PRESERVE=peut-être illisible (on ou off) : ignoré" + ] + }, + { + "m": 6, + "c": 20, + "h": 0, + "e": 0, + "args": [ + "/app/llama-server", + "-m", + "/m/layers.gguf", + "-c", + "32768", + "-t", + "4", + "-b", + "2048", + "-ub", + "512", + "--host", + "0.0.0.0", + "--port", + "8080", + "--parallel", + "1", + "-ngl", + "auto", + "--mmproj", + "/m/mmproj.gguf" + ], + "env": {}, + "notes": [ + "threads auto → -t 4 : cpuset du conteneur, 4 cœurs physiques permis sur 8 (llama.cpp seul en lancerait 8)" + ] + }, + { + "m": 6, + "c": 21, + "h": 0, + "e": 0, + "args": [ + "/app/llama-server", + "-m", + "/m/layers.gguf", + "-c", + "32768", + "-t", + "4", + "-b", + "2048", + "-ub", + "512", + "--host", + "0.0.0.0", + "--port", + "8080", + "--parallel", + "1", + "-ngl", + "auto", + "--mmproj", + "/m/mmproj.gguf", + "--cache-ram", + "4096" + ], + "env": {}, + "notes": [ + "threads auto → -t 4 : cpuset du conteneur, 4 cœurs physiques permis sur 8 (llama.cpp seul en lancerait 8)" + ] + }, + { + "m": 6, + "c": 22, + "h": 0, + "e": 0, + "args": [ + "/app/llama-server", + "-m", + "/m/layers.gguf", + "-c", + "32768", + "-t", + "4", + "-b", + "2048", + "-ub", + "512", + "--host", + "0.0.0.0", + "--port", + "8080", + "--parallel", + "1", + "-ngl", + "auto", + "--mmproj", + "/m/mmproj.gguf", + "--cache-ram", + "0" + ], + "env": {}, + "notes": [ + "threads auto → -t 4 : cpuset du conteneur, 4 cœurs physiques permis sur 8 (llama.cpp seul en lancerait 8)" + ] + }, + { + "m": 6, + "c": 23, + "h": 0, + "e": 0, + "args": [ + "/app/llama-server", + "-m", + "/m/layers.gguf", + "-c", + "32768", + "-t", + "4", + "-b", + "2048", + "-ub", + "512", + "--host", + "0.0.0.0", + "--port", + "8080", + "--parallel", + "1", + "-ngl", + "auto", + "--mmproj", + "/m/mmproj.gguf", + "--cache-ram", + "-1" + ], + "env": {}, + "notes": [ + "threads auto → -t 4 : cpuset du conteneur, 4 cœurs physiques permis sur 8 (llama.cpp seul en lancerait 8)" + ] + }, + { + "m": 6, + "c": 24, + "h": 0, + "e": 0, + "args": [ + "/app/llama-server", + "-m", + "/m/layers.gguf", + "-c", + "32768", + "-t", + "4", + "-b", + "2048", + "-ub", + "512", + "--host", + "0.0.0.0", + "--port", + "8080", + "--parallel", + "1", + "-ngl", + "auto", + "--mmproj", + "/m/mmproj.gguf" + ], + "env": {}, + "notes": [ + "threads auto → -t 4 : cpuset du conteneur, 4 cœurs physiques permis sur 8 (llama.cpp seul en lancerait 8)", + "CACHE_RAM=beaucoup illisible (un nombre de Mio, -1, 0 ou auto) : ignoré" + ] + }, + { + "m": 6, + "c": 25, + "h": 0, + "e": 0, + "args": [ + "/app/llama-server", + "-m", + "/m/layers.gguf", + "-c", + "32768", + "-t", + "4", + "-b", + "2048", + "-ub", + "512", + "--host", + "0.0.0.0", + "--port", + "8080", + "--parallel", + "1", + "-ngl", + "auto", + "--mmproj", + "/m/mmproj.gguf" + ], + "env": {}, + "notes": [ + "threads auto → -t 4 : cpuset du conteneur, 4 cœurs physiques permis sur 8 (llama.cpp seul en lancerait 8)" + ] + }, + { + "m": 6, + "c": 26, + "h": 0, + "e": 0, + "args": [ + "/app/llama-server", + "-m", + "/m/layers.gguf", + "-c", + "32768", + "-t", + "4", + "-b", + "2048", + "-ub", + "512", + "--host", + "0.0.0.0", + "--port", + "8080", + "--parallel", + "1", + "-ngl", + "auto", + "--mmproj", + "/m/mmproj.gguf", + "--cache-ram", + "0" + ], + "env": {}, + "notes": [ + "threads auto → -t 4 : cpuset du conteneur, 4 cœurs physiques permis sur 8 (llama.cpp seul en lancerait 8)" + ] + }, + { + "m": 6, + "c": 27, + "h": 0, + "e": 0, + "args": [ + "/app/llama-server", + "-m", + "/m/layers.gguf", + "-c", + "32768", + "-t", + "4", + "-b", + "2048", + "-ub", + "512", + "--host", + "0.0.0.0", + "--port", + "8080", + "--parallel", + "1", + "-ngl", + "auto", + "--mmproj", + "/m/mmproj.gguf" + ], + "env": {}, + "notes": [ + "threads auto → -t 4 : cpuset du conteneur, 4 cœurs physiques permis sur 8 (llama.cpp seul en lancerait 8)", + "SIDE_SLOT refusé (VRAM NVIDIA inconnue (macOS, AMD, --device…)) : un seul slot, comme sans la clé" + ] + }, + { + "m": 6, + "c": 28, + "h": 0, + "e": 0, + "args": [ + "/app/llama-server", + "-m", + "/m/layers.gguf", + "-c", + "32768", + "-t", + "4", + "-b", + "2048", + "-ub", + "512", + "--host", + "0.0.0.0", + "--port", + "8080", + "--parallel", + "3", + "-ngl", + "auto", + "--mmproj", + "/m/mmproj.gguf" + ], + "env": {}, + "notes": [ + "threads auto → -t 4 : cpuset du conteneur, 4 cœurs physiques permis sur 8 (llama.cpp seul en lancerait 8)", + "SIDE_SLOT refusé (PARALLEL=3 (SIDE_SLOT ouvre exactement 2 slots)) : un seul slot, comme sans la clé" + ] + }, + { + "m": 6, + "c": 29, + "h": 0, + "e": 0, + "args": [ + "/app/llama-server", + "-m", + "/m/layers.gguf", + "-c", + "0", + "-t", + "4", + "-b", + "2048", + "-ub", + "512", + "--host", + "0.0.0.0", + "--port", + "8080", + "--parallel", + "1", + "-ngl", + "auto", + "--mmproj", + "/m/mmproj.gguf" + ], + "env": {}, + "notes": [ + "threads auto → -t 4 : cpuset du conteneur, 4 cœurs physiques permis sur 8 (llama.cpp seul en lancerait 8)", + "SIDE_SLOT refusé (CTX doit être un nombre de jetons explicite) : un seul slot, comme sans la clé" + ] + }, + { + "m": 6, + "c": 30, + "h": 0, + "e": 0, + "args": [ + "/app/llama-server", + "-m", + "/m/layers.gguf", + "-c", + "8192", + "-t", + "4", + "-b", + "2048", + "-ub", + "512", + "--host", + "0.0.0.0", + "--port", + "8080", + "--parallel", + "1", + "-ngl", + "auto", + "-ctk", + "q8_0", + "-ctv", + "q8_0", + "--mmproj", + "/m/mmproj.gguf" + ], + "env": {}, + "notes": [ + "threads auto → -t 4 : cpuset du conteneur, 4 cœurs physiques permis sur 8 (llama.cpp seul en lancerait 8)", + "SIDE_SLOT refusé (VRAM NVIDIA inconnue (macOS, AMD, --device…)) : un seul slot, comme sans la clé", + "cache KV q8_0/q8_0 (KV_TYPE) : modifie légèrement les sorties par rapport à f16 — choix de l'utilisateur, laissé tel quel." + ] + }, + { + "m": 6, + "c": 31, + "h": 0, + "e": 0, + "args": [ + "/app/llama-server", + "-m", + "/m/layers.gguf", + "-c", + "32768", + "-t", + "4", + "-b", + "2048", + "-ub", + "512", + "--host", + "0.0.0.0", + "--port", + "8080", + "--parallel", + "1", + "-ngl", + "auto", + "--mmproj", + "/m/mmproj.gguf" + ], + "env": {}, + "notes": [ + "SPLIT_MODE=tensor refusé (cartes inconnues (--list-devices n'a rien répondu)) : découpe par couches, comme sans la clé", + "threads auto → -t 4 : cpuset du conteneur, 4 cœurs physiques permis sur 8 (llama.cpp seul en lancerait 8)" + ] + }, + { + "m": 6, + "c": 32, + "h": 0, + "e": 0, + "args": [ + "/app/llama-server", + "-m", + "/m/layers.gguf", + "-c", + "16384", + "-t", + "4", + "-b", + "2048", + "-ub", + "512", + "--host", + "0.0.0.0", + "--port", + "8080", + "--parallel", + "1", + "-ngl", + "auto", + "--mmproj", + "/m/mmproj.gguf" + ], + "env": {}, + "notes": [ + "SPLIT_MODE=tensor refusé (cartes inconnues (--list-devices n'a rien répondu)) : découpe par couches, comme sans la clé", + "threads auto → -t 4 : cpuset du conteneur, 4 cœurs physiques permis sur 8 (llama.cpp seul en lancerait 8)" + ] + }, + { + "m": 6, + "c": 33, + "h": 0, + "e": 0, + "args": [ + "/app/llama-server", + "-m", + "/m/layers.gguf", + "-c", + "32768", + "-t", + "4", + "-b", + "2048", + "-ub", + "512", + "--host", + "0.0.0.0", + "--port", + "8080", + "--parallel", + "1", + "-ngl", + "auto", + "--mmproj", + "/m/mmproj.gguf" + ], + "env": {}, + "notes": [ + "SPLIT_MODE=tensor refusé (SPLIT_MODE=bizarre illisible (tensor ou off)) : découpe par couches, comme sans la clé", + "threads auto → -t 4 : cpuset du conteneur, 4 cœurs physiques permis sur 8 (llama.cpp seul en lancerait 8)" + ] + }, + { + "m": 6, + "c": 34, + "h": 0, + "e": 0, + "args": [ + "/app/llama-server", + "-m", + "/m/layers.gguf", + "-c", + "32768", + "-t", + "4", + "-b", + "2048", + "-ub", + "512", + "--host", + "0.0.0.0", + "--port", + "8080", + "--parallel", + "1", + "-ngl", + "auto", + "--mmproj", + "/m/mmproj.gguf" + ], + "env": {}, + "notes": [ + "threads auto → -t 4 : cpuset du conteneur, 4 cœurs physiques permis sur 8 (llama.cpp seul en lancerait 8)" + ] + }, + { + "m": 6, + "c": 35, + "h": 0, + "e": 0, + "args": [ + "/app/llama-server", + "-m", + "/m/layers.gguf", + "-c", + "32768", + "-t", + "4", + "-b", + "2048", + "-ub", + "512", + "--host", + "0.0.0.0", + "--port", + "8080", + "--parallel", + "1", + "-ngl", + "auto", + "-ctk", + "q8_0", + "-ctv", + "q8_0", + "--mmproj", + "/m/mmproj.gguf" + ], + "env": {}, + "notes": [ + "SPLIT_MODE=tensor refusé (cache KV q8_0/q8_0 : le mode tensor n'accepte que f16, bf16 ou f32 (jamais réécrit d'office : la VRAM changerait)) : découpe par couches, comme sans la clé", + "threads auto → -t 4 : cpuset du conteneur, 4 cœurs physiques permis sur 8 (llama.cpp seul en lancerait 8)", + "cache KV q8_0/q8_0 (KV_TYPE) : modifie légèrement les sorties par rapport à f16 — choix de l'utilisateur, laissé tel quel." + ] + }, + { + "m": 6, + "c": 36, + "h": 0, + "e": 0, + "args": [ + "/app/llama-server", + "-m", + "/m/layers.gguf", + "-c", + "32768", + "-t", + "4", + "-b", + "2048", + "-ub", + "512", + "--host", + "0.0.0.0", + "--port", + "8080", + "--parallel", + "1", + "-ngl", + "20", + "--mmproj", + "/m/mmproj.gguf" + ], + "env": {}, + "notes": [ + "SPLIT_MODE=tensor refusé (cartes inconnues (--list-devices n'a rien répondu)) : découpe par couches, comme sans la clé", + "threads auto → -t 4 : cpuset du conteneur, 4 cœurs physiques permis sur 8 (llama.cpp seul en lancerait 8)" + ] + }, + { + "m": 6, + "c": 37, + "h": 0, + "e": 0, + "args": [ + "/app/llama-server", + "-m", + "/m/layers.gguf", + "-c", + "32768", + "-t", + "4", + "-b", + "2048", + "-ub", + "512", + "--host", + "0.0.0.0", + "--port", + "8080", + "--parallel", + "1", + "-ngl", + "auto", + "--mmproj", + "/m/mmproj.gguf" + ], + "env": {}, + "notes": [ + "threads auto → -t 4 : cpuset du conteneur, 4 cœurs physiques permis sur 8 (llama.cpp seul en lancerait 8)" + ] + }, + { + "m": 6, + "c": 38, + "h": 0, + "e": 0, + "args": [ + "/app/llama-server", + "-m", + "/m/layers.gguf", + "-c", + "32768", + "-t", + "4", + "-b", + "2048", + "-ub", + "512", + "--host", + "0.0.0.0", + "--port", + "8080", + "--parallel", + "1", + "-ngl", + "auto", + "--mmproj", + "/m/mmproj.gguf" + ], + "env": {}, + "notes": [ + "threads auto → -t 4 : cpuset du conteneur, 4 cœurs physiques permis sur 8 (llama.cpp seul en lancerait 8)", + "SPEC=mtp : pas de tête MTP dans ce fichier (publiée à part ? MODEL_DRAFT=mtp-….gguf) — sans décodage spéculatif" + ] + }, + { + "m": 6, + "c": 39, + "h": 0, + "e": 0, + "args": [ + "/app/llama-server", + "-m", + "/m/layers.gguf", + "-c", + "32768", + "-t", + "4", + "-b", + "2048", + "-ub", + "512", + "--host", + "0.0.0.0", + "--port", + "8080", + "--parallel", + "1", + "-ngl", + "auto", + "--mmproj", + "/m/mmproj.gguf", + "--spec-type", + "ngram-mod", + "--spec-ngram-mod-n-match", + "24", + "--spec-ngram-mod-n-min", + "48", + "--spec-ngram-mod-n-max", + "64" + ], + "env": {}, + "notes": [ + "threads auto → -t 4 : cpuset du conteneur, 4 cœurs physiques permis sur 8 (llama.cpp seul en lancerait 8)", + "SPEC=ngram → n-grammes du contexte (ngram-mod, brouillons de 48 à 64 jetons) : distribution inchangée (chaque jeton vérifié), ~16 Mo de RAM ; utile surtout en mode code, à mesurer (acceptation par nature dans /api/perf/summary)", + "modèle hybride : chaque brouillon n-gramme copie l'état récurrent (point de reprise en RAM hôte) et le recharge s'il est rejeté — compare le temps par tour, pas seulement le débit" + ] + }, + { + "m": 6, + "c": 40, + "h": 0, + "e": 0, + "args": [ + "/app/llama-server", + "-m", + "/m/layers.gguf", + "-c", + "32768", + "-t", + "4", + "-b", + "2048", + "-ub", + "512", + "--host", + "0.0.0.0", + "--port", + "8080", + "--parallel", + "1", + "-ngl", + "auto", + "--mmproj", + "/m/mmproj.gguf", + "--spec-type", + "ngram-mod", + "--spec-ngram-mod-n-match", + "24", + "--spec-ngram-mod-n-min", + "48", + "--spec-ngram-mod-n-max", + "64" + ], + "env": {}, + "notes": [ + "threads auto → -t 4 : cpuset du conteneur, 4 cœurs physiques permis sur 8 (llama.cpp seul en lancerait 8)", + "SPEC=mtp+ngram : sans MTP (pas de tête MTP dans ce fichier (publiée à part ? MODEL_DRAFT=mtp-….gguf)) → n-grammes seuls. SPEC=ngram → n-grammes du contexte (ngram-mod, brouillons de 48 à 64 jetons) : distribution inchangée (chaque jeton vérifié), ~16 Mo de RAM ; utile surtout en mode code, à mesurer (acceptation par nature dans /api/perf/summary)", + "modèle hybride : chaque brouillon n-gramme copie l'état récurrent (point de reprise en RAM hôte) et le recharge s'il est rejeté — compare le temps par tour, pas seulement le débit" + ] + }, + { + "m": 6, + "c": 41, + "h": 0, + "e": 0, + "args": [ + "/app/llama-server", + "-m", + "/m/layers.gguf", + "-c", + "32768", + "-t", + "4", + "-b", + "2048", + "-ub", + "512", + "--host", + "0.0.0.0", + "--port", + "8080", + "--parallel", + "1", + "-ngl", + "auto", + "--mmproj", + "/m/mmproj.gguf", + "--spec-type", + "ngram-mod", + "--spec-ngram-mod-n-match", + "24", + "--spec-ngram-mod-n-min", + "48", + "--spec-ngram-mod-n-max", + "64" + ], + "env": {}, + "notes": [ + "threads auto → -t 4 : cpuset du conteneur, 4 cœurs physiques permis sur 8 (llama.cpp seul en lancerait 8)", + "SPEC=mtp+ngram : sans MTP (pas de tête MTP dans ce fichier (publiée à part ? MODEL_DRAFT=mtp-….gguf)) → n-grammes seuls. SPEC=ngram → n-grammes du contexte (ngram-mod, brouillons de 48 à 64 jetons) : distribution inchangée (chaque jeton vérifié), ~16 Mo de RAM ; utile surtout en mode code, à mesurer (acceptation par nature dans /api/perf/summary)", + "modèle hybride : chaque brouillon n-gramme copie l'état récurrent (point de reprise en RAM hôte) et le recharge s'il est rejeté — compare le temps par tour, pas seulement le débit" + ] + }, + { + "m": 6, + "c": 42, + "h": 0, + "e": 0, + "args": [ + "/app/llama-server", + "-m", + "/m/layers.gguf", + "-c", + "32768", + "-t", + "4", + "-b", + "2048", + "-ub", + "512", + "--host", + "0.0.0.0", + "--port", + "8080", + "--parallel", + "1", + "-ngl", + "auto", + "--mmproj", + "/m/mmproj.gguf" + ], + "env": {}, + "notes": [ + "threads auto → -t 4 : cpuset du conteneur, 4 cœurs physiques permis sur 8 (llama.cpp seul en lancerait 8)", + "SPEC=turbo illisible (off, auto, mtp, ngram ou mtp+ngram) : sans décodage spéculatif" + ] + }, + { + "m": 6, + "c": 43, + "h": 0, + "e": 0, + "args": [ + "/app/llama-server", + "-m", + "/m/layers.gguf", + "-c", + "32768", + "-t", + "4", + "-b", + "2048", + "-ub", + "512", + "--host", + "0.0.0.0", + "--port", + "8080", + "--parallel", + "1", + "-ngl", + "auto", + "--mmproj", + "/m/mmproj.gguf" + ], + "env": {}, + "notes": [ + "threads auto → -t 4 : cpuset du conteneur, 4 cœurs physiques permis sur 8 (llama.cpp seul en lancerait 8)", + "MODEL_DRAFT ignoré : SPEC=off (auto ou mtp pour s'en servir)" + ] + }, + { + "m": 6, + "c": 44, + "h": 0, + "e": 0, + "args": [ + "/app/llama-server", + "-m", + "/m/layers.gguf", + "-c", + "32768", + "-t", + "4", + "-b", + "2048", + "-ub", + "512", + "--host", + "0.0.0.0", + "--port", + "8080", + "--parallel", + "1", + "-ngl", + "auto", + "--mmproj", + "/m/mmproj.gguf" + ], + "env": {}, + "notes": [ + "threads auto → -t 4 : cpuset du conteneur, 4 cœurs physiques permis sur 8 (llama.cpp seul en lancerait 8)", + "SPEC=mtp : MODEL_DRAFT=mtp-head.gguf introuvable — sans décodage spéculatif" + ] + }, + { + "m": 6, + "c": 45, + "h": 0, + "e": 0, + "args": [ + "/app/llama-server", + "-m", + "/m/layers.gguf", + "-c", + "32768", + "-t", + "4", + "-b", + "2048", + "-ub", + "512", + "--host", + "0.0.0.0", + "--port", + "8080", + "--parallel", + "1", + "-ngl", + "auto", + "--mmproj", + "/m/mmproj.gguf" + ], + "env": {}, + "notes": [ + "threads auto → -t 4 : cpuset du conteneur, 4 cœurs physiques permis sur 8 (llama.cpp seul en lancerait 8)", + "SPEC=mtp : pas de tête MTP dans ce fichier (publiée à part ? MODEL_DRAFT=mtp-….gguf) — sans décodage spéculatif" + ] + }, + { + "m": 6, + "c": 46, + "h": 0, + "e": 0, + "args": [ + "/app/llama-server", + "-m", + "/m/layers.gguf", + "-c", + "32768", + "-t", + "4", + "-b", + "2048", + "-ub", + "512", + "--host", + "0.0.0.0", + "--port", + "8080", + "--parallel", + "1", + "-ngl", + "auto", + "--mmproj", + "/m/mmproj.gguf" + ], + "env": {}, + "notes": [ + "threads auto → -t 4 : cpuset du conteneur, 4 cœurs physiques permis sur 8 (llama.cpp seul en lancerait 8)" + ] + }, + { + "m": 6, + "c": 47, + "h": 0, + "e": 0, + "args": [ + "/app/llama-server", + "-m", + "/m/layers.gguf", + "-c", + "32768", + "-t", + "4", + "-b", + "2048", + "-ub", + "512", + "--host", + "0.0.0.0", + "--port", + "8080", + "--parallel", + "1", + "-ngl", + "auto", + "--mmproj", + "/m/mmproj.gguf" + ], + "env": {}, + "notes": [ + "threads auto → -t 4 : cpuset du conteneur, 4 cœurs physiques permis sur 8 (llama.cpp seul en lancerait 8)", + "SPEC=mtp : pas de tête MTP dans ce fichier (publiée à part ? MODEL_DRAFT=mtp-….gguf) — sans décodage spéculatif" + ] + }, + { + "m": 6, + "c": 48, + "h": 0, + "e": 0, + "args": [ + "/app/llama-server", + "-m", + "/m/layers.gguf", + "-c", + "32768", + "-t", + "4", + "-b", + "2048", + "-ub", + "512", + "--host", + "0.0.0.0", + "--port", + "8080", + "--parallel", + "1", + "-ngl", + "auto", + "--mmproj", + "/m/mmproj.gguf", + "--spec-type", + "ngram-mod", + "--spec-ngram-mod-n-match", + "24", + "--spec-ngram-mod-n-min", + "48", + "--spec-ngram-mod-n-max", + "64" + ], + "env": {}, + "notes": [ + "threads auto → -t 4 : cpuset du conteneur, 4 cœurs physiques permis sur 8 (llama.cpp seul en lancerait 8)", + "SPEC=ngram → n-grammes du contexte (ngram-mod, brouillons de 48 à 64 jetons) : distribution inchangée (chaque jeton vérifié), ~16 Mo de RAM ; utile surtout en mode code, à mesurer (acceptation par nature dans /api/perf/summary)", + "MODEL_DRAFT ignoré avec SPEC=ngram : SPEC=mtp+ngram pour les deux", + "SPEC_N_MAX et SPEC_SAMPLING ignorés : les n-grammes n'ont pas de brouillon à régler", + "modèle hybride : chaque brouillon n-gramme copie l'état récurrent (point de reprise en RAM hôte) et le recharge s'il est rejeté — compare le temps par tour, pas seulement le débit" + ] + }, + { + "m": 6, + "c": 49, + "h": 0, + "e": 0, + "args": [ + "/app/llama-server", + "-m", + "/m/layers.gguf", + "-c", + "32768", + "-t", + "4", + "-b", + "2048", + "-ub", + "512", + "--host", + "0.0.0.0", + "--port", + "8080", + "--parallel", + "1", + "-ngl", + "auto", + "--mmproj", + "/m/mmproj.gguf" + ], + "env": {}, + "notes": [ + "threads auto → -t 4 : cpuset du conteneur, 4 cœurs physiques permis sur 8 (llama.cpp seul en lancerait 8)", + "SPEC=auto : MODEL_DRAFT=small.gguf introuvable — sans décodage spéculatif" + ] + }, + { + "m": 6, + "c": 50, + "h": 0, + "e": 0, + "args": [ + "/app/llama-server", + "-m", + "/m/layers.gguf", + "-c", + "32768", + "-t", + "4", + "-b", + "2048", + "-ub", + "512", + "--host", + "0.0.0.0", + "--port", + "8080", + "--parallel", + "1", + "-ngl", + "auto", + "--mmproj", + "/m/mmproj.gguf", + "--spec-type", + "ngram-mod", + "--spec-ngram-mod-n-match", + "24", + "--spec-ngram-mod-n-min", + "48", + "--spec-ngram-mod-n-max", + "64" + ], + "env": {}, + "notes": [ + "threads auto → -t 4 : cpuset du conteneur, 4 cœurs physiques permis sur 8 (llama.cpp seul en lancerait 8)", + "SPEC=mtp+ngram : sans MTP (MODEL_DRAFT=small.gguf introuvable) → n-grammes seuls. SPEC=ngram → n-grammes du contexte (ngram-mod, brouillons de 48 à 64 jetons) : distribution inchangée (chaque jeton vérifié), ~16 Mo de RAM ; utile surtout en mode code, à mesurer (acceptation par nature dans /api/perf/summary)", + "modèle hybride : chaque brouillon n-gramme copie l'état récurrent (point de reprise en RAM hôte) et le recharge s'il est rejeté — compare le temps par tour, pas seulement le débit" + ] + }, + { + "m": 6, + "c": 51, + "h": 0, + "e": 0, + "args": [ + "/app/llama-server", + "-m", + "/m/layers.gguf", + "-c", + "32768", + "-t", + "4", + "-b", + "2048", + "-ub", + "512", + "--host", + "0.0.0.0", + "--port", + "8080", + "--parallel", + "1", + "-ngl", + "auto", + "--mmproj", + "/m/mmproj.gguf" + ], + "env": {}, + "notes": [ + "threads auto → -t 4 : cpuset du conteneur, 4 cœurs physiques permis sur 8 (llama.cpp seul en lancerait 8)" + ] + }, + { + "m": 6, + "c": 52, + "h": 0, + "e": 0, + "args": [ + "/app/llama-server", + "-m", + "/m/layers.gguf", + "-c", + "32768", + "-t", + "4", + "-b", + "2048", + "-ub", + "512", + "--host", + "0.0.0.0", + "--port", + "8080", + "--parallel", + "1", + "-ngl", + "auto", + "--mmproj", + "/m/mmproj.gguf" + ], + "env": { + "CUDA_SCALE_LAUNCH_QUEUES": "2x" + }, + "notes": [ + "CUDA_SCALE_LAUNCH_QUEUES=2x (CUDA_LAUNCH_QUEUES)", + "threads auto → -t 4 : cpuset du conteneur, 4 cœurs physiques permis sur 8 (llama.cpp seul en lancerait 8)" + ] + }, + { + "m": 6, + "c": 53, + "h": 0, + "e": 0, + "args": [ + "/app/llama-server", + "-m", + "/m/layers.gguf", + "-c", + "32768", + "-t", + "4", + "-b", + "2048", + "-ub", + "512", + "--host", + "0.0.0.0", + "--port", + "8080", + "--parallel", + "1", + "-ngl", + "auto", + "--mmproj", + "/m/mmproj.gguf" + ], + "env": {}, + "notes": [ + "CUDA_LAUNCH_QUEUES=8x ignoré (0.25x, 0.5x, 2x, 4x ou off) → automatique", + "threads auto → -t 4 : cpuset du conteneur, 4 cœurs physiques permis sur 8 (llama.cpp seul en lancerait 8)" + ] + }, + { + "m": 6, + "c": 54, + "h": 0, + "e": 0, + "args": [ + "/app/llama-server", + "-m", + "/m/layers.gguf", + "-c", + "32768", + "-t", + "4", + "-b", + "2048", + "-ub", + "512", + "--host", + "0.0.0.0", + "--port", + "8080", + "--parallel", + "1", + "-ngl", + "auto", + "--mmproj", + "/m/mmproj.gguf" + ], + "env": {}, + "notes": [ + "threads auto → -t 4 : cpuset du conteneur, 4 cœurs physiques permis sur 8 (llama.cpp seul en lancerait 8)" + ] + }, + { + "m": 6, + "c": 55, + "h": 0, + "e": 0, + "args": [ + "/app/llama-server", + "-m", + "/m/layers.gguf", + "-c", + "32768", + "-t", + "4", + "-b", + "2048", + "-ub", + "512", + "--host", + "0.0.0.0", + "--port", + "8080", + "--parallel", + "1", + "-ngl", + "auto", + "--mmproj", + "/m/mmproj.gguf" + ], + "env": { + "CUDA_DEVICE_ORDER": "PCI_BUS_ID", + "CUDA_VISIBLE_DEVICES": "1,0" + }, + "notes": [ + "threads auto → -t 4 : cpuset du conteneur, 4 cœurs physiques permis sur 8 (llama.cpp seul en lancerait 8)" + ] + }, + { + "m": 6, + "c": 56, + "h": 0, + "e": 0, + "args": [ + "/app/llama-server", + "-m", + "/m/layers.gguf", + "-c", + "32768", + "-t", + "4", + "-b", + "2048", + "-ub", + "512", + "--host", + "0.0.0.0", + "--port", + "8080", + "--parallel", + "1", + "-ngl", + "auto", + "--fit-target", + "1024,3072", + "--mmproj", + "/m/mmproj.gguf" + ], + "env": {}, + "notes": [ + "threads auto → -t 4 : cpuset du conteneur, 4 cœurs physiques permis sur 8 (llama.cpp seul en lancerait 8)", + "--fit-target 1024,3072 (FIT_TARGET, Mio libres par carte, dans l'ordre vu par le moteur) — vérifie « offloaded N/N layers » dans le journal" + ] + }, + { + "m": 6, + "c": 57, + "h": 0, + "e": 0, + "args": [ + "/app/llama-server", + "-m", + "/m/layers.gguf", + "-c", + "32768", + "-t", + "4", + "-b", + "2048", + "-ub", + "512", + "--host", + "0.0.0.0", + "--port", + "8080", + "--parallel", + "1", + "-ngl", + "auto", + "--fit-target", + "1024", + "--mmproj", + "/m/mmproj.gguf" + ], + "env": {}, + "notes": [ + "threads auto → -t 4 : cpuset du conteneur, 4 cœurs physiques permis sur 8 (llama.cpp seul en lancerait 8)", + "--fit-target 1024 (FIT_TARGET, Mio libres par carte, dans l'ordre vu par le moteur) — vérifie « offloaded N/N layers » dans le journal ; marges sous 1024 Mio remontées à 1024" + ] + }, + { + "m": 6, + "c": 58, + "h": 0, + "e": 0, + "args": [ + "/app/llama-server", + "-m", + "/m/layers.gguf", + "-c", + "32768", + "-t", + "4", + "-b", + "2048", + "-ub", + "512", + "--host", + "0.0.0.0", + "--port", + "8080", + "--parallel", + "1", + "-ngl", + "auto", + "--mmproj", + "/m/mmproj.gguf" + ], + "env": {}, + "notes": [ + "threads auto → -t 4 : cpuset du conteneur, 4 cœurs physiques permis sur 8 (llama.cpp seul en lancerait 8)", + "FIT_TARGET=1024,,3072 ignoré : des Mio par carte, ex. 1024,3072 (8 valeurs au plus)" + ] + }, + { + "m": 6, + "c": 59, + "h": 0, + "e": 0, + "args": [ + "/app/llama-server", + "-m", + "/m/layers.gguf", + "-c", + "32768", + "-t", + "4", + "-b", + "2048", + "-ub", + "512", + "--host", + "0.0.0.0", + "--port", + "8080", + "--parallel", + "1", + "-ngl", + "auto", + "--mmproj", + "/m/mmproj.gguf" + ], + "env": {}, + "notes": [ + "threads auto → -t 4 : cpuset du conteneur, 4 cœurs physiques permis sur 8 (llama.cpp seul en lancerait 8)", + "FIT_TARGET=1,2,3,4,5,6,7,8,9 ignoré : des Mio par carte, ex. 1024,3072 (8 valeurs au plus)" + ] + }, + { + "m": 6, + "c": 60, + "h": 0, + "e": 0, + "args": [ + "/app/llama-server", + "-m", + "/m/layers.gguf", + "-c", + "0", + "-t", + "4", + "-b", + "2048", + "-ub", + "512", + "--host", + "0.0.0.0", + "--port", + "8080", + "--parallel", + "1", + "-ngl", + "auto", + "--mmproj", + "/m/mmproj.gguf" + ], + "env": {}, + "notes": [ + "threads auto → -t 4 : cpuset du conteneur, 4 cœurs physiques permis sur 8 (llama.cpp seul en lancerait 8)", + "FIT_TARGET=2048/4096 ignoré : contexte « 0 » non fixé — --fit pourrait le réduire pour tenir ; donne un CTX chiffré" + ] + }, + { + "m": 6, + "c": 61, + "h": 0, + "e": 0, + "args": [ + "/app/llama-server", + "-m", + "/m/layers.gguf", + "-c", + "32768", + "-t", + "4", + "-b", + "2048", + "-ub", + "512", + "--host", + "0.0.0.0", + "--port", + "8080", + "--parallel", + "1", + "-ngl", + "30", + "--mmproj", + "/m/mmproj.gguf" + ], + "env": {}, + "notes": [ + "threads auto → -t 4 : cpuset du conteneur, 4 cœurs physiques permis sur 8 (llama.cpp seul en lancerait 8)", + "FIT_TARGET=2048 ignoré : --fit inactif (couches GPU fixées à 30), la marge ne servirait à rien" + ] + }, + { + "m": 6, + "c": 62, + "h": 0, + "e": 0, + "args": [ + "/app/llama-server", + "-m", + "/m/layers.gguf", + "-c", + "32768", + "-t", + "4", + "-b", + "2048", + "-ub", + "512", + "--host", + "0.0.0.0", + "--port", + "8080", + "--parallel", + "1", + "-ngl", + "auto", + "--mmproj", + "/m/mmproj.gguf" + ], + "env": { + "GGML_OP_OFFLOAD_MIN_BATCH": "128" + }, + "notes": [ + "GGML_OP_OFFLOAD_MIN_BATCH=128 (OP_OFFLOAD_MIN_BATCH) : lots de moins de 128 jetons calculés sur CPU pour les poids restés en RAM", + "threads auto → -t 4 : cpuset du conteneur, 4 cœurs physiques permis sur 8 (llama.cpp seul en lancerait 8)" + ] + }, + { + "m": 6, + "c": 63, + "h": 0, + "e": 0, + "args": [ + "/app/llama-server", + "-m", + "/m/layers.gguf", + "-c", + "32768", + "-t", + "4", + "-b", + "2048", + "-ub", + "512", + "--host", + "0.0.0.0", + "--port", + "8080", + "--parallel", + "1", + "-ngl", + "auto", + "--mmproj", + "/m/mmproj.gguf" + ], + "env": {}, + "notes": [ + "OP_OFFLOAD_MIN_BATCH=0 ignoré : un nombre de jetons, ex. 128", + "threads auto → -t 4 : cpuset du conteneur, 4 cœurs physiques permis sur 8 (llama.cpp seul en lancerait 8)" + ] + }, + { + "m": 6, + "c": 64, + "h": 0, + "e": 0, + "args": [ + "/app/llama-server", + "-m", + "/m/layers.gguf", + "-c", + "32768", + "-t", + "4", + "-b", + "2048", + "-ub", + "512", + "--host", + "0.0.0.0", + "--port", + "8080", + "--parallel", + "1", + "-ngl", + "auto", + "--mmproj", + "/m/mmproj.gguf" + ], + "env": {}, + "notes": [ + "threads auto → -t 4 : cpuset du conteneur, 4 cœurs physiques permis sur 8 (llama.cpp seul en lancerait 8)" + ] + }, + { + "m": 6, + "c": 65, + "h": 0, + "e": 0, + "args": [ + "/app/llama-server", + "-m", + "/m/layers.gguf", + "-c", + "32768", + "-t", + "4", + "-b", + "2048", + "-ub", + "512", + "--host", + "0.0.0.0", + "--port", + "8080", + "--parallel", + "1", + "-ngl", + "auto", + "--mmproj", + "/m/mmproj.gguf" + ], + "env": {}, + "notes": [ + "CUDA_GRAPH_OPT=oui ignoré (on ou off)", + "threads auto → -t 4 : cpuset du conteneur, 4 cœurs physiques permis sur 8 (llama.cpp seul en lancerait 8)" + ] + }, + { + "m": 6, + "c": 66, + "h": 0, + "e": 0, + "args": [ + "/app/llama-server", + "-m", + "/m/layers.gguf", + "-c", + "32768", + "-t", + "4", + "-b", + "2048", + "-ub", + "512", + "--host", + "0.0.0.0", + "--port", + "8080", + "--parallel", + "1", + "-ngl", + "auto", + "--mmproj", + "/m/mmproj.gguf" + ], + "env": {}, + "notes": [ + "threads auto → -t 4 : cpuset du conteneur, 4 cœurs physiques permis sur 8 (llama.cpp seul en lancerait 8)" + ] + }, + { + "m": 6, + "c": 67, + "h": 0, + "e": 0, + "args": [ + "/app/llama-server", + "-m", + "/m/layers.gguf", + "-c", + "32768", + "-t", + "4", + "-b", + "2048", + "-ub", + "512", + "--host", + "0.0.0.0", + "--port", + "8080", + "--parallel", + "1", + "-ngl", + "auto", + "--mmproj", + "/m/mmproj.gguf", + "--ctx-checkpoints", + "8" + ], + "env": {}, + "notes": [ + "threads auto → -t 4 : cpuset du conteneur, 4 cœurs physiques permis sur 8 (llama.cpp seul en lancerait 8)" + ] + }, + { + "m": 6, + "c": 68, + "h": 0, + "e": 0, + "args": [ + "/app/llama-server", + "-m", + "/m/layers.gguf", + "-c", + "32768", + "-t", + "4", + "-b", + "2048", + "-ub", + "512", + "--host", + "0.0.0.0", + "--port", + "8080", + "--parallel", + "1", + "-ngl", + "auto", + "--mmproj", + "/m/mmproj.gguf" + ], + "env": {}, + "notes": [ + "threads auto → -t 4 : cpuset du conteneur, 4 cœurs physiques permis sur 8 (llama.cpp seul en lancerait 8)", + "CTX_CHECKPOINTS=beaucoup illisible (un nombre de points de reprise par slot, ≥ 0) : ignoré" + ] + }, + { + "m": 6, + "c": 69, + "h": 0, + "e": 0, + "args": [ + "/app/llama-server", + "-m", + "/m/layers.gguf", + "-c", + "32768", + "-t", + "4", + "-b", + "2048", + "-ub", + "512", + "--host", + "0.0.0.0", + "--port", + "8080", + "--parallel", + "1", + "-ngl", + "auto", + "--mmproj", + "/m/mmproj.gguf", + "--checkpoint-min-step", + "1024" + ], + "env": {}, + "notes": [ + "threads auto → -t 4 : cpuset du conteneur, 4 cœurs physiques permis sur 8 (llama.cpp seul en lancerait 8)" + ] + }, + { + "m": 6, + "c": 70, + "h": 0, + "e": 0, + "args": [ + "/app/llama-server", + "-m", + "/m/layers.gguf", + "-c", + "32768", + "-t", + "4", + "-b", + "2048", + "-ub", + "512", + "--host", + "0.0.0.0", + "--port", + "8080", + "--parallel", + "1", + "-ngl", + "auto", + "--mmproj", + "/m/mmproj.gguf" + ], + "env": {}, + "notes": [ + "threads auto → -t 4 : cpuset du conteneur, 4 cœurs physiques permis sur 8 (llama.cpp seul en lancerait 8)", + "CKPT_MIN_STEP=0 refusé (un nombre de jetons > 0 ; 0 laisserait le moteur jeter le point de reprise du début de la conversation) : défaut du moteur" + ] + }, + { + "m": 6, + "c": 71, + "h": 0, + "e": 0, + "args": [ + "/app/llama-server", + "-m", + "/m/layers.gguf", + "-c", + "32768", + "-t", + "4", + "-b", + "2048", + "-ub", + "512", + "--host", + "0.0.0.0", + "--port", + "8080", + "--parallel", + "1", + "-ngl", + "auto", + "--mmproj", + "/m/mmproj.gguf" + ], + "env": {}, + "notes": [ + "threads auto → -t 4 : cpuset du conteneur, 4 cœurs physiques permis sur 8 (llama.cpp seul en lancerait 8)", + "SLOT_PERSIST refusé (moteur joignable par d'autres sans clé d'API (HOST=127.0.0.1 ou une clé d'API)) : l'état du slot ne sera pas gardé à la bascule de preset" + ] + }, + { + "m": 6, + "c": 72, + "h": 0, + "e": 0, + "args": [ + "/app/llama-server", + "-m", + "/m/layers.gguf", + "-c", + "32768", + "-t", + "4", + "-b", + "2048", + "-ub", + "512", + "--host", + "127.0.0.1", + "--port", + "8080", + "--parallel", + "1", + "-ngl", + "auto", + "--mmproj", + "/m/mmproj.gguf", + "--slot-save-path", + "/data/slots" + ], + "env": {}, + "notes": [ + "threads auto → -t 4 : cpuset du conteneur, 4 cœurs physiques permis sur 8 (llama.cpp seul en lancerait 8)" + ] + }, + { + "m": 6, + "c": 73, + "h": 0, + "e": 0, + "args": [ + "/app/llama-server", + "-m", + "/m/layers.gguf", + "-c", + "32768", + "-t", + "4", + "-b", + "2048", + "-ub", + "512", + "--host", + "127.0.0.1", + "--port", + "8080", + "--parallel", + "1", + "-ngl", + "auto", + "--mmproj", + "/m/mmproj.gguf", + "--slot-save-path", + "/data/slots" + ], + "env": {}, + "notes": [ + "threads auto → -t 4 : cpuset du conteneur, 4 cœurs physiques permis sur 8 (llama.cpp seul en lancerait 8)", + "SLOT_PERSIST refusé (décodage spéculatif : la sauvegarde du moteur ne garde pas l'état du brouillon) : l'état du slot ne sera pas gardé à la bascule de preset", + "SPEC=mtp : pas de tête MTP dans ce fichier (publiée à part ? MODEL_DRAFT=mtp-….gguf) — sans décodage spéculatif" + ] + }, + { + "m": 6, + "c": 74, + "h": 0, + "e": 0, + "args": [ + "/app/llama-server", + "-m", + "/m/layers.gguf", + "-c", + "32768", + "-t", + "4", + "-b", + "2048", + "-ub", + "512", + "--host", + "0.0.0.0", + "--port", + "8080", + "--parallel", + "1", + "-ngl", + "auto", + "--mmproj", + "/m/mmproj.gguf", + "--load-mode", + "none" + ], + "env": {}, + "notes": [ + "threads auto → -t 4 : cpuset du conteneur, 4 cœurs physiques permis sur 8 (llama.cpp seul en lancerait 8)" + ] + }, + { + "m": 6, + "c": 75, + "h": 0, + "e": 0, + "args": [ + "/app/llama-server", + "-m", + "/m/layers.gguf", + "-c", + "32768", + "-t", + "4", + "-b", + "2048", + "-ub", + "512", + "--host", + "0.0.0.0", + "--port", + "8080", + "--parallel", + "1", + "-ngl", + "auto", + "--mmproj", + "/m/mmproj.gguf", + "--load-mode", + "mlock" + ], + "env": {}, + "notes": [ + "threads auto → -t 4 : cpuset du conteneur, 4 cœurs physiques permis sur 8 (llama.cpp seul en lancerait 8)" + ] + }, + { + "m": 6, + "c": 76, + "h": 0, + "e": 0, + "args": [ + "/app/llama-server", + "-m", + "/m/layers.gguf", + "-c", + "32768", + "-t", + "4", + "-b", + "2048", + "-ub", + "512", + "--host", + "0.0.0.0", + "--port", + "8080", + "--parallel", + "1", + "-ngl", + "auto", + "--mmproj", + "/m/mmproj.gguf", + "--load-mode", + "mmap+mlock" + ], + "env": {}, + "notes": [ + "threads auto → -t 4 : cpuset du conteneur, 4 cœurs physiques permis sur 8 (llama.cpp seul en lancerait 8)" + ] + }, + { + "m": 6, + "c": 77, + "h": 0, + "e": 0, + "args": [ + "/app/llama-server", + "-m", + "/m/layers.gguf", + "-c", + "32768", + "-t", + "4", + "-b", + "2048", + "-ub", + "512", + "--host", + "0.0.0.0", + "--port", + "8080", + "--parallel", + "1", + "-ngl", + "auto", + "--mmproj", + "/m/mmproj.gguf", + "--load-mode", + "none" + ], + "env": {}, + "notes": [ + "threads auto → -t 4 : cpuset du conteneur, 4 cœurs physiques permis sur 8 (llama.cpp seul en lancerait 8)" + ] + }, + { + "m": 6, + "c": 78, + "h": 0, + "e": 0, + "args": [ + "/app/llama-server", + "-m", + "/m/layers.gguf", + "-c", + "32768", + "-t", + "4", + "-b", + "2048", + "-ub", + "512", + "--host", + "0.0.0.0", + "--port", + "8080", + "--parallel", + "1", + "-ngl", + "auto", + "--mmproj", + "/m/mmproj.gguf", + "--load-mode", + "mmap" + ], + "env": {}, + "notes": [ + "threads auto → -t 4 : cpuset du conteneur, 4 cœurs physiques permis sur 8 (llama.cpp seul en lancerait 8)" + ] + }, + { + "m": 6, + "c": 79, + "h": 0, + "e": 0, + "args": [ + "/app/llama-server", + "-m", + "/m/layers.gguf", + "-c", + "32768", + "-t", + "4", + "-b", + "2048", + "-ub", + "512", + "--host", + "0.0.0.0", + "--port", + "8080", + "--parallel", + "1", + "-ngl", + "auto", + "--mmproj", + "/m/mmproj.gguf", + "--load-mode", + "dio" + ], + "env": {}, + "notes": [ + "threads auto → -t 4 : cpuset du conteneur, 4 cœurs physiques permis sur 8 (llama.cpp seul en lancerait 8)" + ] + }, + { + "m": 6, + "c": 80, + "h": 0, + "e": 0, + "args": [ + "/app/llama-server", + "-m", + "/m/layers.gguf", + "-c", + "32768", + "-t", + "4", + "-b", + "2048", + "-ub", + "512", + "--host", + "0.0.0.0", + "--port", + "8080", + "--parallel", + "1", + "-ngl", + "auto", + "--mmproj", + "/m/mmproj.gguf", + "--load-mode=mlock" + ], + "env": {}, + "notes": [ + "threads auto → -t 4 : cpuset du conteneur, 4 cœurs physiques permis sur 8 (llama.cpp seul en lancerait 8)" + ] + }, + { + "m": 6, + "c": 81, + "h": 0, + "e": 0, + "args": [ + "/app/llama-server", + "-m", + "/m/layers.gguf", + "-c", + "32768", + "-t", + "4", + "-b", + "2048", + "-ub", + "512", + "--host", + "0.0.0.0", + "--port", + "8080", + "--mmproj", + "/m/mmproj.gguf", + "-ngl", + "30", + "--parallel", + "4" + ], + "env": {}, + "notes": [ + "threads auto → -t 4 : cpuset du conteneur, 4 cœurs physiques permis sur 8 (llama.cpp seul en lancerait 8)" + ] + }, + { + "m": 6, + "c": 82, + "h": 0, + "e": 0, + "args": [ + "/app/llama-server", + "-m", + "/m/layers.gguf", + "-c", + "32768", + "-b", + "2048", + "-ub", + "512", + "--host", + "0.0.0.0", + "--port", + "8080", + "--parallel", + "1", + "-ngl", + "auto", + "--mmproj", + "/m/mmproj.gguf", + "-t", + "6", + "-tb", + "3" + ], + "env": {}, + "notes": null + }, + { + "m": 6, + "c": 83, + "h": 0, + "e": 0, + "args": [ + "/app/llama-server", + "-m", + "/m/layers.gguf", + "-c", + "32768", + "-t", + "4", + "-tb", + "4", + "-b", + "2048", + "-ub", + "512", + "--host", + "0.0.0.0", + "--port", + "8080", + "--parallel", + "1", + "-ngl", + "auto", + "--mmproj", + "/m/mmproj.gguf", + "-Cb", + "0xff", + "--poll-batch", + "1" + ], + "env": {}, + "notes": [ + "threads auto → -t 4 : cpuset du conteneur, 4 cœurs physiques permis sur 8 (llama.cpp seul en lancerait 8)" + ] + }, + { + "m": 6, + "c": 84, + "h": 0, + "e": 0, + "args": [ + "/app/llama-server", + "-m", + "/m/layers.gguf", + "-c", + "32768", + "-t", + "6", + "-tb", + "6", + "-b", + "2048", + "-ub", + "512", + "--host", + "0.0.0.0", + "--port", + "8080", + "--parallel", + "1", + "-ngl", + "auto", + "--mmproj", + "/m/mmproj.gguf", + "--prio-batch", + "2" + ], + "env": {}, + "notes": null + }, + { + "m": 6, + "c": 85, + "h": 0, + "e": 0, + "args": [ + "/app/llama-server", + "-m", + "/m/layers.gguf", + "-c", + "32768", + "-t", + "4", + "-b", + "2048", + "-ub", + "512", + "--host", + "0.0.0.0", + "--port", + "8080", + "--parallel", + "1", + "-ngl", + "auto", + "--mmproj", + "/m/mmproj.gguf", + "--cache-reuse", + "256", + "--context-shift" + ], + "env": {}, + "notes": [ + "threads auto → -t 4 : cpuset du conteneur, 4 cœurs physiques permis sur 8 (llama.cpp seul en lancerait 8)", + "avertissement : --context-shift (EXTRA_ARGS) — contexte plein, le moteur jette des jetons anciens et le modèle perd une partie de la conversation sans le savoir (vision chargée : llama.cpp l'ignore de toute façon).", + "avertissement : --cache-reuse 256 (EXTRA_ARGS) — réutilise des morceaux de cache calculés sous un autre préfixe : les sorties ne sont plus exactement celles d'un calcul complet (vision chargée : llama.cpp l'ignore de toute façon)." + ] + }, + { + "m": 6, + "c": 86, + "h": 0, + "e": 0, + "args": [ + "/app/llama-server", + "-m", + "/m/layers.gguf", + "-c", + "32768", + "-t", + "4", + "-b", + "2048", + "-ub", + "512", + "--host", + "0.0.0.0", + "--port", + "8080", + "--parallel", + "1", + "-ngl", + "auto", + "--mmproj", + "/m/mmproj.gguf", + "-ctk", + "q8_0", + "-ctv", + "q8_0" + ], + "env": {}, + "notes": [ + "threads auto → -t 4 : cpuset du conteneur, 4 cœurs physiques permis sur 8 (llama.cpp seul en lancerait 8)", + "cache KV q8_0/q8_0 (défini par EXTRA_ARGS) : modifie légèrement les sorties par rapport à f16 — choix de l'utilisateur, laissé tel quel." + ] + }, + { + "m": 6, + "c": 87, + "h": 0, + "e": 0, + "args": [ + "/app/llama-server", + "-m", + "/m/layers.gguf", + "-c", + "32768", + "-t", + "4", + "-b", + "2048", + "-ub", + "512", + "--host", + "0.0.0.0", + "--port", + "8080", + "--parallel", + "1", + "-ngl", + "auto", + "--mmproj", + "/m/mmproj.gguf", + "-ot", + "exps=CPU" + ], + "env": {}, + "notes": [ + "threads auto → -t 4 : cpuset du conteneur, 4 cœurs physiques permis sur 8 (llama.cpp seul en lancerait 8)" + ] + }, + { + "m": 6, + "c": 88, + "h": 0, + "e": 0, + "args": [ + "/app/llama-server", + "-m", + "/m/layers.gguf", + "-c", + "32768", + "-t", + "4", + "-b", + "2048", + "-ub", + "512", + "--host", + "0.0.0.0", + "--port", + "8080", + "--parallel", + "1", + "-ngl", + "auto", + "--mmproj", + "/m/mmproj.gguf", + "-ot", + "blk\\.(1|2)\\.ffn_.*_exps=CPU", + "-ub", + "512" + ], + "env": {}, + "notes": [ + "threads auto → -t 4 : cpuset du conteneur, 4 cœurs physiques permis sur 8 (llama.cpp seul en lancerait 8)" + ] + }, + { + "m": 6, + "c": 89, + "h": 0, + "e": 0, + "args": [ + "/app/llama-server", + "-m", + "/m/layers.gguf", + "-c", + "32768", + "-t", + "4", + "-b", + "2048", + "-ub", + "512", + "--host", + "0.0.0.0", + "--port", + "8080", + "--parallel", + "1", + "-ngl", + "auto", + "--mmproj", + "/m/mmproj.gguf", + "--n-cpu-moe", + "20" + ], + "env": {}, + "notes": [ + "threads auto → -t 4 : cpuset du conteneur, 4 cœurs physiques permis sur 8 (llama.cpp seul en lancerait 8)" + ] + }, + { + "m": 6, + "c": 90, + "h": 0, + "e": 0, + "args": [ + "/app/llama-server", + "-m", + "/m/layers.gguf", + "-c", + "32768", + "-t", + "4", + "-b", + "2048", + "-ub", + "512", + "--host", + "0.0.0.0", + "--port", + "8080", + "--parallel", + "1", + "-ngl", + "auto", + "--mmproj", + "/m/mmproj.gguf", + "--cpu-moe" + ], + "env": {}, + "notes": [ + "threads auto → -t 4 : cpuset du conteneur, 4 cœurs physiques permis sur 8 (llama.cpp seul en lancerait 8)" + ] + }, + { + "m": 6, + "c": 91, + "h": 0, + "e": 0, + "args": [ + "/app/llama-server", + "-m", + "/m/layers.gguf", + "-c", + "32768", + "-t", + "4", + "-b", + "2048", + "-ub", + "512", + "--host", + "0.0.0.0", + "--port", + "8080", + "--parallel", + "1", + "-ngl", + "auto", + "--mmproj", + "/m/mmproj.gguf", + "-sm", + "row" + ], + "env": {}, + "notes": [ + "threads auto → -t 4 : cpuset du conteneur, 4 cœurs physiques permis sur 8 (llama.cpp seul en lancerait 8)" + ] + }, + { + "m": 6, + "c": 92, + "h": 0, + "e": 0, + "args": [ + "/app/llama-server", + "-m", + "/m/layers.gguf", + "-c", + "32768", + "-t", + "4", + "-b", + "2048", + "-ub", + "512", + "--host", + "0.0.0.0", + "--port", + "8080", + "--parallel", + "1", + "-ngl", + "auto", + "--mmproj", + "/m/mmproj.gguf", + "-ts", + "1,1" + ], + "env": {}, + "notes": [ + "threads auto → -t 4 : cpuset du conteneur, 4 cœurs physiques permis sur 8 (llama.cpp seul en lancerait 8)" + ] + }, + { + "m": 6, + "c": 93, + "h": 0, + "e": 0, + "args": [ + "/app/llama-server", + "-m", + "/m/layers.gguf", + "-c", + "32768", + "-t", + "4", + "-b", + "2048", + "-ub", + "512", + "--host", + "0.0.0.0", + "--port", + "8080", + "--parallel", + "1", + "-ngl", + "auto", + "--mmproj", + "/m/mmproj.gguf", + "-dev", + "CUDA0,CUDA1" + ], + "env": { + "CUDA_SCALE_LAUNCH_QUEUES": "4x" + }, + "notes": [ + "CUDA_SCALE_LAUNCH_QUEUES=4x (2 GPU, parallélisme pipeline possible) — CUDA_LAUNCH_QUEUES=off pour s'en passer", + "threads auto → -t 4 : cpuset du conteneur, 4 cœurs physiques permis sur 8 (llama.cpp seul en lancerait 8)" + ] + }, + { + "m": 6, + "c": 94, + "h": 0, + "e": 0, + "args": [ + "/app/llama-server", + "-m", + "/m/layers.gguf", + "-c", + "32768", + "-t", + "4", + "-b", + "2048", + "-ub", + "512", + "--host", + "0.0.0.0", + "--port", + "8080", + "--parallel", + "1", + "-ngl", + "auto", + "--mmproj", + "/m/mmproj.gguf", + "-dev", + "Vulkan0" + ], + "env": {}, + "notes": [ + "threads auto → -t 4 : cpuset du conteneur, 4 cœurs physiques permis sur 8 (llama.cpp seul en lancerait 8)" + ] + }, + { + "m": 6, + "c": 95, + "h": 0, + "e": 0, + "args": [ + "/app/llama-server", + "-m", + "/m/layers.gguf", + "-c", + "32768", + "-t", + "4", + "-b", + "2048", + "-ub", + "512", + "--host", + "0.0.0.0", + "--port", + "8080", + "--parallel", + "1", + "-ngl", + "auto", + "--mmproj", + "/m/mmproj.gguf", + "--fit", + "off" + ], + "env": {}, + "notes": [ + "threads auto → -t 4 : cpuset du conteneur, 4 cœurs physiques permis sur 8 (llama.cpp seul en lancerait 8)" + ] + }, + { + "m": 6, + "c": 96, + "h": 0, + "e": 0, + "args": [ + "/app/llama-server", + "-m", + "/m/layers.gguf", + "-c", + "32768", + "-t", + "4", + "-b", + "2048", + "-ub", + "512", + "--host", + "0.0.0.0", + "--port", + "8080", + "--parallel", + "1", + "-ngl", + "auto", + "--mmproj", + "/m/mmproj.gguf", + "--mmproj", + "/m/x.gguf" + ], + "env": {}, + "notes": [ + "threads auto → -t 4 : cpuset du conteneur, 4 cœurs physiques permis sur 8 (llama.cpp seul en lancerait 8)" + ] + }, + { + "m": 6, + "c": 97, + "h": 0, + "e": 0, + "args": [ + "/app/llama-server", + "-m", + "/m/layers.gguf", + "-c", + "32768", + "-t", + "4", + "-b", + "2048", + "-ub", + "512", + "--host", + "0.0.0.0", + "--port", + "8080", + "--parallel", + "1", + "-ngl", + "auto", + "--mmproj", + "/m/mmproj.gguf", + "--spec-synth-len", + "4" + ], + "env": {}, + "notes": [ + "threads auto → -t 4 : cpuset du conteneur, 4 cœurs physiques permis sur 8 (llama.cpp seul en lancerait 8)", + "avertissement : --spec-synth-len — le moteur accepte des jetons du brouillon AU HASARD, sans vérification : les réponses ne sont plus celles du modèle. Réservé aux mesures, retire-le." + ] + }, + { + "m": 6, + "c": 98, + "h": 0, + "e": 0, + "args": [ + "/app/llama-server", + "-m", + "/m/layers.gguf", + "-c", + "32768", + "-t", + "4", + "-b", + "2048", + "-ub", + "512", + "--host", + "0.0.0.0", + "--port", + "8080", + "--parallel", + "1", + "-ngl", + "auto", + "--mmproj", + "/m/mmproj.gguf", + "--slot-save-path", + "/x" + ], + "env": {}, + "notes": [ + "threads auto → -t 4 : cpuset du conteneur, 4 cœurs physiques permis sur 8 (llama.cpp seul en lancerait 8)" + ] + }, + { + "m": 6, + "c": 99, + "h": 0, + "e": 0, + "args": [ + "/app/llama-server", + "-m", + "/m/layers.gguf", + "-c", + "32768", + "-t", + "4", + "-b", + "2048", + "-ub", + "512", + "--host", + "0.0.0.0", + "--port", + "8080", + "--parallel", + "1", + "-ngl", + "auto", + "--mmproj", + "/m/mmproj.gguf", + "-c", + "0" + ], + "env": {}, + "notes": [ + "threads auto → -t 4 : cpuset du conteneur, 4 cœurs physiques permis sur 8 (llama.cpp seul en lancerait 8)" + ] + }, + { + "m": 6, + "c": 100, + "h": 0, + "e": 0, + "args": [ + "/app/llama-server", + "-m", + "/m/layers.gguf", + "-c", + "32768", + "-t", + "4", + "-b", + "2048", + "-ub", + "512", + "--host", + "0.0.0.0", + "--port", + "8080", + "--parallel", + "1", + "-ngl", + "auto", + "--mmproj", + "/m/mmproj.gguf", + "--slot-save-path", + "/data/slots", + "--host", + "127.0.0.1", + "--port", + "9000" + ], + "env": {}, + "notes": [ + "threads auto → -t 4 : cpuset du conteneur, 4 cœurs physiques permis sur 8 (llama.cpp seul en lancerait 8)" + ] + }, + { + "m": 6, + "c": 101, + "h": 0, + "e": 0, + "args": [ + "/app/llama-server", + "-m", + "/m/layers.gguf", + "-c", + "32768", + "-t", + "4", + "-b", + "2048", + "-ub", + "512", + "--host", + "0.0.0.0", + "--port", + "8080", + "--parallel", + "1", + "-ngl", + "auto", + "--mmproj", + "/m/mmproj.gguf", + "--mirostat", + "2" + ], + "env": {}, + "notes": [ + "threads auto → -t 4 : cpuset du conteneur, 4 cœurs physiques permis sur 8 (llama.cpp seul en lancerait 8)", + "SPEC=mtp : pas de tête MTP dans ce fichier (publiée à part ? MODEL_DRAFT=mtp-….gguf) — sans décodage spéculatif" + ] + }, + { + "m": 6, + "c": 102, + "h": 0, + "e": 0, + "args": [ + "/app/llama-server", + "-m", + "/m/layers.gguf", + "-c", + "32768", + "-t", + "4", + "-b", + "2048", + "-ub", + "512", + "--host", + "0.0.0.0", + "--port", + "8080", + "--parallel", + "1", + "-ngl", + "auto", + "--mmproj", + "/m/mmproj.gguf", + "--chat-template-kwargs", + "{\"preserve_reasoning\":true}" + ], + "env": {}, + "notes": [ + "threads auto → -t 4 : cpuset du conteneur, 4 cœurs physiques permis sur 8 (llama.cpp seul en lancerait 8)", + "--chat-template-kwargs fixe déjà preserve_reasoning : REASONING_PRESERVE ignoré" + ] + }, + { + "m": 6, + "c": 103, + "h": 0, + "e": 0, + "args": [ + "/app/llama-server", + "-m", + "/m/layers.gguf", + "-c", + "32768", + "-t", + "4", + "-b", + "2048", + "-ub", + "512", + "--host", + "0.0.0.0", + "--port", + "8080", + "--parallel", + "1", + "-ngl", + "auto", + "--mmproj", + "/m/mmproj.gguf", + "--spec-type", + "ngram-mod" + ], + "env": {}, + "notes": [ + "threads auto → -t 4 : cpuset du conteneur, 4 cœurs physiques permis sur 8 (llama.cpp seul en lancerait 8)", + "décodage spéculatif déjà réglé dans EXTRA_ARGS ou LLAMA_ARG_* : SPEC ignoré" + ] + }, + { + "m": 6, + "c": 104, + "h": 0, + "e": 0, + "args": [ + "/app/llama-server", + "-m", + "/m/layers.gguf", + "-c", + "32768", + "-t", + "4", + "-b", + "2048", + "-ub", + "512", + "--host", + "0.0.0.0", + "--port", + "8080", + "--parallel", + "1", + "-ngl", + "auto", + "--mmproj", + "/m/mmproj.gguf", + "--spec-draft-n-max", + "8" + ], + "env": {}, + "notes": [ + "threads auto → -t 4 : cpuset du conteneur, 4 cœurs physiques permis sur 8 (llama.cpp seul en lancerait 8)", + "SPEC=ngram : --spec-draft-n-max dans EXTRA_ARGS (brouillon réglé à la main) — sans décodage spéculatif" + ] + }, + { + "m": 6, + "c": 105, + "h": 0, + "e": 0, + "args": [ + "/app/llama-server", + "-m", + "/m/layers.gguf", + "-c", + "32768", + "-t", + "4", + "-b", + "2048", + "-ub", + "512", + "--host", + "0.0.0.0", + "--port", + "8080", + "--parallel", + "1", + "-ngl", + "auto", + "--mmproj", + "/m/mmproj.gguf", + "-ot", + "attn=CUDA1" + ], + "env": {}, + "notes": [ + "CUDA_GRAPH_OPT ignoré : -ot sur l'attention peut séparer Q, K et V d'une couche entre cartes", + "threads auto → -t 4 : cpuset du conteneur, 4 cœurs physiques permis sur 8 (llama.cpp seul en lancerait 8)" + ] + }, + { + "m": 6, + "c": 106, + "h": 0, + "e": 0, + "args": [ + "/app/llama-server", + "-m", + "/m/layers.gguf", + "-c", + "32768", + "-t", + "4", + "-b", + "2048", + "-ub", + "512", + "--host", + "0.0.0.0", + "--port", + "8080", + "--parallel", + "1", + "-ngl", + "auto", + "--mmproj", + "/m/mmproj.gguf", + "-fa", + "off" + ], + "env": {}, + "notes": [ + "SPLIT_MODE=tensor refusé (Flash Attention coupée (-fa off) : le mode tensor l'exige) : découpe par couches, comme sans la clé", + "threads auto → -t 4 : cpuset du conteneur, 4 cœurs physiques permis sur 8 (llama.cpp seul en lancerait 8)" + ] + }, + { + "m": 6, + "c": 107, + "h": 0, + "e": 0, + "args": [ + "/app/llama-server", + "-m", + "/m/layers.gguf", + "-c", + "32768", + "-t", + "4", + "-b", + "2048", + "-ub", + "512", + "--host", + "0.0.0.0", + "--port", + "8080", + "--parallel", + "1", + "-ngl", + "auto", + "--mmproj", + "/m/mmproj.gguf", + "-nkvo" + ], + "env": {}, + "notes": [ + "threads auto → -t 4 : cpuset du conteneur, 4 cœurs physiques permis sur 8 (llama.cpp seul en lancerait 8)" + ] + }, + { + "m": 6, + "c": 108, + "h": 0, + "e": 0, + "args": [ + "/app/llama-server", + "-m", + "/m/layers.gguf", + "-c", + "32768", + "-t", + "4", + "-b", + "2048", + "-ub", + "512", + "--host", + "0.0.0.0", + "--port", + "8080", + "--parallel", + "1", + "-ngl", + "auto", + "--mmproj", + "/m/mmproj.gguf", + "--no-context-shift" + ], + "env": {}, + "notes": [ + "threads auto → -t 4 : cpuset du conteneur, 4 cœurs physiques permis sur 8 (llama.cpp seul en lancerait 8)" + ] + }, + { + "m": 6, + "c": 109, + "h": 0, + "e": 0, + "args": [ + "/app/llama-server", + "-m", + "/m/layers.gguf", + "-c", + "32768", + "-t", + "4", + "-b", + "2048", + "-ub", + "512", + "--host", + "0.0.0.0", + "--port", + "8080", + "-ngl", + "auto", + "--mmproj", + "/m/mmproj.gguf", + "-c", + "8192", + "-np", + "2" + ], + "env": {}, + "notes": [ + "threads auto → -t 4 : cpuset du conteneur, 4 cœurs physiques permis sur 8 (llama.cpp seul en lancerait 8)", + "SIDE_SLOT refusé (-c est réglé dans EXTRA_ARGS) : un seul slot, comme sans la clé" + ] + }, + { + "m": 6, + "c": 110, + "h": 0, + "e": 0, + "args": [ + "/app/llama-server", + "-m", + "/m/layers.gguf", + "-c", + "32768", + "-t", + "4", + "-b", + "2048", + "-ub", + "512", + "--host", + "0.0.0.0", + "--port", + "8080", + "--parallel", + "1", + "-ngl", + "auto", + "--mmproj", + "/m/mmproj.gguf" + ], + "env": {}, + "notes": [ + "SPLIT_MODE=tensor refusé (preset externe) : découpe par couches, comme sans la clé", + "threads auto → -t 4 : cpuset du conteneur, 4 cœurs physiques permis sur 8 (llama.cpp seul en lancerait 8)", + "SIDE_SLOT refusé (preset externe) : un seul slot, comme sans la clé" + ] + }, + { + "m": 6, + "c": 111, + "h": 0, + "e": 0, + "args": [ + "/app/llama-server", + "-m", + "/m/layers.gguf", + "-c", + "40000", + "-t", + "4", + "-b", + "2048", + "-ub", + "512", + "--host", + "0.0.0.0", + "--port", + "8080", + "--parallel", + "1", + "-ngl", + "auto", + "-ctk", + "q4_0", + "-ctv", + "q4_0", + "--mmproj", + "/m/mmproj.gguf" + ], + "env": {}, + "notes": [ + "threads auto → -t 4 : cpuset du conteneur, 4 cœurs physiques permis sur 8 (llama.cpp seul en lancerait 8)", + "NGL=999 (sentinelle « toutes les couches ») → -ngl auto : ce moteur mesure lui-même la VRAM libre. NGL=all pour forcer l'ancien comportement.", + "cache KV q4_0/q4_0 (KV_TYPE) : perte mesurable par rapport à f16 — choix de l'utilisateur, laissé tel quel." + ] + }, + { + "m": 7, + "c": 0, + "h": 0, + "e": 0, + "args": [ + "/app/llama-server", + "-m", + "/m/dense.gguf", + "-c", + "32768", + "-b", + "2048", + "-ub", + "512", + "--host", + "0.0.0.0", + "--port", + "8080", + "--parallel", + "1", + "-ngl", + "auto" + ], + "env": { + "CUDA_SCALE_LAUNCH_QUEUES": "4x" + }, + "notes": [ + "CUDA_SCALE_LAUNCH_QUEUES=4x (2 GPU, parallélisme pipeline possible) — CUDA_LAUNCH_QUEUES=off pour s'en passer" + ] + }, + { + "m": 7, + "c": 1, + "h": 0, + "e": 0, + "args": [ + "/app/llama-server", + "-m", + "/m/dense.gguf", + "-c", + "65536", + "-b", + "4096", + "-ub", + "2048", + "--host", + "127.0.0.1", + "--port", + "8081", + "--parallel", + "1", + "-ngl", + "auto" + ], + "env": { + "CUDA_SCALE_LAUNCH_QUEUES": "4x" + }, + "notes": [ + "CUDA_SCALE_LAUNCH_QUEUES=4x (2 GPU, parallélisme pipeline possible) — CUDA_LAUNCH_QUEUES=off pour s'en passer", + "NGL=999 (sentinelle « toutes les couches ») → -ngl auto : ce moteur mesure lui-même la VRAM libre. NGL=all pour forcer l'ancien comportement." + ] + }, + { + "m": 7, + "c": 2, + "h": 0, + "e": 0, + "args": [ + "/app/llama-server", + "-m", + "/m/dense.gguf", + "-c", + "32768", + "-b", + "2048", + "-ub", + "512", + "--host", + "0.0.0.0", + "--port", + "8080", + "--parallel", + "1" + ], + "env": { + "CUDA_SCALE_LAUNCH_QUEUES": "4x" + }, + "notes": [ + "CUDA_SCALE_LAUNCH_QUEUES=4x (2 GPU, parallélisme pipeline possible) — CUDA_LAUNCH_QUEUES=off pour s'en passer" + ] + }, + { + "m": 7, + "c": 3, + "h": 0, + "e": 0, + "args": [ + "/app/llama-server", + "-m", + "/m/dense.gguf", + "-c", + "32768", + "-b", + "2048", + "-ub", + "512", + "--host", + "0.0.0.0", + "--port", + "8080", + "--parallel", + "1", + "-ngl", + "40" + ], + "env": {}, + "notes": null + }, + { + "m": 7, + "c": 4, + "h": 0, + "e": 0, + "args": [ + "/app/llama-server", + "-m", + "/m/dense.gguf", + "-c", + "32768", + "-b", + "2048", + "-ub", + "512", + "--host", + "0.0.0.0", + "--port", + "8080", + "--parallel", + "1", + "-ngl", + "all" + ], + "env": { + "CUDA_SCALE_LAUNCH_QUEUES": "4x" + }, + "notes": [ + "CUDA_SCALE_LAUNCH_QUEUES=4x (2 GPU, parallélisme pipeline possible) — CUDA_LAUNCH_QUEUES=off pour s'en passer" + ] + }, + { + "m": 7, + "c": 5, + "h": 0, + "e": 0, + "args": [ + "/app/llama-server", + "-m", + "/m/dense.gguf", + "-c", + "32768", + "-b", + "2048", + "-ub", + "512", + "--host", + "0.0.0.0", + "--port", + "8080", + "--parallel", + "1", + "-ngl", + "auto" + ], + "env": { + "CUDA_SCALE_LAUNCH_QUEUES": "4x" + }, + "notes": [ + "CUDA_SCALE_LAUNCH_QUEUES=4x (2 GPU, parallélisme pipeline possible) — CUDA_LAUNCH_QUEUES=off pour s'en passer", + "NGL=999 (sentinelle « toutes les couches ») → -ngl auto : ce moteur mesure lui-même la VRAM libre. NGL=all pour forcer l'ancien comportement." + ] + }, + { + "m": 7, + "c": 6, + "h": 0, + "e": 0, + "args": [ + "/app/llama-server", + "-m", + "/m/dense.gguf", + "-c", + "32768", + "-t", + "8", + "-tb", + "4", + "-b", + "2048", + "-ub", + "512", + "--host", + "0.0.0.0", + "--port", + "8080", + "--parallel", + "1", + "-ngl", + "auto" + ], + "env": { + "CUDA_SCALE_LAUNCH_QUEUES": "4x" + }, + "notes": [ + "CUDA_SCALE_LAUNCH_QUEUES=4x (2 GPU, parallélisme pipeline possible) — CUDA_LAUNCH_QUEUES=off pour s'en passer" + ] + }, + { + "m": 7, + "c": 7, + "h": 0, + "e": 0, + "args": [ + "/app/llama-server", + "-m", + "/m/dense.gguf", + "-c", + "32768", + "-b", + "2048", + "-ub", + "512", + "--host", + "0.0.0.0", + "--port", + "8080", + "--parallel", + "1", + "-ngl", + "auto" + ], + "env": { + "CUDA_SCALE_LAUNCH_QUEUES": "4x" + }, + "notes": [ + "CUDA_SCALE_LAUNCH_QUEUES=4x (2 GPU, parallélisme pipeline possible) — CUDA_LAUNCH_QUEUES=off pour s'en passer", + "THREADS=abc ignoré (pas un nombre de threads ≥ 0) → automatique", + "THREADS_BATCH=-2 ignoré (pas un nombre de threads ≥ 0) → automatique" + ] + }, + { + "m": 7, + "c": 8, + "h": 0, + "e": 0, + "args": [ + "/app/llama-server", + "-m", + "/m/dense.gguf", + "-c", + "32768", + "-b", + "2048", + "-ub", + "512", + "--host", + "0.0.0.0", + "--port", + "8080", + "--parallel", + "1", + "-ngl", + "auto" + ], + "env": { + "CUDA_SCALE_LAUNCH_QUEUES": "4x" + }, + "notes": [ + "CUDA_SCALE_LAUNCH_QUEUES=4x (2 GPU, parallélisme pipeline possible) — CUDA_LAUNCH_QUEUES=off pour s'en passer" + ] + }, + { + "m": 7, + "c": 9, + "h": 0, + "e": 0, + "args": [ + "/app/llama-server", + "-m", + "/m/dense.gguf", + "-c", + "32768", + "-b", + "2048", + "-ub", + "512", + "--host", + "0.0.0.0", + "--port", + "8080", + "--parallel", + "1", + "-ngl", + "auto", + "-ctk", + "q8_0", + "-ctv", + "q8_0" + ], + "env": { + "CUDA_SCALE_LAUNCH_QUEUES": "4x" + }, + "notes": [ + "CUDA_SCALE_LAUNCH_QUEUES=4x (2 GPU, parallélisme pipeline possible) — CUDA_LAUNCH_QUEUES=off pour s'en passer", + "cache KV q8_0/q8_0 (KV_TYPE) : modifie légèrement les sorties par rapport à f16 — choix de l'utilisateur, laissé tel quel." + ] + }, + { + "m": 7, + "c": 10, + "h": 0, + "e": 0, + "args": [ + "/app/llama-server", + "-m", + "/m/dense.gguf", + "-c", + "32768", + "-b", + "2048", + "-ub", + "512", + "--host", + "0.0.0.0", + "--port", + "8080", + "--parallel", + "1", + "-ngl", + "auto", + "-ctk", + "q4_0", + "-ctv", + "f16" + ], + "env": { + "CUDA_SCALE_LAUNCH_QUEUES": "4x" + }, + "notes": [ + "CUDA_SCALE_LAUNCH_QUEUES=4x (2 GPU, parallélisme pipeline possible) — CUDA_LAUNCH_QUEUES=off pour s'en passer", + "cache KV q4_0/f16 (KV_TYPE) : perte mesurable par rapport à f16 — choix de l'utilisateur, laissé tel quel." + ] + }, + { + "m": 7, + "c": 11, + "h": 0, + "e": 0, + "args": [ + "/app/llama-server", + "-m", + "/m/dense.gguf", + "-c", + "32768", + "-b", + "2048", + "-ub", + "512", + "--host", + "0.0.0.0", + "--port", + "8080", + "--parallel", + "1", + "-ngl", + "auto", + "-ctk", + "bf16", + "-ctv", + "bf16" + ], + "env": { + "CUDA_SCALE_LAUNCH_QUEUES": "4x" + }, + "notes": [ + "CUDA_SCALE_LAUNCH_QUEUES=4x (2 GPU, parallélisme pipeline possible) — CUDA_LAUNCH_QUEUES=off pour s'en passer", + "cache KV bf16/bf16 (KV_TYPE) : numérique différente de f16 (mantisse plus courte) — choix de l'utilisateur, laissé tel quel." + ] + }, + { + "m": 7, + "c": 12, + "h": 0, + "e": 0, + "args": [ + "/app/llama-server", + "-m", + "/m/dense.gguf", + "-c", + "32768", + "-b", + "2048", + "-ub", + "512", + "--host", + "0.0.0.0", + "--port", + "8080", + "--parallel", + "1", + "-ngl", + "auto", + "-ctk", + "q5_1", + "-ctv", + "q5_1" + ], + "env": { + "CUDA_SCALE_LAUNCH_QUEUES": "4x" + }, + "notes": [ + "CUDA_SCALE_LAUNCH_QUEUES=4x (2 GPU, parallélisme pipeline possible) — CUDA_LAUNCH_QUEUES=off pour s'en passer", + "cache KV q5_1/q5_1 (KV_TYPE) : modifie légèrement les sorties par rapport à f16 — choix de l'utilisateur, laissé tel quel." + ] + }, + { + "m": 7, + "c": 13, + "h": 0, + "e": 0, + "args": [ + "/app/llama-server", + "-m", + "/m/dense.gguf", + "-c", + "32768", + "-b", + "2048", + "-ub", + "512", + "--host", + "0.0.0.0", + "--port", + "8080", + "--parallel", + "1", + "-ngl", + "auto", + "--reasoning", + "on", + "--reasoning-budget", + "-1" + ], + "env": { + "CUDA_SCALE_LAUNCH_QUEUES": "4x" + }, + "notes": [ + "CUDA_SCALE_LAUNCH_QUEUES=4x (2 GPU, parallélisme pipeline possible) — CUDA_LAUNCH_QUEUES=off pour s'en passer" + ] + }, + { + "m": 7, + "c": 14, + "h": 0, + "e": 0, + "args": [ + "/app/llama-server", + "-m", + "/m/dense.gguf", + "-c", + "32768", + "-b", + "2048", + "-ub", + "512", + "--host", + "0.0.0.0", + "--port", + "8080", + "--parallel", + "1", + "-ngl", + "auto", + "--reasoning", + "off" + ], + "env": { + "CUDA_SCALE_LAUNCH_QUEUES": "4x" + }, + "notes": [ + "CUDA_SCALE_LAUNCH_QUEUES=4x (2 GPU, parallélisme pipeline possible) — CUDA_LAUNCH_QUEUES=off pour s'en passer" + ] + }, + { + "m": 7, + "c": 15, + "h": 0, + "e": 0, + "args": [ + "/app/llama-server", + "-m", + "/m/dense.gguf", + "-c", + "32768", + "-b", + "2048", + "-ub", + "512", + "--host", + "0.0.0.0", + "--port", + "8080", + "--parallel", + "1", + "-ngl", + "auto", + "--reasoning", + "auto", + "--reasoning-budget", + "2048" + ], + "env": { + "CUDA_SCALE_LAUNCH_QUEUES": "4x" + }, + "notes": [ + "CUDA_SCALE_LAUNCH_QUEUES=4x (2 GPU, parallélisme pipeline possible) — CUDA_LAUNCH_QUEUES=off pour s'en passer" + ] + }, + { + "m": 7, + "c": 16, + "h": 0, + "e": 0, + "args": [ + "/app/llama-server", + "-m", + "/m/dense.gguf", + "-c", + "32768", + "-b", + "2048", + "-ub", + "512", + "--host", + "0.0.0.0", + "--port", + "8080", + "--parallel", + "1", + "-ngl", + "auto", + "--reasoning", + "deepseek", + "--reasoning-budget", + "-1" + ], + "env": { + "CUDA_SCALE_LAUNCH_QUEUES": "4x" + }, + "notes": [ + "CUDA_SCALE_LAUNCH_QUEUES=4x (2 GPU, parallélisme pipeline possible) — CUDA_LAUNCH_QUEUES=off pour s'en passer" + ] + }, + { + "m": 7, + "c": 17, + "h": 0, + "e": 0, + "args": [ + "/app/llama-server", + "-m", + "/m/dense.gguf", + "-c", + "32768", + "-b", + "2048", + "-ub", + "512", + "--host", + "0.0.0.0", + "--port", + "8080", + "--parallel", + "1", + "-ngl", + "auto", + "--reasoning-preserve" + ], + "env": { + "CUDA_SCALE_LAUNCH_QUEUES": "4x" + }, + "notes": [ + "CUDA_SCALE_LAUNCH_QUEUES=4x (2 GPU, parallélisme pipeline possible) — CUDA_LAUNCH_QUEUES=off pour s'en passer" + ] + }, + { + "m": 7, + "c": 18, + "h": 0, + "e": 0, + "args": [ + "/app/llama-server", + "-m", + "/m/dense.gguf", + "-c", + "32768", + "-b", + "2048", + "-ub", + "512", + "--host", + "0.0.0.0", + "--port", + "8080", + "--parallel", + "1", + "-ngl", + "auto", + "--no-reasoning-preserve" + ], + "env": { + "CUDA_SCALE_LAUNCH_QUEUES": "4x" + }, + "notes": [ + "CUDA_SCALE_LAUNCH_QUEUES=4x (2 GPU, parallélisme pipeline possible) — CUDA_LAUNCH_QUEUES=off pour s'en passer" + ] + }, + { + "m": 7, + "c": 19, + "h": 0, + "e": 0, + "args": [ + "/app/llama-server", + "-m", + "/m/dense.gguf", + "-c", + "32768", + "-b", + "2048", + "-ub", + "512", + "--host", + "0.0.0.0", + "--port", + "8080", + "--parallel", + "1", + "-ngl", + "auto" + ], + "env": { + "CUDA_SCALE_LAUNCH_QUEUES": "4x" + }, + "notes": [ + "CUDA_SCALE_LAUNCH_QUEUES=4x (2 GPU, parallélisme pipeline possible) — CUDA_LAUNCH_QUEUES=off pour s'en passer", + "REASONING_PRESERVE=peut-être illisible (on ou off) : ignoré" + ] + }, + { + "m": 7, + "c": 20, + "h": 0, + "e": 0, + "args": [ + "/app/llama-server", + "-m", + "/m/dense.gguf", + "-c", + "32768", + "-b", + "2048", + "-ub", + "512", + "--host", + "0.0.0.0", + "--port", + "8080", + "--parallel", + "1", + "-ngl", + "auto" + ], + "env": { + "CUDA_SCALE_LAUNCH_QUEUES": "4x" + }, + "notes": [ + "CUDA_SCALE_LAUNCH_QUEUES=4x (2 GPU, parallélisme pipeline possible) — CUDA_LAUNCH_QUEUES=off pour s'en passer" + ] + }, + { + "m": 7, + "c": 21, + "h": 0, + "e": 0, + "args": [ + "/app/llama-server", + "-m", + "/m/dense.gguf", + "-c", + "32768", + "-b", + "2048", + "-ub", + "512", + "--host", + "0.0.0.0", + "--port", + "8080", + "--parallel", + "1", + "-ngl", + "auto", + "--cache-ram", + "4096" + ], + "env": { + "CUDA_SCALE_LAUNCH_QUEUES": "4x" + }, + "notes": [ + "CUDA_SCALE_LAUNCH_QUEUES=4x (2 GPU, parallélisme pipeline possible) — CUDA_LAUNCH_QUEUES=off pour s'en passer" + ] + }, + { + "m": 7, + "c": 22, + "h": 0, + "e": 0, + "args": [ + "/app/llama-server", + "-m", + "/m/dense.gguf", + "-c", + "32768", + "-b", + "2048", + "-ub", + "512", + "--host", + "0.0.0.0", + "--port", + "8080", + "--parallel", + "1", + "-ngl", + "auto", + "--cache-ram", + "0" + ], + "env": { + "CUDA_SCALE_LAUNCH_QUEUES": "4x" + }, + "notes": [ + "CUDA_SCALE_LAUNCH_QUEUES=4x (2 GPU, parallélisme pipeline possible) — CUDA_LAUNCH_QUEUES=off pour s'en passer" + ] + }, + { + "m": 7, + "c": 23, + "h": 0, + "e": 0, + "args": [ + "/app/llama-server", + "-m", + "/m/dense.gguf", + "-c", + "32768", + "-b", + "2048", + "-ub", + "512", + "--host", + "0.0.0.0", + "--port", + "8080", + "--parallel", + "1", + "-ngl", + "auto", + "--cache-ram", + "-1" + ], + "env": { + "CUDA_SCALE_LAUNCH_QUEUES": "4x" + }, + "notes": [ + "CUDA_SCALE_LAUNCH_QUEUES=4x (2 GPU, parallélisme pipeline possible) — CUDA_LAUNCH_QUEUES=off pour s'en passer" + ] + }, + { + "m": 7, + "c": 24, + "h": 0, + "e": 0, + "args": [ + "/app/llama-server", + "-m", + "/m/dense.gguf", + "-c", + "32768", + "-b", + "2048", + "-ub", + "512", + "--host", + "0.0.0.0", + "--port", + "8080", + "--parallel", + "1", + "-ngl", + "auto" + ], + "env": { + "CUDA_SCALE_LAUNCH_QUEUES": "4x" + }, + "notes": [ + "CUDA_SCALE_LAUNCH_QUEUES=4x (2 GPU, parallélisme pipeline possible) — CUDA_LAUNCH_QUEUES=off pour s'en passer", + "CACHE_RAM=beaucoup illisible (un nombre de Mio, -1, 0 ou auto) : ignoré" + ] + }, + { + "m": 7, + "c": 25, + "h": 0, + "e": 0, + "args": [ + "/app/llama-server", + "-m", + "/m/dense.gguf", + "-c", + "32768", + "-b", + "2048", + "-ub", + "512", + "--host", + "0.0.0.0", + "--port", + "8080", + "--parallel", + "1", + "-ngl", + "auto" + ], + "env": { + "CUDA_SCALE_LAUNCH_QUEUES": "4x" + }, + "notes": [ + "CUDA_SCALE_LAUNCH_QUEUES=4x (2 GPU, parallélisme pipeline possible) — CUDA_LAUNCH_QUEUES=off pour s'en passer" + ] + }, + { + "m": 7, + "c": 26, + "h": 0, + "e": 0, + "args": [ + "/app/llama-server", + "-m", + "/m/dense.gguf", + "-c", + "32768", + "-b", + "2048", + "-ub", + "512", + "--host", + "0.0.0.0", + "--port", + "8080", + "--parallel", + "1", + "-ngl", + "auto", + "--cache-ram", + "0" + ], + "env": { + "CUDA_SCALE_LAUNCH_QUEUES": "4x" + }, + "notes": [ + "CUDA_SCALE_LAUNCH_QUEUES=4x (2 GPU, parallélisme pipeline possible) — CUDA_LAUNCH_QUEUES=off pour s'en passer" + ] + }, + { + "m": 7, + "c": 27, + "h": 0, + "e": 0, + "args": [ + "/app/llama-server", + "-m", + "/m/dense.gguf", + "-c", + "32768", + "-b", + "2048", + "-ub", + "512", + "--host", + "0.0.0.0", + "--port", + "8080", + "--parallel", + "1", + "-ngl", + "auto" + ], + "env": { + "CUDA_SCALE_LAUNCH_QUEUES": "4x" + }, + "notes": [ + "CUDA_SCALE_LAUNCH_QUEUES=4x (2 GPU, parallélisme pipeline possible) — CUDA_LAUNCH_QUEUES=off pour s'en passer", + "SIDE_SLOT refusé (VRAM insuffisante : modèle + 2 × 8192 Mio d'état dépassent 90 % de 24000 Mio) : un seul slot, comme sans la clé" + ] + }, + { + "m": 7, + "c": 28, + "h": 0, + "e": 0, + "args": [ + "/app/llama-server", + "-m", + "/m/dense.gguf", + "-c", + "32768", + "-b", + "2048", + "-ub", + "512", + "--host", + "0.0.0.0", + "--port", + "8080", + "--parallel", + "3", + "-ngl", + "auto" + ], + "env": { + "CUDA_SCALE_LAUNCH_QUEUES": "4x" + }, + "notes": [ + "CUDA_SCALE_LAUNCH_QUEUES=4x (2 GPU, parallélisme pipeline possible) — CUDA_LAUNCH_QUEUES=off pour s'en passer", + "SIDE_SLOT refusé (PARALLEL=3 (SIDE_SLOT ouvre exactement 2 slots)) : un seul slot, comme sans la clé" + ] + }, + { + "m": 7, + "c": 29, + "h": 0, + "e": 0, + "args": [ + "/app/llama-server", + "-m", + "/m/dense.gguf", + "-c", + "0", + "-b", + "2048", + "-ub", + "512", + "--host", + "0.0.0.0", + "--port", + "8080", + "--parallel", + "1", + "-ngl", + "auto" + ], + "env": { + "CUDA_SCALE_LAUNCH_QUEUES": "4x" + }, + "notes": [ + "CUDA_SCALE_LAUNCH_QUEUES=4x (2 GPU, parallélisme pipeline possible) — CUDA_LAUNCH_QUEUES=off pour s'en passer", + "SIDE_SLOT refusé (CTX doit être un nombre de jetons explicite) : un seul slot, comme sans la clé" + ] + }, + { + "m": 7, + "c": 30, + "h": 0, + "e": 0, + "args": [ + "/app/llama-server", + "-m", + "/m/dense.gguf", + "-c", + "8192", + "-b", + "2048", + "-ub", + "512", + "--host", + "0.0.0.0", + "--port", + "8080", + "--parallel", + "1", + "-ngl", + "auto", + "-ctk", + "q8_0", + "-ctv", + "q8_0" + ], + "env": { + "CUDA_SCALE_LAUNCH_QUEUES": "4x" + }, + "notes": [ + "CUDA_SCALE_LAUNCH_QUEUES=4x (2 GPU, parallélisme pipeline possible) — CUDA_LAUNCH_QUEUES=off pour s'en passer", + "SIDE_SLOT refusé (VRAM insuffisante : modèle + 2 × 1088 Mio d'état dépassent 90 % de 24000 Mio) : un seul slot, comme sans la clé", + "cache KV q8_0/q8_0 (KV_TYPE) : modifie légèrement les sorties par rapport à f16 — choix de l'utilisateur, laissé tel quel." + ] + }, + { + "m": 7, + "c": 31, + "h": 0, + "e": 0, + "args": [ + "/app/llama-server", + "-m", + "/m/dense.gguf", + "-c", + "32768", + "-b", + "2048", + "-ub", + "512", + "--host", + "0.0.0.0", + "--port", + "8080", + "--parallel", + "1", + "-ngl", + "auto" + ], + "env": { + "CUDA_SCALE_LAUNCH_QUEUES": "4x" + }, + "notes": [ + "SPLIT_MODE=tensor refusé (VRAM insuffisante sans --fit : ~38912 Mio (poids + cache de 32768 jetons) pour 28599 Mio sur 2 cartes — CTX n'est jamais réduit d'office) : découpe par couches, comme sans la clé", + "CUDA_SCALE_LAUNCH_QUEUES=4x (2 GPU, parallélisme pipeline possible) — CUDA_LAUNCH_QUEUES=off pour s'en passer" + ] + }, + { + "m": 7, + "c": 32, + "h": 0, + "e": 0, + "args": [ + "/app/llama-server", + "-m", + "/m/dense.gguf", + "-c", + "16384", + "-b", + "2048", + "-ub", + "512", + "--host", + "0.0.0.0", + "--port", + "8080", + "--parallel", + "1", + "-ngl", + "auto" + ], + "env": { + "CUDA_SCALE_LAUNCH_QUEUES": "4x" + }, + "notes": [ + "SPLIT_MODE=tensor refusé (VRAM insuffisante sans --fit : ~34816 Mio (poids + cache de 16384 jetons) pour 28599 Mio sur 2 cartes — CTX n'est jamais réduit d'office) : découpe par couches, comme sans la clé", + "CUDA_SCALE_LAUNCH_QUEUES=4x (2 GPU, parallélisme pipeline possible) — CUDA_LAUNCH_QUEUES=off pour s'en passer" + ] + }, + { + "m": 7, + "c": 33, + "h": 0, + "e": 0, + "args": [ + "/app/llama-server", + "-m", + "/m/dense.gguf", + "-c", + "32768", + "-b", + "2048", + "-ub", + "512", + "--host", + "0.0.0.0", + "--port", + "8080", + "--parallel", + "1", + "-ngl", + "auto" + ], + "env": { + "CUDA_SCALE_LAUNCH_QUEUES": "4x" + }, + "notes": [ + "SPLIT_MODE=tensor refusé (SPLIT_MODE=bizarre illisible (tensor ou off)) : découpe par couches, comme sans la clé", + "CUDA_SCALE_LAUNCH_QUEUES=4x (2 GPU, parallélisme pipeline possible) — CUDA_LAUNCH_QUEUES=off pour s'en passer" + ] + }, + { + "m": 7, + "c": 34, + "h": 0, + "e": 0, + "args": [ + "/app/llama-server", + "-m", + "/m/dense.gguf", + "-c", + "32768", + "-b", + "2048", + "-ub", + "512", + "--host", + "0.0.0.0", + "--port", + "8080", + "--parallel", + "1", + "-ngl", + "auto" + ], + "env": { + "CUDA_SCALE_LAUNCH_QUEUES": "4x" + }, + "notes": [ + "CUDA_SCALE_LAUNCH_QUEUES=4x (2 GPU, parallélisme pipeline possible) — CUDA_LAUNCH_QUEUES=off pour s'en passer" + ] + }, + { + "m": 7, + "c": 35, + "h": 0, + "e": 0, + "args": [ + "/app/llama-server", + "-m", + "/m/dense.gguf", + "-c", + "32768", + "-b", + "2048", + "-ub", + "512", + "--host", + "0.0.0.0", + "--port", + "8080", + "--parallel", + "1", + "-ngl", + "auto", + "-ctk", + "q8_0", + "-ctv", + "q8_0" + ], + "env": { + "CUDA_SCALE_LAUNCH_QUEUES": "4x" + }, + "notes": [ + "SPLIT_MODE=tensor refusé (cache KV q8_0/q8_0 : le mode tensor n'accepte que f16, bf16 ou f32 (jamais réécrit d'office : la VRAM changerait)) : découpe par couches, comme sans la clé", + "CUDA_SCALE_LAUNCH_QUEUES=4x (2 GPU, parallélisme pipeline possible) — CUDA_LAUNCH_QUEUES=off pour s'en passer", + "cache KV q8_0/q8_0 (KV_TYPE) : modifie légèrement les sorties par rapport à f16 — choix de l'utilisateur, laissé tel quel." + ] + }, + { + "m": 7, + "c": 36, + "h": 0, + "e": 0, + "args": [ + "/app/llama-server", + "-m", + "/m/dense.gguf", + "-c", + "32768", + "-b", + "2048", + "-ub", + "512", + "--host", + "0.0.0.0", + "--port", + "8080", + "--parallel", + "1", + "-ngl", + "20" + ], + "env": {}, + "notes": [ + "SPLIT_MODE=tensor refusé (NGL=20 : le mode tensor veut toutes les couches sur les cartes (all)) : découpe par couches, comme sans la clé" + ] + }, + { + "m": 7, + "c": 37, + "h": 0, + "e": 0, + "args": [ + "/app/llama-server", + "-m", + "/m/dense.gguf", + "-c", + "32768", + "-b", + "2048", + "-ub", + "512", + "--host", + "0.0.0.0", + "--port", + "8080", + "--parallel", + "1", + "-ngl", + "auto" + ], + "env": { + "CUDA_SCALE_LAUNCH_QUEUES": "4x" + }, + "notes": [ + "CUDA_SCALE_LAUNCH_QUEUES=4x (2 GPU, parallélisme pipeline possible) — CUDA_LAUNCH_QUEUES=off pour s'en passer" + ] + }, + { + "m": 7, + "c": 38, + "h": 0, + "e": 0, + "args": [ + "/app/llama-server", + "-m", + "/m/dense.gguf", + "-c", + "32768", + "-b", + "2048", + "-ub", + "512", + "--host", + "0.0.0.0", + "--port", + "8080", + "--parallel", + "1", + "-ngl", + "auto" + ], + "env": { + "CUDA_SCALE_LAUNCH_QUEUES": "4x" + }, + "notes": [ + "CUDA_SCALE_LAUNCH_QUEUES=4x (2 GPU, parallélisme pipeline possible) — CUDA_LAUNCH_QUEUES=off pour s'en passer", + "SPEC=mtp : pas de tête MTP dans ce fichier (publiée à part ? MODEL_DRAFT=mtp-….gguf) — sans décodage spéculatif" + ] + }, + { + "m": 7, + "c": 39, + "h": 0, + "e": 0, + "args": [ + "/app/llama-server", + "-m", + "/m/dense.gguf", + "-c", + "32768", + "-b", + "2048", + "-ub", + "512", + "--host", + "0.0.0.0", + "--port", + "8080", + "--parallel", + "1", + "-ngl", + "auto", + "--spec-type", + "ngram-mod", + "--spec-ngram-mod-n-match", + "24", + "--spec-ngram-mod-n-min", + "48", + "--spec-ngram-mod-n-max", + "64" + ], + "env": { + "CUDA_SCALE_LAUNCH_QUEUES": "4x" + }, + "notes": [ + "CUDA_SCALE_LAUNCH_QUEUES=4x (2 GPU, parallélisme pipeline possible) — CUDA_LAUNCH_QUEUES=off pour s'en passer", + "SPEC=ngram → n-grammes du contexte (ngram-mod, brouillons de 48 à 64 jetons) : distribution inchangée (chaque jeton vérifié), ~16 Mo de RAM ; utile surtout en mode code, à mesurer (acceptation par nature dans /api/perf/summary)" + ] + }, + { + "m": 7, + "c": 40, + "h": 0, + "e": 0, + "args": [ + "/app/llama-server", + "-m", + "/m/dense.gguf", + "-c", + "32768", + "-b", + "2048", + "-ub", + "512", + "--host", + "0.0.0.0", + "--port", + "8080", + "--parallel", + "1", + "-ngl", + "auto", + "--spec-type", + "ngram-mod", + "--spec-ngram-mod-n-match", + "24", + "--spec-ngram-mod-n-min", + "48", + "--spec-ngram-mod-n-max", + "64" + ], + "env": { + "CUDA_SCALE_LAUNCH_QUEUES": "4x" + }, + "notes": [ + "CUDA_SCALE_LAUNCH_QUEUES=4x (2 GPU, parallélisme pipeline possible) — CUDA_LAUNCH_QUEUES=off pour s'en passer", + "SPEC=mtp+ngram : sans MTP (pas de tête MTP dans ce fichier (publiée à part ? MODEL_DRAFT=mtp-….gguf)) → n-grammes seuls. SPEC=ngram → n-grammes du contexte (ngram-mod, brouillons de 48 à 64 jetons) : distribution inchangée (chaque jeton vérifié), ~16 Mo de RAM ; utile surtout en mode code, à mesurer (acceptation par nature dans /api/perf/summary)" + ] + }, + { + "m": 7, + "c": 41, + "h": 0, + "e": 0, + "args": [ + "/app/llama-server", + "-m", + "/m/dense.gguf", + "-c", + "32768", + "-b", + "2048", + "-ub", + "512", + "--host", + "0.0.0.0", + "--port", + "8080", + "--parallel", + "1", + "-ngl", + "auto", + "--spec-type", + "ngram-mod", + "--spec-ngram-mod-n-match", + "24", + "--spec-ngram-mod-n-min", + "48", + "--spec-ngram-mod-n-max", + "64" + ], + "env": { + "CUDA_SCALE_LAUNCH_QUEUES": "4x" + }, + "notes": [ + "CUDA_SCALE_LAUNCH_QUEUES=4x (2 GPU, parallélisme pipeline possible) — CUDA_LAUNCH_QUEUES=off pour s'en passer", + "SPEC=mtp+ngram : sans MTP (pas de tête MTP dans ce fichier (publiée à part ? MODEL_DRAFT=mtp-….gguf)) → n-grammes seuls. SPEC=ngram → n-grammes du contexte (ngram-mod, brouillons de 48 à 64 jetons) : distribution inchangée (chaque jeton vérifié), ~16 Mo de RAM ; utile surtout en mode code, à mesurer (acceptation par nature dans /api/perf/summary)" + ] + }, + { + "m": 7, + "c": 42, + "h": 0, + "e": 0, + "args": [ + "/app/llama-server", + "-m", + "/m/dense.gguf", + "-c", + "32768", + "-b", + "2048", + "-ub", + "512", + "--host", + "0.0.0.0", + "--port", + "8080", + "--parallel", + "1", + "-ngl", + "auto" + ], + "env": { + "CUDA_SCALE_LAUNCH_QUEUES": "4x" + }, + "notes": [ + "CUDA_SCALE_LAUNCH_QUEUES=4x (2 GPU, parallélisme pipeline possible) — CUDA_LAUNCH_QUEUES=off pour s'en passer", + "SPEC=turbo illisible (off, auto, mtp, ngram ou mtp+ngram) : sans décodage spéculatif" + ] + }, + { + "m": 7, + "c": 43, + "h": 0, + "e": 0, + "args": [ + "/app/llama-server", + "-m", + "/m/dense.gguf", + "-c", + "32768", + "-b", + "2048", + "-ub", + "512", + "--host", + "0.0.0.0", + "--port", + "8080", + "--parallel", + "1", + "-ngl", + "auto" + ], + "env": { + "CUDA_SCALE_LAUNCH_QUEUES": "4x" + }, + "notes": [ + "CUDA_SCALE_LAUNCH_QUEUES=4x (2 GPU, parallélisme pipeline possible) — CUDA_LAUNCH_QUEUES=off pour s'en passer", + "MODEL_DRAFT ignoré : SPEC=off (auto ou mtp pour s'en servir)" + ] + }, + { + "m": 7, + "c": 44, + "h": 0, + "e": 0, + "args": [ + "/app/llama-server", + "-m", + "/m/dense.gguf", + "-c", + "32768", + "-b", + "2048", + "-ub", + "512", + "--host", + "0.0.0.0", + "--port", + "8080", + "--parallel", + "1", + "-ngl", + "auto", + "-md", + "/m/mtp-head.gguf", + "--spec-type", + "draft-mtp", + "--spec-draft-n-max", + "4", + "--spec-draft-sampling", + "greedy" + ], + "env": { + "CUDA_SCALE_LAUNCH_QUEUES": "4x" + }, + "notes": [ + "CUDA_SCALE_LAUNCH_QUEUES=4x (2 GPU, parallélisme pipeline possible) — CUDA_LAUNCH_QUEUES=off pour s'en passer", + "SPEC=mtp → tête MTP imposé : sortie inchangée, ~1-2 Go de VRAM en plus" + ] + }, + { + "m": 7, + "c": 45, + "h": 0, + "e": 0, + "args": [ + "/app/llama-server", + "-m", + "/m/dense.gguf", + "-c", + "32768", + "-b", + "2048", + "-ub", + "512", + "--host", + "0.0.0.0", + "--port", + "8080", + "--parallel", + "1", + "-ngl", + "auto" + ], + "env": { + "CUDA_SCALE_LAUNCH_QUEUES": "4x" + }, + "notes": [ + "CUDA_SCALE_LAUNCH_QUEUES=4x (2 GPU, parallélisme pipeline possible) — CUDA_LAUNCH_QUEUES=off pour s'en passer", + "SPEC=mtp : pas de tête MTP dans ce fichier (publiée à part ? MODEL_DRAFT=mtp-….gguf) — sans décodage spéculatif" + ] + }, + { + "m": 7, + "c": 46, + "h": 0, + "e": 0, + "args": [ + "/app/llama-server", + "-m", + "/m/dense.gguf", + "-c", + "32768", + "-b", + "2048", + "-ub", + "512", + "--host", + "0.0.0.0", + "--port", + "8080", + "--parallel", + "1", + "-ngl", + "auto" + ], + "env": { + "CUDA_SCALE_LAUNCH_QUEUES": "4x" + }, + "notes": [ + "CUDA_SCALE_LAUNCH_QUEUES=4x (2 GPU, parallélisme pipeline possible) — CUDA_LAUNCH_QUEUES=off pour s'en passer" + ] + }, + { + "m": 7, + "c": 47, + "h": 0, + "e": 0, + "args": [ + "/app/llama-server", + "-m", + "/m/dense.gguf", + "-c", + "32768", + "-b", + "2048", + "-ub", + "512", + "--host", + "0.0.0.0", + "--port", + "8080", + "--parallel", + "1", + "-ngl", + "auto" + ], + "env": { + "CUDA_SCALE_LAUNCH_QUEUES": "4x" + }, + "notes": [ + "CUDA_SCALE_LAUNCH_QUEUES=4x (2 GPU, parallélisme pipeline possible) — CUDA_LAUNCH_QUEUES=off pour s'en passer", + "SPEC=mtp : pas de tête MTP dans ce fichier (publiée à part ? MODEL_DRAFT=mtp-….gguf) — sans décodage spéculatif" + ] + }, + { + "m": 7, + "c": 48, + "h": 0, + "e": 0, + "args": [ + "/app/llama-server", + "-m", + "/m/dense.gguf", + "-c", + "32768", + "-b", + "2048", + "-ub", + "512", + "--host", + "0.0.0.0", + "--port", + "8080", + "--parallel", + "1", + "-ngl", + "auto", + "--spec-type", + "ngram-mod", + "--spec-ngram-mod-n-match", + "24", + "--spec-ngram-mod-n-min", + "48", + "--spec-ngram-mod-n-max", + "64" + ], + "env": { + "CUDA_SCALE_LAUNCH_QUEUES": "4x" + }, + "notes": [ + "CUDA_SCALE_LAUNCH_QUEUES=4x (2 GPU, parallélisme pipeline possible) — CUDA_LAUNCH_QUEUES=off pour s'en passer", + "SPEC=ngram → n-grammes du contexte (ngram-mod, brouillons de 48 à 64 jetons) : distribution inchangée (chaque jeton vérifié), ~16 Mo de RAM ; utile surtout en mode code, à mesurer (acceptation par nature dans /api/perf/summary)", + "MODEL_DRAFT ignoré avec SPEC=ngram : SPEC=mtp+ngram pour les deux", + "SPEC_N_MAX et SPEC_SAMPLING ignorés : les n-grammes n'ont pas de brouillon à régler" + ] + }, + { + "m": 7, + "c": 49, + "h": 0, + "e": 0, + "args": [ + "/app/llama-server", + "-m", + "/m/dense.gguf", + "-c", + "32768", + "-b", + "2048", + "-ub", + "512", + "--host", + "0.0.0.0", + "--port", + "8080", + "--parallel", + "1", + "-ngl", + "auto", + "-md", + "/m/mtp-head.gguf", + "--spec-type", + "draft-mtp", + "--spec-draft-sampling", + "greedy" + ], + "env": { + "CUDA_SCALE_LAUNCH_QUEUES": "4x" + }, + "notes": [ + "CUDA_SCALE_LAUNCH_QUEUES=4x (2 GPU, parallélisme pipeline possible) — CUDA_LAUNCH_QUEUES=off pour s'en passer", + "SPEC=auto → tête MTP : sortie inchangée (chaque jeton est vérifié par le modèle), ~1-2 Go de VRAM en plus. Mesure le prefill ; SPEC=off pour couper." + ] + }, + { + "m": 7, + "c": 50, + "h": 0, + "e": 0, + "args": [ + "/app/llama-server", + "-m", + "/m/dense.gguf", + "-c", + "32768", + "-b", + "2048", + "-ub", + "512", + "--host", + "0.0.0.0", + "--port", + "8080", + "--parallel", + "1", + "-ngl", + "auto", + "-md", + "/m/mtp-head.gguf", + "--spec-type", + "draft-mtp,ngram-mod", + "--spec-ngram-mod-n-match", + "24", + "--spec-ngram-mod-n-min", + "48", + "--spec-ngram-mod-n-max", + "64", + "--spec-draft-sampling", + "greedy" + ], + "env": { + "CUDA_SCALE_LAUNCH_QUEUES": "4x" + }, + "notes": [ + "CUDA_SCALE_LAUNCH_QUEUES=4x (2 GPU, parallélisme pipeline possible) — CUDA_LAUNCH_QUEUES=off pour s'en passer", + "SPEC=mtp+ngram → tête MTP imposé : sortie inchangée, ~1-2 Go de VRAM en plus ; n-grammes en plus (ngram-mod, prioritaires quand ils trouvent une répétition)" + ] + }, + { + "m": 7, + "c": 51, + "h": 0, + "e": 0, + "args": [ + "/app/llama-server", + "-m", + "/m/dense.gguf", + "-c", + "32768", + "-b", + "2048", + "-ub", + "512", + "--host", + "0.0.0.0", + "--port", + "8080", + "--parallel", + "1", + "-ngl", + "auto" + ], + "env": {}, + "notes": null + }, + { + "m": 7, + "c": 52, + "h": 0, + "e": 0, + "args": [ + "/app/llama-server", + "-m", + "/m/dense.gguf", + "-c", + "32768", + "-b", + "2048", + "-ub", + "512", + "--host", + "0.0.0.0", + "--port", + "8080", + "--parallel", + "1", + "-ngl", + "auto" + ], + "env": { + "CUDA_SCALE_LAUNCH_QUEUES": "2x" + }, + "notes": [ + "CUDA_SCALE_LAUNCH_QUEUES=2x (CUDA_LAUNCH_QUEUES)" + ] + }, + { + "m": 7, + "c": 53, + "h": 0, + "e": 0, + "args": [ + "/app/llama-server", + "-m", + "/m/dense.gguf", + "-c", + "32768", + "-b", + "2048", + "-ub", + "512", + "--host", + "0.0.0.0", + "--port", + "8080", + "--parallel", + "1", + "-ngl", + "auto" + ], + "env": { + "CUDA_SCALE_LAUNCH_QUEUES": "4x" + }, + "notes": [ + "CUDA_LAUNCH_QUEUES=8x ignoré (0.25x, 0.5x, 2x, 4x ou off) → automatique", + "CUDA_SCALE_LAUNCH_QUEUES=4x (2 GPU, parallélisme pipeline possible) — CUDA_LAUNCH_QUEUES=off pour s'en passer" + ] + }, + { + "m": 7, + "c": 54, + "h": 0, + "e": 0, + "args": [ + "/app/llama-server", + "-m", + "/m/dense.gguf", + "-c", + "32768", + "-b", + "2048", + "-ub", + "512", + "--host", + "0.0.0.0", + "--port", + "8080", + "--parallel", + "1", + "-ngl", + "auto" + ], + "env": { + "CUDA_SCALE_LAUNCH_QUEUES": "4x" + }, + "notes": [ + "CUDA_SCALE_LAUNCH_QUEUES=4x (2 GPU, parallélisme pipeline possible) — CUDA_LAUNCH_QUEUES=off pour s'en passer" + ] + }, + { + "m": 7, + "c": 55, + "h": 0, + "e": 0, + "args": [ + "/app/llama-server", + "-m", + "/m/dense.gguf", + "-c", + "32768", + "-b", + "2048", + "-ub", + "512", + "--host", + "0.0.0.0", + "--port", + "8080", + "--parallel", + "1", + "-ngl", + "auto" + ], + "env": { + "CUDA_DEVICE_ORDER": "PCI_BUS_ID", + "CUDA_SCALE_LAUNCH_QUEUES": "4x", + "CUDA_VISIBLE_DEVICES": "1,0" + }, + "notes": [ + "CUDA_SCALE_LAUNCH_QUEUES=4x (2 GPU, parallélisme pipeline possible) — CUDA_LAUNCH_QUEUES=off pour s'en passer" + ] + }, + { + "m": 7, + "c": 56, + "h": 0, + "e": 0, + "args": [ + "/app/llama-server", + "-m", + "/m/dense.gguf", + "-c", + "32768", + "-b", + "2048", + "-ub", + "512", + "--host", + "0.0.0.0", + "--port", + "8080", + "--parallel", + "1", + "-ngl", + "auto", + "--fit-target", + "1024,3072" + ], + "env": { + "CUDA_SCALE_LAUNCH_QUEUES": "4x" + }, + "notes": [ + "CUDA_SCALE_LAUNCH_QUEUES=4x (2 GPU, parallélisme pipeline possible) — CUDA_LAUNCH_QUEUES=off pour s'en passer", + "--fit-target 1024,3072 (FIT_TARGET, Mio libres par carte, dans l'ordre vu par le moteur) — vérifie « offloaded N/N layers » dans le journal" + ] + }, + { + "m": 7, + "c": 57, + "h": 0, + "e": 0, + "args": [ + "/app/llama-server", + "-m", + "/m/dense.gguf", + "-c", + "32768", + "-b", + "2048", + "-ub", + "512", + "--host", + "0.0.0.0", + "--port", + "8080", + "--parallel", + "1", + "-ngl", + "auto", + "--fit-target", + "1024" + ], + "env": { + "CUDA_SCALE_LAUNCH_QUEUES": "4x" + }, + "notes": [ + "CUDA_SCALE_LAUNCH_QUEUES=4x (2 GPU, parallélisme pipeline possible) — CUDA_LAUNCH_QUEUES=off pour s'en passer", + "--fit-target 1024 (FIT_TARGET, Mio libres par carte, dans l'ordre vu par le moteur) — vérifie « offloaded N/N layers » dans le journal ; marges sous 1024 Mio remontées à 1024" + ] + }, + { + "m": 7, + "c": 58, + "h": 0, + "e": 0, + "args": [ + "/app/llama-server", + "-m", + "/m/dense.gguf", + "-c", + "32768", + "-b", + "2048", + "-ub", + "512", + "--host", + "0.0.0.0", + "--port", + "8080", + "--parallel", + "1", + "-ngl", + "auto" + ], + "env": { + "CUDA_SCALE_LAUNCH_QUEUES": "4x" + }, + "notes": [ + "CUDA_SCALE_LAUNCH_QUEUES=4x (2 GPU, parallélisme pipeline possible) — CUDA_LAUNCH_QUEUES=off pour s'en passer", + "FIT_TARGET=1024,,3072 ignoré : des Mio par carte, ex. 1024,3072 (8 valeurs au plus)" + ] + }, + { + "m": 7, + "c": 59, + "h": 0, + "e": 0, + "args": [ + "/app/llama-server", + "-m", + "/m/dense.gguf", + "-c", + "32768", + "-b", + "2048", + "-ub", + "512", + "--host", + "0.0.0.0", + "--port", + "8080", + "--parallel", + "1", + "-ngl", + "auto" + ], + "env": { + "CUDA_SCALE_LAUNCH_QUEUES": "4x" + }, + "notes": [ + "CUDA_SCALE_LAUNCH_QUEUES=4x (2 GPU, parallélisme pipeline possible) — CUDA_LAUNCH_QUEUES=off pour s'en passer", + "FIT_TARGET=1,2,3,4,5,6,7,8,9 ignoré : des Mio par carte, ex. 1024,3072 (8 valeurs au plus)" + ] + }, + { + "m": 7, + "c": 60, + "h": 0, + "e": 0, + "args": [ + "/app/llama-server", + "-m", + "/m/dense.gguf", + "-c", + "0", + "-b", + "2048", + "-ub", + "512", + "--host", + "0.0.0.0", + "--port", + "8080", + "--parallel", + "1", + "-ngl", + "auto" + ], + "env": { + "CUDA_SCALE_LAUNCH_QUEUES": "4x" + }, + "notes": [ + "CUDA_SCALE_LAUNCH_QUEUES=4x (2 GPU, parallélisme pipeline possible) — CUDA_LAUNCH_QUEUES=off pour s'en passer", + "FIT_TARGET=2048/4096 ignoré : contexte « 0 » non fixé — --fit pourrait le réduire pour tenir ; donne un CTX chiffré" + ] + }, + { + "m": 7, + "c": 61, + "h": 0, + "e": 0, + "args": [ + "/app/llama-server", + "-m", + "/m/dense.gguf", + "-c", + "32768", + "-b", + "2048", + "-ub", + "512", + "--host", + "0.0.0.0", + "--port", + "8080", + "--parallel", + "1", + "-ngl", + "30" + ], + "env": {}, + "notes": [ + "FIT_TARGET=2048 ignoré : --fit inactif (couches GPU fixées à 30), la marge ne servirait à rien" + ] + }, + { + "m": 7, + "c": 62, + "h": 0, + "e": 0, + "args": [ + "/app/llama-server", + "-m", + "/m/dense.gguf", + "-c", + "32768", + "-b", + "2048", + "-ub", + "512", + "--host", + "0.0.0.0", + "--port", + "8080", + "--parallel", + "1", + "-ngl", + "auto" + ], + "env": { + "CUDA_SCALE_LAUNCH_QUEUES": "4x", + "GGML_OP_OFFLOAD_MIN_BATCH": "128" + }, + "notes": [ + "CUDA_SCALE_LAUNCH_QUEUES=4x (2 GPU, parallélisme pipeline possible) — CUDA_LAUNCH_QUEUES=off pour s'en passer", + "GGML_OP_OFFLOAD_MIN_BATCH=128 (OP_OFFLOAD_MIN_BATCH) : lots de moins de 128 jetons calculés sur CPU pour les poids restés en RAM" + ] + }, + { + "m": 7, + "c": 63, + "h": 0, + "e": 0, + "args": [ + "/app/llama-server", + "-m", + "/m/dense.gguf", + "-c", + "32768", + "-b", + "2048", + "-ub", + "512", + "--host", + "0.0.0.0", + "--port", + "8080", + "--parallel", + "1", + "-ngl", + "auto" + ], + "env": { + "CUDA_SCALE_LAUNCH_QUEUES": "4x" + }, + "notes": [ + "CUDA_SCALE_LAUNCH_QUEUES=4x (2 GPU, parallélisme pipeline possible) — CUDA_LAUNCH_QUEUES=off pour s'en passer" + ] + }, + { + "m": 7, + "c": 64, + "h": 0, + "e": 0, + "args": [ + "/app/llama-server", + "-m", + "/m/dense.gguf", + "-c", + "32768", + "-b", + "2048", + "-ub", + "512", + "--host", + "0.0.0.0", + "--port", + "8080", + "--parallel", + "1", + "-ngl", + "auto" + ], + "env": { + "CUDA_SCALE_LAUNCH_QUEUES": "4x", + "GGML_CUDA_GRAPH_OPT": "1" + }, + "notes": [ + "CUDA_SCALE_LAUNCH_QUEUES=4x (2 GPU, parallélisme pipeline possible) — CUDA_LAUNCH_QUEUES=off pour s'en passer", + "GGML_CUDA_GRAPH_OPT=1 (CUDA_GRAPH_OPT, expérimental) — si le moteur s'arrête sur un GGML_ASSERT, CUDA_GRAPH_OPT=off puis redémarre" + ] + }, + { + "m": 7, + "c": 65, + "h": 0, + "e": 0, + "args": [ + "/app/llama-server", + "-m", + "/m/dense.gguf", + "-c", + "32768", + "-b", + "2048", + "-ub", + "512", + "--host", + "0.0.0.0", + "--port", + "8080", + "--parallel", + "1", + "-ngl", + "auto" + ], + "env": { + "CUDA_SCALE_LAUNCH_QUEUES": "4x" + }, + "notes": [ + "CUDA_SCALE_LAUNCH_QUEUES=4x (2 GPU, parallélisme pipeline possible) — CUDA_LAUNCH_QUEUES=off pour s'en passer", + "CUDA_GRAPH_OPT=oui ignoré (on ou off)" + ] + }, + { + "m": 7, + "c": 66, + "h": 0, + "e": 0, + "args": [ + "/app/llama-server", + "-m", + "/m/dense.gguf", + "-c", + "32768", + "-b", + "2048", + "-ub", + "512", + "--host", + "0.0.0.0", + "--port", + "8080", + "--parallel", + "1", + "-ngl", + "auto" + ], + "env": { + "CUDA_SCALE_LAUNCH_QUEUES": "4x" + }, + "notes": [ + "CUDA_SCALE_LAUNCH_QUEUES=4x (2 GPU, parallélisme pipeline possible) — CUDA_LAUNCH_QUEUES=off pour s'en passer" + ] + }, + { + "m": 7, + "c": 67, + "h": 0, + "e": 0, + "args": [ + "/app/llama-server", + "-m", + "/m/dense.gguf", + "-c", + "32768", + "-b", + "2048", + "-ub", + "512", + "--host", + "0.0.0.0", + "--port", + "8080", + "--parallel", + "1", + "-ngl", + "auto", + "--ctx-checkpoints", + "8" + ], + "env": { + "CUDA_SCALE_LAUNCH_QUEUES": "4x" + }, + "notes": [ + "CUDA_SCALE_LAUNCH_QUEUES=4x (2 GPU, parallélisme pipeline possible) — CUDA_LAUNCH_QUEUES=off pour s'en passer" + ] + }, + { + "m": 7, + "c": 68, + "h": 0, + "e": 0, + "args": [ + "/app/llama-server", + "-m", + "/m/dense.gguf", + "-c", + "32768", + "-b", + "2048", + "-ub", + "512", + "--host", + "0.0.0.0", + "--port", + "8080", + "--parallel", + "1", + "-ngl", + "auto" + ], + "env": { + "CUDA_SCALE_LAUNCH_QUEUES": "4x" + }, + "notes": [ + "CUDA_SCALE_LAUNCH_QUEUES=4x (2 GPU, parallélisme pipeline possible) — CUDA_LAUNCH_QUEUES=off pour s'en passer", + "CTX_CHECKPOINTS=beaucoup illisible (un nombre de points de reprise par slot, ≥ 0) : ignoré" + ] + }, + { + "m": 7, + "c": 69, + "h": 0, + "e": 0, + "args": [ + "/app/llama-server", + "-m", + "/m/dense.gguf", + "-c", + "32768", + "-b", + "2048", + "-ub", + "512", + "--host", + "0.0.0.0", + "--port", + "8080", + "--parallel", + "1", + "-ngl", + "auto", + "--checkpoint-min-step", + "1024" + ], + "env": { + "CUDA_SCALE_LAUNCH_QUEUES": "4x" + }, + "notes": [ + "CUDA_SCALE_LAUNCH_QUEUES=4x (2 GPU, parallélisme pipeline possible) — CUDA_LAUNCH_QUEUES=off pour s'en passer" + ] + }, + { + "m": 7, + "c": 70, + "h": 0, + "e": 0, + "args": [ + "/app/llama-server", + "-m", + "/m/dense.gguf", + "-c", + "32768", + "-b", + "2048", + "-ub", + "512", + "--host", + "0.0.0.0", + "--port", + "8080", + "--parallel", + "1", + "-ngl", + "auto" + ], + "env": { + "CUDA_SCALE_LAUNCH_QUEUES": "4x" + }, + "notes": [ + "CUDA_SCALE_LAUNCH_QUEUES=4x (2 GPU, parallélisme pipeline possible) — CUDA_LAUNCH_QUEUES=off pour s'en passer", + "CKPT_MIN_STEP=0 refusé (un nombre de jetons > 0 ; 0 laisserait le moteur jeter le point de reprise du début de la conversation) : défaut du moteur" + ] + }, + { + "m": 7, + "c": 71, + "h": 0, + "e": 0, + "args": [ + "/app/llama-server", + "-m", + "/m/dense.gguf", + "-c", + "32768", + "-b", + "2048", + "-ub", + "512", + "--host", + "0.0.0.0", + "--port", + "8080", + "--parallel", + "1", + "-ngl", + "auto" + ], + "env": { + "CUDA_SCALE_LAUNCH_QUEUES": "4x" + }, + "notes": [ + "CUDA_SCALE_LAUNCH_QUEUES=4x (2 GPU, parallélisme pipeline possible) — CUDA_LAUNCH_QUEUES=off pour s'en passer", + "SLOT_PERSIST refusé (dossier des slots indisponible) : l'état du slot ne sera pas gardé à la bascule de preset" + ] + }, + { + "m": 7, + "c": 72, + "h": 0, + "e": 0, + "args": [ + "/app/llama-server", + "-m", + "/m/dense.gguf", + "-c", + "32768", + "-b", + "2048", + "-ub", + "512", + "--host", + "127.0.0.1", + "--port", + "8080", + "--parallel", + "1", + "-ngl", + "auto" + ], + "env": { + "CUDA_SCALE_LAUNCH_QUEUES": "4x" + }, + "notes": [ + "CUDA_SCALE_LAUNCH_QUEUES=4x (2 GPU, parallélisme pipeline possible) — CUDA_LAUNCH_QUEUES=off pour s'en passer", + "SLOT_PERSIST refusé (dossier des slots indisponible) : l'état du slot ne sera pas gardé à la bascule de preset" + ] + }, + { + "m": 7, + "c": 73, + "h": 0, + "e": 0, + "args": [ + "/app/llama-server", + "-m", + "/m/dense.gguf", + "-c", + "32768", + "-b", + "2048", + "-ub", + "512", + "--host", + "127.0.0.1", + "--port", + "8080", + "--parallel", + "1", + "-ngl", + "auto" + ], + "env": { + "CUDA_SCALE_LAUNCH_QUEUES": "4x" + }, + "notes": [ + "CUDA_SCALE_LAUNCH_QUEUES=4x (2 GPU, parallélisme pipeline possible) — CUDA_LAUNCH_QUEUES=off pour s'en passer", + "SLOT_PERSIST refusé (dossier des slots indisponible) : l'état du slot ne sera pas gardé à la bascule de preset", + "SPEC=mtp : pas de tête MTP dans ce fichier (publiée à part ? MODEL_DRAFT=mtp-….gguf) — sans décodage spéculatif" + ] + }, + { + "m": 7, + "c": 74, + "h": 0, + "e": 0, + "args": [ + "/app/llama-server", + "-m", + "/m/dense.gguf", + "-c", + "32768", + "-b", + "2048", + "-ub", + "512", + "--host", + "0.0.0.0", + "--port", + "8080", + "--parallel", + "1", + "-ngl", + "auto", + "--load-mode", + "none" + ], + "env": { + "CUDA_SCALE_LAUNCH_QUEUES": "4x" + }, + "notes": [ + "CUDA_SCALE_LAUNCH_QUEUES=4x (2 GPU, parallélisme pipeline possible) — CUDA_LAUNCH_QUEUES=off pour s'en passer" + ] + }, + { + "m": 7, + "c": 75, + "h": 0, + "e": 0, + "args": [ + "/app/llama-server", + "-m", + "/m/dense.gguf", + "-c", + "32768", + "-b", + "2048", + "-ub", + "512", + "--host", + "0.0.0.0", + "--port", + "8080", + "--parallel", + "1", + "-ngl", + "auto", + "--load-mode", + "mlock" + ], + "env": { + "CUDA_SCALE_LAUNCH_QUEUES": "4x" + }, + "notes": [ + "CUDA_SCALE_LAUNCH_QUEUES=4x (2 GPU, parallélisme pipeline possible) — CUDA_LAUNCH_QUEUES=off pour s'en passer" + ] + }, + { + "m": 7, + "c": 76, + "h": 0, + "e": 0, + "args": [ + "/app/llama-server", + "-m", + "/m/dense.gguf", + "-c", + "32768", + "-b", + "2048", + "-ub", + "512", + "--host", + "0.0.0.0", + "--port", + "8080", + "--parallel", + "1", + "-ngl", + "auto", + "--load-mode", + "mmap+mlock" + ], + "env": { + "CUDA_SCALE_LAUNCH_QUEUES": "4x" + }, + "notes": [ + "CUDA_SCALE_LAUNCH_QUEUES=4x (2 GPU, parallélisme pipeline possible) — CUDA_LAUNCH_QUEUES=off pour s'en passer" + ] + }, + { + "m": 7, + "c": 77, + "h": 0, + "e": 0, + "args": [ + "/app/llama-server", + "-m", + "/m/dense.gguf", + "-c", + "32768", + "-b", + "2048", + "-ub", + "512", + "--host", + "0.0.0.0", + "--port", + "8080", + "--parallel", + "1", + "-ngl", + "auto", + "--load-mode", + "none" + ], + "env": { + "CUDA_SCALE_LAUNCH_QUEUES": "4x" + }, + "notes": [ + "CUDA_SCALE_LAUNCH_QUEUES=4x (2 GPU, parallélisme pipeline possible) — CUDA_LAUNCH_QUEUES=off pour s'en passer" + ] + }, + { + "m": 7, + "c": 78, + "h": 0, + "e": 0, + "args": [ + "/app/llama-server", + "-m", + "/m/dense.gguf", + "-c", + "32768", + "-b", + "2048", + "-ub", + "512", + "--host", + "0.0.0.0", + "--port", + "8080", + "--parallel", + "1", + "-ngl", + "auto", + "--load-mode", + "mmap" + ], + "env": { + "CUDA_SCALE_LAUNCH_QUEUES": "4x" + }, + "notes": [ + "CUDA_SCALE_LAUNCH_QUEUES=4x (2 GPU, parallélisme pipeline possible) — CUDA_LAUNCH_QUEUES=off pour s'en passer" + ] + }, + { + "m": 7, + "c": 79, + "h": 0, + "e": 0, + "args": [ + "/app/llama-server", + "-m", + "/m/dense.gguf", + "-c", + "32768", + "-b", + "2048", + "-ub", + "512", + "--host", + "0.0.0.0", + "--port", + "8080", + "--parallel", + "1", + "-ngl", + "auto", + "--load-mode", + "dio" + ], + "env": { + "CUDA_SCALE_LAUNCH_QUEUES": "4x" + }, + "notes": [ + "CUDA_SCALE_LAUNCH_QUEUES=4x (2 GPU, parallélisme pipeline possible) — CUDA_LAUNCH_QUEUES=off pour s'en passer" + ] + }, + { + "m": 7, + "c": 80, + "h": 0, + "e": 0, + "args": [ + "/app/llama-server", + "-m", + "/m/dense.gguf", + "-c", + "32768", + "-b", + "2048", + "-ub", + "512", + "--host", + "0.0.0.0", + "--port", + "8080", + "--parallel", + "1", + "-ngl", + "auto", + "--load-mode=mlock" + ], + "env": { + "CUDA_SCALE_LAUNCH_QUEUES": "4x" + }, + "notes": [ + "CUDA_SCALE_LAUNCH_QUEUES=4x (2 GPU, parallélisme pipeline possible) — CUDA_LAUNCH_QUEUES=off pour s'en passer" + ] + }, + { + "m": 7, + "c": 81, + "h": 0, + "e": 0, + "args": [ + "/app/llama-server", + "-m", + "/m/dense.gguf", + "-c", + "32768", + "-b", + "2048", + "-ub", + "512", + "--host", + "0.0.0.0", + "--port", + "8080", + "-ngl", + "30", + "--parallel", + "4" + ], + "env": {}, + "notes": null + }, + { + "m": 7, + "c": 82, + "h": 0, + "e": 0, + "args": [ + "/app/llama-server", + "-m", + "/m/dense.gguf", + "-c", + "32768", + "-b", + "2048", + "-ub", + "512", + "--host", + "0.0.0.0", + "--port", + "8080", + "--parallel", + "1", + "-ngl", + "auto", + "-t", + "6", + "-tb", + "3" + ], + "env": { + "CUDA_SCALE_LAUNCH_QUEUES": "4x" + }, + "notes": [ + "CUDA_SCALE_LAUNCH_QUEUES=4x (2 GPU, parallélisme pipeline possible) — CUDA_LAUNCH_QUEUES=off pour s'en passer" + ] + }, + { + "m": 7, + "c": 83, + "h": 0, + "e": 0, + "args": [ + "/app/llama-server", + "-m", + "/m/dense.gguf", + "-c", + "32768", + "-tb", + "0", + "-b", + "2048", + "-ub", + "512", + "--host", + "0.0.0.0", + "--port", + "8080", + "--parallel", + "1", + "-ngl", + "auto", + "-Cb", + "0xff", + "--poll-batch", + "1" + ], + "env": { + "CUDA_SCALE_LAUNCH_QUEUES": "4x" + }, + "notes": [ + "CUDA_SCALE_LAUNCH_QUEUES=4x (2 GPU, parallélisme pipeline possible) — CUDA_LAUNCH_QUEUES=off pour s'en passer", + "EXTRA_ARGS règle le CPU du batch (-Cb, --poll-batch…) : -tb 0 posé pour que llama.cpp le garde (tous les threads logiques) ; THREADS_BATCH pour choisir le nombre" + ] + }, + { + "m": 7, + "c": 84, + "h": 0, + "e": 0, + "args": [ + "/app/llama-server", + "-m", + "/m/dense.gguf", + "-c", + "32768", + "-t", + "6", + "-tb", + "6", + "-b", + "2048", + "-ub", + "512", + "--host", + "0.0.0.0", + "--port", + "8080", + "--parallel", + "1", + "-ngl", + "auto", + "--prio-batch", + "2" + ], + "env": { + "CUDA_SCALE_LAUNCH_QUEUES": "4x" + }, + "notes": [ + "CUDA_SCALE_LAUNCH_QUEUES=4x (2 GPU, parallélisme pipeline possible) — CUDA_LAUNCH_QUEUES=off pour s'en passer" + ] + }, + { + "m": 7, + "c": 85, + "h": 0, + "e": 0, + "args": [ + "/app/llama-server", + "-m", + "/m/dense.gguf", + "-c", + "32768", + "-b", + "2048", + "-ub", + "512", + "--host", + "0.0.0.0", + "--port", + "8080", + "--parallel", + "1", + "-ngl", + "auto", + "--cache-reuse", + "256", + "--context-shift" + ], + "env": { + "CUDA_SCALE_LAUNCH_QUEUES": "4x" + }, + "notes": [ + "CUDA_SCALE_LAUNCH_QUEUES=4x (2 GPU, parallélisme pipeline possible) — CUDA_LAUNCH_QUEUES=off pour s'en passer", + "avertissement : --context-shift (EXTRA_ARGS) — contexte plein, le moteur jette des jetons anciens et le modèle perd une partie de la conversation sans le savoir.", + "avertissement : --cache-reuse 256 (EXTRA_ARGS) — réutilise des morceaux de cache calculés sous un autre préfixe : les sorties ne sont plus exactement celles d'un calcul complet." + ] + }, + { + "m": 7, + "c": 86, + "h": 0, + "e": 0, + "args": [ + "/app/llama-server", + "-m", + "/m/dense.gguf", + "-c", + "32768", + "-b", + "2048", + "-ub", + "512", + "--host", + "0.0.0.0", + "--port", + "8080", + "--parallel", + "1", + "-ngl", + "auto", + "-ctk", + "q8_0", + "-ctv", + "q8_0" + ], + "env": { + "CUDA_SCALE_LAUNCH_QUEUES": "4x" + }, + "notes": [ + "CUDA_SCALE_LAUNCH_QUEUES=4x (2 GPU, parallélisme pipeline possible) — CUDA_LAUNCH_QUEUES=off pour s'en passer", + "cache KV q8_0/q8_0 (défini par EXTRA_ARGS) : modifie légèrement les sorties par rapport à f16 — choix de l'utilisateur, laissé tel quel." + ] + }, + { + "m": 7, + "c": 87, + "h": 0, + "e": 0, + "args": [ + "/app/llama-server", + "-m", + "/m/dense.gguf", + "-c", + "32768", + "-b", + "2048", + "-ub", + "512", + "--host", + "0.0.0.0", + "--port", + "8080", + "--parallel", + "1", + "-ngl", + "auto", + "-ot", + "exps=CPU" + ], + "env": {}, + "notes": null + }, + { + "m": 7, + "c": 88, + "h": 0, + "e": 0, + "args": [ + "/app/llama-server", + "-m", + "/m/dense.gguf", + "-c", + "32768", + "-b", + "2048", + "-ub", + "512", + "--host", + "0.0.0.0", + "--port", + "8080", + "--parallel", + "1", + "-ngl", + "auto", + "-ot", + "blk\\.(1|2)\\.ffn_.*_exps=CPU", + "-ub", + "512" + ], + "env": {}, + "notes": null + }, + { + "m": 7, + "c": 89, + "h": 0, + "e": 0, + "args": [ + "/app/llama-server", + "-m", + "/m/dense.gguf", + "-c", + "32768", + "-b", + "2048", + "-ub", + "512", + "--host", + "0.0.0.0", + "--port", + "8080", + "--parallel", + "1", + "-ngl", + "auto", + "--n-cpu-moe", + "20" + ], + "env": {}, + "notes": null + }, + { + "m": 7, + "c": 90, + "h": 0, + "e": 0, + "args": [ + "/app/llama-server", + "-m", + "/m/dense.gguf", + "-c", + "32768", + "-b", + "2048", + "-ub", + "512", + "--host", + "0.0.0.0", + "--port", + "8080", + "--parallel", + "1", + "-ngl", + "auto", + "--cpu-moe" + ], + "env": {}, + "notes": null + }, + { + "m": 7, + "c": 91, + "h": 0, + "e": 0, + "args": [ + "/app/llama-server", + "-m", + "/m/dense.gguf", + "-c", + "32768", + "-b", + "2048", + "-ub", + "512", + "--host", + "0.0.0.0", + "--port", + "8080", + "--parallel", + "1", + "-ngl", + "auto", + "-sm", + "row" + ], + "env": {}, + "notes": null + }, + { + "m": 7, + "c": 92, + "h": 0, + "e": 0, + "args": [ + "/app/llama-server", + "-m", + "/m/dense.gguf", + "-c", + "32768", + "-b", + "2048", + "-ub", + "512", + "--host", + "0.0.0.0", + "--port", + "8080", + "--parallel", + "1", + "-ngl", + "auto", + "-ts", + "1,1" + ], + "env": { + "CUDA_SCALE_LAUNCH_QUEUES": "4x" + }, + "notes": [ + "CUDA_SCALE_LAUNCH_QUEUES=4x (2 GPU, parallélisme pipeline possible) — CUDA_LAUNCH_QUEUES=off pour s'en passer" + ] + }, + { + "m": 7, + "c": 93, + "h": 0, + "e": 0, + "args": [ + "/app/llama-server", + "-m", + "/m/dense.gguf", + "-c", + "32768", + "-b", + "2048", + "-ub", + "512", + "--host", + "0.0.0.0", + "--port", + "8080", + "--parallel", + "1", + "-ngl", + "auto", + "-dev", + "CUDA0,CUDA1" + ], + "env": { + "CUDA_SCALE_LAUNCH_QUEUES": "4x" + }, + "notes": [ + "CUDA_SCALE_LAUNCH_QUEUES=4x (2 GPU, parallélisme pipeline possible) — CUDA_LAUNCH_QUEUES=off pour s'en passer" + ] + }, + { + "m": 7, + "c": 94, + "h": 0, + "e": 0, + "args": [ + "/app/llama-server", + "-m", + "/m/dense.gguf", + "-c", + "32768", + "-b", + "2048", + "-ub", + "512", + "--host", + "0.0.0.0", + "--port", + "8080", + "--parallel", + "1", + "-ngl", + "auto", + "-dev", + "Vulkan0" + ], + "env": {}, + "notes": null + }, + { + "m": 7, + "c": 95, + "h": 0, + "e": 0, + "args": [ + "/app/llama-server", + "-m", + "/m/dense.gguf", + "-c", + "32768", + "-b", + "2048", + "-ub", + "512", + "--host", + "0.0.0.0", + "--port", + "8080", + "--parallel", + "1", + "-ngl", + "auto", + "--fit", + "off" + ], + "env": { + "CUDA_SCALE_LAUNCH_QUEUES": "4x" + }, + "notes": [ + "CUDA_SCALE_LAUNCH_QUEUES=4x (2 GPU, parallélisme pipeline possible) — CUDA_LAUNCH_QUEUES=off pour s'en passer" + ] + }, + { + "m": 7, + "c": 96, + "h": 0, + "e": 0, + "args": [ + "/app/llama-server", + "-m", + "/m/dense.gguf", + "-c", + "32768", + "-b", + "2048", + "-ub", + "512", + "--host", + "0.0.0.0", + "--port", + "8080", + "--parallel", + "1", + "-ngl", + "auto", + "--mmproj", + "/m/x.gguf" + ], + "env": { + "CUDA_SCALE_LAUNCH_QUEUES": "4x" + }, + "notes": [ + "CUDA_SCALE_LAUNCH_QUEUES=4x (2 GPU, parallélisme pipeline possible) — CUDA_LAUNCH_QUEUES=off pour s'en passer" + ] + }, + { + "m": 7, + "c": 97, + "h": 0, + "e": 0, + "args": [ + "/app/llama-server", + "-m", + "/m/dense.gguf", + "-c", + "32768", + "-b", + "2048", + "-ub", + "512", + "--host", + "0.0.0.0", + "--port", + "8080", + "--parallel", + "1", + "-ngl", + "auto", + "--spec-synth-len", + "4" + ], + "env": { + "CUDA_SCALE_LAUNCH_QUEUES": "4x" + }, + "notes": [ + "CUDA_SCALE_LAUNCH_QUEUES=4x (2 GPU, parallélisme pipeline possible) — CUDA_LAUNCH_QUEUES=off pour s'en passer", + "avertissement : --spec-synth-len — le moteur accepte des jetons du brouillon AU HASARD, sans vérification : les réponses ne sont plus celles du modèle. Réservé aux mesures, retire-le." + ] + }, + { + "m": 7, + "c": 98, + "h": 0, + "e": 0, + "args": [ + "/app/llama-server", + "-m", + "/m/dense.gguf", + "-c", + "32768", + "-b", + "2048", + "-ub", + "512", + "--host", + "0.0.0.0", + "--port", + "8080", + "--parallel", + "1", + "-ngl", + "auto", + "--slot-save-path", + "/x" + ], + "env": { + "CUDA_SCALE_LAUNCH_QUEUES": "4x" + }, + "notes": [ + "CUDA_SCALE_LAUNCH_QUEUES=4x (2 GPU, parallélisme pipeline possible) — CUDA_LAUNCH_QUEUES=off pour s'en passer" + ] + }, + { + "m": 7, + "c": 99, + "h": 0, + "e": 0, + "args": [ + "/app/llama-server", + "-m", + "/m/dense.gguf", + "-c", + "32768", + "-b", + "2048", + "-ub", + "512", + "--host", + "0.0.0.0", + "--port", + "8080", + "--parallel", + "1", + "-ngl", + "auto", + "-c", + "0" + ], + "env": { + "CUDA_SCALE_LAUNCH_QUEUES": "4x" + }, + "notes": [ + "CUDA_SCALE_LAUNCH_QUEUES=4x (2 GPU, parallélisme pipeline possible) — CUDA_LAUNCH_QUEUES=off pour s'en passer" + ] + }, + { + "m": 7, + "c": 100, + "h": 0, + "e": 0, + "args": [ + "/app/llama-server", + "-m", + "/m/dense.gguf", + "-c", + "32768", + "-b", + "2048", + "-ub", + "512", + "--host", + "0.0.0.0", + "--port", + "8080", + "--parallel", + "1", + "-ngl", + "auto", + "--host", + "127.0.0.1", + "--port", + "9000" + ], + "env": { + "CUDA_SCALE_LAUNCH_QUEUES": "4x" + }, + "notes": [ + "CUDA_SCALE_LAUNCH_QUEUES=4x (2 GPU, parallélisme pipeline possible) — CUDA_LAUNCH_QUEUES=off pour s'en passer" + ] + }, + { + "m": 7, + "c": 101, + "h": 0, + "e": 0, + "args": [ + "/app/llama-server", + "-m", + "/m/dense.gguf", + "-c", + "32768", + "-b", + "2048", + "-ub", + "512", + "--host", + "0.0.0.0", + "--port", + "8080", + "--parallel", + "1", + "-ngl", + "auto", + "--mirostat", + "2" + ], + "env": { + "CUDA_SCALE_LAUNCH_QUEUES": "4x" + }, + "notes": [ + "CUDA_SCALE_LAUNCH_QUEUES=4x (2 GPU, parallélisme pipeline possible) — CUDA_LAUNCH_QUEUES=off pour s'en passer", + "SPEC=mtp : pas de tête MTP dans ce fichier (publiée à part ? MODEL_DRAFT=mtp-….gguf) — sans décodage spéculatif" + ] + }, + { + "m": 7, + "c": 102, + "h": 0, + "e": 0, + "args": [ + "/app/llama-server", + "-m", + "/m/dense.gguf", + "-c", + "32768", + "-b", + "2048", + "-ub", + "512", + "--host", + "0.0.0.0", + "--port", + "8080", + "--parallel", + "1", + "-ngl", + "auto", + "--chat-template-kwargs", + "{\"preserve_reasoning\":true}" + ], + "env": { + "CUDA_SCALE_LAUNCH_QUEUES": "4x" + }, + "notes": [ + "CUDA_SCALE_LAUNCH_QUEUES=4x (2 GPU, parallélisme pipeline possible) — CUDA_LAUNCH_QUEUES=off pour s'en passer", + "--chat-template-kwargs fixe déjà preserve_reasoning : REASONING_PRESERVE ignoré" + ] + }, + { + "m": 7, + "c": 103, + "h": 0, + "e": 0, + "args": [ + "/app/llama-server", + "-m", + "/m/dense.gguf", + "-c", + "32768", + "-b", + "2048", + "-ub", + "512", + "--host", + "0.0.0.0", + "--port", + "8080", + "--parallel", + "1", + "-ngl", + "auto", + "--spec-type", + "ngram-mod" + ], + "env": { + "CUDA_SCALE_LAUNCH_QUEUES": "4x" + }, + "notes": [ + "CUDA_SCALE_LAUNCH_QUEUES=4x (2 GPU, parallélisme pipeline possible) — CUDA_LAUNCH_QUEUES=off pour s'en passer", + "décodage spéculatif déjà réglé dans EXTRA_ARGS ou LLAMA_ARG_* : SPEC ignoré" + ] + }, + { + "m": 7, + "c": 104, + "h": 0, + "e": 0, + "args": [ + "/app/llama-server", + "-m", + "/m/dense.gguf", + "-c", + "32768", + "-b", + "2048", + "-ub", + "512", + "--host", + "0.0.0.0", + "--port", + "8080", + "--parallel", + "1", + "-ngl", + "auto", + "--spec-draft-n-max", + "8" + ], + "env": { + "CUDA_SCALE_LAUNCH_QUEUES": "4x" + }, + "notes": [ + "CUDA_SCALE_LAUNCH_QUEUES=4x (2 GPU, parallélisme pipeline possible) — CUDA_LAUNCH_QUEUES=off pour s'en passer", + "SPEC=ngram : --spec-draft-n-max dans EXTRA_ARGS (brouillon réglé à la main) — sans décodage spéculatif" + ] + }, + { + "m": 7, + "c": 105, + "h": 0, + "e": 0, + "args": [ + "/app/llama-server", + "-m", + "/m/dense.gguf", + "-c", + "32768", + "-b", + "2048", + "-ub", + "512", + "--host", + "0.0.0.0", + "--port", + "8080", + "--parallel", + "1", + "-ngl", + "auto", + "-ot", + "attn=CUDA1" + ], + "env": {}, + "notes": null + }, + { + "m": 7, + "c": 106, + "h": 0, + "e": 0, + "args": [ + "/app/llama-server", + "-m", + "/m/dense.gguf", + "-c", + "32768", + "-b", + "2048", + "-ub", + "512", + "--host", + "0.0.0.0", + "--port", + "8080", + "--parallel", + "1", + "-ngl", + "auto", + "-fa", + "off" + ], + "env": { + "CUDA_SCALE_LAUNCH_QUEUES": "4x" + }, + "notes": [ + "SPLIT_MODE=tensor refusé (Flash Attention coupée (-fa off) : le mode tensor l'exige) : découpe par couches, comme sans la clé", + "CUDA_SCALE_LAUNCH_QUEUES=4x (2 GPU, parallélisme pipeline possible) — CUDA_LAUNCH_QUEUES=off pour s'en passer" + ] + }, + { + "m": 7, + "c": 107, + "h": 0, + "e": 0, + "args": [ + "/app/llama-server", + "-m", + "/m/dense.gguf", + "-c", + "32768", + "-b", + "2048", + "-ub", + "512", + "--host", + "0.0.0.0", + "--port", + "8080", + "--parallel", + "1", + "-ngl", + "auto", + "-nkvo" + ], + "env": {}, + "notes": null + }, + { + "m": 7, + "c": 108, + "h": 0, + "e": 0, + "args": [ + "/app/llama-server", + "-m", + "/m/dense.gguf", + "-c", + "32768", + "-b", + "2048", + "-ub", + "512", + "--host", + "0.0.0.0", + "--port", + "8080", + "--parallel", + "1", + "-ngl", + "auto", + "--no-context-shift" + ], + "env": { + "CUDA_SCALE_LAUNCH_QUEUES": "4x" + }, + "notes": [ + "CUDA_SCALE_LAUNCH_QUEUES=4x (2 GPU, parallélisme pipeline possible) — CUDA_LAUNCH_QUEUES=off pour s'en passer" + ] + }, + { + "m": 7, + "c": 109, + "h": 0, + "e": 0, + "args": [ + "/app/llama-server", + "-m", + "/m/dense.gguf", + "-c", + "32768", + "-b", + "2048", + "-ub", + "512", + "--host", + "0.0.0.0", + "--port", + "8080", + "-ngl", + "auto", + "-c", + "8192", + "-np", + "2" + ], + "env": { + "CUDA_SCALE_LAUNCH_QUEUES": "4x" + }, + "notes": [ + "CUDA_SCALE_LAUNCH_QUEUES=4x (2 GPU, parallélisme pipeline possible) — CUDA_LAUNCH_QUEUES=off pour s'en passer", + "SIDE_SLOT refusé (-c est réglé dans EXTRA_ARGS) : un seul slot, comme sans la clé" + ] + }, + { + "m": 7, + "c": 110, + "h": 0, + "e": 0, + "args": [ + "/app/llama-server", + "-m", + "/m/dense.gguf", + "-c", + "32768", + "-b", + "2048", + "-ub", + "512", + "--host", + "0.0.0.0", + "--port", + "8080", + "--parallel", + "1", + "-ngl", + "auto" + ], + "env": { + "CUDA_SCALE_LAUNCH_QUEUES": "4x" + }, + "notes": [ + "SPLIT_MODE=tensor refusé (preset externe) : découpe par couches, comme sans la clé", + "CUDA_SCALE_LAUNCH_QUEUES=4x (2 GPU, parallélisme pipeline possible) — CUDA_LAUNCH_QUEUES=off pour s'en passer", + "SIDE_SLOT refusé (preset externe) : un seul slot, comme sans la clé" + ] + }, + { + "m": 7, + "c": 111, + "h": 0, + "e": 0, + "args": [ + "/app/llama-server", + "-m", + "/m/dense.gguf", + "-c", + "40000", + "-b", + "2048", + "-ub", + "512", + "--host", + "0.0.0.0", + "--port", + "8080", + "--parallel", + "1", + "-ngl", + "auto", + "-ctk", + "q4_0", + "-ctv", + "q4_0" + ], + "env": { + "CUDA_SCALE_LAUNCH_QUEUES": "4x" + }, + "notes": [ + "CUDA_SCALE_LAUNCH_QUEUES=4x (2 GPU, parallélisme pipeline possible) — CUDA_LAUNCH_QUEUES=off pour s'en passer", + "NGL=999 (sentinelle « toutes les couches ») → -ngl auto : ce moteur mesure lui-même la VRAM libre. NGL=all pour forcer l'ancien comportement.", + "cache KV q4_0/q4_0 (KV_TYPE) : perte mesurable par rapport à f16 — choix de l'utilisateur, laissé tel quel." + ] + }, + { + "m": 8, + "c": 0, + "h": 0, + "e": 0, + "args": [ + "/app/llama-server", + "-m", + "/m/dense.gguf", + "-c", + "32768", + "-b", + "2048", + "-ub", + "512", + "--host", + "0.0.0.0", + "--port", + "8080", + "--parallel", + "1", + "-ngl", + "auto" + ], + "env": {}, + "notes": null + }, + { + "m": 8, + "c": 1, + "h": 0, + "e": 0, + "args": [ + "/app/llama-server", + "-m", + "/m/dense.gguf", + "-c", + "65536", + "-b", + "4096", + "-ub", + "2048", + "--host", + "127.0.0.1", + "--port", + "8081", + "--parallel", + "1", + "-ngl", + "auto" + ], + "env": {}, + "notes": [ + "NGL=999 (sentinelle « toutes les couches ») → -ngl auto : ce moteur mesure lui-même la VRAM libre. NGL=all pour forcer l'ancien comportement." + ] + }, + { + "m": 8, + "c": 2, + "h": 0, + "e": 0, + "args": [ + "/app/llama-server", + "-m", + "/m/dense.gguf", + "-c", + "32768", + "-b", + "2048", + "-ub", + "512", + "--host", + "0.0.0.0", + "--port", + "8080", + "--parallel", + "1" + ], + "env": {}, + "notes": null + }, + { + "m": 8, + "c": 3, + "h": 0, + "e": 0, + "args": [ + "/app/llama-server", + "-m", + "/m/dense.gguf", + "-c", + "32768", + "-b", + "2048", + "-ub", + "512", + "--host", + "0.0.0.0", + "--port", + "8080", + "--parallel", + "1", + "-ngl", + "40" + ], + "env": {}, + "notes": null + }, + { + "m": 8, + "c": 4, + "h": 0, + "e": 0, + "args": [ + "/app/llama-server", + "-m", + "/m/dense.gguf", + "-c", + "32768", + "-b", + "2048", + "-ub", + "512", + "--host", + "0.0.0.0", + "--port", + "8080", + "--parallel", + "1", + "-ngl", + "all" + ], + "env": {}, + "notes": null + }, + { + "m": 8, + "c": 5, + "h": 0, + "e": 0, + "args": [ + "/app/llama-server", + "-m", + "/m/dense.gguf", + "-c", + "32768", + "-b", + "2048", + "-ub", + "512", + "--host", + "0.0.0.0", + "--port", + "8080", + "--parallel", + "1", + "-ngl", + "auto" + ], + "env": {}, + "notes": [ + "NGL=999 (sentinelle « toutes les couches ») → -ngl auto : ce moteur mesure lui-même la VRAM libre. NGL=all pour forcer l'ancien comportement." + ] + }, + { + "m": 8, + "c": 6, + "h": 0, + "e": 0, + "args": [ + "/app/llama-server", + "-m", + "/m/dense.gguf", + "-c", + "32768", + "-t", + "8", + "-tb", + "4", + "-b", + "2048", + "-ub", + "512", + "--host", + "0.0.0.0", + "--port", + "8080", + "--parallel", + "1", + "-ngl", + "auto" + ], + "env": {}, + "notes": null + }, + { + "m": 8, + "c": 7, + "h": 0, + "e": 0, + "args": [ + "/app/llama-server", + "-m", + "/m/dense.gguf", + "-c", + "32768", + "-b", + "2048", + "-ub", + "512", + "--host", + "0.0.0.0", + "--port", + "8080", + "--parallel", + "1", + "-ngl", + "auto" + ], + "env": {}, + "notes": [ + "THREADS=abc ignoré (pas un nombre de threads ≥ 0) → automatique", + "THREADS_BATCH=-2 ignoré (pas un nombre de threads ≥ 0) → automatique" + ] + }, + { + "m": 8, + "c": 8, + "h": 0, + "e": 0, + "args": [ + "/app/llama-server", + "-m", + "/m/dense.gguf", + "-c", + "32768", + "-b", + "2048", + "-ub", + "512", + "--host", + "0.0.0.0", + "--port", + "8080", + "--parallel", + "1", + "-ngl", + "auto" + ], + "env": {}, + "notes": null + }, + { + "m": 8, + "c": 9, + "h": 0, + "e": 0, + "args": [ + "/app/llama-server", + "-m", + "/m/dense.gguf", + "-c", + "32768", + "-b", + "2048", + "-ub", + "512", + "--host", + "0.0.0.0", + "--port", + "8080", + "--parallel", + "1", + "-ngl", + "auto", + "-ctk", + "q8_0", + "-ctv", + "q8_0" + ], + "env": {}, + "notes": [ + "cache KV q8_0/q8_0 (KV_TYPE) : modifie légèrement les sorties par rapport à f16 — choix de l'utilisateur, laissé tel quel." + ] + }, + { + "m": 8, + "c": 10, + "h": 0, + "e": 0, + "args": [ + "/app/llama-server", + "-m", + "/m/dense.gguf", + "-c", + "32768", + "-b", + "2048", + "-ub", + "512", + "--host", + "0.0.0.0", + "--port", + "8080", + "--parallel", + "1", + "-ngl", + "auto", + "-ctk", + "q4_0", + "-ctv", + "f16" + ], + "env": {}, + "notes": [ + "cache KV q4_0/f16 (KV_TYPE) : perte mesurable par rapport à f16 — choix de l'utilisateur, laissé tel quel." + ] + }, + { + "m": 8, + "c": 11, + "h": 0, + "e": 0, + "args": [ + "/app/llama-server", + "-m", + "/m/dense.gguf", + "-c", + "32768", + "-b", + "2048", + "-ub", + "512", + "--host", + "0.0.0.0", + "--port", + "8080", + "--parallel", + "1", + "-ngl", + "auto", + "-ctk", + "bf16", + "-ctv", + "bf16" + ], + "env": {}, + "notes": [ + "cache KV bf16/bf16 (KV_TYPE) : numérique différente de f16 (mantisse plus courte) — choix de l'utilisateur, laissé tel quel." + ] + }, + { + "m": 8, + "c": 12, + "h": 0, + "e": 0, + "args": [ + "/app/llama-server", + "-m", + "/m/dense.gguf", + "-c", + "32768", + "-b", + "2048", + "-ub", + "512", + "--host", + "0.0.0.0", + "--port", + "8080", + "--parallel", + "1", + "-ngl", + "auto", + "-ctk", + "q5_1", + "-ctv", + "q5_1" + ], + "env": {}, + "notes": [ + "cache KV q5_1/q5_1 (KV_TYPE) : modifie légèrement les sorties par rapport à f16 — choix de l'utilisateur, laissé tel quel." + ] + }, + { + "m": 8, + "c": 13, + "h": 0, + "e": 0, + "args": [ + "/app/llama-server", + "-m", + "/m/dense.gguf", + "-c", + "32768", + "-b", + "2048", + "-ub", + "512", + "--host", + "0.0.0.0", + "--port", + "8080", + "--parallel", + "1", + "-ngl", + "auto", + "--reasoning", + "on", + "--reasoning-budget", + "-1" + ], + "env": {}, + "notes": null + }, + { + "m": 8, + "c": 14, + "h": 0, + "e": 0, + "args": [ + "/app/llama-server", + "-m", + "/m/dense.gguf", + "-c", + "32768", + "-b", + "2048", + "-ub", + "512", + "--host", + "0.0.0.0", + "--port", + "8080", + "--parallel", + "1", + "-ngl", + "auto", + "--reasoning", + "off" + ], + "env": {}, + "notes": null + }, + { + "m": 8, + "c": 15, + "h": 0, + "e": 0, + "args": [ + "/app/llama-server", + "-m", + "/m/dense.gguf", + "-c", + "32768", + "-b", + "2048", + "-ub", + "512", + "--host", + "0.0.0.0", + "--port", + "8080", + "--parallel", + "1", + "-ngl", + "auto", + "--reasoning", + "auto", + "--reasoning-budget", + "2048" + ], + "env": {}, + "notes": null + }, + { + "m": 8, + "c": 16, + "h": 0, + "e": 0, + "args": [ + "/app/llama-server", + "-m", + "/m/dense.gguf", + "-c", + "32768", + "-b", + "2048", + "-ub", + "512", + "--host", + "0.0.0.0", + "--port", + "8080", + "--parallel", + "1", + "-ngl", + "auto", + "--reasoning", + "deepseek", + "--reasoning-budget", + "-1" + ], + "env": {}, + "notes": null + }, + { + "m": 8, + "c": 17, + "h": 0, + "e": 0, + "args": [ + "/app/llama-server", + "-m", + "/m/dense.gguf", + "-c", + "32768", + "-b", + "2048", + "-ub", + "512", + "--host", + "0.0.0.0", + "--port", + "8080", + "--parallel", + "1", + "-ngl", + "auto", + "--reasoning-preserve" + ], + "env": {}, + "notes": null + }, + { + "m": 8, + "c": 18, + "h": 0, + "e": 0, + "args": [ + "/app/llama-server", + "-m", + "/m/dense.gguf", + "-c", + "32768", + "-b", + "2048", + "-ub", + "512", + "--host", + "0.0.0.0", + "--port", + "8080", + "--parallel", + "1", + "-ngl", + "auto", + "--no-reasoning-preserve" + ], + "env": {}, + "notes": null + }, + { + "m": 8, + "c": 19, + "h": 0, + "e": 0, + "args": [ + "/app/llama-server", + "-m", + "/m/dense.gguf", + "-c", + "32768", + "-b", + "2048", + "-ub", + "512", + "--host", + "0.0.0.0", + "--port", + "8080", + "--parallel", + "1", + "-ngl", + "auto" + ], + "env": {}, + "notes": [ + "REASONING_PRESERVE=peut-être illisible (on ou off) : ignoré" + ] + }, + { + "m": 8, + "c": 20, + "h": 0, + "e": 0, + "args": [ + "/app/llama-server", + "-m", + "/m/dense.gguf", + "-c", + "32768", + "-b", + "2048", + "-ub", + "512", + "--host", + "0.0.0.0", + "--port", + "8080", + "--parallel", + "1", + "-ngl", + "auto" + ], + "env": {}, + "notes": null + }, + { + "m": 8, + "c": 21, + "h": 0, + "e": 0, + "args": [ + "/app/llama-server", + "-m", + "/m/dense.gguf", + "-c", + "32768", + "-b", + "2048", + "-ub", + "512", + "--host", + "0.0.0.0", + "--port", + "8080", + "--parallel", + "1", + "-ngl", + "auto", + "--cache-ram", + "4096" + ], + "env": {}, + "notes": null + }, + { + "m": 8, + "c": 22, + "h": 0, + "e": 0, + "args": [ + "/app/llama-server", + "-m", + "/m/dense.gguf", + "-c", + "32768", + "-b", + "2048", + "-ub", + "512", + "--host", + "0.0.0.0", + "--port", + "8080", + "--parallel", + "1", + "-ngl", + "auto", + "--cache-ram", + "0" + ], + "env": {}, + "notes": null + }, + { + "m": 8, + "c": 23, + "h": 0, + "e": 0, + "args": [ + "/app/llama-server", + "-m", + "/m/dense.gguf", + "-c", + "32768", + "-b", + "2048", + "-ub", + "512", + "--host", + "0.0.0.0", + "--port", + "8080", + "--parallel", + "1", + "-ngl", + "auto", + "--cache-ram", + "-1" + ], + "env": {}, + "notes": null + }, + { + "m": 8, + "c": 24, + "h": 0, + "e": 0, + "args": [ + "/app/llama-server", + "-m", + "/m/dense.gguf", + "-c", + "32768", + "-b", + "2048", + "-ub", + "512", + "--host", + "0.0.0.0", + "--port", + "8080", + "--parallel", + "1", + "-ngl", + "auto" + ], + "env": {}, + "notes": [ + "CACHE_RAM=beaucoup illisible (un nombre de Mio, -1, 0 ou auto) : ignoré" + ] + }, + { + "m": 8, + "c": 25, + "h": 0, + "e": 0, + "args": [ + "/app/llama-server", + "-m", + "/m/dense.gguf", + "-c", + "32768", + "-b", + "2048", + "-ub", + "512", + "--host", + "0.0.0.0", + "--port", + "8080", + "--parallel", + "1", + "-ngl", + "auto" + ], + "env": {}, + "notes": null + }, + { + "m": 8, + "c": 26, + "h": 0, + "e": 0, + "args": [ + "/app/llama-server", + "-m", + "/m/dense.gguf", + "-c", + "32768", + "-b", + "2048", + "-ub", + "512", + "--host", + "0.0.0.0", + "--port", + "8080", + "--parallel", + "1", + "-ngl", + "auto", + "--cache-ram", + "0" + ], + "env": {}, + "notes": null + }, + { + "m": 8, + "c": 27, + "h": 0, + "e": 0, + "args": [ + "/app/llama-server", + "-m", + "/m/dense.gguf", + "-c", + "32768", + "-b", + "2048", + "-ub", + "512", + "--host", + "0.0.0.0", + "--port", + "8080", + "--parallel", + "1", + "-ngl", + "auto" + ], + "env": {}, + "notes": [ + "SIDE_SLOT refusé (VRAM insuffisante : modèle + 2 × 8192 Mio d'état dépassent 90 % de 16311 Mio) : un seul slot, comme sans la clé" + ] + }, + { + "m": 8, + "c": 28, + "h": 0, + "e": 0, + "args": [ + "/app/llama-server", + "-m", + "/m/dense.gguf", + "-c", + "32768", + "-b", + "2048", + "-ub", + "512", + "--host", + "0.0.0.0", + "--port", + "8080", + "--parallel", + "3", + "-ngl", + "auto" + ], + "env": {}, + "notes": [ + "SIDE_SLOT refusé (PARALLEL=3 (SIDE_SLOT ouvre exactement 2 slots)) : un seul slot, comme sans la clé" + ] + }, + { + "m": 8, + "c": 29, + "h": 0, + "e": 0, + "args": [ + "/app/llama-server", + "-m", + "/m/dense.gguf", + "-c", + "0", + "-b", + "2048", + "-ub", + "512", + "--host", + "0.0.0.0", + "--port", + "8080", + "--parallel", + "1", + "-ngl", + "auto" + ], + "env": {}, + "notes": [ + "SIDE_SLOT refusé (CTX doit être un nombre de jetons explicite) : un seul slot, comme sans la clé" + ] + }, + { + "m": 8, + "c": 30, + "h": 0, + "e": 0, + "args": [ + "/app/llama-server", + "-m", + "/m/dense.gguf", + "-c", + "8192", + "-b", + "2048", + "-ub", + "512", + "--host", + "0.0.0.0", + "--port", + "8080", + "--parallel", + "1", + "-ngl", + "auto", + "-ctk", + "q8_0", + "-ctv", + "q8_0" + ], + "env": {}, + "notes": [ + "SIDE_SLOT refusé (VRAM insuffisante : modèle + 2 × 1088 Mio d'état dépassent 90 % de 16311 Mio) : un seul slot, comme sans la clé", + "cache KV q8_0/q8_0 (KV_TYPE) : modifie légèrement les sorties par rapport à f16 — choix de l'utilisateur, laissé tel quel." + ] + }, + { + "m": 8, + "c": 31, + "h": 0, + "e": 0, + "args": [ + "/app/llama-server", + "-m", + "/m/dense.gguf", + "-c", + "32768", + "-b", + "2048", + "-ub", + "512", + "--host", + "0.0.0.0", + "--port", + "8080", + "--parallel", + "1", + "-ngl", + "auto" + ], + "env": {}, + "notes": [ + "SPLIT_MODE=tensor refusé (cartes inconnues (--list-devices n'a rien répondu)) : découpe par couches, comme sans la clé" + ] + }, + { + "m": 8, + "c": 32, + "h": 0, + "e": 0, + "args": [ + "/app/llama-server", + "-m", + "/m/dense.gguf", + "-c", + "16384", + "-b", + "2048", + "-ub", + "512", + "--host", + "0.0.0.0", + "--port", + "8080", + "--parallel", + "1", + "-ngl", + "auto" + ], + "env": {}, + "notes": [ + "SPLIT_MODE=tensor refusé (cartes inconnues (--list-devices n'a rien répondu)) : découpe par couches, comme sans la clé" + ] + }, + { + "m": 8, + "c": 33, + "h": 0, + "e": 0, + "args": [ + "/app/llama-server", + "-m", + "/m/dense.gguf", + "-c", + "32768", + "-b", + "2048", + "-ub", + "512", + "--host", + "0.0.0.0", + "--port", + "8080", + "--parallel", + "1", + "-ngl", + "auto" + ], + "env": {}, + "notes": [ + "SPLIT_MODE=tensor refusé (SPLIT_MODE=bizarre illisible (tensor ou off)) : découpe par couches, comme sans la clé" + ] + }, + { + "m": 8, + "c": 34, + "h": 0, + "e": 0, + "args": [ + "/app/llama-server", + "-m", + "/m/dense.gguf", + "-c", + "32768", + "-b", + "2048", + "-ub", + "512", + "--host", + "0.0.0.0", + "--port", + "8080", + "--parallel", + "1", + "-ngl", + "auto" + ], + "env": {}, + "notes": null + }, + { + "m": 8, + "c": 35, + "h": 0, + "e": 0, + "args": [ + "/app/llama-server", + "-m", + "/m/dense.gguf", + "-c", + "32768", + "-b", + "2048", + "-ub", + "512", + "--host", + "0.0.0.0", + "--port", + "8080", + "--parallel", + "1", + "-ngl", + "auto", + "-ctk", + "q8_0", + "-ctv", + "q8_0" + ], + "env": {}, + "notes": [ + "SPLIT_MODE=tensor refusé (cache KV q8_0/q8_0 : le mode tensor n'accepte que f16, bf16 ou f32 (jamais réécrit d'office : la VRAM changerait)) : découpe par couches, comme sans la clé", + "cache KV q8_0/q8_0 (KV_TYPE) : modifie légèrement les sorties par rapport à f16 — choix de l'utilisateur, laissé tel quel." + ] + }, + { + "m": 8, + "c": 36, + "h": 0, + "e": 0, + "args": [ + "/app/llama-server", + "-m", + "/m/dense.gguf", + "-c", + "32768", + "-b", + "2048", + "-ub", + "512", + "--host", + "0.0.0.0", + "--port", + "8080", + "--parallel", + "1", + "-ngl", + "20" + ], + "env": {}, + "notes": [ + "SPLIT_MODE=tensor refusé (NGL=20 : le mode tensor veut toutes les couches sur les cartes (all)) : découpe par couches, comme sans la clé" + ] + }, + { + "m": 8, + "c": 37, + "h": 0, + "e": 0, + "args": [ + "/app/llama-server", + "-m", + "/m/dense.gguf", + "-c", + "32768", + "-b", + "2048", + "-ub", + "512", + "--host", + "0.0.0.0", + "--port", + "8080", + "--parallel", + "1", + "-ngl", + "auto" + ], + "env": {}, + "notes": null + }, + { + "m": 8, + "c": 38, + "h": 0, + "e": 0, + "args": [ + "/app/llama-server", + "-m", + "/m/dense.gguf", + "-c", + "32768", + "-b", + "2048", + "-ub", + "512", + "--host", + "0.0.0.0", + "--port", + "8080", + "--parallel", + "1", + "-ngl", + "auto" + ], + "env": {}, + "notes": [ + "SPEC=mtp : pas de tête MTP dans ce fichier (publiée à part ? MODEL_DRAFT=mtp-….gguf) — sans décodage spéculatif" + ] + }, + { + "m": 8, + "c": 39, + "h": 0, + "e": 0, + "args": [ + "/app/llama-server", + "-m", + "/m/dense.gguf", + "-c", + "32768", + "-b", + "2048", + "-ub", + "512", + "--host", + "0.0.0.0", + "--port", + "8080", + "--parallel", + "1", + "-ngl", + "auto", + "--spec-type", + "ngram-mod", + "--spec-ngram-mod-n-match", + "24", + "--spec-ngram-mod-n-min", + "48", + "--spec-ngram-mod-n-max", + "64" + ], + "env": {}, + "notes": [ + "SPEC=ngram → n-grammes du contexte (ngram-mod, brouillons de 48 à 64 jetons) : distribution inchangée (chaque jeton vérifié), ~16 Mo de RAM ; utile surtout en mode code, à mesurer (acceptation par nature dans /api/perf/summary)" + ] + }, + { + "m": 8, + "c": 40, + "h": 0, + "e": 0, + "args": [ + "/app/llama-server", + "-m", + "/m/dense.gguf", + "-c", + "32768", + "-b", + "2048", + "-ub", + "512", + "--host", + "0.0.0.0", + "--port", + "8080", + "--parallel", + "1", + "-ngl", + "auto", + "--spec-type", + "ngram-mod", + "--spec-ngram-mod-n-match", + "24", + "--spec-ngram-mod-n-min", + "48", + "--spec-ngram-mod-n-max", + "64" + ], + "env": {}, + "notes": [ + "SPEC=mtp+ngram : sans MTP (pas de tête MTP dans ce fichier (publiée à part ? MODEL_DRAFT=mtp-….gguf)) → n-grammes seuls. SPEC=ngram → n-grammes du contexte (ngram-mod, brouillons de 48 à 64 jetons) : distribution inchangée (chaque jeton vérifié), ~16 Mo de RAM ; utile surtout en mode code, à mesurer (acceptation par nature dans /api/perf/summary)" + ] + }, + { + "m": 8, + "c": 41, + "h": 0, + "e": 0, + "args": [ + "/app/llama-server", + "-m", + "/m/dense.gguf", + "-c", + "32768", + "-b", + "2048", + "-ub", + "512", + "--host", + "0.0.0.0", + "--port", + "8080", + "--parallel", + "1", + "-ngl", + "auto", + "--spec-type", + "ngram-mod", + "--spec-ngram-mod-n-match", + "24", + "--spec-ngram-mod-n-min", + "48", + "--spec-ngram-mod-n-max", + "64" + ], + "env": {}, + "notes": [ + "SPEC=mtp+ngram : sans MTP (pas de tête MTP dans ce fichier (publiée à part ? MODEL_DRAFT=mtp-….gguf)) → n-grammes seuls. SPEC=ngram → n-grammes du contexte (ngram-mod, brouillons de 48 à 64 jetons) : distribution inchangée (chaque jeton vérifié), ~16 Mo de RAM ; utile surtout en mode code, à mesurer (acceptation par nature dans /api/perf/summary)" + ] + }, + { + "m": 8, + "c": 42, + "h": 0, + "e": 0, + "args": [ + "/app/llama-server", + "-m", + "/m/dense.gguf", + "-c", + "32768", + "-b", + "2048", + "-ub", + "512", + "--host", + "0.0.0.0", + "--port", + "8080", + "--parallel", + "1", + "-ngl", + "auto" + ], + "env": {}, + "notes": [ + "SPEC=turbo illisible (off, auto, mtp, ngram ou mtp+ngram) : sans décodage spéculatif" + ] + }, + { + "m": 8, + "c": 43, + "h": 0, + "e": 0, + "args": [ + "/app/llama-server", + "-m", + "/m/dense.gguf", + "-c", + "32768", + "-b", + "2048", + "-ub", + "512", + "--host", + "0.0.0.0", + "--port", + "8080", + "--parallel", + "1", + "-ngl", + "auto" + ], + "env": {}, + "notes": [ + "MODEL_DRAFT ignoré : SPEC=off (auto ou mtp pour s'en servir)" + ] + }, + { + "m": 8, + "c": 44, + "h": 0, + "e": 0, + "args": [ + "/app/llama-server", + "-m", + "/m/dense.gguf", + "-c", + "32768", + "-b", + "2048", + "-ub", + "512", + "--host", + "0.0.0.0", + "--port", + "8080", + "--parallel", + "1", + "-ngl", + "auto" + ], + "env": {}, + "notes": [ + "SPEC=mtp : MODEL_DRAFT est une tête eagle3, pas un modèle brouillon : règle -md et --spec-type dans EXTRA_ARGS — sans décodage spéculatif" + ] + }, + { + "m": 8, + "c": 45, + "h": 0, + "e": 0, + "args": [ + "/app/llama-server", + "-m", + "/m/dense.gguf", + "-c", + "32768", + "-b", + "2048", + "-ub", + "512", + "--host", + "0.0.0.0", + "--port", + "8080", + "--parallel", + "1", + "-ngl", + "auto" + ], + "env": {}, + "notes": [ + "SPEC=mtp : pas de tête MTP dans ce fichier (publiée à part ? MODEL_DRAFT=mtp-….gguf) — sans décodage spéculatif" + ] + }, + { + "m": 8, + "c": 46, + "h": 0, + "e": 0, + "args": [ + "/app/llama-server", + "-m", + "/m/dense.gguf", + "-c", + "32768", + "-b", + "2048", + "-ub", + "512", + "--host", + "0.0.0.0", + "--port", + "8080", + "--parallel", + "1", + "-ngl", + "auto" + ], + "env": {}, + "notes": null + }, + { + "m": 8, + "c": 47, + "h": 0, + "e": 0, + "args": [ + "/app/llama-server", + "-m", + "/m/dense.gguf", + "-c", + "32768", + "-b", + "2048", + "-ub", + "512", + "--host", + "0.0.0.0", + "--port", + "8080", + "--parallel", + "1", + "-ngl", + "auto" + ], + "env": {}, + "notes": [ + "SPEC=mtp : pas de tête MTP dans ce fichier (publiée à part ? MODEL_DRAFT=mtp-….gguf) — sans décodage spéculatif" + ] + }, + { + "m": 8, + "c": 48, + "h": 0, + "e": 0, + "args": [ + "/app/llama-server", + "-m", + "/m/dense.gguf", + "-c", + "32768", + "-b", + "2048", + "-ub", + "512", + "--host", + "0.0.0.0", + "--port", + "8080", + "--parallel", + "1", + "-ngl", + "auto", + "--spec-type", + "ngram-mod", + "--spec-ngram-mod-n-match", + "24", + "--spec-ngram-mod-n-min", + "48", + "--spec-ngram-mod-n-max", + "64" + ], + "env": {}, + "notes": [ + "SPEC=ngram → n-grammes du contexte (ngram-mod, brouillons de 48 à 64 jetons) : distribution inchangée (chaque jeton vérifié), ~16 Mo de RAM ; utile surtout en mode code, à mesurer (acceptation par nature dans /api/perf/summary)", + "MODEL_DRAFT ignoré avec SPEC=ngram : SPEC=mtp+ngram pour les deux", + "SPEC_N_MAX et SPEC_SAMPLING ignorés : les n-grammes n'ont pas de brouillon à régler" + ] + }, + { + "m": 8, + "c": 49, + "h": 0, + "e": 0, + "args": [ + "/app/llama-server", + "-m", + "/m/dense.gguf", + "-c", + "32768", + "-b", + "2048", + "-ub", + "512", + "--host", + "0.0.0.0", + "--port", + "8080", + "--parallel", + "1", + "-ngl", + "auto" + ], + "env": {}, + "notes": [ + "SPEC=auto : MODEL_DRAFT est une tête eagle3, pas un modèle brouillon : règle -md et --spec-type dans EXTRA_ARGS — sans décodage spéculatif" + ] + }, + { + "m": 8, + "c": 50, + "h": 0, + "e": 0, + "args": [ + "/app/llama-server", + "-m", + "/m/dense.gguf", + "-c", + "32768", + "-b", + "2048", + "-ub", + "512", + "--host", + "0.0.0.0", + "--port", + "8080", + "--parallel", + "1", + "-ngl", + "auto", + "--spec-type", + "ngram-mod", + "--spec-ngram-mod-n-match", + "24", + "--spec-ngram-mod-n-min", + "48", + "--spec-ngram-mod-n-max", + "64" + ], + "env": {}, + "notes": [ + "SPEC=mtp+ngram : sans MTP (MODEL_DRAFT est une tête eagle3, pas un modèle brouillon : règle -md et --spec-type dans EXTRA_ARGS) → n-grammes seuls. SPEC=ngram → n-grammes du contexte (ngram-mod, brouillons de 48 à 64 jetons) : distribution inchangée (chaque jeton vérifié), ~16 Mo de RAM ; utile surtout en mode code, à mesurer (acceptation par nature dans /api/perf/summary)" + ] + }, + { + "m": 8, + "c": 51, + "h": 0, + "e": 0, + "args": [ + "/app/llama-server", + "-m", + "/m/dense.gguf", + "-c", + "32768", + "-b", + "2048", + "-ub", + "512", + "--host", + "0.0.0.0", + "--port", + "8080", + "--parallel", + "1", + "-ngl", + "auto" + ], + "env": {}, + "notes": null + }, + { + "m": 8, + "c": 52, + "h": 0, + "e": 0, + "args": [ + "/app/llama-server", + "-m", + "/m/dense.gguf", + "-c", + "32768", + "-b", + "2048", + "-ub", + "512", + "--host", + "0.0.0.0", + "--port", + "8080", + "--parallel", + "1", + "-ngl", + "auto" + ], + "env": { + "CUDA_SCALE_LAUNCH_QUEUES": "2x" + }, + "notes": [ + "CUDA_SCALE_LAUNCH_QUEUES=2x (CUDA_LAUNCH_QUEUES)" + ] + }, + { + "m": 8, + "c": 53, + "h": 0, + "e": 0, + "args": [ + "/app/llama-server", + "-m", + "/m/dense.gguf", + "-c", + "32768", + "-b", + "2048", + "-ub", + "512", + "--host", + "0.0.0.0", + "--port", + "8080", + "--parallel", + "1", + "-ngl", + "auto" + ], + "env": {}, + "notes": [ + "CUDA_LAUNCH_QUEUES=8x ignoré (0.25x, 0.5x, 2x, 4x ou off) → automatique" + ] + }, + { + "m": 8, + "c": 54, + "h": 0, + "e": 0, + "args": [ + "/app/llama-server", + "-m", + "/m/dense.gguf", + "-c", + "32768", + "-b", + "2048", + "-ub", + "512", + "--host", + "0.0.0.0", + "--port", + "8080", + "--parallel", + "1", + "-ngl", + "auto" + ], + "env": {}, + "notes": null + }, + { + "m": 8, + "c": 55, + "h": 0, + "e": 0, + "args": [ + "/app/llama-server", + "-m", + "/m/dense.gguf", + "-c", + "32768", + "-b", + "2048", + "-ub", + "512", + "--host", + "0.0.0.0", + "--port", + "8080", + "--parallel", + "1", + "-ngl", + "auto" + ], + "env": { + "CUDA_DEVICE_ORDER": "PCI_BUS_ID", + "CUDA_VISIBLE_DEVICES": "1,0" + }, + "notes": null + }, + { + "m": 8, + "c": 56, + "h": 0, + "e": 0, + "args": [ + "/app/llama-server", + "-m", + "/m/dense.gguf", + "-c", + "32768", + "-b", + "2048", + "-ub", + "512", + "--host", + "0.0.0.0", + "--port", + "8080", + "--parallel", + "1", + "-ngl", + "auto", + "--fit-target", + "1024,3072" + ], + "env": {}, + "notes": [ + "--fit-target 1024,3072 (FIT_TARGET, Mio libres par carte, dans l'ordre vu par le moteur) — vérifie « offloaded N/N layers » dans le journal" + ] + }, + { + "m": 8, + "c": 57, + "h": 0, + "e": 0, + "args": [ + "/app/llama-server", + "-m", + "/m/dense.gguf", + "-c", + "32768", + "-b", + "2048", + "-ub", + "512", + "--host", + "0.0.0.0", + "--port", + "8080", + "--parallel", + "1", + "-ngl", + "auto", + "--fit-target", + "1024" + ], + "env": {}, + "notes": [ + "--fit-target 1024 (FIT_TARGET, Mio libres par carte, dans l'ordre vu par le moteur) — vérifie « offloaded N/N layers » dans le journal ; marges sous 1024 Mio remontées à 1024" + ] + }, + { + "m": 8, + "c": 58, + "h": 0, + "e": 0, + "args": [ + "/app/llama-server", + "-m", + "/m/dense.gguf", + "-c", + "32768", + "-b", + "2048", + "-ub", + "512", + "--host", + "0.0.0.0", + "--port", + "8080", + "--parallel", + "1", + "-ngl", + "auto" + ], + "env": {}, + "notes": [ + "FIT_TARGET=1024,,3072 ignoré : des Mio par carte, ex. 1024,3072 (8 valeurs au plus)" + ] + }, + { + "m": 8, + "c": 59, + "h": 0, + "e": 0, + "args": [ + "/app/llama-server", + "-m", + "/m/dense.gguf", + "-c", + "32768", + "-b", + "2048", + "-ub", + "512", + "--host", + "0.0.0.0", + "--port", + "8080", + "--parallel", + "1", + "-ngl", + "auto" + ], + "env": {}, + "notes": [ + "FIT_TARGET=1,2,3,4,5,6,7,8,9 ignoré : des Mio par carte, ex. 1024,3072 (8 valeurs au plus)" + ] + }, + { + "m": 8, + "c": 60, + "h": 0, + "e": 0, + "args": [ + "/app/llama-server", + "-m", + "/m/dense.gguf", + "-c", + "0", + "-b", + "2048", + "-ub", + "512", + "--host", + "0.0.0.0", + "--port", + "8080", + "--parallel", + "1", + "-ngl", + "auto" + ], + "env": {}, + "notes": [ + "FIT_TARGET=2048/4096 ignoré : contexte « 0 » non fixé — --fit pourrait le réduire pour tenir ; donne un CTX chiffré" + ] + }, + { + "m": 8, + "c": 61, + "h": 0, + "e": 0, + "args": [ + "/app/llama-server", + "-m", + "/m/dense.gguf", + "-c", + "32768", + "-b", + "2048", + "-ub", + "512", + "--host", + "0.0.0.0", + "--port", + "8080", + "--parallel", + "1", + "-ngl", + "30" + ], + "env": {}, + "notes": [ + "FIT_TARGET=2048 ignoré : --fit inactif (couches GPU fixées à 30), la marge ne servirait à rien" + ] + }, + { + "m": 8, + "c": 62, + "h": 0, + "e": 0, + "args": [ + "/app/llama-server", + "-m", + "/m/dense.gguf", + "-c", + "32768", + "-b", + "2048", + "-ub", + "512", + "--host", + "0.0.0.0", + "--port", + "8080", + "--parallel", + "1", + "-ngl", + "auto" + ], + "env": {}, + "notes": null + }, + { + "m": 8, + "c": 63, + "h": 0, + "e": 0, + "args": [ + "/app/llama-server", + "-m", + "/m/dense.gguf", + "-c", + "32768", + "-b", + "2048", + "-ub", + "512", + "--host", + "0.0.0.0", + "--port", + "8080", + "--parallel", + "1", + "-ngl", + "auto" + ], + "env": {}, + "notes": [ + "OP_OFFLOAD_MIN_BATCH=0 ignoré : un nombre de jetons, ex. 128" + ] + }, + { + "m": 8, + "c": 64, + "h": 0, + "e": 0, + "args": [ + "/app/llama-server", + "-m", + "/m/dense.gguf", + "-c", + "32768", + "-b", + "2048", + "-ub", + "512", + "--host", + "0.0.0.0", + "--port", + "8080", + "--parallel", + "1", + "-ngl", + "auto" + ], + "env": { + "GGML_CUDA_GRAPH_OPT": "1" + }, + "notes": [ + "GGML_CUDA_GRAPH_OPT=1 (CUDA_GRAPH_OPT, expérimental) — si le moteur s'arrête sur un GGML_ASSERT, CUDA_GRAPH_OPT=off puis redémarre" + ] + }, + { + "m": 8, + "c": 65, + "h": 0, + "e": 0, + "args": [ + "/app/llama-server", + "-m", + "/m/dense.gguf", + "-c", + "32768", + "-b", + "2048", + "-ub", + "512", + "--host", + "0.0.0.0", + "--port", + "8080", + "--parallel", + "1", + "-ngl", + "auto" + ], + "env": {}, + "notes": [ + "CUDA_GRAPH_OPT=oui ignoré (on ou off)" + ] + }, + { + "m": 8, + "c": 66, + "h": 0, + "e": 0, + "args": [ + "/app/llama-server", + "-m", + "/m/dense.gguf", + "-c", + "32768", + "-b", + "2048", + "-ub", + "512", + "--host", + "0.0.0.0", + "--port", + "8080", + "--parallel", + "1", + "-ngl", + "auto" + ], + "env": {}, + "notes": null + }, + { + "m": 8, + "c": 67, + "h": 0, + "e": 0, + "args": [ + "/app/llama-server", + "-m", + "/m/dense.gguf", + "-c", + "32768", + "-b", + "2048", + "-ub", + "512", + "--host", + "0.0.0.0", + "--port", + "8080", + "--parallel", + "1", + "-ngl", + "auto", + "--ctx-checkpoints", + "8" + ], + "env": {}, + "notes": null + }, + { + "m": 8, + "c": 68, + "h": 0, + "e": 0, + "args": [ + "/app/llama-server", + "-m", + "/m/dense.gguf", + "-c", + "32768", + "-b", + "2048", + "-ub", + "512", + "--host", + "0.0.0.0", + "--port", + "8080", + "--parallel", + "1", + "-ngl", + "auto" + ], + "env": {}, + "notes": [ + "CTX_CHECKPOINTS=beaucoup illisible (un nombre de points de reprise par slot, ≥ 0) : ignoré" + ] + }, + { + "m": 8, + "c": 69, + "h": 0, + "e": 0, + "args": [ + "/app/llama-server", + "-m", + "/m/dense.gguf", + "-c", + "32768", + "-b", + "2048", + "-ub", + "512", + "--host", + "0.0.0.0", + "--port", + "8080", + "--parallel", + "1", + "-ngl", + "auto", + "--checkpoint-min-step", + "1024" + ], + "env": {}, + "notes": null + }, + { + "m": 8, + "c": 70, + "h": 0, + "e": 0, + "args": [ + "/app/llama-server", + "-m", + "/m/dense.gguf", + "-c", + "32768", + "-b", + "2048", + "-ub", + "512", + "--host", + "0.0.0.0", + "--port", + "8080", + "--parallel", + "1", + "-ngl", + "auto" + ], + "env": {}, + "notes": [ + "CKPT_MIN_STEP=0 refusé (un nombre de jetons > 0 ; 0 laisserait le moteur jeter le point de reprise du début de la conversation) : défaut du moteur" + ] + }, + { + "m": 8, + "c": 71, + "h": 0, + "e": 0, + "args": [ + "/app/llama-server", + "-m", + "/m/dense.gguf", + "-c", + "32768", + "-b", + "2048", + "-ub", + "512", + "--host", + "0.0.0.0", + "--port", + "8080", + "--parallel", + "1", + "-ngl", + "auto" + ], + "env": {}, + "notes": [ + "SLOT_PERSIST refusé (dossier des slots indisponible) : l'état du slot ne sera pas gardé à la bascule de preset" + ] + }, + { + "m": 8, + "c": 72, + "h": 0, + "e": 0, + "args": [ + "/app/llama-server", + "-m", + "/m/dense.gguf", + "-c", + "32768", + "-b", + "2048", + "-ub", + "512", + "--host", + "127.0.0.1", + "--port", + "8080", + "--parallel", + "1", + "-ngl", + "auto" + ], + "env": {}, + "notes": [ + "SLOT_PERSIST refusé (dossier des slots indisponible) : l'état du slot ne sera pas gardé à la bascule de preset" + ] + }, + { + "m": 8, + "c": 73, + "h": 0, + "e": 0, + "args": [ + "/app/llama-server", + "-m", + "/m/dense.gguf", + "-c", + "32768", + "-b", + "2048", + "-ub", + "512", + "--host", + "127.0.0.1", + "--port", + "8080", + "--parallel", + "1", + "-ngl", + "auto" + ], + "env": {}, + "notes": [ + "SLOT_PERSIST refusé (dossier des slots indisponible) : l'état du slot ne sera pas gardé à la bascule de preset", + "SPEC=mtp : pas de tête MTP dans ce fichier (publiée à part ? MODEL_DRAFT=mtp-….gguf) — sans décodage spéculatif" + ] + }, + { + "m": 8, + "c": 74, + "h": 0, + "e": 0, + "args": [ + "/app/llama-server", + "-m", + "/m/dense.gguf", + "-c", + "32768", + "-b", + "2048", + "-ub", + "512", + "--host", + "0.0.0.0", + "--port", + "8080", + "--parallel", + "1", + "-ngl", + "auto", + "--load-mode", + "none" + ], + "env": {}, + "notes": null + }, + { + "m": 8, + "c": 75, + "h": 0, + "e": 0, + "args": [ + "/app/llama-server", + "-m", + "/m/dense.gguf", + "-c", + "32768", + "-b", + "2048", + "-ub", + "512", + "--host", + "0.0.0.0", + "--port", + "8080", + "--parallel", + "1", + "-ngl", + "auto", + "--load-mode", + "mlock" + ], + "env": {}, + "notes": null + }, + { + "m": 8, + "c": 76, + "h": 0, + "e": 0, + "args": [ + "/app/llama-server", + "-m", + "/m/dense.gguf", + "-c", + "32768", + "-b", + "2048", + "-ub", + "512", + "--host", + "0.0.0.0", + "--port", + "8080", + "--parallel", + "1", + "-ngl", + "auto", + "--load-mode", + "mmap+mlock" + ], + "env": {}, + "notes": null + }, + { + "m": 8, + "c": 77, + "h": 0, + "e": 0, + "args": [ + "/app/llama-server", + "-m", + "/m/dense.gguf", + "-c", + "32768", + "-b", + "2048", + "-ub", + "512", + "--host", + "0.0.0.0", + "--port", + "8080", + "--parallel", + "1", + "-ngl", + "auto", + "--load-mode", + "none" + ], + "env": {}, + "notes": null + }, + { + "m": 8, + "c": 78, + "h": 0, + "e": 0, + "args": [ + "/app/llama-server", + "-m", + "/m/dense.gguf", + "-c", + "32768", + "-b", + "2048", + "-ub", + "512", + "--host", + "0.0.0.0", + "--port", + "8080", + "--parallel", + "1", + "-ngl", + "auto", + "--load-mode", + "mmap" + ], + "env": {}, + "notes": null + }, + { + "m": 8, + "c": 79, + "h": 0, + "e": 0, + "args": [ + "/app/llama-server", + "-m", + "/m/dense.gguf", + "-c", + "32768", + "-b", + "2048", + "-ub", + "512", + "--host", + "0.0.0.0", + "--port", + "8080", + "--parallel", + "1", + "-ngl", + "auto", + "--load-mode", + "dio" + ], + "env": {}, + "notes": null + }, + { + "m": 8, + "c": 80, + "h": 0, + "e": 0, + "args": [ + "/app/llama-server", + "-m", + "/m/dense.gguf", + "-c", + "32768", + "-b", + "2048", + "-ub", + "512", + "--host", + "0.0.0.0", + "--port", + "8080", + "--parallel", + "1", + "-ngl", + "auto", + "--load-mode=mlock" + ], + "env": {}, + "notes": null + }, + { + "m": 8, + "c": 81, + "h": 0, + "e": 0, + "args": [ + "/app/llama-server", + "-m", + "/m/dense.gguf", + "-c", + "32768", + "-b", + "2048", + "-ub", + "512", + "--host", + "0.0.0.0", + "--port", + "8080", + "-ngl", + "30", + "--parallel", + "4" + ], + "env": {}, + "notes": null + }, + { + "m": 8, + "c": 82, + "h": 0, + "e": 0, + "args": [ + "/app/llama-server", + "-m", + "/m/dense.gguf", + "-c", + "32768", + "-b", + "2048", + "-ub", + "512", + "--host", + "0.0.0.0", + "--port", + "8080", + "--parallel", + "1", + "-ngl", + "auto", + "-t", + "6", + "-tb", + "3" + ], + "env": {}, + "notes": null + }, + { + "m": 8, + "c": 83, + "h": 0, + "e": 0, + "args": [ + "/app/llama-server", + "-m", + "/m/dense.gguf", + "-c", + "32768", + "-tb", + "0", + "-b", + "2048", + "-ub", + "512", + "--host", + "0.0.0.0", + "--port", + "8080", + "--parallel", + "1", + "-ngl", + "auto", + "-Cb", + "0xff", + "--poll-batch", + "1" + ], + "env": {}, + "notes": [ + "EXTRA_ARGS règle le CPU du batch (-Cb, --poll-batch…) : -tb 0 posé pour que llama.cpp le garde (tous les threads logiques) ; THREADS_BATCH pour choisir le nombre" + ] + }, + { + "m": 8, + "c": 84, + "h": 0, + "e": 0, + "args": [ + "/app/llama-server", + "-m", + "/m/dense.gguf", + "-c", + "32768", + "-t", + "6", + "-tb", + "6", + "-b", + "2048", + "-ub", + "512", + "--host", + "0.0.0.0", + "--port", + "8080", + "--parallel", + "1", + "-ngl", + "auto", + "--prio-batch", + "2" + ], + "env": {}, + "notes": null + }, + { + "m": 8, + "c": 85, + "h": 0, + "e": 0, + "args": [ + "/app/llama-server", + "-m", + "/m/dense.gguf", + "-c", + "32768", + "-b", + "2048", + "-ub", + "512", + "--host", + "0.0.0.0", + "--port", + "8080", + "--parallel", + "1", + "-ngl", + "auto", + "--cache-reuse", + "256", + "--context-shift" + ], + "env": {}, + "notes": [ + "avertissement : --context-shift (EXTRA_ARGS) — contexte plein, le moteur jette des jetons anciens et le modèle perd une partie de la conversation sans le savoir.", + "avertissement : --cache-reuse 256 (EXTRA_ARGS) — réutilise des morceaux de cache calculés sous un autre préfixe : les sorties ne sont plus exactement celles d'un calcul complet." + ] + }, + { + "m": 8, + "c": 86, + "h": 0, + "e": 0, + "args": [ + "/app/llama-server", + "-m", + "/m/dense.gguf", + "-c", + "32768", + "-b", + "2048", + "-ub", + "512", + "--host", + "0.0.0.0", + "--port", + "8080", + "--parallel", + "1", + "-ngl", + "auto", + "-ctk", + "q8_0", + "-ctv", + "q8_0" + ], + "env": {}, + "notes": [ + "cache KV q8_0/q8_0 (défini par EXTRA_ARGS) : modifie légèrement les sorties par rapport à f16 — choix de l'utilisateur, laissé tel quel." + ] + }, + { + "m": 8, + "c": 87, + "h": 0, + "e": 0, + "args": [ + "/app/llama-server", + "-m", + "/m/dense.gguf", + "-c", + "32768", + "-b", + "2048", + "-ub", + "512", + "--host", + "0.0.0.0", + "--port", + "8080", + "--parallel", + "1", + "-ngl", + "auto", + "-ot", + "exps=CPU" + ], + "env": {}, + "notes": null + }, + { + "m": 8, + "c": 88, + "h": 0, + "e": 0, + "args": [ + "/app/llama-server", + "-m", + "/m/dense.gguf", + "-c", + "32768", + "-b", + "2048", + "-ub", + "512", + "--host", + "0.0.0.0", + "--port", + "8080", + "--parallel", + "1", + "-ngl", + "auto", + "-ot", + "blk\\.(1|2)\\.ffn_.*_exps=CPU", + "-ub", + "512" + ], + "env": {}, + "notes": null + }, + { + "m": 8, + "c": 89, + "h": 0, + "e": 0, + "args": [ + "/app/llama-server", + "-m", + "/m/dense.gguf", + "-c", + "32768", + "-b", + "2048", + "-ub", + "512", + "--host", + "0.0.0.0", + "--port", + "8080", + "--parallel", + "1", + "-ngl", + "auto", + "--n-cpu-moe", + "20" + ], + "env": {}, + "notes": null + }, + { + "m": 8, + "c": 90, + "h": 0, + "e": 0, + "args": [ + "/app/llama-server", + "-m", + "/m/dense.gguf", + "-c", + "32768", + "-b", + "2048", + "-ub", + "512", + "--host", + "0.0.0.0", + "--port", + "8080", + "--parallel", + "1", + "-ngl", + "auto", + "--cpu-moe" + ], + "env": {}, + "notes": null + }, + { + "m": 8, + "c": 91, + "h": 0, + "e": 0, + "args": [ + "/app/llama-server", + "-m", + "/m/dense.gguf", + "-c", + "32768", + "-b", + "2048", + "-ub", + "512", + "--host", + "0.0.0.0", + "--port", + "8080", + "--parallel", + "1", + "-ngl", + "auto", + "-sm", + "row" + ], + "env": {}, + "notes": null + }, + { + "m": 8, + "c": 92, + "h": 0, + "e": 0, + "args": [ + "/app/llama-server", + "-m", + "/m/dense.gguf", + "-c", + "32768", + "-b", + "2048", + "-ub", + "512", + "--host", + "0.0.0.0", + "--port", + "8080", + "--parallel", + "1", + "-ngl", + "auto", + "-ts", + "1,1" + ], + "env": {}, + "notes": null + }, + { + "m": 8, + "c": 93, + "h": 0, + "e": 0, + "args": [ + "/app/llama-server", + "-m", + "/m/dense.gguf", + "-c", + "32768", + "-b", + "2048", + "-ub", + "512", + "--host", + "0.0.0.0", + "--port", + "8080", + "--parallel", + "1", + "-ngl", + "auto", + "-dev", + "CUDA0,CUDA1" + ], + "env": { + "CUDA_SCALE_LAUNCH_QUEUES": "4x" + }, + "notes": [ + "CUDA_SCALE_LAUNCH_QUEUES=4x (2 GPU, parallélisme pipeline possible) — CUDA_LAUNCH_QUEUES=off pour s'en passer" + ] + }, + { + "m": 8, + "c": 94, + "h": 0, + "e": 0, + "args": [ + "/app/llama-server", + "-m", + "/m/dense.gguf", + "-c", + "32768", + "-b", + "2048", + "-ub", + "512", + "--host", + "0.0.0.0", + "--port", + "8080", + "--parallel", + "1", + "-ngl", + "auto", + "-dev", + "Vulkan0" + ], + "env": {}, + "notes": null + }, + { + "m": 8, + "c": 95, + "h": 0, + "e": 0, + "args": [ + "/app/llama-server", + "-m", + "/m/dense.gguf", + "-c", + "32768", + "-b", + "2048", + "-ub", + "512", + "--host", + "0.0.0.0", + "--port", + "8080", + "--parallel", + "1", + "-ngl", + "auto", + "--fit", + "off" + ], + "env": {}, + "notes": null + }, + { + "m": 8, + "c": 96, + "h": 0, + "e": 0, + "args": [ + "/app/llama-server", + "-m", + "/m/dense.gguf", + "-c", + "32768", + "-b", + "2048", + "-ub", + "512", + "--host", + "0.0.0.0", + "--port", + "8080", + "--parallel", + "1", + "-ngl", + "auto", + "--mmproj", + "/m/x.gguf" + ], + "env": {}, + "notes": null + }, + { + "m": 8, + "c": 97, + "h": 0, + "e": 0, + "args": [ + "/app/llama-server", + "-m", + "/m/dense.gguf", + "-c", + "32768", + "-b", + "2048", + "-ub", + "512", + "--host", + "0.0.0.0", + "--port", + "8080", + "--parallel", + "1", + "-ngl", + "auto", + "--spec-synth-len", + "4" + ], + "env": {}, + "notes": [ + "avertissement : --spec-synth-len — le moteur accepte des jetons du brouillon AU HASARD, sans vérification : les réponses ne sont plus celles du modèle. Réservé aux mesures, retire-le." + ] + }, + { + "m": 8, + "c": 98, + "h": 0, + "e": 0, + "args": [ + "/app/llama-server", + "-m", + "/m/dense.gguf", + "-c", + "32768", + "-b", + "2048", + "-ub", + "512", + "--host", + "0.0.0.0", + "--port", + "8080", + "--parallel", + "1", + "-ngl", + "auto", + "--slot-save-path", + "/x" + ], + "env": {}, + "notes": null + }, + { + "m": 8, + "c": 99, + "h": 0, + "e": 0, + "args": [ + "/app/llama-server", + "-m", + "/m/dense.gguf", + "-c", + "32768", + "-b", + "2048", + "-ub", + "512", + "--host", + "0.0.0.0", + "--port", + "8080", + "--parallel", + "1", + "-ngl", + "auto", + "-c", + "0" + ], + "env": {}, + "notes": null + }, + { + "m": 8, + "c": 100, + "h": 0, + "e": 0, + "args": [ + "/app/llama-server", + "-m", + "/m/dense.gguf", + "-c", + "32768", + "-b", + "2048", + "-ub", + "512", + "--host", + "0.0.0.0", + "--port", + "8080", + "--parallel", + "1", + "-ngl", + "auto", + "--host", + "127.0.0.1", + "--port", + "9000" + ], + "env": {}, + "notes": null + }, + { + "m": 8, + "c": 101, + "h": 0, + "e": 0, + "args": [ + "/app/llama-server", + "-m", + "/m/dense.gguf", + "-c", + "32768", + "-b", + "2048", + "-ub", + "512", + "--host", + "0.0.0.0", + "--port", + "8080", + "--parallel", + "1", + "-ngl", + "auto", + "--mirostat", + "2" + ], + "env": {}, + "notes": [ + "SPEC=mtp : pas de tête MTP dans ce fichier (publiée à part ? MODEL_DRAFT=mtp-….gguf) — sans décodage spéculatif" + ] + }, + { + "m": 8, + "c": 102, + "h": 0, + "e": 0, + "args": [ + "/app/llama-server", + "-m", + "/m/dense.gguf", + "-c", + "32768", + "-b", + "2048", + "-ub", + "512", + "--host", + "0.0.0.0", + "--port", + "8080", + "--parallel", + "1", + "-ngl", + "auto", + "--chat-template-kwargs", + "{\"preserve_reasoning\":true}" + ], + "env": {}, + "notes": [ + "--chat-template-kwargs fixe déjà preserve_reasoning : REASONING_PRESERVE ignoré" + ] + }, + { + "m": 8, + "c": 103, + "h": 0, + "e": 0, + "args": [ + "/app/llama-server", + "-m", + "/m/dense.gguf", + "-c", + "32768", + "-b", + "2048", + "-ub", + "512", + "--host", + "0.0.0.0", + "--port", + "8080", + "--parallel", + "1", + "-ngl", + "auto", + "--spec-type", + "ngram-mod" + ], + "env": {}, + "notes": [ + "décodage spéculatif déjà réglé dans EXTRA_ARGS ou LLAMA_ARG_* : SPEC ignoré" + ] + }, + { + "m": 8, + "c": 104, + "h": 0, + "e": 0, + "args": [ + "/app/llama-server", + "-m", + "/m/dense.gguf", + "-c", + "32768", + "-b", + "2048", + "-ub", + "512", + "--host", + "0.0.0.0", + "--port", + "8080", + "--parallel", + "1", + "-ngl", + "auto", + "--spec-draft-n-max", + "8" + ], + "env": {}, + "notes": [ + "SPEC=ngram : --spec-draft-n-max dans EXTRA_ARGS (brouillon réglé à la main) — sans décodage spéculatif" + ] + }, + { + "m": 8, + "c": 105, + "h": 0, + "e": 0, + "args": [ + "/app/llama-server", + "-m", + "/m/dense.gguf", + "-c", + "32768", + "-b", + "2048", + "-ub", + "512", + "--host", + "0.0.0.0", + "--port", + "8080", + "--parallel", + "1", + "-ngl", + "auto", + "-ot", + "attn=CUDA1" + ], + "env": {}, + "notes": [ + "CUDA_GRAPH_OPT ignoré : -ot sur l'attention peut séparer Q, K et V d'une couche entre cartes" + ] + }, + { + "m": 8, + "c": 106, + "h": 0, + "e": 0, + "args": [ + "/app/llama-server", + "-m", + "/m/dense.gguf", + "-c", + "32768", + "-b", + "2048", + "-ub", + "512", + "--host", + "0.0.0.0", + "--port", + "8080", + "--parallel", + "1", + "-ngl", + "auto", + "-fa", + "off" + ], + "env": {}, + "notes": [ + "SPLIT_MODE=tensor refusé (Flash Attention coupée (-fa off) : le mode tensor l'exige) : découpe par couches, comme sans la clé" + ] + }, + { + "m": 8, + "c": 107, + "h": 0, + "e": 0, + "args": [ + "/app/llama-server", + "-m", + "/m/dense.gguf", + "-c", + "32768", + "-b", + "2048", + "-ub", + "512", + "--host", + "0.0.0.0", + "--port", + "8080", + "--parallel", + "1", + "-ngl", + "auto", + "-nkvo" + ], + "env": {}, + "notes": null + }, + { + "m": 8, + "c": 108, + "h": 0, + "e": 0, + "args": [ + "/app/llama-server", + "-m", + "/m/dense.gguf", + "-c", + "32768", + "-b", + "2048", + "-ub", + "512", + "--host", + "0.0.0.0", + "--port", + "8080", + "--parallel", + "1", + "-ngl", + "auto", + "--no-context-shift" + ], + "env": {}, + "notes": null + }, + { + "m": 8, + "c": 109, + "h": 0, + "e": 0, + "args": [ + "/app/llama-server", + "-m", + "/m/dense.gguf", + "-c", + "32768", + "-b", + "2048", + "-ub", + "512", + "--host", + "0.0.0.0", + "--port", + "8080", + "-ngl", + "auto", + "-c", + "8192", + "-np", + "2" + ], + "env": {}, + "notes": [ + "SIDE_SLOT refusé (-c est réglé dans EXTRA_ARGS) : un seul slot, comme sans la clé" + ] + }, + { + "m": 8, + "c": 110, + "h": 0, + "e": 0, + "args": [ + "/app/llama-server", + "-m", + "/m/dense.gguf", + "-c", + "32768", + "-b", + "2048", + "-ub", + "512", + "--host", + "0.0.0.0", + "--port", + "8080", + "--parallel", + "1", + "-ngl", + "auto" + ], + "env": {}, + "notes": [ + "SPLIT_MODE=tensor refusé (preset externe) : découpe par couches, comme sans la clé", + "SIDE_SLOT refusé (preset externe) : un seul slot, comme sans la clé" + ] + }, + { + "m": 8, + "c": 111, + "h": 0, + "e": 0, + "args": [ + "/app/llama-server", + "-m", + "/m/dense.gguf", + "-c", + "40000", + "-b", + "2048", + "-ub", + "512", + "--host", + "0.0.0.0", + "--port", + "8080", + "--parallel", + "1", + "-ngl", + "auto", + "-ctk", + "q4_0", + "-ctv", + "q4_0" + ], + "env": {}, + "notes": [ + "NGL=999 (sentinelle « toutes les couches ») → -ngl auto : ce moteur mesure lui-même la VRAM libre. NGL=all pour forcer l'ancien comportement.", + "cache KV q4_0/q4_0 (KV_TYPE) : perte mesurable par rapport à f16 — choix de l'utilisateur, laissé tel quel." + ] + }, + { + "m": 9, + "c": 0, + "h": 0, + "e": 0, + "args": [ + "/app/llama-server", + "-m", + "/m/dense.gguf", + "-c", + "32768", + "-b", + "2048", + "-ub", + "512", + "--host", + "0.0.0.0", + "--port", + "8080", + "--parallel", + "1", + "-ngl", + "auto" + ], + "env": {}, + "notes": null + }, + { + "m": 9, + "c": 1, + "h": 0, + "e": 0, + "args": [ + "/app/llama-server", + "-m", + "/m/dense.gguf", + "-c", + "65536", + "-b", + "4096", + "-ub", + "2048", + "--host", + "127.0.0.1", + "--port", + "8081", + "--parallel", + "1", + "-ngl", + "auto" + ], + "env": {}, + "notes": [ + "NGL=999 (sentinelle « toutes les couches ») → -ngl auto : ce moteur mesure lui-même la VRAM libre. NGL=all pour forcer l'ancien comportement." + ] + }, + { + "m": 9, + "c": 2, + "h": 0, + "e": 0, + "args": [ + "/app/llama-server", + "-m", + "/m/dense.gguf", + "-c", + "32768", + "-b", + "2048", + "-ub", + "512", + "--host", + "0.0.0.0", + "--port", + "8080", + "--parallel", + "1" + ], + "env": {}, + "notes": null + }, + { + "m": 9, + "c": 3, + "h": 0, + "e": 0, + "args": [ + "/app/llama-server", + "-m", + "/m/dense.gguf", + "-c", + "32768", + "-b", + "2048", + "-ub", + "512", + "--host", + "0.0.0.0", + "--port", + "8080", + "--parallel", + "1", + "-ngl", + "40" + ], + "env": {}, + "notes": null + }, + { + "m": 9, + "c": 4, + "h": 0, + "e": 0, + "args": [ + "/app/llama-server", + "-m", + "/m/dense.gguf", + "-c", + "32768", + "-b", + "2048", + "-ub", + "512", + "--host", + "0.0.0.0", + "--port", + "8080", + "--parallel", + "1", + "-ngl", + "all" + ], + "env": {}, + "notes": null + }, + { + "m": 9, + "c": 5, + "h": 0, + "e": 0, + "args": [ + "/app/llama-server", + "-m", + "/m/dense.gguf", + "-c", + "32768", + "-b", + "2048", + "-ub", + "512", + "--host", + "0.0.0.0", + "--port", + "8080", + "--parallel", + "1", + "-ngl", + "auto" + ], + "env": {}, + "notes": [ + "NGL=999 (sentinelle « toutes les couches ») → -ngl auto : ce moteur mesure lui-même la VRAM libre. NGL=all pour forcer l'ancien comportement." + ] + }, + { + "m": 9, + "c": 6, + "h": 0, + "e": 0, + "args": [ + "/app/llama-server", + "-m", + "/m/dense.gguf", + "-c", + "32768", + "-t", + "8", + "-tb", + "4", + "-b", + "2048", + "-ub", + "512", + "--host", + "0.0.0.0", + "--port", + "8080", + "--parallel", + "1", + "-ngl", + "auto" + ], + "env": {}, + "notes": null + }, + { + "m": 9, + "c": 7, + "h": 0, + "e": 0, + "args": [ + "/app/llama-server", + "-m", + "/m/dense.gguf", + "-c", + "32768", + "-b", + "2048", + "-ub", + "512", + "--host", + "0.0.0.0", + "--port", + "8080", + "--parallel", + "1", + "-ngl", + "auto" + ], + "env": {}, + "notes": [ + "THREADS=abc ignoré (pas un nombre de threads ≥ 0) → automatique", + "THREADS_BATCH=-2 ignoré (pas un nombre de threads ≥ 0) → automatique" + ] + }, + { + "m": 9, + "c": 8, + "h": 0, + "e": 0, + "args": [ + "/app/llama-server", + "-m", + "/m/dense.gguf", + "-c", + "32768", + "-b", + "2048", + "-ub", + "512", + "--host", + "0.0.0.0", + "--port", + "8080", + "--parallel", + "1", + "-ngl", + "auto" + ], + "env": {}, + "notes": null + }, + { + "m": 9, + "c": 9, + "h": 0, + "e": 0, + "args": [ + "/app/llama-server", + "-m", + "/m/dense.gguf", + "-c", + "32768", + "-b", + "2048", + "-ub", + "512", + "--host", + "0.0.0.0", + "--port", + "8080", + "--parallel", + "1", + "-ngl", + "auto", + "-ctk", + "q8_0", + "-ctv", + "q8_0" + ], + "env": {}, + "notes": [ + "cache KV q8_0/q8_0 (KV_TYPE) : modifie légèrement les sorties par rapport à f16 — choix de l'utilisateur, laissé tel quel." + ] + }, + { + "m": 9, + "c": 10, + "h": 0, + "e": 0, + "args": [ + "/app/llama-server", + "-m", + "/m/dense.gguf", + "-c", + "32768", + "-b", + "2048", + "-ub", + "512", + "--host", + "0.0.0.0", + "--port", + "8080", + "--parallel", + "1", + "-ngl", + "auto", + "-ctk", + "q4_0", + "-ctv", + "f16" + ], + "env": {}, + "notes": [ + "cache KV q4_0/f16 (KV_TYPE) : perte mesurable par rapport à f16 — choix de l'utilisateur, laissé tel quel." + ] + }, + { + "m": 9, + "c": 11, + "h": 0, + "e": 0, + "args": [ + "/app/llama-server", + "-m", + "/m/dense.gguf", + "-c", + "32768", + "-b", + "2048", + "-ub", + "512", + "--host", + "0.0.0.0", + "--port", + "8080", + "--parallel", + "1", + "-ngl", + "auto", + "-ctk", + "bf16", + "-ctv", + "bf16" + ], + "env": {}, + "notes": [ + "cache KV bf16/bf16 (KV_TYPE) : numérique différente de f16 (mantisse plus courte) — choix de l'utilisateur, laissé tel quel." + ] + }, + { + "m": 9, + "c": 12, + "h": 0, + "e": 0, + "args": [ + "/app/llama-server", + "-m", + "/m/dense.gguf", + "-c", + "32768", + "-b", + "2048", + "-ub", + "512", + "--host", + "0.0.0.0", + "--port", + "8080", + "--parallel", + "1", + "-ngl", + "auto", + "-ctk", + "q5_1", + "-ctv", + "q5_1" + ], + "env": {}, + "notes": [ + "cache KV q5_1/q5_1 (KV_TYPE) : modifie légèrement les sorties par rapport à f16 — choix de l'utilisateur, laissé tel quel." + ] + }, + { + "m": 9, + "c": 13, + "h": 0, + "e": 0, + "args": [ + "/app/llama-server", + "-m", + "/m/dense.gguf", + "-c", + "32768", + "-b", + "2048", + "-ub", + "512", + "--host", + "0.0.0.0", + "--port", + "8080", + "--parallel", + "1", + "-ngl", + "auto", + "--reasoning", + "on", + "--reasoning-budget", + "-1" + ], + "env": {}, + "notes": null + }, + { + "m": 9, + "c": 14, + "h": 0, + "e": 0, + "args": [ + "/app/llama-server", + "-m", + "/m/dense.gguf", + "-c", + "32768", + "-b", + "2048", + "-ub", + "512", + "--host", + "0.0.0.0", + "--port", + "8080", + "--parallel", + "1", + "-ngl", + "auto", + "--reasoning", + "off" + ], + "env": {}, + "notes": null + }, + { + "m": 9, + "c": 15, + "h": 0, + "e": 0, + "args": [ + "/app/llama-server", + "-m", + "/m/dense.gguf", + "-c", + "32768", + "-b", + "2048", + "-ub", + "512", + "--host", + "0.0.0.0", + "--port", + "8080", + "--parallel", + "1", + "-ngl", + "auto", + "--reasoning", + "auto", + "--reasoning-budget", + "2048" + ], + "env": {}, + "notes": null + }, + { + "m": 9, + "c": 16, + "h": 0, + "e": 0, + "args": [ + "/app/llama-server", + "-m", + "/m/dense.gguf", + "-c", + "32768", + "-b", + "2048", + "-ub", + "512", + "--host", + "0.0.0.0", + "--port", + "8080", + "--parallel", + "1", + "-ngl", + "auto", + "--reasoning", + "deepseek", + "--reasoning-budget", + "-1" + ], + "env": {}, + "notes": null + }, + { + "m": 9, + "c": 17, + "h": 0, + "e": 0, + "args": [ + "/app/llama-server", + "-m", + "/m/dense.gguf", + "-c", + "32768", + "-b", + "2048", + "-ub", + "512", + "--host", + "0.0.0.0", + "--port", + "8080", + "--parallel", + "1", + "-ngl", + "auto", + "--reasoning-preserve" + ], + "env": {}, + "notes": null + }, + { + "m": 9, + "c": 18, + "h": 0, + "e": 0, + "args": [ + "/app/llama-server", + "-m", + "/m/dense.gguf", + "-c", + "32768", + "-b", + "2048", + "-ub", + "512", + "--host", + "0.0.0.0", + "--port", + "8080", + "--parallel", + "1", + "-ngl", + "auto", + "--no-reasoning-preserve" + ], + "env": {}, + "notes": null + }, + { + "m": 9, + "c": 19, + "h": 0, + "e": 0, + "args": [ + "/app/llama-server", + "-m", + "/m/dense.gguf", + "-c", + "32768", + "-b", + "2048", + "-ub", + "512", + "--host", + "0.0.0.0", + "--port", + "8080", + "--parallel", + "1", + "-ngl", + "auto" + ], + "env": {}, + "notes": [ + "REASONING_PRESERVE=peut-être illisible (on ou off) : ignoré" + ] + }, + { + "m": 9, + "c": 20, + "h": 0, + "e": 0, + "args": [ + "/app/llama-server", + "-m", + "/m/dense.gguf", + "-c", + "32768", + "-b", + "2048", + "-ub", + "512", + "--host", + "0.0.0.0", + "--port", + "8080", + "--parallel", + "1", + "-ngl", + "auto" + ], + "env": {}, + "notes": null + }, + { + "m": 9, + "c": 21, + "h": 0, + "e": 0, + "args": [ + "/app/llama-server", + "-m", + "/m/dense.gguf", + "-c", + "32768", + "-b", + "2048", + "-ub", + "512", + "--host", + "0.0.0.0", + "--port", + "8080", + "--parallel", + "1", + "-ngl", + "auto", + "--cache-ram", + "4096" + ], + "env": {}, + "notes": null + }, + { + "m": 9, + "c": 22, + "h": 0, + "e": 0, + "args": [ + "/app/llama-server", + "-m", + "/m/dense.gguf", + "-c", + "32768", + "-b", + "2048", + "-ub", + "512", + "--host", + "0.0.0.0", + "--port", + "8080", + "--parallel", + "1", + "-ngl", + "auto", + "--cache-ram", + "0" + ], + "env": {}, + "notes": null + }, + { + "m": 9, + "c": 23, + "h": 0, + "e": 0, + "args": [ + "/app/llama-server", + "-m", + "/m/dense.gguf", + "-c", + "32768", + "-b", + "2048", + "-ub", + "512", + "--host", + "0.0.0.0", + "--port", + "8080", + "--parallel", + "1", + "-ngl", + "auto", + "--cache-ram", + "-1" + ], + "env": {}, + "notes": null + }, + { + "m": 9, + "c": 24, + "h": 0, + "e": 0, + "args": [ + "/app/llama-server", + "-m", + "/m/dense.gguf", + "-c", + "32768", + "-b", + "2048", + "-ub", + "512", + "--host", + "0.0.0.0", + "--port", + "8080", + "--parallel", + "1", + "-ngl", + "auto" + ], + "env": {}, + "notes": [ + "CACHE_RAM=beaucoup illisible (un nombre de Mio, -1, 0 ou auto) : ignoré" + ] + }, + { + "m": 9, + "c": 25, + "h": 0, + "e": 0, + "args": [ + "/app/llama-server", + "-m", + "/m/dense.gguf", + "-c", + "32768", + "-b", + "2048", + "-ub", + "512", + "--host", + "0.0.0.0", + "--port", + "8080", + "--parallel", + "1", + "-ngl", + "auto" + ], + "env": {}, + "notes": null + }, + { + "m": 9, + "c": 26, + "h": 0, + "e": 0, + "args": [ + "/app/llama-server", + "-m", + "/m/dense.gguf", + "-c", + "32768", + "-b", + "2048", + "-ub", + "512", + "--host", + "0.0.0.0", + "--port", + "8080", + "--parallel", + "1", + "-ngl", + "auto", + "--cache-ram", + "0" + ], + "env": {}, + "notes": null + }, + { + "m": 9, + "c": 27, + "h": 0, + "e": 0, + "args": [ + "/app/llama-server", + "-m", + "/m/dense.gguf", + "-c", + "32768", + "-b", + "2048", + "-ub", + "512", + "--host", + "0.0.0.0", + "--port", + "8080", + "--parallel", + "1", + "-ngl", + "auto" + ], + "env": {}, + "notes": [ + "SIDE_SLOT refusé (VRAM insuffisante : modèle + 2 × 8192 Mio d'état dépassent 90 % de 16311 Mio) : un seul slot, comme sans la clé" + ] + }, + { + "m": 9, + "c": 28, + "h": 0, + "e": 0, + "args": [ + "/app/llama-server", + "-m", + "/m/dense.gguf", + "-c", + "32768", + "-b", + "2048", + "-ub", + "512", + "--host", + "0.0.0.0", + "--port", + "8080", + "--parallel", + "3", + "-ngl", + "auto" + ], + "env": {}, + "notes": [ + "SIDE_SLOT refusé (PARALLEL=3 (SIDE_SLOT ouvre exactement 2 slots)) : un seul slot, comme sans la clé" + ] + }, + { + "m": 9, + "c": 29, + "h": 0, + "e": 0, + "args": [ + "/app/llama-server", + "-m", + "/m/dense.gguf", + "-c", + "0", + "-b", + "2048", + "-ub", + "512", + "--host", + "0.0.0.0", + "--port", + "8080", + "--parallel", + "1", + "-ngl", + "auto" + ], + "env": {}, + "notes": [ + "SIDE_SLOT refusé (CTX doit être un nombre de jetons explicite) : un seul slot, comme sans la clé" + ] + }, + { + "m": 9, + "c": 30, + "h": 0, + "e": 0, + "args": [ + "/app/llama-server", + "-m", + "/m/dense.gguf", + "-c", + "8192", + "-b", + "2048", + "-ub", + "512", + "--host", + "0.0.0.0", + "--port", + "8080", + "--parallel", + "1", + "-ngl", + "auto", + "-ctk", + "q8_0", + "-ctv", + "q8_0" + ], + "env": {}, + "notes": [ + "SIDE_SLOT refusé (VRAM insuffisante : modèle + 2 × 1088 Mio d'état dépassent 90 % de 16311 Mio) : un seul slot, comme sans la clé", + "cache KV q8_0/q8_0 (KV_TYPE) : modifie légèrement les sorties par rapport à f16 — choix de l'utilisateur, laissé tel quel." + ] + }, + { + "m": 9, + "c": 31, + "h": 0, + "e": 0, + "args": [ + "/app/llama-server", + "-m", + "/m/dense.gguf", + "-c", + "32768", + "-b", + "2048", + "-ub", + "512", + "--host", + "0.0.0.0", + "--port", + "8080", + "--parallel", + "1", + "-ngl", + "auto" + ], + "env": {}, + "notes": [ + "SPLIT_MODE=tensor refusé (cartes inconnues (--list-devices n'a rien répondu)) : découpe par couches, comme sans la clé" + ] + }, + { + "m": 9, + "c": 32, + "h": 0, + "e": 0, + "args": [ + "/app/llama-server", + "-m", + "/m/dense.gguf", + "-c", + "16384", + "-b", + "2048", + "-ub", + "512", + "--host", + "0.0.0.0", + "--port", + "8080", + "--parallel", + "1", + "-ngl", + "auto" + ], + "env": {}, + "notes": [ + "SPLIT_MODE=tensor refusé (cartes inconnues (--list-devices n'a rien répondu)) : découpe par couches, comme sans la clé" + ] + }, + { + "m": 9, + "c": 33, + "h": 0, + "e": 0, + "args": [ + "/app/llama-server", + "-m", + "/m/dense.gguf", + "-c", + "32768", + "-b", + "2048", + "-ub", + "512", + "--host", + "0.0.0.0", + "--port", + "8080", + "--parallel", + "1", + "-ngl", + "auto" + ], + "env": {}, + "notes": [ + "SPLIT_MODE=tensor refusé (SPLIT_MODE=bizarre illisible (tensor ou off)) : découpe par couches, comme sans la clé" + ] + }, + { + "m": 9, + "c": 34, + "h": 0, + "e": 0, + "args": [ + "/app/llama-server", + "-m", + "/m/dense.gguf", + "-c", + "32768", + "-b", + "2048", + "-ub", + "512", + "--host", + "0.0.0.0", + "--port", + "8080", + "--parallel", + "1", + "-ngl", + "auto" + ], + "env": {}, + "notes": null + }, + { + "m": 9, + "c": 35, + "h": 0, + "e": 0, + "args": [ + "/app/llama-server", + "-m", + "/m/dense.gguf", + "-c", + "32768", + "-b", + "2048", + "-ub", + "512", + "--host", + "0.0.0.0", + "--port", + "8080", + "--parallel", + "1", + "-ngl", + "auto", + "-ctk", + "q8_0", + "-ctv", + "q8_0" + ], + "env": {}, + "notes": [ + "SPLIT_MODE=tensor refusé (cache KV q8_0/q8_0 : le mode tensor n'accepte que f16, bf16 ou f32 (jamais réécrit d'office : la VRAM changerait)) : découpe par couches, comme sans la clé", + "cache KV q8_0/q8_0 (KV_TYPE) : modifie légèrement les sorties par rapport à f16 — choix de l'utilisateur, laissé tel quel." + ] + }, + { + "m": 9, + "c": 36, + "h": 0, + "e": 0, + "args": [ + "/app/llama-server", + "-m", + "/m/dense.gguf", + "-c", + "32768", + "-b", + "2048", + "-ub", + "512", + "--host", + "0.0.0.0", + "--port", + "8080", + "--parallel", + "1", + "-ngl", + "20" + ], + "env": {}, + "notes": [ + "SPLIT_MODE=tensor refusé (NGL=20 : le mode tensor veut toutes les couches sur les cartes (all)) : découpe par couches, comme sans la clé" + ] + }, + { + "m": 9, + "c": 37, + "h": 0, + "e": 0, + "args": [ + "/app/llama-server", + "-m", + "/m/dense.gguf", + "-c", + "32768", + "-b", + "2048", + "-ub", + "512", + "--host", + "0.0.0.0", + "--port", + "8080", + "--parallel", + "1", + "-ngl", + "auto" + ], + "env": {}, + "notes": null + }, + { + "m": 9, + "c": 38, + "h": 0, + "e": 0, + "args": [ + "/app/llama-server", + "-m", + "/m/dense.gguf", + "-c", + "32768", + "-b", + "2048", + "-ub", + "512", + "--host", + "0.0.0.0", + "--port", + "8080", + "--parallel", + "1", + "-ngl", + "auto" + ], + "env": {}, + "notes": [ + "SPEC=mtp : pas de tête MTP dans ce fichier (publiée à part ? MODEL_DRAFT=mtp-….gguf) — sans décodage spéculatif" + ] + }, + { + "m": 9, + "c": 39, + "h": 0, + "e": 0, + "args": [ + "/app/llama-server", + "-m", + "/m/dense.gguf", + "-c", + "32768", + "-b", + "2048", + "-ub", + "512", + "--host", + "0.0.0.0", + "--port", + "8080", + "--parallel", + "1", + "-ngl", + "auto", + "--spec-type", + "ngram-mod", + "--spec-ngram-mod-n-match", + "24", + "--spec-ngram-mod-n-min", + "48", + "--spec-ngram-mod-n-max", + "64" + ], + "env": {}, + "notes": [ + "SPEC=ngram → n-grammes du contexte (ngram-mod, brouillons de 48 à 64 jetons) : distribution inchangée (chaque jeton vérifié), ~16 Mo de RAM ; utile surtout en mode code, à mesurer (acceptation par nature dans /api/perf/summary)" + ] + }, + { + "m": 9, + "c": 40, + "h": 0, + "e": 0, + "args": [ + "/app/llama-server", + "-m", + "/m/dense.gguf", + "-c", + "32768", + "-b", + "2048", + "-ub", + "512", + "--host", + "0.0.0.0", + "--port", + "8080", + "--parallel", + "1", + "-ngl", + "auto", + "--spec-type", + "ngram-mod", + "--spec-ngram-mod-n-match", + "24", + "--spec-ngram-mod-n-min", + "48", + "--spec-ngram-mod-n-max", + "64" + ], + "env": {}, + "notes": [ + "SPEC=mtp+ngram : sans MTP (pas de tête MTP dans ce fichier (publiée à part ? MODEL_DRAFT=mtp-….gguf)) → n-grammes seuls. SPEC=ngram → n-grammes du contexte (ngram-mod, brouillons de 48 à 64 jetons) : distribution inchangée (chaque jeton vérifié), ~16 Mo de RAM ; utile surtout en mode code, à mesurer (acceptation par nature dans /api/perf/summary)" + ] + }, + { + "m": 9, + "c": 41, + "h": 0, + "e": 0, + "args": [ + "/app/llama-server", + "-m", + "/m/dense.gguf", + "-c", + "32768", + "-b", + "2048", + "-ub", + "512", + "--host", + "0.0.0.0", + "--port", + "8080", + "--parallel", + "1", + "-ngl", + "auto", + "--spec-type", + "ngram-mod", + "--spec-ngram-mod-n-match", + "24", + "--spec-ngram-mod-n-min", + "48", + "--spec-ngram-mod-n-max", + "64" + ], + "env": {}, + "notes": [ + "SPEC=mtp+ngram : sans MTP (pas de tête MTP dans ce fichier (publiée à part ? MODEL_DRAFT=mtp-….gguf)) → n-grammes seuls. SPEC=ngram → n-grammes du contexte (ngram-mod, brouillons de 48 à 64 jetons) : distribution inchangée (chaque jeton vérifié), ~16 Mo de RAM ; utile surtout en mode code, à mesurer (acceptation par nature dans /api/perf/summary)" + ] + }, + { + "m": 9, + "c": 42, + "h": 0, + "e": 0, + "args": [ + "/app/llama-server", + "-m", + "/m/dense.gguf", + "-c", + "32768", + "-b", + "2048", + "-ub", + "512", + "--host", + "0.0.0.0", + "--port", + "8080", + "--parallel", + "1", + "-ngl", + "auto" + ], + "env": {}, + "notes": [ + "SPEC=turbo illisible (off, auto, mtp, ngram ou mtp+ngram) : sans décodage spéculatif" + ] + }, + { + "m": 9, + "c": 43, + "h": 0, + "e": 0, + "args": [ + "/app/llama-server", + "-m", + "/m/dense.gguf", + "-c", + "32768", + "-b", + "2048", + "-ub", + "512", + "--host", + "0.0.0.0", + "--port", + "8080", + "--parallel", + "1", + "-ngl", + "auto" + ], + "env": {}, + "notes": [ + "MODEL_DRAFT ignoré : SPEC=off (auto ou mtp pour s'en servir)" + ] + }, + { + "m": 9, + "c": 44, + "h": 0, + "e": 0, + "args": [ + "/app/llama-server", + "-m", + "/m/dense.gguf", + "-c", + "32768", + "-b", + "2048", + "-ub", + "512", + "--host", + "0.0.0.0", + "--port", + "8080", + "--parallel", + "1", + "-ngl", + "auto", + "-md", + "/m/small.gguf", + "--spec-type", + "draft-simple", + "--spec-draft-n-max", + "4", + "--spec-draft-sampling", + "greedy" + ], + "env": {}, + "notes": [ + "SPEC=mtp → brouillon small.gguf imposé : sortie inchangée, ~1-2 Go de VRAM en plus" + ] + }, + { + "m": 9, + "c": 45, + "h": 0, + "e": 0, + "args": [ + "/app/llama-server", + "-m", + "/m/dense.gguf", + "-c", + "32768", + "-b", + "2048", + "-ub", + "512", + "--host", + "0.0.0.0", + "--port", + "8080", + "--parallel", + "1", + "-ngl", + "auto" + ], + "env": {}, + "notes": [ + "SPEC=mtp : pas de tête MTP dans ce fichier (publiée à part ? MODEL_DRAFT=mtp-….gguf) — sans décodage spéculatif" + ] + }, + { + "m": 9, + "c": 46, + "h": 0, + "e": 0, + "args": [ + "/app/llama-server", + "-m", + "/m/dense.gguf", + "-c", + "32768", + "-b", + "2048", + "-ub", + "512", + "--host", + "0.0.0.0", + "--port", + "8080", + "--parallel", + "1", + "-ngl", + "auto" + ], + "env": {}, + "notes": null + }, + { + "m": 9, + "c": 47, + "h": 0, + "e": 0, + "args": [ + "/app/llama-server", + "-m", + "/m/dense.gguf", + "-c", + "32768", + "-b", + "2048", + "-ub", + "512", + "--host", + "0.0.0.0", + "--port", + "8080", + "--parallel", + "1", + "-ngl", + "auto" + ], + "env": {}, + "notes": [ + "SPEC=mtp : pas de tête MTP dans ce fichier (publiée à part ? MODEL_DRAFT=mtp-….gguf) — sans décodage spéculatif" + ] + }, + { + "m": 9, + "c": 48, + "h": 0, + "e": 0, + "args": [ + "/app/llama-server", + "-m", + "/m/dense.gguf", + "-c", + "32768", + "-b", + "2048", + "-ub", + "512", + "--host", + "0.0.0.0", + "--port", + "8080", + "--parallel", + "1", + "-ngl", + "auto", + "--spec-type", + "ngram-mod", + "--spec-ngram-mod-n-match", + "24", + "--spec-ngram-mod-n-min", + "48", + "--spec-ngram-mod-n-max", + "64" + ], + "env": {}, + "notes": [ + "SPEC=ngram → n-grammes du contexte (ngram-mod, brouillons de 48 à 64 jetons) : distribution inchangée (chaque jeton vérifié), ~16 Mo de RAM ; utile surtout en mode code, à mesurer (acceptation par nature dans /api/perf/summary)", + "MODEL_DRAFT ignoré avec SPEC=ngram : SPEC=mtp+ngram pour les deux", + "SPEC_N_MAX et SPEC_SAMPLING ignorés : les n-grammes n'ont pas de brouillon à régler" + ] + }, + { + "m": 9, + "c": 49, + "h": 0, + "e": 0, + "args": [ + "/app/llama-server", + "-m", + "/m/dense.gguf", + "-c", + "32768", + "-b", + "2048", + "-ub", + "512", + "--host", + "0.0.0.0", + "--port", + "8080", + "--parallel", + "1", + "-ngl", + "auto", + "-md", + "/m/small.gguf", + "--spec-type", + "draft-simple", + "--spec-draft-sampling", + "greedy" + ], + "env": {}, + "notes": [ + "SPEC=auto → brouillon small.gguf : sortie inchangée (chaque jeton est vérifié par le modèle), ~1-2 Go de VRAM en plus. Mesure le prefill ; SPEC=off pour couper." + ] + }, + { + "m": 9, + "c": 50, + "h": 0, + "e": 0, + "args": [ + "/app/llama-server", + "-m", + "/m/dense.gguf", + "-c", + "32768", + "-b", + "2048", + "-ub", + "512", + "--host", + "0.0.0.0", + "--port", + "8080", + "--parallel", + "1", + "-ngl", + "auto", + "--spec-type", + "ngram-mod", + "--spec-ngram-mod-n-match", + "24", + "--spec-ngram-mod-n-min", + "48", + "--spec-ngram-mod-n-max", + "64" + ], + "env": {}, + "notes": [ + "SPEC=mtp+ngram : sans MTP (MODEL_DRAFT n'est pas une tête MTP) → n-grammes seuls. SPEC=ngram → n-grammes du contexte (ngram-mod, brouillons de 48 à 64 jetons) : distribution inchangée (chaque jeton vérifié), ~16 Mo de RAM ; utile surtout en mode code, à mesurer (acceptation par nature dans /api/perf/summary)" + ] + }, + { + "m": 9, + "c": 51, + "h": 0, + "e": 0, + "args": [ + "/app/llama-server", + "-m", + "/m/dense.gguf", + "-c", + "32768", + "-b", + "2048", + "-ub", + "512", + "--host", + "0.0.0.0", + "--port", + "8080", + "--parallel", + "1", + "-ngl", + "auto" + ], + "env": {}, + "notes": null + }, + { + "m": 9, + "c": 52, + "h": 0, + "e": 0, + "args": [ + "/app/llama-server", + "-m", + "/m/dense.gguf", + "-c", + "32768", + "-b", + "2048", + "-ub", + "512", + "--host", + "0.0.0.0", + "--port", + "8080", + "--parallel", + "1", + "-ngl", + "auto" + ], + "env": { + "CUDA_SCALE_LAUNCH_QUEUES": "2x" + }, + "notes": [ + "CUDA_SCALE_LAUNCH_QUEUES=2x (CUDA_LAUNCH_QUEUES)" + ] + }, + { + "m": 9, + "c": 53, + "h": 0, + "e": 0, + "args": [ + "/app/llama-server", + "-m", + "/m/dense.gguf", + "-c", + "32768", + "-b", + "2048", + "-ub", + "512", + "--host", + "0.0.0.0", + "--port", + "8080", + "--parallel", + "1", + "-ngl", + "auto" + ], + "env": {}, + "notes": [ + "CUDA_LAUNCH_QUEUES=8x ignoré (0.25x, 0.5x, 2x, 4x ou off) → automatique" + ] + }, + { + "m": 9, + "c": 54, + "h": 0, + "e": 0, + "args": [ + "/app/llama-server", + "-m", + "/m/dense.gguf", + "-c", + "32768", + "-b", + "2048", + "-ub", + "512", + "--host", + "0.0.0.0", + "--port", + "8080", + "--parallel", + "1", + "-ngl", + "auto" + ], + "env": {}, + "notes": null + }, + { + "m": 9, + "c": 55, + "h": 0, + "e": 0, + "args": [ + "/app/llama-server", + "-m", + "/m/dense.gguf", + "-c", + "32768", + "-b", + "2048", + "-ub", + "512", + "--host", + "0.0.0.0", + "--port", + "8080", + "--parallel", + "1", + "-ngl", + "auto" + ], + "env": { + "CUDA_DEVICE_ORDER": "PCI_BUS_ID", + "CUDA_VISIBLE_DEVICES": "1,0" + }, + "notes": null + }, + { + "m": 9, + "c": 56, + "h": 0, + "e": 0, + "args": [ + "/app/llama-server", + "-m", + "/m/dense.gguf", + "-c", + "32768", + "-b", + "2048", + "-ub", + "512", + "--host", + "0.0.0.0", + "--port", + "8080", + "--parallel", + "1", + "-ngl", + "auto", + "--fit-target", + "1024,3072" + ], + "env": {}, + "notes": [ + "--fit-target 1024,3072 (FIT_TARGET, Mio libres par carte, dans l'ordre vu par le moteur) — vérifie « offloaded N/N layers » dans le journal" + ] + }, + { + "m": 9, + "c": 57, + "h": 0, + "e": 0, + "args": [ + "/app/llama-server", + "-m", + "/m/dense.gguf", + "-c", + "32768", + "-b", + "2048", + "-ub", + "512", + "--host", + "0.0.0.0", + "--port", + "8080", + "--parallel", + "1", + "-ngl", + "auto", + "--fit-target", + "1024" + ], + "env": {}, + "notes": [ + "--fit-target 1024 (FIT_TARGET, Mio libres par carte, dans l'ordre vu par le moteur) — vérifie « offloaded N/N layers » dans le journal ; marges sous 1024 Mio remontées à 1024" + ] + }, + { + "m": 9, + "c": 58, + "h": 0, + "e": 0, + "args": [ + "/app/llama-server", + "-m", + "/m/dense.gguf", + "-c", + "32768", + "-b", + "2048", + "-ub", + "512", + "--host", + "0.0.0.0", + "--port", + "8080", + "--parallel", + "1", + "-ngl", + "auto" + ], + "env": {}, + "notes": [ + "FIT_TARGET=1024,,3072 ignoré : des Mio par carte, ex. 1024,3072 (8 valeurs au plus)" + ] + }, + { + "m": 9, + "c": 59, + "h": 0, + "e": 0, + "args": [ + "/app/llama-server", + "-m", + "/m/dense.gguf", + "-c", + "32768", + "-b", + "2048", + "-ub", + "512", + "--host", + "0.0.0.0", + "--port", + "8080", + "--parallel", + "1", + "-ngl", + "auto" + ], + "env": {}, + "notes": [ + "FIT_TARGET=1,2,3,4,5,6,7,8,9 ignoré : des Mio par carte, ex. 1024,3072 (8 valeurs au plus)" + ] + }, + { + "m": 9, + "c": 60, + "h": 0, + "e": 0, + "args": [ + "/app/llama-server", + "-m", + "/m/dense.gguf", + "-c", + "0", + "-b", + "2048", + "-ub", + "512", + "--host", + "0.0.0.0", + "--port", + "8080", + "--parallel", + "1", + "-ngl", + "auto" + ], + "env": {}, + "notes": [ + "FIT_TARGET=2048/4096 ignoré : contexte « 0 » non fixé — --fit pourrait le réduire pour tenir ; donne un CTX chiffré" + ] + }, + { + "m": 9, + "c": 61, + "h": 0, + "e": 0, + "args": [ + "/app/llama-server", + "-m", + "/m/dense.gguf", + "-c", + "32768", + "-b", + "2048", + "-ub", + "512", + "--host", + "0.0.0.0", + "--port", + "8080", + "--parallel", + "1", + "-ngl", + "30" + ], + "env": {}, + "notes": [ + "FIT_TARGET=2048 ignoré : --fit inactif (couches GPU fixées à 30), la marge ne servirait à rien" + ] + }, + { + "m": 9, + "c": 62, + "h": 0, + "e": 0, + "args": [ + "/app/llama-server", + "-m", + "/m/dense.gguf", + "-c", + "32768", + "-b", + "2048", + "-ub", + "512", + "--host", + "0.0.0.0", + "--port", + "8080", + "--parallel", + "1", + "-ngl", + "auto" + ], + "env": { + "GGML_OP_OFFLOAD_MIN_BATCH": "128" + }, + "notes": [ + "GGML_OP_OFFLOAD_MIN_BATCH=128 (OP_OFFLOAD_MIN_BATCH) : lots de moins de 128 jetons calculés sur CPU pour les poids restés en RAM" + ] + }, + { + "m": 9, + "c": 63, + "h": 0, + "e": 0, + "args": [ + "/app/llama-server", + "-m", + "/m/dense.gguf", + "-c", + "32768", + "-b", + "2048", + "-ub", + "512", + "--host", + "0.0.0.0", + "--port", + "8080", + "--parallel", + "1", + "-ngl", + "auto" + ], + "env": {}, + "notes": [ + "OP_OFFLOAD_MIN_BATCH=0 ignoré : un nombre de jetons, ex. 128" + ] + }, + { + "m": 9, + "c": 64, + "h": 0, + "e": 0, + "args": [ + "/app/llama-server", + "-m", + "/m/dense.gguf", + "-c", + "32768", + "-b", + "2048", + "-ub", + "512", + "--host", + "0.0.0.0", + "--port", + "8080", + "--parallel", + "1", + "-ngl", + "auto" + ], + "env": {}, + "notes": null + }, + { + "m": 9, + "c": 65, + "h": 0, + "e": 0, + "args": [ + "/app/llama-server", + "-m", + "/m/dense.gguf", + "-c", + "32768", + "-b", + "2048", + "-ub", + "512", + "--host", + "0.0.0.0", + "--port", + "8080", + "--parallel", + "1", + "-ngl", + "auto" + ], + "env": {}, + "notes": [ + "CUDA_GRAPH_OPT=oui ignoré (on ou off)" + ] + }, + { + "m": 9, + "c": 66, + "h": 0, + "e": 0, + "args": [ + "/app/llama-server", + "-m", + "/m/dense.gguf", + "-c", + "32768", + "-b", + "2048", + "-ub", + "512", + "--host", + "0.0.0.0", + "--port", + "8080", + "--parallel", + "1", + "-ngl", + "auto" + ], + "env": {}, + "notes": null + }, + { + "m": 9, + "c": 67, + "h": 0, + "e": 0, + "args": [ + "/app/llama-server", + "-m", + "/m/dense.gguf", + "-c", + "32768", + "-b", + "2048", + "-ub", + "512", + "--host", + "0.0.0.0", + "--port", + "8080", + "--parallel", + "1", + "-ngl", + "auto", + "--ctx-checkpoints", + "8" + ], + "env": {}, + "notes": null + }, + { + "m": 9, + "c": 68, + "h": 0, + "e": 0, + "args": [ + "/app/llama-server", + "-m", + "/m/dense.gguf", + "-c", + "32768", + "-b", + "2048", + "-ub", + "512", + "--host", + "0.0.0.0", + "--port", + "8080", + "--parallel", + "1", + "-ngl", + "auto" + ], + "env": {}, + "notes": [ + "CTX_CHECKPOINTS=beaucoup illisible (un nombre de points de reprise par slot, ≥ 0) : ignoré" + ] + }, + { + "m": 9, + "c": 69, + "h": 0, + "e": 0, + "args": [ + "/app/llama-server", + "-m", + "/m/dense.gguf", + "-c", + "32768", + "-b", + "2048", + "-ub", + "512", + "--host", + "0.0.0.0", + "--port", + "8080", + "--parallel", + "1", + "-ngl", + "auto", + "--checkpoint-min-step", + "1024" + ], + "env": {}, + "notes": null + }, + { + "m": 9, + "c": 70, + "h": 0, + "e": 0, + "args": [ + "/app/llama-server", + "-m", + "/m/dense.gguf", + "-c", + "32768", + "-b", + "2048", + "-ub", + "512", + "--host", + "0.0.0.0", + "--port", + "8080", + "--parallel", + "1", + "-ngl", + "auto" + ], + "env": {}, + "notes": [ + "CKPT_MIN_STEP=0 refusé (un nombre de jetons > 0 ; 0 laisserait le moteur jeter le point de reprise du début de la conversation) : défaut du moteur" + ] + }, + { + "m": 9, + "c": 71, + "h": 0, + "e": 0, + "args": [ + "/app/llama-server", + "-m", + "/m/dense.gguf", + "-c", + "32768", + "-b", + "2048", + "-ub", + "512", + "--host", + "0.0.0.0", + "--port", + "8080", + "--parallel", + "1", + "-ngl", + "auto" + ], + "env": {}, + "notes": [ + "SLOT_PERSIST refusé (dossier des slots indisponible) : l'état du slot ne sera pas gardé à la bascule de preset" + ] + }, + { + "m": 9, + "c": 72, + "h": 0, + "e": 0, + "args": [ + "/app/llama-server", + "-m", + "/m/dense.gguf", + "-c", + "32768", + "-b", + "2048", + "-ub", + "512", + "--host", + "127.0.0.1", + "--port", + "8080", + "--parallel", + "1", + "-ngl", + "auto" + ], + "env": {}, + "notes": [ + "SLOT_PERSIST refusé (dossier des slots indisponible) : l'état du slot ne sera pas gardé à la bascule de preset" + ] + }, + { + "m": 9, + "c": 73, + "h": 0, + "e": 0, + "args": [ + "/app/llama-server", + "-m", + "/m/dense.gguf", + "-c", + "32768", + "-b", + "2048", + "-ub", + "512", + "--host", + "127.0.0.1", + "--port", + "8080", + "--parallel", + "1", + "-ngl", + "auto" + ], + "env": {}, + "notes": [ + "SLOT_PERSIST refusé (dossier des slots indisponible) : l'état du slot ne sera pas gardé à la bascule de preset", + "SPEC=mtp : pas de tête MTP dans ce fichier (publiée à part ? MODEL_DRAFT=mtp-….gguf) — sans décodage spéculatif" + ] + }, + { + "m": 9, + "c": 74, + "h": 0, + "e": 0, + "args": [ + "/app/llama-server", + "-m", + "/m/dense.gguf", + "-c", + "32768", + "-b", + "2048", + "-ub", + "512", + "--host", + "0.0.0.0", + "--port", + "8080", + "--parallel", + "1", + "-ngl", + "auto", + "--load-mode", + "none" + ], + "env": {}, + "notes": null + }, + { + "m": 9, + "c": 75, + "h": 0, + "e": 0, + "args": [ + "/app/llama-server", + "-m", + "/m/dense.gguf", + "-c", + "32768", + "-b", + "2048", + "-ub", + "512", + "--host", + "0.0.0.0", + "--port", + "8080", + "--parallel", + "1", + "-ngl", + "auto", + "--load-mode", + "mlock" + ], + "env": {}, + "notes": null + }, + { + "m": 9, + "c": 76, + "h": 0, + "e": 0, + "args": [ + "/app/llama-server", + "-m", + "/m/dense.gguf", + "-c", + "32768", + "-b", + "2048", + "-ub", + "512", + "--host", + "0.0.0.0", + "--port", + "8080", + "--parallel", + "1", + "-ngl", + "auto", + "--load-mode", + "mmap+mlock" + ], + "env": {}, + "notes": null + }, + { + "m": 9, + "c": 77, + "h": 0, + "e": 0, + "args": [ + "/app/llama-server", + "-m", + "/m/dense.gguf", + "-c", + "32768", + "-b", + "2048", + "-ub", + "512", + "--host", + "0.0.0.0", + "--port", + "8080", + "--parallel", + "1", + "-ngl", + "auto", + "--load-mode", + "none" + ], + "env": {}, + "notes": null + }, + { + "m": 9, + "c": 78, + "h": 0, + "e": 0, + "args": [ + "/app/llama-server", + "-m", + "/m/dense.gguf", + "-c", + "32768", + "-b", + "2048", + "-ub", + "512", + "--host", + "0.0.0.0", + "--port", + "8080", + "--parallel", + "1", + "-ngl", + "auto", + "--load-mode", + "mmap" + ], + "env": {}, + "notes": null + }, + { + "m": 9, + "c": 79, + "h": 0, + "e": 0, + "args": [ + "/app/llama-server", + "-m", + "/m/dense.gguf", + "-c", + "32768", + "-b", + "2048", + "-ub", + "512", + "--host", + "0.0.0.0", + "--port", + "8080", + "--parallel", + "1", + "-ngl", + "auto", + "--load-mode", + "dio" + ], + "env": {}, + "notes": null + }, + { + "m": 9, + "c": 80, + "h": 0, + "e": 0, + "args": [ + "/app/llama-server", + "-m", + "/m/dense.gguf", + "-c", + "32768", + "-b", + "2048", + "-ub", + "512", + "--host", + "0.0.0.0", + "--port", + "8080", + "--parallel", + "1", + "-ngl", + "auto", + "--load-mode=mlock" + ], + "env": {}, + "notes": null + }, + { + "m": 9, + "c": 81, + "h": 0, + "e": 0, + "args": [ + "/app/llama-server", + "-m", + "/m/dense.gguf", + "-c", + "32768", + "-b", + "2048", + "-ub", + "512", + "--host", + "0.0.0.0", + "--port", + "8080", + "-ngl", + "30", + "--parallel", + "4" + ], + "env": {}, + "notes": null + }, + { + "m": 9, + "c": 82, + "h": 0, + "e": 0, + "args": [ + "/app/llama-server", + "-m", + "/m/dense.gguf", + "-c", + "32768", + "-b", + "2048", + "-ub", + "512", + "--host", + "0.0.0.0", + "--port", + "8080", + "--parallel", + "1", + "-ngl", + "auto", + "-t", + "6", + "-tb", + "3" + ], + "env": {}, + "notes": null + }, + { + "m": 9, + "c": 83, + "h": 0, + "e": 0, + "args": [ + "/app/llama-server", + "-m", + "/m/dense.gguf", + "-c", + "32768", + "-tb", + "0", + "-b", + "2048", + "-ub", + "512", + "--host", + "0.0.0.0", + "--port", + "8080", + "--parallel", + "1", + "-ngl", + "auto", + "-Cb", + "0xff", + "--poll-batch", + "1" + ], + "env": {}, + "notes": [ + "EXTRA_ARGS règle le CPU du batch (-Cb, --poll-batch…) : -tb 0 posé pour que llama.cpp le garde (tous les threads logiques) ; THREADS_BATCH pour choisir le nombre" + ] + }, + { + "m": 9, + "c": 84, + "h": 0, + "e": 0, + "args": [ + "/app/llama-server", + "-m", + "/m/dense.gguf", + "-c", + "32768", + "-t", + "6", + "-tb", + "6", + "-b", + "2048", + "-ub", + "512", + "--host", + "0.0.0.0", + "--port", + "8080", + "--parallel", + "1", + "-ngl", + "auto", + "--prio-batch", + "2" + ], + "env": {}, + "notes": null + }, + { + "m": 9, + "c": 85, + "h": 0, + "e": 0, + "args": [ + "/app/llama-server", + "-m", + "/m/dense.gguf", + "-c", + "32768", + "-b", + "2048", + "-ub", + "512", + "--host", + "0.0.0.0", + "--port", + "8080", + "--parallel", + "1", + "-ngl", + "auto", + "--cache-reuse", + "256", + "--context-shift" + ], + "env": {}, + "notes": [ + "avertissement : --context-shift (EXTRA_ARGS) — contexte plein, le moteur jette des jetons anciens et le modèle perd une partie de la conversation sans le savoir.", + "avertissement : --cache-reuse 256 (EXTRA_ARGS) — réutilise des morceaux de cache calculés sous un autre préfixe : les sorties ne sont plus exactement celles d'un calcul complet." + ] + }, + { + "m": 9, + "c": 86, + "h": 0, + "e": 0, + "args": [ + "/app/llama-server", + "-m", + "/m/dense.gguf", + "-c", + "32768", + "-b", + "2048", + "-ub", + "512", + "--host", + "0.0.0.0", + "--port", + "8080", + "--parallel", + "1", + "-ngl", + "auto", + "-ctk", + "q8_0", + "-ctv", + "q8_0" + ], + "env": {}, + "notes": [ + "cache KV q8_0/q8_0 (défini par EXTRA_ARGS) : modifie légèrement les sorties par rapport à f16 — choix de l'utilisateur, laissé tel quel." + ] + }, + { + "m": 9, + "c": 87, + "h": 0, + "e": 0, + "args": [ + "/app/llama-server", + "-m", + "/m/dense.gguf", + "-c", + "32768", + "-b", + "2048", + "-ub", + "512", + "--host", + "0.0.0.0", + "--port", + "8080", + "--parallel", + "1", + "-ngl", + "auto", + "-ot", + "exps=CPU" + ], + "env": {}, + "notes": null + }, + { + "m": 9, + "c": 88, + "h": 0, + "e": 0, + "args": [ + "/app/llama-server", + "-m", + "/m/dense.gguf", + "-c", + "32768", + "-b", + "2048", + "-ub", + "512", + "--host", + "0.0.0.0", + "--port", + "8080", + "--parallel", + "1", + "-ngl", + "auto", + "-ot", + "blk\\.(1|2)\\.ffn_.*_exps=CPU", + "-ub", + "512" + ], + "env": {}, + "notes": null + }, + { + "m": 9, + "c": 89, + "h": 0, + "e": 0, + "args": [ + "/app/llama-server", + "-m", + "/m/dense.gguf", + "-c", + "32768", + "-b", + "2048", + "-ub", + "512", + "--host", + "0.0.0.0", + "--port", + "8080", + "--parallel", + "1", + "-ngl", + "auto", + "--n-cpu-moe", + "20" + ], + "env": {}, + "notes": null + }, + { + "m": 9, + "c": 90, + "h": 0, + "e": 0, + "args": [ + "/app/llama-server", + "-m", + "/m/dense.gguf", + "-c", + "32768", + "-b", + "2048", + "-ub", + "512", + "--host", + "0.0.0.0", + "--port", + "8080", + "--parallel", + "1", + "-ngl", + "auto", + "--cpu-moe" + ], + "env": {}, + "notes": null + }, + { + "m": 9, + "c": 91, + "h": 0, + "e": 0, + "args": [ + "/app/llama-server", + "-m", + "/m/dense.gguf", + "-c", + "32768", + "-b", + "2048", + "-ub", + "512", + "--host", + "0.0.0.0", + "--port", + "8080", + "--parallel", + "1", + "-ngl", + "auto", + "-sm", + "row" + ], + "env": {}, + "notes": null + }, + { + "m": 9, + "c": 92, + "h": 0, + "e": 0, + "args": [ + "/app/llama-server", + "-m", + "/m/dense.gguf", + "-c", + "32768", + "-b", + "2048", + "-ub", + "512", + "--host", + "0.0.0.0", + "--port", + "8080", + "--parallel", + "1", + "-ngl", + "auto", + "-ts", + "1,1" + ], + "env": {}, + "notes": null + }, + { + "m": 9, + "c": 93, + "h": 0, + "e": 0, + "args": [ + "/app/llama-server", + "-m", + "/m/dense.gguf", + "-c", + "32768", + "-b", + "2048", + "-ub", + "512", + "--host", + "0.0.0.0", + "--port", + "8080", + "--parallel", + "1", + "-ngl", + "auto", + "-dev", + "CUDA0,CUDA1" + ], + "env": { + "CUDA_SCALE_LAUNCH_QUEUES": "4x" + }, + "notes": [ + "CUDA_SCALE_LAUNCH_QUEUES=4x (2 GPU, parallélisme pipeline possible) — CUDA_LAUNCH_QUEUES=off pour s'en passer" + ] + }, + { + "m": 9, + "c": 94, + "h": 0, + "e": 0, + "args": [ + "/app/llama-server", + "-m", + "/m/dense.gguf", + "-c", + "32768", + "-b", + "2048", + "-ub", + "512", + "--host", + "0.0.0.0", + "--port", + "8080", + "--parallel", + "1", + "-ngl", + "auto", + "-dev", + "Vulkan0" + ], + "env": {}, + "notes": null + }, + { + "m": 9, + "c": 95, + "h": 0, + "e": 0, + "args": [ + "/app/llama-server", + "-m", + "/m/dense.gguf", + "-c", + "32768", + "-b", + "2048", + "-ub", + "512", + "--host", + "0.0.0.0", + "--port", + "8080", + "--parallel", + "1", + "-ngl", + "auto", + "--fit", + "off" + ], + "env": {}, + "notes": null + }, + { + "m": 9, + "c": 96, + "h": 0, + "e": 0, + "args": [ + "/app/llama-server", + "-m", + "/m/dense.gguf", + "-c", + "32768", + "-b", + "2048", + "-ub", + "512", + "--host", + "0.0.0.0", + "--port", + "8080", + "--parallel", + "1", + "-ngl", + "auto", + "--mmproj", + "/m/x.gguf" + ], + "env": {}, + "notes": null + }, + { + "m": 9, + "c": 97, + "h": 0, + "e": 0, + "args": [ + "/app/llama-server", + "-m", + "/m/dense.gguf", + "-c", + "32768", + "-b", + "2048", + "-ub", + "512", + "--host", + "0.0.0.0", + "--port", + "8080", + "--parallel", + "1", + "-ngl", + "auto", + "--spec-synth-len", + "4" + ], + "env": {}, + "notes": [ + "avertissement : --spec-synth-len — le moteur accepte des jetons du brouillon AU HASARD, sans vérification : les réponses ne sont plus celles du modèle. Réservé aux mesures, retire-le." + ] + }, + { + "m": 9, + "c": 98, + "h": 0, + "e": 0, + "args": [ + "/app/llama-server", + "-m", + "/m/dense.gguf", + "-c", + "32768", + "-b", + "2048", + "-ub", + "512", + "--host", + "0.0.0.0", + "--port", + "8080", + "--parallel", + "1", + "-ngl", + "auto", + "--slot-save-path", + "/x" + ], + "env": {}, + "notes": null + }, + { + "m": 9, + "c": 99, + "h": 0, + "e": 0, + "args": [ + "/app/llama-server", + "-m", + "/m/dense.gguf", + "-c", + "32768", + "-b", + "2048", + "-ub", + "512", + "--host", + "0.0.0.0", + "--port", + "8080", + "--parallel", + "1", + "-ngl", + "auto", + "-c", + "0" + ], + "env": {}, + "notes": null + }, + { + "m": 9, + "c": 100, + "h": 0, + "e": 0, + "args": [ + "/app/llama-server", + "-m", + "/m/dense.gguf", + "-c", + "32768", + "-b", + "2048", + "-ub", + "512", + "--host", + "0.0.0.0", + "--port", + "8080", + "--parallel", + "1", + "-ngl", + "auto", + "--host", + "127.0.0.1", + "--port", + "9000" + ], + "env": {}, + "notes": null + }, + { + "m": 9, + "c": 101, + "h": 0, + "e": 0, + "args": [ + "/app/llama-server", + "-m", + "/m/dense.gguf", + "-c", + "32768", + "-b", + "2048", + "-ub", + "512", + "--host", + "0.0.0.0", + "--port", + "8080", + "--parallel", + "1", + "-ngl", + "auto", + "--mirostat", + "2" + ], + "env": {}, + "notes": [ + "SPEC=mtp : pas de tête MTP dans ce fichier (publiée à part ? MODEL_DRAFT=mtp-….gguf) — sans décodage spéculatif" + ] + }, + { + "m": 9, + "c": 102, + "h": 0, + "e": 0, + "args": [ + "/app/llama-server", + "-m", + "/m/dense.gguf", + "-c", + "32768", + "-b", + "2048", + "-ub", + "512", + "--host", + "0.0.0.0", + "--port", + "8080", + "--parallel", + "1", + "-ngl", + "auto", + "--chat-template-kwargs", + "{\"preserve_reasoning\":true}" + ], + "env": {}, + "notes": [ + "--chat-template-kwargs fixe déjà preserve_reasoning : REASONING_PRESERVE ignoré" + ] + }, + { + "m": 9, + "c": 103, + "h": 0, + "e": 0, + "args": [ + "/app/llama-server", + "-m", + "/m/dense.gguf", + "-c", + "32768", + "-b", + "2048", + "-ub", + "512", + "--host", + "0.0.0.0", + "--port", + "8080", + "--parallel", + "1", + "-ngl", + "auto", + "--spec-type", + "ngram-mod" + ], + "env": {}, + "notes": [ + "décodage spéculatif déjà réglé dans EXTRA_ARGS ou LLAMA_ARG_* : SPEC ignoré" + ] + }, + { + "m": 9, + "c": 104, + "h": 0, + "e": 0, + "args": [ + "/app/llama-server", + "-m", + "/m/dense.gguf", + "-c", + "32768", + "-b", + "2048", + "-ub", + "512", + "--host", + "0.0.0.0", + "--port", + "8080", + "--parallel", + "1", + "-ngl", + "auto", + "--spec-draft-n-max", + "8" + ], + "env": {}, + "notes": [ + "SPEC=ngram : --spec-draft-n-max dans EXTRA_ARGS (brouillon réglé à la main) — sans décodage spéculatif" + ] + }, + { + "m": 9, + "c": 105, + "h": 0, + "e": 0, + "args": [ + "/app/llama-server", + "-m", + "/m/dense.gguf", + "-c", + "32768", + "-b", + "2048", + "-ub", + "512", + "--host", + "0.0.0.0", + "--port", + "8080", + "--parallel", + "1", + "-ngl", + "auto", + "-ot", + "attn=CUDA1" + ], + "env": {}, + "notes": [ + "CUDA_GRAPH_OPT ignoré : -ot sur l'attention peut séparer Q, K et V d'une couche entre cartes" + ] + }, + { + "m": 9, + "c": 106, + "h": 0, + "e": 0, + "args": [ + "/app/llama-server", + "-m", + "/m/dense.gguf", + "-c", + "32768", + "-b", + "2048", + "-ub", + "512", + "--host", + "0.0.0.0", + "--port", + "8080", + "--parallel", + "1", + "-ngl", + "auto", + "-fa", + "off" + ], + "env": {}, + "notes": [ + "SPLIT_MODE=tensor refusé (Flash Attention coupée (-fa off) : le mode tensor l'exige) : découpe par couches, comme sans la clé" + ] + }, + { + "m": 9, + "c": 107, + "h": 0, + "e": 0, + "args": [ + "/app/llama-server", + "-m", + "/m/dense.gguf", + "-c", + "32768", + "-b", + "2048", + "-ub", + "512", + "--host", + "0.0.0.0", + "--port", + "8080", + "--parallel", + "1", + "-ngl", + "auto", + "-nkvo" + ], + "env": {}, + "notes": null + }, + { + "m": 9, + "c": 108, + "h": 0, + "e": 0, + "args": [ + "/app/llama-server", + "-m", + "/m/dense.gguf", + "-c", + "32768", + "-b", + "2048", + "-ub", + "512", + "--host", + "0.0.0.0", + "--port", + "8080", + "--parallel", + "1", + "-ngl", + "auto", + "--no-context-shift" + ], + "env": {}, + "notes": null + }, + { + "m": 9, + "c": 109, + "h": 0, + "e": 0, + "args": [ + "/app/llama-server", + "-m", + "/m/dense.gguf", + "-c", + "32768", + "-b", + "2048", + "-ub", + "512", + "--host", + "0.0.0.0", + "--port", + "8080", + "-ngl", + "auto", + "-c", + "8192", + "-np", + "2" + ], + "env": {}, + "notes": [ + "SIDE_SLOT refusé (-c est réglé dans EXTRA_ARGS) : un seul slot, comme sans la clé" + ] + }, + { + "m": 9, + "c": 110, + "h": 0, + "e": 0, + "args": [ + "/app/llama-server", + "-m", + "/m/dense.gguf", + "-c", + "32768", + "-b", + "2048", + "-ub", + "512", + "--host", + "0.0.0.0", + "--port", + "8080", + "--parallel", + "1", + "-ngl", + "auto" + ], + "env": {}, + "notes": [ + "SPLIT_MODE=tensor refusé (preset externe) : découpe par couches, comme sans la clé", + "SIDE_SLOT refusé (preset externe) : un seul slot, comme sans la clé" + ] + }, + { + "m": 9, + "c": 111, + "h": 0, + "e": 0, + "args": [ + "/app/llama-server", + "-m", + "/m/dense.gguf", + "-c", + "40000", + "-b", + "2048", + "-ub", + "512", + "--host", + "0.0.0.0", + "--port", + "8080", + "--parallel", + "1", + "-ngl", + "auto", + "-ctk", + "q4_0", + "-ctv", + "q4_0" + ], + "env": {}, + "notes": [ + "NGL=999 (sentinelle « toutes les couches ») → -ngl auto : ce moteur mesure lui-même la VRAM libre. NGL=all pour forcer l'ancien comportement.", + "cache KV q4_0/q4_0 (KV_TYPE) : perte mesurable par rapport à f16 — choix de l'utilisateur, laissé tel quel." + ] + }, + { + "m": 10, + "c": 0, + "h": 0, + "e": 0, + "args": [ + "/app/llama-server", + "-m", + "/m/hybrid.gguf", + "-c", + "32768", + "-b", + "2048", + "-ub", + "512", + "--host", + "0.0.0.0", + "--port", + "8080", + "--parallel", + "1", + "-ngl", + "auto" + ], + "env": {}, + "notes": null + }, + { + "m": 10, + "c": 1, + "h": 0, + "e": 0, + "args": [ + "/app/llama-server", + "-m", + "/m/hybrid.gguf", + "-c", + "65536", + "-b", + "4096", + "-ub", + "2048", + "--host", + "127.0.0.1", + "--port", + "8081", + "--parallel", + "1", + "-ngl", + "auto", + "--cache-ram", + "13981" + ], + "env": {}, + "notes": [ + "NGL=999 (sentinelle « toutes les couches ») → -ngl auto : ce moteur mesure lui-même la VRAM libre. NGL=all pour forcer l'ancien comportement.", + "cache de prompts : --cache-ram 13981 (RAM hôte, copie exacte de l'état, jamais la VRAM ; besoin estimé 13981 Mio, plafond 19660 Mio). CACHE_RAM= pour fixer, 0 pour couper." + ] + }, + { + "m": 10, + "c": 2, + "h": 0, + "e": 0, + "args": [ + "/app/llama-server", + "-m", + "/m/hybrid.gguf", + "-c", + "32768", + "-b", + "2048", + "-ub", + "512", + "--host", + "0.0.0.0", + "--port", + "8080", + "--parallel", + "1" + ], + "env": {}, + "notes": null + }, + { + "m": 10, + "c": 3, + "h": 0, + "e": 0, + "args": [ + "/app/llama-server", + "-m", + "/m/hybrid.gguf", + "-c", + "32768", + "-b", + "2048", + "-ub", + "512", + "--host", + "0.0.0.0", + "--port", + "8080", + "--parallel", + "1", + "-ngl", + "40" + ], + "env": {}, + "notes": null + }, + { + "m": 10, + "c": 4, + "h": 0, + "e": 0, + "args": [ + "/app/llama-server", + "-m", + "/m/hybrid.gguf", + "-c", + "32768", + "-b", + "2048", + "-ub", + "512", + "--host", + "0.0.0.0", + "--port", + "8080", + "--parallel", + "1", + "-ngl", + "all" + ], + "env": {}, + "notes": null + }, + { + "m": 10, + "c": 5, + "h": 0, + "e": 0, + "args": [ + "/app/llama-server", + "-m", + "/m/hybrid.gguf", + "-c", + "32768", + "-b", + "2048", + "-ub", + "512", + "--host", + "0.0.0.0", + "--port", + "8080", + "--parallel", + "1", + "-ngl", + "auto" + ], + "env": {}, + "notes": [ + "NGL=999 (sentinelle « toutes les couches ») → -ngl auto : ce moteur mesure lui-même la VRAM libre. NGL=all pour forcer l'ancien comportement." + ] + }, + { + "m": 10, + "c": 6, + "h": 0, + "e": 0, + "args": [ + "/app/llama-server", + "-m", + "/m/hybrid.gguf", + "-c", + "32768", + "-t", + "8", + "-tb", + "4", + "-b", + "2048", + "-ub", + "512", + "--host", + "0.0.0.0", + "--port", + "8080", + "--parallel", + "1", + "-ngl", + "auto" + ], + "env": {}, + "notes": null + }, + { + "m": 10, + "c": 7, + "h": 0, + "e": 0, + "args": [ + "/app/llama-server", + "-m", + "/m/hybrid.gguf", + "-c", + "32768", + "-b", + "2048", + "-ub", + "512", + "--host", + "0.0.0.0", + "--port", + "8080", + "--parallel", + "1", + "-ngl", + "auto" + ], + "env": {}, + "notes": [ + "THREADS=abc ignoré (pas un nombre de threads ≥ 0) → automatique", + "THREADS_BATCH=-2 ignoré (pas un nombre de threads ≥ 0) → automatique" + ] + }, + { + "m": 10, + "c": 8, + "h": 0, + "e": 0, + "args": [ + "/app/llama-server", + "-m", + "/m/hybrid.gguf", + "-c", + "32768", + "-b", + "2048", + "-ub", + "512", + "--host", + "0.0.0.0", + "--port", + "8080", + "--parallel", + "1", + "-ngl", + "auto" + ], + "env": {}, + "notes": null + }, + { + "m": 10, + "c": 9, + "h": 0, + "e": 0, + "args": [ + "/app/llama-server", + "-m", + "/m/hybrid.gguf", + "-c", + "32768", + "-b", + "2048", + "-ub", + "512", + "--host", + "0.0.0.0", + "--port", + "8080", + "--parallel", + "1", + "-ngl", + "auto", + "-ctk", + "q8_0", + "-ctv", + "q8_0" + ], + "env": {}, + "notes": [ + "cache KV q8_0/q8_0 (KV_TYPE) : modifie légèrement les sorties par rapport à f16 — choix de l'utilisateur, laissé tel quel." + ] + }, + { + "m": 10, + "c": 10, + "h": 0, + "e": 0, + "args": [ + "/app/llama-server", + "-m", + "/m/hybrid.gguf", + "-c", + "32768", + "-b", + "2048", + "-ub", + "512", + "--host", + "0.0.0.0", + "--port", + "8080", + "--parallel", + "1", + "-ngl", + "auto", + "-ctk", + "q4_0", + "-ctv", + "f16" + ], + "env": {}, + "notes": [ + "cache KV q4_0/f16 (KV_TYPE) : perte mesurable par rapport à f16 — choix de l'utilisateur, laissé tel quel." + ] + }, + { + "m": 10, + "c": 11, + "h": 0, + "e": 0, + "args": [ + "/app/llama-server", + "-m", + "/m/hybrid.gguf", + "-c", + "32768", + "-b", + "2048", + "-ub", + "512", + "--host", + "0.0.0.0", + "--port", + "8080", + "--parallel", + "1", + "-ngl", + "auto", + "-ctk", + "bf16", + "-ctv", + "bf16" + ], + "env": {}, + "notes": [ + "cache KV bf16/bf16 (KV_TYPE) : numérique différente de f16 (mantisse plus courte) — choix de l'utilisateur, laissé tel quel." + ] + }, + { + "m": 10, + "c": 12, + "h": 0, + "e": 0, + "args": [ + "/app/llama-server", + "-m", + "/m/hybrid.gguf", + "-c", + "32768", + "-b", + "2048", + "-ub", + "512", + "--host", + "0.0.0.0", + "--port", + "8080", + "--parallel", + "1", + "-ngl", + "auto", + "-ctk", + "q5_1", + "-ctv", + "q5_1" + ], + "env": {}, + "notes": [ + "cache KV q5_1/q5_1 (KV_TYPE) : modifie légèrement les sorties par rapport à f16 — choix de l'utilisateur, laissé tel quel." + ] + }, + { + "m": 10, + "c": 13, + "h": 0, + "e": 0, + "args": [ + "/app/llama-server", + "-m", + "/m/hybrid.gguf", + "-c", + "32768", + "-b", + "2048", + "-ub", + "512", + "--host", + "0.0.0.0", + "--port", + "8080", + "--parallel", + "1", + "-ngl", + "auto", + "--reasoning", + "on", + "--reasoning-budget", + "-1" + ], + "env": {}, + "notes": null + }, + { + "m": 10, + "c": 14, + "h": 0, + "e": 0, + "args": [ + "/app/llama-server", + "-m", + "/m/hybrid.gguf", + "-c", + "32768", + "-b", + "2048", + "-ub", + "512", + "--host", + "0.0.0.0", + "--port", + "8080", + "--parallel", + "1", + "-ngl", + "auto", + "--reasoning", + "off" + ], + "env": {}, + "notes": null + }, + { + "m": 10, + "c": 15, + "h": 0, + "e": 0, + "args": [ + "/app/llama-server", + "-m", + "/m/hybrid.gguf", + "-c", + "32768", + "-b", + "2048", + "-ub", + "512", + "--host", + "0.0.0.0", + "--port", + "8080", + "--parallel", + "1", + "-ngl", + "auto", + "--reasoning", + "auto", + "--reasoning-budget", + "2048" + ], + "env": {}, + "notes": null + }, + { + "m": 10, + "c": 16, + "h": 0, + "e": 0, + "args": [ + "/app/llama-server", + "-m", + "/m/hybrid.gguf", + "-c", + "32768", + "-b", + "2048", + "-ub", + "512", + "--host", + "0.0.0.0", + "--port", + "8080", + "--parallel", + "1", + "-ngl", + "auto", + "--reasoning", + "deepseek", + "--reasoning-budget", + "-1" + ], + "env": {}, + "notes": null + }, + { + "m": 10, + "c": 17, + "h": 0, + "e": 0, + "args": [ + "/app/llama-server", + "-m", + "/m/hybrid.gguf", + "-c", + "32768", + "-b", + "2048", + "-ub", + "512", + "--host", + "0.0.0.0", + "--port", + "8080", + "--parallel", + "1", + "-ngl", + "auto", + "--reasoning-preserve" + ], + "env": {}, + "notes": null + }, + { + "m": 10, + "c": 18, + "h": 0, + "e": 0, + "args": [ + "/app/llama-server", + "-m", + "/m/hybrid.gguf", + "-c", + "32768", + "-b", + "2048", + "-ub", + "512", + "--host", + "0.0.0.0", + "--port", + "8080", + "--parallel", + "1", + "-ngl", + "auto", + "--no-reasoning-preserve" + ], + "env": {}, + "notes": null + }, + { + "m": 10, + "c": 19, + "h": 0, + "e": 0, + "args": [ + "/app/llama-server", + "-m", + "/m/hybrid.gguf", + "-c", + "32768", + "-b", + "2048", + "-ub", + "512", + "--host", + "0.0.0.0", + "--port", + "8080", + "--parallel", + "1", + "-ngl", + "auto" + ], + "env": {}, + "notes": [ + "REASONING_PRESERVE=peut-être illisible (on ou off) : ignoré" + ] + }, + { + "m": 10, + "c": 20, + "h": 0, + "e": 0, + "args": [ + "/app/llama-server", + "-m", + "/m/hybrid.gguf", + "-c", + "32768", + "-b", + "2048", + "-ub", + "512", + "--host", + "0.0.0.0", + "--port", + "8080", + "--parallel", + "1", + "-ngl", + "auto" + ], + "env": {}, + "notes": null + }, + { + "m": 10, + "c": 21, + "h": 0, + "e": 0, + "args": [ + "/app/llama-server", + "-m", + "/m/hybrid.gguf", + "-c", + "32768", + "-b", + "2048", + "-ub", + "512", + "--host", + "0.0.0.0", + "--port", + "8080", + "--parallel", + "1", + "-ngl", + "auto", + "--cache-ram", + "4096" + ], + "env": {}, + "notes": null + }, + { + "m": 10, + "c": 22, + "h": 0, + "e": 0, + "args": [ + "/app/llama-server", + "-m", + "/m/hybrid.gguf", + "-c", + "32768", + "-b", + "2048", + "-ub", + "512", + "--host", + "0.0.0.0", + "--port", + "8080", + "--parallel", + "1", + "-ngl", + "auto", + "--cache-ram", + "0" + ], + "env": {}, + "notes": null + }, + { + "m": 10, + "c": 23, + "h": 0, + "e": 0, + "args": [ + "/app/llama-server", + "-m", + "/m/hybrid.gguf", + "-c", + "32768", + "-b", + "2048", + "-ub", + "512", + "--host", + "0.0.0.0", + "--port", + "8080", + "--parallel", + "1", + "-ngl", + "auto", + "--cache-ram", + "-1" + ], + "env": {}, + "notes": null + }, + { + "m": 10, + "c": 24, + "h": 0, + "e": 0, + "args": [ + "/app/llama-server", + "-m", + "/m/hybrid.gguf", + "-c", + "32768", + "-b", + "2048", + "-ub", + "512", + "--host", + "0.0.0.0", + "--port", + "8080", + "--parallel", + "1", + "-ngl", + "auto" + ], + "env": {}, + "notes": [ + "CACHE_RAM=beaucoup illisible (un nombre de Mio, -1, 0 ou auto) : ignoré" + ] + }, + { + "m": 10, + "c": 25, + "h": 0, + "e": 0, + "args": [ + "/app/llama-server", + "-m", + "/m/hybrid.gguf", + "-c", + "32768", + "-b", + "2048", + "-ub", + "512", + "--host", + "0.0.0.0", + "--port", + "8080", + "--parallel", + "1", + "-ngl", + "auto" + ], + "env": {}, + "notes": null + }, + { + "m": 10, + "c": 26, + "h": 0, + "e": 0, + "args": [ + "/app/llama-server", + "-m", + "/m/hybrid.gguf", + "-c", + "32768", + "-b", + "2048", + "-ub", + "512", + "--host", + "0.0.0.0", + "--port", + "8080", + "--parallel", + "1", + "-ngl", + "auto", + "--cache-ram", + "0" + ], + "env": {}, + "notes": null + }, + { + "m": 10, + "c": 27, + "h": 0, + "e": 0, + "args": [ + "/app/llama-server", + "-m", + "/m/hybrid.gguf", + "-c", + "65536", + "-b", + "2048", + "-ub", + "512", + "--host", + "0.0.0.0", + "--port", + "8080", + "--parallel", + "2", + "--no-kv-unified", + "-ngl", + "auto" + ], + "env": {}, + "notes": [ + "SIDE_SLOT : 2 slots de 32768 jetons (-c 65536, --no-kv-unified), ~2198 Mio de VRAM en plus pour le second (cache KV et état récurrent ; le brouillon MTP éventuel et les points de reprise en RAM hôte doublent aussi) ; le placement automatique sortira des couches du GPU s'il manque de place" + ] + }, + { + "m": 10, + "c": 28, + "h": 0, + "e": 0, + "args": [ + "/app/llama-server", + "-m", + "/m/hybrid.gguf", + "-c", + "32768", + "-b", + "2048", + "-ub", + "512", + "--host", + "0.0.0.0", + "--port", + "8080", + "--parallel", + "3", + "-ngl", + "auto" + ], + "env": {}, + "notes": [ + "SIDE_SLOT refusé (PARALLEL=3 (SIDE_SLOT ouvre exactement 2 slots)) : un seul slot, comme sans la clé" + ] + }, + { + "m": 10, + "c": 29, + "h": 0, + "e": 0, + "args": [ + "/app/llama-server", + "-m", + "/m/hybrid.gguf", + "-c", + "0", + "-b", + "2048", + "-ub", + "512", + "--host", + "0.0.0.0", + "--port", + "8080", + "--parallel", + "1", + "-ngl", + "auto" + ], + "env": {}, + "notes": [ + "SIDE_SLOT refusé (CTX doit être un nombre de jetons explicite) : un seul slot, comme sans la clé" + ] + }, + { + "m": 10, + "c": 30, + "h": 0, + "e": 0, + "args": [ + "/app/llama-server", + "-m", + "/m/hybrid.gguf", + "-c", + "16384", + "-b", + "2048", + "-ub", + "512", + "--host", + "0.0.0.0", + "--port", + "8080", + "--parallel", + "2", + "--no-kv-unified", + "-ngl", + "auto", + "-ctk", + "q8_0", + "-ctv", + "q8_0" + ], + "env": {}, + "notes": [ + "SIDE_SLOT : 2 slots de 8192 jetons (-c 16384, --no-kv-unified), ~422 Mio de VRAM en plus pour le second (cache KV et état récurrent ; le brouillon MTP éventuel et les points de reprise en RAM hôte doublent aussi) ; le placement automatique sortira des couches du GPU s'il manque de place", + "cache KV q8_0/q8_0 (KV_TYPE) : modifie légèrement les sorties par rapport à f16 — choix de l'utilisateur, laissé tel quel." + ] + }, + { + "m": 10, + "c": 31, + "h": 0, + "e": 0, + "args": [ + "/app/llama-server", + "-m", + "/m/hybrid.gguf", + "-c", + "32768", + "-b", + "2048", + "-ub", + "512", + "--host", + "0.0.0.0", + "--port", + "8080", + "--parallel", + "1", + "-ngl", + "auto" + ], + "env": {}, + "notes": [ + "SPLIT_MODE=tensor refusé (cartes inconnues (--list-devices n'a rien répondu)) : découpe par couches, comme sans la clé" + ] + }, + { + "m": 10, + "c": 32, + "h": 0, + "e": 0, + "args": [ + "/app/llama-server", + "-m", + "/m/hybrid.gguf", + "-c", + "16384", + "-b", + "2048", + "-ub", + "512", + "--host", + "0.0.0.0", + "--port", + "8080", + "--parallel", + "1", + "-ngl", + "auto" + ], + "env": {}, + "notes": [ + "SPLIT_MODE=tensor refusé (cartes inconnues (--list-devices n'a rien répondu)) : découpe par couches, comme sans la clé" + ] + }, + { + "m": 10, + "c": 33, + "h": 0, + "e": 0, + "args": [ + "/app/llama-server", + "-m", + "/m/hybrid.gguf", + "-c", + "32768", + "-b", + "2048", + "-ub", + "512", + "--host", + "0.0.0.0", + "--port", + "8080", + "--parallel", + "1", + "-ngl", + "auto" + ], + "env": {}, + "notes": [ + "SPLIT_MODE=tensor refusé (SPLIT_MODE=bizarre illisible (tensor ou off)) : découpe par couches, comme sans la clé" + ] + }, + { + "m": 10, + "c": 34, + "h": 0, + "e": 0, + "args": [ + "/app/llama-server", + "-m", + "/m/hybrid.gguf", + "-c", + "32768", + "-b", + "2048", + "-ub", + "512", + "--host", + "0.0.0.0", + "--port", + "8080", + "--parallel", + "1", + "-ngl", + "auto" + ], + "env": {}, + "notes": null + }, + { + "m": 10, + "c": 35, + "h": 0, + "e": 0, + "args": [ + "/app/llama-server", + "-m", + "/m/hybrid.gguf", + "-c", + "32768", + "-b", + "2048", + "-ub", + "512", + "--host", + "0.0.0.0", + "--port", + "8080", + "--parallel", + "1", + "-ngl", + "auto", + "-ctk", + "q8_0", + "-ctv", + "q8_0" + ], + "env": {}, + "notes": [ + "SPLIT_MODE=tensor refusé (cache KV q8_0/q8_0 : le mode tensor n'accepte que f16, bf16 ou f32 (jamais réécrit d'office : la VRAM changerait)) : découpe par couches, comme sans la clé", + "cache KV q8_0/q8_0 (KV_TYPE) : modifie légèrement les sorties par rapport à f16 — choix de l'utilisateur, laissé tel quel." + ] + }, + { + "m": 10, + "c": 36, + "h": 0, + "e": 0, + "args": [ + "/app/llama-server", + "-m", + "/m/hybrid.gguf", + "-c", + "32768", + "-b", + "2048", + "-ub", + "512", + "--host", + "0.0.0.0", + "--port", + "8080", + "--parallel", + "1", + "-ngl", + "20" + ], + "env": {}, + "notes": [ + "SPLIT_MODE=tensor refusé (NGL=20 : le mode tensor veut toutes les couches sur les cartes (all)) : découpe par couches, comme sans la clé" + ] + }, + { + "m": 10, + "c": 37, + "h": 0, + "e": 0, + "args": [ + "/app/llama-server", + "-m", + "/m/hybrid.gguf", + "-c", + "32768", + "-b", + "2048", + "-ub", + "512", + "--host", + "0.0.0.0", + "--port", + "8080", + "--parallel", + "1", + "-ngl", + "auto" + ], + "env": {}, + "notes": [ + "SPEC=auto : le dernier lancement avec cette configuration n'a jamais répondu — sans décodage spéculatif" + ] + }, + { + "m": 10, + "c": 38, + "h": 0, + "e": 0, + "args": [ + "/app/llama-server", + "-m", + "/m/hybrid.gguf", + "-c", + "32768", + "-b", + "2048", + "-ub", + "512", + "--host", + "0.0.0.0", + "--port", + "8080", + "--parallel", + "1", + "-ngl", + "auto", + "--spec-type", + "draft-mtp", + "--spec-draft-sampling", + "greedy" + ], + "env": {}, + "notes": [ + "SPEC=mtp → tête MTP imposé : sortie inchangée, ~1-2 Go de VRAM en plus" + ] + }, + { + "m": 10, + "c": 39, + "h": 0, + "e": 0, + "args": [ + "/app/llama-server", + "-m", + "/m/hybrid.gguf", + "-c", + "32768", + "-b", + "2048", + "-ub", + "512", + "--host", + "0.0.0.0", + "--port", + "8080", + "--parallel", + "1", + "-ngl", + "auto", + "--spec-type", + "ngram-mod", + "--spec-ngram-mod-n-match", + "24", + "--spec-ngram-mod-n-min", + "48", + "--spec-ngram-mod-n-max", + "64" + ], + "env": {}, + "notes": [ + "SPEC=ngram → n-grammes du contexte (ngram-mod, brouillons de 48 à 64 jetons) : distribution inchangée (chaque jeton vérifié), ~16 Mo de RAM ; utile surtout en mode code, à mesurer (acceptation par nature dans /api/perf/summary)", + "tête MTP du modèle inutilisée avec SPEC=ngram : SPEC=mtp+ngram pour les deux", + "modèle hybride : chaque brouillon n-gramme copie l'état récurrent (point de reprise en RAM hôte) et le recharge s'il est rejeté — compare le temps par tour, pas seulement le débit" + ] + }, + { + "m": 10, + "c": 40, + "h": 0, + "e": 0, + "args": [ + "/app/llama-server", + "-m", + "/m/hybrid.gguf", + "-c", + "32768", + "-b", + "2048", + "-ub", + "512", + "--host", + "0.0.0.0", + "--port", + "8080", + "--parallel", + "1", + "-ngl", + "auto", + "--spec-type", + "draft-mtp,ngram-mod", + "--spec-ngram-mod-n-match", + "24", + "--spec-ngram-mod-n-min", + "48", + "--spec-ngram-mod-n-max", + "64", + "--spec-draft-sampling", + "greedy" + ], + "env": {}, + "notes": [ + "SPEC=mtp+ngram → tête MTP imposé : sortie inchangée, ~1-2 Go de VRAM en plus ; n-grammes en plus (ngram-mod, prioritaires quand ils trouvent une répétition)", + "modèle hybride : chaque brouillon n-gramme copie l'état récurrent (point de reprise en RAM hôte) et le recharge s'il est rejeté — compare le temps par tour, pas seulement le débit" + ] + }, + { + "m": 10, + "c": 41, + "h": 0, + "e": 0, + "args": [ + "/app/llama-server", + "-m", + "/m/hybrid.gguf", + "-c", + "32768", + "-b", + "2048", + "-ub", + "512", + "--host", + "0.0.0.0", + "--port", + "8080", + "--parallel", + "1", + "-ngl", + "auto", + "--spec-type", + "draft-mtp,ngram-mod", + "--spec-ngram-mod-n-match", + "24", + "--spec-ngram-mod-n-min", + "48", + "--spec-ngram-mod-n-max", + "64", + "--spec-draft-sampling", + "greedy" + ], + "env": {}, + "notes": [ + "SPEC=mtp+ngram → tête MTP imposé : sortie inchangée, ~1-2 Go de VRAM en plus ; n-grammes en plus (ngram-mod, prioritaires quand ils trouvent une répétition)", + "modèle hybride : chaque brouillon n-gramme copie l'état récurrent (point de reprise en RAM hôte) et le recharge s'il est rejeté — compare le temps par tour, pas seulement le débit" + ] + }, + { + "m": 10, + "c": 42, + "h": 0, + "e": 0, + "args": [ + "/app/llama-server", + "-m", + "/m/hybrid.gguf", + "-c", + "32768", + "-b", + "2048", + "-ub", + "512", + "--host", + "0.0.0.0", + "--port", + "8080", + "--parallel", + "1", + "-ngl", + "auto" + ], + "env": {}, + "notes": [ + "SPEC=turbo illisible (off, auto, mtp, ngram ou mtp+ngram) : sans décodage spéculatif" + ] + }, + { + "m": 10, + "c": 43, + "h": 0, + "e": 0, + "args": [ + "/app/llama-server", + "-m", + "/m/hybrid.gguf", + "-c", + "32768", + "-b", + "2048", + "-ub", + "512", + "--host", + "0.0.0.0", + "--port", + "8080", + "--parallel", + "1", + "-ngl", + "auto" + ], + "env": {}, + "notes": [ + "MODEL_DRAFT est le modèle principal lui-même : ignoré — la tête MTP intégrée sert de brouillon sans recharger les poids (retire MODEL_DRAFT du preset)" + ] + }, + { + "m": 10, + "c": 44, + "h": 0, + "e": 0, + "args": [ + "/app/llama-server", + "-m", + "/m/hybrid.gguf", + "-c", + "32768", + "-b", + "2048", + "-ub", + "512", + "--host", + "0.0.0.0", + "--port", + "8080", + "--parallel", + "1", + "-ngl", + "auto", + "--spec-type", + "draft-mtp", + "--spec-draft-n-max", + "4", + "--spec-draft-sampling", + "greedy" + ], + "env": {}, + "notes": [ + "MODEL_DRAFT est le modèle principal lui-même : ignoré — la tête MTP intégrée sert de brouillon sans recharger les poids (retire MODEL_DRAFT du preset)", + "SPEC=mtp → tête MTP imposé : sortie inchangée, ~1-2 Go de VRAM en plus" + ] + }, + { + "m": 10, + "c": 45, + "h": 0, + "e": 0, + "args": [ + "/app/llama-server", + "-m", + "/m/hybrid.gguf", + "-c", + "32768", + "-b", + "2048", + "-ub", + "512", + "--host", + "0.0.0.0", + "--port", + "8080", + "--parallel", + "1", + "-ngl", + "auto", + "--spec-type", + "draft-mtp", + "--spec-draft-sampling", + "probabilistic" + ], + "env": {}, + "notes": [ + "SPEC=mtp → tête MTP imposé : sortie inchangée, ~1-2 Go de VRAM en plus", + "SPEC_N_MAX=99 illisible (1 à 64 jetons) : défaut du moteur" + ] + }, + { + "m": 10, + "c": 46, + "h": 0, + "e": 0, + "args": [ + "/app/llama-server", + "-m", + "/m/hybrid.gguf", + "-c", + "32768", + "-b", + "2048", + "-ub", + "512", + "--host", + "0.0.0.0", + "--port", + "8080", + "--parallel", + "1", + "-ngl", + "auto" + ], + "env": {}, + "notes": [ + "SPEC=auto : le dernier lancement avec cette configuration n'a jamais répondu — sans décodage spéculatif" + ] + }, + { + "m": 10, + "c": 47, + "h": 0, + "e": 0, + "args": [ + "/app/llama-server", + "-m", + "/m/hybrid.gguf", + "-c", + "32768", + "-b", + "2048", + "-ub", + "512", + "--host", + "0.0.0.0", + "--port", + "8080", + "--parallel", + "1", + "-ngl", + "auto", + "--spec-type", + "draft-mtp", + "--spec-draft-sampling", + "greedy" + ], + "env": {}, + "notes": [ + "SPEC=mtp → tête MTP imposé : sortie inchangée, ~1-2 Go de VRAM en plus", + "SPEC_SAMPLING=aléatoire illisible (greedy ou probabilistic) : greedy" + ] + }, + { + "m": 10, + "c": 48, + "h": 0, + "e": 0, + "args": [ + "/app/llama-server", + "-m", + "/m/hybrid.gguf", + "-c", + "32768", + "-b", + "2048", + "-ub", + "512", + "--host", + "0.0.0.0", + "--port", + "8080", + "--parallel", + "1", + "-ngl", + "auto", + "--spec-type", + "ngram-mod", + "--spec-ngram-mod-n-match", + "24", + "--spec-ngram-mod-n-min", + "48", + "--spec-ngram-mod-n-max", + "64" + ], + "env": {}, + "notes": [ + "MODEL_DRAFT est le modèle principal lui-même : ignoré — la tête MTP intégrée sert de brouillon sans recharger les poids (retire MODEL_DRAFT du preset)", + "SPEC=ngram → n-grammes du contexte (ngram-mod, brouillons de 48 à 64 jetons) : distribution inchangée (chaque jeton vérifié), ~16 Mo de RAM ; utile surtout en mode code, à mesurer (acceptation par nature dans /api/perf/summary)", + "tête MTP du modèle inutilisée avec SPEC=ngram : SPEC=mtp+ngram pour les deux", + "SPEC_N_MAX et SPEC_SAMPLING ignorés : les n-grammes n'ont pas de brouillon à régler", + "modèle hybride : chaque brouillon n-gramme copie l'état récurrent (point de reprise en RAM hôte) et le recharge s'il est rejeté — compare le temps par tour, pas seulement le débit" + ] + }, + { + "m": 10, + "c": 49, + "h": 0, + "e": 0, + "args": [ + "/app/llama-server", + "-m", + "/m/hybrid.gguf", + "-c", + "32768", + "-b", + "2048", + "-ub", + "512", + "--host", + "0.0.0.0", + "--port", + "8080", + "--parallel", + "1", + "-ngl", + "auto" + ], + "env": {}, + "notes": [ + "MODEL_DRAFT est le modèle principal lui-même : ignoré — la tête MTP intégrée sert de brouillon sans recharger les poids (retire MODEL_DRAFT du preset)", + "SPEC=auto : le dernier lancement avec cette configuration n'a jamais répondu — sans décodage spéculatif" + ] + }, + { + "m": 10, + "c": 50, + "h": 0, + "e": 0, + "args": [ + "/app/llama-server", + "-m", + "/m/hybrid.gguf", + "-c", + "32768", + "-b", + "2048", + "-ub", + "512", + "--host", + "0.0.0.0", + "--port", + "8080", + "--parallel", + "1", + "-ngl", + "auto", + "--spec-type", + "draft-mtp,ngram-mod", + "--spec-ngram-mod-n-match", + "24", + "--spec-ngram-mod-n-min", + "48", + "--spec-ngram-mod-n-max", + "64", + "--spec-draft-sampling", + "greedy" + ], + "env": {}, + "notes": [ + "MODEL_DRAFT est le modèle principal lui-même : ignoré — la tête MTP intégrée sert de brouillon sans recharger les poids (retire MODEL_DRAFT du preset)", + "SPEC=mtp+ngram → tête MTP imposé : sortie inchangée, ~1-2 Go de VRAM en plus ; n-grammes en plus (ngram-mod, prioritaires quand ils trouvent une répétition)", + "modèle hybride : chaque brouillon n-gramme copie l'état récurrent (point de reprise en RAM hôte) et le recharge s'il est rejeté — compare le temps par tour, pas seulement le débit" + ] + }, + { + "m": 10, + "c": 51, + "h": 0, + "e": 0, + "args": [ + "/app/llama-server", + "-m", + "/m/hybrid.gguf", + "-c", + "32768", + "-b", + "2048", + "-ub", + "512", + "--host", + "0.0.0.0", + "--port", + "8080", + "--parallel", + "1", + "-ngl", + "auto" + ], + "env": {}, + "notes": null + }, + { + "m": 10, + "c": 52, + "h": 0, + "e": 0, + "args": [ + "/app/llama-server", + "-m", + "/m/hybrid.gguf", + "-c", + "32768", + "-b", + "2048", + "-ub", + "512", + "--host", + "0.0.0.0", + "--port", + "8080", + "--parallel", + "1", + "-ngl", + "auto" + ], + "env": { + "CUDA_SCALE_LAUNCH_QUEUES": "2x" + }, + "notes": [ + "CUDA_SCALE_LAUNCH_QUEUES=2x (CUDA_LAUNCH_QUEUES)" + ] + }, + { + "m": 10, + "c": 53, + "h": 0, + "e": 0, + "args": [ + "/app/llama-server", + "-m", + "/m/hybrid.gguf", + "-c", + "32768", + "-b", + "2048", + "-ub", + "512", + "--host", + "0.0.0.0", + "--port", + "8080", + "--parallel", + "1", + "-ngl", + "auto" + ], + "env": {}, + "notes": [ + "CUDA_LAUNCH_QUEUES=8x ignoré (0.25x, 0.5x, 2x, 4x ou off) → automatique" + ] + }, + { + "m": 10, + "c": 54, + "h": 0, + "e": 0, + "args": [ + "/app/llama-server", + "-m", + "/m/hybrid.gguf", + "-c", + "32768", + "-b", + "2048", + "-ub", + "512", + "--host", + "0.0.0.0", + "--port", + "8080", + "--parallel", + "1", + "-ngl", + "auto" + ], + "env": {}, + "notes": null + }, + { + "m": 10, + "c": 55, + "h": 0, + "e": 0, + "args": [ + "/app/llama-server", + "-m", + "/m/hybrid.gguf", + "-c", + "32768", + "-b", + "2048", + "-ub", + "512", + "--host", + "0.0.0.0", + "--port", + "8080", + "--parallel", + "1", + "-ngl", + "auto" + ], + "env": { + "CUDA_DEVICE_ORDER": "PCI_BUS_ID", + "CUDA_VISIBLE_DEVICES": "1,0" + }, + "notes": null + }, + { + "m": 10, + "c": 56, + "h": 0, + "e": 0, + "args": [ + "/app/llama-server", + "-m", + "/m/hybrid.gguf", + "-c", + "32768", + "-b", + "2048", + "-ub", + "512", + "--host", + "0.0.0.0", + "--port", + "8080", + "--parallel", + "1", + "-ngl", + "auto", + "--fit-target", + "1024,3072" + ], + "env": {}, + "notes": [ + "--fit-target 1024,3072 (FIT_TARGET, Mio libres par carte, dans l'ordre vu par le moteur) — vérifie « offloaded N/N layers » dans le journal" + ] + }, + { + "m": 10, + "c": 57, + "h": 0, + "e": 0, + "args": [ + "/app/llama-server", + "-m", + "/m/hybrid.gguf", + "-c", + "32768", + "-b", + "2048", + "-ub", + "512", + "--host", + "0.0.0.0", + "--port", + "8080", + "--parallel", + "1", + "-ngl", + "auto", + "--fit-target", + "1024" + ], + "env": {}, + "notes": [ + "--fit-target 1024 (FIT_TARGET, Mio libres par carte, dans l'ordre vu par le moteur) — vérifie « offloaded N/N layers » dans le journal ; marges sous 1024 Mio remontées à 1024" + ] + }, + { + "m": 10, + "c": 58, + "h": 0, + "e": 0, + "args": [ + "/app/llama-server", + "-m", + "/m/hybrid.gguf", + "-c", + "32768", + "-b", + "2048", + "-ub", + "512", + "--host", + "0.0.0.0", + "--port", + "8080", + "--parallel", + "1", + "-ngl", + "auto" + ], + "env": {}, + "notes": [ + "FIT_TARGET=1024,,3072 ignoré : des Mio par carte, ex. 1024,3072 (8 valeurs au plus)" + ] + }, + { + "m": 10, + "c": 59, + "h": 0, + "e": 0, + "args": [ + "/app/llama-server", + "-m", + "/m/hybrid.gguf", + "-c", + "32768", + "-b", + "2048", + "-ub", + "512", + "--host", + "0.0.0.0", + "--port", + "8080", + "--parallel", + "1", + "-ngl", + "auto" + ], + "env": {}, + "notes": [ + "FIT_TARGET=1,2,3,4,5,6,7,8,9 ignoré : des Mio par carte, ex. 1024,3072 (8 valeurs au plus)" + ] + }, + { + "m": 10, + "c": 60, + "h": 0, + "e": 0, + "args": [ + "/app/llama-server", + "-m", + "/m/hybrid.gguf", + "-c", + "0", + "-b", + "2048", + "-ub", + "512", + "--host", + "0.0.0.0", + "--port", + "8080", + "--parallel", + "1", + "-ngl", + "auto" + ], + "env": {}, + "notes": [ + "FIT_TARGET=2048/4096 ignoré : contexte « 0 » non fixé — --fit pourrait le réduire pour tenir ; donne un CTX chiffré" + ] + }, + { + "m": 10, + "c": 61, + "h": 0, + "e": 0, + "args": [ + "/app/llama-server", + "-m", + "/m/hybrid.gguf", + "-c", + "32768", + "-b", + "2048", + "-ub", + "512", + "--host", + "0.0.0.0", + "--port", + "8080", + "--parallel", + "1", + "-ngl", + "30" + ], + "env": {}, + "notes": [ + "FIT_TARGET=2048 ignoré : --fit inactif (couches GPU fixées à 30), la marge ne servirait à rien" + ] + }, + { + "m": 10, + "c": 62, + "h": 0, + "e": 0, + "args": [ + "/app/llama-server", + "-m", + "/m/hybrid.gguf", + "-c", + "32768", + "-b", + "2048", + "-ub", + "512", + "--host", + "0.0.0.0", + "--port", + "8080", + "--parallel", + "1", + "-ngl", + "auto" + ], + "env": { + "GGML_OP_OFFLOAD_MIN_BATCH": "128" + }, + "notes": [ + "GGML_OP_OFFLOAD_MIN_BATCH=128 (OP_OFFLOAD_MIN_BATCH) : lots de moins de 128 jetons calculés sur CPU pour les poids restés en RAM" + ] + }, + { + "m": 10, + "c": 63, + "h": 0, + "e": 0, + "args": [ + "/app/llama-server", + "-m", + "/m/hybrid.gguf", + "-c", + "32768", + "-b", + "2048", + "-ub", + "512", + "--host", + "0.0.0.0", + "--port", + "8080", + "--parallel", + "1", + "-ngl", + "auto" + ], + "env": {}, + "notes": null + }, + { + "m": 10, + "c": 64, + "h": 0, + "e": 0, + "args": [ + "/app/llama-server", + "-m", + "/m/hybrid.gguf", + "-c", + "32768", + "-b", + "2048", + "-ub", + "512", + "--host", + "0.0.0.0", + "--port", + "8080", + "--parallel", + "1", + "-ngl", + "auto" + ], + "env": { + "GGML_CUDA_GRAPH_OPT": "1" + }, + "notes": [ + "GGML_CUDA_GRAPH_OPT=1 (CUDA_GRAPH_OPT, expérimental) — si le moteur s'arrête sur un GGML_ASSERT, CUDA_GRAPH_OPT=off puis redémarre" + ] + }, + { + "m": 10, + "c": 65, + "h": 0, + "e": 0, + "args": [ + "/app/llama-server", + "-m", + "/m/hybrid.gguf", + "-c", + "32768", + "-b", + "2048", + "-ub", + "512", + "--host", + "0.0.0.0", + "--port", + "8080", + "--parallel", + "1", + "-ngl", + "auto" + ], + "env": {}, + "notes": [ + "CUDA_GRAPH_OPT=oui ignoré (on ou off)" + ] + }, + { + "m": 10, + "c": 66, + "h": 0, + "e": 0, + "args": [ + "/app/llama-server", + "-m", + "/m/hybrid.gguf", + "-c", + "32768", + "-b", + "2048", + "-ub", + "512", + "--host", + "0.0.0.0", + "--port", + "8080", + "--parallel", + "1", + "-ngl", + "auto" + ], + "env": {}, + "notes": null + }, + { + "m": 10, + "c": 67, + "h": 0, + "e": 0, + "args": [ + "/app/llama-server", + "-m", + "/m/hybrid.gguf", + "-c", + "32768", + "-b", + "2048", + "-ub", + "512", + "--host", + "0.0.0.0", + "--port", + "8080", + "--parallel", + "1", + "-ngl", + "auto", + "--ctx-checkpoints", + "8" + ], + "env": {}, + "notes": null + }, + { + "m": 10, + "c": 68, + "h": 0, + "e": 0, + "args": [ + "/app/llama-server", + "-m", + "/m/hybrid.gguf", + "-c", + "32768", + "-b", + "2048", + "-ub", + "512", + "--host", + "0.0.0.0", + "--port", + "8080", + "--parallel", + "1", + "-ngl", + "auto" + ], + "env": {}, + "notes": [ + "CTX_CHECKPOINTS=beaucoup illisible (un nombre de points de reprise par slot, ≥ 0) : ignoré" + ] + }, + { + "m": 10, + "c": 69, + "h": 0, + "e": 0, + "args": [ + "/app/llama-server", + "-m", + "/m/hybrid.gguf", + "-c", + "32768", + "-b", + "2048", + "-ub", + "512", + "--host", + "0.0.0.0", + "--port", + "8080", + "--parallel", + "1", + "-ngl", + "auto", + "--checkpoint-min-step", + "1024" + ], + "env": {}, + "notes": null + }, + { + "m": 10, + "c": 70, + "h": 0, + "e": 0, + "args": [ + "/app/llama-server", + "-m", + "/m/hybrid.gguf", + "-c", + "32768", + "-b", + "2048", + "-ub", + "512", + "--host", + "0.0.0.0", + "--port", + "8080", + "--parallel", + "1", + "-ngl", + "auto" + ], + "env": {}, + "notes": [ + "CKPT_MIN_STEP=0 refusé (un nombre de jetons > 0 ; 0 laisserait le moteur jeter le point de reprise du début de la conversation) : défaut du moteur" + ] + }, + { + "m": 10, + "c": 71, + "h": 0, + "e": 0, + "args": [ + "/app/llama-server", + "-m", + "/m/hybrid.gguf", + "-c", + "32768", + "-b", + "2048", + "-ub", + "512", + "--host", + "0.0.0.0", + "--port", + "8080", + "--parallel", + "1", + "-ngl", + "auto" + ], + "env": {}, + "notes": [ + "SLOT_PERSIST refusé (dossier des slots indisponible) : l'état du slot ne sera pas gardé à la bascule de preset" + ] + }, + { + "m": 10, + "c": 72, + "h": 0, + "e": 0, + "args": [ + "/app/llama-server", + "-m", + "/m/hybrid.gguf", + "-c", + "32768", + "-b", + "2048", + "-ub", + "512", + "--host", + "127.0.0.1", + "--port", + "8080", + "--parallel", + "1", + "-ngl", + "auto" + ], + "env": {}, + "notes": [ + "SLOT_PERSIST refusé (dossier des slots indisponible) : l'état du slot ne sera pas gardé à la bascule de preset" + ] + }, + { + "m": 10, + "c": 73, + "h": 0, + "e": 0, + "args": [ + "/app/llama-server", + "-m", + "/m/hybrid.gguf", + "-c", + "32768", + "-b", + "2048", + "-ub", + "512", + "--host", + "127.0.0.1", + "--port", + "8080", + "--parallel", + "1", + "-ngl", + "auto", + "--spec-type", + "draft-mtp", + "--spec-draft-sampling", + "greedy" + ], + "env": {}, + "notes": [ + "SLOT_PERSIST refusé (dossier des slots indisponible) : l'état du slot ne sera pas gardé à la bascule de preset", + "SPEC=mtp → tête MTP imposé : sortie inchangée, ~1-2 Go de VRAM en plus" + ] + }, + { + "m": 10, + "c": 74, + "h": 0, + "e": 0, + "args": [ + "/app/llama-server", + "-m", + "/m/hybrid.gguf", + "-c", + "32768", + "-b", + "2048", + "-ub", + "512", + "--host", + "0.0.0.0", + "--port", + "8080", + "--parallel", + "1", + "-ngl", + "auto", + "--load-mode", + "none" + ], + "env": {}, + "notes": null + }, + { + "m": 10, + "c": 75, + "h": 0, + "e": 0, + "args": [ + "/app/llama-server", + "-m", + "/m/hybrid.gguf", + "-c", + "32768", + "-b", + "2048", + "-ub", + "512", + "--host", + "0.0.0.0", + "--port", + "8080", + "--parallel", + "1", + "-ngl", + "auto", + "--load-mode", + "mlock" + ], + "env": {}, + "notes": null + }, + { + "m": 10, + "c": 76, + "h": 0, + "e": 0, + "args": [ + "/app/llama-server", + "-m", + "/m/hybrid.gguf", + "-c", + "32768", + "-b", + "2048", + "-ub", + "512", + "--host", + "0.0.0.0", + "--port", + "8080", + "--parallel", + "1", + "-ngl", + "auto", + "--load-mode", + "mmap+mlock" + ], + "env": {}, + "notes": null + }, + { + "m": 10, + "c": 77, + "h": 0, + "e": 0, + "args": [ + "/app/llama-server", + "-m", + "/m/hybrid.gguf", + "-c", + "32768", + "-b", + "2048", + "-ub", + "512", + "--host", + "0.0.0.0", + "--port", + "8080", + "--parallel", + "1", + "-ngl", + "auto", + "--load-mode", + "none" + ], + "env": {}, + "notes": null + }, + { + "m": 10, + "c": 78, + "h": 0, + "e": 0, + "args": [ + "/app/llama-server", + "-m", + "/m/hybrid.gguf", + "-c", + "32768", + "-b", + "2048", + "-ub", + "512", + "--host", + "0.0.0.0", + "--port", + "8080", + "--parallel", + "1", + "-ngl", + "auto", + "--load-mode", + "mmap" + ], + "env": {}, + "notes": null + }, + { + "m": 10, + "c": 79, + "h": 0, + "e": 0, + "args": [ + "/app/llama-server", + "-m", + "/m/hybrid.gguf", + "-c", + "32768", + "-b", + "2048", + "-ub", + "512", + "--host", + "0.0.0.0", + "--port", + "8080", + "--parallel", + "1", + "-ngl", + "auto", + "--load-mode", + "dio" + ], + "env": {}, + "notes": null + }, + { + "m": 10, + "c": 80, + "h": 0, + "e": 0, + "args": [ + "/app/llama-server", + "-m", + "/m/hybrid.gguf", + "-c", + "32768", + "-b", + "2048", + "-ub", + "512", + "--host", + "0.0.0.0", + "--port", + "8080", + "--parallel", + "1", + "-ngl", + "auto", + "--load-mode=mlock" + ], + "env": {}, + "notes": null + }, + { + "m": 10, + "c": 81, + "h": 0, + "e": 0, + "args": [ + "/app/llama-server", + "-m", + "/m/hybrid.gguf", + "-c", + "32768", + "-b", + "2048", + "-ub", + "512", + "--host", + "0.0.0.0", + "--port", + "8080", + "-ngl", + "30", + "--parallel", + "4" + ], + "env": {}, + "notes": null + }, + { + "m": 10, + "c": 82, + "h": 0, + "e": 0, + "args": [ + "/app/llama-server", + "-m", + "/m/hybrid.gguf", + "-c", + "32768", + "-b", + "2048", + "-ub", + "512", + "--host", + "0.0.0.0", + "--port", + "8080", + "--parallel", + "1", + "-ngl", + "auto", + "-t", + "6", + "-tb", + "3" + ], + "env": {}, + "notes": null + }, + { + "m": 10, + "c": 83, + "h": 0, + "e": 0, + "args": [ + "/app/llama-server", + "-m", + "/m/hybrid.gguf", + "-c", + "32768", + "-tb", + "0", + "-b", + "2048", + "-ub", + "512", + "--host", + "0.0.0.0", + "--port", + "8080", + "--parallel", + "1", + "-ngl", + "auto", + "-Cb", + "0xff", + "--poll-batch", + "1" + ], + "env": {}, + "notes": [ + "EXTRA_ARGS règle le CPU du batch (-Cb, --poll-batch…) : -tb 0 posé pour que llama.cpp le garde (tous les threads logiques) ; THREADS_BATCH pour choisir le nombre" + ] + }, + { + "m": 10, + "c": 84, + "h": 0, + "e": 0, + "args": [ + "/app/llama-server", + "-m", + "/m/hybrid.gguf", + "-c", + "32768", + "-t", + "6", + "-tb", + "6", + "-b", + "2048", + "-ub", + "512", + "--host", + "0.0.0.0", + "--port", + "8080", + "--parallel", + "1", + "-ngl", + "auto", + "--prio-batch", + "2" + ], + "env": {}, + "notes": null + }, + { + "m": 10, + "c": 85, + "h": 0, + "e": 0, + "args": [ + "/app/llama-server", + "-m", + "/m/hybrid.gguf", + "-c", + "32768", + "-b", + "2048", + "-ub", + "512", + "--host", + "0.0.0.0", + "--port", + "8080", + "--parallel", + "1", + "-ngl", + "auto", + "--cache-reuse", + "256", + "--context-shift" + ], + "env": {}, + "notes": [ + "avertissement : --context-shift (EXTRA_ARGS) — contexte plein, le moteur jette des jetons anciens et le modèle perd une partie de la conversation sans le savoir.", + "avertissement : --cache-reuse 256 (EXTRA_ARGS) — réutilise des morceaux de cache calculés sous un autre préfixe : les sorties ne sont plus exactement celles d'un calcul complet." + ] + }, + { + "m": 10, + "c": 86, + "h": 0, + "e": 0, + "args": [ + "/app/llama-server", + "-m", + "/m/hybrid.gguf", + "-c", + "32768", + "-b", + "2048", + "-ub", + "512", + "--host", + "0.0.0.0", + "--port", + "8080", + "--parallel", + "1", + "-ngl", + "auto", + "-ctk", + "q8_0", + "-ctv", + "q8_0" + ], + "env": {}, + "notes": [ + "cache KV q8_0/q8_0 (défini par EXTRA_ARGS) : modifie légèrement les sorties par rapport à f16 — choix de l'utilisateur, laissé tel quel." + ] + }, + { + "m": 10, + "c": 87, + "h": 0, + "e": 0, + "args": [ + "/app/llama-server", + "-m", + "/m/hybrid.gguf", + "-c", + "32768", + "-b", + "2048", + "-ub", + "512", + "--host", + "0.0.0.0", + "--port", + "8080", + "--parallel", + "1", + "-ngl", + "auto", + "-ot", + "exps=CPU" + ], + "env": {}, + "notes": null + }, + { + "m": 10, + "c": 88, + "h": 0, + "e": 0, + "args": [ + "/app/llama-server", + "-m", + "/m/hybrid.gguf", + "-c", + "32768", + "-b", + "2048", + "-ub", + "512", + "--host", + "0.0.0.0", + "--port", + "8080", + "--parallel", + "1", + "-ngl", + "auto", + "-ot", + "blk\\.(1|2)\\.ffn_.*_exps=CPU", + "-ub", + "512" + ], + "env": {}, + "notes": null + }, + { + "m": 10, + "c": 89, + "h": 0, + "e": 0, + "args": [ + "/app/llama-server", + "-m", + "/m/hybrid.gguf", + "-c", + "32768", + "-b", + "2048", + "-ub", + "512", + "--host", + "0.0.0.0", + "--port", + "8080", + "--parallel", + "1", + "-ngl", + "auto", + "--n-cpu-moe", + "20" + ], + "env": {}, + "notes": null + }, + { + "m": 10, + "c": 90, + "h": 0, + "e": 0, + "args": [ + "/app/llama-server", + "-m", + "/m/hybrid.gguf", + "-c", + "32768", + "-b", + "2048", + "-ub", + "512", + "--host", + "0.0.0.0", + "--port", + "8080", + "--parallel", + "1", + "-ngl", + "auto", + "--cpu-moe" + ], + "env": {}, + "notes": null + }, + { + "m": 10, + "c": 91, + "h": 0, + "e": 0, + "args": [ + "/app/llama-server", + "-m", + "/m/hybrid.gguf", + "-c", + "32768", + "-b", + "2048", + "-ub", + "512", + "--host", + "0.0.0.0", + "--port", + "8080", + "--parallel", + "1", + "-ngl", + "auto", + "-sm", + "row" + ], + "env": {}, + "notes": null + }, + { + "m": 10, + "c": 92, + "h": 0, + "e": 0, + "args": [ + "/app/llama-server", + "-m", + "/m/hybrid.gguf", + "-c", + "32768", + "-b", + "2048", + "-ub", + "512", + "--host", + "0.0.0.0", + "--port", + "8080", + "--parallel", + "1", + "-ngl", + "auto", + "-ts", + "1,1" + ], + "env": {}, + "notes": null + }, + { + "m": 10, + "c": 93, + "h": 0, + "e": 0, + "args": [ + "/app/llama-server", + "-m", + "/m/hybrid.gguf", + "-c", + "32768", + "-b", + "2048", + "-ub", + "512", + "--host", + "0.0.0.0", + "--port", + "8080", + "--parallel", + "1", + "-ngl", + "auto", + "-dev", + "CUDA0,CUDA1" + ], + "env": { + "CUDA_SCALE_LAUNCH_QUEUES": "4x" + }, + "notes": [ + "CUDA_SCALE_LAUNCH_QUEUES=4x (2 GPU, parallélisme pipeline possible) — CUDA_LAUNCH_QUEUES=off pour s'en passer" + ] + }, + { + "m": 10, + "c": 94, + "h": 0, + "e": 0, + "args": [ + "/app/llama-server", + "-m", + "/m/hybrid.gguf", + "-c", + "32768", + "-b", + "2048", + "-ub", + "512", + "--host", + "0.0.0.0", + "--port", + "8080", + "--parallel", + "1", + "-ngl", + "auto", + "-dev", + "Vulkan0" + ], + "env": {}, + "notes": null + }, + { + "m": 10, + "c": 95, + "h": 0, + "e": 0, + "args": [ + "/app/llama-server", + "-m", + "/m/hybrid.gguf", + "-c", + "32768", + "-b", + "2048", + "-ub", + "512", + "--host", + "0.0.0.0", + "--port", + "8080", + "--parallel", + "1", + "-ngl", + "auto", + "--fit", + "off" + ], + "env": {}, + "notes": null + }, + { + "m": 10, + "c": 96, + "h": 0, + "e": 0, + "args": [ + "/app/llama-server", + "-m", + "/m/hybrid.gguf", + "-c", + "32768", + "-b", + "2048", + "-ub", + "512", + "--host", + "0.0.0.0", + "--port", + "8080", + "--parallel", + "1", + "-ngl", + "auto", + "--mmproj", + "/m/x.gguf" + ], + "env": {}, + "notes": null + }, + { + "m": 10, + "c": 97, + "h": 0, + "e": 0, + "args": [ + "/app/llama-server", + "-m", + "/m/hybrid.gguf", + "-c", + "32768", + "-b", + "2048", + "-ub", + "512", + "--host", + "0.0.0.0", + "--port", + "8080", + "--parallel", + "1", + "-ngl", + "auto", + "--spec-synth-len", + "4" + ], + "env": {}, + "notes": [ + "avertissement : --spec-synth-len — le moteur accepte des jetons du brouillon AU HASARD, sans vérification : les réponses ne sont plus celles du modèle. Réservé aux mesures, retire-le." + ] + }, + { + "m": 10, + "c": 98, + "h": 0, + "e": 0, + "args": [ + "/app/llama-server", + "-m", + "/m/hybrid.gguf", + "-c", + "32768", + "-b", + "2048", + "-ub", + "512", + "--host", + "0.0.0.0", + "--port", + "8080", + "--parallel", + "1", + "-ngl", + "auto", + "--slot-save-path", + "/x" + ], + "env": {}, + "notes": null + }, + { + "m": 10, + "c": 99, + "h": 0, + "e": 0, + "args": [ + "/app/llama-server", + "-m", + "/m/hybrid.gguf", + "-c", + "32768", + "-b", + "2048", + "-ub", + "512", + "--host", + "0.0.0.0", + "--port", + "8080", + "--parallel", + "1", + "-ngl", + "auto", + "-c", + "0" + ], + "env": {}, + "notes": null + }, + { + "m": 10, + "c": 100, + "h": 0, + "e": 0, + "args": [ + "/app/llama-server", + "-m", + "/m/hybrid.gguf", + "-c", + "32768", + "-b", + "2048", + "-ub", + "512", + "--host", + "0.0.0.0", + "--port", + "8080", + "--parallel", + "1", + "-ngl", + "auto", + "--host", + "127.0.0.1", + "--port", + "9000" + ], + "env": {}, + "notes": null + }, + { + "m": 10, + "c": 101, + "h": 0, + "e": 0, + "args": [ + "/app/llama-server", + "-m", + "/m/hybrid.gguf", + "-c", + "32768", + "-b", + "2048", + "-ub", + "512", + "--host", + "0.0.0.0", + "--port", + "8080", + "--parallel", + "1", + "-ngl", + "auto", + "--spec-type", + "draft-mtp", + "--spec-draft-sampling", + "greedy", + "--mirostat", + "2" + ], + "env": {}, + "notes": [ + "SPEC=mtp → tête MTP imposé : sortie inchangée, ~1-2 Go de VRAM en plus", + "SPEC_SAMPLING=probabilistic refusé avec --mirostat 2 (cet échantillonneur avancerait sur des jetons rejetés) : greedy" + ] + }, + { + "m": 10, + "c": 102, + "h": 0, + "e": 0, + "args": [ + "/app/llama-server", + "-m", + "/m/hybrid.gguf", + "-c", + "32768", + "-b", + "2048", + "-ub", + "512", + "--host", + "0.0.0.0", + "--port", + "8080", + "--parallel", + "1", + "-ngl", + "auto", + "--chat-template-kwargs", + "{\"preserve_reasoning\":true}" + ], + "env": {}, + "notes": [ + "--chat-template-kwargs fixe déjà preserve_reasoning : REASONING_PRESERVE ignoré" + ] + }, + { + "m": 10, + "c": 103, + "h": 0, + "e": 0, + "args": [ + "/app/llama-server", + "-m", + "/m/hybrid.gguf", + "-c", + "32768", + "-b", + "2048", + "-ub", + "512", + "--host", + "0.0.0.0", + "--port", + "8080", + "--parallel", + "1", + "-ngl", + "auto", + "--spec-type", + "ngram-mod" + ], + "env": {}, + "notes": [ + "décodage spéculatif déjà réglé dans EXTRA_ARGS ou LLAMA_ARG_* : SPEC ignoré" + ] + }, + { + "m": 10, + "c": 104, + "h": 0, + "e": 0, + "args": [ + "/app/llama-server", + "-m", + "/m/hybrid.gguf", + "-c", + "32768", + "-b", + "2048", + "-ub", + "512", + "--host", + "0.0.0.0", + "--port", + "8080", + "--parallel", + "1", + "-ngl", + "auto", + "--spec-draft-n-max", + "8" + ], + "env": {}, + "notes": [ + "SPEC=ngram : --spec-draft-n-max dans EXTRA_ARGS (brouillon réglé à la main) — sans décodage spéculatif" + ] + }, + { + "m": 10, + "c": 105, + "h": 0, + "e": 0, + "args": [ + "/app/llama-server", + "-m", + "/m/hybrid.gguf", + "-c", + "32768", + "-b", + "2048", + "-ub", + "512", + "--host", + "0.0.0.0", + "--port", + "8080", + "--parallel", + "1", + "-ngl", + "auto", + "-ot", + "attn=CUDA1" + ], + "env": {}, + "notes": null + }, + { + "m": 10, + "c": 106, + "h": 0, + "e": 0, + "args": [ + "/app/llama-server", + "-m", + "/m/hybrid.gguf", + "-c", + "32768", + "-b", + "2048", + "-ub", + "512", + "--host", + "0.0.0.0", + "--port", + "8080", + "--parallel", + "1", + "-ngl", + "auto", + "-fa", + "off" + ], + "env": {}, + "notes": [ + "SPLIT_MODE=tensor refusé (Flash Attention coupée (-fa off) : le mode tensor l'exige) : découpe par couches, comme sans la clé" + ] + }, + { + "m": 10, + "c": 107, + "h": 0, + "e": 0, + "args": [ + "/app/llama-server", + "-m", + "/m/hybrid.gguf", + "-c", + "32768", + "-b", + "2048", + "-ub", + "512", + "--host", + "0.0.0.0", + "--port", + "8080", + "--parallel", + "1", + "-ngl", + "auto", + "-nkvo" + ], + "env": {}, + "notes": null + }, + { + "m": 10, + "c": 108, + "h": 0, + "e": 0, + "args": [ + "/app/llama-server", + "-m", + "/m/hybrid.gguf", + "-c", + "32768", + "-b", + "2048", + "-ub", + "512", + "--host", + "0.0.0.0", + "--port", + "8080", + "--parallel", + "1", + "-ngl", + "auto", + "--no-context-shift" + ], + "env": {}, + "notes": null + }, + { + "m": 10, + "c": 109, + "h": 0, + "e": 0, + "args": [ + "/app/llama-server", + "-m", + "/m/hybrid.gguf", + "-c", + "32768", + "-b", + "2048", + "-ub", + "512", + "--host", + "0.0.0.0", + "--port", + "8080", + "-ngl", + "auto", + "-c", + "8192", + "-np", + "2" + ], + "env": {}, + "notes": [ + "SIDE_SLOT refusé (-c est réglé dans EXTRA_ARGS) : un seul slot, comme sans la clé" + ] + }, + { + "m": 10, + "c": 110, + "h": 0, + "e": 0, + "args": [ + "/app/llama-server", + "-m", + "/m/hybrid.gguf", + "-c", + "32768", + "-b", + "2048", + "-ub", + "512", + "--host", + "0.0.0.0", + "--port", + "8080", + "--parallel", + "1", + "-ngl", + "auto" + ], + "env": {}, + "notes": [ + "SPLIT_MODE=tensor refusé (preset externe) : découpe par couches, comme sans la clé", + "SIDE_SLOT refusé (preset externe) : un seul slot, comme sans la clé" + ] + }, + { + "m": 10, + "c": 111, + "h": 0, + "e": 0, + "args": [ + "/app/llama-server", + "-m", + "/m/hybrid.gguf", + "-c", + "40000", + "-b", + "2048", + "-ub", + "512", + "--host", + "0.0.0.0", + "--port", + "8080", + "--parallel", + "1", + "-ngl", + "auto", + "-ctk", + "q4_0", + "-ctv", + "q4_0" + ], + "env": {}, + "notes": [ + "NGL=999 (sentinelle « toutes les couches ») → -ngl auto : ce moteur mesure lui-même la VRAM libre. NGL=all pour forcer l'ancien comportement.", + "SPEC=auto : le dernier lancement avec cette configuration n'a jamais répondu — sans décodage spéculatif", + "cache KV q4_0/q4_0 (KV_TYPE) : perte mesurable par rapport à f16 — choix de l'utilisateur, laissé tel quel." + ] + }, + { + "m": 11, + "c": 0, + "h": 0, + "e": 0, + "args": [ + "/app/llama-server", + "-m", + "/m/dense.gguf", + "-c", + "32768", + "-b", + "2048", + "-ub", + "512", + "--host", + "0.0.0.0", + "--port", + "8080", + "--parallel", + "1", + "-ngl", + "auto" + ], + "env": {}, + "notes": null + }, + { + "m": 11, + "c": 1, + "h": 0, + "e": 0, + "args": [ + "/app/llama-server", + "-m", + "/m/dense.gguf", + "-c", + "65536", + "-b", + "4096", + "-ub", + "2048", + "--host", + "127.0.0.1", + "--port", + "8081", + "--parallel", + "1", + "-ngl", + "auto" + ], + "env": {}, + "notes": [ + "NGL=999 (sentinelle « toutes les couches ») → -ngl auto : ce moteur mesure lui-même la VRAM libre. NGL=all pour forcer l'ancien comportement." + ] + }, + { + "m": 11, + "c": 2, + "h": 0, + "e": 0, + "args": [ + "/app/llama-server", + "-m", + "/m/dense.gguf", + "-c", + "32768", + "-b", + "2048", + "-ub", + "512", + "--host", + "0.0.0.0", + "--port", + "8080", + "--parallel", + "1" + ], + "env": {}, + "notes": null + }, + { + "m": 11, + "c": 3, + "h": 0, + "e": 0, + "args": [ + "/app/llama-server", + "-m", + "/m/dense.gguf", + "-c", + "32768", + "-b", + "2048", + "-ub", + "512", + "--host", + "0.0.0.0", + "--port", + "8080", + "--parallel", + "1", + "-ngl", + "40" + ], + "env": {}, + "notes": null + }, + { + "m": 11, + "c": 4, + "h": 0, + "e": 0, + "args": [ + "/app/llama-server", + "-m", + "/m/dense.gguf", + "-c", + "32768", + "-b", + "2048", + "-ub", + "512", + "--host", + "0.0.0.0", + "--port", + "8080", + "--parallel", + "1", + "-ngl", + "all" + ], + "env": {}, + "notes": null + }, + { + "m": 11, + "c": 5, + "h": 0, + "e": 0, + "args": [ + "/app/llama-server", + "-m", + "/m/dense.gguf", + "-c", + "32768", + "-b", + "2048", + "-ub", + "512", + "--host", + "0.0.0.0", + "--port", + "8080", + "--parallel", + "1", + "-ngl", + "auto" + ], + "env": {}, + "notes": [ + "NGL=999 (sentinelle « toutes les couches ») → -ngl auto : ce moteur mesure lui-même la VRAM libre. NGL=all pour forcer l'ancien comportement." + ] + }, + { + "m": 11, + "c": 6, + "h": 0, + "e": 0, + "args": [ + "/app/llama-server", + "-m", + "/m/dense.gguf", + "-c", + "32768", + "-t", + "8", + "-tb", + "4", + "-b", + "2048", + "-ub", + "512", + "--host", + "0.0.0.0", + "--port", + "8080", + "--parallel", + "1", + "-ngl", + "auto" + ], + "env": {}, + "notes": null + }, + { + "m": 11, + "c": 7, + "h": 0, + "e": 0, + "args": [ + "/app/llama-server", + "-m", + "/m/dense.gguf", + "-c", + "32768", + "-b", + "2048", + "-ub", + "512", + "--host", + "0.0.0.0", + "--port", + "8080", + "--parallel", + "1", + "-ngl", + "auto" + ], + "env": {}, + "notes": [ + "THREADS=abc ignoré (pas un nombre de threads ≥ 0) → automatique", + "THREADS_BATCH=-2 ignoré (pas un nombre de threads ≥ 0) → automatique" + ] + }, + { + "m": 11, + "c": 8, + "h": 0, + "e": 0, + "args": [ + "/app/llama-server", + "-m", + "/m/dense.gguf", + "-c", + "32768", + "-b", + "2048", + "-ub", + "512", + "--host", + "0.0.0.0", + "--port", + "8080", + "--parallel", + "1", + "-ngl", + "auto" + ], + "env": {}, + "notes": null + }, + { + "m": 11, + "c": 9, + "h": 0, + "e": 0, + "args": [ + "/app/llama-server", + "-m", + "/m/dense.gguf", + "-c", + "32768", + "-b", + "2048", + "-ub", + "512", + "--host", + "0.0.0.0", + "--port", + "8080", + "--parallel", + "1", + "-ngl", + "auto", + "-ctk", + "q8_0", + "-ctv", + "q8_0" + ], + "env": {}, + "notes": [ + "cache KV q8_0/q8_0 (KV_TYPE) : modifie légèrement les sorties par rapport à f16 — choix de l'utilisateur, laissé tel quel." + ] + }, + { + "m": 11, + "c": 10, + "h": 0, + "e": 0, + "args": [ + "/app/llama-server", + "-m", + "/m/dense.gguf", + "-c", + "32768", + "-b", + "2048", + "-ub", + "512", + "--host", + "0.0.0.0", + "--port", + "8080", + "--parallel", + "1", + "-ngl", + "auto", + "-ctk", + "q4_0", + "-ctv", + "f16" + ], + "env": {}, + "notes": [ + "cache KV q4_0/f16 (KV_TYPE) : perte mesurable par rapport à f16 — choix de l'utilisateur, laissé tel quel." + ] + }, + { + "m": 11, + "c": 11, + "h": 0, + "e": 0, + "args": [ + "/app/llama-server", + "-m", + "/m/dense.gguf", + "-c", + "32768", + "-b", + "2048", + "-ub", + "512", + "--host", + "0.0.0.0", + "--port", + "8080", + "--parallel", + "1", + "-ngl", + "auto", + "-ctk", + "bf16", + "-ctv", + "bf16" + ], + "env": {}, + "notes": [ + "cache KV bf16/bf16 (KV_TYPE) : numérique différente de f16 (mantisse plus courte) — choix de l'utilisateur, laissé tel quel." + ] + }, + { + "m": 11, + "c": 12, + "h": 0, + "e": 0, + "args": [ + "/app/llama-server", + "-m", + "/m/dense.gguf", + "-c", + "32768", + "-b", + "2048", + "-ub", + "512", + "--host", + "0.0.0.0", + "--port", + "8080", + "--parallel", + "1", + "-ngl", + "auto", + "-ctk", + "q5_1", + "-ctv", + "q5_1" + ], + "env": {}, + "notes": [ + "cache KV q5_1/q5_1 (KV_TYPE) : modifie légèrement les sorties par rapport à f16 — choix de l'utilisateur, laissé tel quel." + ] + }, + { + "m": 11, + "c": 13, + "h": 0, + "e": 0, + "args": [ + "/app/llama-server", + "-m", + "/m/dense.gguf", + "-c", + "32768", + "-b", + "2048", + "-ub", + "512", + "--host", + "0.0.0.0", + "--port", + "8080", + "--parallel", + "1", + "-ngl", + "auto", + "--reasoning", + "on", + "--reasoning-budget", + "-1" + ], + "env": {}, + "notes": null + }, + { + "m": 11, + "c": 14, + "h": 0, + "e": 0, + "args": [ + "/app/llama-server", + "-m", + "/m/dense.gguf", + "-c", + "32768", + "-b", + "2048", + "-ub", + "512", + "--host", + "0.0.0.0", + "--port", + "8080", + "--parallel", + "1", + "-ngl", + "auto", + "--reasoning", + "off" + ], + "env": {}, + "notes": null + }, + { + "m": 11, + "c": 15, + "h": 0, + "e": 0, + "args": [ + "/app/llama-server", + "-m", + "/m/dense.gguf", + "-c", + "32768", + "-b", + "2048", + "-ub", + "512", + "--host", + "0.0.0.0", + "--port", + "8080", + "--parallel", + "1", + "-ngl", + "auto", + "--reasoning", + "auto", + "--reasoning-budget", + "2048" + ], + "env": {}, + "notes": null + }, + { + "m": 11, + "c": 16, + "h": 0, + "e": 0, + "args": [ + "/app/llama-server", + "-m", + "/m/dense.gguf", + "-c", + "32768", + "-b", + "2048", + "-ub", + "512", + "--host", + "0.0.0.0", + "--port", + "8080", + "--parallel", + "1", + "-ngl", + "auto", + "--reasoning", + "deepseek", + "--reasoning-budget", + "-1" + ], + "env": {}, + "notes": null + }, + { + "m": 11, + "c": 17, + "h": 0, + "e": 0, + "args": [ + "/app/llama-server", + "-m", + "/m/dense.gguf", + "-c", + "32768", + "-b", + "2048", + "-ub", + "512", + "--host", + "0.0.0.0", + "--port", + "8080", + "--parallel", + "1", + "-ngl", + "auto", + "--reasoning-preserve" + ], + "env": {}, + "notes": null + }, + { + "m": 11, + "c": 18, + "h": 0, + "e": 0, + "args": [ + "/app/llama-server", + "-m", + "/m/dense.gguf", + "-c", + "32768", + "-b", + "2048", + "-ub", + "512", + "--host", + "0.0.0.0", + "--port", + "8080", + "--parallel", + "1", + "-ngl", + "auto", + "--no-reasoning-preserve" + ], + "env": {}, + "notes": null + }, + { + "m": 11, + "c": 19, + "h": 0, + "e": 0, + "args": [ + "/app/llama-server", + "-m", + "/m/dense.gguf", + "-c", + "32768", + "-b", + "2048", + "-ub", + "512", + "--host", + "0.0.0.0", + "--port", + "8080", + "--parallel", + "1", + "-ngl", + "auto" + ], + "env": {}, + "notes": [ + "REASONING_PRESERVE=peut-être illisible (on ou off) : ignoré" + ] + }, + { + "m": 11, + "c": 20, + "h": 0, + "e": 0, + "args": [ + "/app/llama-server", + "-m", + "/m/dense.gguf", + "-c", + "32768", + "-b", + "2048", + "-ub", + "512", + "--host", + "0.0.0.0", + "--port", + "8080", + "--parallel", + "1", + "-ngl", + "auto" + ], + "env": {}, + "notes": null + }, + { + "m": 11, + "c": 21, + "h": 0, + "e": 0, + "args": [ + "/app/llama-server", + "-m", + "/m/dense.gguf", + "-c", + "32768", + "-b", + "2048", + "-ub", + "512", + "--host", + "0.0.0.0", + "--port", + "8080", + "--parallel", + "1", + "-ngl", + "auto", + "--cache-ram", + "4096" + ], + "env": {}, + "notes": null + }, + { + "m": 11, + "c": 22, + "h": 0, + "e": 0, + "args": [ + "/app/llama-server", + "-m", + "/m/dense.gguf", + "-c", + "32768", + "-b", + "2048", + "-ub", + "512", + "--host", + "0.0.0.0", + "--port", + "8080", + "--parallel", + "1", + "-ngl", + "auto", + "--cache-ram", + "0" + ], + "env": {}, + "notes": null + }, + { + "m": 11, + "c": 23, + "h": 0, + "e": 0, + "args": [ + "/app/llama-server", + "-m", + "/m/dense.gguf", + "-c", + "32768", + "-b", + "2048", + "-ub", + "512", + "--host", + "0.0.0.0", + "--port", + "8080", + "--parallel", + "1", + "-ngl", + "auto", + "--cache-ram", + "-1" + ], + "env": {}, + "notes": null + }, + { + "m": 11, + "c": 24, + "h": 0, + "e": 0, + "args": [ + "/app/llama-server", + "-m", + "/m/dense.gguf", + "-c", + "32768", + "-b", + "2048", + "-ub", + "512", + "--host", + "0.0.0.0", + "--port", + "8080", + "--parallel", + "1", + "-ngl", + "auto" + ], + "env": {}, + "notes": [ + "CACHE_RAM=beaucoup illisible (un nombre de Mio, -1, 0 ou auto) : ignoré" + ] + }, + { + "m": 11, + "c": 25, + "h": 0, + "e": 0, + "args": [ + "/app/llama-server", + "-m", + "/m/dense.gguf", + "-c", + "32768", + "-b", + "2048", + "-ub", + "512", + "--host", + "0.0.0.0", + "--port", + "8080", + "--parallel", + "1", + "-ngl", + "auto" + ], + "env": {}, + "notes": null + }, + { + "m": 11, + "c": 26, + "h": 0, + "e": 0, + "args": [ + "/app/llama-server", + "-m", + "/m/dense.gguf", + "-c", + "32768", + "-b", + "2048", + "-ub", + "512", + "--host", + "0.0.0.0", + "--port", + "8080", + "--parallel", + "1", + "-ngl", + "auto", + "--cache-ram", + "0" + ], + "env": {}, + "notes": null + }, + { + "m": 11, + "c": 27, + "h": 0, + "e": 0, + "args": [ + "/app/llama-server", + "-m", + "/m/dense.gguf", + "-c", + "32768", + "-b", + "2048", + "-ub", + "512", + "--host", + "0.0.0.0", + "--port", + "8080", + "--parallel", + "1", + "-ngl", + "auto" + ], + "env": {}, + "notes": [ + "SIDE_SLOT refusé (VRAM insuffisante : modèle + 2 × 8192 Mio d'état dépassent 90 % de 16311 Mio) : un seul slot, comme sans la clé" + ] + }, + { + "m": 11, + "c": 28, + "h": 0, + "e": 0, + "args": [ + "/app/llama-server", + "-m", + "/m/dense.gguf", + "-c", + "32768", + "-b", + "2048", + "-ub", + "512", + "--host", + "0.0.0.0", + "--port", + "8080", + "--parallel", + "3", + "-ngl", + "auto" + ], + "env": {}, + "notes": [ + "SIDE_SLOT refusé (PARALLEL=3 (SIDE_SLOT ouvre exactement 2 slots)) : un seul slot, comme sans la clé" + ] + }, + { + "m": 11, + "c": 29, + "h": 0, + "e": 0, + "args": [ + "/app/llama-server", + "-m", + "/m/dense.gguf", + "-c", + "0", + "-b", + "2048", + "-ub", + "512", + "--host", + "0.0.0.0", + "--port", + "8080", + "--parallel", + "1", + "-ngl", + "auto" + ], + "env": {}, + "notes": [ + "SIDE_SLOT refusé (CTX doit être un nombre de jetons explicite) : un seul slot, comme sans la clé" + ] + }, + { + "m": 11, + "c": 30, + "h": 0, + "e": 0, + "args": [ + "/app/llama-server", + "-m", + "/m/dense.gguf", + "-c", + "8192", + "-b", + "2048", + "-ub", + "512", + "--host", + "0.0.0.0", + "--port", + "8080", + "--parallel", + "1", + "-ngl", + "auto", + "-ctk", + "q8_0", + "-ctv", + "q8_0" + ], + "env": {}, + "notes": [ + "SIDE_SLOT refusé (VRAM insuffisante : modèle + 2 × 1088 Mio d'état dépassent 90 % de 16311 Mio) : un seul slot, comme sans la clé", + "cache KV q8_0/q8_0 (KV_TYPE) : modifie légèrement les sorties par rapport à f16 — choix de l'utilisateur, laissé tel quel." + ] + }, + { + "m": 11, + "c": 31, + "h": 0, + "e": 0, + "args": [ + "/app/llama-server", + "-m", + "/m/dense.gguf", + "-c", + "32768", + "-b", + "2048", + "-ub", + "512", + "--host", + "0.0.0.0", + "--port", + "8080", + "--parallel", + "1", + "-ngl", + "auto" + ], + "env": {}, + "notes": [ + "SPLIT_MODE=tensor refusé (cartes inconnues (--list-devices n'a rien répondu)) : découpe par couches, comme sans la clé" + ] + }, + { + "m": 11, + "c": 32, + "h": 0, + "e": 0, + "args": [ + "/app/llama-server", + "-m", + "/m/dense.gguf", + "-c", + "16384", + "-b", + "2048", + "-ub", + "512", + "--host", + "0.0.0.0", + "--port", + "8080", + "--parallel", + "1", + "-ngl", + "auto" + ], + "env": {}, + "notes": [ + "SPLIT_MODE=tensor refusé (cartes inconnues (--list-devices n'a rien répondu)) : découpe par couches, comme sans la clé" + ] + }, + { + "m": 11, + "c": 33, + "h": 0, + "e": 0, + "args": [ + "/app/llama-server", + "-m", + "/m/dense.gguf", + "-c", + "32768", + "-b", + "2048", + "-ub", + "512", + "--host", + "0.0.0.0", + "--port", + "8080", + "--parallel", + "1", + "-ngl", + "auto" + ], + "env": {}, + "notes": [ + "SPLIT_MODE=tensor refusé (SPLIT_MODE=bizarre illisible (tensor ou off)) : découpe par couches, comme sans la clé" + ] + }, + { + "m": 11, + "c": 34, + "h": 0, + "e": 0, + "args": [ + "/app/llama-server", + "-m", + "/m/dense.gguf", + "-c", + "32768", + "-b", + "2048", + "-ub", + "512", + "--host", + "0.0.0.0", + "--port", + "8080", + "--parallel", + "1", + "-ngl", + "auto" + ], + "env": {}, + "notes": null + }, + { + "m": 11, + "c": 35, + "h": 0, + "e": 0, + "args": [ + "/app/llama-server", + "-m", + "/m/dense.gguf", + "-c", + "32768", + "-b", + "2048", + "-ub", + "512", + "--host", + "0.0.0.0", + "--port", + "8080", + "--parallel", + "1", + "-ngl", + "auto", + "-ctk", + "q8_0", + "-ctv", + "q8_0" + ], + "env": {}, + "notes": [ + "SPLIT_MODE=tensor refusé (cache KV q8_0/q8_0 : le mode tensor n'accepte que f16, bf16 ou f32 (jamais réécrit d'office : la VRAM changerait)) : découpe par couches, comme sans la clé", + "cache KV q8_0/q8_0 (KV_TYPE) : modifie légèrement les sorties par rapport à f16 — choix de l'utilisateur, laissé tel quel." + ] + }, + { + "m": 11, + "c": 36, + "h": 0, + "e": 0, + "args": [ + "/app/llama-server", + "-m", + "/m/dense.gguf", + "-c", + "32768", + "-b", + "2048", + "-ub", + "512", + "--host", + "0.0.0.0", + "--port", + "8080", + "--parallel", + "1", + "-ngl", + "20" + ], + "env": {}, + "notes": [ + "SPLIT_MODE=tensor refusé (NGL=20 : le mode tensor veut toutes les couches sur les cartes (all)) : découpe par couches, comme sans la clé" + ] + }, + { + "m": 11, + "c": 37, + "h": 0, + "e": 0, + "args": [ + "/app/llama-server", + "-m", + "/m/dense.gguf", + "-c", + "32768", + "-b", + "2048", + "-ub", + "512", + "--host", + "0.0.0.0", + "--port", + "8080", + "--parallel", + "1", + "-ngl", + "auto" + ], + "env": {}, + "notes": null + }, + { + "m": 11, + "c": 38, + "h": 0, + "e": 0, + "args": [ + "/app/llama-server", + "-m", + "/m/dense.gguf", + "-c", + "32768", + "-b", + "2048", + "-ub", + "512", + "--host", + "0.0.0.0", + "--port", + "8080", + "--parallel", + "1", + "-ngl", + "auto" + ], + "env": {}, + "notes": [ + "SPEC=mtp : pas de tête MTP dans ce fichier (publiée à part ? MODEL_DRAFT=mtp-….gguf) — sans décodage spéculatif" + ] + }, + { + "m": 11, + "c": 39, + "h": 0, + "e": 0, + "args": [ + "/app/llama-server", + "-m", + "/m/dense.gguf", + "-c", + "32768", + "-b", + "2048", + "-ub", + "512", + "--host", + "0.0.0.0", + "--port", + "8080", + "--parallel", + "1", + "-ngl", + "auto", + "--spec-type", + "ngram-mod", + "--spec-ngram-mod-n-match", + "24", + "--spec-ngram-mod-n-min", + "48", + "--spec-ngram-mod-n-max", + "64" + ], + "env": {}, + "notes": [ + "SPEC=ngram → n-grammes du contexte (ngram-mod, brouillons de 48 à 64 jetons) : distribution inchangée (chaque jeton vérifié), ~16 Mo de RAM ; utile surtout en mode code, à mesurer (acceptation par nature dans /api/perf/summary)" + ] + }, + { + "m": 11, + "c": 40, + "h": 0, + "e": 0, + "args": [ + "/app/llama-server", + "-m", + "/m/dense.gguf", + "-c", + "32768", + "-b", + "2048", + "-ub", + "512", + "--host", + "0.0.0.0", + "--port", + "8080", + "--parallel", + "1", + "-ngl", + "auto", + "--spec-type", + "ngram-mod", + "--spec-ngram-mod-n-match", + "24", + "--spec-ngram-mod-n-min", + "48", + "--spec-ngram-mod-n-max", + "64" + ], + "env": {}, + "notes": [ + "SPEC=mtp+ngram : sans MTP (pas de tête MTP dans ce fichier (publiée à part ? MODEL_DRAFT=mtp-….gguf)) → n-grammes seuls. SPEC=ngram → n-grammes du contexte (ngram-mod, brouillons de 48 à 64 jetons) : distribution inchangée (chaque jeton vérifié), ~16 Mo de RAM ; utile surtout en mode code, à mesurer (acceptation par nature dans /api/perf/summary)" + ] + }, + { + "m": 11, + "c": 41, + "h": 0, + "e": 0, + "args": [ + "/app/llama-server", + "-m", + "/m/dense.gguf", + "-c", + "32768", + "-b", + "2048", + "-ub", + "512", + "--host", + "0.0.0.0", + "--port", + "8080", + "--parallel", + "1", + "-ngl", + "auto", + "--spec-type", + "ngram-mod", + "--spec-ngram-mod-n-match", + "24", + "--spec-ngram-mod-n-min", + "48", + "--spec-ngram-mod-n-max", + "64" + ], + "env": {}, + "notes": [ + "SPEC=mtp+ngram : sans MTP (pas de tête MTP dans ce fichier (publiée à part ? MODEL_DRAFT=mtp-….gguf)) → n-grammes seuls. SPEC=ngram → n-grammes du contexte (ngram-mod, brouillons de 48 à 64 jetons) : distribution inchangée (chaque jeton vérifié), ~16 Mo de RAM ; utile surtout en mode code, à mesurer (acceptation par nature dans /api/perf/summary)" + ] + }, + { + "m": 11, + "c": 42, + "h": 0, + "e": 0, + "args": [ + "/app/llama-server", + "-m", + "/m/dense.gguf", + "-c", + "32768", + "-b", + "2048", + "-ub", + "512", + "--host", + "0.0.0.0", + "--port", + "8080", + "--parallel", + "1", + "-ngl", + "auto" + ], + "env": {}, + "notes": [ + "SPEC=turbo illisible (off, auto, mtp, ngram ou mtp+ngram) : sans décodage spéculatif" + ] + }, + { + "m": 11, + "c": 43, + "h": 0, + "e": 0, + "args": [ + "/app/llama-server", + "-m", + "/m/dense.gguf", + "-c", + "32768", + "-b", + "2048", + "-ub", + "512", + "--host", + "0.0.0.0", + "--port", + "8080", + "--parallel", + "1", + "-ngl", + "auto" + ], + "env": {}, + "notes": [ + "MODEL_DRAFT ignoré : SPEC=off (auto ou mtp pour s'en servir)" + ] + }, + { + "m": 11, + "c": 44, + "h": 0, + "e": 0, + "args": [ + "/app/llama-server", + "-m", + "/m/dense.gguf", + "-c", + "32768", + "-b", + "2048", + "-ub", + "512", + "--host", + "0.0.0.0", + "--port", + "8080", + "--parallel", + "1", + "-ngl", + "auto" + ], + "env": {}, + "notes": [ + "SPEC=mtp : MODEL_DRAFT=mtp-head.gguf introuvable (stat /m/absent.gguf: no such file or directory) — sans décodage spéculatif" + ] + }, + { + "m": 11, + "c": 45, + "h": 0, + "e": 0, + "args": [ + "/app/llama-server", + "-m", + "/m/dense.gguf", + "-c", + "32768", + "-b", + "2048", + "-ub", + "512", + "--host", + "0.0.0.0", + "--port", + "8080", + "--parallel", + "1", + "-ngl", + "auto" + ], + "env": {}, + "notes": [ + "SPEC=mtp : pas de tête MTP dans ce fichier (publiée à part ? MODEL_DRAFT=mtp-….gguf) — sans décodage spéculatif" + ] + }, + { + "m": 11, + "c": 46, + "h": 0, + "e": 0, + "args": [ + "/app/llama-server", + "-m", + "/m/dense.gguf", + "-c", + "32768", + "-b", + "2048", + "-ub", + "512", + "--host", + "0.0.0.0", + "--port", + "8080", + "--parallel", + "1", + "-ngl", + "auto" + ], + "env": {}, + "notes": null + }, + { + "m": 11, + "c": 47, + "h": 0, + "e": 0, + "args": [ + "/app/llama-server", + "-m", + "/m/dense.gguf", + "-c", + "32768", + "-b", + "2048", + "-ub", + "512", + "--host", + "0.0.0.0", + "--port", + "8080", + "--parallel", + "1", + "-ngl", + "auto" + ], + "env": {}, + "notes": [ + "SPEC=mtp : pas de tête MTP dans ce fichier (publiée à part ? MODEL_DRAFT=mtp-….gguf) — sans décodage spéculatif" + ] + }, + { + "m": 11, + "c": 48, + "h": 0, + "e": 0, + "args": [ + "/app/llama-server", + "-m", + "/m/dense.gguf", + "-c", + "32768", + "-b", + "2048", + "-ub", + "512", + "--host", + "0.0.0.0", + "--port", + "8080", + "--parallel", + "1", + "-ngl", + "auto", + "--spec-type", + "ngram-mod", + "--spec-ngram-mod-n-match", + "24", + "--spec-ngram-mod-n-min", + "48", + "--spec-ngram-mod-n-max", + "64" + ], + "env": {}, + "notes": [ + "SPEC=ngram → n-grammes du contexte (ngram-mod, brouillons de 48 à 64 jetons) : distribution inchangée (chaque jeton vérifié), ~16 Mo de RAM ; utile surtout en mode code, à mesurer (acceptation par nature dans /api/perf/summary)", + "MODEL_DRAFT ignoré avec SPEC=ngram : SPEC=mtp+ngram pour les deux", + "SPEC_N_MAX et SPEC_SAMPLING ignorés : les n-grammes n'ont pas de brouillon à régler" + ] + }, + { + "m": 11, + "c": 49, + "h": 0, + "e": 0, + "args": [ + "/app/llama-server", + "-m", + "/m/dense.gguf", + "-c", + "32768", + "-b", + "2048", + "-ub", + "512", + "--host", + "0.0.0.0", + "--port", + "8080", + "--parallel", + "1", + "-ngl", + "auto" + ], + "env": {}, + "notes": [ + "SPEC=auto : MODEL_DRAFT=small.gguf introuvable (stat /m/absent.gguf: no such file or directory) — sans décodage spéculatif" + ] + }, + { + "m": 11, + "c": 50, + "h": 0, + "e": 0, + "args": [ + "/app/llama-server", + "-m", + "/m/dense.gguf", + "-c", + "32768", + "-b", + "2048", + "-ub", + "512", + "--host", + "0.0.0.0", + "--port", + "8080", + "--parallel", + "1", + "-ngl", + "auto", + "--spec-type", + "ngram-mod", + "--spec-ngram-mod-n-match", + "24", + "--spec-ngram-mod-n-min", + "48", + "--spec-ngram-mod-n-max", + "64" + ], + "env": {}, + "notes": [ + "SPEC=mtp+ngram : sans MTP (MODEL_DRAFT=small.gguf introuvable (stat /m/absent.gguf: no such file or directory)) → n-grammes seuls. SPEC=ngram → n-grammes du contexte (ngram-mod, brouillons de 48 à 64 jetons) : distribution inchangée (chaque jeton vérifié), ~16 Mo de RAM ; utile surtout en mode code, à mesurer (acceptation par nature dans /api/perf/summary)" + ] + }, + { + "m": 11, + "c": 51, + "h": 0, + "e": 0, + "args": [ + "/app/llama-server", + "-m", + "/m/dense.gguf", + "-c", + "32768", + "-b", + "2048", + "-ub", + "512", + "--host", + "0.0.0.0", + "--port", + "8080", + "--parallel", + "1", + "-ngl", + "auto" + ], + "env": {}, + "notes": null + }, + { + "m": 11, + "c": 52, + "h": 0, + "e": 0, + "args": [ + "/app/llama-server", + "-m", + "/m/dense.gguf", + "-c", + "32768", + "-b", + "2048", + "-ub", + "512", + "--host", + "0.0.0.0", + "--port", + "8080", + "--parallel", + "1", + "-ngl", + "auto" + ], + "env": { + "CUDA_SCALE_LAUNCH_QUEUES": "2x" + }, + "notes": [ + "CUDA_SCALE_LAUNCH_QUEUES=2x (CUDA_LAUNCH_QUEUES)" + ] + }, + { + "m": 11, + "c": 53, + "h": 0, + "e": 0, + "args": [ + "/app/llama-server", + "-m", + "/m/dense.gguf", + "-c", + "32768", + "-b", + "2048", + "-ub", + "512", + "--host", + "0.0.0.0", + "--port", + "8080", + "--parallel", + "1", + "-ngl", + "auto" + ], + "env": {}, + "notes": [ + "CUDA_LAUNCH_QUEUES=8x ignoré (0.25x, 0.5x, 2x, 4x ou off) → automatique" + ] + }, + { + "m": 11, + "c": 54, + "h": 0, + "e": 0, + "args": [ + "/app/llama-server", + "-m", + "/m/dense.gguf", + "-c", + "32768", + "-b", + "2048", + "-ub", + "512", + "--host", + "0.0.0.0", + "--port", + "8080", + "--parallel", + "1", + "-ngl", + "auto" + ], + "env": {}, + "notes": null + }, + { + "m": 11, + "c": 55, + "h": 0, + "e": 0, + "args": [ + "/app/llama-server", + "-m", + "/m/dense.gguf", + "-c", + "32768", + "-b", + "2048", + "-ub", + "512", + "--host", + "0.0.0.0", + "--port", + "8080", + "--parallel", + "1", + "-ngl", + "auto" + ], + "env": { + "CUDA_DEVICE_ORDER": "PCI_BUS_ID", + "CUDA_VISIBLE_DEVICES": "1,0" + }, + "notes": null + }, + { + "m": 11, + "c": 56, + "h": 0, + "e": 0, + "args": [ + "/app/llama-server", + "-m", + "/m/dense.gguf", + "-c", + "32768", + "-b", + "2048", + "-ub", + "512", + "--host", + "0.0.0.0", + "--port", + "8080", + "--parallel", + "1", + "-ngl", + "auto", + "--fit-target", + "1024,3072" + ], + "env": {}, + "notes": [ + "--fit-target 1024,3072 (FIT_TARGET, Mio libres par carte, dans l'ordre vu par le moteur) — vérifie « offloaded N/N layers » dans le journal" + ] + }, + { + "m": 11, + "c": 57, + "h": 0, + "e": 0, + "args": [ + "/app/llama-server", + "-m", + "/m/dense.gguf", + "-c", + "32768", + "-b", + "2048", + "-ub", + "512", + "--host", + "0.0.0.0", + "--port", + "8080", + "--parallel", + "1", + "-ngl", + "auto", + "--fit-target", + "1024" + ], + "env": {}, + "notes": [ + "--fit-target 1024 (FIT_TARGET, Mio libres par carte, dans l'ordre vu par le moteur) — vérifie « offloaded N/N layers » dans le journal ; marges sous 1024 Mio remontées à 1024" + ] + }, + { + "m": 11, + "c": 58, + "h": 0, + "e": 0, + "args": [ + "/app/llama-server", + "-m", + "/m/dense.gguf", + "-c", + "32768", + "-b", + "2048", + "-ub", + "512", + "--host", + "0.0.0.0", + "--port", + "8080", + "--parallel", + "1", + "-ngl", + "auto" + ], + "env": {}, + "notes": [ + "FIT_TARGET=1024,,3072 ignoré : des Mio par carte, ex. 1024,3072 (8 valeurs au plus)" + ] + }, + { + "m": 11, + "c": 59, + "h": 0, + "e": 0, + "args": [ + "/app/llama-server", + "-m", + "/m/dense.gguf", + "-c", + "32768", + "-b", + "2048", + "-ub", + "512", + "--host", + "0.0.0.0", + "--port", + "8080", + "--parallel", + "1", + "-ngl", + "auto" + ], + "env": {}, + "notes": [ + "FIT_TARGET=1,2,3,4,5,6,7,8,9 ignoré : des Mio par carte, ex. 1024,3072 (8 valeurs au plus)" + ] + }, + { + "m": 11, + "c": 60, + "h": 0, + "e": 0, + "args": [ + "/app/llama-server", + "-m", + "/m/dense.gguf", + "-c", + "0", + "-b", + "2048", + "-ub", + "512", + "--host", + "0.0.0.0", + "--port", + "8080", + "--parallel", + "1", + "-ngl", + "auto" + ], + "env": {}, + "notes": [ + "FIT_TARGET=2048/4096 ignoré : contexte « 0 » non fixé — --fit pourrait le réduire pour tenir ; donne un CTX chiffré" + ] + }, + { + "m": 11, + "c": 61, + "h": 0, + "e": 0, + "args": [ + "/app/llama-server", + "-m", + "/m/dense.gguf", + "-c", + "32768", + "-b", + "2048", + "-ub", + "512", + "--host", + "0.0.0.0", + "--port", + "8080", + "--parallel", + "1", + "-ngl", + "30" + ], + "env": {}, + "notes": [ + "FIT_TARGET=2048 ignoré : --fit inactif (couches GPU fixées à 30), la marge ne servirait à rien" + ] + }, + { + "m": 11, + "c": 62, + "h": 0, + "e": 0, + "args": [ + "/app/llama-server", + "-m", + "/m/dense.gguf", + "-c", + "32768", + "-b", + "2048", + "-ub", + "512", + "--host", + "0.0.0.0", + "--port", + "8080", + "--parallel", + "1", + "-ngl", + "auto" + ], + "env": {}, + "notes": null + }, + { + "m": 11, + "c": 63, + "h": 0, + "e": 0, + "args": [ + "/app/llama-server", + "-m", + "/m/dense.gguf", + "-c", + "32768", + "-b", + "2048", + "-ub", + "512", + "--host", + "0.0.0.0", + "--port", + "8080", + "--parallel", + "1", + "-ngl", + "auto" + ], + "env": {}, + "notes": [ + "OP_OFFLOAD_MIN_BATCH=0 ignoré : un nombre de jetons, ex. 128" + ] + }, + { + "m": 11, + "c": 64, + "h": 0, + "e": 0, + "args": [ + "/app/llama-server", + "-m", + "/m/dense.gguf", + "-c", + "32768", + "-b", + "2048", + "-ub", + "512", + "--host", + "0.0.0.0", + "--port", + "8080", + "--parallel", + "1", + "-ngl", + "auto" + ], + "env": { + "GGML_CUDA_GRAPH_OPT": "1" + }, + "notes": [ + "GGML_CUDA_GRAPH_OPT=1 (CUDA_GRAPH_OPT, expérimental) — si le moteur s'arrête sur un GGML_ASSERT, CUDA_GRAPH_OPT=off puis redémarre" + ] + }, + { + "m": 11, + "c": 65, + "h": 0, + "e": 0, + "args": [ + "/app/llama-server", + "-m", + "/m/dense.gguf", + "-c", + "32768", + "-b", + "2048", + "-ub", + "512", + "--host", + "0.0.0.0", + "--port", + "8080", + "--parallel", + "1", + "-ngl", + "auto" + ], + "env": {}, + "notes": [ + "CUDA_GRAPH_OPT=oui ignoré (on ou off)" + ] + }, + { + "m": 11, + "c": 66, + "h": 0, + "e": 0, + "args": [ + "/app/llama-server", + "-m", + "/m/dense.gguf", + "-c", + "32768", + "-b", + "2048", + "-ub", + "512", + "--host", + "0.0.0.0", + "--port", + "8080", + "--parallel", + "1", + "-ngl", + "auto" + ], + "env": {}, + "notes": null + }, + { + "m": 11, + "c": 67, + "h": 0, + "e": 0, + "args": [ + "/app/llama-server", + "-m", + "/m/dense.gguf", + "-c", + "32768", + "-b", + "2048", + "-ub", + "512", + "--host", + "0.0.0.0", + "--port", + "8080", + "--parallel", + "1", + "-ngl", + "auto", + "--ctx-checkpoints", + "8" + ], + "env": {}, + "notes": null + }, + { + "m": 11, + "c": 68, + "h": 0, + "e": 0, + "args": [ + "/app/llama-server", + "-m", + "/m/dense.gguf", + "-c", + "32768", + "-b", + "2048", + "-ub", + "512", + "--host", + "0.0.0.0", + "--port", + "8080", + "--parallel", + "1", + "-ngl", + "auto" + ], + "env": {}, + "notes": [ + "CTX_CHECKPOINTS=beaucoup illisible (un nombre de points de reprise par slot, ≥ 0) : ignoré" + ] + }, + { + "m": 11, + "c": 69, + "h": 0, + "e": 0, + "args": [ + "/app/llama-server", + "-m", + "/m/dense.gguf", + "-c", + "32768", + "-b", + "2048", + "-ub", + "512", + "--host", + "0.0.0.0", + "--port", + "8080", + "--parallel", + "1", + "-ngl", + "auto", + "--checkpoint-min-step", + "1024" + ], + "env": {}, + "notes": null + }, + { + "m": 11, + "c": 70, + "h": 0, + "e": 0, + "args": [ + "/app/llama-server", + "-m", + "/m/dense.gguf", + "-c", + "32768", + "-b", + "2048", + "-ub", + "512", + "--host", + "0.0.0.0", + "--port", + "8080", + "--parallel", + "1", + "-ngl", + "auto" + ], + "env": {}, + "notes": [ + "CKPT_MIN_STEP=0 refusé (un nombre de jetons > 0 ; 0 laisserait le moteur jeter le point de reprise du début de la conversation) : défaut du moteur" + ] + }, + { + "m": 11, + "c": 71, + "h": 0, + "e": 0, + "args": [ + "/app/llama-server", + "-m", + "/m/dense.gguf", + "-c", + "32768", + "-b", + "2048", + "-ub", + "512", + "--host", + "0.0.0.0", + "--port", + "8080", + "--parallel", + "1", + "-ngl", + "auto" + ], + "env": {}, + "notes": [ + "SLOT_PERSIST refusé (dossier des slots indisponible) : l'état du slot ne sera pas gardé à la bascule de preset" + ] + }, + { + "m": 11, + "c": 72, + "h": 0, + "e": 0, + "args": [ + "/app/llama-server", + "-m", + "/m/dense.gguf", + "-c", + "32768", + "-b", + "2048", + "-ub", + "512", + "--host", + "127.0.0.1", + "--port", + "8080", + "--parallel", + "1", + "-ngl", + "auto" + ], + "env": {}, + "notes": [ + "SLOT_PERSIST refusé (dossier des slots indisponible) : l'état du slot ne sera pas gardé à la bascule de preset" + ] + }, + { + "m": 11, + "c": 73, + "h": 0, + "e": 0, + "args": [ + "/app/llama-server", + "-m", + "/m/dense.gguf", + "-c", + "32768", + "-b", + "2048", + "-ub", + "512", + "--host", + "127.0.0.1", + "--port", + "8080", + "--parallel", + "1", + "-ngl", + "auto" + ], + "env": {}, + "notes": [ + "SLOT_PERSIST refusé (dossier des slots indisponible) : l'état du slot ne sera pas gardé à la bascule de preset", + "SPEC=mtp : pas de tête MTP dans ce fichier (publiée à part ? MODEL_DRAFT=mtp-….gguf) — sans décodage spéculatif" + ] + }, + { + "m": 11, + "c": 74, + "h": 0, + "e": 0, + "args": [ + "/app/llama-server", + "-m", + "/m/dense.gguf", + "-c", + "32768", + "-b", + "2048", + "-ub", + "512", + "--host", + "0.0.0.0", + "--port", + "8080", + "--parallel", + "1", + "-ngl", + "auto", + "--load-mode", + "none" + ], + "env": {}, + "notes": null + }, + { + "m": 11, + "c": 75, + "h": 0, + "e": 0, + "args": [ + "/app/llama-server", + "-m", + "/m/dense.gguf", + "-c", + "32768", + "-b", + "2048", + "-ub", + "512", + "--host", + "0.0.0.0", + "--port", + "8080", + "--parallel", + "1", + "-ngl", + "auto", + "--load-mode", + "mlock" + ], + "env": {}, + "notes": null + }, + { + "m": 11, + "c": 76, + "h": 0, + "e": 0, + "args": [ + "/app/llama-server", + "-m", + "/m/dense.gguf", + "-c", + "32768", + "-b", + "2048", + "-ub", + "512", + "--host", + "0.0.0.0", + "--port", + "8080", + "--parallel", + "1", + "-ngl", + "auto", + "--load-mode", + "mmap+mlock" + ], + "env": {}, + "notes": null + }, + { + "m": 11, + "c": 77, + "h": 0, + "e": 0, + "args": [ + "/app/llama-server", + "-m", + "/m/dense.gguf", + "-c", + "32768", + "-b", + "2048", + "-ub", + "512", + "--host", + "0.0.0.0", + "--port", + "8080", + "--parallel", + "1", + "-ngl", + "auto", + "--load-mode", + "none" + ], + "env": {}, + "notes": null + }, + { + "m": 11, + "c": 78, + "h": 0, + "e": 0, + "args": [ + "/app/llama-server", + "-m", + "/m/dense.gguf", + "-c", + "32768", + "-b", + "2048", + "-ub", + "512", + "--host", + "0.0.0.0", + "--port", + "8080", + "--parallel", + "1", + "-ngl", + "auto", + "--load-mode", + "mmap" + ], + "env": {}, + "notes": null + }, + { + "m": 11, + "c": 79, + "h": 0, + "e": 0, + "args": [ + "/app/llama-server", + "-m", + "/m/dense.gguf", + "-c", + "32768", + "-b", + "2048", + "-ub", + "512", + "--host", + "0.0.0.0", + "--port", + "8080", + "--parallel", + "1", + "-ngl", + "auto", + "--load-mode", + "dio" + ], + "env": {}, + "notes": null + }, + { + "m": 11, + "c": 80, + "h": 0, + "e": 0, + "args": [ + "/app/llama-server", + "-m", + "/m/dense.gguf", + "-c", + "32768", + "-b", + "2048", + "-ub", + "512", + "--host", + "0.0.0.0", + "--port", + "8080", + "--parallel", + "1", + "-ngl", + "auto", + "--load-mode=mlock" + ], + "env": {}, + "notes": null + }, + { + "m": 11, + "c": 81, + "h": 0, + "e": 0, + "args": [ + "/app/llama-server", + "-m", + "/m/dense.gguf", + "-c", + "32768", + "-b", + "2048", + "-ub", + "512", + "--host", + "0.0.0.0", + "--port", + "8080", + "-ngl", + "30", + "--parallel", + "4" + ], + "env": {}, + "notes": null + }, + { + "m": 11, + "c": 82, + "h": 0, + "e": 0, + "args": [ + "/app/llama-server", + "-m", + "/m/dense.gguf", + "-c", + "32768", + "-b", + "2048", + "-ub", + "512", + "--host", + "0.0.0.0", + "--port", + "8080", + "--parallel", + "1", + "-ngl", + "auto", + "-t", + "6", + "-tb", + "3" + ], + "env": {}, + "notes": null + }, + { + "m": 11, + "c": 83, + "h": 0, + "e": 0, + "args": [ + "/app/llama-server", + "-m", + "/m/dense.gguf", + "-c", + "32768", + "-tb", + "0", + "-b", + "2048", + "-ub", + "512", + "--host", + "0.0.0.0", + "--port", + "8080", + "--parallel", + "1", + "-ngl", + "auto", + "-Cb", + "0xff", + "--poll-batch", + "1" + ], + "env": {}, + "notes": [ + "EXTRA_ARGS règle le CPU du batch (-Cb, --poll-batch…) : -tb 0 posé pour que llama.cpp le garde (tous les threads logiques) ; THREADS_BATCH pour choisir le nombre" + ] + }, + { + "m": 11, + "c": 84, + "h": 0, + "e": 0, + "args": [ + "/app/llama-server", + "-m", + "/m/dense.gguf", + "-c", + "32768", + "-t", + "6", + "-tb", + "6", + "-b", + "2048", + "-ub", + "512", + "--host", + "0.0.0.0", + "--port", + "8080", + "--parallel", + "1", + "-ngl", + "auto", + "--prio-batch", + "2" + ], + "env": {}, + "notes": null + }, + { + "m": 11, + "c": 85, + "h": 0, + "e": 0, + "args": [ + "/app/llama-server", + "-m", + "/m/dense.gguf", + "-c", + "32768", + "-b", + "2048", + "-ub", + "512", + "--host", + "0.0.0.0", + "--port", + "8080", + "--parallel", + "1", + "-ngl", + "auto", + "--cache-reuse", + "256", + "--context-shift" + ], + "env": {}, + "notes": [ + "avertissement : --context-shift (EXTRA_ARGS) — contexte plein, le moteur jette des jetons anciens et le modèle perd une partie de la conversation sans le savoir.", + "avertissement : --cache-reuse 256 (EXTRA_ARGS) — réutilise des morceaux de cache calculés sous un autre préfixe : les sorties ne sont plus exactement celles d'un calcul complet." + ] + }, + { + "m": 11, + "c": 86, + "h": 0, + "e": 0, + "args": [ + "/app/llama-server", + "-m", + "/m/dense.gguf", + "-c", + "32768", + "-b", + "2048", + "-ub", + "512", + "--host", + "0.0.0.0", + "--port", + "8080", + "--parallel", + "1", + "-ngl", + "auto", + "-ctk", + "q8_0", + "-ctv", + "q8_0" + ], + "env": {}, + "notes": [ + "cache KV q8_0/q8_0 (défini par EXTRA_ARGS) : modifie légèrement les sorties par rapport à f16 — choix de l'utilisateur, laissé tel quel." + ] + }, + { + "m": 11, + "c": 87, + "h": 0, + "e": 0, + "args": [ + "/app/llama-server", + "-m", + "/m/dense.gguf", + "-c", + "32768", + "-b", + "2048", + "-ub", + "512", + "--host", + "0.0.0.0", + "--port", + "8080", + "--parallel", + "1", + "-ngl", + "auto", + "-ot", + "exps=CPU" + ], + "env": {}, + "notes": null + }, + { + "m": 11, + "c": 88, + "h": 0, + "e": 0, + "args": [ + "/app/llama-server", + "-m", + "/m/dense.gguf", + "-c", + "32768", + "-b", + "2048", + "-ub", + "512", + "--host", + "0.0.0.0", + "--port", + "8080", + "--parallel", + "1", + "-ngl", + "auto", + "-ot", + "blk\\.(1|2)\\.ffn_.*_exps=CPU", + "-ub", + "512" + ], + "env": {}, + "notes": null + }, + { + "m": 11, + "c": 89, + "h": 0, + "e": 0, + "args": [ + "/app/llama-server", + "-m", + "/m/dense.gguf", + "-c", + "32768", + "-b", + "2048", + "-ub", + "512", + "--host", + "0.0.0.0", + "--port", + "8080", + "--parallel", + "1", + "-ngl", + "auto", + "--n-cpu-moe", + "20" + ], + "env": {}, + "notes": null + }, + { + "m": 11, + "c": 90, + "h": 0, + "e": 0, + "args": [ + "/app/llama-server", + "-m", + "/m/dense.gguf", + "-c", + "32768", + "-b", + "2048", + "-ub", + "512", + "--host", + "0.0.0.0", + "--port", + "8080", + "--parallel", + "1", + "-ngl", + "auto", + "--cpu-moe" + ], + "env": {}, + "notes": null + }, + { + "m": 11, + "c": 91, + "h": 0, + "e": 0, + "args": [ + "/app/llama-server", + "-m", + "/m/dense.gguf", + "-c", + "32768", + "-b", + "2048", + "-ub", + "512", + "--host", + "0.0.0.0", + "--port", + "8080", + "--parallel", + "1", + "-ngl", + "auto", + "-sm", + "row" + ], + "env": {}, + "notes": null + }, + { + "m": 11, + "c": 92, + "h": 0, + "e": 0, + "args": [ + "/app/llama-server", + "-m", + "/m/dense.gguf", + "-c", + "32768", + "-b", + "2048", + "-ub", + "512", + "--host", + "0.0.0.0", + "--port", + "8080", + "--parallel", + "1", + "-ngl", + "auto", + "-ts", + "1,1" + ], + "env": {}, + "notes": null + }, + { + "m": 11, + "c": 93, + "h": 0, + "e": 0, + "args": [ + "/app/llama-server", + "-m", + "/m/dense.gguf", + "-c", + "32768", + "-b", + "2048", + "-ub", + "512", + "--host", + "0.0.0.0", + "--port", + "8080", + "--parallel", + "1", + "-ngl", + "auto", + "-dev", + "CUDA0,CUDA1" + ], + "env": { + "CUDA_SCALE_LAUNCH_QUEUES": "4x" + }, + "notes": [ + "CUDA_SCALE_LAUNCH_QUEUES=4x (2 GPU, parallélisme pipeline possible) — CUDA_LAUNCH_QUEUES=off pour s'en passer" + ] + }, + { + "m": 11, + "c": 94, + "h": 0, + "e": 0, + "args": [ + "/app/llama-server", + "-m", + "/m/dense.gguf", + "-c", + "32768", + "-b", + "2048", + "-ub", + "512", + "--host", + "0.0.0.0", + "--port", + "8080", + "--parallel", + "1", + "-ngl", + "auto", + "-dev", + "Vulkan0" + ], + "env": {}, + "notes": null + }, + { + "m": 11, + "c": 95, + "h": 0, + "e": 0, + "args": [ + "/app/llama-server", + "-m", + "/m/dense.gguf", + "-c", + "32768", + "-b", + "2048", + "-ub", + "512", + "--host", + "0.0.0.0", + "--port", + "8080", + "--parallel", + "1", + "-ngl", + "auto", + "--fit", + "off" + ], + "env": {}, + "notes": null + }, + { + "m": 11, + "c": 96, + "h": 0, + "e": 0, + "args": [ + "/app/llama-server", + "-m", + "/m/dense.gguf", + "-c", + "32768", + "-b", + "2048", + "-ub", + "512", + "--host", + "0.0.0.0", + "--port", + "8080", + "--parallel", + "1", + "-ngl", + "auto", + "--mmproj", + "/m/x.gguf" + ], + "env": {}, + "notes": null + }, + { + "m": 11, + "c": 97, + "h": 0, + "e": 0, + "args": [ + "/app/llama-server", + "-m", + "/m/dense.gguf", + "-c", + "32768", + "-b", + "2048", + "-ub", + "512", + "--host", + "0.0.0.0", + "--port", + "8080", + "--parallel", + "1", + "-ngl", + "auto", + "--spec-synth-len", + "4" + ], + "env": {}, + "notes": [ + "avertissement : --spec-synth-len — le moteur accepte des jetons du brouillon AU HASARD, sans vérification : les réponses ne sont plus celles du modèle. Réservé aux mesures, retire-le." + ] + }, + { + "m": 11, + "c": 98, + "h": 0, + "e": 0, + "args": [ + "/app/llama-server", + "-m", + "/m/dense.gguf", + "-c", + "32768", + "-b", + "2048", + "-ub", + "512", + "--host", + "0.0.0.0", + "--port", + "8080", + "--parallel", + "1", + "-ngl", + "auto", + "--slot-save-path", + "/x" + ], + "env": {}, + "notes": null + }, + { + "m": 11, + "c": 99, + "h": 0, + "e": 0, + "args": [ + "/app/llama-server", + "-m", + "/m/dense.gguf", + "-c", + "32768", + "-b", + "2048", + "-ub", + "512", + "--host", + "0.0.0.0", + "--port", + "8080", + "--parallel", + "1", + "-ngl", + "auto", + "-c", + "0" + ], + "env": {}, + "notes": null + }, + { + "m": 11, + "c": 100, + "h": 0, + "e": 0, + "args": [ + "/app/llama-server", + "-m", + "/m/dense.gguf", + "-c", + "32768", + "-b", + "2048", + "-ub", + "512", + "--host", + "0.0.0.0", + "--port", + "8080", + "--parallel", + "1", + "-ngl", + "auto", + "--host", + "127.0.0.1", + "--port", + "9000" + ], + "env": {}, + "notes": null + }, + { + "m": 11, + "c": 101, + "h": 0, + "e": 0, + "args": [ + "/app/llama-server", + "-m", + "/m/dense.gguf", + "-c", + "32768", + "-b", + "2048", + "-ub", + "512", + "--host", + "0.0.0.0", + "--port", + "8080", + "--parallel", + "1", + "-ngl", + "auto", + "--mirostat", + "2" + ], + "env": {}, + "notes": [ + "SPEC=mtp : pas de tête MTP dans ce fichier (publiée à part ? MODEL_DRAFT=mtp-….gguf) — sans décodage spéculatif" + ] + }, + { + "m": 11, + "c": 102, + "h": 0, + "e": 0, + "args": [ + "/app/llama-server", + "-m", + "/m/dense.gguf", + "-c", + "32768", + "-b", + "2048", + "-ub", + "512", + "--host", + "0.0.0.0", + "--port", + "8080", + "--parallel", + "1", + "-ngl", + "auto", + "--chat-template-kwargs", + "{\"preserve_reasoning\":true}" + ], + "env": {}, + "notes": [ + "--chat-template-kwargs fixe déjà preserve_reasoning : REASONING_PRESERVE ignoré" + ] + }, + { + "m": 11, + "c": 103, + "h": 0, + "e": 0, + "args": [ + "/app/llama-server", + "-m", + "/m/dense.gguf", + "-c", + "32768", + "-b", + "2048", + "-ub", + "512", + "--host", + "0.0.0.0", + "--port", + "8080", + "--parallel", + "1", + "-ngl", + "auto", + "--spec-type", + "ngram-mod" + ], + "env": {}, + "notes": [ + "décodage spéculatif déjà réglé dans EXTRA_ARGS ou LLAMA_ARG_* : SPEC ignoré" + ] + }, + { + "m": 11, + "c": 104, + "h": 0, + "e": 0, + "args": [ + "/app/llama-server", + "-m", + "/m/dense.gguf", + "-c", + "32768", + "-b", + "2048", + "-ub", + "512", + "--host", + "0.0.0.0", + "--port", + "8080", + "--parallel", + "1", + "-ngl", + "auto", + "--spec-draft-n-max", + "8" + ], + "env": {}, + "notes": [ + "SPEC=ngram : --spec-draft-n-max dans EXTRA_ARGS (brouillon réglé à la main) — sans décodage spéculatif" + ] + }, + { + "m": 11, + "c": 105, + "h": 0, + "e": 0, + "args": [ + "/app/llama-server", + "-m", + "/m/dense.gguf", + "-c", + "32768", + "-b", + "2048", + "-ub", + "512", + "--host", + "0.0.0.0", + "--port", + "8080", + "--parallel", + "1", + "-ngl", + "auto", + "-ot", + "attn=CUDA1" + ], + "env": {}, + "notes": [ + "CUDA_GRAPH_OPT ignoré : -ot sur l'attention peut séparer Q, K et V d'une couche entre cartes" + ] + }, + { + "m": 11, + "c": 106, + "h": 0, + "e": 0, + "args": [ + "/app/llama-server", + "-m", + "/m/dense.gguf", + "-c", + "32768", + "-b", + "2048", + "-ub", + "512", + "--host", + "0.0.0.0", + "--port", + "8080", + "--parallel", + "1", + "-ngl", + "auto", + "-fa", + "off" + ], + "env": {}, + "notes": [ + "SPLIT_MODE=tensor refusé (Flash Attention coupée (-fa off) : le mode tensor l'exige) : découpe par couches, comme sans la clé" + ] + }, + { + "m": 11, + "c": 107, + "h": 0, + "e": 0, + "args": [ + "/app/llama-server", + "-m", + "/m/dense.gguf", + "-c", + "32768", + "-b", + "2048", + "-ub", + "512", + "--host", + "0.0.0.0", + "--port", + "8080", + "--parallel", + "1", + "-ngl", + "auto", + "-nkvo" + ], + "env": {}, + "notes": null + }, + { + "m": 11, + "c": 108, + "h": 0, + "e": 0, + "args": [ + "/app/llama-server", + "-m", + "/m/dense.gguf", + "-c", + "32768", + "-b", + "2048", + "-ub", + "512", + "--host", + "0.0.0.0", + "--port", + "8080", + "--parallel", + "1", + "-ngl", + "auto", + "--no-context-shift" + ], + "env": {}, + "notes": null + }, + { + "m": 11, + "c": 109, + "h": 0, + "e": 0, + "args": [ + "/app/llama-server", + "-m", + "/m/dense.gguf", + "-c", + "32768", + "-b", + "2048", + "-ub", + "512", + "--host", + "0.0.0.0", + "--port", + "8080", + "-ngl", + "auto", + "-c", + "8192", + "-np", + "2" + ], + "env": {}, + "notes": [ + "SIDE_SLOT refusé (-c est réglé dans EXTRA_ARGS) : un seul slot, comme sans la clé" + ] + }, + { + "m": 11, + "c": 110, + "h": 0, + "e": 0, + "args": [ + "/app/llama-server", + "-m", + "/m/dense.gguf", + "-c", + "32768", + "-b", + "2048", + "-ub", + "512", + "--host", + "0.0.0.0", + "--port", + "8080", + "--parallel", + "1", + "-ngl", + "auto" + ], + "env": {}, + "notes": [ + "SPLIT_MODE=tensor refusé (preset externe) : découpe par couches, comme sans la clé", + "SIDE_SLOT refusé (preset externe) : un seul slot, comme sans la clé" + ] + }, + { + "m": 11, + "c": 111, + "h": 0, + "e": 0, + "args": [ + "/app/llama-server", + "-m", + "/m/dense.gguf", + "-c", + "40000", + "-b", + "2048", + "-ub", + "512", + "--host", + "0.0.0.0", + "--port", + "8080", + "--parallel", + "1", + "-ngl", + "auto", + "-ctk", + "q4_0", + "-ctv", + "q4_0" + ], + "env": {}, + "notes": [ + "NGL=999 (sentinelle « toutes les couches ») → -ngl auto : ce moteur mesure lui-même la VRAM libre. NGL=all pour forcer l'ancien comportement.", + "cache KV q4_0/q4_0 (KV_TYPE) : perte mesurable par rapport à f16 — choix de l'utilisateur, laissé tel quel." + ] + }, + { + "m": 0, + "c": 0, + "h": 1, + "e": 0, + "args": [ + "/app/llama-server", + "-m", + "/m/dense.gguf", + "-c", + "32768", + "-b", + "2048", + "-ub", + "512", + "--host", + "0.0.0.0", + "--port", + "8080", + "--parallel", + "1", + "-ngl", + "999" + ], + "env": {}, + "notes": [ + "avertissement : ce llama-server ancien glisse le contexte par défaut — contexte plein, il jette des jetons anciens et le modèle perd une partie de la conversation sans le savoir. --no-context-shift dans EXTRA_ARGS l'en empêche." + ] + }, + { + "m": 2, + "c": 0, + "h": 2, + "e": 0, + "args": [ + "/app/llama-server", + "-m", + "/m/inconnu.gguf", + "-c", + "32768", + "-b", + "2048", + "-ub", + "512", + "--host", + "0.0.0.0", + "--port", + "8080", + "--parallel", + "1", + "-ngl", + "999" + ], + "env": {}, + "notes": null + }, + { + "m": 4, + "c": 0, + "h": 1, + "e": 0, + "args": [ + "/app/llama-server", + "-m", + "/m/hybrid.gguf", + "-c", + "32768", + "-b", + "2048", + "-ub", + "512", + "--host", + "0.0.0.0", + "--port", + "8080", + "--parallel", + "1", + "-ngl", + "999" + ], + "env": { + "CUDA_SCALE_LAUNCH_QUEUES": "4x" + }, + "notes": [ + "CUDA_SCALE_LAUNCH_QUEUES=4x (2 GPU, parallélisme pipeline possible) — CUDA_LAUNCH_QUEUES=off pour s'en passer", + "avertissement : ce llama-server ancien glisse le contexte par défaut — contexte plein, il jette des jetons anciens et le modèle perd une partie de la conversation sans le savoir. --no-context-shift dans EXTRA_ARGS l'en empêche." + ] + }, + { + "m": 0, + "c": 1, + "h": 1, + "e": 0, + "args": [ + "/app/llama-server", + "-m", + "/m/dense.gguf", + "-c", + "65536", + "-b", + "4096", + "-ub", + "2048", + "--host", + "127.0.0.1", + "--port", + "8081", + "--parallel", + "1", + "-ngl", + "999" + ], + "env": {}, + "notes": [ + "avertissement : ce llama-server ancien glisse le contexte par défaut — contexte plein, il jette des jetons anciens et le modèle perd une partie de la conversation sans le savoir. --no-context-shift dans EXTRA_ARGS l'en empêche." + ] + }, + { + "m": 2, + "c": 1, + "h": 2, + "e": 0, + "args": [ + "/app/llama-server", + "-m", + "/m/inconnu.gguf", + "-c", + "65536", + "-b", + "4096", + "-ub", + "2048", + "--host", + "127.0.0.1", + "--port", + "8081", + "--parallel", + "1", + "-ngl", + "999" + ], + "env": {}, + "notes": null + }, + { + "m": 4, + "c": 1, + "h": 1, + "e": 0, + "args": [ + "/app/llama-server", + "-m", + "/m/hybrid.gguf", + "-c", + "65536", + "-b", + "4096", + "-ub", + "2048", + "--host", + "127.0.0.1", + "--port", + "8081", + "--parallel", + "1", + "-ngl", + "999", + "--cache-ram", + "13981" + ], + "env": { + "CUDA_SCALE_LAUNCH_QUEUES": "4x" + }, + "notes": [ + "CUDA_SCALE_LAUNCH_QUEUES=4x (2 GPU, parallélisme pipeline possible) — CUDA_LAUNCH_QUEUES=off pour s'en passer", + "cache de prompts : --cache-ram 13981 (RAM hôte, copie exacte de l'état, jamais la VRAM ; besoin estimé 13981 Mio, plafond 39321 Mio). CACHE_RAM= pour fixer, 0 pour couper.", + "avertissement : ce llama-server ancien glisse le contexte par défaut — contexte plein, il jette des jetons anciens et le modèle perd une partie de la conversation sans le savoir. --no-context-shift dans EXTRA_ARGS l'en empêche." + ] + }, + { + "m": 0, + "c": 2, + "h": 1, + "e": 0, + "args": [ + "/app/llama-server", + "-m", + "/m/dense.gguf", + "-c", + "32768", + "-b", + "2048", + "-ub", + "512", + "--host", + "0.0.0.0", + "--port", + "8080", + "--parallel", + "1" + ], + "env": {}, + "notes": [ + "avertissement : ce llama-server ancien glisse le contexte par défaut — contexte plein, il jette des jetons anciens et le modèle perd une partie de la conversation sans le savoir. --no-context-shift dans EXTRA_ARGS l'en empêche." + ] + }, + { + "m": 2, + "c": 2, + "h": 2, + "e": 0, + "args": [ + "/app/llama-server", + "-m", + "/m/inconnu.gguf", + "-c", + "32768", + "-b", + "2048", + "-ub", + "512", + "--host", + "0.0.0.0", + "--port", + "8080", + "--parallel", + "1" + ], + "env": {}, + "notes": null + }, + { + "m": 4, + "c": 2, + "h": 1, + "e": 0, + "args": [ + "/app/llama-server", + "-m", + "/m/hybrid.gguf", + "-c", + "32768", + "-b", + "2048", + "-ub", + "512", + "--host", + "0.0.0.0", + "--port", + "8080", + "--parallel", + "1" + ], + "env": { + "CUDA_SCALE_LAUNCH_QUEUES": "4x" + }, + "notes": [ + "CUDA_SCALE_LAUNCH_QUEUES=4x (2 GPU, parallélisme pipeline possible) — CUDA_LAUNCH_QUEUES=off pour s'en passer", + "avertissement : ce llama-server ancien glisse le contexte par défaut — contexte plein, il jette des jetons anciens et le modèle perd une partie de la conversation sans le savoir. --no-context-shift dans EXTRA_ARGS l'en empêche." + ] + }, + { + "m": 0, + "c": 3, + "h": 1, + "e": 0, + "args": [ + "/app/llama-server", + "-m", + "/m/dense.gguf", + "-c", + "32768", + "-b", + "2048", + "-ub", + "512", + "--host", + "0.0.0.0", + "--port", + "8080", + "--parallel", + "1", + "-ngl", + "40" + ], + "env": {}, + "notes": [ + "avertissement : ce llama-server ancien glisse le contexte par défaut — contexte plein, il jette des jetons anciens et le modèle perd une partie de la conversation sans le savoir. --no-context-shift dans EXTRA_ARGS l'en empêche." + ] + }, + { + "m": 2, + "c": 3, + "h": 2, + "e": 0, + "args": [ + "/app/llama-server", + "-m", + "/m/inconnu.gguf", + "-c", + "32768", + "-b", + "2048", + "-ub", + "512", + "--host", + "0.0.0.0", + "--port", + "8080", + "--parallel", + "1", + "-ngl", + "40" + ], + "env": {}, + "notes": null + }, + { + "m": 4, + "c": 3, + "h": 1, + "e": 0, + "args": [ + "/app/llama-server", + "-m", + "/m/hybrid.gguf", + "-c", + "32768", + "-b", + "2048", + "-ub", + "512", + "--host", + "0.0.0.0", + "--port", + "8080", + "--parallel", + "1", + "-ngl", + "40" + ], + "env": {}, + "notes": [ + "avertissement : ce llama-server ancien glisse le contexte par défaut — contexte plein, il jette des jetons anciens et le modèle perd une partie de la conversation sans le savoir. --no-context-shift dans EXTRA_ARGS l'en empêche." + ] + }, + { + "m": 0, + "c": 4, + "h": 1, + "e": 0, + "args": [ + "/app/llama-server", + "-m", + "/m/dense.gguf", + "-c", + "32768", + "-b", + "2048", + "-ub", + "512", + "--host", + "0.0.0.0", + "--port", + "8080", + "--parallel", + "1", + "-ngl", + "all" + ], + "env": {}, + "notes": [ + "avertissement : ce llama-server ancien glisse le contexte par défaut — contexte plein, il jette des jetons anciens et le modèle perd une partie de la conversation sans le savoir. --no-context-shift dans EXTRA_ARGS l'en empêche." + ] + }, + { + "m": 2, + "c": 4, + "h": 2, + "e": 0, + "args": [ + "/app/llama-server", + "-m", + "/m/inconnu.gguf", + "-c", + "32768", + "-b", + "2048", + "-ub", + "512", + "--host", + "0.0.0.0", + "--port", + "8080", + "--parallel", + "1", + "-ngl", + "all" + ], + "env": {}, + "notes": null + }, + { + "m": 4, + "c": 4, + "h": 1, + "e": 0, + "args": [ + "/app/llama-server", + "-m", + "/m/hybrid.gguf", + "-c", + "32768", + "-b", + "2048", + "-ub", + "512", + "--host", + "0.0.0.0", + "--port", + "8080", + "--parallel", + "1", + "-ngl", + "all" + ], + "env": { + "CUDA_SCALE_LAUNCH_QUEUES": "4x" + }, + "notes": [ + "CUDA_SCALE_LAUNCH_QUEUES=4x (2 GPU, parallélisme pipeline possible) — CUDA_LAUNCH_QUEUES=off pour s'en passer", + "avertissement : ce llama-server ancien glisse le contexte par défaut — contexte plein, il jette des jetons anciens et le modèle perd une partie de la conversation sans le savoir. --no-context-shift dans EXTRA_ARGS l'en empêche." + ] + }, + { + "m": 0, + "c": 5, + "h": 1, + "e": 0, + "args": [ + "/app/llama-server", + "-m", + "/m/dense.gguf", + "-c", + "32768", + "-b", + "2048", + "-ub", + "512", + "--host", + "0.0.0.0", + "--port", + "8080", + "--parallel", + "1", + "-ngl", + "999" + ], + "env": {}, + "notes": [ + "avertissement : ce llama-server ancien glisse le contexte par défaut — contexte plein, il jette des jetons anciens et le modèle perd une partie de la conversation sans le savoir. --no-context-shift dans EXTRA_ARGS l'en empêche." + ] + }, + { + "m": 2, + "c": 5, + "h": 2, + "e": 0, + "args": [ + "/app/llama-server", + "-m", + "/m/inconnu.gguf", + "-c", + "32768", + "-b", + "2048", + "-ub", + "512", + "--host", + "0.0.0.0", + "--port", + "8080", + "--parallel", + "1", + "-ngl", + "999" + ], + "env": {}, + "notes": null + }, + { + "m": 4, + "c": 5, + "h": 1, + "e": 0, + "args": [ + "/app/llama-server", + "-m", + "/m/hybrid.gguf", + "-c", + "32768", + "-b", + "2048", + "-ub", + "512", + "--host", + "0.0.0.0", + "--port", + "8080", + "--parallel", + "1", + "-ngl", + "999" + ], + "env": { + "CUDA_SCALE_LAUNCH_QUEUES": "4x" + }, + "notes": [ + "CUDA_SCALE_LAUNCH_QUEUES=4x (2 GPU, parallélisme pipeline possible) — CUDA_LAUNCH_QUEUES=off pour s'en passer", + "avertissement : ce llama-server ancien glisse le contexte par défaut — contexte plein, il jette des jetons anciens et le modèle perd une partie de la conversation sans le savoir. --no-context-shift dans EXTRA_ARGS l'en empêche." + ] + }, + { + "m": 0, + "c": 6, + "h": 1, + "e": 0, + "args": [ + "/app/llama-server", + "-m", + "/m/dense.gguf", + "-c", + "32768", + "-t", + "8", + "-tb", + "4", + "-b", + "2048", + "-ub", + "512", + "--host", + "0.0.0.0", + "--port", + "8080", + "--parallel", + "1", + "-ngl", + "999" + ], + "env": {}, + "notes": [ + "avertissement : ce llama-server ancien glisse le contexte par défaut — contexte plein, il jette des jetons anciens et le modèle perd une partie de la conversation sans le savoir. --no-context-shift dans EXTRA_ARGS l'en empêche." + ] + }, + { + "m": 2, + "c": 6, + "h": 2, + "e": 0, + "args": [ + "/app/llama-server", + "-m", + "/m/inconnu.gguf", + "-c", + "32768", + "-t", + "8", + "-tb", + "4", + "-b", + "2048", + "-ub", + "512", + "--host", + "0.0.0.0", + "--port", + "8080", + "--parallel", + "1", + "-ngl", + "999" + ], + "env": {}, + "notes": null + }, + { + "m": 4, + "c": 6, + "h": 1, + "e": 0, + "args": [ + "/app/llama-server", + "-m", + "/m/hybrid.gguf", + "-c", + "32768", + "-t", + "8", + "-tb", + "4", + "-b", + "2048", + "-ub", + "512", + "--host", + "0.0.0.0", + "--port", + "8080", + "--parallel", + "1", + "-ngl", + "999" + ], + "env": { + "CUDA_SCALE_LAUNCH_QUEUES": "4x" + }, + "notes": [ + "CUDA_SCALE_LAUNCH_QUEUES=4x (2 GPU, parallélisme pipeline possible) — CUDA_LAUNCH_QUEUES=off pour s'en passer", + "avertissement : ce llama-server ancien glisse le contexte par défaut — contexte plein, il jette des jetons anciens et le modèle perd une partie de la conversation sans le savoir. --no-context-shift dans EXTRA_ARGS l'en empêche." + ] + }, + { + "m": 0, + "c": 7, + "h": 1, + "e": 0, + "args": [ + "/app/llama-server", + "-m", + "/m/dense.gguf", + "-c", + "32768", + "-b", + "2048", + "-ub", + "512", + "--host", + "0.0.0.0", + "--port", + "8080", + "--parallel", + "1", + "-ngl", + "999" + ], + "env": {}, + "notes": [ + "THREADS=abc ignoré (pas un nombre de threads ≥ 0) → automatique", + "THREADS_BATCH=-2 ignoré (pas un nombre de threads ≥ 0) → automatique", + "avertissement : ce llama-server ancien glisse le contexte par défaut — contexte plein, il jette des jetons anciens et le modèle perd une partie de la conversation sans le savoir. --no-context-shift dans EXTRA_ARGS l'en empêche." + ] + }, + { + "m": 2, + "c": 7, + "h": 2, + "e": 0, + "args": [ + "/app/llama-server", + "-m", + "/m/inconnu.gguf", + "-c", + "32768", + "-b", + "2048", + "-ub", + "512", + "--host", + "0.0.0.0", + "--port", + "8080", + "--parallel", + "1", + "-ngl", + "999" + ], + "env": {}, + "notes": [ + "THREADS=abc ignoré (pas un nombre de threads ≥ 0) → automatique", + "THREADS_BATCH=-2 ignoré (pas un nombre de threads ≥ 0) → automatique" + ] + }, + { + "m": 4, + "c": 7, + "h": 1, + "e": 0, + "args": [ + "/app/llama-server", + "-m", + "/m/hybrid.gguf", + "-c", + "32768", + "-b", + "2048", + "-ub", + "512", + "--host", + "0.0.0.0", + "--port", + "8080", + "--parallel", + "1", + "-ngl", + "999" + ], + "env": { + "CUDA_SCALE_LAUNCH_QUEUES": "4x" + }, + "notes": [ + "CUDA_SCALE_LAUNCH_QUEUES=4x (2 GPU, parallélisme pipeline possible) — CUDA_LAUNCH_QUEUES=off pour s'en passer", + "THREADS=abc ignoré (pas un nombre de threads ≥ 0) → automatique", + "THREADS_BATCH=-2 ignoré (pas un nombre de threads ≥ 0) → automatique", + "avertissement : ce llama-server ancien glisse le contexte par défaut — contexte plein, il jette des jetons anciens et le modèle perd une partie de la conversation sans le savoir. --no-context-shift dans EXTRA_ARGS l'en empêche." + ] + }, + { + "m": 0, + "c": 8, + "h": 1, + "e": 0, + "args": [ + "/app/llama-server", + "-m", + "/m/dense.gguf", + "-c", + "32768", + "-b", + "2048", + "-ub", + "512", + "--host", + "0.0.0.0", + "--port", + "8080", + "--parallel", + "1", + "-ngl", + "999" + ], + "env": {}, + "notes": [ + "avertissement : ce llama-server ancien glisse le contexte par défaut — contexte plein, il jette des jetons anciens et le modèle perd une partie de la conversation sans le savoir. --no-context-shift dans EXTRA_ARGS l'en empêche." + ] + }, + { + "m": 2, + "c": 8, + "h": 2, + "e": 0, + "args": [ + "/app/llama-server", + "-m", + "/m/inconnu.gguf", + "-c", + "32768", + "-b", + "2048", + "-ub", + "512", + "--host", + "0.0.0.0", + "--port", + "8080", + "--parallel", + "1", + "-ngl", + "999" + ], + "env": {}, + "notes": null + }, + { + "m": 4, + "c": 8, + "h": 1, + "e": 0, + "args": [ + "/app/llama-server", + "-m", + "/m/hybrid.gguf", + "-c", + "32768", + "-b", + "2048", + "-ub", + "512", + "--host", + "0.0.0.0", + "--port", + "8080", + "--parallel", + "1", + "-ngl", + "999" + ], + "env": { + "CUDA_SCALE_LAUNCH_QUEUES": "4x" + }, + "notes": [ + "CUDA_SCALE_LAUNCH_QUEUES=4x (2 GPU, parallélisme pipeline possible) — CUDA_LAUNCH_QUEUES=off pour s'en passer", + "avertissement : ce llama-server ancien glisse le contexte par défaut — contexte plein, il jette des jetons anciens et le modèle perd une partie de la conversation sans le savoir. --no-context-shift dans EXTRA_ARGS l'en empêche." + ] + }, + { + "m": 0, + "c": 9, + "h": 1, + "e": 0, + "args": [ + "/app/llama-server", + "-m", + "/m/dense.gguf", + "-c", + "32768", + "-b", + "2048", + "-ub", + "512", + "--host", + "0.0.0.0", + "--port", + "8080", + "--parallel", + "1", + "-ngl", + "999", + "-ctk", + "q8_0", + "-ctv", + "q8_0" + ], + "env": {}, + "notes": [ + "cache KV q8_0/q8_0 (KV_TYPE) : modifie légèrement les sorties par rapport à f16 — choix de l'utilisateur, laissé tel quel.", + "avertissement : ce llama-server ancien glisse le contexte par défaut — contexte plein, il jette des jetons anciens et le modèle perd une partie de la conversation sans le savoir. --no-context-shift dans EXTRA_ARGS l'en empêche." + ] + }, + { + "m": 2, + "c": 9, + "h": 2, + "e": 0, + "args": [ + "/app/llama-server", + "-m", + "/m/inconnu.gguf", + "-c", + "32768", + "-b", + "2048", + "-ub", + "512", + "--host", + "0.0.0.0", + "--port", + "8080", + "--parallel", + "1", + "-ngl", + "999", + "-ctk", + "q8_0", + "-ctv", + "q8_0" + ], + "env": {}, + "notes": [ + "cache KV q8_0/q8_0 (KV_TYPE) : modifie légèrement les sorties par rapport à f16 — choix de l'utilisateur, laissé tel quel." + ] + }, + { + "m": 4, + "c": 9, + "h": 1, + "e": 0, + "args": [ + "/app/llama-server", + "-m", + "/m/hybrid.gguf", + "-c", + "32768", + "-b", + "2048", + "-ub", + "512", + "--host", + "0.0.0.0", + "--port", + "8080", + "--parallel", + "1", + "-ngl", + "999", + "-ctk", + "q8_0", + "-ctv", + "q8_0" + ], + "env": { + "CUDA_SCALE_LAUNCH_QUEUES": "4x" + }, + "notes": [ + "CUDA_SCALE_LAUNCH_QUEUES=4x (2 GPU, parallélisme pipeline possible) — CUDA_LAUNCH_QUEUES=off pour s'en passer", + "cache KV q8_0/q8_0 (KV_TYPE) : modifie légèrement les sorties par rapport à f16 — choix de l'utilisateur, laissé tel quel.", + "avertissement : ce llama-server ancien glisse le contexte par défaut — contexte plein, il jette des jetons anciens et le modèle perd une partie de la conversation sans le savoir. --no-context-shift dans EXTRA_ARGS l'en empêche." + ] + }, + { + "m": 0, + "c": 10, + "h": 1, + "e": 0, + "args": [ + "/app/llama-server", + "-m", + "/m/dense.gguf", + "-c", + "32768", + "-b", + "2048", + "-ub", + "512", + "--host", + "0.0.0.0", + "--port", + "8080", + "--parallel", + "1", + "-ngl", + "999", + "-ctk", + "q4_0", + "-ctv", + "f16" + ], + "env": {}, + "notes": [ + "cache KV q4_0/f16 (KV_TYPE) : perte mesurable par rapport à f16 — choix de l'utilisateur, laissé tel quel.", + "avertissement : ce llama-server ancien glisse le contexte par défaut — contexte plein, il jette des jetons anciens et le modèle perd une partie de la conversation sans le savoir. --no-context-shift dans EXTRA_ARGS l'en empêche." + ] + }, + { + "m": 2, + "c": 10, + "h": 2, + "e": 0, + "args": [ + "/app/llama-server", + "-m", + "/m/inconnu.gguf", + "-c", + "32768", + "-b", + "2048", + "-ub", + "512", + "--host", + "0.0.0.0", + "--port", + "8080", + "--parallel", + "1", + "-ngl", + "999", + "-ctk", + "q4_0", + "-ctv", + "f16" + ], + "env": {}, + "notes": [ + "cache KV q4_0/f16 (KV_TYPE) : perte mesurable par rapport à f16 — choix de l'utilisateur, laissé tel quel." + ] + }, + { + "m": 4, + "c": 10, + "h": 1, + "e": 0, + "args": [ + "/app/llama-server", + "-m", + "/m/hybrid.gguf", + "-c", + "32768", + "-b", + "2048", + "-ub", + "512", + "--host", + "0.0.0.0", + "--port", + "8080", + "--parallel", + "1", + "-ngl", + "999", + "-ctk", + "q4_0", + "-ctv", + "f16" + ], + "env": { + "CUDA_SCALE_LAUNCH_QUEUES": "4x" + }, + "notes": [ + "CUDA_SCALE_LAUNCH_QUEUES=4x (2 GPU, parallélisme pipeline possible) — CUDA_LAUNCH_QUEUES=off pour s'en passer", + "cache KV q4_0/f16 (KV_TYPE) : perte mesurable par rapport à f16 — choix de l'utilisateur, laissé tel quel.", + "avertissement : ce llama-server ancien glisse le contexte par défaut — contexte plein, il jette des jetons anciens et le modèle perd une partie de la conversation sans le savoir. --no-context-shift dans EXTRA_ARGS l'en empêche." + ] + }, + { + "m": 0, + "c": 11, + "h": 1, + "e": 0, + "args": [ + "/app/llama-server", + "-m", + "/m/dense.gguf", + "-c", + "32768", + "-b", + "2048", + "-ub", + "512", + "--host", + "0.0.0.0", + "--port", + "8080", + "--parallel", + "1", + "-ngl", + "999", + "-ctk", + "bf16", + "-ctv", + "bf16" + ], + "env": {}, + "notes": [ + "cache KV bf16/bf16 (KV_TYPE) : numérique différente de f16 (mantisse plus courte) — choix de l'utilisateur, laissé tel quel.", + "avertissement : ce llama-server ancien glisse le contexte par défaut — contexte plein, il jette des jetons anciens et le modèle perd une partie de la conversation sans le savoir. --no-context-shift dans EXTRA_ARGS l'en empêche." + ] + }, + { + "m": 2, + "c": 11, + "h": 2, + "e": 0, + "args": [ + "/app/llama-server", + "-m", + "/m/inconnu.gguf", + "-c", + "32768", + "-b", + "2048", + "-ub", + "512", + "--host", + "0.0.0.0", + "--port", + "8080", + "--parallel", + "1", + "-ngl", + "999", + "-ctk", + "bf16", + "-ctv", + "bf16" + ], + "env": {}, + "notes": [ + "cache KV bf16/bf16 (KV_TYPE) : numérique différente de f16 (mantisse plus courte) — choix de l'utilisateur, laissé tel quel." + ] + }, + { + "m": 4, + "c": 11, + "h": 1, + "e": 0, + "args": [ + "/app/llama-server", + "-m", + "/m/hybrid.gguf", + "-c", + "32768", + "-b", + "2048", + "-ub", + "512", + "--host", + "0.0.0.0", + "--port", + "8080", + "--parallel", + "1", + "-ngl", + "999", + "-ctk", + "bf16", + "-ctv", + "bf16" + ], + "env": { + "CUDA_SCALE_LAUNCH_QUEUES": "4x" + }, + "notes": [ + "CUDA_SCALE_LAUNCH_QUEUES=4x (2 GPU, parallélisme pipeline possible) — CUDA_LAUNCH_QUEUES=off pour s'en passer", + "cache KV bf16/bf16 (KV_TYPE) : numérique différente de f16 (mantisse plus courte) — choix de l'utilisateur, laissé tel quel.", + "avertissement : ce llama-server ancien glisse le contexte par défaut — contexte plein, il jette des jetons anciens et le modèle perd une partie de la conversation sans le savoir. --no-context-shift dans EXTRA_ARGS l'en empêche." + ] + }, + { + "m": 0, + "c": 12, + "h": 1, + "e": 0, + "args": [ + "/app/llama-server", + "-m", + "/m/dense.gguf", + "-c", + "32768", + "-b", + "2048", + "-ub", + "512", + "--host", + "0.0.0.0", + "--port", + "8080", + "--parallel", + "1", + "-ngl", + "999", + "-ctk", + "q5_1", + "-ctv", + "q5_1" + ], + "env": {}, + "notes": [ + "cache KV q5_1/q5_1 (KV_TYPE) : modifie légèrement les sorties par rapport à f16 — choix de l'utilisateur, laissé tel quel.", + "avertissement : ce llama-server ancien glisse le contexte par défaut — contexte plein, il jette des jetons anciens et le modèle perd une partie de la conversation sans le savoir. --no-context-shift dans EXTRA_ARGS l'en empêche." + ] + }, + { + "m": 2, + "c": 12, + "h": 2, + "e": 0, + "args": [ + "/app/llama-server", + "-m", + "/m/inconnu.gguf", + "-c", + "32768", + "-b", + "2048", + "-ub", + "512", + "--host", + "0.0.0.0", + "--port", + "8080", + "--parallel", + "1", + "-ngl", + "999", + "-ctk", + "q5_1", + "-ctv", + "q5_1" + ], + "env": {}, + "notes": [ + "cache KV q5_1/q5_1 (KV_TYPE) : modifie légèrement les sorties par rapport à f16 — choix de l'utilisateur, laissé tel quel." + ] + }, + { + "m": 4, + "c": 12, + "h": 1, + "e": 0, + "args": [ + "/app/llama-server", + "-m", + "/m/hybrid.gguf", + "-c", + "32768", + "-b", + "2048", + "-ub", + "512", + "--host", + "0.0.0.0", + "--port", + "8080", + "--parallel", + "1", + "-ngl", + "999", + "-ctk", + "q5_1", + "-ctv", + "q5_1" + ], + "env": { + "CUDA_SCALE_LAUNCH_QUEUES": "4x" + }, + "notes": [ + "CUDA_SCALE_LAUNCH_QUEUES=4x (2 GPU, parallélisme pipeline possible) — CUDA_LAUNCH_QUEUES=off pour s'en passer", + "cache KV q5_1/q5_1 (KV_TYPE) : modifie légèrement les sorties par rapport à f16 — choix de l'utilisateur, laissé tel quel.", + "avertissement : ce llama-server ancien glisse le contexte par défaut — contexte plein, il jette des jetons anciens et le modèle perd une partie de la conversation sans le savoir. --no-context-shift dans EXTRA_ARGS l'en empêche." + ] + }, + { + "m": 0, + "c": 13, + "h": 1, + "e": 0, + "args": [ + "/app/llama-server", + "-m", + "/m/dense.gguf", + "-c", + "32768", + "-b", + "2048", + "-ub", + "512", + "--host", + "0.0.0.0", + "--port", + "8080", + "--parallel", + "1", + "-ngl", + "999", + "--reasoning", + "on", + "--reasoning-budget", + "-1" + ], + "env": {}, + "notes": [ + "avertissement : ce llama-server ancien glisse le contexte par défaut — contexte plein, il jette des jetons anciens et le modèle perd une partie de la conversation sans le savoir. --no-context-shift dans EXTRA_ARGS l'en empêche." + ] + }, + { + "m": 2, + "c": 13, + "h": 2, + "e": 0, + "args": [ + "/app/llama-server", + "-m", + "/m/inconnu.gguf", + "-c", + "32768", + "-b", + "2048", + "-ub", + "512", + "--host", + "0.0.0.0", + "--port", + "8080", + "--parallel", + "1", + "-ngl", + "999", + "--reasoning", + "on", + "--reasoning-budget", + "-1" + ], + "env": {}, + "notes": null + }, + { + "m": 4, + "c": 13, + "h": 1, + "e": 0, + "args": [ + "/app/llama-server", + "-m", + "/m/hybrid.gguf", + "-c", + "32768", + "-b", + "2048", + "-ub", + "512", + "--host", + "0.0.0.0", + "--port", + "8080", + "--parallel", + "1", + "-ngl", + "999", + "--reasoning", + "on", + "--reasoning-budget", + "-1" + ], + "env": { + "CUDA_SCALE_LAUNCH_QUEUES": "4x" + }, + "notes": [ + "CUDA_SCALE_LAUNCH_QUEUES=4x (2 GPU, parallélisme pipeline possible) — CUDA_LAUNCH_QUEUES=off pour s'en passer", + "avertissement : ce llama-server ancien glisse le contexte par défaut — contexte plein, il jette des jetons anciens et le modèle perd une partie de la conversation sans le savoir. --no-context-shift dans EXTRA_ARGS l'en empêche." + ] + }, + { + "m": 0, + "c": 14, + "h": 1, + "e": 0, + "args": [ + "/app/llama-server", + "-m", + "/m/dense.gguf", + "-c", + "32768", + "-b", + "2048", + "-ub", + "512", + "--host", + "0.0.0.0", + "--port", + "8080", + "--parallel", + "1", + "-ngl", + "999" + ], + "env": {}, + "notes": [ + "ce moteur ne connaît pas --reasoning : impossible de désactiver le raisonnement", + "avertissement : ce llama-server ancien glisse le contexte par défaut — contexte plein, il jette des jetons anciens et le modèle perd une partie de la conversation sans le savoir. --no-context-shift dans EXTRA_ARGS l'en empêche." + ] + }, + { + "m": 2, + "c": 14, + "h": 2, + "e": 0, + "args": [ + "/app/llama-server", + "-m", + "/m/inconnu.gguf", + "-c", + "32768", + "-b", + "2048", + "-ub", + "512", + "--host", + "0.0.0.0", + "--port", + "8080", + "--parallel", + "1", + "-ngl", + "999" + ], + "env": {}, + "notes": [ + "ce moteur ne connaît pas --reasoning : impossible de désactiver le raisonnement" + ] + }, + { + "m": 4, + "c": 14, + "h": 1, + "e": 0, + "args": [ + "/app/llama-server", + "-m", + "/m/hybrid.gguf", + "-c", + "32768", + "-b", + "2048", + "-ub", + "512", + "--host", + "0.0.0.0", + "--port", + "8080", + "--parallel", + "1", + "-ngl", + "999" + ], + "env": { + "CUDA_SCALE_LAUNCH_QUEUES": "4x" + }, + "notes": [ + "CUDA_SCALE_LAUNCH_QUEUES=4x (2 GPU, parallélisme pipeline possible) — CUDA_LAUNCH_QUEUES=off pour s'en passer", + "ce moteur ne connaît pas --reasoning : impossible de désactiver le raisonnement", + "avertissement : ce llama-server ancien glisse le contexte par défaut — contexte plein, il jette des jetons anciens et le modèle perd une partie de la conversation sans le savoir. --no-context-shift dans EXTRA_ARGS l'en empêche." + ] + }, + { + "m": 0, + "c": 15, + "h": 1, + "e": 0, + "args": [ + "/app/llama-server", + "-m", + "/m/dense.gguf", + "-c", + "32768", + "-b", + "2048", + "-ub", + "512", + "--host", + "0.0.0.0", + "--port", + "8080", + "--parallel", + "1", + "-ngl", + "999", + "--reasoning", + "auto", + "--reasoning-budget", + "2048" + ], + "env": {}, + "notes": [ + "avertissement : ce llama-server ancien glisse le contexte par défaut — contexte plein, il jette des jetons anciens et le modèle perd une partie de la conversation sans le savoir. --no-context-shift dans EXTRA_ARGS l'en empêche." + ] + }, + { + "m": 2, + "c": 15, + "h": 2, + "e": 0, + "args": [ + "/app/llama-server", + "-m", + "/m/inconnu.gguf", + "-c", + "32768", + "-b", + "2048", + "-ub", + "512", + "--host", + "0.0.0.0", + "--port", + "8080", + "--parallel", + "1", + "-ngl", + "999", + "--reasoning", + "auto", + "--reasoning-budget", + "2048" + ], + "env": {}, + "notes": null + }, + { + "m": 4, + "c": 15, + "h": 1, + "e": 0, + "args": [ + "/app/llama-server", + "-m", + "/m/hybrid.gguf", + "-c", + "32768", + "-b", + "2048", + "-ub", + "512", + "--host", + "0.0.0.0", + "--port", + "8080", + "--parallel", + "1", + "-ngl", + "999", + "--reasoning", + "auto", + "--reasoning-budget", + "2048" + ], + "env": { + "CUDA_SCALE_LAUNCH_QUEUES": "4x" + }, + "notes": [ + "CUDA_SCALE_LAUNCH_QUEUES=4x (2 GPU, parallélisme pipeline possible) — CUDA_LAUNCH_QUEUES=off pour s'en passer", + "avertissement : ce llama-server ancien glisse le contexte par défaut — contexte plein, il jette des jetons anciens et le modèle perd une partie de la conversation sans le savoir. --no-context-shift dans EXTRA_ARGS l'en empêche." + ] + }, + { + "m": 0, + "c": 16, + "h": 1, + "e": 0, + "args": [ + "/app/llama-server", + "-m", + "/m/dense.gguf", + "-c", + "32768", + "-b", + "2048", + "-ub", + "512", + "--host", + "0.0.0.0", + "--port", + "8080", + "--parallel", + "1", + "-ngl", + "999", + "--reasoning", + "deepseek", + "--reasoning-budget", + "-1" + ], + "env": {}, + "notes": [ + "avertissement : ce llama-server ancien glisse le contexte par défaut — contexte plein, il jette des jetons anciens et le modèle perd une partie de la conversation sans le savoir. --no-context-shift dans EXTRA_ARGS l'en empêche." + ] + }, + { + "m": 2, + "c": 16, + "h": 2, + "e": 0, + "args": [ + "/app/llama-server", + "-m", + "/m/inconnu.gguf", + "-c", + "32768", + "-b", + "2048", + "-ub", + "512", + "--host", + "0.0.0.0", + "--port", + "8080", + "--parallel", + "1", + "-ngl", + "999", + "--reasoning", + "deepseek", + "--reasoning-budget", + "-1" + ], + "env": {}, + "notes": null + }, + { + "m": 4, + "c": 16, + "h": 1, + "e": 0, + "args": [ + "/app/llama-server", + "-m", + "/m/hybrid.gguf", + "-c", + "32768", + "-b", + "2048", + "-ub", + "512", + "--host", + "0.0.0.0", + "--port", + "8080", + "--parallel", + "1", + "-ngl", + "999", + "--reasoning", + "deepseek", + "--reasoning-budget", + "-1" + ], + "env": { + "CUDA_SCALE_LAUNCH_QUEUES": "4x" + }, + "notes": [ + "CUDA_SCALE_LAUNCH_QUEUES=4x (2 GPU, parallélisme pipeline possible) — CUDA_LAUNCH_QUEUES=off pour s'en passer", + "avertissement : ce llama-server ancien glisse le contexte par défaut — contexte plein, il jette des jetons anciens et le modèle perd une partie de la conversation sans le savoir. --no-context-shift dans EXTRA_ARGS l'en empêche." + ] + }, + { + "m": 0, + "c": 17, + "h": 1, + "e": 0, + "args": [ + "/app/llama-server", + "-m", + "/m/dense.gguf", + "-c", + "32768", + "-b", + "2048", + "-ub", + "512", + "--host", + "0.0.0.0", + "--port", + "8080", + "--parallel", + "1", + "-ngl", + "999" + ], + "env": {}, + "notes": [ + "ce moteur ne connaît pas --reasoning-preserve : REASONING_PRESERVE ignoré", + "avertissement : ce llama-server ancien glisse le contexte par défaut — contexte plein, il jette des jetons anciens et le modèle perd une partie de la conversation sans le savoir. --no-context-shift dans EXTRA_ARGS l'en empêche." + ] + }, + { + "m": 2, + "c": 17, + "h": 2, + "e": 0, + "args": [ + "/app/llama-server", + "-m", + "/m/inconnu.gguf", + "-c", + "32768", + "-b", + "2048", + "-ub", + "512", + "--host", + "0.0.0.0", + "--port", + "8080", + "--parallel", + "1", + "-ngl", + "999" + ], + "env": {}, + "notes": [ + "ce moteur ne connaît pas --reasoning-preserve : REASONING_PRESERVE ignoré" + ] + }, + { + "m": 4, + "c": 17, + "h": 1, + "e": 0, + "args": [ + "/app/llama-server", + "-m", + "/m/hybrid.gguf", + "-c", + "32768", + "-b", + "2048", + "-ub", + "512", + "--host", + "0.0.0.0", + "--port", + "8080", + "--parallel", + "1", + "-ngl", + "999" + ], + "env": { + "CUDA_SCALE_LAUNCH_QUEUES": "4x" + }, + "notes": [ + "CUDA_SCALE_LAUNCH_QUEUES=4x (2 GPU, parallélisme pipeline possible) — CUDA_LAUNCH_QUEUES=off pour s'en passer", + "ce moteur ne connaît pas --reasoning-preserve : REASONING_PRESERVE ignoré", + "avertissement : ce llama-server ancien glisse le contexte par défaut — contexte plein, il jette des jetons anciens et le modèle perd une partie de la conversation sans le savoir. --no-context-shift dans EXTRA_ARGS l'en empêche." + ] + }, + { + "m": 0, + "c": 18, + "h": 1, + "e": 0, + "args": [ + "/app/llama-server", + "-m", + "/m/dense.gguf", + "-c", + "32768", + "-b", + "2048", + "-ub", + "512", + "--host", + "0.0.0.0", + "--port", + "8080", + "--parallel", + "1", + "-ngl", + "999" + ], + "env": {}, + "notes": [ + "ce moteur ne connaît pas --reasoning-preserve : REASONING_PRESERVE ignoré", + "avertissement : ce llama-server ancien glisse le contexte par défaut — contexte plein, il jette des jetons anciens et le modèle perd une partie de la conversation sans le savoir. --no-context-shift dans EXTRA_ARGS l'en empêche." + ] + }, + { + "m": 2, + "c": 18, + "h": 2, + "e": 0, + "args": [ + "/app/llama-server", + "-m", + "/m/inconnu.gguf", + "-c", + "32768", + "-b", + "2048", + "-ub", + "512", + "--host", + "0.0.0.0", + "--port", + "8080", + "--parallel", + "1", + "-ngl", + "999" + ], + "env": {}, + "notes": [ + "ce moteur ne connaît pas --reasoning-preserve : REASONING_PRESERVE ignoré" + ] + }, + { + "m": 4, + "c": 18, + "h": 1, + "e": 0, + "args": [ + "/app/llama-server", + "-m", + "/m/hybrid.gguf", + "-c", + "32768", + "-b", + "2048", + "-ub", + "512", + "--host", + "0.0.0.0", + "--port", + "8080", + "--parallel", + "1", + "-ngl", + "999" + ], + "env": { + "CUDA_SCALE_LAUNCH_QUEUES": "4x" + }, + "notes": [ + "CUDA_SCALE_LAUNCH_QUEUES=4x (2 GPU, parallélisme pipeline possible) — CUDA_LAUNCH_QUEUES=off pour s'en passer", + "ce moteur ne connaît pas --reasoning-preserve : REASONING_PRESERVE ignoré", + "avertissement : ce llama-server ancien glisse le contexte par défaut — contexte plein, il jette des jetons anciens et le modèle perd une partie de la conversation sans le savoir. --no-context-shift dans EXTRA_ARGS l'en empêche." + ] + }, + { + "m": 0, + "c": 19, + "h": 1, + "e": 0, + "args": [ + "/app/llama-server", + "-m", + "/m/dense.gguf", + "-c", + "32768", + "-b", + "2048", + "-ub", + "512", + "--host", + "0.0.0.0", + "--port", + "8080", + "--parallel", + "1", + "-ngl", + "999" + ], + "env": {}, + "notes": [ + "REASONING_PRESERVE=peut-être illisible (on ou off) : ignoré", + "avertissement : ce llama-server ancien glisse le contexte par défaut — contexte plein, il jette des jetons anciens et le modèle perd une partie de la conversation sans le savoir. --no-context-shift dans EXTRA_ARGS l'en empêche." + ] + }, + { + "m": 2, + "c": 19, + "h": 2, + "e": 0, + "args": [ + "/app/llama-server", + "-m", + "/m/inconnu.gguf", + "-c", + "32768", + "-b", + "2048", + "-ub", + "512", + "--host", + "0.0.0.0", + "--port", + "8080", + "--parallel", + "1", + "-ngl", + "999" + ], + "env": {}, + "notes": [ + "REASONING_PRESERVE=peut-être illisible (on ou off) : ignoré" + ] + }, + { + "m": 4, + "c": 19, + "h": 1, + "e": 0, + "args": [ + "/app/llama-server", + "-m", + "/m/hybrid.gguf", + "-c", + "32768", + "-b", + "2048", + "-ub", + "512", + "--host", + "0.0.0.0", + "--port", + "8080", + "--parallel", + "1", + "-ngl", + "999" + ], + "env": { + "CUDA_SCALE_LAUNCH_QUEUES": "4x" + }, + "notes": [ + "CUDA_SCALE_LAUNCH_QUEUES=4x (2 GPU, parallélisme pipeline possible) — CUDA_LAUNCH_QUEUES=off pour s'en passer", + "REASONING_PRESERVE=peut-être illisible (on ou off) : ignoré", + "avertissement : ce llama-server ancien glisse le contexte par défaut — contexte plein, il jette des jetons anciens et le modèle perd une partie de la conversation sans le savoir. --no-context-shift dans EXTRA_ARGS l'en empêche." + ] + }, + { + "m": 0, + "c": 20, + "h": 1, + "e": 0, + "args": [ + "/app/llama-server", + "-m", + "/m/dense.gguf", + "-c", + "32768", + "-b", + "2048", + "-ub", + "512", + "--host", + "0.0.0.0", + "--port", + "8080", + "--parallel", + "1", + "-ngl", + "999" + ], + "env": {}, + "notes": [ + "avertissement : ce llama-server ancien glisse le contexte par défaut — contexte plein, il jette des jetons anciens et le modèle perd une partie de la conversation sans le savoir. --no-context-shift dans EXTRA_ARGS l'en empêche." + ] + }, + { + "m": 2, + "c": 20, + "h": 2, + "e": 0, + "args": [ + "/app/llama-server", + "-m", + "/m/inconnu.gguf", + "-c", + "32768", + "-b", + "2048", + "-ub", + "512", + "--host", + "0.0.0.0", + "--port", + "8080", + "--parallel", + "1", + "-ngl", + "999" + ], + "env": {}, + "notes": null + }, + { + "m": 4, + "c": 20, + "h": 1, + "e": 0, + "args": [ + "/app/llama-server", + "-m", + "/m/hybrid.gguf", + "-c", + "32768", + "-b", + "2048", + "-ub", + "512", + "--host", + "0.0.0.0", + "--port", + "8080", + "--parallel", + "1", + "-ngl", + "999" + ], + "env": { + "CUDA_SCALE_LAUNCH_QUEUES": "4x" + }, + "notes": [ + "CUDA_SCALE_LAUNCH_QUEUES=4x (2 GPU, parallélisme pipeline possible) — CUDA_LAUNCH_QUEUES=off pour s'en passer", + "avertissement : ce llama-server ancien glisse le contexte par défaut — contexte plein, il jette des jetons anciens et le modèle perd une partie de la conversation sans le savoir. --no-context-shift dans EXTRA_ARGS l'en empêche." + ] + }, + { + "m": 0, + "c": 21, + "h": 1, + "e": 0, + "args": [ + "/app/llama-server", + "-m", + "/m/dense.gguf", + "-c", + "32768", + "-b", + "2048", + "-ub", + "512", + "--host", + "0.0.0.0", + "--port", + "8080", + "--parallel", + "1", + "-ngl", + "999", + "--cache-ram", + "4096" + ], + "env": {}, + "notes": [ + "avertissement : ce llama-server ancien glisse le contexte par défaut — contexte plein, il jette des jetons anciens et le modèle perd une partie de la conversation sans le savoir. --no-context-shift dans EXTRA_ARGS l'en empêche." + ] + }, + { + "m": 2, + "c": 21, + "h": 2, + "e": 0, + "args": [ + "/app/llama-server", + "-m", + "/m/inconnu.gguf", + "-c", + "32768", + "-b", + "2048", + "-ub", + "512", + "--host", + "0.0.0.0", + "--port", + "8080", + "--parallel", + "1", + "-ngl", + "999" + ], + "env": {}, + "notes": [ + "ce moteur ne connaît pas --cache-ram : CACHE_RAM ignoré" + ] + }, + { + "m": 4, + "c": 21, + "h": 1, + "e": 0, + "args": [ + "/app/llama-server", + "-m", + "/m/hybrid.gguf", + "-c", + "32768", + "-b", + "2048", + "-ub", + "512", + "--host", + "0.0.0.0", + "--port", + "8080", + "--parallel", + "1", + "-ngl", + "999", + "--cache-ram", + "4096" + ], + "env": { + "CUDA_SCALE_LAUNCH_QUEUES": "4x" + }, + "notes": [ + "CUDA_SCALE_LAUNCH_QUEUES=4x (2 GPU, parallélisme pipeline possible) — CUDA_LAUNCH_QUEUES=off pour s'en passer", + "avertissement : ce llama-server ancien glisse le contexte par défaut — contexte plein, il jette des jetons anciens et le modèle perd une partie de la conversation sans le savoir. --no-context-shift dans EXTRA_ARGS l'en empêche." + ] + }, + { + "m": 0, + "c": 22, + "h": 1, + "e": 0, + "args": [ + "/app/llama-server", + "-m", + "/m/dense.gguf", + "-c", + "32768", + "-b", + "2048", + "-ub", + "512", + "--host", + "0.0.0.0", + "--port", + "8080", + "--parallel", + "1", + "-ngl", + "999", + "--cache-ram", + "0" + ], + "env": {}, + "notes": [ + "avertissement : ce llama-server ancien glisse le contexte par défaut — contexte plein, il jette des jetons anciens et le modèle perd une partie de la conversation sans le savoir. --no-context-shift dans EXTRA_ARGS l'en empêche." + ] + }, + { + "m": 2, + "c": 22, + "h": 2, + "e": 0, + "args": [ + "/app/llama-server", + "-m", + "/m/inconnu.gguf", + "-c", + "32768", + "-b", + "2048", + "-ub", + "512", + "--host", + "0.0.0.0", + "--port", + "8080", + "--parallel", + "1", + "-ngl", + "999" + ], + "env": {}, + "notes": [ + "ce moteur ne connaît pas --cache-ram : CACHE_RAM ignoré" + ] + }, + { + "m": 4, + "c": 22, + "h": 1, + "e": 0, + "args": [ + "/app/llama-server", + "-m", + "/m/hybrid.gguf", + "-c", + "32768", + "-b", + "2048", + "-ub", + "512", + "--host", + "0.0.0.0", + "--port", + "8080", + "--parallel", + "1", + "-ngl", + "999", + "--cache-ram", + "0" + ], + "env": { + "CUDA_SCALE_LAUNCH_QUEUES": "4x" + }, + "notes": [ + "CUDA_SCALE_LAUNCH_QUEUES=4x (2 GPU, parallélisme pipeline possible) — CUDA_LAUNCH_QUEUES=off pour s'en passer", + "avertissement : ce llama-server ancien glisse le contexte par défaut — contexte plein, il jette des jetons anciens et le modèle perd une partie de la conversation sans le savoir. --no-context-shift dans EXTRA_ARGS l'en empêche." + ] + }, + { + "m": 0, + "c": 23, + "h": 1, + "e": 0, + "args": [ + "/app/llama-server", + "-m", + "/m/dense.gguf", + "-c", + "32768", + "-b", + "2048", + "-ub", + "512", + "--host", + "0.0.0.0", + "--port", + "8080", + "--parallel", + "1", + "-ngl", + "999", + "--cache-ram", + "-1" + ], + "env": {}, + "notes": [ + "avertissement : ce llama-server ancien glisse le contexte par défaut — contexte plein, il jette des jetons anciens et le modèle perd une partie de la conversation sans le savoir. --no-context-shift dans EXTRA_ARGS l'en empêche." + ] + }, + { + "m": 2, + "c": 23, + "h": 2, + "e": 0, + "args": [ + "/app/llama-server", + "-m", + "/m/inconnu.gguf", + "-c", + "32768", + "-b", + "2048", + "-ub", + "512", + "--host", + "0.0.0.0", + "--port", + "8080", + "--parallel", + "1", + "-ngl", + "999" + ], + "env": {}, + "notes": [ + "ce moteur ne connaît pas --cache-ram : CACHE_RAM ignoré" + ] + }, + { + "m": 4, + "c": 23, + "h": 1, + "e": 0, + "args": [ + "/app/llama-server", + "-m", + "/m/hybrid.gguf", + "-c", + "32768", + "-b", + "2048", + "-ub", + "512", + "--host", + "0.0.0.0", + "--port", + "8080", + "--parallel", + "1", + "-ngl", + "999", + "--cache-ram", + "-1" + ], + "env": { + "CUDA_SCALE_LAUNCH_QUEUES": "4x" + }, + "notes": [ + "CUDA_SCALE_LAUNCH_QUEUES=4x (2 GPU, parallélisme pipeline possible) — CUDA_LAUNCH_QUEUES=off pour s'en passer", + "avertissement : ce llama-server ancien glisse le contexte par défaut — contexte plein, il jette des jetons anciens et le modèle perd une partie de la conversation sans le savoir. --no-context-shift dans EXTRA_ARGS l'en empêche." + ] + }, + { + "m": 0, + "c": 24, + "h": 1, + "e": 0, + "args": [ + "/app/llama-server", + "-m", + "/m/dense.gguf", + "-c", + "32768", + "-b", + "2048", + "-ub", + "512", + "--host", + "0.0.0.0", + "--port", + "8080", + "--parallel", + "1", + "-ngl", + "999" + ], + "env": {}, + "notes": [ + "CACHE_RAM=beaucoup illisible (un nombre de Mio, -1, 0 ou auto) : ignoré", + "avertissement : ce llama-server ancien glisse le contexte par défaut — contexte plein, il jette des jetons anciens et le modèle perd une partie de la conversation sans le savoir. --no-context-shift dans EXTRA_ARGS l'en empêche." + ] + }, + { + "m": 2, + "c": 24, + "h": 2, + "e": 0, + "args": [ + "/app/llama-server", + "-m", + "/m/inconnu.gguf", + "-c", + "32768", + "-b", + "2048", + "-ub", + "512", + "--host", + "0.0.0.0", + "--port", + "8080", + "--parallel", + "1", + "-ngl", + "999" + ], + "env": {}, + "notes": [ + "CACHE_RAM=beaucoup illisible (un nombre de Mio, -1, 0 ou auto) : ignoré" + ] + }, + { + "m": 4, + "c": 24, + "h": 1, + "e": 0, + "args": [ + "/app/llama-server", + "-m", + "/m/hybrid.gguf", + "-c", + "32768", + "-b", + "2048", + "-ub", + "512", + "--host", + "0.0.0.0", + "--port", + "8080", + "--parallel", + "1", + "-ngl", + "999" + ], + "env": { + "CUDA_SCALE_LAUNCH_QUEUES": "4x" + }, + "notes": [ + "CUDA_SCALE_LAUNCH_QUEUES=4x (2 GPU, parallélisme pipeline possible) — CUDA_LAUNCH_QUEUES=off pour s'en passer", + "CACHE_RAM=beaucoup illisible (un nombre de Mio, -1, 0 ou auto) : ignoré", + "avertissement : ce llama-server ancien glisse le contexte par défaut — contexte plein, il jette des jetons anciens et le modèle perd une partie de la conversation sans le savoir. --no-context-shift dans EXTRA_ARGS l'en empêche." + ] + }, + { + "m": 0, + "c": 25, + "h": 1, + "e": 0, + "args": [ + "/app/llama-server", + "-m", + "/m/dense.gguf", + "-c", + "32768", + "-b", + "2048", + "-ub", + "512", + "--host", + "0.0.0.0", + "--port", + "8080", + "--parallel", + "1", + "-ngl", + "999" + ], + "env": {}, + "notes": [ + "avertissement : ce llama-server ancien glisse le contexte par défaut — contexte plein, il jette des jetons anciens et le modèle perd une partie de la conversation sans le savoir. --no-context-shift dans EXTRA_ARGS l'en empêche." + ] + }, + { + "m": 2, + "c": 25, + "h": 2, + "e": 0, + "args": [ + "/app/llama-server", + "-m", + "/m/inconnu.gguf", + "-c", + "32768", + "-b", + "2048", + "-ub", + "512", + "--host", + "0.0.0.0", + "--port", + "8080", + "--parallel", + "1", + "-ngl", + "999" + ], + "env": {}, + "notes": null + }, + { + "m": 4, + "c": 25, + "h": 1, + "e": 0, + "args": [ + "/app/llama-server", + "-m", + "/m/hybrid.gguf", + "-c", + "32768", + "-b", + "2048", + "-ub", + "512", + "--host", + "0.0.0.0", + "--port", + "8080", + "--parallel", + "1", + "-ngl", + "999" + ], + "env": { + "CUDA_SCALE_LAUNCH_QUEUES": "4x" + }, + "notes": [ + "CUDA_SCALE_LAUNCH_QUEUES=4x (2 GPU, parallélisme pipeline possible) — CUDA_LAUNCH_QUEUES=off pour s'en passer", + "avertissement : ce llama-server ancien glisse le contexte par défaut — contexte plein, il jette des jetons anciens et le modèle perd une partie de la conversation sans le savoir. --no-context-shift dans EXTRA_ARGS l'en empêche." + ] + }, + { + "m": 0, + "c": 26, + "h": 1, + "e": 0, + "args": [ + "/app/llama-server", + "-m", + "/m/dense.gguf", + "-c", + "32768", + "-b", + "2048", + "-ub", + "512", + "--host", + "0.0.0.0", + "--port", + "8080", + "--parallel", + "1", + "-ngl", + "999", + "--cache-ram", + "0" + ], + "env": {}, + "notes": [ + "avertissement : ce llama-server ancien glisse le contexte par défaut — contexte plein, il jette des jetons anciens et le modèle perd une partie de la conversation sans le savoir. --no-context-shift dans EXTRA_ARGS l'en empêche." + ] + }, + { + "m": 2, + "c": 26, + "h": 2, + "e": 0, + "args": [ + "/app/llama-server", + "-m", + "/m/inconnu.gguf", + "-c", + "32768", + "-b", + "2048", + "-ub", + "512", + "--host", + "0.0.0.0", + "--port", + "8080", + "--parallel", + "1", + "-ngl", + "999" + ], + "env": {}, + "notes": [ + "ce moteur ne connaît pas --cache-ram : CACHE_RAM ignoré" + ] + }, + { + "m": 4, + "c": 26, + "h": 1, + "e": 0, + "args": [ + "/app/llama-server", + "-m", + "/m/hybrid.gguf", + "-c", + "32768", + "-b", + "2048", + "-ub", + "512", + "--host", + "0.0.0.0", + "--port", + "8080", + "--parallel", + "1", + "-ngl", + "999", + "--cache-ram", + "0" + ], + "env": { + "CUDA_SCALE_LAUNCH_QUEUES": "4x" + }, + "notes": [ + "CUDA_SCALE_LAUNCH_QUEUES=4x (2 GPU, parallélisme pipeline possible) — CUDA_LAUNCH_QUEUES=off pour s'en passer", + "avertissement : ce llama-server ancien glisse le contexte par défaut — contexte plein, il jette des jetons anciens et le modèle perd une partie de la conversation sans le savoir. --no-context-shift dans EXTRA_ARGS l'en empêche." + ] + }, + { + "m": 0, + "c": 27, + "h": 1, + "e": 0, + "args": [ + "/app/llama-server", + "-m", + "/m/dense.gguf", + "-c", + "32768", + "-b", + "2048", + "-ub", + "512", + "--host", + "0.0.0.0", + "--port", + "8080", + "--parallel", + "1", + "-ngl", + "999" + ], + "env": {}, + "notes": [ + "SIDE_SLOT refusé (ce moteur ne connaît pas --no-kv-unified) : un seul slot, comme sans la clé", + "avertissement : ce llama-server ancien glisse le contexte par défaut — contexte plein, il jette des jetons anciens et le modèle perd une partie de la conversation sans le savoir. --no-context-shift dans EXTRA_ARGS l'en empêche." + ] + }, + { + "m": 2, + "c": 27, + "h": 2, + "e": 0, + "args": [ + "/app/llama-server", + "-m", + "/m/inconnu.gguf", + "-c", + "32768", + "-b", + "2048", + "-ub", + "512", + "--host", + "0.0.0.0", + "--port", + "8080", + "--parallel", + "1", + "-ngl", + "999" + ], + "env": {}, + "notes": [ + "SIDE_SLOT refusé (ce moteur ne connaît pas --no-kv-unified) : un seul slot, comme sans la clé" + ] + }, + { + "m": 4, + "c": 27, + "h": 1, + "e": 0, + "args": [ + "/app/llama-server", + "-m", + "/m/hybrid.gguf", + "-c", + "32768", + "-b", + "2048", + "-ub", + "512", + "--host", + "0.0.0.0", + "--port", + "8080", + "--parallel", + "1", + "-ngl", + "999" + ], + "env": { + "CUDA_SCALE_LAUNCH_QUEUES": "4x" + }, + "notes": [ + "CUDA_SCALE_LAUNCH_QUEUES=4x (2 GPU, parallélisme pipeline possible) — CUDA_LAUNCH_QUEUES=off pour s'en passer", + "SIDE_SLOT refusé (ce moteur ne connaît pas --no-kv-unified) : un seul slot, comme sans la clé", + "avertissement : ce llama-server ancien glisse le contexte par défaut — contexte plein, il jette des jetons anciens et le modèle perd une partie de la conversation sans le savoir. --no-context-shift dans EXTRA_ARGS l'en empêche." + ] + }, + { + "m": 0, + "c": 28, + "h": 1, + "e": 0, + "args": [ + "/app/llama-server", + "-m", + "/m/dense.gguf", + "-c", + "32768", + "-b", + "2048", + "-ub", + "512", + "--host", + "0.0.0.0", + "--port", + "8080", + "--parallel", + "3", + "-ngl", + "999" + ], + "env": {}, + "notes": [ + "SIDE_SLOT refusé (PARALLEL=3 (SIDE_SLOT ouvre exactement 2 slots)) : un seul slot, comme sans la clé", + "avertissement : ce llama-server ancien glisse le contexte par défaut — contexte plein, il jette des jetons anciens et le modèle perd une partie de la conversation sans le savoir. --no-context-shift dans EXTRA_ARGS l'en empêche." + ] + }, + { + "m": 2, + "c": 28, + "h": 2, + "e": 0, + "args": [ + "/app/llama-server", + "-m", + "/m/inconnu.gguf", + "-c", + "32768", + "-b", + "2048", + "-ub", + "512", + "--host", + "0.0.0.0", + "--port", + "8080", + "--parallel", + "3", + "-ngl", + "999" + ], + "env": {}, + "notes": [ + "SIDE_SLOT refusé (PARALLEL=3 (SIDE_SLOT ouvre exactement 2 slots)) : un seul slot, comme sans la clé" + ] + }, + { + "m": 4, + "c": 28, + "h": 1, + "e": 0, + "args": [ + "/app/llama-server", + "-m", + "/m/hybrid.gguf", + "-c", + "32768", + "-b", + "2048", + "-ub", + "512", + "--host", + "0.0.0.0", + "--port", + "8080", + "--parallel", + "3", + "-ngl", + "999" + ], + "env": { + "CUDA_SCALE_LAUNCH_QUEUES": "4x" + }, + "notes": [ + "CUDA_SCALE_LAUNCH_QUEUES=4x (2 GPU, parallélisme pipeline possible) — CUDA_LAUNCH_QUEUES=off pour s'en passer", + "SIDE_SLOT refusé (PARALLEL=3 (SIDE_SLOT ouvre exactement 2 slots)) : un seul slot, comme sans la clé", + "avertissement : ce llama-server ancien glisse le contexte par défaut — contexte plein, il jette des jetons anciens et le modèle perd une partie de la conversation sans le savoir. --no-context-shift dans EXTRA_ARGS l'en empêche." + ] + }, + { + "m": 0, + "c": 29, + "h": 1, + "e": 0, + "args": [ + "/app/llama-server", + "-m", + "/m/dense.gguf", + "-c", + "0", + "-b", + "2048", + "-ub", + "512", + "--host", + "0.0.0.0", + "--port", + "8080", + "--parallel", + "1", + "-ngl", + "999" + ], + "env": {}, + "notes": [ + "SIDE_SLOT refusé (ce moteur ne connaît pas --no-kv-unified) : un seul slot, comme sans la clé", + "avertissement : ce llama-server ancien glisse le contexte par défaut — contexte plein, il jette des jetons anciens et le modèle perd une partie de la conversation sans le savoir. --no-context-shift dans EXTRA_ARGS l'en empêche." + ] + }, + { + "m": 2, + "c": 29, + "h": 2, + "e": 0, + "args": [ + "/app/llama-server", + "-m", + "/m/inconnu.gguf", + "-c", + "0", + "-b", + "2048", + "-ub", + "512", + "--host", + "0.0.0.0", + "--port", + "8080", + "--parallel", + "1", + "-ngl", + "999" + ], + "env": {}, + "notes": [ + "SIDE_SLOT refusé (ce moteur ne connaît pas --no-kv-unified) : un seul slot, comme sans la clé" + ] + }, + { + "m": 4, + "c": 29, + "h": 1, + "e": 0, + "args": [ + "/app/llama-server", + "-m", + "/m/hybrid.gguf", + "-c", + "0", + "-b", + "2048", + "-ub", + "512", + "--host", + "0.0.0.0", + "--port", + "8080", + "--parallel", + "1", + "-ngl", + "999" + ], + "env": { + "CUDA_SCALE_LAUNCH_QUEUES": "4x" + }, + "notes": [ + "CUDA_SCALE_LAUNCH_QUEUES=4x (2 GPU, parallélisme pipeline possible) — CUDA_LAUNCH_QUEUES=off pour s'en passer", + "SIDE_SLOT refusé (ce moteur ne connaît pas --no-kv-unified) : un seul slot, comme sans la clé", + "avertissement : ce llama-server ancien glisse le contexte par défaut — contexte plein, il jette des jetons anciens et le modèle perd une partie de la conversation sans le savoir. --no-context-shift dans EXTRA_ARGS l'en empêche." + ] + }, + { + "m": 0, + "c": 30, + "h": 1, + "e": 0, + "args": [ + "/app/llama-server", + "-m", + "/m/dense.gguf", + "-c", + "8192", + "-b", + "2048", + "-ub", + "512", + "--host", + "0.0.0.0", + "--port", + "8080", + "--parallel", + "1", + "-ngl", + "999", + "-ctk", + "q8_0", + "-ctv", + "q8_0" + ], + "env": {}, + "notes": [ + "SIDE_SLOT refusé (ce moteur ne connaît pas --no-kv-unified) : un seul slot, comme sans la clé", + "cache KV q8_0/q8_0 (KV_TYPE) : modifie légèrement les sorties par rapport à f16 — choix de l'utilisateur, laissé tel quel.", + "avertissement : ce llama-server ancien glisse le contexte par défaut — contexte plein, il jette des jetons anciens et le modèle perd une partie de la conversation sans le savoir. --no-context-shift dans EXTRA_ARGS l'en empêche." + ] + }, + { + "m": 2, + "c": 30, + "h": 2, + "e": 0, + "args": [ + "/app/llama-server", + "-m", + "/m/inconnu.gguf", + "-c", + "8192", + "-b", + "2048", + "-ub", + "512", + "--host", + "0.0.0.0", + "--port", + "8080", + "--parallel", + "1", + "-ngl", + "999", + "-ctk", + "q8_0", + "-ctv", + "q8_0" + ], + "env": {}, + "notes": [ + "SIDE_SLOT refusé (ce moteur ne connaît pas --no-kv-unified) : un seul slot, comme sans la clé", + "cache KV q8_0/q8_0 (KV_TYPE) : modifie légèrement les sorties par rapport à f16 — choix de l'utilisateur, laissé tel quel." + ] + }, + { + "m": 4, + "c": 30, + "h": 1, + "e": 0, + "args": [ + "/app/llama-server", + "-m", + "/m/hybrid.gguf", + "-c", + "8192", + "-b", + "2048", + "-ub", + "512", + "--host", + "0.0.0.0", + "--port", + "8080", + "--parallel", + "1", + "-ngl", + "999", + "-ctk", + "q8_0", + "-ctv", + "q8_0" + ], + "env": { + "CUDA_SCALE_LAUNCH_QUEUES": "4x" + }, + "notes": [ + "CUDA_SCALE_LAUNCH_QUEUES=4x (2 GPU, parallélisme pipeline possible) — CUDA_LAUNCH_QUEUES=off pour s'en passer", + "SIDE_SLOT refusé (ce moteur ne connaît pas --no-kv-unified) : un seul slot, comme sans la clé", + "cache KV q8_0/q8_0 (KV_TYPE) : modifie légèrement les sorties par rapport à f16 — choix de l'utilisateur, laissé tel quel.", + "avertissement : ce llama-server ancien glisse le contexte par défaut — contexte plein, il jette des jetons anciens et le modèle perd une partie de la conversation sans le savoir. --no-context-shift dans EXTRA_ARGS l'en empêche." + ] + }, + { + "m": 0, + "c": 31, + "h": 1, + "e": 0, + "args": [ + "/app/llama-server", + "-m", + "/m/dense.gguf", + "-c", + "32768", + "-b", + "2048", + "-ub", + "512", + "--host", + "0.0.0.0", + "--port", + "8080", + "--parallel", + "1", + "-ngl", + "999" + ], + "env": {}, + "notes": [ + "SPLIT_MODE=tensor refusé (ce moteur ne propose pas -sm tensor) : découpe par couches, comme sans la clé", + "avertissement : ce llama-server ancien glisse le contexte par défaut — contexte plein, il jette des jetons anciens et le modèle perd une partie de la conversation sans le savoir. --no-context-shift dans EXTRA_ARGS l'en empêche." + ] + }, + { + "m": 2, + "c": 31, + "h": 2, + "e": 0, + "args": [ + "/app/llama-server", + "-m", + "/m/inconnu.gguf", + "-c", + "32768", + "-b", + "2048", + "-ub", + "512", + "--host", + "0.0.0.0", + "--port", + "8080", + "--parallel", + "1", + "-ngl", + "999" + ], + "env": {}, + "notes": [ + "SPLIT_MODE=tensor refusé (ce moteur ne propose pas -sm tensor) : découpe par couches, comme sans la clé" + ] + }, + { + "m": 4, + "c": 31, + "h": 1, + "e": 0, + "args": [ + "/app/llama-server", + "-m", + "/m/hybrid.gguf", + "-c", + "32768", + "-b", + "2048", + "-ub", + "512", + "--host", + "0.0.0.0", + "--port", + "8080", + "--parallel", + "1", + "-ngl", + "999" + ], + "env": { + "CUDA_SCALE_LAUNCH_QUEUES": "4x" + }, + "notes": [ + "SPLIT_MODE=tensor refusé (ce moteur ne propose pas -sm tensor) : découpe par couches, comme sans la clé", + "CUDA_SCALE_LAUNCH_QUEUES=4x (2 GPU, parallélisme pipeline possible) — CUDA_LAUNCH_QUEUES=off pour s'en passer", + "avertissement : ce llama-server ancien glisse le contexte par défaut — contexte plein, il jette des jetons anciens et le modèle perd une partie de la conversation sans le savoir. --no-context-shift dans EXTRA_ARGS l'en empêche." + ] + }, + { + "m": 0, + "c": 32, + "h": 1, + "e": 0, + "args": [ + "/app/llama-server", + "-m", + "/m/dense.gguf", + "-c", + "16384", + "-b", + "2048", + "-ub", + "512", + "--host", + "0.0.0.0", + "--port", + "8080", + "--parallel", + "1", + "-ngl", + "999" + ], + "env": {}, + "notes": [ + "SPLIT_MODE=tensor refusé (ce moteur ne propose pas -sm tensor) : découpe par couches, comme sans la clé", + "avertissement : ce llama-server ancien glisse le contexte par défaut — contexte plein, il jette des jetons anciens et le modèle perd une partie de la conversation sans le savoir. --no-context-shift dans EXTRA_ARGS l'en empêche." + ] + }, + { + "m": 2, + "c": 32, + "h": 2, + "e": 0, + "args": [ + "/app/llama-server", + "-m", + "/m/inconnu.gguf", + "-c", + "16384", + "-b", + "2048", + "-ub", + "512", + "--host", + "0.0.0.0", + "--port", + "8080", + "--parallel", + "1", + "-ngl", + "999" + ], + "env": {}, + "notes": [ + "SPLIT_MODE=tensor refusé (ce moteur ne propose pas -sm tensor) : découpe par couches, comme sans la clé" + ] + }, + { + "m": 4, + "c": 32, + "h": 1, + "e": 0, + "args": [ + "/app/llama-server", + "-m", + "/m/hybrid.gguf", + "-c", + "16384", + "-b", + "2048", + "-ub", + "512", + "--host", + "0.0.0.0", + "--port", + "8080", + "--parallel", + "1", + "-ngl", + "999" + ], + "env": { + "CUDA_SCALE_LAUNCH_QUEUES": "4x" + }, + "notes": [ + "SPLIT_MODE=tensor refusé (ce moteur ne propose pas -sm tensor) : découpe par couches, comme sans la clé", + "CUDA_SCALE_LAUNCH_QUEUES=4x (2 GPU, parallélisme pipeline possible) — CUDA_LAUNCH_QUEUES=off pour s'en passer", + "avertissement : ce llama-server ancien glisse le contexte par défaut — contexte plein, il jette des jetons anciens et le modèle perd une partie de la conversation sans le savoir. --no-context-shift dans EXTRA_ARGS l'en empêche." + ] + }, + { + "m": 0, + "c": 33, + "h": 1, + "e": 0, + "args": [ + "/app/llama-server", + "-m", + "/m/dense.gguf", + "-c", + "32768", + "-b", + "2048", + "-ub", + "512", + "--host", + "0.0.0.0", + "--port", + "8080", + "--parallel", + "1", + "-ngl", + "999" + ], + "env": {}, + "notes": [ + "SPLIT_MODE=tensor refusé (SPLIT_MODE=bizarre illisible (tensor ou off)) : découpe par couches, comme sans la clé", + "avertissement : ce llama-server ancien glisse le contexte par défaut — contexte plein, il jette des jetons anciens et le modèle perd une partie de la conversation sans le savoir. --no-context-shift dans EXTRA_ARGS l'en empêche." + ] + }, + { + "m": 2, + "c": 33, + "h": 2, + "e": 0, + "args": [ + "/app/llama-server", + "-m", + "/m/inconnu.gguf", + "-c", + "32768", + "-b", + "2048", + "-ub", + "512", + "--host", + "0.0.0.0", + "--port", + "8080", + "--parallel", + "1", + "-ngl", + "999" + ], + "env": {}, + "notes": [ + "SPLIT_MODE=tensor refusé (SPLIT_MODE=bizarre illisible (tensor ou off)) : découpe par couches, comme sans la clé" + ] + }, + { + "m": 4, + "c": 33, + "h": 1, + "e": 0, + "args": [ + "/app/llama-server", + "-m", + "/m/hybrid.gguf", + "-c", + "32768", + "-b", + "2048", + "-ub", + "512", + "--host", + "0.0.0.0", + "--port", + "8080", + "--parallel", + "1", + "-ngl", + "999" + ], + "env": { + "CUDA_SCALE_LAUNCH_QUEUES": "4x" + }, + "notes": [ + "SPLIT_MODE=tensor refusé (SPLIT_MODE=bizarre illisible (tensor ou off)) : découpe par couches, comme sans la clé", + "CUDA_SCALE_LAUNCH_QUEUES=4x (2 GPU, parallélisme pipeline possible) — CUDA_LAUNCH_QUEUES=off pour s'en passer", + "avertissement : ce llama-server ancien glisse le contexte par défaut — contexte plein, il jette des jetons anciens et le modèle perd une partie de la conversation sans le savoir. --no-context-shift dans EXTRA_ARGS l'en empêche." + ] + }, + { + "m": 0, + "c": 34, + "h": 1, + "e": 0, + "args": [ + "/app/llama-server", + "-m", + "/m/dense.gguf", + "-c", + "32768", + "-b", + "2048", + "-ub", + "512", + "--host", + "0.0.0.0", + "--port", + "8080", + "--parallel", + "1", + "-ngl", + "999" + ], + "env": {}, + "notes": [ + "avertissement : ce llama-server ancien glisse le contexte par défaut — contexte plein, il jette des jetons anciens et le modèle perd une partie de la conversation sans le savoir. --no-context-shift dans EXTRA_ARGS l'en empêche." + ] + }, + { + "m": 2, + "c": 34, + "h": 2, + "e": 0, + "args": [ + "/app/llama-server", + "-m", + "/m/inconnu.gguf", + "-c", + "32768", + "-b", + "2048", + "-ub", + "512", + "--host", + "0.0.0.0", + "--port", + "8080", + "--parallel", + "1", + "-ngl", + "999" + ], + "env": {}, + "notes": null + }, + { + "m": 4, + "c": 34, + "h": 1, + "e": 0, + "args": [ + "/app/llama-server", + "-m", + "/m/hybrid.gguf", + "-c", + "32768", + "-b", + "2048", + "-ub", + "512", + "--host", + "0.0.0.0", + "--port", + "8080", + "--parallel", + "1", + "-ngl", + "999" + ], + "env": { + "CUDA_SCALE_LAUNCH_QUEUES": "4x" + }, + "notes": [ + "CUDA_SCALE_LAUNCH_QUEUES=4x (2 GPU, parallélisme pipeline possible) — CUDA_LAUNCH_QUEUES=off pour s'en passer", + "avertissement : ce llama-server ancien glisse le contexte par défaut — contexte plein, il jette des jetons anciens et le modèle perd une partie de la conversation sans le savoir. --no-context-shift dans EXTRA_ARGS l'en empêche." + ] + }, + { + "m": 0, + "c": 35, + "h": 1, + "e": 0, + "args": [ + "/app/llama-server", + "-m", + "/m/dense.gguf", + "-c", + "32768", + "-b", + "2048", + "-ub", + "512", + "--host", + "0.0.0.0", + "--port", + "8080", + "--parallel", + "1", + "-ngl", + "999", + "-ctk", + "q8_0", + "-ctv", + "q8_0" + ], + "env": {}, + "notes": [ + "SPLIT_MODE=tensor refusé (ce moteur ne propose pas -sm tensor) : découpe par couches, comme sans la clé", + "cache KV q8_0/q8_0 (KV_TYPE) : modifie légèrement les sorties par rapport à f16 — choix de l'utilisateur, laissé tel quel.", + "avertissement : ce llama-server ancien glisse le contexte par défaut — contexte plein, il jette des jetons anciens et le modèle perd une partie de la conversation sans le savoir. --no-context-shift dans EXTRA_ARGS l'en empêche." + ] + }, + { + "m": 2, + "c": 35, + "h": 2, + "e": 0, + "args": [ + "/app/llama-server", + "-m", + "/m/inconnu.gguf", + "-c", + "32768", + "-b", + "2048", + "-ub", + "512", + "--host", + "0.0.0.0", + "--port", + "8080", + "--parallel", + "1", + "-ngl", + "999", + "-ctk", + "q8_0", + "-ctv", + "q8_0" + ], + "env": {}, + "notes": [ + "SPLIT_MODE=tensor refusé (ce moteur ne propose pas -sm tensor) : découpe par couches, comme sans la clé", + "cache KV q8_0/q8_0 (KV_TYPE) : modifie légèrement les sorties par rapport à f16 — choix de l'utilisateur, laissé tel quel." + ] + }, + { + "m": 4, + "c": 35, + "h": 1, + "e": 0, + "args": [ + "/app/llama-server", + "-m", + "/m/hybrid.gguf", + "-c", + "32768", + "-b", + "2048", + "-ub", + "512", + "--host", + "0.0.0.0", + "--port", + "8080", + "--parallel", + "1", + "-ngl", + "999", + "-ctk", + "q8_0", + "-ctv", + "q8_0" + ], + "env": { + "CUDA_SCALE_LAUNCH_QUEUES": "4x" + }, + "notes": [ + "SPLIT_MODE=tensor refusé (ce moteur ne propose pas -sm tensor) : découpe par couches, comme sans la clé", + "CUDA_SCALE_LAUNCH_QUEUES=4x (2 GPU, parallélisme pipeline possible) — CUDA_LAUNCH_QUEUES=off pour s'en passer", + "cache KV q8_0/q8_0 (KV_TYPE) : modifie légèrement les sorties par rapport à f16 — choix de l'utilisateur, laissé tel quel.", + "avertissement : ce llama-server ancien glisse le contexte par défaut — contexte plein, il jette des jetons anciens et le modèle perd une partie de la conversation sans le savoir. --no-context-shift dans EXTRA_ARGS l'en empêche." + ] + }, + { + "m": 0, + "c": 36, + "h": 1, + "e": 0, + "args": [ + "/app/llama-server", + "-m", + "/m/dense.gguf", + "-c", + "32768", + "-b", + "2048", + "-ub", + "512", + "--host", + "0.0.0.0", + "--port", + "8080", + "--parallel", + "1", + "-ngl", + "20" + ], + "env": {}, + "notes": [ + "SPLIT_MODE=tensor refusé (ce moteur ne propose pas -sm tensor) : découpe par couches, comme sans la clé", + "avertissement : ce llama-server ancien glisse le contexte par défaut — contexte plein, il jette des jetons anciens et le modèle perd une partie de la conversation sans le savoir. --no-context-shift dans EXTRA_ARGS l'en empêche." + ] + }, + { + "m": 2, + "c": 36, + "h": 2, + "e": 0, + "args": [ + "/app/llama-server", + "-m", + "/m/inconnu.gguf", + "-c", + "32768", + "-b", + "2048", + "-ub", + "512", + "--host", + "0.0.0.0", + "--port", + "8080", + "--parallel", + "1", + "-ngl", + "20" + ], + "env": {}, + "notes": [ + "SPLIT_MODE=tensor refusé (ce moteur ne propose pas -sm tensor) : découpe par couches, comme sans la clé" + ] + }, + { + "m": 4, + "c": 36, + "h": 1, + "e": 0, + "args": [ + "/app/llama-server", + "-m", + "/m/hybrid.gguf", + "-c", + "32768", + "-b", + "2048", + "-ub", + "512", + "--host", + "0.0.0.0", + "--port", + "8080", + "--parallel", + "1", + "-ngl", + "20" + ], + "env": {}, + "notes": [ + "SPLIT_MODE=tensor refusé (ce moteur ne propose pas -sm tensor) : découpe par couches, comme sans la clé", + "avertissement : ce llama-server ancien glisse le contexte par défaut — contexte plein, il jette des jetons anciens et le modèle perd une partie de la conversation sans le savoir. --no-context-shift dans EXTRA_ARGS l'en empêche." + ] + }, + { + "m": 0, + "c": 37, + "h": 1, + "e": 0, + "args": [ + "/app/llama-server", + "-m", + "/m/dense.gguf", + "-c", + "32768", + "-b", + "2048", + "-ub", + "512", + "--host", + "0.0.0.0", + "--port", + "8080", + "--parallel", + "1", + "-ngl", + "999" + ], + "env": {}, + "notes": [ + "avertissement : ce llama-server ancien glisse le contexte par défaut — contexte plein, il jette des jetons anciens et le modèle perd une partie de la conversation sans le savoir. --no-context-shift dans EXTRA_ARGS l'en empêche." + ] + }, + { + "m": 2, + "c": 37, + "h": 2, + "e": 0, + "args": [ + "/app/llama-server", + "-m", + "/m/inconnu.gguf", + "-c", + "32768", + "-b", + "2048", + "-ub", + "512", + "--host", + "0.0.0.0", + "--port", + "8080", + "--parallel", + "1", + "-ngl", + "999" + ], + "env": {}, + "notes": [ + "SPEC=auto : métadonnées du modèle illisibles — sans décodage spéculatif" + ] + }, + { + "m": 4, + "c": 37, + "h": 1, + "e": 0, + "args": [ + "/app/llama-server", + "-m", + "/m/hybrid.gguf", + "-c", + "32768", + "-b", + "2048", + "-ub", + "512", + "--host", + "0.0.0.0", + "--port", + "8080", + "--parallel", + "1", + "-ngl", + "999" + ], + "env": { + "CUDA_SCALE_LAUNCH_QUEUES": "4x" + }, + "notes": [ + "CUDA_SCALE_LAUNCH_QUEUES=4x (2 GPU, parallélisme pipeline possible) — CUDA_LAUNCH_QUEUES=off pour s'en passer", + "SPEC=auto : ce moteur ne connaît pas draft-mtp — sans décodage spéculatif", + "avertissement : ce llama-server ancien glisse le contexte par défaut — contexte plein, il jette des jetons anciens et le modèle perd une partie de la conversation sans le savoir. --no-context-shift dans EXTRA_ARGS l'en empêche." + ] + }, + { + "m": 0, + "c": 38, + "h": 1, + "e": 0, + "args": [ + "/app/llama-server", + "-m", + "/m/dense.gguf", + "-c", + "32768", + "-b", + "2048", + "-ub", + "512", + "--host", + "0.0.0.0", + "--port", + "8080", + "--parallel", + "1", + "-ngl", + "999" + ], + "env": {}, + "notes": [ + "SPEC=mtp : pas de tête MTP dans ce fichier (publiée à part ? MODEL_DRAFT=mtp-….gguf) — sans décodage spéculatif", + "avertissement : ce llama-server ancien glisse le contexte par défaut — contexte plein, il jette des jetons anciens et le modèle perd une partie de la conversation sans le savoir. --no-context-shift dans EXTRA_ARGS l'en empêche." + ] + }, + { + "m": 2, + "c": 38, + "h": 2, + "e": 0, + "args": [ + "/app/llama-server", + "-m", + "/m/inconnu.gguf", + "-c", + "32768", + "-b", + "2048", + "-ub", + "512", + "--host", + "0.0.0.0", + "--port", + "8080", + "--parallel", + "1", + "-ngl", + "999" + ], + "env": {}, + "notes": [ + "SPEC=mtp : métadonnées du modèle illisibles — sans décodage spéculatif" + ] + }, + { + "m": 4, + "c": 38, + "h": 1, + "e": 0, + "args": [ + "/app/llama-server", + "-m", + "/m/hybrid.gguf", + "-c", + "32768", + "-b", + "2048", + "-ub", + "512", + "--host", + "0.0.0.0", + "--port", + "8080", + "--parallel", + "1", + "-ngl", + "999" + ], + "env": { + "CUDA_SCALE_LAUNCH_QUEUES": "4x" + }, + "notes": [ + "CUDA_SCALE_LAUNCH_QUEUES=4x (2 GPU, parallélisme pipeline possible) — CUDA_LAUNCH_QUEUES=off pour s'en passer", + "SPEC=mtp : ce moteur ne connaît pas draft-mtp — sans décodage spéculatif", + "avertissement : ce llama-server ancien glisse le contexte par défaut — contexte plein, il jette des jetons anciens et le modèle perd une partie de la conversation sans le savoir. --no-context-shift dans EXTRA_ARGS l'en empêche." + ] + }, + { + "m": 0, + "c": 39, + "h": 1, + "e": 0, + "args": [ + "/app/llama-server", + "-m", + "/m/dense.gguf", + "-c", + "32768", + "-b", + "2048", + "-ub", + "512", + "--host", + "0.0.0.0", + "--port", + "8080", + "--parallel", + "1", + "-ngl", + "999" + ], + "env": {}, + "notes": [ + "SPEC=ngram : ce moteur ne connaît pas --spec-ngram-mod-n-match — sans décodage spéculatif", + "avertissement : ce llama-server ancien glisse le contexte par défaut — contexte plein, il jette des jetons anciens et le modèle perd une partie de la conversation sans le savoir. --no-context-shift dans EXTRA_ARGS l'en empêche." + ] + }, + { + "m": 2, + "c": 39, + "h": 2, + "e": 0, + "args": [ + "/app/llama-server", + "-m", + "/m/inconnu.gguf", + "-c", + "32768", + "-b", + "2048", + "-ub", + "512", + "--host", + "0.0.0.0", + "--port", + "8080", + "--parallel", + "1", + "-ngl", + "999" + ], + "env": {}, + "notes": [ + "SPEC=ngram : ce moteur ne connaît pas --spec-ngram-mod-n-match — sans décodage spéculatif" + ] + }, + { + "m": 4, + "c": 39, + "h": 1, + "e": 0, + "args": [ + "/app/llama-server", + "-m", + "/m/hybrid.gguf", + "-c", + "32768", + "-b", + "2048", + "-ub", + "512", + "--host", + "0.0.0.0", + "--port", + "8080", + "--parallel", + "1", + "-ngl", + "999" + ], + "env": { + "CUDA_SCALE_LAUNCH_QUEUES": "4x" + }, + "notes": [ + "CUDA_SCALE_LAUNCH_QUEUES=4x (2 GPU, parallélisme pipeline possible) — CUDA_LAUNCH_QUEUES=off pour s'en passer", + "SPEC=ngram : ce moteur ne connaît pas --spec-ngram-mod-n-match — sans décodage spéculatif", + "avertissement : ce llama-server ancien glisse le contexte par défaut — contexte plein, il jette des jetons anciens et le modèle perd une partie de la conversation sans le savoir. --no-context-shift dans EXTRA_ARGS l'en empêche." + ] + }, + { + "m": 0, + "c": 40, + "h": 1, + "e": 0, + "args": [ + "/app/llama-server", + "-m", + "/m/dense.gguf", + "-c", + "32768", + "-b", + "2048", + "-ub", + "512", + "--host", + "0.0.0.0", + "--port", + "8080", + "--parallel", + "1", + "-ngl", + "999" + ], + "env": {}, + "notes": [ + "SPEC=mtp+ngram : ce moteur ne connaît pas --spec-ngram-mod-n-match — sans décodage spéculatif", + "avertissement : ce llama-server ancien glisse le contexte par défaut — contexte plein, il jette des jetons anciens et le modèle perd une partie de la conversation sans le savoir. --no-context-shift dans EXTRA_ARGS l'en empêche." + ] + }, + { + "m": 2, + "c": 40, + "h": 2, + "e": 0, + "args": [ + "/app/llama-server", + "-m", + "/m/inconnu.gguf", + "-c", + "32768", + "-b", + "2048", + "-ub", + "512", + "--host", + "0.0.0.0", + "--port", + "8080", + "--parallel", + "1", + "-ngl", + "999" + ], + "env": {}, + "notes": [ + "SPEC=mtp+ngram : ce moteur ne connaît pas --spec-ngram-mod-n-match — sans décodage spéculatif" + ] + }, + { + "m": 4, + "c": 40, + "h": 1, + "e": 0, + "args": [ + "/app/llama-server", + "-m", + "/m/hybrid.gguf", + "-c", + "32768", + "-b", + "2048", + "-ub", + "512", + "--host", + "0.0.0.0", + "--port", + "8080", + "--parallel", + "1", + "-ngl", + "999" + ], + "env": { + "CUDA_SCALE_LAUNCH_QUEUES": "4x" + }, + "notes": [ + "CUDA_SCALE_LAUNCH_QUEUES=4x (2 GPU, parallélisme pipeline possible) — CUDA_LAUNCH_QUEUES=off pour s'en passer", + "SPEC=mtp+ngram : ce moteur ne connaît pas --spec-ngram-mod-n-match — sans décodage spéculatif", + "avertissement : ce llama-server ancien glisse le contexte par défaut — contexte plein, il jette des jetons anciens et le modèle perd une partie de la conversation sans le savoir. --no-context-shift dans EXTRA_ARGS l'en empêche." + ] + }, + { + "m": 0, + "c": 41, + "h": 1, + "e": 0, + "args": [ + "/app/llama-server", + "-m", + "/m/dense.gguf", + "-c", + "32768", + "-b", + "2048", + "-ub", + "512", + "--host", + "0.0.0.0", + "--port", + "8080", + "--parallel", + "1", + "-ngl", + "999" + ], + "env": {}, + "notes": [ + "SPEC=mtp+ngram : ce moteur ne connaît pas --spec-ngram-mod-n-match — sans décodage spéculatif", + "avertissement : ce llama-server ancien glisse le contexte par défaut — contexte plein, il jette des jetons anciens et le modèle perd une partie de la conversation sans le savoir. --no-context-shift dans EXTRA_ARGS l'en empêche." + ] + }, + { + "m": 2, + "c": 41, + "h": 2, + "e": 0, + "args": [ + "/app/llama-server", + "-m", + "/m/inconnu.gguf", + "-c", + "32768", + "-b", + "2048", + "-ub", + "512", + "--host", + "0.0.0.0", + "--port", + "8080", + "--parallel", + "1", + "-ngl", + "999" + ], + "env": {}, + "notes": [ + "SPEC=mtp+ngram : ce moteur ne connaît pas --spec-ngram-mod-n-match — sans décodage spéculatif" + ] + }, + { + "m": 4, + "c": 41, + "h": 1, + "e": 0, + "args": [ + "/app/llama-server", + "-m", + "/m/hybrid.gguf", + "-c", + "32768", + "-b", + "2048", + "-ub", + "512", + "--host", + "0.0.0.0", + "--port", + "8080", + "--parallel", + "1", + "-ngl", + "999" + ], + "env": { + "CUDA_SCALE_LAUNCH_QUEUES": "4x" + }, + "notes": [ + "CUDA_SCALE_LAUNCH_QUEUES=4x (2 GPU, parallélisme pipeline possible) — CUDA_LAUNCH_QUEUES=off pour s'en passer", + "SPEC=mtp+ngram : ce moteur ne connaît pas --spec-ngram-mod-n-match — sans décodage spéculatif", + "avertissement : ce llama-server ancien glisse le contexte par défaut — contexte plein, il jette des jetons anciens et le modèle perd une partie de la conversation sans le savoir. --no-context-shift dans EXTRA_ARGS l'en empêche." + ] + }, + { + "m": 0, + "c": 42, + "h": 1, + "e": 0, + "args": [ + "/app/llama-server", + "-m", + "/m/dense.gguf", + "-c", + "32768", + "-b", + "2048", + "-ub", + "512", + "--host", + "0.0.0.0", + "--port", + "8080", + "--parallel", + "1", + "-ngl", + "999" + ], + "env": {}, + "notes": [ + "SPEC=turbo illisible (off, auto, mtp, ngram ou mtp+ngram) : sans décodage spéculatif", + "avertissement : ce llama-server ancien glisse le contexte par défaut — contexte plein, il jette des jetons anciens et le modèle perd une partie de la conversation sans le savoir. --no-context-shift dans EXTRA_ARGS l'en empêche." + ] + }, + { + "m": 2, + "c": 42, + "h": 2, + "e": 0, + "args": [ + "/app/llama-server", + "-m", + "/m/inconnu.gguf", + "-c", + "32768", + "-b", + "2048", + "-ub", + "512", + "--host", + "0.0.0.0", + "--port", + "8080", + "--parallel", + "1", + "-ngl", + "999" + ], + "env": {}, + "notes": [ + "SPEC=turbo illisible (off, auto, mtp, ngram ou mtp+ngram) : sans décodage spéculatif" + ] + }, + { + "m": 4, + "c": 42, + "h": 1, + "e": 0, + "args": [ + "/app/llama-server", + "-m", + "/m/hybrid.gguf", + "-c", + "32768", + "-b", + "2048", + "-ub", + "512", + "--host", + "0.0.0.0", + "--port", + "8080", + "--parallel", + "1", + "-ngl", + "999" + ], + "env": { + "CUDA_SCALE_LAUNCH_QUEUES": "4x" + }, + "notes": [ + "CUDA_SCALE_LAUNCH_QUEUES=4x (2 GPU, parallélisme pipeline possible) — CUDA_LAUNCH_QUEUES=off pour s'en passer", + "SPEC=turbo illisible (off, auto, mtp, ngram ou mtp+ngram) : sans décodage spéculatif", + "avertissement : ce llama-server ancien glisse le contexte par défaut — contexte plein, il jette des jetons anciens et le modèle perd une partie de la conversation sans le savoir. --no-context-shift dans EXTRA_ARGS l'en empêche." + ] + }, + { + "m": 0, + "c": 43, + "h": 1, + "e": 0, + "args": [ + "/app/llama-server", + "-m", + "/m/dense.gguf", + "-c", + "32768", + "-b", + "2048", + "-ub", + "512", + "--host", + "0.0.0.0", + "--port", + "8080", + "--parallel", + "1", + "-ngl", + "999" + ], + "env": {}, + "notes": [ + "MODEL_DRAFT ignoré : SPEC=off (auto ou mtp pour s'en servir)", + "avertissement : ce llama-server ancien glisse le contexte par défaut — contexte plein, il jette des jetons anciens et le modèle perd une partie de la conversation sans le savoir. --no-context-shift dans EXTRA_ARGS l'en empêche." + ] + }, + { + "m": 2, + "c": 43, + "h": 2, + "e": 0, + "args": [ + "/app/llama-server", + "-m", + "/m/inconnu.gguf", + "-c", + "32768", + "-b", + "2048", + "-ub", + "512", + "--host", + "0.0.0.0", + "--port", + "8080", + "--parallel", + "1", + "-ngl", + "999" + ], + "env": {}, + "notes": [ + "MODEL_DRAFT ignoré : SPEC=off (auto ou mtp pour s'en servir)" + ] + }, + { + "m": 4, + "c": 43, + "h": 1, + "e": 0, + "args": [ + "/app/llama-server", + "-m", + "/m/hybrid.gguf", + "-c", + "32768", + "-b", + "2048", + "-ub", + "512", + "--host", + "0.0.0.0", + "--port", + "8080", + "--parallel", + "1", + "-ngl", + "999" + ], + "env": { + "CUDA_SCALE_LAUNCH_QUEUES": "4x" + }, + "notes": [ + "CUDA_SCALE_LAUNCH_QUEUES=4x (2 GPU, parallélisme pipeline possible) — CUDA_LAUNCH_QUEUES=off pour s'en passer", + "MODEL_DRAFT ignoré : SPEC=off (auto ou mtp pour s'en servir)", + "avertissement : ce llama-server ancien glisse le contexte par défaut — contexte plein, il jette des jetons anciens et le modèle perd une partie de la conversation sans le savoir. --no-context-shift dans EXTRA_ARGS l'en empêche." + ] + }, + { + "m": 0, + "c": 44, + "h": 1, + "e": 0, + "args": [ + "/app/llama-server", + "-m", + "/m/dense.gguf", + "-c", + "32768", + "-b", + "2048", + "-ub", + "512", + "--host", + "0.0.0.0", + "--port", + "8080", + "--parallel", + "1", + "-ngl", + "999" + ], + "env": {}, + "notes": [ + "SPEC=mtp : MODEL_DRAFT=mtp-head.gguf introuvable — sans décodage spéculatif", + "avertissement : ce llama-server ancien glisse le contexte par défaut — contexte plein, il jette des jetons anciens et le modèle perd une partie de la conversation sans le savoir. --no-context-shift dans EXTRA_ARGS l'en empêche." + ] + }, + { + "m": 2, + "c": 44, + "h": 2, + "e": 0, + "args": [ + "/app/llama-server", + "-m", + "/m/inconnu.gguf", + "-c", + "32768", + "-b", + "2048", + "-ub", + "512", + "--host", + "0.0.0.0", + "--port", + "8080", + "--parallel", + "1", + "-ngl", + "999" + ], + "env": {}, + "notes": [ + "SPEC=mtp : MODEL_DRAFT=mtp-head.gguf introuvable — sans décodage spéculatif" + ] + }, + { + "m": 4, + "c": 44, + "h": 1, + "e": 0, + "args": [ + "/app/llama-server", + "-m", + "/m/hybrid.gguf", + "-c", + "32768", + "-b", + "2048", + "-ub", + "512", + "--host", + "0.0.0.0", + "--port", + "8080", + "--parallel", + "1", + "-ngl", + "999" + ], + "env": { + "CUDA_SCALE_LAUNCH_QUEUES": "4x" + }, + "notes": [ + "CUDA_SCALE_LAUNCH_QUEUES=4x (2 GPU, parallélisme pipeline possible) — CUDA_LAUNCH_QUEUES=off pour s'en passer", + "SPEC=mtp : MODEL_DRAFT=mtp-head.gguf introuvable — sans décodage spéculatif", + "avertissement : ce llama-server ancien glisse le contexte par défaut — contexte plein, il jette des jetons anciens et le modèle perd une partie de la conversation sans le savoir. --no-context-shift dans EXTRA_ARGS l'en empêche." + ] + }, + { + "m": 0, + "c": 45, + "h": 1, + "e": 0, + "args": [ + "/app/llama-server", + "-m", + "/m/dense.gguf", + "-c", + "32768", + "-b", + "2048", + "-ub", + "512", + "--host", + "0.0.0.0", + "--port", + "8080", + "--parallel", + "1", + "-ngl", + "999" + ], + "env": {}, + "notes": [ + "SPEC=mtp : pas de tête MTP dans ce fichier (publiée à part ? MODEL_DRAFT=mtp-….gguf) — sans décodage spéculatif", + "avertissement : ce llama-server ancien glisse le contexte par défaut — contexte plein, il jette des jetons anciens et le modèle perd une partie de la conversation sans le savoir. --no-context-shift dans EXTRA_ARGS l'en empêche." + ] + }, + { + "m": 2, + "c": 45, + "h": 2, + "e": 0, + "args": [ + "/app/llama-server", + "-m", + "/m/inconnu.gguf", + "-c", + "32768", + "-b", + "2048", + "-ub", + "512", + "--host", + "0.0.0.0", + "--port", + "8080", + "--parallel", + "1", + "-ngl", + "999" + ], + "env": {}, + "notes": [ + "SPEC=mtp : métadonnées du modèle illisibles — sans décodage spéculatif" + ] + }, + { + "m": 4, + "c": 45, + "h": 1, + "e": 0, + "args": [ + "/app/llama-server", + "-m", + "/m/hybrid.gguf", + "-c", + "32768", + "-b", + "2048", + "-ub", + "512", + "--host", + "0.0.0.0", + "--port", + "8080", + "--parallel", + "1", + "-ngl", + "999" + ], + "env": { + "CUDA_SCALE_LAUNCH_QUEUES": "4x" + }, + "notes": [ + "CUDA_SCALE_LAUNCH_QUEUES=4x (2 GPU, parallélisme pipeline possible) — CUDA_LAUNCH_QUEUES=off pour s'en passer", + "SPEC=mtp : ce moteur ne connaît pas draft-mtp — sans décodage spéculatif", + "avertissement : ce llama-server ancien glisse le contexte par défaut — contexte plein, il jette des jetons anciens et le modèle perd une partie de la conversation sans le savoir. --no-context-shift dans EXTRA_ARGS l'en empêche." + ] + }, + { + "m": 0, + "c": 46, + "h": 1, + "e": 0, + "args": [ + "/app/llama-server", + "-m", + "/m/dense.gguf", + "-c", + "32768", + "-b", + "2048", + "-ub", + "512", + "--host", + "0.0.0.0", + "--port", + "8080", + "--parallel", + "1", + "-ngl", + "999" + ], + "env": {}, + "notes": [ + "avertissement : ce llama-server ancien glisse le contexte par défaut — contexte plein, il jette des jetons anciens et le modèle perd une partie de la conversation sans le savoir. --no-context-shift dans EXTRA_ARGS l'en empêche." + ] + }, + { + "m": 2, + "c": 46, + "h": 2, + "e": 0, + "args": [ + "/app/llama-server", + "-m", + "/m/inconnu.gguf", + "-c", + "32768", + "-b", + "2048", + "-ub", + "512", + "--host", + "0.0.0.0", + "--port", + "8080", + "--parallel", + "1", + "-ngl", + "999" + ], + "env": {}, + "notes": [ + "SPEC=auto : métadonnées du modèle illisibles — sans décodage spéculatif" + ] + }, + { + "m": 4, + "c": 46, + "h": 1, + "e": 0, + "args": [ + "/app/llama-server", + "-m", + "/m/hybrid.gguf", + "-c", + "32768", + "-b", + "2048", + "-ub", + "512", + "--host", + "0.0.0.0", + "--port", + "8080", + "--parallel", + "1", + "-ngl", + "999" + ], + "env": { + "CUDA_SCALE_LAUNCH_QUEUES": "4x" + }, + "notes": [ + "CUDA_SCALE_LAUNCH_QUEUES=4x (2 GPU, parallélisme pipeline possible) — CUDA_LAUNCH_QUEUES=off pour s'en passer", + "SPEC=auto : ce moteur ne connaît pas draft-mtp — sans décodage spéculatif", + "avertissement : ce llama-server ancien glisse le contexte par défaut — contexte plein, il jette des jetons anciens et le modèle perd une partie de la conversation sans le savoir. --no-context-shift dans EXTRA_ARGS l'en empêche." + ] + }, + { + "m": 0, + "c": 47, + "h": 1, + "e": 0, + "args": [ + "/app/llama-server", + "-m", + "/m/dense.gguf", + "-c", + "32768", + "-b", + "2048", + "-ub", + "512", + "--host", + "0.0.0.0", + "--port", + "8080", + "--parallel", + "1", + "-ngl", + "999" + ], + "env": {}, + "notes": [ + "SPEC=mtp : pas de tête MTP dans ce fichier (publiée à part ? MODEL_DRAFT=mtp-….gguf) — sans décodage spéculatif", + "avertissement : ce llama-server ancien glisse le contexte par défaut — contexte plein, il jette des jetons anciens et le modèle perd une partie de la conversation sans le savoir. --no-context-shift dans EXTRA_ARGS l'en empêche." + ] + }, + { + "m": 2, + "c": 47, + "h": 2, + "e": 0, + "args": [ + "/app/llama-server", + "-m", + "/m/inconnu.gguf", + "-c", + "32768", + "-b", + "2048", + "-ub", + "512", + "--host", + "0.0.0.0", + "--port", + "8080", + "--parallel", + "1", + "-ngl", + "999" + ], + "env": {}, + "notes": [ + "SPEC=mtp : métadonnées du modèle illisibles — sans décodage spéculatif" + ] + }, + { + "m": 4, + "c": 47, + "h": 1, + "e": 0, + "args": [ + "/app/llama-server", + "-m", + "/m/hybrid.gguf", + "-c", + "32768", + "-b", + "2048", + "-ub", + "512", + "--host", + "0.0.0.0", + "--port", + "8080", + "--parallel", + "1", + "-ngl", + "999" + ], + "env": { + "CUDA_SCALE_LAUNCH_QUEUES": "4x" + }, + "notes": [ + "CUDA_SCALE_LAUNCH_QUEUES=4x (2 GPU, parallélisme pipeline possible) — CUDA_LAUNCH_QUEUES=off pour s'en passer", + "SPEC=mtp : ce moteur ne connaît pas draft-mtp — sans décodage spéculatif", + "avertissement : ce llama-server ancien glisse le contexte par défaut — contexte plein, il jette des jetons anciens et le modèle perd une partie de la conversation sans le savoir. --no-context-shift dans EXTRA_ARGS l'en empêche." + ] + }, + { + "m": 0, + "c": 48, + "h": 1, + "e": 0, + "args": [ + "/app/llama-server", + "-m", + "/m/dense.gguf", + "-c", + "32768", + "-b", + "2048", + "-ub", + "512", + "--host", + "0.0.0.0", + "--port", + "8080", + "--parallel", + "1", + "-ngl", + "999" + ], + "env": {}, + "notes": [ + "SPEC=ngram : ce moteur ne connaît pas --spec-ngram-mod-n-match — sans décodage spéculatif", + "avertissement : ce llama-server ancien glisse le contexte par défaut — contexte plein, il jette des jetons anciens et le modèle perd une partie de la conversation sans le savoir. --no-context-shift dans EXTRA_ARGS l'en empêche." + ] + }, + { + "m": 2, + "c": 48, + "h": 2, + "e": 0, + "args": [ + "/app/llama-server", + "-m", + "/m/inconnu.gguf", + "-c", + "32768", + "-b", + "2048", + "-ub", + "512", + "--host", + "0.0.0.0", + "--port", + "8080", + "--parallel", + "1", + "-ngl", + "999" + ], + "env": {}, + "notes": [ + "SPEC=ngram : ce moteur ne connaît pas --spec-ngram-mod-n-match — sans décodage spéculatif" + ] + }, + { + "m": 4, + "c": 48, + "h": 1, + "e": 0, + "args": [ + "/app/llama-server", + "-m", + "/m/hybrid.gguf", + "-c", + "32768", + "-b", + "2048", + "-ub", + "512", + "--host", + "0.0.0.0", + "--port", + "8080", + "--parallel", + "1", + "-ngl", + "999" + ], + "env": { + "CUDA_SCALE_LAUNCH_QUEUES": "4x" + }, + "notes": [ + "CUDA_SCALE_LAUNCH_QUEUES=4x (2 GPU, parallélisme pipeline possible) — CUDA_LAUNCH_QUEUES=off pour s'en passer", + "SPEC=ngram : ce moteur ne connaît pas --spec-ngram-mod-n-match — sans décodage spéculatif", + "avertissement : ce llama-server ancien glisse le contexte par défaut — contexte plein, il jette des jetons anciens et le modèle perd une partie de la conversation sans le savoir. --no-context-shift dans EXTRA_ARGS l'en empêche." + ] + }, + { + "m": 0, + "c": 49, + "h": 1, + "e": 0, + "args": [ + "/app/llama-server", + "-m", + "/m/dense.gguf", + "-c", + "32768", + "-b", + "2048", + "-ub", + "512", + "--host", + "0.0.0.0", + "--port", + "8080", + "--parallel", + "1", + "-ngl", + "999" + ], + "env": {}, + "notes": [ + "SPEC=auto : MODEL_DRAFT=small.gguf introuvable — sans décodage spéculatif", + "avertissement : ce llama-server ancien glisse le contexte par défaut — contexte plein, il jette des jetons anciens et le modèle perd une partie de la conversation sans le savoir. --no-context-shift dans EXTRA_ARGS l'en empêche." + ] + }, + { + "m": 2, + "c": 49, + "h": 2, + "e": 0, + "args": [ + "/app/llama-server", + "-m", + "/m/inconnu.gguf", + "-c", + "32768", + "-b", + "2048", + "-ub", + "512", + "--host", + "0.0.0.0", + "--port", + "8080", + "--parallel", + "1", + "-ngl", + "999" + ], + "env": {}, + "notes": [ + "SPEC=auto : MODEL_DRAFT=small.gguf introuvable — sans décodage spéculatif" + ] + }, + { + "m": 4, + "c": 49, + "h": 1, + "e": 0, + "args": [ + "/app/llama-server", + "-m", + "/m/hybrid.gguf", + "-c", + "32768", + "-b", + "2048", + "-ub", + "512", + "--host", + "0.0.0.0", + "--port", + "8080", + "--parallel", + "1", + "-ngl", + "999" + ], + "env": { + "CUDA_SCALE_LAUNCH_QUEUES": "4x" + }, + "notes": [ + "CUDA_SCALE_LAUNCH_QUEUES=4x (2 GPU, parallélisme pipeline possible) — CUDA_LAUNCH_QUEUES=off pour s'en passer", + "SPEC=auto : MODEL_DRAFT=small.gguf introuvable — sans décodage spéculatif", + "avertissement : ce llama-server ancien glisse le contexte par défaut — contexte plein, il jette des jetons anciens et le modèle perd une partie de la conversation sans le savoir. --no-context-shift dans EXTRA_ARGS l'en empêche." + ] + }, + { + "m": 0, + "c": 50, + "h": 1, + "e": 0, + "args": [ + "/app/llama-server", + "-m", + "/m/dense.gguf", + "-c", + "32768", + "-b", + "2048", + "-ub", + "512", + "--host", + "0.0.0.0", + "--port", + "8080", + "--parallel", + "1", + "-ngl", + "999" + ], + "env": {}, + "notes": [ + "SPEC=mtp+ngram : ce moteur ne connaît pas --spec-ngram-mod-n-match — sans décodage spéculatif", + "avertissement : ce llama-server ancien glisse le contexte par défaut — contexte plein, il jette des jetons anciens et le modèle perd une partie de la conversation sans le savoir. --no-context-shift dans EXTRA_ARGS l'en empêche." + ] + }, + { + "m": 2, + "c": 50, + "h": 2, + "e": 0, + "args": [ + "/app/llama-server", + "-m", + "/m/inconnu.gguf", + "-c", + "32768", + "-b", + "2048", + "-ub", + "512", + "--host", + "0.0.0.0", + "--port", + "8080", + "--parallel", + "1", + "-ngl", + "999" + ], + "env": {}, + "notes": [ + "SPEC=mtp+ngram : ce moteur ne connaît pas --spec-ngram-mod-n-match — sans décodage spéculatif" + ] + }, + { + "m": 4, + "c": 50, + "h": 1, + "e": 0, + "args": [ + "/app/llama-server", + "-m", + "/m/hybrid.gguf", + "-c", + "32768", + "-b", + "2048", + "-ub", + "512", + "--host", + "0.0.0.0", + "--port", + "8080", + "--parallel", + "1", + "-ngl", + "999" + ], + "env": { + "CUDA_SCALE_LAUNCH_QUEUES": "4x" + }, + "notes": [ + "CUDA_SCALE_LAUNCH_QUEUES=4x (2 GPU, parallélisme pipeline possible) — CUDA_LAUNCH_QUEUES=off pour s'en passer", + "SPEC=mtp+ngram : ce moteur ne connaît pas --spec-ngram-mod-n-match — sans décodage spéculatif", + "avertissement : ce llama-server ancien glisse le contexte par défaut — contexte plein, il jette des jetons anciens et le modèle perd une partie de la conversation sans le savoir. --no-context-shift dans EXTRA_ARGS l'en empêche." + ] + }, + { + "m": 0, + "c": 51, + "h": 1, + "e": 0, + "args": [ + "/app/llama-server", + "-m", + "/m/dense.gguf", + "-c", + "32768", + "-b", + "2048", + "-ub", + "512", + "--host", + "0.0.0.0", + "--port", + "8080", + "--parallel", + "1", + "-ngl", + "999" + ], + "env": {}, + "notes": [ + "avertissement : ce llama-server ancien glisse le contexte par défaut — contexte plein, il jette des jetons anciens et le modèle perd une partie de la conversation sans le savoir. --no-context-shift dans EXTRA_ARGS l'en empêche." + ] + }, + { + "m": 2, + "c": 51, + "h": 2, + "e": 0, + "args": [ + "/app/llama-server", + "-m", + "/m/inconnu.gguf", + "-c", + "32768", + "-b", + "2048", + "-ub", + "512", + "--host", + "0.0.0.0", + "--port", + "8080", + "--parallel", + "1", + "-ngl", + "999" + ], + "env": {}, + "notes": null + }, + { + "m": 4, + "c": 51, + "h": 1, + "e": 0, + "args": [ + "/app/llama-server", + "-m", + "/m/hybrid.gguf", + "-c", + "32768", + "-b", + "2048", + "-ub", + "512", + "--host", + "0.0.0.0", + "--port", + "8080", + "--parallel", + "1", + "-ngl", + "999" + ], + "env": {}, + "notes": [ + "avertissement : ce llama-server ancien glisse le contexte par défaut — contexte plein, il jette des jetons anciens et le modèle perd une partie de la conversation sans le savoir. --no-context-shift dans EXTRA_ARGS l'en empêche." + ] + }, + { + "m": 0, + "c": 52, + "h": 1, + "e": 0, + "args": [ + "/app/llama-server", + "-m", + "/m/dense.gguf", + "-c", + "32768", + "-b", + "2048", + "-ub", + "512", + "--host", + "0.0.0.0", + "--port", + "8080", + "--parallel", + "1", + "-ngl", + "999" + ], + "env": { + "CUDA_SCALE_LAUNCH_QUEUES": "2x" + }, + "notes": [ + "CUDA_SCALE_LAUNCH_QUEUES=2x (CUDA_LAUNCH_QUEUES)", + "avertissement : ce llama-server ancien glisse le contexte par défaut — contexte plein, il jette des jetons anciens et le modèle perd une partie de la conversation sans le savoir. --no-context-shift dans EXTRA_ARGS l'en empêche." + ] + }, + { + "m": 2, + "c": 52, + "h": 2, + "e": 0, + "args": [ + "/app/llama-server", + "-m", + "/m/inconnu.gguf", + "-c", + "32768", + "-b", + "2048", + "-ub", + "512", + "--host", + "0.0.0.0", + "--port", + "8080", + "--parallel", + "1", + "-ngl", + "999" + ], + "env": { + "CUDA_SCALE_LAUNCH_QUEUES": "2x" + }, + "notes": [ + "CUDA_SCALE_LAUNCH_QUEUES=2x (CUDA_LAUNCH_QUEUES)" + ] + }, + { + "m": 4, + "c": 52, + "h": 1, + "e": 0, + "args": [ + "/app/llama-server", + "-m", + "/m/hybrid.gguf", + "-c", + "32768", + "-b", + "2048", + "-ub", + "512", + "--host", + "0.0.0.0", + "--port", + "8080", + "--parallel", + "1", + "-ngl", + "999" + ], + "env": { + "CUDA_SCALE_LAUNCH_QUEUES": "2x" + }, + "notes": [ + "CUDA_SCALE_LAUNCH_QUEUES=2x (CUDA_LAUNCH_QUEUES)", + "avertissement : ce llama-server ancien glisse le contexte par défaut — contexte plein, il jette des jetons anciens et le modèle perd une partie de la conversation sans le savoir. --no-context-shift dans EXTRA_ARGS l'en empêche." + ] + }, + { + "m": 0, + "c": 53, + "h": 1, + "e": 0, + "args": [ + "/app/llama-server", + "-m", + "/m/dense.gguf", + "-c", + "32768", + "-b", + "2048", + "-ub", + "512", + "--host", + "0.0.0.0", + "--port", + "8080", + "--parallel", + "1", + "-ngl", + "999" + ], + "env": {}, + "notes": [ + "CUDA_LAUNCH_QUEUES=8x ignoré (0.25x, 0.5x, 2x, 4x ou off) → automatique", + "avertissement : ce llama-server ancien glisse le contexte par défaut — contexte plein, il jette des jetons anciens et le modèle perd une partie de la conversation sans le savoir. --no-context-shift dans EXTRA_ARGS l'en empêche." + ] + }, + { + "m": 2, + "c": 53, + "h": 2, + "e": 0, + "args": [ + "/app/llama-server", + "-m", + "/m/inconnu.gguf", + "-c", + "32768", + "-b", + "2048", + "-ub", + "512", + "--host", + "0.0.0.0", + "--port", + "8080", + "--parallel", + "1", + "-ngl", + "999" + ], + "env": {}, + "notes": [ + "CUDA_LAUNCH_QUEUES=8x ignoré (0.25x, 0.5x, 2x, 4x ou off) → automatique" + ] + }, + { + "m": 4, + "c": 53, + "h": 1, + "e": 0, + "args": [ + "/app/llama-server", + "-m", + "/m/hybrid.gguf", + "-c", + "32768", + "-b", + "2048", + "-ub", + "512", + "--host", + "0.0.0.0", + "--port", + "8080", + "--parallel", + "1", + "-ngl", + "999" + ], + "env": { + "CUDA_SCALE_LAUNCH_QUEUES": "4x" + }, + "notes": [ + "CUDA_LAUNCH_QUEUES=8x ignoré (0.25x, 0.5x, 2x, 4x ou off) → automatique", + "CUDA_SCALE_LAUNCH_QUEUES=4x (2 GPU, parallélisme pipeline possible) — CUDA_LAUNCH_QUEUES=off pour s'en passer", + "avertissement : ce llama-server ancien glisse le contexte par défaut — contexte plein, il jette des jetons anciens et le modèle perd une partie de la conversation sans le savoir. --no-context-shift dans EXTRA_ARGS l'en empêche." + ] + }, + { + "m": 0, + "c": 54, + "h": 1, + "e": 0, + "args": [ + "/app/llama-server", + "-m", + "/m/dense.gguf", + "-c", + "32768", + "-b", + "2048", + "-ub", + "512", + "--host", + "0.0.0.0", + "--port", + "8080", + "--parallel", + "1", + "-ngl", + "999" + ], + "env": {}, + "notes": [ + "avertissement : ce llama-server ancien glisse le contexte par défaut — contexte plein, il jette des jetons anciens et le modèle perd une partie de la conversation sans le savoir. --no-context-shift dans EXTRA_ARGS l'en empêche." + ] + }, + { + "m": 2, + "c": 54, + "h": 2, + "e": 0, + "args": [ + "/app/llama-server", + "-m", + "/m/inconnu.gguf", + "-c", + "32768", + "-b", + "2048", + "-ub", + "512", + "--host", + "0.0.0.0", + "--port", + "8080", + "--parallel", + "1", + "-ngl", + "999" + ], + "env": {}, + "notes": null + }, + { + "m": 4, + "c": 54, + "h": 1, + "e": 0, + "args": [ + "/app/llama-server", + "-m", + "/m/hybrid.gguf", + "-c", + "32768", + "-b", + "2048", + "-ub", + "512", + "--host", + "0.0.0.0", + "--port", + "8080", + "--parallel", + "1", + "-ngl", + "999" + ], + "env": { + "CUDA_SCALE_LAUNCH_QUEUES": "4x" + }, + "notes": [ + "CUDA_SCALE_LAUNCH_QUEUES=4x (2 GPU, parallélisme pipeline possible) — CUDA_LAUNCH_QUEUES=off pour s'en passer", + "avertissement : ce llama-server ancien glisse le contexte par défaut — contexte plein, il jette des jetons anciens et le modèle perd une partie de la conversation sans le savoir. --no-context-shift dans EXTRA_ARGS l'en empêche." + ] + }, + { + "m": 0, + "c": 55, + "h": 1, + "e": 0, + "args": [ + "/app/llama-server", + "-m", + "/m/dense.gguf", + "-c", + "32768", + "-b", + "2048", + "-ub", + "512", + "--host", + "0.0.0.0", + "--port", + "8080", + "--parallel", + "1", + "-ngl", + "999" + ], + "env": { + "CUDA_DEVICE_ORDER": "PCI_BUS_ID", + "CUDA_VISIBLE_DEVICES": "1,0" + }, + "notes": [ + "avertissement : ce llama-server ancien glisse le contexte par défaut — contexte plein, il jette des jetons anciens et le modèle perd une partie de la conversation sans le savoir. --no-context-shift dans EXTRA_ARGS l'en empêche." + ] + }, + { + "m": 2, + "c": 55, + "h": 2, + "e": 0, + "args": [ + "/app/llama-server", + "-m", + "/m/inconnu.gguf", + "-c", + "32768", + "-b", + "2048", + "-ub", + "512", + "--host", + "0.0.0.0", + "--port", + "8080", + "--parallel", + "1", + "-ngl", + "999" + ], + "env": { + "CUDA_DEVICE_ORDER": "PCI_BUS_ID", + "CUDA_VISIBLE_DEVICES": "1,0" + }, + "notes": null + }, + { + "m": 4, + "c": 55, + "h": 1, + "e": 0, + "args": [ + "/app/llama-server", + "-m", + "/m/hybrid.gguf", + "-c", + "32768", + "-b", + "2048", + "-ub", + "512", + "--host", + "0.0.0.0", + "--port", + "8080", + "--parallel", + "1", + "-ngl", + "999" + ], + "env": { + "CUDA_DEVICE_ORDER": "PCI_BUS_ID", + "CUDA_SCALE_LAUNCH_QUEUES": "4x", + "CUDA_VISIBLE_DEVICES": "1,0" + }, + "notes": [ + "CUDA_SCALE_LAUNCH_QUEUES=4x (2 GPU, parallélisme pipeline possible) — CUDA_LAUNCH_QUEUES=off pour s'en passer", + "avertissement : ce llama-server ancien glisse le contexte par défaut — contexte plein, il jette des jetons anciens et le modèle perd une partie de la conversation sans le savoir. --no-context-shift dans EXTRA_ARGS l'en empêche." + ] + }, + { + "m": 0, + "c": 56, + "h": 1, + "e": 0, + "args": [ + "/app/llama-server", + "-m", + "/m/dense.gguf", + "-c", + "32768", + "-b", + "2048", + "-ub", + "512", + "--host", + "0.0.0.0", + "--port", + "8080", + "--parallel", + "1", + "-ngl", + "999" + ], + "env": {}, + "notes": [ + "FIT_TARGET=1024,3072 ignoré : ce moteur ne connaît pas --fit-target", + "avertissement : ce llama-server ancien glisse le contexte par défaut — contexte plein, il jette des jetons anciens et le modèle perd une partie de la conversation sans le savoir. --no-context-shift dans EXTRA_ARGS l'en empêche." + ] + }, + { + "m": 2, + "c": 56, + "h": 2, + "e": 0, + "args": [ + "/app/llama-server", + "-m", + "/m/inconnu.gguf", + "-c", + "32768", + "-b", + "2048", + "-ub", + "512", + "--host", + "0.0.0.0", + "--port", + "8080", + "--parallel", + "1", + "-ngl", + "999" + ], + "env": {}, + "notes": [ + "FIT_TARGET=1024,3072 ignoré : ce moteur ne connaît pas --fit-target" + ] + }, + { + "m": 4, + "c": 56, + "h": 1, + "e": 0, + "args": [ + "/app/llama-server", + "-m", + "/m/hybrid.gguf", + "-c", + "32768", + "-b", + "2048", + "-ub", + "512", + "--host", + "0.0.0.0", + "--port", + "8080", + "--parallel", + "1", + "-ngl", + "999" + ], + "env": { + "CUDA_SCALE_LAUNCH_QUEUES": "4x" + }, + "notes": [ + "CUDA_SCALE_LAUNCH_QUEUES=4x (2 GPU, parallélisme pipeline possible) — CUDA_LAUNCH_QUEUES=off pour s'en passer", + "FIT_TARGET=1024,3072 ignoré : ce moteur ne connaît pas --fit-target", + "avertissement : ce llama-server ancien glisse le contexte par défaut — contexte plein, il jette des jetons anciens et le modèle perd une partie de la conversation sans le savoir. --no-context-shift dans EXTRA_ARGS l'en empêche." + ] + }, + { + "m": 0, + "c": 57, + "h": 1, + "e": 0, + "args": [ + "/app/llama-server", + "-m", + "/m/dense.gguf", + "-c", + "32768", + "-b", + "2048", + "-ub", + "512", + "--host", + "0.0.0.0", + "--port", + "8080", + "--parallel", + "1", + "-ngl", + "999" + ], + "env": {}, + "notes": [ + "FIT_TARGET=512 ignoré : ce moteur ne connaît pas --fit-target", + "avertissement : ce llama-server ancien glisse le contexte par défaut — contexte plein, il jette des jetons anciens et le modèle perd une partie de la conversation sans le savoir. --no-context-shift dans EXTRA_ARGS l'en empêche." + ] + }, + { + "m": 2, + "c": 57, + "h": 2, + "e": 0, + "args": [ + "/app/llama-server", + "-m", + "/m/inconnu.gguf", + "-c", + "32768", + "-b", + "2048", + "-ub", + "512", + "--host", + "0.0.0.0", + "--port", + "8080", + "--parallel", + "1", + "-ngl", + "999" + ], + "env": {}, + "notes": [ + "FIT_TARGET=512 ignoré : ce moteur ne connaît pas --fit-target" + ] + }, + { + "m": 4, + "c": 57, + "h": 1, + "e": 0, + "args": [ + "/app/llama-server", + "-m", + "/m/hybrid.gguf", + "-c", + "32768", + "-b", + "2048", + "-ub", + "512", + "--host", + "0.0.0.0", + "--port", + "8080", + "--parallel", + "1", + "-ngl", + "999" + ], + "env": { + "CUDA_SCALE_LAUNCH_QUEUES": "4x" + }, + "notes": [ + "CUDA_SCALE_LAUNCH_QUEUES=4x (2 GPU, parallélisme pipeline possible) — CUDA_LAUNCH_QUEUES=off pour s'en passer", + "FIT_TARGET=512 ignoré : ce moteur ne connaît pas --fit-target", + "avertissement : ce llama-server ancien glisse le contexte par défaut — contexte plein, il jette des jetons anciens et le modèle perd une partie de la conversation sans le savoir. --no-context-shift dans EXTRA_ARGS l'en empêche." + ] + }, + { + "m": 0, + "c": 58, + "h": 1, + "e": 0, + "args": [ + "/app/llama-server", + "-m", + "/m/dense.gguf", + "-c", + "32768", + "-b", + "2048", + "-ub", + "512", + "--host", + "0.0.0.0", + "--port", + "8080", + "--parallel", + "1", + "-ngl", + "999" + ], + "env": {}, + "notes": [ + "FIT_TARGET=1024,,3072 ignoré : des Mio par carte, ex. 1024,3072 (8 valeurs au plus)", + "avertissement : ce llama-server ancien glisse le contexte par défaut — contexte plein, il jette des jetons anciens et le modèle perd une partie de la conversation sans le savoir. --no-context-shift dans EXTRA_ARGS l'en empêche." + ] + }, + { + "m": 2, + "c": 58, + "h": 2, + "e": 0, + "args": [ + "/app/llama-server", + "-m", + "/m/inconnu.gguf", + "-c", + "32768", + "-b", + "2048", + "-ub", + "512", + "--host", + "0.0.0.0", + "--port", + "8080", + "--parallel", + "1", + "-ngl", + "999" + ], + "env": {}, + "notes": [ + "FIT_TARGET=1024,,3072 ignoré : des Mio par carte, ex. 1024,3072 (8 valeurs au plus)" + ] + }, + { + "m": 4, + "c": 58, + "h": 1, + "e": 0, + "args": [ + "/app/llama-server", + "-m", + "/m/hybrid.gguf", + "-c", + "32768", + "-b", + "2048", + "-ub", + "512", + "--host", + "0.0.0.0", + "--port", + "8080", + "--parallel", + "1", + "-ngl", + "999" + ], + "env": { + "CUDA_SCALE_LAUNCH_QUEUES": "4x" + }, + "notes": [ + "CUDA_SCALE_LAUNCH_QUEUES=4x (2 GPU, parallélisme pipeline possible) — CUDA_LAUNCH_QUEUES=off pour s'en passer", + "FIT_TARGET=1024,,3072 ignoré : des Mio par carte, ex. 1024,3072 (8 valeurs au plus)", + "avertissement : ce llama-server ancien glisse le contexte par défaut — contexte plein, il jette des jetons anciens et le modèle perd une partie de la conversation sans le savoir. --no-context-shift dans EXTRA_ARGS l'en empêche." + ] + }, + { + "m": 0, + "c": 59, + "h": 1, + "e": 0, + "args": [ + "/app/llama-server", + "-m", + "/m/dense.gguf", + "-c", + "32768", + "-b", + "2048", + "-ub", + "512", + "--host", + "0.0.0.0", + "--port", + "8080", + "--parallel", + "1", + "-ngl", + "999" + ], + "env": {}, + "notes": [ + "FIT_TARGET=1,2,3,4,5,6,7,8,9 ignoré : des Mio par carte, ex. 1024,3072 (8 valeurs au plus)", + "avertissement : ce llama-server ancien glisse le contexte par défaut — contexte plein, il jette des jetons anciens et le modèle perd une partie de la conversation sans le savoir. --no-context-shift dans EXTRA_ARGS l'en empêche." + ] + }, + { + "m": 2, + "c": 59, + "h": 2, + "e": 0, + "args": [ + "/app/llama-server", + "-m", + "/m/inconnu.gguf", + "-c", + "32768", + "-b", + "2048", + "-ub", + "512", + "--host", + "0.0.0.0", + "--port", + "8080", + "--parallel", + "1", + "-ngl", + "999" + ], + "env": {}, + "notes": [ + "FIT_TARGET=1,2,3,4,5,6,7,8,9 ignoré : des Mio par carte, ex. 1024,3072 (8 valeurs au plus)" + ] + }, + { + "m": 4, + "c": 59, + "h": 1, + "e": 0, + "args": [ + "/app/llama-server", + "-m", + "/m/hybrid.gguf", + "-c", + "32768", + "-b", + "2048", + "-ub", + "512", + "--host", + "0.0.0.0", + "--port", + "8080", + "--parallel", + "1", + "-ngl", + "999" + ], + "env": { + "CUDA_SCALE_LAUNCH_QUEUES": "4x" + }, + "notes": [ + "CUDA_SCALE_LAUNCH_QUEUES=4x (2 GPU, parallélisme pipeline possible) — CUDA_LAUNCH_QUEUES=off pour s'en passer", + "FIT_TARGET=1,2,3,4,5,6,7,8,9 ignoré : des Mio par carte, ex. 1024,3072 (8 valeurs au plus)", + "avertissement : ce llama-server ancien glisse le contexte par défaut — contexte plein, il jette des jetons anciens et le modèle perd une partie de la conversation sans le savoir. --no-context-shift dans EXTRA_ARGS l'en empêche." + ] + }, + { + "m": 0, + "c": 60, + "h": 1, + "e": 0, + "args": [ + "/app/llama-server", + "-m", + "/m/dense.gguf", + "-c", + "0", + "-b", + "2048", + "-ub", + "512", + "--host", + "0.0.0.0", + "--port", + "8080", + "--parallel", + "1", + "-ngl", + "999" + ], + "env": {}, + "notes": [ + "FIT_TARGET=2048/4096 ignoré : ce moteur ne connaît pas --fit-target", + "avertissement : ce llama-server ancien glisse le contexte par défaut — contexte plein, il jette des jetons anciens et le modèle perd une partie de la conversation sans le savoir. --no-context-shift dans EXTRA_ARGS l'en empêche." + ] + }, + { + "m": 2, + "c": 60, + "h": 2, + "e": 0, + "args": [ + "/app/llama-server", + "-m", + "/m/inconnu.gguf", + "-c", + "0", + "-b", + "2048", + "-ub", + "512", + "--host", + "0.0.0.0", + "--port", + "8080", + "--parallel", + "1", + "-ngl", + "999" + ], + "env": {}, + "notes": [ + "FIT_TARGET=2048/4096 ignoré : ce moteur ne connaît pas --fit-target" + ] + }, + { + "m": 4, + "c": 60, + "h": 1, + "e": 0, + "args": [ + "/app/llama-server", + "-m", + "/m/hybrid.gguf", + "-c", + "0", + "-b", + "2048", + "-ub", + "512", + "--host", + "0.0.0.0", + "--port", + "8080", + "--parallel", + "1", + "-ngl", + "999" + ], + "env": { + "CUDA_SCALE_LAUNCH_QUEUES": "4x" + }, + "notes": [ + "CUDA_SCALE_LAUNCH_QUEUES=4x (2 GPU, parallélisme pipeline possible) — CUDA_LAUNCH_QUEUES=off pour s'en passer", + "FIT_TARGET=2048/4096 ignoré : ce moteur ne connaît pas --fit-target", + "avertissement : ce llama-server ancien glisse le contexte par défaut — contexte plein, il jette des jetons anciens et le modèle perd une partie de la conversation sans le savoir. --no-context-shift dans EXTRA_ARGS l'en empêche." + ] + }, + { + "m": 0, + "c": 61, + "h": 1, + "e": 0, + "args": [ + "/app/llama-server", + "-m", + "/m/dense.gguf", + "-c", + "32768", + "-b", + "2048", + "-ub", + "512", + "--host", + "0.0.0.0", + "--port", + "8080", + "--parallel", + "1", + "-ngl", + "30" + ], + "env": {}, + "notes": [ + "FIT_TARGET=2048 ignoré : ce moteur ne connaît pas --fit-target", + "avertissement : ce llama-server ancien glisse le contexte par défaut — contexte plein, il jette des jetons anciens et le modèle perd une partie de la conversation sans le savoir. --no-context-shift dans EXTRA_ARGS l'en empêche." + ] + }, + { + "m": 2, + "c": 61, + "h": 2, + "e": 0, + "args": [ + "/app/llama-server", + "-m", + "/m/inconnu.gguf", + "-c", + "32768", + "-b", + "2048", + "-ub", + "512", + "--host", + "0.0.0.0", + "--port", + "8080", + "--parallel", + "1", + "-ngl", + "30" + ], + "env": {}, + "notes": [ + "FIT_TARGET=2048 ignoré : ce moteur ne connaît pas --fit-target" + ] + }, + { + "m": 4, + "c": 61, + "h": 1, + "e": 0, + "args": [ + "/app/llama-server", + "-m", + "/m/hybrid.gguf", + "-c", + "32768", + "-b", + "2048", + "-ub", + "512", + "--host", + "0.0.0.0", + "--port", + "8080", + "--parallel", + "1", + "-ngl", + "30" + ], + "env": {}, + "notes": [ + "FIT_TARGET=2048 ignoré : ce moteur ne connaît pas --fit-target", + "avertissement : ce llama-server ancien glisse le contexte par défaut — contexte plein, il jette des jetons anciens et le modèle perd une partie de la conversation sans le savoir. --no-context-shift dans EXTRA_ARGS l'en empêche." + ] + }, + { + "m": 0, + "c": 62, + "h": 1, + "e": 0, + "args": [ + "/app/llama-server", + "-m", + "/m/dense.gguf", + "-c", + "32768", + "-b", + "2048", + "-ub", + "512", + "--host", + "0.0.0.0", + "--port", + "8080", + "--parallel", + "1", + "-ngl", + "999" + ], + "env": { + "GGML_OP_OFFLOAD_MIN_BATCH": "128" + }, + "notes": [ + "GGML_OP_OFFLOAD_MIN_BATCH=128 (OP_OFFLOAD_MIN_BATCH) : lots de moins de 128 jetons calculés sur CPU pour les poids restés en RAM", + "avertissement : ce llama-server ancien glisse le contexte par défaut — contexte plein, il jette des jetons anciens et le modèle perd une partie de la conversation sans le savoir. --no-context-shift dans EXTRA_ARGS l'en empêche." + ] + }, + { + "m": 2, + "c": 62, + "h": 2, + "e": 0, + "args": [ + "/app/llama-server", + "-m", + "/m/inconnu.gguf", + "-c", + "32768", + "-b", + "2048", + "-ub", + "512", + "--host", + "0.0.0.0", + "--port", + "8080", + "--parallel", + "1", + "-ngl", + "999" + ], + "env": {}, + "notes": null + }, + { + "m": 4, + "c": 62, + "h": 1, + "e": 0, + "args": [ + "/app/llama-server", + "-m", + "/m/hybrid.gguf", + "-c", + "32768", + "-b", + "2048", + "-ub", + "512", + "--host", + "0.0.0.0", + "--port", + "8080", + "--parallel", + "1", + "-ngl", + "999" + ], + "env": { + "CUDA_SCALE_LAUNCH_QUEUES": "4x", + "GGML_OP_OFFLOAD_MIN_BATCH": "128" + }, + "notes": [ + "CUDA_SCALE_LAUNCH_QUEUES=4x (2 GPU, parallélisme pipeline possible) — CUDA_LAUNCH_QUEUES=off pour s'en passer", + "GGML_OP_OFFLOAD_MIN_BATCH=128 (OP_OFFLOAD_MIN_BATCH) : lots de moins de 128 jetons calculés sur CPU pour les poids restés en RAM", + "avertissement : ce llama-server ancien glisse le contexte par défaut — contexte plein, il jette des jetons anciens et le modèle perd une partie de la conversation sans le savoir. --no-context-shift dans EXTRA_ARGS l'en empêche." + ] + }, + { + "m": 0, + "c": 63, + "h": 1, + "e": 0, + "args": [ + "/app/llama-server", + "-m", + "/m/dense.gguf", + "-c", + "32768", + "-b", + "2048", + "-ub", + "512", + "--host", + "0.0.0.0", + "--port", + "8080", + "--parallel", + "1", + "-ngl", + "999" + ], + "env": {}, + "notes": [ + "OP_OFFLOAD_MIN_BATCH=0 ignoré : un nombre de jetons, ex. 128", + "avertissement : ce llama-server ancien glisse le contexte par défaut — contexte plein, il jette des jetons anciens et le modèle perd une partie de la conversation sans le savoir. --no-context-shift dans EXTRA_ARGS l'en empêche." + ] + }, + { + "m": 2, + "c": 63, + "h": 2, + "e": 0, + "args": [ + "/app/llama-server", + "-m", + "/m/inconnu.gguf", + "-c", + "32768", + "-b", + "2048", + "-ub", + "512", + "--host", + "0.0.0.0", + "--port", + "8080", + "--parallel", + "1", + "-ngl", + "999" + ], + "env": {}, + "notes": [ + "OP_OFFLOAD_MIN_BATCH=0 ignoré : un nombre de jetons, ex. 128" + ] + }, + { + "m": 4, + "c": 63, + "h": 1, + "e": 0, + "args": [ + "/app/llama-server", + "-m", + "/m/hybrid.gguf", + "-c", + "32768", + "-b", + "2048", + "-ub", + "512", + "--host", + "0.0.0.0", + "--port", + "8080", + "--parallel", + "1", + "-ngl", + "999" + ], + "env": { + "CUDA_SCALE_LAUNCH_QUEUES": "4x" + }, + "notes": [ + "CUDA_SCALE_LAUNCH_QUEUES=4x (2 GPU, parallélisme pipeline possible) — CUDA_LAUNCH_QUEUES=off pour s'en passer", + "avertissement : ce llama-server ancien glisse le contexte par défaut — contexte plein, il jette des jetons anciens et le modèle perd une partie de la conversation sans le savoir. --no-context-shift dans EXTRA_ARGS l'en empêche." + ] + }, + { + "m": 0, + "c": 64, + "h": 1, + "e": 0, + "args": [ + "/app/llama-server", + "-m", + "/m/dense.gguf", + "-c", + "32768", + "-b", + "2048", + "-ub", + "512", + "--host", + "0.0.0.0", + "--port", + "8080", + "--parallel", + "1", + "-ngl", + "999" + ], + "env": {}, + "notes": [ + "avertissement : ce llama-server ancien glisse le contexte par défaut — contexte plein, il jette des jetons anciens et le modèle perd une partie de la conversation sans le savoir. --no-context-shift dans EXTRA_ARGS l'en empêche." + ] + }, + { + "m": 2, + "c": 64, + "h": 2, + "e": 0, + "args": [ + "/app/llama-server", + "-m", + "/m/inconnu.gguf", + "-c", + "32768", + "-b", + "2048", + "-ub", + "512", + "--host", + "0.0.0.0", + "--port", + "8080", + "--parallel", + "1", + "-ngl", + "999" + ], + "env": { + "GGML_CUDA_GRAPH_OPT": "1" + }, + "notes": [ + "GGML_CUDA_GRAPH_OPT=1 (CUDA_GRAPH_OPT, expérimental) — si le moteur s'arrête sur un GGML_ASSERT, CUDA_GRAPH_OPT=off puis redémarre" + ] + }, + { + "m": 4, + "c": 64, + "h": 1, + "e": 0, + "args": [ + "/app/llama-server", + "-m", + "/m/hybrid.gguf", + "-c", + "32768", + "-b", + "2048", + "-ub", + "512", + "--host", + "0.0.0.0", + "--port", + "8080", + "--parallel", + "1", + "-ngl", + "999" + ], + "env": { + "CUDA_SCALE_LAUNCH_QUEUES": "4x", + "GGML_CUDA_GRAPH_OPT": "1" + }, + "notes": [ + "CUDA_SCALE_LAUNCH_QUEUES=4x (2 GPU, parallélisme pipeline possible) — CUDA_LAUNCH_QUEUES=off pour s'en passer", + "GGML_CUDA_GRAPH_OPT=1 (CUDA_GRAPH_OPT, expérimental) — si le moteur s'arrête sur un GGML_ASSERT, CUDA_GRAPH_OPT=off puis redémarre", + "avertissement : ce llama-server ancien glisse le contexte par défaut — contexte plein, il jette des jetons anciens et le modèle perd une partie de la conversation sans le savoir. --no-context-shift dans EXTRA_ARGS l'en empêche." + ] + }, + { + "m": 0, + "c": 65, + "h": 1, + "e": 0, + "args": [ + "/app/llama-server", + "-m", + "/m/dense.gguf", + "-c", + "32768", + "-b", + "2048", + "-ub", + "512", + "--host", + "0.0.0.0", + "--port", + "8080", + "--parallel", + "1", + "-ngl", + "999" + ], + "env": {}, + "notes": [ + "CUDA_GRAPH_OPT=oui ignoré (on ou off)", + "avertissement : ce llama-server ancien glisse le contexte par défaut — contexte plein, il jette des jetons anciens et le modèle perd une partie de la conversation sans le savoir. --no-context-shift dans EXTRA_ARGS l'en empêche." + ] + }, + { + "m": 2, + "c": 65, + "h": 2, + "e": 0, + "args": [ + "/app/llama-server", + "-m", + "/m/inconnu.gguf", + "-c", + "32768", + "-b", + "2048", + "-ub", + "512", + "--host", + "0.0.0.0", + "--port", + "8080", + "--parallel", + "1", + "-ngl", + "999" + ], + "env": {}, + "notes": [ + "CUDA_GRAPH_OPT=oui ignoré (on ou off)" + ] + }, + { + "m": 4, + "c": 65, + "h": 1, + "e": 0, + "args": [ + "/app/llama-server", + "-m", + "/m/hybrid.gguf", + "-c", + "32768", + "-b", + "2048", + "-ub", + "512", + "--host", + "0.0.0.0", + "--port", + "8080", + "--parallel", + "1", + "-ngl", + "999" + ], + "env": { + "CUDA_SCALE_LAUNCH_QUEUES": "4x" + }, + "notes": [ + "CUDA_SCALE_LAUNCH_QUEUES=4x (2 GPU, parallélisme pipeline possible) — CUDA_LAUNCH_QUEUES=off pour s'en passer", + "CUDA_GRAPH_OPT=oui ignoré (on ou off)", + "avertissement : ce llama-server ancien glisse le contexte par défaut — contexte plein, il jette des jetons anciens et le modèle perd une partie de la conversation sans le savoir. --no-context-shift dans EXTRA_ARGS l'en empêche." + ] + }, + { + "m": 0, + "c": 66, + "h": 1, + "e": 0, + "args": [ + "/app/llama-server", + "-m", + "/m/dense.gguf", + "-c", + "32768", + "-b", + "2048", + "-ub", + "512", + "--host", + "0.0.0.0", + "--port", + "8080", + "--parallel", + "1", + "-ngl", + "999" + ], + "env": {}, + "notes": [ + "avertissement : ce llama-server ancien glisse le contexte par défaut — contexte plein, il jette des jetons anciens et le modèle perd une partie de la conversation sans le savoir. --no-context-shift dans EXTRA_ARGS l'en empêche." + ] + }, + { + "m": 2, + "c": 66, + "h": 2, + "e": 0, + "args": [ + "/app/llama-server", + "-m", + "/m/inconnu.gguf", + "-c", + "32768", + "-b", + "2048", + "-ub", + "512", + "--host", + "0.0.0.0", + "--port", + "8080", + "--parallel", + "1", + "-ngl", + "999" + ], + "env": {}, + "notes": null + }, + { + "m": 4, + "c": 66, + "h": 1, + "e": 0, + "args": [ + "/app/llama-server", + "-m", + "/m/hybrid.gguf", + "-c", + "32768", + "-b", + "2048", + "-ub", + "512", + "--host", + "0.0.0.0", + "--port", + "8080", + "--parallel", + "1", + "-ngl", + "999" + ], + "env": { + "CUDA_SCALE_LAUNCH_QUEUES": "4x" + }, + "notes": [ + "CUDA_SCALE_LAUNCH_QUEUES=4x (2 GPU, parallélisme pipeline possible) — CUDA_LAUNCH_QUEUES=off pour s'en passer", + "avertissement : ce llama-server ancien glisse le contexte par défaut — contexte plein, il jette des jetons anciens et le modèle perd une partie de la conversation sans le savoir. --no-context-shift dans EXTRA_ARGS l'en empêche." + ] + }, + { + "m": 0, + "c": 67, + "h": 1, + "e": 0, + "args": [ + "/app/llama-server", + "-m", + "/m/dense.gguf", + "-c", + "32768", + "-b", + "2048", + "-ub", + "512", + "--host", + "0.0.0.0", + "--port", + "8080", + "--parallel", + "1", + "-ngl", + "999" + ], + "env": {}, + "notes": [ + "ce moteur ne connaît pas --ctx-checkpoints : CTX_CHECKPOINTS ignoré", + "avertissement : ce llama-server ancien glisse le contexte par défaut — contexte plein, il jette des jetons anciens et le modèle perd une partie de la conversation sans le savoir. --no-context-shift dans EXTRA_ARGS l'en empêche." + ] + }, + { + "m": 2, + "c": 67, + "h": 2, + "e": 0, + "args": [ + "/app/llama-server", + "-m", + "/m/inconnu.gguf", + "-c", + "32768", + "-b", + "2048", + "-ub", + "512", + "--host", + "0.0.0.0", + "--port", + "8080", + "--parallel", + "1", + "-ngl", + "999" + ], + "env": {}, + "notes": [ + "ce moteur ne connaît pas --ctx-checkpoints : CTX_CHECKPOINTS ignoré" + ] + }, + { + "m": 4, + "c": 67, + "h": 1, + "e": 0, + "args": [ + "/app/llama-server", + "-m", + "/m/hybrid.gguf", + "-c", + "32768", + "-b", + "2048", + "-ub", + "512", + "--host", + "0.0.0.0", + "--port", + "8080", + "--parallel", + "1", + "-ngl", + "999" + ], + "env": { + "CUDA_SCALE_LAUNCH_QUEUES": "4x" + }, + "notes": [ + "CUDA_SCALE_LAUNCH_QUEUES=4x (2 GPU, parallélisme pipeline possible) — CUDA_LAUNCH_QUEUES=off pour s'en passer", + "ce moteur ne connaît pas --ctx-checkpoints : CTX_CHECKPOINTS ignoré", + "avertissement : ce llama-server ancien glisse le contexte par défaut — contexte plein, il jette des jetons anciens et le modèle perd une partie de la conversation sans le savoir. --no-context-shift dans EXTRA_ARGS l'en empêche." + ] + }, + { + "m": 0, + "c": 68, + "h": 1, + "e": 0, + "args": [ + "/app/llama-server", + "-m", + "/m/dense.gguf", + "-c", + "32768", + "-b", + "2048", + "-ub", + "512", + "--host", + "0.0.0.0", + "--port", + "8080", + "--parallel", + "1", + "-ngl", + "999" + ], + "env": {}, + "notes": [ + "CTX_CHECKPOINTS=beaucoup illisible (un nombre de points de reprise par slot, ≥ 0) : ignoré", + "avertissement : ce llama-server ancien glisse le contexte par défaut — contexte plein, il jette des jetons anciens et le modèle perd une partie de la conversation sans le savoir. --no-context-shift dans EXTRA_ARGS l'en empêche." + ] + }, + { + "m": 2, + "c": 68, + "h": 2, + "e": 0, + "args": [ + "/app/llama-server", + "-m", + "/m/inconnu.gguf", + "-c", + "32768", + "-b", + "2048", + "-ub", + "512", + "--host", + "0.0.0.0", + "--port", + "8080", + "--parallel", + "1", + "-ngl", + "999" + ], + "env": {}, + "notes": [ + "CTX_CHECKPOINTS=beaucoup illisible (un nombre de points de reprise par slot, ≥ 0) : ignoré" + ] + }, + { + "m": 4, + "c": 68, + "h": 1, + "e": 0, + "args": [ + "/app/llama-server", + "-m", + "/m/hybrid.gguf", + "-c", + "32768", + "-b", + "2048", + "-ub", + "512", + "--host", + "0.0.0.0", + "--port", + "8080", + "--parallel", + "1", + "-ngl", + "999" + ], + "env": { + "CUDA_SCALE_LAUNCH_QUEUES": "4x" + }, + "notes": [ + "CUDA_SCALE_LAUNCH_QUEUES=4x (2 GPU, parallélisme pipeline possible) — CUDA_LAUNCH_QUEUES=off pour s'en passer", + "CTX_CHECKPOINTS=beaucoup illisible (un nombre de points de reprise par slot, ≥ 0) : ignoré", + "avertissement : ce llama-server ancien glisse le contexte par défaut — contexte plein, il jette des jetons anciens et le modèle perd une partie de la conversation sans le savoir. --no-context-shift dans EXTRA_ARGS l'en empêche." + ] + }, + { + "m": 0, + "c": 69, + "h": 1, + "e": 0, + "args": [ + "/app/llama-server", + "-m", + "/m/dense.gguf", + "-c", + "32768", + "-b", + "2048", + "-ub", + "512", + "--host", + "0.0.0.0", + "--port", + "8080", + "--parallel", + "1", + "-ngl", + "999" + ], + "env": {}, + "notes": [ + "ce moteur ne connaît pas --checkpoint-min-step : CKPT_MIN_STEP ignoré", + "avertissement : ce llama-server ancien glisse le contexte par défaut — contexte plein, il jette des jetons anciens et le modèle perd une partie de la conversation sans le savoir. --no-context-shift dans EXTRA_ARGS l'en empêche." + ] + }, + { + "m": 2, + "c": 69, + "h": 2, + "e": 0, + "args": [ + "/app/llama-server", + "-m", + "/m/inconnu.gguf", + "-c", + "32768", + "-b", + "2048", + "-ub", + "512", + "--host", + "0.0.0.0", + "--port", + "8080", + "--parallel", + "1", + "-ngl", + "999" + ], + "env": {}, + "notes": [ + "ce moteur ne connaît pas --checkpoint-min-step : CKPT_MIN_STEP ignoré" + ] + }, + { + "m": 4, + "c": 69, + "h": 1, + "e": 0, + "args": [ + "/app/llama-server", + "-m", + "/m/hybrid.gguf", + "-c", + "32768", + "-b", + "2048", + "-ub", + "512", + "--host", + "0.0.0.0", + "--port", + "8080", + "--parallel", + "1", + "-ngl", + "999" + ], + "env": { + "CUDA_SCALE_LAUNCH_QUEUES": "4x" + }, + "notes": [ + "CUDA_SCALE_LAUNCH_QUEUES=4x (2 GPU, parallélisme pipeline possible) — CUDA_LAUNCH_QUEUES=off pour s'en passer", + "ce moteur ne connaît pas --checkpoint-min-step : CKPT_MIN_STEP ignoré", + "avertissement : ce llama-server ancien glisse le contexte par défaut — contexte plein, il jette des jetons anciens et le modèle perd une partie de la conversation sans le savoir. --no-context-shift dans EXTRA_ARGS l'en empêche." + ] + }, + { + "m": 0, + "c": 70, + "h": 1, + "e": 0, + "args": [ + "/app/llama-server", + "-m", + "/m/dense.gguf", + "-c", + "32768", + "-b", + "2048", + "-ub", + "512", + "--host", + "0.0.0.0", + "--port", + "8080", + "--parallel", + "1", + "-ngl", + "999" + ], + "env": {}, + "notes": [ + "CKPT_MIN_STEP=0 refusé (un nombre de jetons > 0 ; 0 laisserait le moteur jeter le point de reprise du début de la conversation) : défaut du moteur", + "avertissement : ce llama-server ancien glisse le contexte par défaut — contexte plein, il jette des jetons anciens et le modèle perd une partie de la conversation sans le savoir. --no-context-shift dans EXTRA_ARGS l'en empêche." + ] + }, + { + "m": 2, + "c": 70, + "h": 2, + "e": 0, + "args": [ + "/app/llama-server", + "-m", + "/m/inconnu.gguf", + "-c", + "32768", + "-b", + "2048", + "-ub", + "512", + "--host", + "0.0.0.0", + "--port", + "8080", + "--parallel", + "1", + "-ngl", + "999" + ], + "env": {}, + "notes": [ + "CKPT_MIN_STEP=0 refusé (un nombre de jetons > 0 ; 0 laisserait le moteur jeter le point de reprise du début de la conversation) : défaut du moteur" + ] + }, + { + "m": 4, + "c": 70, + "h": 1, + "e": 0, + "args": [ + "/app/llama-server", + "-m", + "/m/hybrid.gguf", + "-c", + "32768", + "-b", + "2048", + "-ub", + "512", + "--host", + "0.0.0.0", + "--port", + "8080", + "--parallel", + "1", + "-ngl", + "999" + ], + "env": { + "CUDA_SCALE_LAUNCH_QUEUES": "4x" + }, + "notes": [ + "CUDA_SCALE_LAUNCH_QUEUES=4x (2 GPU, parallélisme pipeline possible) — CUDA_LAUNCH_QUEUES=off pour s'en passer", + "CKPT_MIN_STEP=0 refusé (un nombre de jetons > 0 ; 0 laisserait le moteur jeter le point de reprise du début de la conversation) : défaut du moteur", + "avertissement : ce llama-server ancien glisse le contexte par défaut — contexte plein, il jette des jetons anciens et le modèle perd une partie de la conversation sans le savoir. --no-context-shift dans EXTRA_ARGS l'en empêche." + ] + }, + { + "m": 0, + "c": 71, + "h": 1, + "e": 0, + "args": [ + "/app/llama-server", + "-m", + "/m/dense.gguf", + "-c", + "32768", + "-b", + "2048", + "-ub", + "512", + "--host", + "0.0.0.0", + "--port", + "8080", + "--parallel", + "1", + "-ngl", + "999" + ], + "env": {}, + "notes": [ + "SLOT_PERSIST refusé (ce moteur ne connaît pas --slot-save-path) : l'état du slot ne sera pas gardé à la bascule de preset", + "avertissement : ce llama-server ancien glisse le contexte par défaut — contexte plein, il jette des jetons anciens et le modèle perd une partie de la conversation sans le savoir. --no-context-shift dans EXTRA_ARGS l'en empêche." + ] + }, + { + "m": 2, + "c": 71, + "h": 2, + "e": 0, + "args": [ + "/app/llama-server", + "-m", + "/m/inconnu.gguf", + "-c", + "32768", + "-b", + "2048", + "-ub", + "512", + "--host", + "0.0.0.0", + "--port", + "8080", + "--parallel", + "1", + "-ngl", + "999" + ], + "env": {}, + "notes": [ + "SLOT_PERSIST refusé (ce moteur ne connaît pas --slot-save-path) : l'état du slot ne sera pas gardé à la bascule de preset" + ] + }, + { + "m": 4, + "c": 71, + "h": 1, + "e": 0, + "args": [ + "/app/llama-server", + "-m", + "/m/hybrid.gguf", + "-c", + "32768", + "-b", + "2048", + "-ub", + "512", + "--host", + "0.0.0.0", + "--port", + "8080", + "--parallel", + "1", + "-ngl", + "999" + ], + "env": { + "CUDA_SCALE_LAUNCH_QUEUES": "4x" + }, + "notes": [ + "CUDA_SCALE_LAUNCH_QUEUES=4x (2 GPU, parallélisme pipeline possible) — CUDA_LAUNCH_QUEUES=off pour s'en passer", + "SLOT_PERSIST refusé (ce moteur ne connaît pas --slot-save-path) : l'état du slot ne sera pas gardé à la bascule de preset", + "avertissement : ce llama-server ancien glisse le contexte par défaut — contexte plein, il jette des jetons anciens et le modèle perd une partie de la conversation sans le savoir. --no-context-shift dans EXTRA_ARGS l'en empêche." + ] + }, + { + "m": 0, + "c": 72, + "h": 1, + "e": 0, + "args": [ + "/app/llama-server", + "-m", + "/m/dense.gguf", + "-c", + "32768", + "-b", + "2048", + "-ub", + "512", + "--host", + "127.0.0.1", + "--port", + "8080", + "--parallel", + "1", + "-ngl", + "999" + ], + "env": {}, + "notes": [ + "SLOT_PERSIST refusé (ce moteur ne connaît pas --slot-save-path) : l'état du slot ne sera pas gardé à la bascule de preset", + "avertissement : ce llama-server ancien glisse le contexte par défaut — contexte plein, il jette des jetons anciens et le modèle perd une partie de la conversation sans le savoir. --no-context-shift dans EXTRA_ARGS l'en empêche." + ] + }, + { + "m": 2, + "c": 72, + "h": 2, + "e": 0, + "args": [ + "/app/llama-server", + "-m", + "/m/inconnu.gguf", + "-c", + "32768", + "-b", + "2048", + "-ub", + "512", + "--host", + "127.0.0.1", + "--port", + "8080", + "--parallel", + "1", + "-ngl", + "999" + ], + "env": {}, + "notes": [ + "SLOT_PERSIST refusé (ce moteur ne connaît pas --slot-save-path) : l'état du slot ne sera pas gardé à la bascule de preset" + ] + }, + { + "m": 4, + "c": 72, + "h": 1, + "e": 0, + "args": [ + "/app/llama-server", + "-m", + "/m/hybrid.gguf", + "-c", + "32768", + "-b", + "2048", + "-ub", + "512", + "--host", + "127.0.0.1", + "--port", + "8080", + "--parallel", + "1", + "-ngl", + "999" + ], + "env": { + "CUDA_SCALE_LAUNCH_QUEUES": "4x" + }, + "notes": [ + "CUDA_SCALE_LAUNCH_QUEUES=4x (2 GPU, parallélisme pipeline possible) — CUDA_LAUNCH_QUEUES=off pour s'en passer", + "SLOT_PERSIST refusé (ce moteur ne connaît pas --slot-save-path) : l'état du slot ne sera pas gardé à la bascule de preset", + "avertissement : ce llama-server ancien glisse le contexte par défaut — contexte plein, il jette des jetons anciens et le modèle perd une partie de la conversation sans le savoir. --no-context-shift dans EXTRA_ARGS l'en empêche." + ] + }, + { + "m": 0, + "c": 73, + "h": 1, + "e": 0, + "args": [ + "/app/llama-server", + "-m", + "/m/dense.gguf", + "-c", + "32768", + "-b", + "2048", + "-ub", + "512", + "--host", + "127.0.0.1", + "--port", + "8080", + "--parallel", + "1", + "-ngl", + "999" + ], + "env": {}, + "notes": [ + "SLOT_PERSIST refusé (ce moteur ne connaît pas --slot-save-path) : l'état du slot ne sera pas gardé à la bascule de preset", + "SPEC=mtp : pas de tête MTP dans ce fichier (publiée à part ? MODEL_DRAFT=mtp-….gguf) — sans décodage spéculatif", + "avertissement : ce llama-server ancien glisse le contexte par défaut — contexte plein, il jette des jetons anciens et le modèle perd une partie de la conversation sans le savoir. --no-context-shift dans EXTRA_ARGS l'en empêche." + ] + }, + { + "m": 2, + "c": 73, + "h": 2, + "e": 0, + "args": [ + "/app/llama-server", + "-m", + "/m/inconnu.gguf", + "-c", + "32768", + "-b", + "2048", + "-ub", + "512", + "--host", + "127.0.0.1", + "--port", + "8080", + "--parallel", + "1", + "-ngl", + "999" + ], + "env": {}, + "notes": [ + "SLOT_PERSIST refusé (ce moteur ne connaît pas --slot-save-path) : l'état du slot ne sera pas gardé à la bascule de preset", + "SPEC=mtp : métadonnées du modèle illisibles — sans décodage spéculatif" + ] + }, + { + "m": 4, + "c": 73, + "h": 1, + "e": 0, + "args": [ + "/app/llama-server", + "-m", + "/m/hybrid.gguf", + "-c", + "32768", + "-b", + "2048", + "-ub", + "512", + "--host", + "127.0.0.1", + "--port", + "8080", + "--parallel", + "1", + "-ngl", + "999" + ], + "env": { + "CUDA_SCALE_LAUNCH_QUEUES": "4x" + }, + "notes": [ + "CUDA_SCALE_LAUNCH_QUEUES=4x (2 GPU, parallélisme pipeline possible) — CUDA_LAUNCH_QUEUES=off pour s'en passer", + "SLOT_PERSIST refusé (ce moteur ne connaît pas --slot-save-path) : l'état du slot ne sera pas gardé à la bascule de preset", + "SPEC=mtp : ce moteur ne connaît pas draft-mtp — sans décodage spéculatif", + "avertissement : ce llama-server ancien glisse le contexte par défaut — contexte plein, il jette des jetons anciens et le modèle perd une partie de la conversation sans le savoir. --no-context-shift dans EXTRA_ARGS l'en empêche." + ] + }, + { + "m": 0, + "c": 74, + "h": 1, + "e": 0, + "args": [ + "/app/llama-server", + "-m", + "/m/dense.gguf", + "-c", + "32768", + "-b", + "2048", + "-ub", + "512", + "--host", + "0.0.0.0", + "--port", + "8080", + "--parallel", + "1", + "-ngl", + "999", + "--no-mmap" + ], + "env": {}, + "notes": [ + "avertissement : ce llama-server ancien glisse le contexte par défaut — contexte plein, il jette des jetons anciens et le modèle perd une partie de la conversation sans le savoir. --no-context-shift dans EXTRA_ARGS l'en empêche." + ] + }, + { + "m": 2, + "c": 74, + "h": 2, + "e": 0, + "args": [ + "/app/llama-server", + "-m", + "/m/inconnu.gguf", + "-c", + "32768", + "-b", + "2048", + "-ub", + "512", + "--host", + "0.0.0.0", + "--port", + "8080", + "--parallel", + "1", + "-ngl", + "999", + "--no-mmap" + ], + "env": {}, + "notes": null + }, + { + "m": 4, + "c": 74, + "h": 1, + "e": 0, + "args": [ + "/app/llama-server", + "-m", + "/m/hybrid.gguf", + "-c", + "32768", + "-b", + "2048", + "-ub", + "512", + "--host", + "0.0.0.0", + "--port", + "8080", + "--parallel", + "1", + "-ngl", + "999", + "--no-mmap" + ], + "env": { + "CUDA_SCALE_LAUNCH_QUEUES": "4x" + }, + "notes": [ + "CUDA_SCALE_LAUNCH_QUEUES=4x (2 GPU, parallélisme pipeline possible) — CUDA_LAUNCH_QUEUES=off pour s'en passer", + "avertissement : ce llama-server ancien glisse le contexte par défaut — contexte plein, il jette des jetons anciens et le modèle perd une partie de la conversation sans le savoir. --no-context-shift dans EXTRA_ARGS l'en empêche." + ] + }, + { + "m": 0, + "c": 75, + "h": 1, + "e": 0, + "args": [ + "/app/llama-server", + "-m", + "/m/dense.gguf", + "-c", + "32768", + "-b", + "2048", + "-ub", + "512", + "--host", + "0.0.0.0", + "--port", + "8080", + "--parallel", + "1", + "-ngl", + "999", + "--mlock", + "--no-mmap" + ], + "env": {}, + "notes": [ + "avertissement : ce llama-server ancien glisse le contexte par défaut — contexte plein, il jette des jetons anciens et le modèle perd une partie de la conversation sans le savoir. --no-context-shift dans EXTRA_ARGS l'en empêche." + ] + }, + { + "m": 2, + "c": 75, + "h": 2, + "e": 0, + "args": [ + "/app/llama-server", + "-m", + "/m/inconnu.gguf", + "-c", + "32768", + "-b", + "2048", + "-ub", + "512", + "--host", + "0.0.0.0", + "--port", + "8080", + "--parallel", + "1", + "-ngl", + "999", + "--mlock", + "--no-mmap" + ], + "env": {}, + "notes": null + }, + { + "m": 4, + "c": 75, + "h": 1, + "e": 0, + "args": [ + "/app/llama-server", + "-m", + "/m/hybrid.gguf", + "-c", + "32768", + "-b", + "2048", + "-ub", + "512", + "--host", + "0.0.0.0", + "--port", + "8080", + "--parallel", + "1", + "-ngl", + "999", + "--mlock", + "--no-mmap" + ], + "env": { + "CUDA_SCALE_LAUNCH_QUEUES": "4x" + }, + "notes": [ + "CUDA_SCALE_LAUNCH_QUEUES=4x (2 GPU, parallélisme pipeline possible) — CUDA_LAUNCH_QUEUES=off pour s'en passer", + "avertissement : ce llama-server ancien glisse le contexte par défaut — contexte plein, il jette des jetons anciens et le modèle perd une partie de la conversation sans le savoir. --no-context-shift dans EXTRA_ARGS l'en empêche." + ] + }, + { + "m": 0, + "c": 76, + "h": 1, + "e": 0, + "args": [ + "/app/llama-server", + "-m", + "/m/dense.gguf", + "-c", + "32768", + "-b", + "2048", + "-ub", + "512", + "--host", + "0.0.0.0", + "--port", + "8080", + "--parallel", + "1", + "-ngl", + "999", + "--mlock" + ], + "env": {}, + "notes": [ + "avertissement : ce llama-server ancien glisse le contexte par défaut — contexte plein, il jette des jetons anciens et le modèle perd une partie de la conversation sans le savoir. --no-context-shift dans EXTRA_ARGS l'en empêche." + ] + }, + { + "m": 2, + "c": 76, + "h": 2, + "e": 0, + "args": [ + "/app/llama-server", + "-m", + "/m/inconnu.gguf", + "-c", + "32768", + "-b", + "2048", + "-ub", + "512", + "--host", + "0.0.0.0", + "--port", + "8080", + "--parallel", + "1", + "-ngl", + "999", + "--mlock" + ], + "env": {}, + "notes": null + }, + { + "m": 4, + "c": 76, + "h": 1, + "e": 0, + "args": [ + "/app/llama-server", + "-m", + "/m/hybrid.gguf", + "-c", + "32768", + "-b", + "2048", + "-ub", + "512", + "--host", + "0.0.0.0", + "--port", + "8080", + "--parallel", + "1", + "-ngl", + "999", + "--mlock" + ], + "env": { + "CUDA_SCALE_LAUNCH_QUEUES": "4x" + }, + "notes": [ + "CUDA_SCALE_LAUNCH_QUEUES=4x (2 GPU, parallélisme pipeline possible) — CUDA_LAUNCH_QUEUES=off pour s'en passer", + "avertissement : ce llama-server ancien glisse le contexte par défaut — contexte plein, il jette des jetons anciens et le modèle perd une partie de la conversation sans le savoir. --no-context-shift dans EXTRA_ARGS l'en empêche." + ] + }, + { + "m": 0, + "c": 77, + "h": 1, + "e": 0, + "args": [ + "/app/llama-server", + "-m", + "/m/dense.gguf", + "-c", + "32768", + "-b", + "2048", + "-ub", + "512", + "--host", + "0.0.0.0", + "--port", + "8080", + "--parallel", + "1", + "-ngl", + "999", + "--no-mmap" + ], + "env": {}, + "notes": [ + "avertissement : ce llama-server ancien glisse le contexte par défaut — contexte plein, il jette des jetons anciens et le modèle perd une partie de la conversation sans le savoir. --no-context-shift dans EXTRA_ARGS l'en empêche." + ] + }, + { + "m": 2, + "c": 77, + "h": 2, + "e": 0, + "args": [ + "/app/llama-server", + "-m", + "/m/inconnu.gguf", + "-c", + "32768", + "-b", + "2048", + "-ub", + "512", + "--host", + "0.0.0.0", + "--port", + "8080", + "--parallel", + "1", + "-ngl", + "999", + "--no-mmap" + ], + "env": {}, + "notes": null + }, + { + "m": 4, + "c": 77, + "h": 1, + "e": 0, + "args": [ + "/app/llama-server", + "-m", + "/m/hybrid.gguf", + "-c", + "32768", + "-b", + "2048", + "-ub", + "512", + "--host", + "0.0.0.0", + "--port", + "8080", + "--parallel", + "1", + "-ngl", + "999", + "--no-mmap" + ], + "env": { + "CUDA_SCALE_LAUNCH_QUEUES": "4x" + }, + "notes": [ + "CUDA_SCALE_LAUNCH_QUEUES=4x (2 GPU, parallélisme pipeline possible) — CUDA_LAUNCH_QUEUES=off pour s'en passer", + "avertissement : ce llama-server ancien glisse le contexte par défaut — contexte plein, il jette des jetons anciens et le modèle perd une partie de la conversation sans le savoir. --no-context-shift dans EXTRA_ARGS l'en empêche." + ] + }, + { + "m": 0, + "c": 78, + "h": 1, + "e": 0, + "args": [ + "/app/llama-server", + "-m", + "/m/dense.gguf", + "-c", + "32768", + "-b", + "2048", + "-ub", + "512", + "--host", + "0.0.0.0", + "--port", + "8080", + "--parallel", + "1", + "-ngl", + "999", + "--mmap" + ], + "env": {}, + "notes": [ + "avertissement : ce llama-server ancien glisse le contexte par défaut — contexte plein, il jette des jetons anciens et le modèle perd une partie de la conversation sans le savoir. --no-context-shift dans EXTRA_ARGS l'en empêche." + ] + }, + { + "m": 2, + "c": 78, + "h": 2, + "e": 0, + "args": [ + "/app/llama-server", + "-m", + "/m/inconnu.gguf", + "-c", + "32768", + "-b", + "2048", + "-ub", + "512", + "--host", + "0.0.0.0", + "--port", + "8080", + "--parallel", + "1", + "-ngl", + "999", + "--mmap" + ], + "env": {}, + "notes": null + }, + { + "m": 4, + "c": 78, + "h": 1, + "e": 0, + "args": [ + "/app/llama-server", + "-m", + "/m/hybrid.gguf", + "-c", + "32768", + "-b", + "2048", + "-ub", + "512", + "--host", + "0.0.0.0", + "--port", + "8080", + "--parallel", + "1", + "-ngl", + "999", + "--mmap" + ], + "env": { + "CUDA_SCALE_LAUNCH_QUEUES": "4x" + }, + "notes": [ + "CUDA_SCALE_LAUNCH_QUEUES=4x (2 GPU, parallélisme pipeline possible) — CUDA_LAUNCH_QUEUES=off pour s'en passer", + "avertissement : ce llama-server ancien glisse le contexte par défaut — contexte plein, il jette des jetons anciens et le modèle perd une partie de la conversation sans le savoir. --no-context-shift dans EXTRA_ARGS l'en empêche." + ] + }, + { + "m": 0, + "c": 79, + "h": 1, + "e": 0, + "args": [ + "/app/llama-server", + "-m", + "/m/dense.gguf", + "-c", + "32768", + "-b", + "2048", + "-ub", + "512", + "--host", + "0.0.0.0", + "--port", + "8080", + "--parallel", + "1", + "-ngl", + "999" + ], + "env": {}, + "notes": [ + "ce moteur ne connaît pas --load-mode dio (DirectIO) : chargement par défaut", + "avertissement : ce llama-server ancien glisse le contexte par défaut — contexte plein, il jette des jetons anciens et le modèle perd une partie de la conversation sans le savoir. --no-context-shift dans EXTRA_ARGS l'en empêche." + ] + }, + { + "m": 2, + "c": 79, + "h": 2, + "e": 0, + "args": [ + "/app/llama-server", + "-m", + "/m/inconnu.gguf", + "-c", + "32768", + "-b", + "2048", + "-ub", + "512", + "--host", + "0.0.0.0", + "--port", + "8080", + "--parallel", + "1", + "-ngl", + "999" + ], + "env": {}, + "notes": [ + "ce moteur ne connaît pas --load-mode dio (DirectIO) : chargement par défaut" + ] + }, + { + "m": 4, + "c": 79, + "h": 1, + "e": 0, + "args": [ + "/app/llama-server", + "-m", + "/m/hybrid.gguf", + "-c", + "32768", + "-b", + "2048", + "-ub", + "512", + "--host", + "0.0.0.0", + "--port", + "8080", + "--parallel", + "1", + "-ngl", + "999" + ], + "env": { + "CUDA_SCALE_LAUNCH_QUEUES": "4x" + }, + "notes": [ + "ce moteur ne connaît pas --load-mode dio (DirectIO) : chargement par défaut", + "CUDA_SCALE_LAUNCH_QUEUES=4x (2 GPU, parallélisme pipeline possible) — CUDA_LAUNCH_QUEUES=off pour s'en passer", + "avertissement : ce llama-server ancien glisse le contexte par défaut — contexte plein, il jette des jetons anciens et le modèle perd une partie de la conversation sans le savoir. --no-context-shift dans EXTRA_ARGS l'en empêche." + ] + }, + { + "m": 0, + "c": 80, + "h": 1, + "e": 0, + "args": [ + "/app/llama-server", + "-m", + "/m/dense.gguf", + "-c", + "32768", + "-b", + "2048", + "-ub", + "512", + "--host", + "0.0.0.0", + "--port", + "8080", + "--parallel", + "1", + "-ngl", + "999", + "--mlock", + "--no-mmap" + ], + "env": {}, + "notes": [ + "avertissement : ce llama-server ancien glisse le contexte par défaut — contexte plein, il jette des jetons anciens et le modèle perd une partie de la conversation sans le savoir. --no-context-shift dans EXTRA_ARGS l'en empêche." + ] + }, + { + "m": 2, + "c": 80, + "h": 2, + "e": 0, + "args": [ + "/app/llama-server", + "-m", + "/m/inconnu.gguf", + "-c", + "32768", + "-b", + "2048", + "-ub", + "512", + "--host", + "0.0.0.0", + "--port", + "8080", + "--parallel", + "1", + "-ngl", + "999", + "--mlock", + "--no-mmap" + ], + "env": {}, + "notes": null + }, + { + "m": 4, + "c": 80, + "h": 1, + "e": 0, + "args": [ + "/app/llama-server", + "-m", + "/m/hybrid.gguf", + "-c", + "32768", + "-b", + "2048", + "-ub", + "512", + "--host", + "0.0.0.0", + "--port", + "8080", + "--parallel", + "1", + "-ngl", + "999", + "--mlock", + "--no-mmap" + ], + "env": { + "CUDA_SCALE_LAUNCH_QUEUES": "4x" + }, + "notes": [ + "CUDA_SCALE_LAUNCH_QUEUES=4x (2 GPU, parallélisme pipeline possible) — CUDA_LAUNCH_QUEUES=off pour s'en passer", + "avertissement : ce llama-server ancien glisse le contexte par défaut — contexte plein, il jette des jetons anciens et le modèle perd une partie de la conversation sans le savoir. --no-context-shift dans EXTRA_ARGS l'en empêche." + ] + }, + { + "m": 0, + "c": 81, + "h": 1, + "e": 0, + "args": [ + "/app/llama-server", + "-m", + "/m/dense.gguf", + "-c", + "32768", + "-b", + "2048", + "-ub", + "512", + "--host", + "0.0.0.0", + "--port", + "8080", + "-ngl", + "30", + "--parallel", + "4" + ], + "env": {}, + "notes": [ + "avertissement : ce llama-server ancien glisse le contexte par défaut — contexte plein, il jette des jetons anciens et le modèle perd une partie de la conversation sans le savoir. --no-context-shift dans EXTRA_ARGS l'en empêche." + ] + }, + { + "m": 2, + "c": 81, + "h": 2, + "e": 0, + "args": [ + "/app/llama-server", + "-m", + "/m/inconnu.gguf", + "-c", + "32768", + "-b", + "2048", + "-ub", + "512", + "--host", + "0.0.0.0", + "--port", + "8080", + "-ngl", + "30", + "--parallel", + "4" + ], + "env": {}, + "notes": null + }, + { + "m": 4, + "c": 81, + "h": 1, + "e": 0, + "args": [ + "/app/llama-server", + "-m", + "/m/hybrid.gguf", + "-c", + "32768", + "-b", + "2048", + "-ub", + "512", + "--host", + "0.0.0.0", + "--port", + "8080", + "-ngl", + "30", + "--parallel", + "4" + ], + "env": {}, + "notes": [ + "avertissement : ce llama-server ancien glisse le contexte par défaut — contexte plein, il jette des jetons anciens et le modèle perd une partie de la conversation sans le savoir. --no-context-shift dans EXTRA_ARGS l'en empêche." + ] + }, + { + "m": 0, + "c": 82, + "h": 1, + "e": 0, + "args": [ + "/app/llama-server", + "-m", + "/m/dense.gguf", + "-c", + "32768", + "-b", + "2048", + "-ub", + "512", + "--host", + "0.0.0.0", + "--port", + "8080", + "--parallel", + "1", + "-ngl", + "999", + "-t", + "6", + "-tb", + "3" + ], + "env": {}, + "notes": [ + "avertissement : ce llama-server ancien glisse le contexte par défaut — contexte plein, il jette des jetons anciens et le modèle perd une partie de la conversation sans le savoir. --no-context-shift dans EXTRA_ARGS l'en empêche." + ] + }, + { + "m": 2, + "c": 82, + "h": 2, + "e": 0, + "args": [ + "/app/llama-server", + "-m", + "/m/inconnu.gguf", + "-c", + "32768", + "-b", + "2048", + "-ub", + "512", + "--host", + "0.0.0.0", + "--port", + "8080", + "--parallel", + "1", + "-ngl", + "999", + "-t", + "6", + "-tb", + "3" + ], + "env": {}, + "notes": null + }, + { + "m": 4, + "c": 82, + "h": 1, + "e": 0, + "args": [ + "/app/llama-server", + "-m", + "/m/hybrid.gguf", + "-c", + "32768", + "-b", + "2048", + "-ub", + "512", + "--host", + "0.0.0.0", + "--port", + "8080", + "--parallel", + "1", + "-ngl", + "999", + "-t", + "6", + "-tb", + "3" + ], + "env": { + "CUDA_SCALE_LAUNCH_QUEUES": "4x" + }, + "notes": [ + "CUDA_SCALE_LAUNCH_QUEUES=4x (2 GPU, parallélisme pipeline possible) — CUDA_LAUNCH_QUEUES=off pour s'en passer", + "avertissement : ce llama-server ancien glisse le contexte par défaut — contexte plein, il jette des jetons anciens et le modèle perd une partie de la conversation sans le savoir. --no-context-shift dans EXTRA_ARGS l'en empêche." + ] + }, + { + "m": 0, + "c": 83, + "h": 1, + "e": 0, + "args": [ + "/app/llama-server", + "-m", + "/m/dense.gguf", + "-c", + "32768", + "-tb", + "0", + "-b", + "2048", + "-ub", + "512", + "--host", + "0.0.0.0", + "--port", + "8080", + "--parallel", + "1", + "-ngl", + "999", + "-Cb", + "0xff", + "--poll-batch", + "1" + ], + "env": {}, + "notes": [ + "EXTRA_ARGS règle le CPU du batch (-Cb, --poll-batch…) : -tb 0 posé pour que llama.cpp le garde (tous les threads logiques) ; THREADS_BATCH pour choisir le nombre", + "avertissement : ce llama-server ancien glisse le contexte par défaut — contexte plein, il jette des jetons anciens et le modèle perd une partie de la conversation sans le savoir. --no-context-shift dans EXTRA_ARGS l'en empêche." + ] + }, + { + "m": 2, + "c": 83, + "h": 2, + "e": 0, + "args": [ + "/app/llama-server", + "-m", + "/m/inconnu.gguf", + "-c", + "32768", + "-tb", + "0", + "-b", + "2048", + "-ub", + "512", + "--host", + "0.0.0.0", + "--port", + "8080", + "--parallel", + "1", + "-ngl", + "999", + "-Cb", + "0xff", + "--poll-batch", + "1" + ], + "env": {}, + "notes": [ + "EXTRA_ARGS règle le CPU du batch (-Cb, --poll-batch…) : -tb 0 posé pour que llama.cpp le garde (tous les threads logiques) ; THREADS_BATCH pour choisir le nombre" + ] + }, + { + "m": 4, + "c": 83, + "h": 1, + "e": 0, + "args": [ + "/app/llama-server", + "-m", + "/m/hybrid.gguf", + "-c", + "32768", + "-tb", + "0", + "-b", + "2048", + "-ub", + "512", + "--host", + "0.0.0.0", + "--port", + "8080", + "--parallel", + "1", + "-ngl", + "999", + "-Cb", + "0xff", + "--poll-batch", + "1" + ], + "env": { + "CUDA_SCALE_LAUNCH_QUEUES": "4x" + }, + "notes": [ + "CUDA_SCALE_LAUNCH_QUEUES=4x (2 GPU, parallélisme pipeline possible) — CUDA_LAUNCH_QUEUES=off pour s'en passer", + "EXTRA_ARGS règle le CPU du batch (-Cb, --poll-batch…) : -tb 0 posé pour que llama.cpp le garde (tous les threads logiques) ; THREADS_BATCH pour choisir le nombre", + "avertissement : ce llama-server ancien glisse le contexte par défaut — contexte plein, il jette des jetons anciens et le modèle perd une partie de la conversation sans le savoir. --no-context-shift dans EXTRA_ARGS l'en empêche." + ] + }, + { + "m": 0, + "c": 84, + "h": 1, + "e": 0, + "args": [ + "/app/llama-server", + "-m", + "/m/dense.gguf", + "-c", + "32768", + "-t", + "6", + "-tb", + "6", + "-b", + "2048", + "-ub", + "512", + "--host", + "0.0.0.0", + "--port", + "8080", + "--parallel", + "1", + "-ngl", + "999", + "--prio-batch", + "2" + ], + "env": {}, + "notes": [ + "avertissement : ce llama-server ancien glisse le contexte par défaut — contexte plein, il jette des jetons anciens et le modèle perd une partie de la conversation sans le savoir. --no-context-shift dans EXTRA_ARGS l'en empêche." + ] + }, + { + "m": 2, + "c": 84, + "h": 2, + "e": 0, + "args": [ + "/app/llama-server", + "-m", + "/m/inconnu.gguf", + "-c", + "32768", + "-t", + "6", + "-tb", + "6", + "-b", + "2048", + "-ub", + "512", + "--host", + "0.0.0.0", + "--port", + "8080", + "--parallel", + "1", + "-ngl", + "999", + "--prio-batch", + "2" + ], + "env": {}, + "notes": null + }, + { + "m": 4, + "c": 84, + "h": 1, + "e": 0, + "args": [ + "/app/llama-server", + "-m", + "/m/hybrid.gguf", + "-c", + "32768", + "-t", + "6", + "-tb", + "6", + "-b", + "2048", + "-ub", + "512", + "--host", + "0.0.0.0", + "--port", + "8080", + "--parallel", + "1", + "-ngl", + "999", + "--prio-batch", + "2" + ], + "env": { + "CUDA_SCALE_LAUNCH_QUEUES": "4x" + }, + "notes": [ + "CUDA_SCALE_LAUNCH_QUEUES=4x (2 GPU, parallélisme pipeline possible) — CUDA_LAUNCH_QUEUES=off pour s'en passer", + "avertissement : ce llama-server ancien glisse le contexte par défaut — contexte plein, il jette des jetons anciens et le modèle perd une partie de la conversation sans le savoir. --no-context-shift dans EXTRA_ARGS l'en empêche." + ] + }, + { + "m": 0, + "c": 85, + "h": 1, + "e": 0, + "args": [ + "/app/llama-server", + "-m", + "/m/dense.gguf", + "-c", + "32768", + "-b", + "2048", + "-ub", + "512", + "--host", + "0.0.0.0", + "--port", + "8080", + "--parallel", + "1", + "-ngl", + "999", + "--cache-reuse", + "256", + "--context-shift" + ], + "env": {}, + "notes": [ + "avertissement : --context-shift (EXTRA_ARGS) — contexte plein, le moteur jette des jetons anciens et le modèle perd une partie de la conversation sans le savoir.", + "avertissement : --cache-reuse 256 (EXTRA_ARGS) — réutilise des morceaux de cache calculés sous un autre préfixe : les sorties ne sont plus exactement celles d'un calcul complet." + ] + }, + { + "m": 2, + "c": 85, + "h": 2, + "e": 0, + "args": [ + "/app/llama-server", + "-m", + "/m/inconnu.gguf", + "-c", + "32768", + "-b", + "2048", + "-ub", + "512", + "--host", + "0.0.0.0", + "--port", + "8080", + "--parallel", + "1", + "-ngl", + "999", + "--cache-reuse", + "256", + "--context-shift" + ], + "env": {}, + "notes": [ + "avertissement : --context-shift (EXTRA_ARGS) — contexte plein, le moteur jette des jetons anciens et le modèle perd une partie de la conversation sans le savoir.", + "avertissement : --cache-reuse 256 (EXTRA_ARGS) — réutilise des morceaux de cache calculés sous un autre préfixe : les sorties ne sont plus exactement celles d'un calcul complet." + ] + }, + { + "m": 4, + "c": 85, + "h": 1, + "e": 0, + "args": [ + "/app/llama-server", + "-m", + "/m/hybrid.gguf", + "-c", + "32768", + "-b", + "2048", + "-ub", + "512", + "--host", + "0.0.0.0", + "--port", + "8080", + "--parallel", + "1", + "-ngl", + "999", + "--cache-reuse", + "256", + "--context-shift" + ], + "env": { + "CUDA_SCALE_LAUNCH_QUEUES": "4x" + }, + "notes": [ + "CUDA_SCALE_LAUNCH_QUEUES=4x (2 GPU, parallélisme pipeline possible) — CUDA_LAUNCH_QUEUES=off pour s'en passer", + "avertissement : --context-shift (EXTRA_ARGS) — contexte plein, le moteur jette des jetons anciens et le modèle perd une partie de la conversation sans le savoir.", + "avertissement : --cache-reuse 256 (EXTRA_ARGS) — réutilise des morceaux de cache calculés sous un autre préfixe : les sorties ne sont plus exactement celles d'un calcul complet." + ] + }, + { + "m": 0, + "c": 86, + "h": 1, + "e": 0, + "args": [ + "/app/llama-server", + "-m", + "/m/dense.gguf", + "-c", + "32768", + "-b", + "2048", + "-ub", + "512", + "--host", + "0.0.0.0", + "--port", + "8080", + "--parallel", + "1", + "-ngl", + "999", + "-ctk", + "q8_0", + "-ctv", + "q8_0" + ], + "env": {}, + "notes": [ + "cache KV q8_0/q8_0 (défini par EXTRA_ARGS) : modifie légèrement les sorties par rapport à f16 — choix de l'utilisateur, laissé tel quel.", + "avertissement : ce llama-server ancien glisse le contexte par défaut — contexte plein, il jette des jetons anciens et le modèle perd une partie de la conversation sans le savoir. --no-context-shift dans EXTRA_ARGS l'en empêche." + ] + }, + { + "m": 2, + "c": 86, + "h": 2, + "e": 0, + "args": [ + "/app/llama-server", + "-m", + "/m/inconnu.gguf", + "-c", + "32768", + "-b", + "2048", + "-ub", + "512", + "--host", + "0.0.0.0", + "--port", + "8080", + "--parallel", + "1", + "-ngl", + "999", + "-ctk", + "q8_0", + "-ctv", + "q8_0" + ], + "env": {}, + "notes": [ + "cache KV q8_0/q8_0 (défini par EXTRA_ARGS) : modifie légèrement les sorties par rapport à f16 — choix de l'utilisateur, laissé tel quel." + ] + }, + { + "m": 4, + "c": 86, + "h": 1, + "e": 0, + "args": [ + "/app/llama-server", + "-m", + "/m/hybrid.gguf", + "-c", + "32768", + "-b", + "2048", + "-ub", + "512", + "--host", + "0.0.0.0", + "--port", + "8080", + "--parallel", + "1", + "-ngl", + "999", + "-ctk", + "q8_0", + "-ctv", + "q8_0" + ], + "env": { + "CUDA_SCALE_LAUNCH_QUEUES": "4x" + }, + "notes": [ + "CUDA_SCALE_LAUNCH_QUEUES=4x (2 GPU, parallélisme pipeline possible) — CUDA_LAUNCH_QUEUES=off pour s'en passer", + "cache KV q8_0/q8_0 (défini par EXTRA_ARGS) : modifie légèrement les sorties par rapport à f16 — choix de l'utilisateur, laissé tel quel.", + "avertissement : ce llama-server ancien glisse le contexte par défaut — contexte plein, il jette des jetons anciens et le modèle perd une partie de la conversation sans le savoir. --no-context-shift dans EXTRA_ARGS l'en empêche." + ] + }, + { + "m": 0, + "c": 87, + "h": 1, + "e": 0, + "args": [ + "/app/llama-server", + "-m", + "/m/dense.gguf", + "-c", + "32768", + "-b", + "2048", + "-ub", + "512", + "--host", + "0.0.0.0", + "--port", + "8080", + "--parallel", + "1", + "-ngl", + "999", + "-ot", + "exps=CPU" + ], + "env": {}, + "notes": [ + "avertissement : ce llama-server ancien glisse le contexte par défaut — contexte plein, il jette des jetons anciens et le modèle perd une partie de la conversation sans le savoir. --no-context-shift dans EXTRA_ARGS l'en empêche." + ] + }, + { + "m": 2, + "c": 87, + "h": 2, + "e": 0, + "args": [ + "/app/llama-server", + "-m", + "/m/inconnu.gguf", + "-c", + "32768", + "-b", + "2048", + "-ub", + "512", + "--host", + "0.0.0.0", + "--port", + "8080", + "--parallel", + "1", + "-ngl", + "999", + "-ot", + "exps=CPU" + ], + "env": {}, + "notes": null + }, + { + "m": 4, + "c": 87, + "h": 1, + "e": 0, + "args": [ + "/app/llama-server", + "-m", + "/m/hybrid.gguf", + "-c", + "32768", + "-b", + "2048", + "-ub", + "512", + "--host", + "0.0.0.0", + "--port", + "8080", + "--parallel", + "1", + "-ngl", + "999", + "-ot", + "exps=CPU" + ], + "env": {}, + "notes": [ + "avertissement : ce llama-server ancien glisse le contexte par défaut — contexte plein, il jette des jetons anciens et le modèle perd une partie de la conversation sans le savoir. --no-context-shift dans EXTRA_ARGS l'en empêche." + ] + }, + { + "m": 0, + "c": 88, + "h": 1, + "e": 0, + "args": [ + "/app/llama-server", + "-m", + "/m/dense.gguf", + "-c", + "32768", + "-b", + "2048", + "-ub", + "512", + "--host", + "0.0.0.0", + "--port", + "8080", + "--parallel", + "1", + "-ngl", + "999", + "-ot", + "blk\\.(1|2)\\.ffn_.*_exps=CPU", + "-ub", + "512" + ], + "env": {}, + "notes": [ + "avertissement : ce llama-server ancien glisse le contexte par défaut — contexte plein, il jette des jetons anciens et le modèle perd une partie de la conversation sans le savoir. --no-context-shift dans EXTRA_ARGS l'en empêche." + ] + }, + { + "m": 2, + "c": 88, + "h": 2, + "e": 0, + "args": [ + "/app/llama-server", + "-m", + "/m/inconnu.gguf", + "-c", + "32768", + "-b", + "2048", + "-ub", + "512", + "--host", + "0.0.0.0", + "--port", + "8080", + "--parallel", + "1", + "-ngl", + "999", + "-ot", + "blk\\.(1|2)\\.ffn_.*_exps=CPU", + "-ub", + "512" + ], + "env": {}, + "notes": null + }, + { + "m": 4, + "c": 88, + "h": 1, + "e": 0, + "args": [ + "/app/llama-server", + "-m", + "/m/hybrid.gguf", + "-c", + "32768", + "-b", + "2048", + "-ub", + "512", + "--host", + "0.0.0.0", + "--port", + "8080", + "--parallel", + "1", + "-ngl", + "999", + "-ot", + "blk\\.(1|2)\\.ffn_.*_exps=CPU", + "-ub", + "512" + ], + "env": {}, + "notes": [ + "avertissement : ce llama-server ancien glisse le contexte par défaut — contexte plein, il jette des jetons anciens et le modèle perd une partie de la conversation sans le savoir. --no-context-shift dans EXTRA_ARGS l'en empêche." + ] + }, + { + "m": 0, + "c": 89, + "h": 1, + "e": 0, + "args": [ + "/app/llama-server", + "-m", + "/m/dense.gguf", + "-c", + "32768", + "-b", + "2048", + "-ub", + "512", + "--host", + "0.0.0.0", + "--port", + "8080", + "--parallel", + "1", + "-ngl", + "999", + "--n-cpu-moe", + "20" + ], + "env": {}, + "notes": [ + "avertissement : ce llama-server ancien glisse le contexte par défaut — contexte plein, il jette des jetons anciens et le modèle perd une partie de la conversation sans le savoir. --no-context-shift dans EXTRA_ARGS l'en empêche." + ] + }, + { + "m": 2, + "c": 89, + "h": 2, + "e": 0, + "args": [ + "/app/llama-server", + "-m", + "/m/inconnu.gguf", + "-c", + "32768", + "-b", + "2048", + "-ub", + "512", + "--host", + "0.0.0.0", + "--port", + "8080", + "--parallel", + "1", + "-ngl", + "999", + "--n-cpu-moe", + "20" + ], + "env": {}, + "notes": null + }, + { + "m": 4, + "c": 89, + "h": 1, + "e": 0, + "args": [ + "/app/llama-server", + "-m", + "/m/hybrid.gguf", + "-c", + "32768", + "-b", + "2048", + "-ub", + "512", + "--host", + "0.0.0.0", + "--port", + "8080", + "--parallel", + "1", + "-ngl", + "999", + "--n-cpu-moe", + "20" + ], + "env": {}, + "notes": [ + "avertissement : ce llama-server ancien glisse le contexte par défaut — contexte plein, il jette des jetons anciens et le modèle perd une partie de la conversation sans le savoir. --no-context-shift dans EXTRA_ARGS l'en empêche." + ] + }, + { + "m": 0, + "c": 90, + "h": 1, + "e": 0, + "args": [ + "/app/llama-server", + "-m", + "/m/dense.gguf", + "-c", + "32768", + "-b", + "2048", + "-ub", + "512", + "--host", + "0.0.0.0", + "--port", + "8080", + "--parallel", + "1", + "-ngl", + "999", + "--cpu-moe" + ], + "env": {}, + "notes": [ + "avertissement : ce llama-server ancien glisse le contexte par défaut — contexte plein, il jette des jetons anciens et le modèle perd une partie de la conversation sans le savoir. --no-context-shift dans EXTRA_ARGS l'en empêche." + ] + }, + { + "m": 2, + "c": 90, + "h": 2, + "e": 0, + "args": [ + "/app/llama-server", + "-m", + "/m/inconnu.gguf", + "-c", + "32768", + "-b", + "2048", + "-ub", + "512", + "--host", + "0.0.0.0", + "--port", + "8080", + "--parallel", + "1", + "-ngl", + "999", + "--cpu-moe" + ], + "env": {}, + "notes": null + }, + { + "m": 4, + "c": 90, + "h": 1, + "e": 0, + "args": [ + "/app/llama-server", + "-m", + "/m/hybrid.gguf", + "-c", + "32768", + "-b", + "2048", + "-ub", + "512", + "--host", + "0.0.0.0", + "--port", + "8080", + "--parallel", + "1", + "-ngl", + "999", + "--cpu-moe" + ], + "env": {}, + "notes": [ + "avertissement : ce llama-server ancien glisse le contexte par défaut — contexte plein, il jette des jetons anciens et le modèle perd une partie de la conversation sans le savoir. --no-context-shift dans EXTRA_ARGS l'en empêche." + ] + }, + { + "m": 0, + "c": 91, + "h": 1, + "e": 0, + "args": [ + "/app/llama-server", + "-m", + "/m/dense.gguf", + "-c", + "32768", + "-b", + "2048", + "-ub", + "512", + "--host", + "0.0.0.0", + "--port", + "8080", + "--parallel", + "1", + "-ngl", + "999", + "-sm", + "row" + ], + "env": {}, + "notes": [ + "avertissement : ce llama-server ancien glisse le contexte par défaut — contexte plein, il jette des jetons anciens et le modèle perd une partie de la conversation sans le savoir. --no-context-shift dans EXTRA_ARGS l'en empêche." + ] + }, + { + "m": 2, + "c": 91, + "h": 2, + "e": 0, + "args": [ + "/app/llama-server", + "-m", + "/m/inconnu.gguf", + "-c", + "32768", + "-b", + "2048", + "-ub", + "512", + "--host", + "0.0.0.0", + "--port", + "8080", + "--parallel", + "1", + "-ngl", + "999", + "-sm", + "row" + ], + "env": {}, + "notes": null + }, + { + "m": 4, + "c": 91, + "h": 1, + "e": 0, + "args": [ + "/app/llama-server", + "-m", + "/m/hybrid.gguf", + "-c", + "32768", + "-b", + "2048", + "-ub", + "512", + "--host", + "0.0.0.0", + "--port", + "8080", + "--parallel", + "1", + "-ngl", + "999", + "-sm", + "row" + ], + "env": {}, + "notes": [ + "avertissement : ce llama-server ancien glisse le contexte par défaut — contexte plein, il jette des jetons anciens et le modèle perd une partie de la conversation sans le savoir. --no-context-shift dans EXTRA_ARGS l'en empêche." + ] + }, + { + "m": 0, + "c": 92, + "h": 1, + "e": 0, + "args": [ + "/app/llama-server", + "-m", + "/m/dense.gguf", + "-c", + "32768", + "-b", + "2048", + "-ub", + "512", + "--host", + "0.0.0.0", + "--port", + "8080", + "--parallel", + "1", + "-ngl", + "999", + "-ts", + "1,1" + ], + "env": {}, + "notes": [ + "avertissement : ce llama-server ancien glisse le contexte par défaut — contexte plein, il jette des jetons anciens et le modèle perd une partie de la conversation sans le savoir. --no-context-shift dans EXTRA_ARGS l'en empêche." + ] + }, + { + "m": 2, + "c": 92, + "h": 2, + "e": 0, + "args": [ + "/app/llama-server", + "-m", + "/m/inconnu.gguf", + "-c", + "32768", + "-b", + "2048", + "-ub", + "512", + "--host", + "0.0.0.0", + "--port", + "8080", + "--parallel", + "1", + "-ngl", + "999", + "-ts", + "1,1" + ], + "env": {}, + "notes": null + }, + { + "m": 4, + "c": 92, + "h": 1, + "e": 0, + "args": [ + "/app/llama-server", + "-m", + "/m/hybrid.gguf", + "-c", + "32768", + "-b", + "2048", + "-ub", + "512", + "--host", + "0.0.0.0", + "--port", + "8080", + "--parallel", + "1", + "-ngl", + "999", + "-ts", + "1,1" + ], + "env": { + "CUDA_SCALE_LAUNCH_QUEUES": "4x" + }, + "notes": [ + "CUDA_SCALE_LAUNCH_QUEUES=4x (2 GPU, parallélisme pipeline possible) — CUDA_LAUNCH_QUEUES=off pour s'en passer", + "avertissement : ce llama-server ancien glisse le contexte par défaut — contexte plein, il jette des jetons anciens et le modèle perd une partie de la conversation sans le savoir. --no-context-shift dans EXTRA_ARGS l'en empêche." + ] + }, + { + "m": 0, + "c": 93, + "h": 1, + "e": 0, + "args": [ + "/app/llama-server", + "-m", + "/m/dense.gguf", + "-c", + "32768", + "-b", + "2048", + "-ub", + "512", + "--host", + "0.0.0.0", + "--port", + "8080", + "--parallel", + "1", + "-ngl", + "999", + "-dev", + "CUDA0,CUDA1" + ], + "env": { + "CUDA_SCALE_LAUNCH_QUEUES": "4x" + }, + "notes": [ + "CUDA_SCALE_LAUNCH_QUEUES=4x (2 GPU, parallélisme pipeline possible) — CUDA_LAUNCH_QUEUES=off pour s'en passer", + "avertissement : ce llama-server ancien glisse le contexte par défaut — contexte plein, il jette des jetons anciens et le modèle perd une partie de la conversation sans le savoir. --no-context-shift dans EXTRA_ARGS l'en empêche." + ] + }, + { + "m": 2, + "c": 93, + "h": 2, + "e": 0, + "args": [ + "/app/llama-server", + "-m", + "/m/inconnu.gguf", + "-c", + "32768", + "-b", + "2048", + "-ub", + "512", + "--host", + "0.0.0.0", + "--port", + "8080", + "--parallel", + "1", + "-ngl", + "999", + "-dev", + "CUDA0,CUDA1" + ], + "env": { + "CUDA_SCALE_LAUNCH_QUEUES": "4x" + }, + "notes": [ + "CUDA_SCALE_LAUNCH_QUEUES=4x (2 GPU, parallélisme pipeline possible) — CUDA_LAUNCH_QUEUES=off pour s'en passer" + ] + }, + { + "m": 4, + "c": 93, + "h": 1, + "e": 0, + "args": [ + "/app/llama-server", + "-m", + "/m/hybrid.gguf", + "-c", + "32768", + "-b", + "2048", + "-ub", + "512", + "--host", + "0.0.0.0", + "--port", + "8080", + "--parallel", + "1", + "-ngl", + "999", + "-dev", + "CUDA0,CUDA1" + ], + "env": { + "CUDA_SCALE_LAUNCH_QUEUES": "4x" + }, + "notes": [ + "CUDA_SCALE_LAUNCH_QUEUES=4x (2 GPU, parallélisme pipeline possible) — CUDA_LAUNCH_QUEUES=off pour s'en passer", + "avertissement : ce llama-server ancien glisse le contexte par défaut — contexte plein, il jette des jetons anciens et le modèle perd une partie de la conversation sans le savoir. --no-context-shift dans EXTRA_ARGS l'en empêche." + ] + }, + { + "m": 0, + "c": 94, + "h": 1, + "e": 0, + "args": [ + "/app/llama-server", + "-m", + "/m/dense.gguf", + "-c", + "32768", + "-b", + "2048", + "-ub", + "512", + "--host", + "0.0.0.0", + "--port", + "8080", + "--parallel", + "1", + "-ngl", + "999", + "-dev", + "Vulkan0" + ], + "env": {}, + "notes": [ + "avertissement : ce llama-server ancien glisse le contexte par défaut — contexte plein, il jette des jetons anciens et le modèle perd une partie de la conversation sans le savoir. --no-context-shift dans EXTRA_ARGS l'en empêche." + ] + }, + { + "m": 2, + "c": 94, + "h": 2, + "e": 0, + "args": [ + "/app/llama-server", + "-m", + "/m/inconnu.gguf", + "-c", + "32768", + "-b", + "2048", + "-ub", + "512", + "--host", + "0.0.0.0", + "--port", + "8080", + "--parallel", + "1", + "-ngl", + "999", + "-dev", + "Vulkan0" + ], + "env": {}, + "notes": null + }, + { + "m": 4, + "c": 94, + "h": 1, + "e": 0, + "args": [ + "/app/llama-server", + "-m", + "/m/hybrid.gguf", + "-c", + "32768", + "-b", + "2048", + "-ub", + "512", + "--host", + "0.0.0.0", + "--port", + "8080", + "--parallel", + "1", + "-ngl", + "999", + "-dev", + "Vulkan0" + ], + "env": {}, + "notes": [ + "avertissement : ce llama-server ancien glisse le contexte par défaut — contexte plein, il jette des jetons anciens et le modèle perd une partie de la conversation sans le savoir. --no-context-shift dans EXTRA_ARGS l'en empêche." + ] + }, + { + "m": 0, + "c": 95, + "h": 1, + "e": 0, + "args": [ + "/app/llama-server", + "-m", + "/m/dense.gguf", + "-c", + "32768", + "-b", + "2048", + "-ub", + "512", + "--host", + "0.0.0.0", + "--port", + "8080", + "--parallel", + "1", + "-ngl", + "999", + "--fit", + "off" + ], + "env": {}, + "notes": [ + "avertissement : ce llama-server ancien glisse le contexte par défaut — contexte plein, il jette des jetons anciens et le modèle perd une partie de la conversation sans le savoir. --no-context-shift dans EXTRA_ARGS l'en empêche." + ] + }, + { + "m": 2, + "c": 95, + "h": 2, + "e": 0, + "args": [ + "/app/llama-server", + "-m", + "/m/inconnu.gguf", + "-c", + "32768", + "-b", + "2048", + "-ub", + "512", + "--host", + "0.0.0.0", + "--port", + "8080", + "--parallel", + "1", + "-ngl", + "999", + "--fit", + "off" + ], + "env": {}, + "notes": null + }, + { + "m": 4, + "c": 95, + "h": 1, + "e": 0, + "args": [ + "/app/llama-server", + "-m", + "/m/hybrid.gguf", + "-c", + "32768", + "-b", + "2048", + "-ub", + "512", + "--host", + "0.0.0.0", + "--port", + "8080", + "--parallel", + "1", + "-ngl", + "999", + "--fit", + "off" + ], + "env": { + "CUDA_SCALE_LAUNCH_QUEUES": "4x" + }, + "notes": [ + "CUDA_SCALE_LAUNCH_QUEUES=4x (2 GPU, parallélisme pipeline possible) — CUDA_LAUNCH_QUEUES=off pour s'en passer", + "avertissement : ce llama-server ancien glisse le contexte par défaut — contexte plein, il jette des jetons anciens et le modèle perd une partie de la conversation sans le savoir. --no-context-shift dans EXTRA_ARGS l'en empêche." + ] + }, + { + "m": 0, + "c": 96, + "h": 1, + "e": 0, + "args": [ + "/app/llama-server", + "-m", + "/m/dense.gguf", + "-c", + "32768", + "-b", + "2048", + "-ub", + "512", + "--host", + "0.0.0.0", + "--port", + "8080", + "--parallel", + "1", + "-ngl", + "999", + "--mmproj", + "/m/x.gguf" + ], + "env": {}, + "notes": [ + "avertissement : ce llama-server ancien glisse le contexte par défaut — contexte plein, il jette des jetons anciens et le modèle perd une partie de la conversation sans le savoir (vision chargée : llama.cpp l'ignore de toute façon). --no-context-shift dans EXTRA_ARGS l'en empêche." + ] + }, + { + "m": 2, + "c": 96, + "h": 2, + "e": 0, + "args": [ + "/app/llama-server", + "-m", + "/m/inconnu.gguf", + "-c", + "32768", + "-b", + "2048", + "-ub", + "512", + "--host", + "0.0.0.0", + "--port", + "8080", + "--parallel", + "1", + "-ngl", + "999", + "--mmproj", + "/m/x.gguf" + ], + "env": {}, + "notes": null + }, + { + "m": 4, + "c": 96, + "h": 1, + "e": 0, + "args": [ + "/app/llama-server", + "-m", + "/m/hybrid.gguf", + "-c", + "32768", + "-b", + "2048", + "-ub", + "512", + "--host", + "0.0.0.0", + "--port", + "8080", + "--parallel", + "1", + "-ngl", + "999", + "--mmproj", + "/m/x.gguf" + ], + "env": { + "CUDA_SCALE_LAUNCH_QUEUES": "4x" + }, + "notes": [ + "CUDA_SCALE_LAUNCH_QUEUES=4x (2 GPU, parallélisme pipeline possible) — CUDA_LAUNCH_QUEUES=off pour s'en passer", + "avertissement : ce llama-server ancien glisse le contexte par défaut — contexte plein, il jette des jetons anciens et le modèle perd une partie de la conversation sans le savoir (vision chargée : llama.cpp l'ignore de toute façon). --no-context-shift dans EXTRA_ARGS l'en empêche." + ] + }, + { + "m": 0, + "c": 97, + "h": 1, + "e": 0, + "args": [ + "/app/llama-server", + "-m", + "/m/dense.gguf", + "-c", + "32768", + "-b", + "2048", + "-ub", + "512", + "--host", + "0.0.0.0", + "--port", + "8080", + "--parallel", + "1", + "-ngl", + "999", + "--spec-synth-len", + "4" + ], + "env": {}, + "notes": [ + "avertissement : ce llama-server ancien glisse le contexte par défaut — contexte plein, il jette des jetons anciens et le modèle perd une partie de la conversation sans le savoir. --no-context-shift dans EXTRA_ARGS l'en empêche.", + "avertissement : --spec-synth-len — le moteur accepte des jetons du brouillon AU HASARD, sans vérification : les réponses ne sont plus celles du modèle. Réservé aux mesures, retire-le." + ] + }, + { + "m": 2, + "c": 97, + "h": 2, + "e": 0, + "args": [ + "/app/llama-server", + "-m", + "/m/inconnu.gguf", + "-c", + "32768", + "-b", + "2048", + "-ub", + "512", + "--host", + "0.0.0.0", + "--port", + "8080", + "--parallel", + "1", + "-ngl", + "999", + "--spec-synth-len", + "4" + ], + "env": {}, + "notes": [ + "avertissement : --spec-synth-len — le moteur accepte des jetons du brouillon AU HASARD, sans vérification : les réponses ne sont plus celles du modèle. Réservé aux mesures, retire-le." + ] + }, + { + "m": 4, + "c": 97, + "h": 1, + "e": 0, + "args": [ + "/app/llama-server", + "-m", + "/m/hybrid.gguf", + "-c", + "32768", + "-b", + "2048", + "-ub", + "512", + "--host", + "0.0.0.0", + "--port", + "8080", + "--parallel", + "1", + "-ngl", + "999", + "--spec-synth-len", + "4" + ], + "env": { + "CUDA_SCALE_LAUNCH_QUEUES": "4x" + }, + "notes": [ + "CUDA_SCALE_LAUNCH_QUEUES=4x (2 GPU, parallélisme pipeline possible) — CUDA_LAUNCH_QUEUES=off pour s'en passer", + "avertissement : ce llama-server ancien glisse le contexte par défaut — contexte plein, il jette des jetons anciens et le modèle perd une partie de la conversation sans le savoir. --no-context-shift dans EXTRA_ARGS l'en empêche.", + "avertissement : --spec-synth-len — le moteur accepte des jetons du brouillon AU HASARD, sans vérification : les réponses ne sont plus celles du modèle. Réservé aux mesures, retire-le." + ] + }, + { + "m": 0, + "c": 98, + "h": 1, + "e": 0, + "args": [ + "/app/llama-server", + "-m", + "/m/dense.gguf", + "-c", + "32768", + "-b", + "2048", + "-ub", + "512", + "--host", + "0.0.0.0", + "--port", + "8080", + "--parallel", + "1", + "-ngl", + "999", + "--slot-save-path", + "/x" + ], + "env": {}, + "notes": [ + "avertissement : ce llama-server ancien glisse le contexte par défaut — contexte plein, il jette des jetons anciens et le modèle perd une partie de la conversation sans le savoir. --no-context-shift dans EXTRA_ARGS l'en empêche." + ] + }, + { + "m": 2, + "c": 98, + "h": 2, + "e": 0, + "args": [ + "/app/llama-server", + "-m", + "/m/inconnu.gguf", + "-c", + "32768", + "-b", + "2048", + "-ub", + "512", + "--host", + "0.0.0.0", + "--port", + "8080", + "--parallel", + "1", + "-ngl", + "999", + "--slot-save-path", + "/x" + ], + "env": {}, + "notes": null + }, + { + "m": 4, + "c": 98, + "h": 1, + "e": 0, + "args": [ + "/app/llama-server", + "-m", + "/m/hybrid.gguf", + "-c", + "32768", + "-b", + "2048", + "-ub", + "512", + "--host", + "0.0.0.0", + "--port", + "8080", + "--parallel", + "1", + "-ngl", + "999", + "--slot-save-path", + "/x" + ], + "env": { + "CUDA_SCALE_LAUNCH_QUEUES": "4x" + }, + "notes": [ + "CUDA_SCALE_LAUNCH_QUEUES=4x (2 GPU, parallélisme pipeline possible) — CUDA_LAUNCH_QUEUES=off pour s'en passer", + "avertissement : ce llama-server ancien glisse le contexte par défaut — contexte plein, il jette des jetons anciens et le modèle perd une partie de la conversation sans le savoir. --no-context-shift dans EXTRA_ARGS l'en empêche." + ] + }, + { + "m": 0, + "c": 99, + "h": 1, + "e": 0, + "args": [ + "/app/llama-server", + "-m", + "/m/dense.gguf", + "-c", + "32768", + "-b", + "2048", + "-ub", + "512", + "--host", + "0.0.0.0", + "--port", + "8080", + "--parallel", + "1", + "-ngl", + "999", + "-c", + "0" + ], + "env": {}, + "notes": [ + "avertissement : ce llama-server ancien glisse le contexte par défaut — contexte plein, il jette des jetons anciens et le modèle perd une partie de la conversation sans le savoir. --no-context-shift dans EXTRA_ARGS l'en empêche." + ] + }, + { + "m": 2, + "c": 99, + "h": 2, + "e": 0, + "args": [ + "/app/llama-server", + "-m", + "/m/inconnu.gguf", + "-c", + "32768", + "-b", + "2048", + "-ub", + "512", + "--host", + "0.0.0.0", + "--port", + "8080", + "--parallel", + "1", + "-ngl", + "999", + "-c", + "0" + ], + "env": {}, + "notes": null + }, + { + "m": 4, + "c": 99, + "h": 1, + "e": 0, + "args": [ + "/app/llama-server", + "-m", + "/m/hybrid.gguf", + "-c", + "32768", + "-b", + "2048", + "-ub", + "512", + "--host", + "0.0.0.0", + "--port", + "8080", + "--parallel", + "1", + "-ngl", + "999", + "-c", + "0" + ], + "env": { + "CUDA_SCALE_LAUNCH_QUEUES": "4x" + }, + "notes": [ + "CUDA_SCALE_LAUNCH_QUEUES=4x (2 GPU, parallélisme pipeline possible) — CUDA_LAUNCH_QUEUES=off pour s'en passer", + "avertissement : ce llama-server ancien glisse le contexte par défaut — contexte plein, il jette des jetons anciens et le modèle perd une partie de la conversation sans le savoir. --no-context-shift dans EXTRA_ARGS l'en empêche." + ] + }, + { + "m": 0, + "c": 100, + "h": 1, + "e": 0, + "args": [ + "/app/llama-server", + "-m", + "/m/dense.gguf", + "-c", + "32768", + "-b", + "2048", + "-ub", + "512", + "--host", + "0.0.0.0", + "--port", + "8080", + "--parallel", + "1", + "-ngl", + "999", + "--host", + "127.0.0.1", + "--port", + "9000" + ], + "env": {}, + "notes": [ + "avertissement : ce llama-server ancien glisse le contexte par défaut — contexte plein, il jette des jetons anciens et le modèle perd une partie de la conversation sans le savoir. --no-context-shift dans EXTRA_ARGS l'en empêche." + ] + }, + { + "m": 2, + "c": 100, + "h": 2, + "e": 0, + "args": [ + "/app/llama-server", + "-m", + "/m/inconnu.gguf", + "-c", + "32768", + "-b", + "2048", + "-ub", + "512", + "--host", + "0.0.0.0", + "--port", + "8080", + "--parallel", + "1", + "-ngl", + "999", + "--host", + "127.0.0.1", + "--port", + "9000" + ], + "env": {}, + "notes": null + }, + { + "m": 4, + "c": 100, + "h": 1, + "e": 0, + "args": [ + "/app/llama-server", + "-m", + "/m/hybrid.gguf", + "-c", + "32768", + "-b", + "2048", + "-ub", + "512", + "--host", + "0.0.0.0", + "--port", + "8080", + "--parallel", + "1", + "-ngl", + "999", + "--host", + "127.0.0.1", + "--port", + "9000" + ], + "env": { + "CUDA_SCALE_LAUNCH_QUEUES": "4x" + }, + "notes": [ + "CUDA_SCALE_LAUNCH_QUEUES=4x (2 GPU, parallélisme pipeline possible) — CUDA_LAUNCH_QUEUES=off pour s'en passer", + "avertissement : ce llama-server ancien glisse le contexte par défaut — contexte plein, il jette des jetons anciens et le modèle perd une partie de la conversation sans le savoir. --no-context-shift dans EXTRA_ARGS l'en empêche." + ] + }, + { + "m": 0, + "c": 101, + "h": 1, + "e": 0, + "args": [ + "/app/llama-server", + "-m", + "/m/dense.gguf", + "-c", + "32768", + "-b", + "2048", + "-ub", + "512", + "--host", + "0.0.0.0", + "--port", + "8080", + "--parallel", + "1", + "-ngl", + "999", + "--mirostat", + "2" + ], + "env": {}, + "notes": [ + "SPEC=mtp : pas de tête MTP dans ce fichier (publiée à part ? MODEL_DRAFT=mtp-….gguf) — sans décodage spéculatif", + "avertissement : ce llama-server ancien glisse le contexte par défaut — contexte plein, il jette des jetons anciens et le modèle perd une partie de la conversation sans le savoir. --no-context-shift dans EXTRA_ARGS l'en empêche." + ] + }, + { + "m": 2, + "c": 101, + "h": 2, + "e": 0, + "args": [ + "/app/llama-server", + "-m", + "/m/inconnu.gguf", + "-c", + "32768", + "-b", + "2048", + "-ub", + "512", + "--host", + "0.0.0.0", + "--port", + "8080", + "--parallel", + "1", + "-ngl", + "999", + "--mirostat", + "2" + ], + "env": {}, + "notes": [ + "SPEC=mtp : métadonnées du modèle illisibles — sans décodage spéculatif" + ] + }, + { + "m": 4, + "c": 101, + "h": 1, + "e": 0, + "args": [ + "/app/llama-server", + "-m", + "/m/hybrid.gguf", + "-c", + "32768", + "-b", + "2048", + "-ub", + "512", + "--host", + "0.0.0.0", + "--port", + "8080", + "--parallel", + "1", + "-ngl", + "999", + "--mirostat", + "2" + ], + "env": { + "CUDA_SCALE_LAUNCH_QUEUES": "4x" + }, + "notes": [ + "CUDA_SCALE_LAUNCH_QUEUES=4x (2 GPU, parallélisme pipeline possible) — CUDA_LAUNCH_QUEUES=off pour s'en passer", + "SPEC=mtp : ce moteur ne connaît pas draft-mtp — sans décodage spéculatif", + "avertissement : ce llama-server ancien glisse le contexte par défaut — contexte plein, il jette des jetons anciens et le modèle perd une partie de la conversation sans le savoir. --no-context-shift dans EXTRA_ARGS l'en empêche." + ] + }, + { + "m": 0, + "c": 102, + "h": 1, + "e": 0, + "args": [ + "/app/llama-server", + "-m", + "/m/dense.gguf", + "-c", + "32768", + "-b", + "2048", + "-ub", + "512", + "--host", + "0.0.0.0", + "--port", + "8080", + "--parallel", + "1", + "-ngl", + "999", + "--chat-template-kwargs", + "{\"preserve_reasoning\":true}" + ], + "env": {}, + "notes": [ + "ce moteur ne connaît pas --reasoning-preserve : REASONING_PRESERVE ignoré", + "avertissement : ce llama-server ancien glisse le contexte par défaut — contexte plein, il jette des jetons anciens et le modèle perd une partie de la conversation sans le savoir. --no-context-shift dans EXTRA_ARGS l'en empêche." + ] + }, + { + "m": 2, + "c": 102, + "h": 2, + "e": 0, + "args": [ + "/app/llama-server", + "-m", + "/m/inconnu.gguf", + "-c", + "32768", + "-b", + "2048", + "-ub", + "512", + "--host", + "0.0.0.0", + "--port", + "8080", + "--parallel", + "1", + "-ngl", + "999", + "--chat-template-kwargs", + "{\"preserve_reasoning\":true}" + ], + "env": {}, + "notes": [ + "ce moteur ne connaît pas --reasoning-preserve : REASONING_PRESERVE ignoré" + ] + }, + { + "m": 4, + "c": 102, + "h": 1, + "e": 0, + "args": [ + "/app/llama-server", + "-m", + "/m/hybrid.gguf", + "-c", + "32768", + "-b", + "2048", + "-ub", + "512", + "--host", + "0.0.0.0", + "--port", + "8080", + "--parallel", + "1", + "-ngl", + "999", + "--chat-template-kwargs", + "{\"preserve_reasoning\":true}" + ], + "env": { + "CUDA_SCALE_LAUNCH_QUEUES": "4x" + }, + "notes": [ + "CUDA_SCALE_LAUNCH_QUEUES=4x (2 GPU, parallélisme pipeline possible) — CUDA_LAUNCH_QUEUES=off pour s'en passer", + "ce moteur ne connaît pas --reasoning-preserve : REASONING_PRESERVE ignoré", + "avertissement : ce llama-server ancien glisse le contexte par défaut — contexte plein, il jette des jetons anciens et le modèle perd une partie de la conversation sans le savoir. --no-context-shift dans EXTRA_ARGS l'en empêche." + ] + }, + { + "m": 0, + "c": 103, + "h": 1, + "e": 0, + "args": [ + "/app/llama-server", + "-m", + "/m/dense.gguf", + "-c", + "32768", + "-b", + "2048", + "-ub", + "512", + "--host", + "0.0.0.0", + "--port", + "8080", + "--parallel", + "1", + "-ngl", + "999", + "--spec-type", + "ngram-mod" + ], + "env": {}, + "notes": [ + "décodage spéculatif déjà réglé dans EXTRA_ARGS ou LLAMA_ARG_* : SPEC ignoré", + "avertissement : ce llama-server ancien glisse le contexte par défaut — contexte plein, il jette des jetons anciens et le modèle perd une partie de la conversation sans le savoir. --no-context-shift dans EXTRA_ARGS l'en empêche." + ] + }, + { + "m": 2, + "c": 103, + "h": 2, + "e": 0, + "args": [ + "/app/llama-server", + "-m", + "/m/inconnu.gguf", + "-c", + "32768", + "-b", + "2048", + "-ub", + "512", + "--host", + "0.0.0.0", + "--port", + "8080", + "--parallel", + "1", + "-ngl", + "999", + "--spec-type", + "ngram-mod" + ], + "env": {}, + "notes": [ + "décodage spéculatif déjà réglé dans EXTRA_ARGS ou LLAMA_ARG_* : SPEC ignoré" + ] + }, + { + "m": 4, + "c": 103, + "h": 1, + "e": 0, + "args": [ + "/app/llama-server", + "-m", + "/m/hybrid.gguf", + "-c", + "32768", + "-b", + "2048", + "-ub", + "512", + "--host", + "0.0.0.0", + "--port", + "8080", + "--parallel", + "1", + "-ngl", + "999", + "--spec-type", + "ngram-mod" + ], + "env": { + "CUDA_SCALE_LAUNCH_QUEUES": "4x" + }, + "notes": [ + "CUDA_SCALE_LAUNCH_QUEUES=4x (2 GPU, parallélisme pipeline possible) — CUDA_LAUNCH_QUEUES=off pour s'en passer", + "décodage spéculatif déjà réglé dans EXTRA_ARGS ou LLAMA_ARG_* : SPEC ignoré", + "avertissement : ce llama-server ancien glisse le contexte par défaut — contexte plein, il jette des jetons anciens et le modèle perd une partie de la conversation sans le savoir. --no-context-shift dans EXTRA_ARGS l'en empêche." + ] + }, + { + "m": 0, + "c": 104, + "h": 1, + "e": 0, + "args": [ + "/app/llama-server", + "-m", + "/m/dense.gguf", + "-c", + "32768", + "-b", + "2048", + "-ub", + "512", + "--host", + "0.0.0.0", + "--port", + "8080", + "--parallel", + "1", + "-ngl", + "999", + "--spec-draft-n-max", + "8" + ], + "env": {}, + "notes": [ + "SPEC=ngram : ce moteur ne connaît pas --spec-ngram-mod-n-match — sans décodage spéculatif", + "avertissement : ce llama-server ancien glisse le contexte par défaut — contexte plein, il jette des jetons anciens et le modèle perd une partie de la conversation sans le savoir. --no-context-shift dans EXTRA_ARGS l'en empêche." + ] + }, + { + "m": 2, + "c": 104, + "h": 2, + "e": 0, + "args": [ + "/app/llama-server", + "-m", + "/m/inconnu.gguf", + "-c", + "32768", + "-b", + "2048", + "-ub", + "512", + "--host", + "0.0.0.0", + "--port", + "8080", + "--parallel", + "1", + "-ngl", + "999", + "--spec-draft-n-max", + "8" + ], + "env": {}, + "notes": [ + "SPEC=ngram : ce moteur ne connaît pas --spec-ngram-mod-n-match — sans décodage spéculatif" + ] + }, + { + "m": 4, + "c": 104, + "h": 1, + "e": 0, + "args": [ + "/app/llama-server", + "-m", + "/m/hybrid.gguf", + "-c", + "32768", + "-b", + "2048", + "-ub", + "512", + "--host", + "0.0.0.0", + "--port", + "8080", + "--parallel", + "1", + "-ngl", + "999", + "--spec-draft-n-max", + "8" + ], + "env": { + "CUDA_SCALE_LAUNCH_QUEUES": "4x" + }, + "notes": [ + "CUDA_SCALE_LAUNCH_QUEUES=4x (2 GPU, parallélisme pipeline possible) — CUDA_LAUNCH_QUEUES=off pour s'en passer", + "SPEC=ngram : ce moteur ne connaît pas --spec-ngram-mod-n-match — sans décodage spéculatif", + "avertissement : ce llama-server ancien glisse le contexte par défaut — contexte plein, il jette des jetons anciens et le modèle perd une partie de la conversation sans le savoir. --no-context-shift dans EXTRA_ARGS l'en empêche." + ] + }, + { + "m": 0, + "c": 105, + "h": 1, + "e": 0, + "args": [ + "/app/llama-server", + "-m", + "/m/dense.gguf", + "-c", + "32768", + "-b", + "2048", + "-ub", + "512", + "--host", + "0.0.0.0", + "--port", + "8080", + "--parallel", + "1", + "-ngl", + "999", + "-ot", + "attn=CUDA1" + ], + "env": {}, + "notes": [ + "CUDA_GRAPH_OPT ignoré : -ot sur l'attention peut séparer Q, K et V d'une couche entre cartes", + "avertissement : ce llama-server ancien glisse le contexte par défaut — contexte plein, il jette des jetons anciens et le modèle perd une partie de la conversation sans le savoir. --no-context-shift dans EXTRA_ARGS l'en empêche." + ] + }, + { + "m": 2, + "c": 105, + "h": 2, + "e": 0, + "args": [ + "/app/llama-server", + "-m", + "/m/inconnu.gguf", + "-c", + "32768", + "-b", + "2048", + "-ub", + "512", + "--host", + "0.0.0.0", + "--port", + "8080", + "--parallel", + "1", + "-ngl", + "999", + "-ot", + "attn=CUDA1" + ], + "env": {}, + "notes": [ + "CUDA_GRAPH_OPT ignoré : -ot sur l'attention peut séparer Q, K et V d'une couche entre cartes" + ] + }, + { + "m": 4, + "c": 105, + "h": 1, + "e": 0, + "args": [ + "/app/llama-server", + "-m", + "/m/hybrid.gguf", + "-c", + "32768", + "-b", + "2048", + "-ub", + "512", + "--host", + "0.0.0.0", + "--port", + "8080", + "--parallel", + "1", + "-ngl", + "999", + "-ot", + "attn=CUDA1" + ], + "env": {}, + "notes": [ + "avertissement : ce llama-server ancien glisse le contexte par défaut — contexte plein, il jette des jetons anciens et le modèle perd une partie de la conversation sans le savoir. --no-context-shift dans EXTRA_ARGS l'en empêche." + ] + }, + { + "m": 0, + "c": 106, + "h": 1, + "e": 0, + "args": [ + "/app/llama-server", + "-m", + "/m/dense.gguf", + "-c", + "32768", + "-b", + "2048", + "-ub", + "512", + "--host", + "0.0.0.0", + "--port", + "8080", + "--parallel", + "1", + "-ngl", + "999", + "-fa", + "off" + ], + "env": {}, + "notes": [ + "SPLIT_MODE=tensor refusé (ce moteur ne propose pas -sm tensor) : découpe par couches, comme sans la clé", + "avertissement : ce llama-server ancien glisse le contexte par défaut — contexte plein, il jette des jetons anciens et le modèle perd une partie de la conversation sans le savoir. --no-context-shift dans EXTRA_ARGS l'en empêche." + ] + }, + { + "m": 2, + "c": 106, + "h": 2, + "e": 0, + "args": [ + "/app/llama-server", + "-m", + "/m/inconnu.gguf", + "-c", + "32768", + "-b", + "2048", + "-ub", + "512", + "--host", + "0.0.0.0", + "--port", + "8080", + "--parallel", + "1", + "-ngl", + "999", + "-fa", + "off" + ], + "env": {}, + "notes": [ + "SPLIT_MODE=tensor refusé (ce moteur ne propose pas -sm tensor) : découpe par couches, comme sans la clé" + ] + }, + { + "m": 4, + "c": 106, + "h": 1, + "e": 0, + "args": [ + "/app/llama-server", + "-m", + "/m/hybrid.gguf", + "-c", + "32768", + "-b", + "2048", + "-ub", + "512", + "--host", + "0.0.0.0", + "--port", + "8080", + "--parallel", + "1", + "-ngl", + "999", + "-fa", + "off" + ], + "env": { + "CUDA_SCALE_LAUNCH_QUEUES": "4x" + }, + "notes": [ + "SPLIT_MODE=tensor refusé (ce moteur ne propose pas -sm tensor) : découpe par couches, comme sans la clé", + "CUDA_SCALE_LAUNCH_QUEUES=4x (2 GPU, parallélisme pipeline possible) — CUDA_LAUNCH_QUEUES=off pour s'en passer", + "avertissement : ce llama-server ancien glisse le contexte par défaut — contexte plein, il jette des jetons anciens et le modèle perd une partie de la conversation sans le savoir. --no-context-shift dans EXTRA_ARGS l'en empêche." + ] + }, + { + "m": 0, + "c": 107, + "h": 1, + "e": 0, + "args": [ + "/app/llama-server", + "-m", + "/m/dense.gguf", + "-c", + "32768", + "-b", + "2048", + "-ub", + "512", + "--host", + "0.0.0.0", + "--port", + "8080", + "--parallel", + "1", + "-ngl", + "999", + "-nkvo" + ], + "env": {}, + "notes": [ + "avertissement : ce llama-server ancien glisse le contexte par défaut — contexte plein, il jette des jetons anciens et le modèle perd une partie de la conversation sans le savoir. --no-context-shift dans EXTRA_ARGS l'en empêche." + ] + }, + { + "m": 2, + "c": 107, + "h": 2, + "e": 0, + "args": [ + "/app/llama-server", + "-m", + "/m/inconnu.gguf", + "-c", + "32768", + "-b", + "2048", + "-ub", + "512", + "--host", + "0.0.0.0", + "--port", + "8080", + "--parallel", + "1", + "-ngl", + "999", + "-nkvo" + ], + "env": {}, + "notes": null + }, + { + "m": 4, + "c": 107, + "h": 1, + "e": 0, + "args": [ + "/app/llama-server", + "-m", + "/m/hybrid.gguf", + "-c", + "32768", + "-b", + "2048", + "-ub", + "512", + "--host", + "0.0.0.0", + "--port", + "8080", + "--parallel", + "1", + "-ngl", + "999", + "-nkvo" + ], + "env": {}, + "notes": [ + "avertissement : ce llama-server ancien glisse le contexte par défaut — contexte plein, il jette des jetons anciens et le modèle perd une partie de la conversation sans le savoir. --no-context-shift dans EXTRA_ARGS l'en empêche." + ] + }, + { + "m": 0, + "c": 108, + "h": 1, + "e": 0, + "args": [ + "/app/llama-server", + "-m", + "/m/dense.gguf", + "-c", + "32768", + "-b", + "2048", + "-ub", + "512", + "--host", + "0.0.0.0", + "--port", + "8080", + "--parallel", + "1", + "-ngl", + "999", + "--no-context-shift" + ], + "env": {}, + "notes": null + }, + { + "m": 2, + "c": 108, + "h": 2, + "e": 0, + "args": [ + "/app/llama-server", + "-m", + "/m/inconnu.gguf", + "-c", + "32768", + "-b", + "2048", + "-ub", + "512", + "--host", + "0.0.0.0", + "--port", + "8080", + "--parallel", + "1", + "-ngl", + "999", + "--no-context-shift" + ], + "env": {}, + "notes": null + }, + { + "m": 4, + "c": 108, + "h": 1, + "e": 0, + "args": [ + "/app/llama-server", + "-m", + "/m/hybrid.gguf", + "-c", + "32768", + "-b", + "2048", + "-ub", + "512", + "--host", + "0.0.0.0", + "--port", + "8080", + "--parallel", + "1", + "-ngl", + "999", + "--no-context-shift" + ], + "env": { + "CUDA_SCALE_LAUNCH_QUEUES": "4x" + }, + "notes": [ + "CUDA_SCALE_LAUNCH_QUEUES=4x (2 GPU, parallélisme pipeline possible) — CUDA_LAUNCH_QUEUES=off pour s'en passer" + ] + }, + { + "m": 0, + "c": 109, + "h": 1, + "e": 0, + "args": [ + "/app/llama-server", + "-m", + "/m/dense.gguf", + "-c", + "32768", + "-b", + "2048", + "-ub", + "512", + "--host", + "0.0.0.0", + "--port", + "8080", + "-ngl", + "999", + "-c", + "8192", + "-np", + "2" + ], + "env": {}, + "notes": [ + "SIDE_SLOT refusé (-c est réglé dans EXTRA_ARGS) : un seul slot, comme sans la clé", + "avertissement : ce llama-server ancien glisse le contexte par défaut — contexte plein, il jette des jetons anciens et le modèle perd une partie de la conversation sans le savoir. --no-context-shift dans EXTRA_ARGS l'en empêche." + ] + }, + { + "m": 2, + "c": 109, + "h": 2, + "e": 0, + "args": [ + "/app/llama-server", + "-m", + "/m/inconnu.gguf", + "-c", + "32768", + "-b", + "2048", + "-ub", + "512", + "--host", + "0.0.0.0", + "--port", + "8080", + "-ngl", + "999", + "-c", + "8192", + "-np", + "2" + ], + "env": {}, + "notes": [ + "SIDE_SLOT refusé (-c est réglé dans EXTRA_ARGS) : un seul slot, comme sans la clé" + ] + }, + { + "m": 4, + "c": 109, + "h": 1, + "e": 0, + "args": [ + "/app/llama-server", + "-m", + "/m/hybrid.gguf", + "-c", + "32768", + "-b", + "2048", + "-ub", + "512", + "--host", + "0.0.0.0", + "--port", + "8080", + "-ngl", + "999", + "-c", + "8192", + "-np", + "2" + ], + "env": { + "CUDA_SCALE_LAUNCH_QUEUES": "4x" + }, + "notes": [ + "CUDA_SCALE_LAUNCH_QUEUES=4x (2 GPU, parallélisme pipeline possible) — CUDA_LAUNCH_QUEUES=off pour s'en passer", + "SIDE_SLOT refusé (-c est réglé dans EXTRA_ARGS) : un seul slot, comme sans la clé", + "avertissement : ce llama-server ancien glisse le contexte par défaut — contexte plein, il jette des jetons anciens et le modèle perd une partie de la conversation sans le savoir. --no-context-shift dans EXTRA_ARGS l'en empêche." + ] + }, + { + "m": 0, + "c": 110, + "h": 1, + "e": 0, + "args": [ + "/app/llama-server", + "-m", + "/m/dense.gguf", + "-c", + "32768", + "-b", + "2048", + "-ub", + "512", + "--host", + "0.0.0.0", + "--port", + "8080", + "--parallel", + "1", + "-ngl", + "999" + ], + "env": {}, + "notes": [ + "SPLIT_MODE=tensor refusé (preset externe) : découpe par couches, comme sans la clé", + "SIDE_SLOT refusé (preset externe) : un seul slot, comme sans la clé", + "avertissement : ce llama-server ancien glisse le contexte par défaut — contexte plein, il jette des jetons anciens et le modèle perd une partie de la conversation sans le savoir. --no-context-shift dans EXTRA_ARGS l'en empêche." + ] + }, + { + "m": 2, + "c": 110, + "h": 2, + "e": 0, + "args": [ + "/app/llama-server", + "-m", + "/m/inconnu.gguf", + "-c", + "32768", + "-b", + "2048", + "-ub", + "512", + "--host", + "0.0.0.0", + "--port", + "8080", + "--parallel", + "1", + "-ngl", + "999" + ], + "env": {}, + "notes": [ + "SPLIT_MODE=tensor refusé (preset externe) : découpe par couches, comme sans la clé", + "SIDE_SLOT refusé (preset externe) : un seul slot, comme sans la clé" + ] + }, + { + "m": 4, + "c": 110, + "h": 1, + "e": 0, + "args": [ + "/app/llama-server", + "-m", + "/m/hybrid.gguf", + "-c", + "32768", + "-b", + "2048", + "-ub", + "512", + "--host", + "0.0.0.0", + "--port", + "8080", + "--parallel", + "1", + "-ngl", + "999" + ], + "env": { + "CUDA_SCALE_LAUNCH_QUEUES": "4x" + }, + "notes": [ + "SPLIT_MODE=tensor refusé (preset externe) : découpe par couches, comme sans la clé", + "CUDA_SCALE_LAUNCH_QUEUES=4x (2 GPU, parallélisme pipeline possible) — CUDA_LAUNCH_QUEUES=off pour s'en passer", + "SIDE_SLOT refusé (preset externe) : un seul slot, comme sans la clé", + "avertissement : ce llama-server ancien glisse le contexte par défaut — contexte plein, il jette des jetons anciens et le modèle perd une partie de la conversation sans le savoir. --no-context-shift dans EXTRA_ARGS l'en empêche." + ] + }, + { + "m": 0, + "c": 111, + "h": 1, + "e": 0, + "args": [ + "/app/llama-server", + "-m", + "/m/dense.gguf", + "-c", + "40000", + "-b", + "2048", + "-ub", + "512", + "--host", + "0.0.0.0", + "--port", + "8080", + "--parallel", + "1", + "-ngl", + "999", + "-ctk", + "q4_0", + "-ctv", + "q4_0" + ], + "env": {}, + "notes": [ + "cache KV q4_0/q4_0 (KV_TYPE) : perte mesurable par rapport à f16 — choix de l'utilisateur, laissé tel quel.", + "avertissement : ce llama-server ancien glisse le contexte par défaut — contexte plein, il jette des jetons anciens et le modèle perd une partie de la conversation sans le savoir. --no-context-shift dans EXTRA_ARGS l'en empêche." + ] + }, + { + "m": 2, + "c": 111, + "h": 2, + "e": 0, + "args": [ + "/app/llama-server", + "-m", + "/m/inconnu.gguf", + "-c", + "40000", + "-b", + "2048", + "-ub", + "512", + "--host", + "0.0.0.0", + "--port", + "8080", + "--parallel", + "1", + "-ngl", + "999", + "-ctk", + "q4_0", + "-ctv", + "q4_0" + ], + "env": {}, + "notes": [ + "SPEC=auto : métadonnées du modèle illisibles — sans décodage spéculatif", + "cache KV q4_0/q4_0 (KV_TYPE) : perte mesurable par rapport à f16 — choix de l'utilisateur, laissé tel quel." + ] + }, + { + "m": 4, + "c": 111, + "h": 1, + "e": 0, + "args": [ + "/app/llama-server", + "-m", + "/m/hybrid.gguf", + "-c", + "40000", + "-b", + "2048", + "-ub", + "512", + "--host", + "0.0.0.0", + "--port", + "8080", + "--parallel", + "1", + "-ngl", + "999", + "-ctk", + "q4_0", + "-ctv", + "q4_0" + ], + "env": { + "CUDA_SCALE_LAUNCH_QUEUES": "4x" + }, + "notes": [ + "CUDA_SCALE_LAUNCH_QUEUES=4x (2 GPU, parallélisme pipeline possible) — CUDA_LAUNCH_QUEUES=off pour s'en passer", + "SPEC=auto : ce moteur ne connaît pas draft-mtp — sans décodage spéculatif", + "cache KV q4_0/q4_0 (KV_TYPE) : perte mesurable par rapport à f16 — choix de l'utilisateur, laissé tel quel.", + "avertissement : ce llama-server ancien glisse le contexte par défaut — contexte plein, il jette des jetons anciens et le modèle perd une partie de la conversation sans le savoir. --no-context-shift dans EXTRA_ARGS l'en empêche." + ] + }, + { + "m": 1, + "c": 0, + "h": 0, + "e": 1, + "args": [ + "/app/llama-server", + "-m", + "/m/moe.gguf", + "-c", + "32768", + "-b", + "2048", + "-ub", + "512", + "--host", + "0.0.0.0", + "--port", + "8080", + "--parallel", + "1", + "-ngl", + "auto" + ], + "env": { + "CUDA_SCALE_LAUNCH_QUEUES": "4x" + }, + "notes": [ + "CUDA_SCALE_LAUNCH_QUEUES=4x (2 GPU, parallélisme pipeline possible) — CUDA_LAUNCH_QUEUES=off pour s'en passer", + "cache KV q8_0/f16 (défini par LLAMA_ARG_CACHE_TYPE_K/V) : modifie légèrement les sorties par rapport à f16 — choix de l'utilisateur, laissé tel quel." + ] + }, + { + "m": 2, + "c": 1, + "h": 0, + "e": 1, + "args": [ + "/app/llama-server", + "-m", + "/m/inconnu.gguf", + "-c", + "65536", + "-b", + "4096", + "-ub", + "2048", + "--host", + "127.0.0.1", + "--port", + "8081", + "--parallel", + "1", + "-ngl", + "auto" + ], + "env": {}, + "notes": [ + "NGL=999 (sentinelle « toutes les couches ») → -ngl auto : ce moteur mesure lui-même la VRAM libre. NGL=all pour forcer l'ancien comportement.", + "cache KV q8_0/f16 (défini par LLAMA_ARG_CACHE_TYPE_K/V) : modifie légèrement les sorties par rapport à f16 — choix de l'utilisateur, laissé tel quel." + ] + }, + { + "m": 10, + "c": 9, + "h": 0, + "e": 1, + "args": [ + "/app/llama-server", + "-m", + "/m/hybrid.gguf", + "-c", + "32768", + "-b", + "2048", + "-ub", + "512", + "--host", + "0.0.0.0", + "--port", + "8080", + "--parallel", + "1", + "-ngl", + "auto", + "-ctk", + "q8_0", + "-ctv", + "q8_0" + ], + "env": {}, + "notes": [ + "cache KV q8_0/q8_0 (KV_TYPE) : modifie légèrement les sorties par rapport à f16 — choix de l'utilisateur, laissé tel quel." + ] + }, + { + "m": 9, + "c": 20, + "h": 0, + "e": 1, + "args": [ + "/app/llama-server", + "-m", + "/m/dense.gguf", + "-c", + "32768", + "-b", + "2048", + "-ub", + "512", + "--host", + "0.0.0.0", + "--port", + "8080", + "--parallel", + "1", + "-ngl", + "auto" + ], + "env": {}, + "notes": [ + "cache KV q8_0/f16 (défini par LLAMA_ARG_CACHE_TYPE_K/V) : modifie légèrement les sorties par rapport à f16 — choix de l'utilisateur, laissé tel quel." + ] + }, + { + "m": 4, + "c": 27, + "h": 0, + "e": 1, + "args": [ + "/app/llama-server", + "-m", + "/m/hybrid.gguf", + "-c", + "65536", + "-b", + "2048", + "-ub", + "512", + "--host", + "0.0.0.0", + "--port", + "8080", + "--parallel", + "2", + "--no-kv-unified", + "-ngl", + "auto" + ], + "env": { + "CUDA_SCALE_LAUNCH_QUEUES": "4x" + }, + "notes": [ + "CUDA_SCALE_LAUNCH_QUEUES=4x (2 GPU, parallélisme pipeline possible) — CUDA_LAUNCH_QUEUES=off pour s'en passer", + "SIDE_SLOT : 2 slots de 32768 jetons (-c 65536, --no-kv-unified), ~1718 Mio de VRAM en plus pour le second (cache KV et état récurrent ; le brouillon MTP éventuel et les points de reprise en RAM hôte doublent aussi) ; le placement automatique sortira des couches du GPU s'il manque de place", + "cache KV q8_0/f16 (défini par LLAMA_ARG_CACHE_TYPE_K/V) : modifie légèrement les sorties par rapport à f16 — choix de l'utilisateur, laissé tel quel." + ] + }, + { + "m": 8, + "c": 31, + "h": 0, + "e": 1, + "args": [ + "/app/llama-server", + "-m", + "/m/dense.gguf", + "-c", + "32768", + "-b", + "2048", + "-ub", + "512", + "--host", + "0.0.0.0", + "--port", + "8080", + "--parallel", + "1", + "-ngl", + "auto" + ], + "env": {}, + "notes": [ + "SPLIT_MODE=tensor refusé (cache KV q8_0/f16 : le mode tensor n'accepte que f16, bf16 ou f32 (jamais réécrit d'office : la VRAM changerait)) : découpe par couches, comme sans la clé", + "cache KV q8_0/f16 (défini par LLAMA_ARG_CACHE_TYPE_K/V) : modifie légèrement les sorties par rapport à f16 — choix de l'utilisateur, laissé tel quel." + ] + }, + { + "m": 2, + "c": 37, + "h": 0, + "e": 1, + "args": [ + "/app/llama-server", + "-m", + "/m/inconnu.gguf", + "-c", + "32768", + "-b", + "2048", + "-ub", + "512", + "--host", + "0.0.0.0", + "--port", + "8080", + "--parallel", + "1", + "-ngl", + "auto" + ], + "env": {}, + "notes": [ + "SPEC=auto : métadonnées du modèle illisibles — sans décodage spéculatif", + "cache KV q8_0/f16 (défini par LLAMA_ARG_CACHE_TYPE_K/V) : modifie légèrement les sorties par rapport à f16 — choix de l'utilisateur, laissé tel quel." + ] + }, + { + "m": 4, + "c": 39, + "h": 0, + "e": 1, + "args": [ + "/app/llama-server", + "-m", + "/m/hybrid.gguf", + "-c", + "32768", + "-b", + "2048", + "-ub", + "512", + "--host", + "0.0.0.0", + "--port", + "8080", + "--parallel", + "1", + "-ngl", + "auto", + "--spec-type", + "ngram-mod", + "--spec-ngram-mod-n-match", + "24", + "--spec-ngram-mod-n-min", + "48", + "--spec-ngram-mod-n-max", + "64" + ], + "env": { + "CUDA_SCALE_LAUNCH_QUEUES": "4x" + }, + "notes": [ + "CUDA_SCALE_LAUNCH_QUEUES=4x (2 GPU, parallélisme pipeline possible) — CUDA_LAUNCH_QUEUES=off pour s'en passer", + "SPEC=ngram → n-grammes du contexte (ngram-mod, brouillons de 48 à 64 jetons) : distribution inchangée (chaque jeton vérifié), ~16 Mo de RAM ; utile surtout en mode code, à mesurer (acceptation par nature dans /api/perf/summary)", + "tête MTP du modèle inutilisée avec SPEC=ngram : SPEC=mtp+ngram pour les deux", + "modèle hybride : chaque brouillon n-gramme copie l'état récurrent (point de reprise en RAM hôte) et le recharge s'il est rejeté — compare le temps par tour, pas seulement le débit", + "cache KV q8_0/f16 (défini par LLAMA_ARG_CACHE_TYPE_K/V) : modifie légèrement les sorties par rapport à f16 — choix de l'utilisateur, laissé tel quel." + ] + }, + { + "m": 5, + "c": 40, + "h": 0, + "e": 1, + "args": [ + "/app/llama-server", + "-m", + "/m/ds.gguf", + "-c", + "32768", + "-b", + "2048", + "-ub", + "512", + "--host", + "0.0.0.0", + "--port", + "8080", + "--parallel", + "1", + "-ngl", + "auto", + "--api-key", + "sk-loki-x", + "--spec-type", + "ngram-mod", + "--spec-ngram-mod-n-match", + "24", + "--spec-ngram-mod-n-min", + "48", + "--spec-ngram-mod-n-max", + "64" + ], + "env": {}, + "notes": [ + "SPEC=mtp+ngram : sans MTP (pas de tête MTP dans ce fichier (publiée à part ? MODEL_DRAFT=mtp-….gguf)) → n-grammes seuls. SPEC=ngram → n-grammes du contexte (ngram-mod, brouillons de 48 à 64 jetons) : distribution inchangée (chaque jeton vérifié), ~16 Mo de RAM ; utile surtout en mode code, à mesurer (acceptation par nature dans /api/perf/summary)", + "cache KV q8_0/f16 (défini par LLAMA_ARG_CACHE_TYPE_K/V) : modifie légèrement les sorties par rapport à f16 — choix de l'utilisateur, laissé tel quel." + ] + }, + { + "m": 10, + "c": 57, + "h": 0, + "e": 1, + "args": [ + "/app/llama-server", + "-m", + "/m/hybrid.gguf", + "-c", + "32768", + "-b", + "2048", + "-ub", + "512", + "--host", + "0.0.0.0", + "--port", + "8080", + "--parallel", + "1", + "-ngl", + "auto", + "--fit-target", + "1024" + ], + "env": {}, + "notes": [ + "--fit-target 1024 (FIT_TARGET, Mio libres par carte, dans l'ordre vu par le moteur) — vérifie « offloaded N/N layers » dans le journal ; marges sous 1024 Mio remontées à 1024", + "cache KV q8_0/f16 (défini par LLAMA_ARG_CACHE_TYPE_K/V) : modifie légèrement les sorties par rapport à f16 — choix de l'utilisateur, laissé tel quel." + ] + }, + { + "m": 6, + "c": 65, + "h": 0, + "e": 1, + "args": [ + "/app/llama-server", + "-m", + "/m/layers.gguf", + "-c", + "32768", + "-t", + "4", + "-b", + "2048", + "-ub", + "512", + "--host", + "0.0.0.0", + "--port", + "8080", + "--parallel", + "1", + "-ngl", + "auto", + "--mmproj", + "/m/mmproj.gguf" + ], + "env": {}, + "notes": [ + "CUDA_GRAPH_OPT=oui ignoré (on ou off)", + "threads auto → -t 4 : cpuset du conteneur, 4 cœurs physiques permis sur 8 (llama.cpp seul en lancerait 8)", + "cache KV q8_0/f16 (défini par LLAMA_ARG_CACHE_TYPE_K/V) : modifie légèrement les sorties par rapport à f16 — choix de l'utilisateur, laissé tel quel." + ] + }, + { + "m": 10, + "c": 69, + "h": 0, + "e": 1, + "args": [ + "/app/llama-server", + "-m", + "/m/hybrid.gguf", + "-c", + "32768", + "-b", + "2048", + "-ub", + "512", + "--host", + "0.0.0.0", + "--port", + "8080", + "--parallel", + "1", + "-ngl", + "auto", + "--checkpoint-min-step", + "1024" + ], + "env": {}, + "notes": [ + "cache KV q8_0/f16 (défini par LLAMA_ARG_CACHE_TYPE_K/V) : modifie légèrement les sorties par rapport à f16 — choix de l'utilisateur, laissé tel quel." + ] + }, + { + "m": 6, + "c": 77, + "h": 0, + "e": 1, + "args": [ + "/app/llama-server", + "-m", + "/m/layers.gguf", + "-c", + "32768", + "-t", + "4", + "-b", + "2048", + "-ub", + "512", + "--host", + "0.0.0.0", + "--port", + "8080", + "--parallel", + "1", + "-ngl", + "auto", + "--mmproj", + "/m/mmproj.gguf", + "--load-mode", + "none" + ], + "env": {}, + "notes": [ + "threads auto → -t 4 : cpuset du conteneur, 4 cœurs physiques permis sur 8 (llama.cpp seul en lancerait 8)", + "cache KV q8_0/f16 (défini par LLAMA_ARG_CACHE_TYPE_K/V) : modifie légèrement les sorties par rapport à f16 — choix de l'utilisateur, laissé tel quel." + ] + }, + { + "m": 1, + "c": 84, + "h": 0, + "e": 1, + "args": [ + "/app/llama-server", + "-m", + "/m/moe.gguf", + "-c", + "32768", + "-t", + "6", + "-tb", + "6", + "-b", + "2048", + "-ub", + "512", + "--host", + "0.0.0.0", + "--port", + "8080", + "--parallel", + "1", + "-ngl", + "auto", + "--prio-batch", + "2" + ], + "env": { + "CUDA_SCALE_LAUNCH_QUEUES": "4x" + }, + "notes": [ + "CUDA_SCALE_LAUNCH_QUEUES=4x (2 GPU, parallélisme pipeline possible) — CUDA_LAUNCH_QUEUES=off pour s'en passer", + "cache KV q8_0/f16 (défini par LLAMA_ARG_CACHE_TYPE_K/V) : modifie légèrement les sorties par rapport à f16 — choix de l'utilisateur, laissé tel quel." + ] + }, + { + "m": 9, + "c": 92, + "h": 0, + "e": 1, + "args": [ + "/app/llama-server", + "-m", + "/m/dense.gguf", + "-c", + "32768", + "-b", + "2048", + "-ub", + "512", + "--host", + "0.0.0.0", + "--port", + "8080", + "--parallel", + "1", + "-ngl", + "auto", + "-ts", + "1,1" + ], + "env": {}, + "notes": [ + "cache KV q8_0/f16 (défini par LLAMA_ARG_CACHE_TYPE_K/V) : modifie légèrement les sorties par rapport à f16 — choix de l'utilisateur, laissé tel quel." + ] + }, + { + "m": 10, + "c": 93, + "h": 0, + "e": 1, + "args": [ + "/app/llama-server", + "-m", + "/m/hybrid.gguf", + "-c", + "32768", + "-b", + "2048", + "-ub", + "512", + "--host", + "0.0.0.0", + "--port", + "8080", + "--parallel", + "1", + "-ngl", + "auto", + "-dev", + "CUDA0,CUDA1" + ], + "env": { + "CUDA_SCALE_LAUNCH_QUEUES": "4x" + }, + "notes": [ + "CUDA_SCALE_LAUNCH_QUEUES=4x (2 GPU, parallélisme pipeline possible) — CUDA_LAUNCH_QUEUES=off pour s'en passer", + "cache KV q8_0/f16 (défini par LLAMA_ARG_CACHE_TYPE_K/V) : modifie légèrement les sorties par rapport à f16 — choix de l'utilisateur, laissé tel quel." + ] + }, + { + "m": 2, + "c": 0, + "h": 0, + "e": 2, + "args": [ + "/app/llama-server", + "-m", + "/m/inconnu.gguf", + "-c", + "32768", + "-b", + "2048", + "-ub", + "512", + "--host", + "0.0.0.0", + "--port", + "8080", + "--parallel", + "1", + "-ngl", + "auto" + ], + "env": {}, + "notes": null + }, + { + "m": 3, + "c": 1, + "h": 0, + "e": 2, + "args": [ + "/app/llama-server", + "-m", + "/m/hybrid.gguf", + "-c", + "65536", + "-b", + "4096", + "-ub", + "2048", + "--host", + "127.0.0.1", + "--port", + "8081", + "--parallel", + "1", + "-ngl", + "auto" + ], + "env": {}, + "notes": [ + "NGL=999 (sentinelle « toutes les couches ») → -ngl auto : ce moteur mesure lui-même la VRAM libre. NGL=all pour forcer l'ancien comportement.", + "modèle hybride de 20 Gio pour 24 Gio de RAM : jusqu'à 32 points de reprise (~150 Mio chacun) prennent sur le cache de pages des poids. CTX_CHECKPOINTS=8 à 16 si le débit baisse.", + "moteur b9000 : les points de reprise des modèles hybrides (Qwen3.5/3.6, Qwen3-Next…) y sont évincés trop tôt — jusqu'à ~8 k jetons recalculés à chaque reprise. Corrigé à partir de b10864. Pas d'espacement resserré d'office : RAM inconnue ou déjà prise par le modèle (CKPT_MIN_STEP=2048 pour l'imposer)." + ] + }, + { + "m": 11, + "c": 9, + "h": 0, + "e": 2, + "args": [ + "/app/llama-server", + "-m", + "/m/dense.gguf", + "-c", + "32768", + "-b", + "2048", + "-ub", + "512", + "--host", + "0.0.0.0", + "--port", + "8080", + "--parallel", + "1", + "-ngl", + "auto", + "-ctk", + "q8_0", + "-ctv", + "q8_0" + ], + "env": {}, + "notes": [ + "cache KV q8_0/q8_0 (KV_TYPE) : modifie légèrement les sorties par rapport à f16 — choix de l'utilisateur, laissé tel quel." + ] + }, + { + "m": 10, + "c": 20, + "h": 0, + "e": 2, + "args": [ + "/app/llama-server", + "-m", + "/m/hybrid.gguf", + "-c", + "32768", + "-b", + "2048", + "-ub", + "512", + "--host", + "0.0.0.0", + "--port", + "8080", + "--parallel", + "1", + "-ngl", + "auto" + ], + "env": {}, + "notes": null + }, + { + "m": 5, + "c": 27, + "h": 0, + "e": 2, + "args": [ + "/app/llama-server", + "-m", + "/m/ds.gguf", + "-c", + "32768", + "-b", + "2048", + "-ub", + "512", + "--host", + "0.0.0.0", + "--port", + "8080", + "--parallel", + "1", + "-ngl", + "auto", + "--api-key", + "sk-loki-x" + ], + "env": {}, + "notes": [ + "SIDE_SLOT refusé (VRAM NVIDIA inconnue (macOS, AMD, --device…)) : un seul slot, comme sans la clé" + ] + }, + { + "m": 9, + "c": 31, + "h": 0, + "e": 2, + "args": [ + "/app/llama-server", + "-m", + "/m/dense.gguf", + "-c", + "32768", + "-b", + "2048", + "-ub", + "512", + "--host", + "0.0.0.0", + "--port", + "8080", + "--parallel", + "1", + "-ngl", + "auto" + ], + "env": {}, + "notes": [ + "SPLIT_MODE=tensor refusé (cache KV sur CPU) : découpe par couches, comme sans la clé" + ] + }, + { + "m": 3, + "c": 37, + "h": 0, + "e": 2, + "args": [ + "/app/llama-server", + "-m", + "/m/hybrid.gguf", + "-c", + "32768", + "-b", + "2048", + "-ub", + "512", + "--host", + "0.0.0.0", + "--port", + "8080", + "--parallel", + "1", + "-ngl", + "auto" + ], + "env": {}, + "notes": [ + "modèle hybride de 20 Gio pour 24 Gio de RAM : jusqu'à 32 points de reprise (~150 Mio chacun) prennent sur le cache de pages des poids. CTX_CHECKPOINTS=8 à 16 si le débit baisse.", + "moteur b9000 : les points de reprise des modèles hybrides (Qwen3.5/3.6, Qwen3-Next…) y sont évincés trop tôt — jusqu'à ~8 k jetons recalculés à chaque reprise. Corrigé à partir de b10864. Pas d'espacement resserré d'office : RAM inconnue ou déjà prise par le modèle (CKPT_MIN_STEP=2048 pour l'imposer).", + "SPEC=auto : moteur b9000, b11009 au moins — sans décodage spéculatif" + ] + }, + { + "m": 5, + "c": 39, + "h": 0, + "e": 2, + "args": [ + "/app/llama-server", + "-m", + "/m/ds.gguf", + "-c", + "32768", + "-b", + "2048", + "-ub", + "512", + "--host", + "0.0.0.0", + "--port", + "8080", + "--parallel", + "1", + "-ngl", + "auto", + "--api-key", + "sk-loki-x" + ], + "env": {}, + "notes": [ + "SPEC=ngram : cache KV sur CPU : un lot de vérification de 65 jetons atteint GGML_OP_OFFLOAD_MIN_BATCH (32) et recopierait ces poids vers le GPU à chaque brouillon — OP_OFFLOAD_MIN_BATCH=128 pour l'essayer, à mesurer — sans décodage spéculatif" + ] + }, + { + "m": 6, + "c": 40, + "h": 0, + "e": 2, + "args": [ + "/app/llama-server", + "-m", + "/m/layers.gguf", + "-c", + "32768", + "-t", + "4", + "-b", + "2048", + "-ub", + "512", + "--host", + "0.0.0.0", + "--port", + "8080", + "--parallel", + "1", + "-ngl", + "auto", + "--mmproj", + "/m/mmproj.gguf" + ], + "env": {}, + "notes": [ + "threads auto → -t 4 : cpuset du conteneur, 4 cœurs physiques permis sur 8 (llama.cpp seul en lancerait 8)", + "SPEC=mtp+ngram : cache KV sur CPU : un lot de vérification de 65 jetons atteint GGML_OP_OFFLOAD_MIN_BATCH (64) et recopierait ces poids vers le GPU à chaque brouillon — OP_OFFLOAD_MIN_BATCH=128 pour l'essayer, à mesurer — sans décodage spéculatif" + ] + }, + { + "m": 11, + "c": 57, + "h": 0, + "e": 2, + "args": [ + "/app/llama-server", + "-m", + "/m/dense.gguf", + "-c", + "32768", + "-b", + "2048", + "-ub", + "512", + "--host", + "0.0.0.0", + "--port", + "8080", + "--parallel", + "1", + "-ngl", + "auto", + "--fit-target", + "1024" + ], + "env": {}, + "notes": [ + "--fit-target 1024 (FIT_TARGET, Mio libres par carte, dans l'ordre vu par le moteur) — vérifie « offloaded N/N layers » dans le journal ; marges sous 1024 Mio remontées à 1024" + ] + }, + { + "m": 7, + "c": 65, + "h": 0, + "e": 2, + "args": [ + "/app/llama-server", + "-m", + "/m/dense.gguf", + "-c", + "32768", + "-b", + "2048", + "-ub", + "512", + "--host", + "0.0.0.0", + "--port", + "8080", + "--parallel", + "1", + "-ngl", + "auto" + ], + "env": {}, + "notes": [ + "CUDA_GRAPH_OPT=oui ignoré (on ou off)" + ] + }, + { + "m": 11, + "c": 69, + "h": 0, + "e": 2, + "args": [ + "/app/llama-server", + "-m", + "/m/dense.gguf", + "-c", + "32768", + "-b", + "2048", + "-ub", + "512", + "--host", + "0.0.0.0", + "--port", + "8080", + "--parallel", + "1", + "-ngl", + "auto", + "--checkpoint-min-step", + "1024" + ], + "env": {}, + "notes": null + }, + { + "m": 7, + "c": 77, + "h": 0, + "e": 2, + "args": [ + "/app/llama-server", + "-m", + "/m/dense.gguf", + "-c", + "32768", + "-b", + "2048", + "-ub", + "512", + "--host", + "0.0.0.0", + "--port", + "8080", + "--parallel", + "1", + "-ngl", + "auto", + "--load-mode", + "none" + ], + "env": {}, + "notes": null + }, + { + "m": 2, + "c": 84, + "h": 0, + "e": 2, + "args": [ + "/app/llama-server", + "-m", + "/m/inconnu.gguf", + "-c", + "32768", + "-t", + "6", + "-tb", + "6", + "-b", + "2048", + "-ub", + "512", + "--host", + "0.0.0.0", + "--port", + "8080", + "--parallel", + "1", + "-ngl", + "auto", + "--prio-batch", + "2" + ], + "env": {}, + "notes": null + }, + { + "m": 10, + "c": 92, + "h": 0, + "e": 2, + "args": [ + "/app/llama-server", + "-m", + "/m/hybrid.gguf", + "-c", + "32768", + "-b", + "2048", + "-ub", + "512", + "--host", + "0.0.0.0", + "--port", + "8080", + "--parallel", + "1", + "-ngl", + "auto", + "-ts", + "1,1" + ], + "env": {}, + "notes": null + }, + { + "m": 11, + "c": 93, + "h": 0, + "e": 2, + "args": [ + "/app/llama-server", + "-m", + "/m/dense.gguf", + "-c", + "32768", + "-b", + "2048", + "-ub", + "512", + "--host", + "0.0.0.0", + "--port", + "8080", + "--parallel", + "1", + "-ngl", + "auto", + "-dev", + "CUDA0,CUDA1" + ], + "env": {}, + "notes": null + }, + { + "m": 3, + "c": 0, + "h": 0, + "e": 3, + "args": [ + "/app/llama-server", + "-m", + "/m/hybrid.gguf", + "-c", + "32768", + "-b", + "2048", + "-ub", + "512", + "--host", + "0.0.0.0", + "--port", + "8080", + "--parallel", + "1", + "-ngl", + "auto" + ], + "env": {}, + "notes": [ + "modèle hybride de 20 Gio pour 24 Gio de RAM : jusqu'à 32 points de reprise (~150 Mio chacun) prennent sur le cache de pages des poids. CTX_CHECKPOINTS=8 à 16 si le débit baisse.", + "moteur b9000 : les points de reprise des modèles hybrides (Qwen3.5/3.6, Qwen3-Next…) y sont évincés trop tôt — jusqu'à ~8 k jetons recalculés à chaque reprise. Corrigé à partir de b10864. Pas d'espacement resserré d'office : RAM inconnue ou déjà prise par le modèle (CKPT_MIN_STEP=2048 pour l'imposer)." + ] + }, + { + "m": 4, + "c": 1, + "h": 0, + "e": 3, + "args": [ + "/app/llama-server", + "-m", + "/m/hybrid.gguf", + "-c", + "65536", + "-b", + "4096", + "-ub", + "2048", + "--host", + "127.0.0.1", + "--port", + "8081", + "--parallel", + "1", + "-ngl", + "auto", + "--slot-save-path", + "/data/slots" + ], + "env": {}, + "notes": [ + "NGL=999 (sentinelle « toutes les couches ») → -ngl auto : ce moteur mesure lui-même la VRAM libre. NGL=all pour forcer l'ancien comportement." + ] + }, + { + "m": 0, + "c": 9, + "h": 0, + "e": 3, + "args": [ + "/app/llama-server", + "-m", + "/m/dense.gguf", + "-c", + "32768", + "-b", + "2048", + "-ub", + "512", + "--host", + "0.0.0.0", + "--port", + "8080", + "--parallel", + "1", + "-ngl", + "auto", + "-ctk", + "q8_0", + "-ctv", + "q8_0" + ], + "env": {}, + "notes": [ + "cache KV q8_0/q8_0 (KV_TYPE) : modifie légèrement les sorties par rapport à f16 — choix de l'utilisateur, laissé tel quel. Repasser en f16 demande plus de VRAM, et le placement fixé par -ot/--n-cpu-moe empêche --fit de compenser : il faudrait sans doute relever --n-cpu-moe." + ] + }, + { + "m": 11, + "c": 20, + "h": 0, + "e": 3, + "args": [ + "/app/llama-server", + "-m", + "/m/dense.gguf", + "-c", + "32768", + "-b", + "2048", + "-ub", + "512", + "--host", + "0.0.0.0", + "--port", + "8080", + "--parallel", + "1", + "-ngl", + "auto" + ], + "env": {}, + "notes": null + }, + { + "m": 6, + "c": 27, + "h": 0, + "e": 3, + "args": [ + "/app/llama-server", + "-m", + "/m/layers.gguf", + "-c", + "32768", + "-t", + "4", + "-b", + "2048", + "-ub", + "512", + "--host", + "0.0.0.0", + "--port", + "8080", + "--parallel", + "1", + "-ngl", + "auto", + "--mmproj", + "/m/mmproj.gguf" + ], + "env": {}, + "notes": [ + "threads auto → -t 4 : cpuset du conteneur, 4 cœurs physiques permis sur 8 (llama.cpp seul en lancerait 8)", + "SIDE_SLOT refusé (VRAM NVIDIA inconnue (macOS, AMD, --device…)) : un seul slot, comme sans la clé" + ] + }, + { + "m": 10, + "c": 31, + "h": 0, + "e": 3, + "args": [ + "/app/llama-server", + "-m", + "/m/hybrid.gguf", + "-c", + "32768", + "-b", + "2048", + "-ub", + "512", + "--host", + "0.0.0.0", + "--port", + "8080", + "--parallel", + "1", + "-ngl", + "auto" + ], + "env": {}, + "notes": [ + "SPLIT_MODE=tensor refusé (couches --n-cpu-moe sur CPU : le mode tensor met tout sur les cartes) : découpe par couches, comme sans la clé" + ] + }, + { + "m": 4, + "c": 37, + "h": 0, + "e": 3, + "args": [ + "/app/llama-server", + "-m", + "/m/hybrid.gguf", + "-c", + "32768", + "-b", + "2048", + "-ub", + "512", + "--host", + "0.0.0.0", + "--port", + "8080", + "--parallel", + "1", + "-ngl", + "auto" + ], + "env": {}, + "notes": [ + "SPEC=auto : placement manuel (couches --n-cpu-moe sur CPU) — --fit ne compte pas la VRAM du brouillon — sans décodage spéculatif" + ] + }, + { + "m": 6, + "c": 39, + "h": 0, + "e": 3, + "args": [ + "/app/llama-server", + "-m", + "/m/layers.gguf", + "-c", + "32768", + "-t", + "4", + "-b", + "2048", + "-ub", + "512", + "--host", + "0.0.0.0", + "--port", + "8080", + "--parallel", + "1", + "-ngl", + "auto", + "--mmproj", + "/m/mmproj.gguf" + ], + "env": {}, + "notes": [ + "threads auto → -t 4 : cpuset du conteneur, 4 cœurs physiques permis sur 8 (llama.cpp seul en lancerait 8)", + "SPEC=ngram : couches --n-cpu-moe sur CPU : un lot de vérification de 65 jetons atteint GGML_OP_OFFLOAD_MIN_BATCH (32) et recopierait ces poids vers le GPU à chaque brouillon — OP_OFFLOAD_MIN_BATCH=128 pour l'essayer, à mesurer — sans décodage spéculatif" + ] + }, + { + "m": 7, + "c": 40, + "h": 0, + "e": 3, + "args": [ + "/app/llama-server", + "-m", + "/m/dense.gguf", + "-c", + "32768", + "-b", + "2048", + "-ub", + "512", + "--host", + "0.0.0.0", + "--port", + "8080", + "--parallel", + "1", + "-ngl", + "auto" + ], + "env": {}, + "notes": [ + "SPEC=mtp+ngram : couches --n-cpu-moe sur CPU : un lot de vérification de 65 jetons atteint GGML_OP_OFFLOAD_MIN_BATCH (32) et recopierait ces poids vers le GPU à chaque brouillon — OP_OFFLOAD_MIN_BATCH=128 pour l'essayer, à mesurer — sans décodage spéculatif" + ] + }, + { + "m": 0, + "c": 57, + "h": 0, + "e": 3, + "args": [ + "/app/llama-server", + "-m", + "/m/dense.gguf", + "-c", + "32768", + "-b", + "2048", + "-ub", + "512", + "--host", + "0.0.0.0", + "--port", + "8080", + "--parallel", + "1", + "-ngl", + "auto" + ], + "env": {}, + "notes": [ + "FIT_TARGET=512 ignoré : --fit inactif (couches --n-cpu-moe sur CPU), la marge ne servirait à rien" + ] + }, + { + "m": 8, + "c": 65, + "h": 0, + "e": 3, + "args": [ + "/app/llama-server", + "-m", + "/m/dense.gguf", + "-c", + "32768", + "-b", + "2048", + "-ub", + "512", + "--host", + "0.0.0.0", + "--port", + "8080", + "--parallel", + "1", + "-ngl", + "auto" + ], + "env": {}, + "notes": [ + "CUDA_GRAPH_OPT=oui ignoré (on ou off)" + ] + }, + { + "m": 0, + "c": 69, + "h": 0, + "e": 3, + "args": [ + "/app/llama-server", + "-m", + "/m/dense.gguf", + "-c", + "32768", + "-b", + "2048", + "-ub", + "512", + "--host", + "0.0.0.0", + "--port", + "8080", + "--parallel", + "1", + "-ngl", + "auto", + "--checkpoint-min-step", + "1024" + ], + "env": {}, + "notes": null + }, + { + "m": 8, + "c": 77, + "h": 0, + "e": 3, + "args": [ + "/app/llama-server", + "-m", + "/m/dense.gguf", + "-c", + "32768", + "-b", + "2048", + "-ub", + "512", + "--host", + "0.0.0.0", + "--port", + "8080", + "--parallel", + "1", + "-ngl", + "auto", + "--load-mode", + "none" + ], + "env": {}, + "notes": null + }, + { + "m": 3, + "c": 84, + "h": 0, + "e": 3, + "args": [ + "/app/llama-server", + "-m", + "/m/hybrid.gguf", + "-c", + "32768", + "-t", + "6", + "-tb", + "6", + "-b", + "2048", + "-ub", + "512", + "--host", + "0.0.0.0", + "--port", + "8080", + "--parallel", + "1", + "-ngl", + "auto", + "--prio-batch", + "2" + ], + "env": {}, + "notes": [ + "modèle hybride de 20 Gio pour 24 Gio de RAM : jusqu'à 32 points de reprise (~150 Mio chacun) prennent sur le cache de pages des poids. CTX_CHECKPOINTS=8 à 16 si le débit baisse.", + "moteur b9000 : les points de reprise des modèles hybrides (Qwen3.5/3.6, Qwen3-Next…) y sont évincés trop tôt — jusqu'à ~8 k jetons recalculés à chaque reprise. Corrigé à partir de b10864. Pas d'espacement resserré d'office : RAM inconnue ou déjà prise par le modèle (CKPT_MIN_STEP=2048 pour l'imposer)." + ] + }, + { + "m": 11, + "c": 92, + "h": 0, + "e": 3, + "args": [ + "/app/llama-server", + "-m", + "/m/dense.gguf", + "-c", + "32768", + "-b", + "2048", + "-ub", + "512", + "--host", + "0.0.0.0", + "--port", + "8080", + "--parallel", + "1", + "-ngl", + "auto", + "-ts", + "1,1" + ], + "env": {}, + "notes": null + }, + { + "m": 0, + "c": 93, + "h": 0, + "e": 3, + "args": [ + "/app/llama-server", + "-m", + "/m/dense.gguf", + "-c", + "32768", + "-b", + "2048", + "-ub", + "512", + "--host", + "0.0.0.0", + "--port", + "8080", + "--parallel", + "1", + "-ngl", + "auto", + "-dev", + "CUDA0,CUDA1" + ], + "env": {}, + "notes": null + }, + { + "m": 4, + "c": 0, + "h": 0, + "e": 4, + "args": [ + "/app/llama-server", + "-m", + "/m/hybrid.gguf", + "-c", + "32768", + "-b", + "2048", + "-ub", + "512", + "--host", + "0.0.0.0", + "--port", + "8080", + "--parallel", + "1", + "-ngl", + "auto" + ], + "env": { + "CUDA_SCALE_LAUNCH_QUEUES": "4x" + }, + "notes": [ + "CUDA_SCALE_LAUNCH_QUEUES=4x (2 GPU, parallélisme pipeline possible) — CUDA_LAUNCH_QUEUES=off pour s'en passer" + ] + }, + { + "m": 5, + "c": 1, + "h": 0, + "e": 4, + "args": [ + "/app/llama-server", + "-m", + "/m/ds.gguf", + "-c", + "65536", + "-b", + "4096", + "-ub", + "2048", + "--host", + "127.0.0.1", + "--port", + "8081", + "--parallel", + "1", + "-ngl", + "auto", + "--api-key", + "sk-loki-x" + ], + "env": {}, + "notes": [ + "NGL=999 (sentinelle « toutes les couches ») → -ngl auto : ce moteur mesure lui-même la VRAM libre. NGL=all pour forcer l'ancien comportement." + ] + }, + { + "m": 1, + "c": 9, + "h": 0, + "e": 4, + "args": [ + "/app/llama-server", + "-m", + "/m/moe.gguf", + "-c", + "32768", + "-b", + "2048", + "-ub", + "512", + "--host", + "0.0.0.0", + "--port", + "8080", + "--parallel", + "1", + "-ngl", + "auto", + "-ctk", + "q8_0", + "-ctv", + "q8_0" + ], + "env": { + "CUDA_SCALE_LAUNCH_QUEUES": "4x" + }, + "notes": [ + "CUDA_SCALE_LAUNCH_QUEUES=4x (2 GPU, parallélisme pipeline possible) — CUDA_LAUNCH_QUEUES=off pour s'en passer", + "cache KV q8_0/q8_0 (KV_TYPE) : modifie légèrement les sorties par rapport à f16 — choix de l'utilisateur, laissé tel quel." + ] + }, + { + "m": 0, + "c": 20, + "h": 0, + "e": 4, + "args": [ + "/app/llama-server", + "-m", + "/m/dense.gguf", + "-c", + "32768", + "-b", + "2048", + "-ub", + "512", + "--host", + "0.0.0.0", + "--port", + "8080", + "--parallel", + "1", + "-ngl", + "auto" + ], + "env": {}, + "notes": null + }, + { + "m": 7, + "c": 27, + "h": 0, + "e": 4, + "args": [ + "/app/llama-server", + "-m", + "/m/dense.gguf", + "-c", + "32768", + "-b", + "2048", + "-ub", + "512", + "--host", + "0.0.0.0", + "--port", + "8080", + "--parallel", + "1", + "-ngl", + "auto" + ], + "env": { + "CUDA_SCALE_LAUNCH_QUEUES": "4x" + }, + "notes": [ + "CUDA_SCALE_LAUNCH_QUEUES=4x (2 GPU, parallélisme pipeline possible) — CUDA_LAUNCH_QUEUES=off pour s'en passer", + "SIDE_SLOT refusé (VRAM insuffisante : modèle + 2 × 8192 Mio d'état dépassent 90 % de 24000 Mio) : un seul slot, comme sans la clé" + ] + }, + { + "m": 11, + "c": 31, + "h": 0, + "e": 4, + "args": [ + "/app/llama-server", + "-m", + "/m/dense.gguf", + "-c", + "32768", + "-b", + "2048", + "-ub", + "512", + "--host", + "0.0.0.0", + "--port", + "8080", + "--parallel", + "1", + "-ngl", + "auto" + ], + "env": {}, + "notes": [ + "SPLIT_MODE=tensor refusé (cartes inconnues (--list-devices n'a rien répondu)) : découpe par couches, comme sans la clé" + ] + }, + { + "m": 5, + "c": 37, + "h": 0, + "e": 4, + "args": [ + "/app/llama-server", + "-m", + "/m/ds.gguf", + "-c", + "32768", + "-b", + "2048", + "-ub", + "512", + "--host", + "0.0.0.0", + "--port", + "8080", + "--parallel", + "1", + "-ngl", + "auto", + "--api-key", + "sk-loki-x" + ], + "env": {}, + "notes": null + }, + { + "m": 7, + "c": 39, + "h": 0, + "e": 4, + "args": [ + "/app/llama-server", + "-m", + "/m/dense.gguf", + "-c", + "32768", + "-b", + "2048", + "-ub", + "512", + "--host", + "0.0.0.0", + "--port", + "8080", + "--parallel", + "1", + "-ngl", + "auto", + "--spec-type", + "ngram-mod", + "--spec-ngram-mod-n-match", + "24", + "--spec-ngram-mod-n-min", + "48", + "--spec-ngram-mod-n-max", + "64" + ], + "env": { + "CUDA_SCALE_LAUNCH_QUEUES": "4x" + }, + "notes": [ + "CUDA_SCALE_LAUNCH_QUEUES=4x (2 GPU, parallélisme pipeline possible) — CUDA_LAUNCH_QUEUES=off pour s'en passer", + "SPEC=ngram → n-grammes du contexte (ngram-mod, brouillons de 48 à 64 jetons) : distribution inchangée (chaque jeton vérifié), ~16 Mo de RAM ; utile surtout en mode code, à mesurer (acceptation par nature dans /api/perf/summary)" + ] + }, + { + "m": 8, + "c": 40, + "h": 0, + "e": 4, + "args": [ + "/app/llama-server", + "-m", + "/m/dense.gguf", + "-c", + "32768", + "-b", + "2048", + "-ub", + "512", + "--host", + "0.0.0.0", + "--port", + "8080", + "--parallel", + "1", + "-ngl", + "auto", + "--spec-type", + "ngram-mod", + "--spec-ngram-mod-n-match", + "24", + "--spec-ngram-mod-n-min", + "48", + "--spec-ngram-mod-n-max", + "64" + ], + "env": {}, + "notes": [ + "SPEC=mtp+ngram : sans MTP (pas de tête MTP dans ce fichier (publiée à part ? MODEL_DRAFT=mtp-….gguf)) → n-grammes seuls. SPEC=ngram → n-grammes du contexte (ngram-mod, brouillons de 48 à 64 jetons) : distribution inchangée (chaque jeton vérifié), ~16 Mo de RAM ; utile surtout en mode code, à mesurer (acceptation par nature dans /api/perf/summary)" + ] + }, + { + "m": 1, + "c": 57, + "h": 0, + "e": 4, + "args": [ + "/app/llama-server", + "-m", + "/m/moe.gguf", + "-c", + "32768", + "-b", + "2048", + "-ub", + "512", + "--host", + "0.0.0.0", + "--port", + "8080", + "--parallel", + "1", + "-ngl", + "auto", + "--fit-target", + "1024" + ], + "env": { + "CUDA_SCALE_LAUNCH_QUEUES": "4x" + }, + "notes": [ + "CUDA_SCALE_LAUNCH_QUEUES=4x (2 GPU, parallélisme pipeline possible) — CUDA_LAUNCH_QUEUES=off pour s'en passer", + "--fit-target 1024 (FIT_TARGET, Mio libres par carte, dans l'ordre vu par le moteur) — vérifie « offloaded N/N layers » dans le journal ; marges sous 1024 Mio remontées à 1024" + ] + }, + { + "m": 9, + "c": 65, + "h": 0, + "e": 4, + "args": [ + "/app/llama-server", + "-m", + "/m/dense.gguf", + "-c", + "32768", + "-b", + "2048", + "-ub", + "512", + "--host", + "0.0.0.0", + "--port", + "8080", + "--parallel", + "1", + "-ngl", + "auto" + ], + "env": {}, + "notes": [ + "CUDA_GRAPH_OPT=oui ignoré (on ou off)" + ] + }, + { + "m": 1, + "c": 69, + "h": 0, + "e": 4, + "args": [ + "/app/llama-server", + "-m", + "/m/moe.gguf", + "-c", + "32768", + "-b", + "2048", + "-ub", + "512", + "--host", + "0.0.0.0", + "--port", + "8080", + "--parallel", + "1", + "-ngl", + "auto", + "--checkpoint-min-step", + "1024" + ], + "env": { + "CUDA_SCALE_LAUNCH_QUEUES": "4x" + }, + "notes": [ + "CUDA_SCALE_LAUNCH_QUEUES=4x (2 GPU, parallélisme pipeline possible) — CUDA_LAUNCH_QUEUES=off pour s'en passer" + ] + }, + { + "m": 9, + "c": 77, + "h": 0, + "e": 4, + "args": [ + "/app/llama-server", + "-m", + "/m/dense.gguf", + "-c", + "32768", + "-b", + "2048", + "-ub", + "512", + "--host", + "0.0.0.0", + "--port", + "8080", + "--parallel", + "1", + "-ngl", + "auto", + "--load-mode", + "none" + ], + "env": {}, + "notes": null + }, + { + "m": 4, + "c": 84, + "h": 0, + "e": 4, + "args": [ + "/app/llama-server", + "-m", + "/m/hybrid.gguf", + "-c", + "32768", + "-t", + "6", + "-tb", + "6", + "-b", + "2048", + "-ub", + "512", + "--host", + "0.0.0.0", + "--port", + "8080", + "--parallel", + "1", + "-ngl", + "auto", + "--prio-batch", + "2" + ], + "env": { + "CUDA_SCALE_LAUNCH_QUEUES": "4x" + }, + "notes": [ + "CUDA_SCALE_LAUNCH_QUEUES=4x (2 GPU, parallélisme pipeline possible) — CUDA_LAUNCH_QUEUES=off pour s'en passer" + ] + }, + { + "m": 0, + "c": 92, + "h": 0, + "e": 4, + "args": [ + "/app/llama-server", + "-m", + "/m/dense.gguf", + "-c", + "32768", + "-b", + "2048", + "-ub", + "512", + "--host", + "0.0.0.0", + "--port", + "8080", + "--parallel", + "1", + "-ngl", + "auto", + "-ts", + "1,1" + ], + "env": {}, + "notes": null + }, + { + "m": 1, + "c": 93, + "h": 0, + "e": 4, + "args": [ + "/app/llama-server", + "-m", + "/m/moe.gguf", + "-c", + "32768", + "-b", + "2048", + "-ub", + "512", + "--host", + "0.0.0.0", + "--port", + "8080", + "--parallel", + "1", + "-ngl", + "auto", + "-dev", + "CUDA0,CUDA1" + ], + "env": { + "CUDA_SCALE_LAUNCH_QUEUES": "4x" + }, + "notes": [ + "CUDA_SCALE_LAUNCH_QUEUES=4x (2 GPU, parallélisme pipeline possible) — CUDA_LAUNCH_QUEUES=off pour s'en passer" + ] + }, + { + "m": 5, + "c": 0, + "h": 0, + "e": 5, + "args": [ + "/app/llama-server", + "-m", + "/m/ds.gguf", + "-c", + "32768", + "-b", + "2048", + "-ub", + "512", + "--host", + "0.0.0.0", + "--port", + "8080", + "--parallel", + "1", + "-ngl", + "auto", + "--api-key", + "sk-loki-x" + ], + "env": {}, + "notes": [ + "avertissement : --context-shift (LLAMA_ARG_CONTEXT_SHIFT) — contexte plein, le moteur jette des jetons anciens et le modèle perd une partie de la conversation sans le savoir." + ] + }, + { + "m": 6, + "c": 1, + "h": 0, + "e": 5, + "args": [ + "/app/llama-server", + "-m", + "/m/layers.gguf", + "-c", + "65536", + "-t", + "4", + "-b", + "4096", + "-ub", + "2048", + "--host", + "127.0.0.1", + "--port", + "8081", + "--parallel", + "1", + "-ngl", + "auto", + "--mmproj", + "/m/mmproj.gguf", + "--slot-save-path", + "/data/slots" + ], + "env": {}, + "notes": [ + "threads auto → -t 4 : cpuset du conteneur, 4 cœurs physiques permis sur 8 (llama.cpp seul en lancerait 8)", + "NGL=999 (sentinelle « toutes les couches ») → -ngl auto : ce moteur mesure lui-même la VRAM libre. NGL=all pour forcer l'ancien comportement.", + "avertissement : --context-shift (LLAMA_ARG_CONTEXT_SHIFT) — contexte plein, le moteur jette des jetons anciens et le modèle perd une partie de la conversation sans le savoir (vision chargée : llama.cpp l'ignore de toute façon)." + ] + }, + { + "m": 2, + "c": 9, + "h": 0, + "e": 5, + "args": [ + "/app/llama-server", + "-m", + "/m/inconnu.gguf", + "-c", + "32768", + "-b", + "2048", + "-ub", + "512", + "--host", + "0.0.0.0", + "--port", + "8080", + "--parallel", + "1", + "-ngl", + "auto", + "-ctk", + "q8_0", + "-ctv", + "q8_0" + ], + "env": {}, + "notes": [ + "cache KV q8_0/q8_0 (KV_TYPE) : modifie légèrement les sorties par rapport à f16 — choix de l'utilisateur, laissé tel quel.", + "avertissement : --context-shift (LLAMA_ARG_CONTEXT_SHIFT) — contexte plein, le moteur jette des jetons anciens et le modèle perd une partie de la conversation sans le savoir." + ] + }, + { + "m": 1, + "c": 20, + "h": 0, + "e": 5, + "args": [ + "/app/llama-server", + "-m", + "/m/moe.gguf", + "-c", + "32768", + "-b", + "2048", + "-ub", + "512", + "--host", + "0.0.0.0", + "--port", + "8080", + "--parallel", + "1", + "-ngl", + "auto" + ], + "env": { + "CUDA_SCALE_LAUNCH_QUEUES": "4x" + }, + "notes": [ + "CUDA_SCALE_LAUNCH_QUEUES=4x (2 GPU, parallélisme pipeline possible) — CUDA_LAUNCH_QUEUES=off pour s'en passer", + "avertissement : --context-shift (LLAMA_ARG_CONTEXT_SHIFT) — contexte plein, le moteur jette des jetons anciens et le modèle perd une partie de la conversation sans le savoir." + ] + }, + { + "m": 8, + "c": 27, + "h": 0, + "e": 5, + "args": [ + "/app/llama-server", + "-m", + "/m/dense.gguf", + "-c", + "32768", + "-b", + "2048", + "-ub", + "512", + "--host", + "0.0.0.0", + "--port", + "8080", + "--parallel", + "1", + "-ngl", + "auto" + ], + "env": {}, + "notes": [ + "SIDE_SLOT refusé (VRAM insuffisante : modèle + 2 × 8192 Mio d'état dépassent 90 % de 16311 Mio) : un seul slot, comme sans la clé", + "avertissement : --context-shift (LLAMA_ARG_CONTEXT_SHIFT) — contexte plein, le moteur jette des jetons anciens et le modèle perd une partie de la conversation sans le savoir." + ] + }, + { + "m": 0, + "c": 31, + "h": 0, + "e": 5, + "args": [ + "/app/llama-server", + "-m", + "/m/dense.gguf", + "-c", + "32768", + "-b", + "2048", + "-ub", + "512", + "--host", + "0.0.0.0", + "--port", + "8080", + "--parallel", + "1", + "-ngl", + "auto" + ], + "env": {}, + "notes": [ + "SPLIT_MODE=tensor refusé (cartes inconnues (--list-devices n'a rien répondu)) : découpe par couches, comme sans la clé", + "avertissement : --context-shift (LLAMA_ARG_CONTEXT_SHIFT) — contexte plein, le moteur jette des jetons anciens et le modèle perd une partie de la conversation sans le savoir." + ] + }, + { + "m": 6, + "c": 37, + "h": 0, + "e": 5, + "args": [ + "/app/llama-server", + "-m", + "/m/layers.gguf", + "-c", + "32768", + "-t", + "4", + "-b", + "2048", + "-ub", + "512", + "--host", + "0.0.0.0", + "--port", + "8080", + "--parallel", + "1", + "-ngl", + "auto", + "--mmproj", + "/m/mmproj.gguf" + ], + "env": {}, + "notes": [ + "threads auto → -t 4 : cpuset du conteneur, 4 cœurs physiques permis sur 8 (llama.cpp seul en lancerait 8)", + "avertissement : --context-shift (LLAMA_ARG_CONTEXT_SHIFT) — contexte plein, le moteur jette des jetons anciens et le modèle perd une partie de la conversation sans le savoir (vision chargée : llama.cpp l'ignore de toute façon)." + ] + }, + { + "m": 8, + "c": 39, + "h": 0, + "e": 5, + "args": [ + "/app/llama-server", + "-m", + "/m/dense.gguf", + "-c", + "32768", + "-b", + "2048", + "-ub", + "512", + "--host", + "0.0.0.0", + "--port", + "8080", + "--parallel", + "1", + "-ngl", + "auto", + "--spec-type", + "ngram-mod", + "--spec-ngram-mod-n-match", + "24", + "--spec-ngram-mod-n-min", + "48", + "--spec-ngram-mod-n-max", + "64" + ], + "env": {}, + "notes": [ + "SPEC=ngram → n-grammes du contexte (ngram-mod, brouillons de 48 à 64 jetons) : distribution inchangée (chaque jeton vérifié), ~16 Mo de RAM ; utile surtout en mode code, à mesurer (acceptation par nature dans /api/perf/summary)", + "avertissement : --context-shift (LLAMA_ARG_CONTEXT_SHIFT) — contexte plein, le moteur jette des jetons anciens et le modèle perd une partie de la conversation sans le savoir." + ] + }, + { + "m": 9, + "c": 40, + "h": 0, + "e": 5, + "args": [ + "/app/llama-server", + "-m", + "/m/dense.gguf", + "-c", + "32768", + "-b", + "2048", + "-ub", + "512", + "--host", + "0.0.0.0", + "--port", + "8080", + "--parallel", + "1", + "-ngl", + "auto", + "--spec-type", + "ngram-mod", + "--spec-ngram-mod-n-match", + "24", + "--spec-ngram-mod-n-min", + "48", + "--spec-ngram-mod-n-max", + "64" + ], + "env": {}, + "notes": [ + "SPEC=mtp+ngram : sans MTP (pas de tête MTP dans ce fichier (publiée à part ? MODEL_DRAFT=mtp-….gguf)) → n-grammes seuls. SPEC=ngram → n-grammes du contexte (ngram-mod, brouillons de 48 à 64 jetons) : distribution inchangée (chaque jeton vérifié), ~16 Mo de RAM ; utile surtout en mode code, à mesurer (acceptation par nature dans /api/perf/summary)", + "avertissement : --context-shift (LLAMA_ARG_CONTEXT_SHIFT) — contexte plein, le moteur jette des jetons anciens et le modèle perd une partie de la conversation sans le savoir." + ] + }, + { + "m": 2, + "c": 57, + "h": 0, + "e": 5, + "args": [ + "/app/llama-server", + "-m", + "/m/inconnu.gguf", + "-c", + "32768", + "-b", + "2048", + "-ub", + "512", + "--host", + "0.0.0.0", + "--port", + "8080", + "--parallel", + "1", + "-ngl", + "auto", + "--fit-target", + "1024" + ], + "env": {}, + "notes": [ + "--fit-target 1024 (FIT_TARGET, Mio libres par carte, dans l'ordre vu par le moteur) — vérifie « offloaded N/N layers » dans le journal ; marges sous 1024 Mio remontées à 1024", + "avertissement : --context-shift (LLAMA_ARG_CONTEXT_SHIFT) — contexte plein, le moteur jette des jetons anciens et le modèle perd une partie de la conversation sans le savoir." + ] + }, + { + "m": 10, + "c": 65, + "h": 0, + "e": 5, + "args": [ + "/app/llama-server", + "-m", + "/m/hybrid.gguf", + "-c", + "32768", + "-b", + "2048", + "-ub", + "512", + "--host", + "0.0.0.0", + "--port", + "8080", + "--parallel", + "1", + "-ngl", + "auto" + ], + "env": {}, + "notes": [ + "CUDA_GRAPH_OPT=oui ignoré (on ou off)", + "avertissement : --context-shift (LLAMA_ARG_CONTEXT_SHIFT) — contexte plein, le moteur jette des jetons anciens et le modèle perd une partie de la conversation sans le savoir." + ] + }, + { + "m": 2, + "c": 69, + "h": 0, + "e": 5, + "args": [ + "/app/llama-server", + "-m", + "/m/inconnu.gguf", + "-c", + "32768", + "-b", + "2048", + "-ub", + "512", + "--host", + "0.0.0.0", + "--port", + "8080", + "--parallel", + "1", + "-ngl", + "auto", + "--checkpoint-min-step", + "1024" + ], + "env": {}, + "notes": [ + "avertissement : --context-shift (LLAMA_ARG_CONTEXT_SHIFT) — contexte plein, le moteur jette des jetons anciens et le modèle perd une partie de la conversation sans le savoir." + ] + }, + { + "m": 10, + "c": 77, + "h": 0, + "e": 5, + "args": [ + "/app/llama-server", + "-m", + "/m/hybrid.gguf", + "-c", + "32768", + "-b", + "2048", + "-ub", + "512", + "--host", + "0.0.0.0", + "--port", + "8080", + "--parallel", + "1", + "-ngl", + "auto", + "--load-mode", + "none" + ], + "env": {}, + "notes": [ + "avertissement : --context-shift (LLAMA_ARG_CONTEXT_SHIFT) — contexte plein, le moteur jette des jetons anciens et le modèle perd une partie de la conversation sans le savoir." + ] + }, + { + "m": 5, + "c": 84, + "h": 0, + "e": 5, + "args": [ + "/app/llama-server", + "-m", + "/m/ds.gguf", + "-c", + "32768", + "-t", + "6", + "-tb", + "6", + "-b", + "2048", + "-ub", + "512", + "--host", + "0.0.0.0", + "--port", + "8080", + "--parallel", + "1", + "-ngl", + "auto", + "--api-key", + "sk-loki-x", + "--prio-batch", + "2" + ], + "env": {}, + "notes": [ + "avertissement : --context-shift (LLAMA_ARG_CONTEXT_SHIFT) — contexte plein, le moteur jette des jetons anciens et le modèle perd une partie de la conversation sans le savoir." + ] + }, + { + "m": 1, + "c": 92, + "h": 0, + "e": 5, + "args": [ + "/app/llama-server", + "-m", + "/m/moe.gguf", + "-c", + "32768", + "-b", + "2048", + "-ub", + "512", + "--host", + "0.0.0.0", + "--port", + "8080", + "--parallel", + "1", + "-ngl", + "auto", + "-ts", + "1,1" + ], + "env": { + "CUDA_SCALE_LAUNCH_QUEUES": "4x" + }, + "notes": [ + "CUDA_SCALE_LAUNCH_QUEUES=4x (2 GPU, parallélisme pipeline possible) — CUDA_LAUNCH_QUEUES=off pour s'en passer", + "avertissement : --context-shift (LLAMA_ARG_CONTEXT_SHIFT) — contexte plein, le moteur jette des jetons anciens et le modèle perd une partie de la conversation sans le savoir." + ] + }, + { + "m": 2, + "c": 93, + "h": 0, + "e": 5, + "args": [ + "/app/llama-server", + "-m", + "/m/inconnu.gguf", + "-c", + "32768", + "-b", + "2048", + "-ub", + "512", + "--host", + "0.0.0.0", + "--port", + "8080", + "--parallel", + "1", + "-ngl", + "auto", + "-dev", + "CUDA0,CUDA1" + ], + "env": { + "CUDA_SCALE_LAUNCH_QUEUES": "4x" + }, + "notes": [ + "CUDA_SCALE_LAUNCH_QUEUES=4x (2 GPU, parallélisme pipeline possible) — CUDA_LAUNCH_QUEUES=off pour s'en passer", + "avertissement : --context-shift (LLAMA_ARG_CONTEXT_SHIFT) — contexte plein, le moteur jette des jetons anciens et le modèle perd une partie de la conversation sans le savoir." + ] + }, + { + "m": 6, + "c": 0, + "h": 0, + "e": 6, + "args": [ + "/app/llama-server", + "-m", + "/m/layers.gguf", + "-c", + "32768", + "-t", + "4", + "-b", + "2048", + "-ub", + "512", + "--host", + "0.0.0.0", + "--port", + "8080", + "--parallel", + "1", + "-ngl", + "auto", + "--mmproj", + "/m/mmproj.gguf" + ], + "env": {}, + "notes": [ + "threads auto → -t 4 : cpuset du conteneur, 4 cœurs physiques permis sur 8 (llama.cpp seul en lancerait 8)" + ] + }, + { + "m": 7, + "c": 1, + "h": 0, + "e": 6, + "args": [ + "/app/llama-server", + "-m", + "/m/dense.gguf", + "-c", + "65536", + "-b", + "4096", + "-ub", + "2048", + "--host", + "127.0.0.1", + "--port", + "8081", + "--parallel", + "1", + "-ngl", + "auto" + ], + "env": { + "CUDA_SCALE_LAUNCH_QUEUES": "4x" + }, + "notes": [ + "CUDA_SCALE_LAUNCH_QUEUES=4x (2 GPU, parallélisme pipeline possible) — CUDA_LAUNCH_QUEUES=off pour s'en passer", + "NGL=999 (sentinelle « toutes les couches ») → -ngl auto : ce moteur mesure lui-même la VRAM libre. NGL=all pour forcer l'ancien comportement." + ] + }, + { + "m": 3, + "c": 9, + "h": 0, + "e": 6, + "args": [ + "/app/llama-server", + "-m", + "/m/hybrid.gguf", + "-c", + "32768", + "-b", + "2048", + "-ub", + "512", + "--host", + "0.0.0.0", + "--port", + "8080", + "--parallel", + "1", + "-ngl", + "auto", + "-ctk", + "q8_0", + "-ctv", + "q8_0" + ], + "env": {}, + "notes": [ + "modèle hybride de 20 Gio pour 24 Gio de RAM : jusqu'à 32 points de reprise (~150 Mio chacun) prennent sur le cache de pages des poids. CTX_CHECKPOINTS=8 à 16 si le débit baisse.", + "moteur b9000 : les points de reprise des modèles hybrides (Qwen3.5/3.6, Qwen3-Next…) y sont évincés trop tôt — jusqu'à ~8 k jetons recalculés à chaque reprise. Corrigé à partir de b10864. Pas d'espacement resserré d'office : RAM inconnue ou déjà prise par le modèle (CKPT_MIN_STEP=2048 pour l'imposer).", + "cache KV q8_0/q8_0 (KV_TYPE) : modifie légèrement les sorties par rapport à f16 — choix de l'utilisateur, laissé tel quel." + ] + }, + { + "m": 2, + "c": 20, + "h": 0, + "e": 6, + "args": [ + "/app/llama-server", + "-m", + "/m/inconnu.gguf", + "-c", + "32768", + "-b", + "2048", + "-ub", + "512", + "--host", + "0.0.0.0", + "--port", + "8080", + "--parallel", + "1", + "-ngl", + "auto" + ], + "env": {}, + "notes": null + }, + { + "m": 9, + "c": 27, + "h": 0, + "e": 6, + "args": [ + "/app/llama-server", + "-m", + "/m/dense.gguf", + "-c", + "32768", + "-b", + "2048", + "-ub", + "512", + "--host", + "0.0.0.0", + "--port", + "8080", + "--parallel", + "1", + "-ngl", + "auto" + ], + "env": {}, + "notes": [ + "SIDE_SLOT refusé (VRAM insuffisante : modèle + 2 × 8192 Mio d'état dépassent 90 % de 16311 Mio) : un seul slot, comme sans la clé" + ] + }, + { + "m": 1, + "c": 31, + "h": 0, + "e": 6, + "args": [ + "/app/llama-server", + "-m", + "/m/moe.gguf", + "-c", + "32768", + "-b", + "2048", + "-ub", + "512", + "--host", + "0.0.0.0", + "--port", + "8080", + "--parallel", + "1", + "-ngl", + "all", + "-sm", + "tensor", + "-ts", + "57,43", + "-fa", + "on" + ], + "env": { + "GGML_CUDA_ALLREDUCE": "internal" + }, + "notes": [ + "SPLIT_MODE=tensor (expérimental) : -sm tensor sur 2 cartes, -ts 57,43 (VRAM totale), ~25600 Mio estimés sans --fit ; décodage plus rapide, prefill plus lent — compare le temps par tour ; GGML_CUDA_ALLREDUCE=internal : sommes dans le type natif (NCCL les compresserait en BF16, avec perte). « NCCL not compiled in » ou « NCCL is unavailable » au journal : sans effet ici. GeForce sans P2P : les sommes passent par l'hôte ; GGML_CUDA_P2P=1 à poser soi-même si le pilote le permet" + ] + }, + { + "m": 7, + "c": 37, + "h": 0, + "e": 6, + "args": [ + "/app/llama-server", + "-m", + "/m/dense.gguf", + "-c", + "32768", + "-b", + "2048", + "-ub", + "512", + "--host", + "0.0.0.0", + "--port", + "8080", + "--parallel", + "1", + "-ngl", + "auto" + ], + "env": { + "CUDA_SCALE_LAUNCH_QUEUES": "4x" + }, + "notes": [ + "CUDA_SCALE_LAUNCH_QUEUES=4x (2 GPU, parallélisme pipeline possible) — CUDA_LAUNCH_QUEUES=off pour s'en passer" + ] + }, + { + "m": 9, + "c": 39, + "h": 0, + "e": 6, + "args": [ + "/app/llama-server", + "-m", + "/m/dense.gguf", + "-c", + "32768", + "-b", + "2048", + "-ub", + "512", + "--host", + "0.0.0.0", + "--port", + "8080", + "--parallel", + "1", + "-ngl", + "auto", + "--spec-type", + "ngram-mod", + "--spec-ngram-mod-n-match", + "24", + "--spec-ngram-mod-n-min", + "48", + "--spec-ngram-mod-n-max", + "64" + ], + "env": {}, + "notes": [ + "SPEC=ngram → n-grammes du contexte (ngram-mod, brouillons de 48 à 64 jetons) : distribution inchangée (chaque jeton vérifié), ~16 Mo de RAM ; utile surtout en mode code, à mesurer (acceptation par nature dans /api/perf/summary)" + ] + }, + { + "m": 10, + "c": 40, + "h": 0, + "e": 6, + "args": [ + "/app/llama-server", + "-m", + "/m/hybrid.gguf", + "-c", + "32768", + "-b", + "2048", + "-ub", + "512", + "--host", + "0.0.0.0", + "--port", + "8080", + "--parallel", + "1", + "-ngl", + "auto", + "--spec-type", + "draft-mtp,ngram-mod", + "--spec-ngram-mod-n-match", + "24", + "--spec-ngram-mod-n-min", + "48", + "--spec-ngram-mod-n-max", + "64", + "--spec-draft-sampling", + "greedy" + ], + "env": {}, + "notes": [ + "SPEC=mtp+ngram → tête MTP imposé : sortie inchangée, ~1-2 Go de VRAM en plus ; n-grammes en plus (ngram-mod, prioritaires quand ils trouvent une répétition) ; placement manuel (--fit off) : --fit ne compte pas le brouillon, place-le avec -devd / -ngld si la VRAM manque", + "modèle hybride : chaque brouillon n-gramme copie l'état récurrent (point de reprise en RAM hôte) et le recharge s'il est rejeté — compare le temps par tour, pas seulement le débit" + ] + }, + { + "m": 3, + "c": 57, + "h": 0, + "e": 6, + "args": [ + "/app/llama-server", + "-m", + "/m/hybrid.gguf", + "-c", + "32768", + "-b", + "2048", + "-ub", + "512", + "--host", + "0.0.0.0", + "--port", + "8080", + "--parallel", + "1", + "-ngl", + "auto" + ], + "env": {}, + "notes": [ + "FIT_TARGET=512 ignoré : --fit inactif (--fit off), la marge ne servirait à rien", + "modèle hybride de 20 Gio pour 24 Gio de RAM : jusqu'à 32 points de reprise (~150 Mio chacun) prennent sur le cache de pages des poids. CTX_CHECKPOINTS=8 à 16 si le débit baisse.", + "moteur b9000 : les points de reprise des modèles hybrides (Qwen3.5/3.6, Qwen3-Next…) y sont évincés trop tôt — jusqu'à ~8 k jetons recalculés à chaque reprise. Corrigé à partir de b10864. Pas d'espacement resserré d'office : RAM inconnue ou déjà prise par le modèle (CKPT_MIN_STEP=2048 pour l'imposer)." + ] + }, + { + "m": 11, + "c": 65, + "h": 0, + "e": 6, + "args": [ + "/app/llama-server", + "-m", + "/m/dense.gguf", + "-c", + "32768", + "-b", + "2048", + "-ub", + "512", + "--host", + "0.0.0.0", + "--port", + "8080", + "--parallel", + "1", + "-ngl", + "auto" + ], + "env": {}, + "notes": [ + "CUDA_GRAPH_OPT=oui ignoré (on ou off)" + ] + }, + { + "m": 3, + "c": 69, + "h": 0, + "e": 6, + "args": [ + "/app/llama-server", + "-m", + "/m/hybrid.gguf", + "-c", + "32768", + "-b", + "2048", + "-ub", + "512", + "--host", + "0.0.0.0", + "--port", + "8080", + "--parallel", + "1", + "-ngl", + "auto", + "--checkpoint-min-step", + "1024" + ], + "env": {}, + "notes": [ + "modèle hybride de 20 Gio pour 24 Gio de RAM : jusqu'à 32 points de reprise (~150 Mio chacun) prennent sur le cache de pages des poids. CTX_CHECKPOINTS=8 à 16 si le débit baisse." + ] + }, + { + "m": 11, + "c": 77, + "h": 0, + "e": 6, + "args": [ + "/app/llama-server", + "-m", + "/m/dense.gguf", + "-c", + "32768", + "-b", + "2048", + "-ub", + "512", + "--host", + "0.0.0.0", + "--port", + "8080", + "--parallel", + "1", + "-ngl", + "auto", + "--load-mode", + "none" + ], + "env": {}, + "notes": null + }, + { + "m": 6, + "c": 84, + "h": 0, + "e": 6, + "args": [ + "/app/llama-server", + "-m", + "/m/layers.gguf", + "-c", + "32768", + "-t", + "6", + "-tb", + "6", + "-b", + "2048", + "-ub", + "512", + "--host", + "0.0.0.0", + "--port", + "8080", + "--parallel", + "1", + "-ngl", + "auto", + "--mmproj", + "/m/mmproj.gguf", + "--prio-batch", + "2" + ], + "env": {}, + "notes": null + }, + { + "m": 2, + "c": 92, + "h": 0, + "e": 6, + "args": [ + "/app/llama-server", + "-m", + "/m/inconnu.gguf", + "-c", + "32768", + "-b", + "2048", + "-ub", + "512", + "--host", + "0.0.0.0", + "--port", + "8080", + "--parallel", + "1", + "-ngl", + "auto", + "-ts", + "1,1" + ], + "env": {}, + "notes": null + }, + { + "m": 3, + "c": 93, + "h": 0, + "e": 6, + "args": [ + "/app/llama-server", + "-m", + "/m/hybrid.gguf", + "-c", + "32768", + "-b", + "2048", + "-ub", + "512", + "--host", + "0.0.0.0", + "--port", + "8080", + "--parallel", + "1", + "-ngl", + "auto", + "-dev", + "CUDA0,CUDA1" + ], + "env": { + "CUDA_SCALE_LAUNCH_QUEUES": "4x" + }, + "notes": [ + "CUDA_SCALE_LAUNCH_QUEUES=4x (2 GPU, parallélisme pipeline possible) — CUDA_LAUNCH_QUEUES=off pour s'en passer", + "modèle hybride de 20 Gio pour 24 Gio de RAM : jusqu'à 32 points de reprise (~150 Mio chacun) prennent sur le cache de pages des poids. CTX_CHECKPOINTS=8 à 16 si le débit baisse.", + "moteur b9000 : les points de reprise des modèles hybrides (Qwen3.5/3.6, Qwen3-Next…) y sont évincés trop tôt — jusqu'à ~8 k jetons recalculés à chaque reprise. Corrigé à partir de b10864. Pas d'espacement resserré d'office : RAM inconnue ou déjà prise par le modèle (CKPT_MIN_STEP=2048 pour l'imposer)." + ] + }, + { + "m": 7, + "c": 0, + "h": 0, + "e": 7, + "args": [ + "/app/llama-server", + "-m", + "/m/dense.gguf", + "-c", + "32768", + "-b", + "2048", + "-ub", + "512", + "--host", + "0.0.0.0", + "--port", + "8080", + "--parallel", + "1", + "-ngl", + "auto" + ], + "env": {}, + "notes": null + }, + { + "m": 8, + "c": 1, + "h": 0, + "e": 7, + "args": [ + "/app/llama-server", + "-m", + "/m/dense.gguf", + "-c", + "65536", + "-b", + "4096", + "-ub", + "2048", + "--host", + "127.0.0.1", + "--port", + "8081", + "--parallel", + "1", + "-ngl", + "auto" + ], + "env": {}, + "notes": [ + "NGL=999 (sentinelle « toutes les couches ») → -ngl auto : ce moteur mesure lui-même la VRAM libre. NGL=all pour forcer l'ancien comportement." + ] + }, + { + "m": 4, + "c": 9, + "h": 0, + "e": 7, + "args": [ + "/app/llama-server", + "-m", + "/m/hybrid.gguf", + "-c", + "32768", + "-b", + "2048", + "-ub", + "512", + "--host", + "0.0.0.0", + "--port", + "8080", + "--parallel", + "1", + "-ngl", + "auto", + "-ctk", + "q8_0", + "-ctv", + "q8_0" + ], + "env": {}, + "notes": [ + "cache KV q8_0/q8_0 (KV_TYPE) : modifie légèrement les sorties par rapport à f16 — choix de l'utilisateur, laissé tel quel." + ] + }, + { + "m": 3, + "c": 20, + "h": 0, + "e": 7, + "args": [ + "/app/llama-server", + "-m", + "/m/hybrid.gguf", + "-c", + "32768", + "-b", + "2048", + "-ub", + "512", + "--host", + "0.0.0.0", + "--port", + "8080", + "--parallel", + "1", + "-ngl", + "auto" + ], + "env": {}, + "notes": [ + "modèle hybride de 20 Gio pour 24 Gio de RAM : jusqu'à 32 points de reprise (~150 Mio chacun) prennent sur le cache de pages des poids. CTX_CHECKPOINTS=8 à 16 si le débit baisse.", + "moteur b9000 : les points de reprise des modèles hybrides (Qwen3.5/3.6, Qwen3-Next…) y sont évincés trop tôt — jusqu'à ~8 k jetons recalculés à chaque reprise. Corrigé à partir de b10864. Pas d'espacement resserré d'office : RAM inconnue ou déjà prise par le modèle (CKPT_MIN_STEP=2048 pour l'imposer)." + ] + }, + { + "m": 10, + "c": 27, + "h": 0, + "e": 7, + "args": [ + "/app/llama-server", + "-m", + "/m/hybrid.gguf", + "-c", + "65536", + "-b", + "2048", + "-ub", + "512", + "--host", + "0.0.0.0", + "--port", + "8080", + "--parallel", + "2", + "--no-kv-unified", + "-ngl", + "auto" + ], + "env": {}, + "notes": [ + "SIDE_SLOT : 2 slots de 32768 jetons (-c 65536, --no-kv-unified), ~2198 Mio de VRAM en plus pour le second (cache KV et état récurrent ; le brouillon MTP éventuel et les points de reprise en RAM hôte doublent aussi) ; le placement automatique sortira des couches du GPU s'il manque de place" + ] + }, + { + "m": 2, + "c": 31, + "h": 0, + "e": 7, + "args": [ + "/app/llama-server", + "-m", + "/m/inconnu.gguf", + "-c", + "32768", + "-b", + "2048", + "-ub", + "512", + "--host", + "0.0.0.0", + "--port", + "8080", + "--parallel", + "1", + "-ngl", + "auto" + ], + "env": {}, + "notes": [ + "SPLIT_MODE=tensor refusé (LLAMA_ARG_SPLIT_MODE déjà posée) : découpe par couches, comme sans la clé" + ] + }, + { + "m": 8, + "c": 37, + "h": 0, + "e": 7, + "args": [ + "/app/llama-server", + "-m", + "/m/dense.gguf", + "-c", + "32768", + "-b", + "2048", + "-ub", + "512", + "--host", + "0.0.0.0", + "--port", + "8080", + "--parallel", + "1", + "-ngl", + "auto" + ], + "env": {}, + "notes": null + }, + { + "m": 10, + "c": 39, + "h": 0, + "e": 7, + "args": [ + "/app/llama-server", + "-m", + "/m/hybrid.gguf", + "-c", + "32768", + "-b", + "2048", + "-ub", + "512", + "--host", + "0.0.0.0", + "--port", + "8080", + "--parallel", + "1", + "-ngl", + "auto", + "--spec-type", + "ngram-mod", + "--spec-ngram-mod-n-match", + "24", + "--spec-ngram-mod-n-min", + "48", + "--spec-ngram-mod-n-max", + "64" + ], + "env": {}, + "notes": [ + "SPEC=ngram → n-grammes du contexte (ngram-mod, brouillons de 48 à 64 jetons) : distribution inchangée (chaque jeton vérifié), ~16 Mo de RAM ; utile surtout en mode code, à mesurer (acceptation par nature dans /api/perf/summary)", + "tête MTP du modèle inutilisée avec SPEC=ngram : SPEC=mtp+ngram pour les deux", + "modèle hybride : chaque brouillon n-gramme copie l'état récurrent (point de reprise en RAM hôte) et le recharge s'il est rejeté — compare le temps par tour, pas seulement le débit" + ] + }, + { + "m": 11, + "c": 40, + "h": 0, + "e": 7, + "args": [ + "/app/llama-server", + "-m", + "/m/dense.gguf", + "-c", + "32768", + "-b", + "2048", + "-ub", + "512", + "--host", + "0.0.0.0", + "--port", + "8080", + "--parallel", + "1", + "-ngl", + "auto", + "--spec-type", + "ngram-mod", + "--spec-ngram-mod-n-match", + "24", + "--spec-ngram-mod-n-min", + "48", + "--spec-ngram-mod-n-max", + "64" + ], + "env": {}, + "notes": [ + "SPEC=mtp+ngram : sans MTP (pas de tête MTP dans ce fichier (publiée à part ? MODEL_DRAFT=mtp-….gguf)) → n-grammes seuls. SPEC=ngram → n-grammes du contexte (ngram-mod, brouillons de 48 à 64 jetons) : distribution inchangée (chaque jeton vérifié), ~16 Mo de RAM ; utile surtout en mode code, à mesurer (acceptation par nature dans /api/perf/summary)" + ] + }, + { + "m": 4, + "c": 57, + "h": 0, + "e": 7, + "args": [ + "/app/llama-server", + "-m", + "/m/hybrid.gguf", + "-c", + "32768", + "-b", + "2048", + "-ub", + "512", + "--host", + "0.0.0.0", + "--port", + "8080", + "--parallel", + "1", + "-ngl", + "auto" + ], + "env": {}, + "notes": [ + "FIT_TARGET=512 ignoré : --fit inactif (-sm row), la marge ne servirait à rien" + ] + }, + { + "m": 0, + "c": 65, + "h": 0, + "e": 7, + "args": [ + "/app/llama-server", + "-m", + "/m/dense.gguf", + "-c", + "32768", + "-b", + "2048", + "-ub", + "512", + "--host", + "0.0.0.0", + "--port", + "8080", + "--parallel", + "1", + "-ngl", + "auto" + ], + "env": {}, + "notes": [ + "CUDA_GRAPH_OPT=oui ignoré (on ou off)" + ] + }, + { + "m": 4, + "c": 69, + "h": 0, + "e": 7, + "args": [ + "/app/llama-server", + "-m", + "/m/hybrid.gguf", + "-c", + "32768", + "-b", + "2048", + "-ub", + "512", + "--host", + "0.0.0.0", + "--port", + "8080", + "--parallel", + "1", + "-ngl", + "auto", + "--checkpoint-min-step", + "1024" + ], + "env": {}, + "notes": null + }, + { + "m": 0, + "c": 77, + "h": 0, + "e": 7, + "args": [ + "/app/llama-server", + "-m", + "/m/dense.gguf", + "-c", + "32768", + "-b", + "2048", + "-ub", + "512", + "--host", + "0.0.0.0", + "--port", + "8080", + "--parallel", + "1", + "-ngl", + "auto", + "--load-mode", + "none" + ], + "env": {}, + "notes": null + }, + { + "m": 7, + "c": 84, + "h": 0, + "e": 7, + "args": [ + "/app/llama-server", + "-m", + "/m/dense.gguf", + "-c", + "32768", + "-t", + "6", + "-tb", + "6", + "-b", + "2048", + "-ub", + "512", + "--host", + "0.0.0.0", + "--port", + "8080", + "--parallel", + "1", + "-ngl", + "auto", + "--prio-batch", + "2" + ], + "env": {}, + "notes": null + }, + { + "m": 3, + "c": 92, + "h": 0, + "e": 7, + "args": [ + "/app/llama-server", + "-m", + "/m/hybrid.gguf", + "-c", + "32768", + "-b", + "2048", + "-ub", + "512", + "--host", + "0.0.0.0", + "--port", + "8080", + "--parallel", + "1", + "-ngl", + "auto", + "-ts", + "1,1" + ], + "env": {}, + "notes": [ + "modèle hybride de 20 Gio pour 24 Gio de RAM : jusqu'à 32 points de reprise (~150 Mio chacun) prennent sur le cache de pages des poids. CTX_CHECKPOINTS=8 à 16 si le débit baisse.", + "moteur b9000 : les points de reprise des modèles hybrides (Qwen3.5/3.6, Qwen3-Next…) y sont évincés trop tôt — jusqu'à ~8 k jetons recalculés à chaque reprise. Corrigé à partir de b10864. Pas d'espacement resserré d'office : RAM inconnue ou déjà prise par le modèle (CKPT_MIN_STEP=2048 pour l'imposer)." + ] + }, + { + "m": 4, + "c": 93, + "h": 0, + "e": 7, + "args": [ + "/app/llama-server", + "-m", + "/m/hybrid.gguf", + "-c", + "32768", + "-b", + "2048", + "-ub", + "512", + "--host", + "0.0.0.0", + "--port", + "8080", + "--parallel", + "1", + "-ngl", + "auto", + "-dev", + "CUDA0,CUDA1" + ], + "env": {}, + "notes": null + }, + { + "m": 8, + "c": 0, + "h": 0, + "e": 8, + "args": [ + "/app/llama-server", + "-m", + "/m/dense.gguf", + "-c", + "32768", + "-b", + "2048", + "-ub", + "512", + "--host", + "0.0.0.0", + "--port", + "8080", + "--parallel", + "1", + "-ngl", + "auto" + ], + "env": {}, + "notes": null + }, + { + "m": 9, + "c": 1, + "h": 0, + "e": 8, + "args": [ + "/app/llama-server", + "-m", + "/m/dense.gguf", + "-c", + "65536", + "-b", + "4096", + "-ub", + "2048", + "--host", + "127.0.0.1", + "--port", + "8081", + "--parallel", + "1", + "-ngl", + "auto" + ], + "env": {}, + "notes": [ + "NGL=999 (sentinelle « toutes les couches ») → -ngl auto : ce moteur mesure lui-même la VRAM libre. NGL=all pour forcer l'ancien comportement." + ] + }, + { + "m": 5, + "c": 9, + "h": 0, + "e": 8, + "args": [ + "/app/llama-server", + "-m", + "/m/ds.gguf", + "-c", + "32768", + "-b", + "2048", + "-ub", + "512", + "--host", + "0.0.0.0", + "--port", + "8080", + "--parallel", + "1", + "-ngl", + "auto", + "-ctk", + "q8_0", + "-ctv", + "q8_0", + "--api-key", + "sk-loki-x" + ], + "env": {}, + "notes": [ + "cache KV q8_0/q8_0 (KV_TYPE) : modifie légèrement les sorties par rapport à f16 — choix de l'utilisateur, laissé tel quel." + ] + }, + { + "m": 4, + "c": 20, + "h": 0, + "e": 8, + "args": [ + "/app/llama-server", + "-m", + "/m/hybrid.gguf", + "-c", + "32768", + "-b", + "2048", + "-ub", + "512", + "--host", + "0.0.0.0", + "--port", + "8080", + "--parallel", + "1", + "-ngl", + "auto" + ], + "env": {}, + "notes": null + }, + { + "m": 11, + "c": 27, + "h": 0, + "e": 8, + "args": [ + "/app/llama-server", + "-m", + "/m/dense.gguf", + "-c", + "32768", + "-b", + "2048", + "-ub", + "512", + "--host", + "0.0.0.0", + "--port", + "8080", + "--parallel", + "1", + "-ngl", + "auto" + ], + "env": {}, + "notes": [ + "SIDE_SLOT refusé (VRAM insuffisante : modèle + 2 × 8192 Mio d'état dépassent 90 % de 16311 Mio) : un seul slot, comme sans la clé" + ] + }, + { + "m": 3, + "c": 31, + "h": 0, + "e": 8, + "args": [ + "/app/llama-server", + "-m", + "/m/hybrid.gguf", + "-c", + "32768", + "-b", + "2048", + "-ub", + "512", + "--host", + "0.0.0.0", + "--port", + "8080", + "--parallel", + "1", + "-ngl", + "auto" + ], + "env": {}, + "notes": [ + "SPLIT_MODE=tensor refusé (cartes inconnues (--list-devices n'a rien répondu)) : découpe par couches, comme sans la clé", + "modèle hybride de 20 Gio pour 24 Gio de RAM : jusqu'à 32 points de reprise (~150 Mio chacun) prennent sur le cache de pages des poids. CTX_CHECKPOINTS=8 à 16 si le débit baisse.", + "moteur b9000 : les points de reprise des modèles hybrides (Qwen3.5/3.6, Qwen3-Next…) y sont évincés trop tôt — jusqu'à ~8 k jetons recalculés à chaque reprise. Corrigé à partir de b10864. Pas d'espacement resserré d'office : RAM inconnue ou déjà prise par le modèle (CKPT_MIN_STEP=2048 pour l'imposer)." + ] + }, + { + "m": 9, + "c": 37, + "h": 0, + "e": 8, + "args": [ + "/app/llama-server", + "-m", + "/m/dense.gguf", + "-c", + "32768", + "-b", + "2048", + "-ub", + "512", + "--host", + "0.0.0.0", + "--port", + "8080", + "--parallel", + "1", + "-ngl", + "auto" + ], + "env": {}, + "notes": null + }, + { + "m": 11, + "c": 39, + "h": 0, + "e": 8, + "args": [ + "/app/llama-server", + "-m", + "/m/dense.gguf", + "-c", + "32768", + "-b", + "2048", + "-ub", + "512", + "--host", + "0.0.0.0", + "--port", + "8080", + "--parallel", + "1", + "-ngl", + "auto", + "--spec-type", + "ngram-mod", + "--spec-ngram-mod-n-match", + "24", + "--spec-ngram-mod-n-min", + "48", + "--spec-ngram-mod-n-max", + "64" + ], + "env": {}, + "notes": [ + "SPEC=ngram → n-grammes du contexte (ngram-mod, brouillons de 48 à 64 jetons) : distribution inchangée (chaque jeton vérifié), ~16 Mo de RAM ; utile surtout en mode code, à mesurer (acceptation par nature dans /api/perf/summary)" + ] + }, + { + "m": 0, + "c": 40, + "h": 0, + "e": 8, + "args": [ + "/app/llama-server", + "-m", + "/m/dense.gguf", + "-c", + "32768", + "-b", + "2048", + "-ub", + "512", + "--host", + "0.0.0.0", + "--port", + "8080", + "--parallel", + "1", + "-ngl", + "auto", + "--spec-type", + "ngram-mod", + "--spec-ngram-mod-n-match", + "24", + "--spec-ngram-mod-n-min", + "48", + "--spec-ngram-mod-n-max", + "64" + ], + "env": {}, + "notes": [ + "SPEC=mtp+ngram : sans MTP (pas de tête MTP dans ce fichier (publiée à part ? MODEL_DRAFT=mtp-….gguf)) → n-grammes seuls. SPEC=ngram → n-grammes du contexte (ngram-mod, brouillons de 48 à 64 jetons) : distribution inchangée (chaque jeton vérifié), ~16 Mo de RAM ; utile surtout en mode code, à mesurer (acceptation par nature dans /api/perf/summary)" + ] + }, + { + "m": 5, + "c": 57, + "h": 0, + "e": 8, + "args": [ + "/app/llama-server", + "-m", + "/m/ds.gguf", + "-c", + "32768", + "-b", + "2048", + "-ub", + "512", + "--host", + "0.0.0.0", + "--port", + "8080", + "--parallel", + "1", + "-ngl", + "auto", + "--fit-target", + "1024", + "--api-key", + "sk-loki-x" + ], + "env": {}, + "notes": [ + "--fit-target 1024 (FIT_TARGET, Mio libres par carte, dans l'ordre vu par le moteur) — vérifie « offloaded N/N layers » dans le journal ; marges sous 1024 Mio remontées à 1024" + ] + }, + { + "m": 1, + "c": 65, + "h": 0, + "e": 8, + "args": [ + "/app/llama-server", + "-m", + "/m/moe.gguf", + "-c", + "32768", + "-b", + "2048", + "-ub", + "512", + "--host", + "0.0.0.0", + "--port", + "8080", + "--parallel", + "1", + "-ngl", + "auto" + ], + "env": {}, + "notes": [ + "CUDA_GRAPH_OPT=oui ignoré (on ou off)" + ] + }, + { + "m": 5, + "c": 69, + "h": 0, + "e": 8, + "args": [ + "/app/llama-server", + "-m", + "/m/ds.gguf", + "-c", + "32768", + "-b", + "2048", + "-ub", + "512", + "--host", + "0.0.0.0", + "--port", + "8080", + "--parallel", + "1", + "-ngl", + "auto", + "--api-key", + "sk-loki-x", + "--checkpoint-min-step", + "1024" + ], + "env": {}, + "notes": null + }, + { + "m": 1, + "c": 77, + "h": 0, + "e": 8, + "args": [ + "/app/llama-server", + "-m", + "/m/moe.gguf", + "-c", + "32768", + "-b", + "2048", + "-ub", + "512", + "--host", + "0.0.0.0", + "--port", + "8080", + "--parallel", + "1", + "-ngl", + "auto", + "--load-mode", + "none" + ], + "env": {}, + "notes": null + }, + { + "m": 8, + "c": 84, + "h": 0, + "e": 8, + "args": [ + "/app/llama-server", + "-m", + "/m/dense.gguf", + "-c", + "32768", + "-t", + "6", + "-tb", + "6", + "-b", + "2048", + "-ub", + "512", + "--host", + "0.0.0.0", + "--port", + "8080", + "--parallel", + "1", + "-ngl", + "auto", + "--prio-batch", + "2" + ], + "env": {}, + "notes": null + }, + { + "m": 4, + "c": 92, + "h": 0, + "e": 8, + "args": [ + "/app/llama-server", + "-m", + "/m/hybrid.gguf", + "-c", + "32768", + "-b", + "2048", + "-ub", + "512", + "--host", + "0.0.0.0", + "--port", + "8080", + "--parallel", + "1", + "-ngl", + "auto", + "-ts", + "1,1" + ], + "env": {}, + "notes": null + }, + { + "m": 5, + "c": 93, + "h": 0, + "e": 8, + "args": [ + "/app/llama-server", + "-m", + "/m/ds.gguf", + "-c", + "32768", + "-b", + "2048", + "-ub", + "512", + "--host", + "0.0.0.0", + "--port", + "8080", + "--parallel", + "1", + "-ngl", + "auto", + "--api-key", + "sk-loki-x", + "-dev", + "CUDA0,CUDA1" + ], + "env": { + "CUDA_SCALE_LAUNCH_QUEUES": "4x" + }, + "notes": [ + "CUDA_SCALE_LAUNCH_QUEUES=4x (2 GPU, parallélisme pipeline possible) — CUDA_LAUNCH_QUEUES=off pour s'en passer" + ] + }, + { + "m": 9, + "c": 0, + "h": 0, + "e": 9, + "args": [ + "/app/llama-server", + "-m", + "/m/dense.gguf", + "-c", + "32768", + "-b", + "2048", + "-ub", + "512", + "--host", + "0.0.0.0", + "--port", + "8080", + "--parallel", + "1", + "-ngl", + "auto" + ], + "env": {}, + "notes": null + }, + { + "m": 10, + "c": 1, + "h": 0, + "e": 9, + "args": [ + "/app/llama-server", + "-m", + "/m/hybrid.gguf", + "-c", + "65536", + "-b", + "4096", + "-ub", + "2048", + "--host", + "127.0.0.1", + "--port", + "8081", + "--parallel", + "1", + "-ngl", + "auto", + "--cache-ram", + "13981" + ], + "env": {}, + "notes": [ + "NGL=999 (sentinelle « toutes les couches ») → -ngl auto : ce moteur mesure lui-même la VRAM libre. NGL=all pour forcer l'ancien comportement.", + "cache de prompts : --cache-ram 13981 (RAM hôte, copie exacte de l'état, jamais la VRAM ; besoin estimé 13981 Mio, plafond 19660 Mio). CACHE_RAM= pour fixer, 0 pour couper." + ] + }, + { + "m": 6, + "c": 9, + "h": 0, + "e": 9, + "args": [ + "/app/llama-server", + "-m", + "/m/layers.gguf", + "-c", + "32768", + "-t", + "4", + "-b", + "2048", + "-ub", + "512", + "--host", + "0.0.0.0", + "--port", + "8080", + "--parallel", + "1", + "-ngl", + "auto", + "-ctk", + "q8_0", + "-ctv", + "q8_0", + "--mmproj", + "/m/mmproj.gguf" + ], + "env": {}, + "notes": [ + "threads auto → -t 4 : cpuset du conteneur, 4 cœurs physiques permis sur 8 (llama.cpp seul en lancerait 8)", + "cache KV q8_0/q8_0 (KV_TYPE) : modifie légèrement les sorties par rapport à f16 — choix de l'utilisateur, laissé tel quel." + ] + }, + { + "m": 5, + "c": 20, + "h": 0, + "e": 9, + "args": [ + "/app/llama-server", + "-m", + "/m/ds.gguf", + "-c", + "32768", + "-b", + "2048", + "-ub", + "512", + "--host", + "0.0.0.0", + "--port", + "8080", + "--parallel", + "1", + "-ngl", + "auto", + "--api-key", + "sk-loki-x" + ], + "env": {}, + "notes": null + }, + { + "m": 0, + "c": 27, + "h": 0, + "e": 9, + "args": [ + "/app/llama-server", + "-m", + "/m/dense.gguf", + "-c", + "32768", + "-b", + "2048", + "-ub", + "512", + "--host", + "0.0.0.0", + "--port", + "8080", + "--parallel", + "1", + "-ngl", + "auto" + ], + "env": {}, + "notes": [ + "SIDE_SLOT refusé (VRAM insuffisante : modèle + 2 × 8192 Mio d'état dépassent 90 % de 16311 Mio) : un seul slot, comme sans la clé" + ] + }, + { + "m": 4, + "c": 31, + "h": 0, + "e": 9, + "args": [ + "/app/llama-server", + "-m", + "/m/hybrid.gguf", + "-c", + "32768", + "-b", + "2048", + "-ub", + "512", + "--host", + "0.0.0.0", + "--port", + "8080", + "--parallel", + "1", + "-ngl", + "all", + "-sm", + "tensor", + "-ts", + "57,43", + "-fa", + "on" + ], + "env": { + "GGML_CUDA_ALLREDUCE": "internal" + }, + "notes": [ + "SPLIT_MODE=tensor (expérimental) : -sm tensor sur 2 cartes, -ts 57,43 (VRAM totale), ~17558 Mio estimés sans --fit ; décodage plus rapide, prefill plus lent — compare le temps par tour ; GGML_CUDA_ALLREDUCE=internal : sommes dans le type natif (NCCL les compresserait en BF16, avec perte). « NCCL not compiled in » ou « NCCL is unavailable » au journal : sans effet ici. GeForce sans P2P : les sommes passent par l'hôte ; GGML_CUDA_P2P=1 à poser soi-même si le pilote le permet ; modèle hybride : état récurrent et points de reprise répartis entre cartes, non validés en amont" + ] + }, + { + "m": 10, + "c": 37, + "h": 0, + "e": 9, + "args": [ + "/app/llama-server", + "-m", + "/m/hybrid.gguf", + "-c", + "32768", + "-b", + "2048", + "-ub", + "512", + "--host", + "0.0.0.0", + "--port", + "8080", + "--parallel", + "1", + "-ngl", + "auto" + ], + "env": {}, + "notes": [ + "SPEC=auto : le dernier lancement avec cette configuration n'a jamais répondu — sans décodage spéculatif" + ] + }, + { + "m": 0, + "c": 39, + "h": 0, + "e": 9, + "args": [ + "/app/llama-server", + "-m", + "/m/dense.gguf", + "-c", + "32768", + "-b", + "2048", + "-ub", + "512", + "--host", + "0.0.0.0", + "--port", + "8080", + "--parallel", + "1", + "-ngl", + "auto", + "--spec-type", + "ngram-mod", + "--spec-ngram-mod-n-match", + "24", + "--spec-ngram-mod-n-min", + "48", + "--spec-ngram-mod-n-max", + "64" + ], + "env": {}, + "notes": [ + "SPEC=ngram → n-grammes du contexte (ngram-mod, brouillons de 48 à 64 jetons) : distribution inchangée (chaque jeton vérifié), ~16 Mo de RAM ; utile surtout en mode code, à mesurer (acceptation par nature dans /api/perf/summary)" + ] + }, + { + "m": 1, + "c": 40, + "h": 0, + "e": 9, + "args": [ + "/app/llama-server", + "-m", + "/m/moe.gguf", + "-c", + "32768", + "-b", + "2048", + "-ub", + "512", + "--host", + "0.0.0.0", + "--port", + "8080", + "--parallel", + "1", + "-ngl", + "auto", + "--spec-type", + "ngram-mod", + "--spec-ngram-mod-n-match", + "24", + "--spec-ngram-mod-n-min", + "48", + "--spec-ngram-mod-n-max", + "64" + ], + "env": { + "CUDA_SCALE_LAUNCH_QUEUES": "4x" + }, + "notes": [ + "CUDA_SCALE_LAUNCH_QUEUES=4x (2 GPU, parallélisme pipeline possible) — CUDA_LAUNCH_QUEUES=off pour s'en passer", + "SPEC=mtp+ngram : sans MTP (pas de tête MTP dans ce fichier (publiée à part ? MODEL_DRAFT=mtp-….gguf)) → n-grammes seuls. SPEC=ngram → n-grammes du contexte (ngram-mod, brouillons de 48 à 64 jetons) : distribution inchangée (chaque jeton vérifié), ~16 Mo de RAM ; utile surtout en mode code, à mesurer (acceptation par nature dans /api/perf/summary)" + ] + }, + { + "m": 6, + "c": 57, + "h": 0, + "e": 9, + "args": [ + "/app/llama-server", + "-m", + "/m/layers.gguf", + "-c", + "32768", + "-t", + "4", + "-b", + "2048", + "-ub", + "512", + "--host", + "0.0.0.0", + "--port", + "8080", + "--parallel", + "1", + "-ngl", + "auto", + "--fit-target", + "1024", + "--mmproj", + "/m/mmproj.gguf" + ], + "env": {}, + "notes": [ + "threads auto → -t 4 : cpuset du conteneur, 4 cœurs physiques permis sur 8 (llama.cpp seul en lancerait 8)", + "--fit-target 1024 (FIT_TARGET, Mio libres par carte, dans l'ordre vu par le moteur) — vérifie « offloaded N/N layers » dans le journal ; marges sous 1024 Mio remontées à 1024" + ] + }, + { + "m": 2, + "c": 65, + "h": 0, + "e": 9, + "args": [ + "/app/llama-server", + "-m", + "/m/inconnu.gguf", + "-c", + "32768", + "-b", + "2048", + "-ub", + "512", + "--host", + "0.0.0.0", + "--port", + "8080", + "--parallel", + "1", + "-ngl", + "auto" + ], + "env": {}, + "notes": [ + "CUDA_GRAPH_OPT=oui ignoré (on ou off)" + ] + }, + { + "m": 6, + "c": 69, + "h": 0, + "e": 9, + "args": [ + "/app/llama-server", + "-m", + "/m/layers.gguf", + "-c", + "32768", + "-t", + "4", + "-b", + "2048", + "-ub", + "512", + "--host", + "0.0.0.0", + "--port", + "8080", + "--parallel", + "1", + "-ngl", + "auto", + "--mmproj", + "/m/mmproj.gguf", + "--checkpoint-min-step", + "1024" + ], + "env": {}, + "notes": [ + "threads auto → -t 4 : cpuset du conteneur, 4 cœurs physiques permis sur 8 (llama.cpp seul en lancerait 8)" + ] + }, + { + "m": 2, + "c": 77, + "h": 0, + "e": 9, + "args": [ + "/app/llama-server", + "-m", + "/m/inconnu.gguf", + "-c", + "32768", + "-b", + "2048", + "-ub", + "512", + "--host", + "0.0.0.0", + "--port", + "8080", + "--parallel", + "1", + "-ngl", + "auto", + "--load-mode", + "none" + ], + "env": {}, + "notes": null + }, + { + "m": 9, + "c": 84, + "h": 0, + "e": 9, + "args": [ + "/app/llama-server", + "-m", + "/m/dense.gguf", + "-c", + "32768", + "-t", + "6", + "-tb", + "6", + "-b", + "2048", + "-ub", + "512", + "--host", + "0.0.0.0", + "--port", + "8080", + "--parallel", + "1", + "-ngl", + "auto", + "--prio-batch", + "2" + ], + "env": {}, + "notes": null + }, + { + "m": 5, + "c": 92, + "h": 0, + "e": 9, + "args": [ + "/app/llama-server", + "-m", + "/m/ds.gguf", + "-c", + "32768", + "-b", + "2048", + "-ub", + "512", + "--host", + "0.0.0.0", + "--port", + "8080", + "--parallel", + "1", + "-ngl", + "auto", + "--api-key", + "sk-loki-x", + "-ts", + "1,1" + ], + "env": {}, + "notes": null + }, + { + "m": 6, + "c": 93, + "h": 0, + "e": 9, + "args": [ + "/app/llama-server", + "-m", + "/m/layers.gguf", + "-c", + "32768", + "-t", + "4", + "-b", + "2048", + "-ub", + "512", + "--host", + "0.0.0.0", + "--port", + "8080", + "--parallel", + "1", + "-ngl", + "auto", + "--mmproj", + "/m/mmproj.gguf", + "-dev", + "CUDA0,CUDA1" + ], + "env": { + "CUDA_SCALE_LAUNCH_QUEUES": "4x" + }, + "notes": [ + "CUDA_SCALE_LAUNCH_QUEUES=4x (2 GPU, parallélisme pipeline possible) — CUDA_LAUNCH_QUEUES=off pour s'en passer", + "threads auto → -t 4 : cpuset du conteneur, 4 cœurs physiques permis sur 8 (llama.cpp seul en lancerait 8)" + ] + }, + { + "m": 10, + "c": 0, + "h": 0, + "e": 10, + "args": [ + "/app/llama-server", + "-m", + "/m/hybrid.gguf", + "-c", + "32768", + "-b", + "2048", + "-ub", + "512", + "--host", + "0.0.0.0", + "--port", + "8080", + "--parallel", + "1", + "-ngl", + "auto" + ], + "env": {}, + "notes": null + }, + { + "m": 11, + "c": 1, + "h": 0, + "e": 10, + "args": [ + "/app/llama-server", + "-m", + "/m/dense.gguf", + "-c", + "65536", + "-b", + "4096", + "-ub", + "2048", + "--host", + "127.0.0.1", + "--port", + "8081", + "--parallel", + "1", + "-ngl", + "auto" + ], + "env": {}, + "notes": [ + "NGL=999 (sentinelle « toutes les couches ») → -ngl auto : ce moteur mesure lui-même la VRAM libre. NGL=all pour forcer l'ancien comportement." + ] + }, + { + "m": 7, + "c": 9, + "h": 0, + "e": 10, + "args": [ + "/app/llama-server", + "-m", + "/m/dense.gguf", + "-c", + "32768", + "-b", + "2048", + "-ub", + "512", + "--host", + "0.0.0.0", + "--port", + "8080", + "--parallel", + "1", + "-ngl", + "auto", + "-ctk", + "q8_0", + "-ctv", + "q8_0" + ], + "env": { + "CUDA_SCALE_LAUNCH_QUEUES": "4x" + }, + "notes": [ + "CUDA_SCALE_LAUNCH_QUEUES=4x (2 GPU, parallélisme pipeline possible) — CUDA_LAUNCH_QUEUES=off pour s'en passer", + "cache KV q8_0/q8_0 (KV_TYPE) : modifie légèrement les sorties par rapport à f16 — choix de l'utilisateur, laissé tel quel." + ] + }, + { + "m": 6, + "c": 20, + "h": 0, + "e": 10, + "args": [ + "/app/llama-server", + "-m", + "/m/layers.gguf", + "-c", + "32768", + "-t", + "4", + "-b", + "2048", + "-ub", + "512", + "--host", + "0.0.0.0", + "--port", + "8080", + "--parallel", + "1", + "-ngl", + "auto", + "--mmproj", + "/m/mmproj.gguf" + ], + "env": {}, + "notes": [ + "threads auto → -t 4 : cpuset du conteneur, 4 cœurs physiques permis sur 8 (llama.cpp seul en lancerait 8)" + ] + }, + { + "m": 1, + "c": 27, + "h": 0, + "e": 10, + "args": [ + "/app/llama-server", + "-m", + "/m/moe.gguf", + "-c", + "32768", + "-b", + "2048", + "-ub", + "512", + "--host", + "0.0.0.0", + "--port", + "8080", + "--parallel", + "1", + "-ngl", + "auto" + ], + "env": { + "CUDA_SCALE_LAUNCH_QUEUES": "4x" + }, + "notes": [ + "CUDA_SCALE_LAUNCH_QUEUES=4x (2 GPU, parallélisme pipeline possible) — CUDA_LAUNCH_QUEUES=off pour s'en passer", + "SIDE_SLOT refusé (VRAM insuffisante : modèle + 2 × 3072 Mio d'état dépassent 90 % de 28599 Mio) : un seul slot, comme sans la clé" + ] + }, + { + "m": 5, + "c": 31, + "h": 0, + "e": 10, + "args": [ + "/app/llama-server", + "-m", + "/m/ds.gguf", + "-c", + "32768", + "-b", + "2048", + "-ub", + "512", + "--host", + "0.0.0.0", + "--port", + "8080", + "--parallel", + "1", + "-ngl", + "auto", + "--api-key", + "sk-loki-x" + ], + "env": {}, + "notes": [ + "SPLIT_MODE=tensor refusé (architecture deepseek2 exclue du mode tensor) : découpe par couches, comme sans la clé" + ] + }, + { + "m": 11, + "c": 37, + "h": 0, + "e": 10, + "args": [ + "/app/llama-server", + "-m", + "/m/dense.gguf", + "-c", + "32768", + "-b", + "2048", + "-ub", + "512", + "--host", + "0.0.0.0", + "--port", + "8080", + "--parallel", + "1", + "-ngl", + "auto" + ], + "env": {}, + "notes": null + }, + { + "m": 1, + "c": 39, + "h": 0, + "e": 10, + "args": [ + "/app/llama-server", + "-m", + "/m/moe.gguf", + "-c", + "32768", + "-b", + "2048", + "-ub", + "512", + "--host", + "0.0.0.0", + "--port", + "8080", + "--parallel", + "1", + "-ngl", + "auto", + "--spec-type", + "ngram-mod", + "--spec-ngram-mod-n-match", + "24", + "--spec-ngram-mod-n-min", + "48", + "--spec-ngram-mod-n-max", + "64" + ], + "env": { + "CUDA_SCALE_LAUNCH_QUEUES": "4x" + }, + "notes": [ + "CUDA_SCALE_LAUNCH_QUEUES=4x (2 GPU, parallélisme pipeline possible) — CUDA_LAUNCH_QUEUES=off pour s'en passer", + "SPEC=ngram → n-grammes du contexte (ngram-mod, brouillons de 48 à 64 jetons) : distribution inchangée (chaque jeton vérifié), ~16 Mo de RAM ; utile surtout en mode code, à mesurer (acceptation par nature dans /api/perf/summary)" + ] + }, + { + "m": 2, + "c": 40, + "h": 0, + "e": 10, + "args": [ + "/app/llama-server", + "-m", + "/m/inconnu.gguf", + "-c", + "32768", + "-b", + "2048", + "-ub", + "512", + "--host", + "0.0.0.0", + "--port", + "8080", + "--parallel", + "1", + "-ngl", + "auto", + "--spec-type", + "ngram-mod", + "--spec-ngram-mod-n-match", + "24", + "--spec-ngram-mod-n-min", + "48", + "--spec-ngram-mod-n-max", + "64" + ], + "env": {}, + "notes": [ + "SPEC=mtp+ngram : sans MTP (métadonnées du modèle illisibles) → n-grammes seuls. SPEC=ngram → n-grammes du contexte (ngram-mod, brouillons de 48 à 64 jetons) : distribution inchangée (chaque jeton vérifié), ~16 Mo de RAM ; utile surtout en mode code, à mesurer (acceptation par nature dans /api/perf/summary)" + ] + }, + { + "m": 7, + "c": 57, + "h": 0, + "e": 10, + "args": [ + "/app/llama-server", + "-m", + "/m/dense.gguf", + "-c", + "32768", + "-b", + "2048", + "-ub", + "512", + "--host", + "0.0.0.0", + "--port", + "8080", + "--parallel", + "1", + "-ngl", + "auto", + "--fit-target", + "1024" + ], + "env": { + "CUDA_SCALE_LAUNCH_QUEUES": "4x" + }, + "notes": [ + "CUDA_SCALE_LAUNCH_QUEUES=4x (2 GPU, parallélisme pipeline possible) — CUDA_LAUNCH_QUEUES=off pour s'en passer", + "--fit-target 1024 (FIT_TARGET, Mio libres par carte, dans l'ordre vu par le moteur) — vérifie « offloaded N/N layers » dans le journal ; marges sous 1024 Mio remontées à 1024" + ] + }, + { + "m": 3, + "c": 65, + "h": 0, + "e": 10, + "args": [ + "/app/llama-server", + "-m", + "/m/hybrid.gguf", + "-c", + "32768", + "-b", + "2048", + "-ub", + "512", + "--host", + "0.0.0.0", + "--port", + "8080", + "--parallel", + "1", + "-ngl", + "auto" + ], + "env": {}, + "notes": [ + "CUDA_GRAPH_OPT=oui ignoré (on ou off)", + "modèle hybride de 20 Gio pour 24 Gio de RAM : jusqu'à 32 points de reprise (~150 Mio chacun) prennent sur le cache de pages des poids. CTX_CHECKPOINTS=8 à 16 si le débit baisse.", + "moteur b9000 : les points de reprise des modèles hybrides (Qwen3.5/3.6, Qwen3-Next…) y sont évincés trop tôt — jusqu'à ~8 k jetons recalculés à chaque reprise. Corrigé à partir de b10864. Pas d'espacement resserré d'office : RAM inconnue ou déjà prise par le modèle (CKPT_MIN_STEP=2048 pour l'imposer)." + ] + }, + { + "m": 7, + "c": 69, + "h": 0, + "e": 10, + "args": [ + "/app/llama-server", + "-m", + "/m/dense.gguf", + "-c", + "32768", + "-b", + "2048", + "-ub", + "512", + "--host", + "0.0.0.0", + "--port", + "8080", + "--parallel", + "1", + "-ngl", + "auto", + "--checkpoint-min-step", + "1024" + ], + "env": { + "CUDA_SCALE_LAUNCH_QUEUES": "4x" + }, + "notes": [ + "CUDA_SCALE_LAUNCH_QUEUES=4x (2 GPU, parallélisme pipeline possible) — CUDA_LAUNCH_QUEUES=off pour s'en passer" + ] + }, + { + "m": 3, + "c": 77, + "h": 0, + "e": 10, + "args": [ + "/app/llama-server", + "-m", + "/m/hybrid.gguf", + "-c", + "32768", + "-b", + "2048", + "-ub", + "512", + "--host", + "0.0.0.0", + "--port", + "8080", + "--parallel", + "1", + "-ngl", + "auto", + "--load-mode", + "none" + ], + "env": {}, + "notes": [ + "modèle hybride de 20 Gio pour 24 Gio de RAM : jusqu'à 32 points de reprise (~150 Mio chacun) prennent sur le cache de pages des poids. CTX_CHECKPOINTS=8 à 16 si le débit baisse.", + "moteur b9000 : les points de reprise des modèles hybrides (Qwen3.5/3.6, Qwen3-Next…) y sont évincés trop tôt — jusqu'à ~8 k jetons recalculés à chaque reprise. Corrigé à partir de b10864. Pas d'espacement resserré d'office : RAM inconnue ou déjà prise par le modèle (CKPT_MIN_STEP=2048 pour l'imposer)." + ] + }, + { + "m": 10, + "c": 84, + "h": 0, + "e": 10, + "args": [ + "/app/llama-server", + "-m", + "/m/hybrid.gguf", + "-c", + "32768", + "-t", + "6", + "-tb", + "6", + "-b", + "2048", + "-ub", + "512", + "--host", + "0.0.0.0", + "--port", + "8080", + "--parallel", + "1", + "-ngl", + "auto", + "--prio-batch", + "2" + ], + "env": {}, + "notes": null + }, + { + "m": 6, + "c": 92, + "h": 0, + "e": 10, + "args": [ + "/app/llama-server", + "-m", + "/m/layers.gguf", + "-c", + "32768", + "-t", + "4", + "-b", + "2048", + "-ub", + "512", + "--host", + "0.0.0.0", + "--port", + "8080", + "--parallel", + "1", + "-ngl", + "auto", + "--mmproj", + "/m/mmproj.gguf", + "-ts", + "1,1" + ], + "env": {}, + "notes": [ + "threads auto → -t 4 : cpuset du conteneur, 4 cœurs physiques permis sur 8 (llama.cpp seul en lancerait 8)" + ] + }, + { + "m": 7, + "c": 93, + "h": 0, + "e": 10, + "args": [ + "/app/llama-server", + "-m", + "/m/dense.gguf", + "-c", + "32768", + "-b", + "2048", + "-ub", + "512", + "--host", + "0.0.0.0", + "--port", + "8080", + "--parallel", + "1", + "-ngl", + "auto", + "-dev", + "CUDA0,CUDA1" + ], + "env": { + "CUDA_SCALE_LAUNCH_QUEUES": "4x" + }, + "notes": [ + "CUDA_SCALE_LAUNCH_QUEUES=4x (2 GPU, parallélisme pipeline possible) — CUDA_LAUNCH_QUEUES=off pour s'en passer" + ] + }, + { + "m": 11, + "c": 0, + "h": 0, + "e": 11, + "args": [ + "/app/llama-server", + "-m", + "/m/dense.gguf", + "-c", + "32768", + "-b", + "2048", + "-ub", + "512", + "--host", + "0.0.0.0", + "--port", + "8080", + "--parallel", + "1", + "-ngl", + "auto" + ], + "env": {}, + "notes": [ + "avertissement : LLAMA_ARG_SPEC_SYNTH_LEN — le moteur accepte des jetons du brouillon AU HASARD, sans vérification : les réponses ne sont plus celles du modèle. Réservé aux mesures, retire-le." + ] + }, + { + "m": 0, + "c": 1, + "h": 0, + "e": 11, + "args": [ + "/app/llama-server", + "-m", + "/m/dense.gguf", + "-c", + "65536", + "-b", + "4096", + "-ub", + "2048", + "--host", + "127.0.0.1", + "--port", + "8081", + "--parallel", + "1", + "-ngl", + "auto" + ], + "env": {}, + "notes": [ + "NGL=999 (sentinelle « toutes les couches ») → -ngl auto : ce moteur mesure lui-même la VRAM libre. NGL=all pour forcer l'ancien comportement.", + "avertissement : LLAMA_ARG_SPEC_SYNTH_LEN — le moteur accepte des jetons du brouillon AU HASARD, sans vérification : les réponses ne sont plus celles du modèle. Réservé aux mesures, retire-le." + ] + }, + { + "m": 8, + "c": 9, + "h": 0, + "e": 11, + "args": [ + "/app/llama-server", + "-m", + "/m/dense.gguf", + "-c", + "32768", + "-b", + "2048", + "-ub", + "512", + "--host", + "0.0.0.0", + "--port", + "8080", + "--parallel", + "1", + "-ngl", + "auto", + "-ctk", + "q8_0", + "-ctv", + "q8_0" + ], + "env": {}, + "notes": [ + "cache KV q8_0/q8_0 (KV_TYPE) : modifie légèrement les sorties par rapport à f16 — choix de l'utilisateur, laissé tel quel.", + "avertissement : LLAMA_ARG_SPEC_SYNTH_LEN — le moteur accepte des jetons du brouillon AU HASARD, sans vérification : les réponses ne sont plus celles du modèle. Réservé aux mesures, retire-le." + ] + }, + { + "m": 7, + "c": 20, + "h": 0, + "e": 11, + "args": [ + "/app/llama-server", + "-m", + "/m/dense.gguf", + "-c", + "32768", + "-b", + "2048", + "-ub", + "512", + "--host", + "0.0.0.0", + "--port", + "8080", + "--parallel", + "1", + "-ngl", + "auto" + ], + "env": { + "CUDA_SCALE_LAUNCH_QUEUES": "4x" + }, + "notes": [ + "CUDA_SCALE_LAUNCH_QUEUES=4x (2 GPU, parallélisme pipeline possible) — CUDA_LAUNCH_QUEUES=off pour s'en passer", + "avertissement : LLAMA_ARG_SPEC_SYNTH_LEN — le moteur accepte des jetons du brouillon AU HASARD, sans vérification : les réponses ne sont plus celles du modèle. Réservé aux mesures, retire-le." + ] + }, + { + "m": 2, + "c": 27, + "h": 0, + "e": 11, + "args": [ + "/app/llama-server", + "-m", + "/m/inconnu.gguf", + "-c", + "32768", + "-b", + "2048", + "-ub", + "512", + "--host", + "0.0.0.0", + "--port", + "8080", + "--parallel", + "1", + "-ngl", + "auto" + ], + "env": {}, + "notes": [ + "SIDE_SLOT refusé (VRAM NVIDIA inconnue (macOS, AMD, --device…)) : un seul slot, comme sans la clé", + "avertissement : LLAMA_ARG_SPEC_SYNTH_LEN — le moteur accepte des jetons du brouillon AU HASARD, sans vérification : les réponses ne sont plus celles du modèle. Réservé aux mesures, retire-le." + ] + }, + { + "m": 6, + "c": 31, + "h": 0, + "e": 11, + "args": [ + "/app/llama-server", + "-m", + "/m/layers.gguf", + "-c", + "32768", + "-t", + "4", + "-b", + "2048", + "-ub", + "512", + "--host", + "0.0.0.0", + "--port", + "8080", + "--parallel", + "1", + "-ngl", + "auto", + "--mmproj", + "/m/mmproj.gguf" + ], + "env": {}, + "notes": [ + "SPLIT_MODE=tensor refusé (cartes inconnues (--list-devices n'a rien répondu)) : découpe par couches, comme sans la clé", + "threads auto → -t 4 : cpuset du conteneur, 4 cœurs physiques permis sur 8 (llama.cpp seul en lancerait 8)", + "avertissement : LLAMA_ARG_SPEC_SYNTH_LEN — le moteur accepte des jetons du brouillon AU HASARD, sans vérification : les réponses ne sont plus celles du modèle. Réservé aux mesures, retire-le." + ] + }, + { + "m": 0, + "c": 37, + "h": 0, + "e": 11, + "args": [ + "/app/llama-server", + "-m", + "/m/dense.gguf", + "-c", + "32768", + "-b", + "2048", + "-ub", + "512", + "--host", + "0.0.0.0", + "--port", + "8080", + "--parallel", + "1", + "-ngl", + "auto" + ], + "env": {}, + "notes": [ + "avertissement : LLAMA_ARG_SPEC_SYNTH_LEN — le moteur accepte des jetons du brouillon AU HASARD, sans vérification : les réponses ne sont plus celles du modèle. Réservé aux mesures, retire-le." + ] + }, + { + "m": 2, + "c": 39, + "h": 0, + "e": 11, + "args": [ + "/app/llama-server", + "-m", + "/m/inconnu.gguf", + "-c", + "32768", + "-b", + "2048", + "-ub", + "512", + "--host", + "0.0.0.0", + "--port", + "8080", + "--parallel", + "1", + "-ngl", + "auto", + "--spec-type", + "ngram-mod", + "--spec-ngram-mod-n-match", + "24", + "--spec-ngram-mod-n-min", + "48", + "--spec-ngram-mod-n-max", + "64" + ], + "env": {}, + "notes": [ + "SPEC=ngram → n-grammes du contexte (ngram-mod, brouillons de 48 à 64 jetons) : distribution inchangée (chaque jeton vérifié), ~16 Mo de RAM ; utile surtout en mode code, à mesurer (acceptation par nature dans /api/perf/summary)", + "avertissement : LLAMA_ARG_SPEC_SYNTH_LEN — le moteur accepte des jetons du brouillon AU HASARD, sans vérification : les réponses ne sont plus celles du modèle. Réservé aux mesures, retire-le." + ] + }, + { + "m": 3, + "c": 40, + "h": 0, + "e": 11, + "args": [ + "/app/llama-server", + "-m", + "/m/hybrid.gguf", + "-c", + "32768", + "-b", + "2048", + "-ub", + "512", + "--host", + "0.0.0.0", + "--port", + "8080", + "--parallel", + "1", + "-ngl", + "auto", + "--spec-type", + "draft-mtp,ngram-mod", + "--spec-ngram-mod-n-match", + "24", + "--spec-ngram-mod-n-min", + "48", + "--spec-ngram-mod-n-max", + "64", + "--spec-draft-sampling", + "greedy" + ], + "env": {}, + "notes": [ + "modèle hybride de 20 Gio pour 24 Gio de RAM : jusqu'à 32 points de reprise (~150 Mio chacun) prennent sur le cache de pages des poids. CTX_CHECKPOINTS=8 à 16 si le débit baisse.", + "moteur b9000 : les points de reprise des modèles hybrides (Qwen3.5/3.6, Qwen3-Next…) y sont évincés trop tôt — jusqu'à ~8 k jetons recalculés à chaque reprise. Corrigé à partir de b10864. Pas d'espacement resserré d'office : RAM inconnue ou déjà prise par le modèle (CKPT_MIN_STEP=2048 pour l'imposer).", + "SPEC=mtp+ngram → tête MTP imposé : sortie inchangée, ~1-2 Go de VRAM en plus ; n-grammes en plus (ngram-mod, prioritaires quand ils trouvent une répétition)", + "modèle hybride : chaque brouillon n-gramme copie l'état récurrent (point de reprise en RAM hôte) et le recharge s'il est rejeté — compare le temps par tour, pas seulement le débit", + "avertissement : LLAMA_ARG_SPEC_SYNTH_LEN — le moteur accepte des jetons du brouillon AU HASARD, sans vérification : les réponses ne sont plus celles du modèle. Réservé aux mesures, retire-le." + ] + }, + { + "m": 8, + "c": 57, + "h": 0, + "e": 11, + "args": [ + "/app/llama-server", + "-m", + "/m/dense.gguf", + "-c", + "32768", + "-b", + "2048", + "-ub", + "512", + "--host", + "0.0.0.0", + "--port", + "8080", + "--parallel", + "1", + "-ngl", + "auto", + "--fit-target", + "1024" + ], + "env": {}, + "notes": [ + "--fit-target 1024 (FIT_TARGET, Mio libres par carte, dans l'ordre vu par le moteur) — vérifie « offloaded N/N layers » dans le journal ; marges sous 1024 Mio remontées à 1024", + "avertissement : LLAMA_ARG_SPEC_SYNTH_LEN — le moteur accepte des jetons du brouillon AU HASARD, sans vérification : les réponses ne sont plus celles du modèle. Réservé aux mesures, retire-le." + ] + }, + { + "m": 4, + "c": 65, + "h": 0, + "e": 11, + "args": [ + "/app/llama-server", + "-m", + "/m/hybrid.gguf", + "-c", + "32768", + "-b", + "2048", + "-ub", + "512", + "--host", + "0.0.0.0", + "--port", + "8080", + "--parallel", + "1", + "-ngl", + "auto" + ], + "env": { + "CUDA_SCALE_LAUNCH_QUEUES": "4x" + }, + "notes": [ + "CUDA_SCALE_LAUNCH_QUEUES=4x (2 GPU, parallélisme pipeline possible) — CUDA_LAUNCH_QUEUES=off pour s'en passer", + "CUDA_GRAPH_OPT=oui ignoré (on ou off)", + "avertissement : LLAMA_ARG_SPEC_SYNTH_LEN — le moteur accepte des jetons du brouillon AU HASARD, sans vérification : les réponses ne sont plus celles du modèle. Réservé aux mesures, retire-le." + ] + }, + { + "m": 8, + "c": 69, + "h": 0, + "e": 11, + "args": [ + "/app/llama-server", + "-m", + "/m/dense.gguf", + "-c", + "32768", + "-b", + "2048", + "-ub", + "512", + "--host", + "0.0.0.0", + "--port", + "8080", + "--parallel", + "1", + "-ngl", + "auto", + "--checkpoint-min-step", + "1024" + ], + "env": {}, + "notes": [ + "avertissement : LLAMA_ARG_SPEC_SYNTH_LEN — le moteur accepte des jetons du brouillon AU HASARD, sans vérification : les réponses ne sont plus celles du modèle. Réservé aux mesures, retire-le." + ] + }, + { + "m": 4, + "c": 77, + "h": 0, + "e": 11, + "args": [ + "/app/llama-server", + "-m", + "/m/hybrid.gguf", + "-c", + "32768", + "-b", + "2048", + "-ub", + "512", + "--host", + "0.0.0.0", + "--port", + "8080", + "--parallel", + "1", + "-ngl", + "auto", + "--load-mode", + "none" + ], + "env": { + "CUDA_SCALE_LAUNCH_QUEUES": "4x" + }, + "notes": [ + "CUDA_SCALE_LAUNCH_QUEUES=4x (2 GPU, parallélisme pipeline possible) — CUDA_LAUNCH_QUEUES=off pour s'en passer", + "avertissement : LLAMA_ARG_SPEC_SYNTH_LEN — le moteur accepte des jetons du brouillon AU HASARD, sans vérification : les réponses ne sont plus celles du modèle. Réservé aux mesures, retire-le." + ] + }, + { + "m": 11, + "c": 84, + "h": 0, + "e": 11, + "args": [ + "/app/llama-server", + "-m", + "/m/dense.gguf", + "-c", + "32768", + "-t", + "6", + "-tb", + "6", + "-b", + "2048", + "-ub", + "512", + "--host", + "0.0.0.0", + "--port", + "8080", + "--parallel", + "1", + "-ngl", + "auto", + "--prio-batch", + "2" + ], + "env": {}, + "notes": [ + "avertissement : LLAMA_ARG_SPEC_SYNTH_LEN — le moteur accepte des jetons du brouillon AU HASARD, sans vérification : les réponses ne sont plus celles du modèle. Réservé aux mesures, retire-le." + ] + }, + { + "m": 7, + "c": 92, + "h": 0, + "e": 11, + "args": [ + "/app/llama-server", + "-m", + "/m/dense.gguf", + "-c", + "32768", + "-b", + "2048", + "-ub", + "512", + "--host", + "0.0.0.0", + "--port", + "8080", + "--parallel", + "1", + "-ngl", + "auto", + "-ts", + "1,1" + ], + "env": { + "CUDA_SCALE_LAUNCH_QUEUES": "4x" + }, + "notes": [ + "CUDA_SCALE_LAUNCH_QUEUES=4x (2 GPU, parallélisme pipeline possible) — CUDA_LAUNCH_QUEUES=off pour s'en passer", + "avertissement : LLAMA_ARG_SPEC_SYNTH_LEN — le moteur accepte des jetons du brouillon AU HASARD, sans vérification : les réponses ne sont plus celles du modèle. Réservé aux mesures, retire-le." + ] + }, + { + "m": 8, + "c": 93, + "h": 0, + "e": 11, + "args": [ + "/app/llama-server", + "-m", + "/m/dense.gguf", + "-c", + "32768", + "-b", + "2048", + "-ub", + "512", + "--host", + "0.0.0.0", + "--port", + "8080", + "--parallel", + "1", + "-ngl", + "auto", + "-dev", + "CUDA0,CUDA1" + ], + "env": { + "CUDA_SCALE_LAUNCH_QUEUES": "4x" + }, + "notes": [ + "CUDA_SCALE_LAUNCH_QUEUES=4x (2 GPU, parallélisme pipeline possible) — CUDA_LAUNCH_QUEUES=off pour s'en passer", + "avertissement : LLAMA_ARG_SPEC_SYNTH_LEN — le moteur accepte des jetons du brouillon AU HASARD, sans vérification : les réponses ne sont plus celles du modèle. Réservé aux mesures, retire-le." + ] + }, + { + "m": 0, + "c": 0, + "h": 0, + "e": 12, + "args": [ + "/app/llama-server", + "-m", + "/m/dense.gguf", + "-c", + "32768", + "-b", + "2048", + "-ub", + "512", + "--host", + "0.0.0.0", + "--port", + "8080", + "--parallel", + "1", + "-ngl", + "auto" + ], + "env": {}, + "notes": null + }, + { + "m": 1, + "c": 1, + "h": 0, + "e": 12, + "args": [ + "/app/llama-server", + "-m", + "/m/moe.gguf", + "-c", + "65536", + "-b", + "4096", + "-ub", + "2048", + "--host", + "127.0.0.1", + "--port", + "8081", + "--parallel", + "1", + "-ngl", + "auto" + ], + "env": { + "CUDA_SCALE_LAUNCH_QUEUES": "4x" + }, + "notes": [ + "CUDA_SCALE_LAUNCH_QUEUES=4x (2 GPU, parallélisme pipeline possible) — CUDA_LAUNCH_QUEUES=off pour s'en passer", + "NGL=999 (sentinelle « toutes les couches ») → -ngl auto : ce moteur mesure lui-même la VRAM libre. NGL=all pour forcer l'ancien comportement." + ] + }, + { + "m": 9, + "c": 9, + "h": 0, + "e": 12, + "args": [ + "/app/llama-server", + "-m", + "/m/dense.gguf", + "-c", + "32768", + "-b", + "2048", + "-ub", + "512", + "--host", + "0.0.0.0", + "--port", + "8080", + "--parallel", + "1", + "-ngl", + "auto", + "-ctk", + "q8_0", + "-ctv", + "q8_0" + ], + "env": {}, + "notes": [ + "cache KV q8_0/q8_0 (KV_TYPE) : modifie légèrement les sorties par rapport à f16 — choix de l'utilisateur, laissé tel quel." + ] + }, + { + "m": 8, + "c": 20, + "h": 0, + "e": 12, + "args": [ + "/app/llama-server", + "-m", + "/m/dense.gguf", + "-c", + "32768", + "-b", + "2048", + "-ub", + "512", + "--host", + "0.0.0.0", + "--port", + "8080", + "--parallel", + "1", + "-ngl", + "auto" + ], + "env": {}, + "notes": null + }, + { + "m": 3, + "c": 27, + "h": 0, + "e": 12, + "args": [ + "/app/llama-server", + "-m", + "/m/hybrid.gguf", + "-c", + "32768", + "-b", + "2048", + "-ub", + "512", + "--host", + "0.0.0.0", + "--port", + "8080", + "--parallel", + "1", + "-ngl", + "auto" + ], + "env": {}, + "notes": [ + "SIDE_SLOT refusé (VRAM insuffisante : modèle + 2 × 2198 Mio d'état dépassent 90 % de 24576 Mio) : un seul slot, comme sans la clé", + "modèle hybride de 20 Gio pour 24 Gio de RAM : jusqu'à 32 points de reprise (~150 Mio chacun) prennent sur le cache de pages des poids. CTX_CHECKPOINTS=8 à 16 si le débit baisse.", + "moteur b9000 : les points de reprise des modèles hybrides (Qwen3.5/3.6, Qwen3-Next…) y sont évincés trop tôt — jusqu'à ~8 k jetons recalculés à chaque reprise. Corrigé à partir de b10864. Pas d'espacement resserré d'office : RAM inconnue ou déjà prise par le modèle (CKPT_MIN_STEP=2048 pour l'imposer)." + ] + }, + { + "m": 7, + "c": 31, + "h": 0, + "e": 12, + "args": [ + "/app/llama-server", + "-m", + "/m/dense.gguf", + "-c", + "32768", + "-b", + "2048", + "-ub", + "512", + "--host", + "0.0.0.0", + "--port", + "8080", + "--parallel", + "1", + "-ngl", + "auto" + ], + "env": { + "CUDA_SCALE_LAUNCH_QUEUES": "4x" + }, + "notes": [ + "SPLIT_MODE=tensor refusé (VRAM insuffisante sans --fit : ~38912 Mio (poids + cache de 32768 jetons) pour 28599 Mio sur 2 cartes — CTX n'est jamais réduit d'office) : découpe par couches, comme sans la clé", + "CUDA_SCALE_LAUNCH_QUEUES=4x (2 GPU, parallélisme pipeline possible) — CUDA_LAUNCH_QUEUES=off pour s'en passer" + ] + }, + { + "m": 1, + "c": 37, + "h": 0, + "e": 12, + "args": [ + "/app/llama-server", + "-m", + "/m/moe.gguf", + "-c", + "32768", + "-b", + "2048", + "-ub", + "512", + "--host", + "0.0.0.0", + "--port", + "8080", + "--parallel", + "1", + "-ngl", + "auto" + ], + "env": { + "CUDA_SCALE_LAUNCH_QUEUES": "4x" + }, + "notes": [ + "CUDA_SCALE_LAUNCH_QUEUES=4x (2 GPU, parallélisme pipeline possible) — CUDA_LAUNCH_QUEUES=off pour s'en passer" + ] + }, + { + "m": 3, + "c": 39, + "h": 0, + "e": 12, + "args": [ + "/app/llama-server", + "-m", + "/m/hybrid.gguf", + "-c", + "32768", + "-b", + "2048", + "-ub", + "512", + "--host", + "0.0.0.0", + "--port", + "8080", + "--parallel", + "1", + "-ngl", + "auto", + "--spec-type", + "ngram-mod", + "--spec-ngram-mod-n-match", + "24", + "--spec-ngram-mod-n-min", + "48", + "--spec-ngram-mod-n-max", + "64" + ], + "env": {}, + "notes": [ + "modèle hybride de 20 Gio pour 24 Gio de RAM : jusqu'à 32 points de reprise (~150 Mio chacun) prennent sur le cache de pages des poids. CTX_CHECKPOINTS=8 à 16 si le débit baisse.", + "moteur b9000 : les points de reprise des modèles hybrides (Qwen3.5/3.6, Qwen3-Next…) y sont évincés trop tôt — jusqu'à ~8 k jetons recalculés à chaque reprise. Corrigé à partir de b10864. Pas d'espacement resserré d'office : RAM inconnue ou déjà prise par le modèle (CKPT_MIN_STEP=2048 pour l'imposer).", + "SPEC=ngram → n-grammes du contexte (ngram-mod, brouillons de 48 à 64 jetons) : distribution inchangée (chaque jeton vérifié), ~16 Mo de RAM ; utile surtout en mode code, à mesurer (acceptation par nature dans /api/perf/summary)", + "tête MTP du modèle inutilisée avec SPEC=ngram : SPEC=mtp+ngram pour les deux", + "modèle hybride : chaque brouillon n-gramme copie l'état récurrent (point de reprise en RAM hôte) et le recharge s'il est rejeté — compare le temps par tour, pas seulement le débit" + ] + }, + { + "m": 4, + "c": 40, + "h": 0, + "e": 12, + "args": [ + "/app/llama-server", + "-m", + "/m/hybrid.gguf", + "-c", + "32768", + "-b", + "2048", + "-ub", + "512", + "--host", + "0.0.0.0", + "--port", + "8080", + "--parallel", + "1", + "-ngl", + "auto", + "--spec-type", + "draft-mtp,ngram-mod", + "--spec-ngram-mod-n-match", + "24", + "--spec-ngram-mod-n-min", + "48", + "--spec-ngram-mod-n-max", + "64", + "--spec-draft-sampling", + "greedy" + ], + "env": { + "CUDA_SCALE_LAUNCH_QUEUES": "4x" + }, + "notes": [ + "CUDA_SCALE_LAUNCH_QUEUES=4x (2 GPU, parallélisme pipeline possible) — CUDA_LAUNCH_QUEUES=off pour s'en passer", + "SPEC=mtp+ngram → tête MTP imposé : sortie inchangée, ~1-2 Go de VRAM en plus ; n-grammes en plus (ngram-mod, prioritaires quand ils trouvent une répétition) ; placement manuel (--tensor-split) : --fit ne compte pas le brouillon, place-le avec -devd / -ngld si la VRAM manque", + "modèle hybride : chaque brouillon n-gramme copie l'état récurrent (point de reprise en RAM hôte) et le recharge s'il est rejeté — compare le temps par tour, pas seulement le débit" + ] + }, + { + "m": 9, + "c": 57, + "h": 0, + "e": 12, + "args": [ + "/app/llama-server", + "-m", + "/m/dense.gguf", + "-c", + "32768", + "-b", + "2048", + "-ub", + "512", + "--host", + "0.0.0.0", + "--port", + "8080", + "--parallel", + "1", + "-ngl", + "auto" + ], + "env": {}, + "notes": [ + "FIT_TARGET=512 ignoré : --fit inactif (--tensor-split), la marge ne servirait à rien" + ] + }, + { + "m": 5, + "c": 65, + "h": 0, + "e": 12, + "args": [ + "/app/llama-server", + "-m", + "/m/ds.gguf", + "-c", + "32768", + "-b", + "2048", + "-ub", + "512", + "--host", + "0.0.0.0", + "--port", + "8080", + "--parallel", + "1", + "-ngl", + "auto", + "--api-key", + "sk-loki-x" + ], + "env": {}, + "notes": [ + "CUDA_GRAPH_OPT=oui ignoré (on ou off)" + ] + }, + { + "m": 9, + "c": 69, + "h": 0, + "e": 12, + "args": [ + "/app/llama-server", + "-m", + "/m/dense.gguf", + "-c", + "32768", + "-b", + "2048", + "-ub", + "512", + "--host", + "0.0.0.0", + "--port", + "8080", + "--parallel", + "1", + "-ngl", + "auto", + "--checkpoint-min-step", + "1024" + ], + "env": {}, + "notes": null + }, + { + "m": 5, + "c": 77, + "h": 0, + "e": 12, + "args": [ + "/app/llama-server", + "-m", + "/m/ds.gguf", + "-c", + "32768", + "-b", + "2048", + "-ub", + "512", + "--host", + "0.0.0.0", + "--port", + "8080", + "--parallel", + "1", + "-ngl", + "auto", + "--api-key", + "sk-loki-x", + "--load-mode", + "none" + ], + "env": {}, + "notes": null + }, + { + "m": 0, + "c": 84, + "h": 0, + "e": 12, + "args": [ + "/app/llama-server", + "-m", + "/m/dense.gguf", + "-c", + "32768", + "-t", + "6", + "-tb", + "6", + "-b", + "2048", + "-ub", + "512", + "--host", + "0.0.0.0", + "--port", + "8080", + "--parallel", + "1", + "-ngl", + "auto", + "--prio-batch", + "2" + ], + "env": {}, + "notes": null + }, + { + "m": 8, + "c": 92, + "h": 0, + "e": 12, + "args": [ + "/app/llama-server", + "-m", + "/m/dense.gguf", + "-c", + "32768", + "-b", + "2048", + "-ub", + "512", + "--host", + "0.0.0.0", + "--port", + "8080", + "--parallel", + "1", + "-ngl", + "auto", + "-ts", + "1,1" + ], + "env": {}, + "notes": null + }, + { + "m": 9, + "c": 93, + "h": 0, + "e": 12, + "args": [ + "/app/llama-server", + "-m", + "/m/dense.gguf", + "-c", + "32768", + "-b", + "2048", + "-ub", + "512", + "--host", + "0.0.0.0", + "--port", + "8080", + "--parallel", + "1", + "-ngl", + "auto", + "-dev", + "CUDA0,CUDA1" + ], + "env": { + "CUDA_SCALE_LAUNCH_QUEUES": "4x" + }, + "notes": [ + "CUDA_SCALE_LAUNCH_QUEUES=4x (2 GPU, parallélisme pipeline possible) — CUDA_LAUNCH_QUEUES=off pour s'en passer" + ] + } + ], + "cpu_list": { + "": [], + " 5 ": [ + 5 + ], + "0,0,1": [ + 0, + 1 + ], + "0-3,8-11": [ + 0, + 1, + 2, + 3, + 8, + 9, + 10, + 11 + ], + "0-70000": "erreur", + "2-2,1": [ + 1, + 2 + ], + "3-1": "erreur", + "a-b": "erreur" + }, + "cpuset": [ + { + "engine": 0, + "files": { + "proc/self/status": "Name:\tx\nCpus_allowed_list:\t0-7\n", + "sys/devices/system/cpu/cpu0/topology/thread_siblings": "0\n", + "sys/devices/system/cpu/cpu1/topology/thread_siblings": "0\n", + "sys/devices/system/cpu/cpu2/topology/thread_siblings": "1\n", + "sys/devices/system/cpu/cpu3/topology/thread_siblings": "1\n", + "sys/devices/system/cpu/cpu4/topology/thread_siblings": "2\n", + "sys/devices/system/cpu/cpu5/topology/thread_siblings": "2\n", + "sys/devices/system/cpu/cpu6/topology/thread_siblings": "3\n", + "sys/devices/system/cpu/cpu7/topology/thread_siblings": "3\n", + "sys/devices/system/cpu/online": "0-7\n" + }, + "n": 0, + "name": "sans restriction", + "why": "" + }, + { + "engine": 8, + "files": { + "proc/self/status": "Cpus_allowed_list:\t0-5\n", + "sys/devices/system/cpu/cpu0/topology/thread_siblings": "0\n", + "sys/devices/system/cpu/cpu1/topology/thread_siblings": "0\n", + "sys/devices/system/cpu/cpu10/topology/thread_siblings": "5\n", + "sys/devices/system/cpu/cpu11/topology/thread_siblings": "5\n", + "sys/devices/system/cpu/cpu12/topology/thread_siblings": "6\n", + "sys/devices/system/cpu/cpu13/topology/thread_siblings": "6\n", + "sys/devices/system/cpu/cpu14/topology/thread_siblings": "7\n", + "sys/devices/system/cpu/cpu15/topology/thread_siblings": "7\n", + "sys/devices/system/cpu/cpu2/topology/thread_siblings": "1\n", + "sys/devices/system/cpu/cpu3/topology/thread_siblings": "1\n", + "sys/devices/system/cpu/cpu4/topology/thread_siblings": "2\n", + "sys/devices/system/cpu/cpu5/topology/thread_siblings": "2\n", + "sys/devices/system/cpu/cpu6/topology/thread_siblings": "3\n", + "sys/devices/system/cpu/cpu7/topology/thread_siblings": "3\n", + "sys/devices/system/cpu/cpu8/topology/thread_siblings": "4\n", + "sys/devices/system/cpu/cpu9/topology/thread_siblings": "4\n", + "sys/devices/system/cpu/online": "0-15\n" + }, + "n": 3, + "name": "cpuset", + "why": "cpuset du conteneur, 3 cœurs physiques permis sur 8" + }, + { + "engine": 16, + "files": { + "proc/self/status": "Cpus_allowed_list:\t0-15\n", + "sys/devices/system/cpu/cpu0/topology/thread_siblings": "0\n", + "sys/devices/system/cpu/cpu1/topology/thread_siblings": "1\n", + "sys/devices/system/cpu/cpu10/topology/thread_siblings": "10\n", + "sys/devices/system/cpu/cpu11/topology/thread_siblings": "11\n", + "sys/devices/system/cpu/cpu12/topology/thread_siblings": "12\n", + "sys/devices/system/cpu/cpu13/topology/thread_siblings": "13\n", + "sys/devices/system/cpu/cpu14/topology/thread_siblings": "14\n", + "sys/devices/system/cpu/cpu15/topology/thread_siblings": "15\n", + "sys/devices/system/cpu/cpu2/topology/thread_siblings": "2\n", + "sys/devices/system/cpu/cpu3/topology/thread_siblings": "3\n", + "sys/devices/system/cpu/cpu4/topology/thread_siblings": "4\n", + "sys/devices/system/cpu/cpu5/topology/thread_siblings": "5\n", + "sys/devices/system/cpu/cpu6/topology/thread_siblings": "6\n", + "sys/devices/system/cpu/cpu7/topology/thread_siblings": "7\n", + "sys/devices/system/cpu/cpu8/topology/thread_siblings": "8\n", + "sys/devices/system/cpu/cpu9/topology/thread_siblings": "9\n", + "sys/devices/system/cpu/online": "0-15\n", + "sys/fs/cgroup/cpu.max": "250000 100000\n" + }, + "n": 3, + "name": "quota v2", + "why": "quota cgroup de 3 CPU" + }, + { + "engine": 16, + "files": { + "proc/self/status": "Cpus_allowed_list:\t0-15\n", + "sys/devices/system/cpu/cpu0/topology/thread_siblings": "0\n", + "sys/devices/system/cpu/cpu1/topology/thread_siblings": "1\n", + "sys/devices/system/cpu/cpu10/topology/thread_siblings": "10\n", + "sys/devices/system/cpu/cpu11/topology/thread_siblings": "11\n", + "sys/devices/system/cpu/cpu12/topology/thread_siblings": "12\n", + "sys/devices/system/cpu/cpu13/topology/thread_siblings": "13\n", + "sys/devices/system/cpu/cpu14/topology/thread_siblings": "14\n", + "sys/devices/system/cpu/cpu15/topology/thread_siblings": "15\n", + "sys/devices/system/cpu/cpu2/topology/thread_siblings": "2\n", + "sys/devices/system/cpu/cpu3/topology/thread_siblings": "3\n", + "sys/devices/system/cpu/cpu4/topology/thread_siblings": "4\n", + "sys/devices/system/cpu/cpu5/topology/thread_siblings": "5\n", + "sys/devices/system/cpu/cpu6/topology/thread_siblings": "6\n", + "sys/devices/system/cpu/cpu7/topology/thread_siblings": "7\n", + "sys/devices/system/cpu/cpu8/topology/thread_siblings": "8\n", + "sys/devices/system/cpu/cpu9/topology/thread_siblings": "9\n", + "sys/devices/system/cpu/online": "0-15\n", + "sys/fs/cgroup/cpu/cpu.cfs_period_us": "100000\n", + "sys/fs/cgroup/cpu/cpu.cfs_quota_us": "150000\n" + }, + "n": 2, + "name": "quota v1", + "why": "quota cgroup de 2 CPU" + }, + { + "engine": 12, + "files": { + "proc/self/status": "Cpus_allowed_list:\t0-3,8-11\n", + "sys/devices/cpu_atom/cpus": "8-11\n", + "sys/devices/system/cpu/cpu0/topology/thread_siblings": "0\n", + "sys/devices/system/cpu/cpu1/topology/thread_siblings": "1\n", + "sys/devices/system/cpu/cpu10/topology/thread_siblings": "10\n", + "sys/devices/system/cpu/cpu11/topology/thread_siblings": "11\n", + "sys/devices/system/cpu/cpu2/topology/thread_siblings": "2\n", + "sys/devices/system/cpu/cpu3/topology/thread_siblings": "3\n", + "sys/devices/system/cpu/cpu4/topology/thread_siblings": "4\n", + "sys/devices/system/cpu/cpu5/topology/thread_siblings": "5\n", + "sys/devices/system/cpu/cpu6/topology/thread_siblings": "6\n", + "sys/devices/system/cpu/cpu7/topology/thread_siblings": "7\n", + "sys/devices/system/cpu/cpu8/topology/thread_siblings": "8\n", + "sys/devices/system/cpu/cpu9/topology/thread_siblings": "9\n", + "sys/devices/system/cpu/online": "0-11\n" + }, + "n": 4, + "name": "cœurs E", + "why": "cpuset du conteneur, 4 cœurs physiques permis sur 12 (cœurs E écartés)" + }, + { + "engine": 0, + "files": { + "proc/self/status": "Cpus_allowed_list:\t0-3\n", + "sys/devices/system/cpu/cpu0/topology/thread_siblings": "0\n", + "sys/devices/system/cpu/cpu1/topology/thread_siblings": "1\n", + "sys/devices/system/cpu/cpu2/topology/thread_siblings": "2\n", + "sys/devices/system/cpu/cpu3/topology/thread_siblings": "3\n", + "sys/devices/system/cpu/online": "0-3\n", + "sys/fs/cgroup/cpu.max": "max 100000\n" + }, + "n": 0, + "name": "max", + "why": "" + }, + { + "engine": 0, + "files": { + "sys/devices/system/cpu/online": "x\n" + }, + "n": 0, + "name": "illisible", + "why": "" + } + ], + "cgroup": [ + { + "current": 4096, + "files": { + "sys/fs/cgroup/memory.current": "4294967296\n", + "sys/fs/cgroup/memory.max": "17179869184\n" + }, + "limit": 16384 + }, + { + "current": 0, + "files": { + "sys/fs/cgroup/memory.max": "max\n" + }, + "limit": 0 + }, + { + "current": 0, + "files": { + "sys/fs/cgroup/memory/memory.limit_in_bytes": "9223372036854771712\n" + }, + "limit": 0 + }, + { + "current": 1024, + "files": { + "sys/fs/cgroup/memory/memory.limit_in_bytes": "8589934592\n", + "sys/fs/cgroup/memory/memory.usage_in_bytes": "1073741824\n" + }, + "limit": 8192 + }, + { + "current": 0, + "files": {}, + "limit": 0 + } + ], + "smi": [ + { + "cvd": "", + "mib": 28599, + "out": "0, 16311\n1, 12288\n", + "pci": true + }, + { + "cvd": "1", + "mib": 12288, + "out": "0, 16311\n1, 12288\n", + "pci": true + }, + { + "cvd": "1,0", + "mib": 24576, + "out": "0, 16311\n1, 12288\n", + "pci": false + }, + { + "cvd": "2", + "mib": 0, + "out": "0, 16311\n1, 12288\n", + "pci": true + }, + { + "cvd": "GPU-abc", + "mib": 0, + "out": "0, 16311\n1, 12288\n", + "pci": true + }, + { + "cvd": "", + "mib": 0, + "out": "0, 0\n", + "pci": true + }, + { + "cvd": "", + "mib": 0, + "out": "", + "pci": true + }, + { + "cvd": "0", + "mib": 8192, + "out": "0, 8192\n", + "pci": false + } + ], + "devices": { + "devs": [ + { + "ID": "CUDA0", + "TotalMiB": 16311 + }, + { + "ID": "CUDA1", + "TotalMiB": 12288 + }, + { + "ID": "Vulkan0", + "TotalMiB": 8000 + } + ], + "out": "Available devices:\n CUDA0: NVIDIA GeForce RTX 5060 Ti (16311 MiB, 15579 MiB free)\n CUDA1: NVIDIA GeForce RTX 3060 (12288 MiB, 11000 MiB free)\r\n Vulkan0: AMD (8000 MiB, 0 MiB free)\nautre ligne\n" + }, + "builds": { + "build: 12 (ff)": 12, + "llama-server build 11009, commit 1a2b3c4": 11009, + "rien": 0, + "version: 6543 (abcdef0)\nbuilt with gcc": 6543 + }, + "cmdlines": [ + { + "args": [ + "/app/llama-server", + "-m", + "/m/a b.gguf", + "--api-key", + "secret", + "--chat-template-kwargs", + "{\"x\":\"y\"}", + "" + ], + "env": { + "CUDA_VISIBLE_DEVICES": "0,1", + "GGML_X": "a b" + }, + "out": "CUDA_VISIBLE_DEVICES=0,1 GGML_X=\"a b\" /app/llama-server -m \"/m/a b.gguf\" --api-key \"\" --chat-template-kwargs \"{\\\"x\\\":\\\"y\\\"}\" \"\"" + }, + { + "args": [ + "/bin/x", + "--api-key-file", + "/k" + ], + "env": {}, + "out": "/bin/x --api-key-file \"\"" + } + ], + "verdicts": [ + { + "attempt": null, + "failed": null, + "record": false, + "why": "" + }, + { + "attempt": { + "fp": "abc", + "bin": "/app/llama-server", + "build": 11500 + }, + "failed": null, + "record": true, + "why": "le dernier lancement avec cette configuration n'a jamais répondu" + }, + { + "attempt": { + "fp": "x", + "bin": "", + "build": 0 + }, + "failed": null, + "record": false, + "why": "" + }, + { + "attempt": null, + "failed": { + "abc|/app/llama-server|11500": "déjà échoué" + }, + "record": false, + "why": "déjà échoué" + } + ], + "help_fit": { + "--fit": true, + "--fit [on|off]": true, + "--fit-target": false, + "--fit=on": true, + "--fitness": false, + "-fit, --fit X": true, + "a,--fit,b": true, + "x--fit": false + }, + "gib": { + "0": "0,0 Gio", + "104857": "102,4 Gio", + "1280": "1,2 Gio", + "1536": "1,5 Gio", + "16311": "15,9 Gio", + "22528": "22,0 Gio", + "512": "0,5 Gio", + "65536": "64,0 Gio" + }, + "load_flags": [ + { + "args": [ + "--mlock" + ], + "modern": true, + "note": "", + "out": [ + "--load-mode", + "mmap+mlock" + ] + }, + { + "args": [ + "--no-mmap", + "--mlock" + ], + "modern": true, + "note": "", + "out": [ + "--load-mode", + "mlock" + ] + }, + { + "args": [ + "--mmap", + "-x" + ], + "modern": true, + "note": "", + "out": [ + "-x", + "--load-mode", + "mmap" + ] + }, + { + "args": [ + "--load-mode", + "none", + "--mlock" + ], + "modern": true, + "note": "", + "out": [ + "--load-mode", + "none" + ] + }, + { + "args": [ + "--load-mode", + "mlock" + ], + "modern": false, + "note": "", + "out": [ + "--mlock", + "--no-mmap" + ] + }, + { + "args": [ + "--load-mode=none", + "--no-mmap" + ], + "modern": false, + "note": "", + "out": [ + "--no-mmap" + ] + }, + { + "args": [ + "-lm", + "dio" + ], + "modern": false, + "note": "ce moteur ne connaît pas --load-mode dio (DirectIO) : chargement par défaut", + "out": [] + }, + { + "args": [ + "-lm", + "MMAP+MLOCK" + ], + "modern": false, + "note": "", + "out": [ + "--mlock" + ] + }, + { + "args": [ + "--load-mode" + ], + "modern": false, + "note": "", + "out": [] + }, + { + "args": [ + "-x" + ], + "modern": false, + "note": "", + "out": [ + "-x" + ] + } + ], + "state_bytes": [ + { + "gguf": { + "Version": 3, + "Arch": "qwen3", + "BlockCount": 64, + "NextN": 0, + "ContextLength": 262144, + "ExpertCount": 0, + "KeyLen": 128, + "ValLen": 128, + "HeadCountKV": 8, + "HeadCountKVLayers": null, + "FullAttnInterval": 0, + "Hybrid": false, + "EmbeddingLength": 5120, + "HeadCount": 40, + "SSMConv": 0, + "SSMInner": 0, + "SSMState": 0, + "SSMGroups": 0, + "HasNextNTensor": false, + "TensorCount": 707 + }, + "k": "", + "per_token": 262144, + "recurrent": 0, + "v": "" + }, + { + "gguf": { + "Version": 3, + "Arch": "qwen3", + "BlockCount": 64, + "NextN": 0, + "ContextLength": 262144, + "ExpertCount": 0, + "KeyLen": 128, + "ValLen": 128, + "HeadCountKV": 8, + "HeadCountKVLayers": null, + "FullAttnInterval": 0, + "Hybrid": false, + "EmbeddingLength": 5120, + "HeadCount": 40, + "SSMConv": 0, + "SSMInner": 0, + "SSMState": 0, + "SSMGroups": 0, + "HasNextNTensor": false, + "TensorCount": 707 + }, + "k": "q8_0", + "per_token": 106496, + "recurrent": 0, + "v": "q4_0" + }, + { + "gguf": { + "Version": 3, + "Arch": "qwen3", + "BlockCount": 64, + "NextN": 0, + "ContextLength": 262144, + "ExpertCount": 0, + "KeyLen": 128, + "ValLen": 128, + "HeadCountKV": 8, + "HeadCountKVLayers": null, + "FullAttnInterval": 0, + "Hybrid": false, + "EmbeddingLength": 5120, + "HeadCount": 40, + "SSMConv": 0, + "SSMInner": 0, + "SSMState": 0, + "SSMGroups": 0, + "HasNextNTensor": false, + "TensorCount": 707 + }, + "k": "f32", + "per_token": 299008, + "recurrent": 0, + "v": "iq4_nl" + }, + { + "gguf": { + "Version": 3, + "Arch": "qwen3moe", + "BlockCount": 48, + "NextN": 0, + "ContextLength": 262144, + "ExpertCount": 128, + "KeyLen": 128, + "ValLen": 128, + "HeadCountKV": 4, + "HeadCountKVLayers": null, + "FullAttnInterval": 0, + "Hybrid": false, + "EmbeddingLength": 2048, + "HeadCount": 32, + "SSMConv": 0, + "SSMInner": 0, + "SSMState": 0, + "SSMGroups": 0, + "HasNextNTensor": false, + "TensorCount": 579 + }, + "k": "", + "per_token": 98304, + "recurrent": 0, + "v": "" + }, + { + "gguf": { + "Version": 3, + "Arch": "qwen3moe", + "BlockCount": 48, + "NextN": 0, + "ContextLength": 262144, + "ExpertCount": 128, + "KeyLen": 128, + "ValLen": 128, + "HeadCountKV": 4, + "HeadCountKVLayers": null, + "FullAttnInterval": 0, + "Hybrid": false, + "EmbeddingLength": 2048, + "HeadCount": 32, + "SSMConv": 0, + "SSMInner": 0, + "SSMState": 0, + "SSMGroups": 0, + "HasNextNTensor": false, + "TensorCount": 579 + }, + "k": "q8_0", + "per_token": 39936, + "recurrent": 0, + "v": "q4_0" + }, + { + "gguf": { + "Version": 3, + "Arch": "qwen3moe", + "BlockCount": 48, + "NextN": 0, + "ContextLength": 262144, + "ExpertCount": 128, + "KeyLen": 128, + "ValLen": 128, + "HeadCountKV": 4, + "HeadCountKVLayers": null, + "FullAttnInterval": 0, + "Hybrid": false, + "EmbeddingLength": 2048, + "HeadCount": 32, + "SSMConv": 0, + "SSMInner": 0, + "SSMState": 0, + "SSMGroups": 0, + "HasNextNTensor": false, + "TensorCount": 579 + }, + "k": "f32", + "per_token": 112128, + "recurrent": 0, + "v": "iq4_nl" + }, + { + "gguf": { + "Version": 3, + "Arch": "qwen35", + "BlockCount": 64, + "NextN": 1, + "ContextLength": 262144, + "ExpertCount": 0, + "KeyLen": 256, + "ValLen": 256, + "HeadCountKV": 4, + "HeadCountKVLayers": null, + "FullAttnInterval": 4, + "Hybrid": true, + "EmbeddingLength": 5120, + "HeadCount": 24, + "SSMConv": 4, + "SSMInner": 6144, + "SSMState": 128, + "SSMGroups": 16, + "HasNextNTensor": true, + "TensorCount": 900 + }, + "k": "", + "per_token": 65536, + "recurrent": 156893184, + "v": "" + }, + { + "gguf": { + "Version": 3, + "Arch": "qwen35", + "BlockCount": 64, + "NextN": 1, + "ContextLength": 262144, + "ExpertCount": 0, + "KeyLen": 256, + "ValLen": 256, + "HeadCountKV": 4, + "HeadCountKVLayers": null, + "FullAttnInterval": 4, + "Hybrid": true, + "EmbeddingLength": 5120, + "HeadCount": 24, + "SSMConv": 4, + "SSMInner": 6144, + "SSMState": 128, + "SSMGroups": 16, + "HasNextNTensor": true, + "TensorCount": 900 + }, + "k": "q8_0", + "per_token": 26624, + "recurrent": 156893184, + "v": "q4_0" + }, + { + "gguf": { + "Version": 3, + "Arch": "qwen35", + "BlockCount": 64, + "NextN": 1, + "ContextLength": 262144, + "ExpertCount": 0, + "KeyLen": 256, + "ValLen": 256, + "HeadCountKV": 4, + "HeadCountKVLayers": null, + "FullAttnInterval": 4, + "Hybrid": true, + "EmbeddingLength": 5120, + "HeadCount": 24, + "SSMConv": 4, + "SSMInner": 6144, + "SSMState": 128, + "SSMGroups": 16, + "HasNextNTensor": true, + "TensorCount": 900 + }, + "k": "f32", + "per_token": 74752, + "recurrent": 156893184, + "v": "iq4_nl" + }, + { + "gguf": { + "Version": 3, + "Arch": "gemma3", + "BlockCount": 6, + "NextN": 0, + "ContextLength": 0, + "ExpertCount": 0, + "KeyLen": 64, + "ValLen": 64, + "HeadCountKV": 2, + "HeadCountKVLayers": [ + 2, + 2, + 0, + 2, + 2, + 0 + ], + "FullAttnInterval": 0, + "Hybrid": true, + "EmbeddingLength": 0, + "HeadCount": 0, + "SSMConv": 4, + "SSMInner": 512, + "SSMState": 16, + "SSMGroups": 1, + "HasNextNTensor": false, + "TensorCount": 0 + }, + "k": "", + "per_token": 2048, + "recurrent": 78592, + "v": "" + }, + { + "gguf": { + "Version": 3, + "Arch": "gemma3", + "BlockCount": 6, + "NextN": 0, + "ContextLength": 0, + "ExpertCount": 0, + "KeyLen": 64, + "ValLen": 64, + "HeadCountKV": 2, + "HeadCountKVLayers": [ + 2, + 2, + 0, + 2, + 2, + 0 + ], + "FullAttnInterval": 0, + "Hybrid": true, + "EmbeddingLength": 0, + "HeadCount": 0, + "SSMConv": 4, + "SSMInner": 512, + "SSMState": 16, + "SSMGroups": 1, + "HasNextNTensor": false, + "TensorCount": 0 + }, + "k": "q8_0", + "per_token": 832, + "recurrent": 78592, + "v": "q4_0" + }, + { + "gguf": { + "Version": 3, + "Arch": "gemma3", + "BlockCount": 6, + "NextN": 0, + "ContextLength": 0, + "ExpertCount": 0, + "KeyLen": 64, + "ValLen": 64, + "HeadCountKV": 2, + "HeadCountKVLayers": [ + 2, + 2, + 0, + 2, + 2, + 0 + ], + "FullAttnInterval": 0, + "Hybrid": true, + "EmbeddingLength": 0, + "HeadCount": 0, + "SSMConv": 4, + "SSMInner": 512, + "SSMState": 16, + "SSMGroups": 1, + "HasNextNTensor": false, + "TensorCount": 0 + }, + "k": "f32", + "per_token": 2336, + "recurrent": 78592, + "v": "iq4_nl" + }, + { + "gguf": { + "Version": 3, + "Arch": "deepseek2", + "BlockCount": 61, + "NextN": 0, + "ContextLength": 0, + "ExpertCount": 256, + "KeyLen": 192, + "ValLen": 128, + "HeadCountKV": 128, + "HeadCountKVLayers": null, + "FullAttnInterval": 0, + "Hybrid": false, + "EmbeddingLength": 0, + "HeadCount": 0, + "SSMConv": 0, + "SSMInner": 0, + "SSMState": 0, + "SSMGroups": 0, + "HasNextNTensor": false, + "TensorCount": 0 + }, + "k": "", + "per_token": 4997120, + "recurrent": 0, + "v": "" + }, + { + "gguf": { + "Version": 3, + "Arch": "deepseek2", + "BlockCount": 61, + "NextN": 0, + "ContextLength": 0, + "ExpertCount": 256, + "KeyLen": 192, + "ValLen": 128, + "HeadCountKV": 128, + "HeadCountKVLayers": null, + "FullAttnInterval": 0, + "Hybrid": false, + "EmbeddingLength": 0, + "HeadCount": 0, + "SSMConv": 0, + "SSMInner": 0, + "SSMState": 0, + "SSMGroups": 0, + "HasNextNTensor": false, + "TensorCount": 0 + }, + "k": "q8_0", + "per_token": 2155008, + "recurrent": 0, + "v": "q4_0" + }, + { + "gguf": { + "Version": 3, + "Arch": "deepseek2", + "BlockCount": 61, + "NextN": 0, + "ContextLength": 0, + "ExpertCount": 256, + "KeyLen": 192, + "ValLen": 128, + "HeadCountKV": 128, + "HeadCountKVLayers": null, + "FullAttnInterval": 0, + "Hybrid": false, + "EmbeddingLength": 0, + "HeadCount": 0, + "SSMConv": 0, + "SSMInner": 0, + "SSMState": 0, + "SSMGroups": 0, + "HasNextNTensor": false, + "TensorCount": 0 + }, + "k": "f32", + "per_token": 6558720, + "recurrent": 0, + "v": "iq4_nl" + }, + { + "gguf": { + "Version": 0, + "Arch": "", + "BlockCount": 10, + "NextN": 0, + "ContextLength": 0, + "ExpertCount": 0, + "KeyLen": 0, + "ValLen": 0, + "HeadCountKV": 0, + "HeadCountKVLayers": null, + "FullAttnInterval": 0, + "Hybrid": false, + "EmbeddingLength": 0, + "HeadCount": 0, + "SSMConv": 0, + "SSMInner": 0, + "SSMState": 0, + "SSMGroups": 0, + "HasNextNTensor": false, + "TensorCount": 0 + }, + "k": "", + "per_token": 0, + "recurrent": 0, + "v": "" + }, + { + "gguf": { + "Version": 0, + "Arch": "", + "BlockCount": 10, + "NextN": 0, + "ContextLength": 0, + "ExpertCount": 0, + "KeyLen": 0, + "ValLen": 0, + "HeadCountKV": 0, + "HeadCountKVLayers": null, + "FullAttnInterval": 0, + "Hybrid": false, + "EmbeddingLength": 0, + "HeadCount": 0, + "SSMConv": 0, + "SSMInner": 0, + "SSMState": 0, + "SSMGroups": 0, + "HasNextNTensor": false, + "TensorCount": 0 + }, + "k": "q8_0", + "per_token": 0, + "recurrent": 0, + "v": "q4_0" + }, + { + "gguf": { + "Version": 0, + "Arch": "", + "BlockCount": 10, + "NextN": 0, + "ContextLength": 0, + "ExpertCount": 0, + "KeyLen": 0, + "ValLen": 0, + "HeadCountKV": 0, + "HeadCountKVLayers": null, + "FullAttnInterval": 0, + "Hybrid": false, + "EmbeddingLength": 0, + "HeadCount": 0, + "SSMConv": 0, + "SSMInner": 0, + "SSMState": 0, + "SSMGroups": 0, + "HasNextNTensor": false, + "TensorCount": 0 + }, + "k": "f32", + "per_token": 0, + "recurrent": 0, + "v": "iq4_nl" + } + ] +} diff --git a/rust/loki-rs/tests/serve.rs b/rust/loki-rs/tests/serve.rs new file mode 100644 index 0000000..66156b5 --- /dev/null +++ b/rust/loki-rs/tests/serve.rs @@ -0,0 +1,523 @@ +//! Lot 14 : `loki serve` composé en Rust (`LOKI_SERVE=rust`). +//! +//! Le journal tiré du Go (`internal/loki/parity_serve_test.go` → +//! `tests/parity_serve/serve.json`) est rejoué : ligne de commande, +//! environnement et notes de ~1 900 lancements (machines × presets × aides de +//! moteur × variables), puis les sondes pures. + +use std::collections::{BTreeMap, HashMap}; + +use loki_rs::gguf::GgufMeta; +use loki_rs::serve::{self, CpuBudget, SplitDev, SysInfo}; +use serde_json::Value; + +fn fixture() -> Value { + serde_json::from_str(include_str!("parity_serve/serve.json")).expect("journal") +} + +fn i(v: &Value, k: &str) -> i64 { + v.get(k).and_then(Value::as_i64).unwrap_or(0) +} +fn st(v: &Value, k: &str) -> String { + v.get(k).and_then(Value::as_str).unwrap_or("").to_string() +} +fn b(v: &Value, k: &str) -> bool { + v.get(k).and_then(Value::as_bool).unwrap_or(false) +} + +fn gguf_from(v: &Value) -> Option { + if v.is_null() { + return None; + } + Some(GgufMeta { + version: i(v, "Version"), + arch: st(v, "Arch"), + block_count: i(v, "BlockCount"), + next_n: i(v, "NextN"), + context_length: i(v, "ContextLength"), + expert_count: i(v, "ExpertCount"), + key_len: i(v, "KeyLen"), + val_len: i(v, "ValLen"), + head_count_kv: i(v, "HeadCountKV"), + head_count_kv_layers: v + .get("HeadCountKVLayers") + .and_then(Value::as_array) + .map(|a| a.iter().map(|x| x.as_i64().unwrap()).collect()), + full_attn_interval: i(v, "FullAttnInterval"), + hybrid: b(v, "Hybrid"), + embedding_length: i(v, "EmbeddingLength"), + head_count: i(v, "HeadCount"), + ssm_conv: i(v, "SSMConv"), + ssm_inner: i(v, "SSMInner"), + ssm_state: i(v, "SSMState"), + ssm_groups: i(v, "SSMGroups"), + has_next_n_tensor: b(v, "HasNextNTensor"), + tensor_count: i(v, "TensorCount"), + }) +} + +fn map_from(v: &Value) -> HashMap { + v.as_object() + .map(|o| { + o.iter() + .map(|(k, x)| (k.clone(), x.as_str().unwrap().to_string())) + .collect() + }) + .unwrap_or_default() +} + +fn si_from(m: &Value) -> SysInfo { + let cpu = &m["CPU"]; + SysInfo { + help: st(m, "Help"), + model: st(m, "Model"), + mmproj: st(m, "MMProj"), + api_key: st(m, "APIKey"), + cpu: CpuBudget { + n: i(cpu, "N"), + engine: i(cpu, "Engine"), + why: st(cpu, "Why"), + }, + launch_queues: st(m, "LaunchQueues"), + gpus: i(m, "GPUs"), + arg_env: map_from(&m["ArgEnv"]), + user_env: map_from(&m["UserEnv"]), + gguf: gguf_from(&m["GGUF"]), + model_bytes: i(m, "ModelBytes"), + ram_mib: i(m, "RAMMiB"), + ram_avail_mib: i(m, "RAMAvailMiB"), + vram_mib: i(m, "VRAMMiB"), + unified_mem: b(m, "UnifiedMem"), + engine_vram_mib: i(m, "EngineVRAMMiB"), + slot_dir: st(m, "SlotDir"), + engine_build: i(m, "EngineBuild"), + draft: st(m, "Draft"), + draft_err: st(m, "DraftErr"), + draft_gguf: gguf_from(&m["DraftGGUF"]), + draft_is_model: b(m, "DraftIsModel"), + spec_auto_blocked: st(m, "SpecAutoBlocked"), + split_devs: m["SplitDevs"] + .as_array() + .map(|a| { + a.iter() + .map(|d| SplitDev { + id: st(d, "ID"), + total_mib: i(d, "TotalMiB"), + }) + .collect() + }) + .unwrap_or_default(), + } +} + +fn strs(v: &Value) -> Vec { + v.as_array() + .map(|a| a.iter().map(|x| x.as_str().unwrap().to_string()).collect()) + .unwrap_or_default() +} + +#[test] +fn parite_des_lignes_de_commande() { + let fx = fixture(); + let machines = fx["machines"].as_array().unwrap(); + let presets = fx["presets"].as_array().unwrap(); + let helps = fx["helps"].as_array().unwrap(); + let arg_envs = fx["arg_envs"].as_array().unwrap(); + let user_envs = fx["user_envs"].as_array().unwrap(); + let mut n = 0; + for c in fx["cases"].as_array().unwrap() { + let (m, p, h, e) = ( + i(c, "m") as usize, + i(c, "c") as usize, + i(c, "h") as usize, + i(c, "e") as usize, + ); + let mut si = si_from(&machines[m]); + si.help = helps[h].as_str().unwrap().to_string(); + si.arg_env = map_from(&arg_envs[e]); + si.user_env = if (m + p) % 3 == 1 { + map_from(&user_envs[1]) + } else { + HashMap::new() + }; + let cfg = map_from(&presets[p]); + let extra = loki_rs::models::split_args(cfg.get("EXTRA_ARGS").map_or("", String::as_str)); + let got = serve::build_serve_args(&cfg, &extra, "/app/llama-server", &si); + let ctx = format!( + "machine {m}, preset {p} {:?}, aide {h}, variables {e}", + presets[p] + ); + assert_eq!(got.args, strs(&c["args"]), "{ctx} : arguments"); + let want_env: BTreeMap = map_from(&c["env"]).into_iter().collect(); + assert_eq!(got.env, want_env, "{ctx} : environnement"); + assert_eq!(got.notes, strs(&c["notes"]), "{ctx} : notes"); + n += 1; + } + assert!(n > 1500, "{n} lancements seulement"); +} + +#[test] +fn parite_des_sondes() { + let fx = fixture(); + for (input, want) in fx["cpu_list"].as_object().unwrap() { + let got = serve::parse_cpu_list(input); + match want { + Value::String(_) => assert!(got.is_none(), "{input:?}"), + _ => assert_eq!( + got.unwrap(), + want.as_array() + .unwrap() + .iter() + .map(|x| x.as_i64().unwrap()) + .collect::>(), + "{input:?}" + ), + } + } + for c in fx["cpuset"].as_array().unwrap() { + let tmp = tempfile::tempdir().unwrap(); + for (p, d) in c["files"].as_object().unwrap() { + let path = tmp.path().join(p); + std::fs::create_dir_all(path.parent().unwrap()).unwrap(); + std::fs::write(path, d.as_str().unwrap()).unwrap(); + } + let got = serve::cpuset_threads(tmp.path()); + assert_eq!( + (got.n, got.engine, got.why), + (i(c, "n"), i(c, "engine"), st(c, "why")), + "{}", + c["name"] + ); + } + for c in fx["cgroup"].as_array().unwrap() { + let tmp = tempfile::tempdir().unwrap(); + for (p, d) in c["files"].as_object().unwrap() { + let path = tmp.path().join(p); + std::fs::create_dir_all(path.parent().unwrap()).unwrap(); + std::fs::write(path, d.as_str().unwrap()).unwrap(); + } + assert_eq!( + serve::cgroup_mem_mib(tmp.path()), + (i(c, "limit"), i(c, "current")), + "{}", + c["files"] + ); + } + for c in fx["smi"].as_array().unwrap() { + assert_eq!( + serve::vram_from_smi(&st(c, "out"), &st(c, "cvd"), b(c, "pci")), + i(c, "mib"), + "{c}" + ); + } + let devs = serve::parse_list_devices(&st(&fx["devices"], "out")); + let want: Vec<(String, i64)> = fx["devices"]["devs"] + .as_array() + .unwrap() + .iter() + .map(|d| (st(d, "ID"), i(d, "TotalMiB"))) + .collect(); + assert_eq!( + devs.iter() + .map(|d| (d.id.clone(), d.total_mib)) + .collect::>(), + want + ); + for (out, want) in fx["builds"].as_object().unwrap() { + assert_eq!( + serve::run::parse_engine_build(out), + want.as_i64().unwrap(), + "{out:?}" + ); + } + for c in fx["cmdlines"].as_array().unwrap() { + let env: BTreeMap = map_from(&c["env"]).into_iter().collect(); + assert_eq!(serve::engine_cmdline(&env, &strs(&c["args"])), st(c, "out")); + } + let cur = serve::SpecAutoMark { + fp: "abc".into(), + bin: "/app/llama-server".into(), + build: 11500, + }; + for c in fx["verdicts"].as_array().unwrap() { + let attempt = c["attempt"].as_object().map(|a| serve::SpecAutoMark { + fp: a["fp"].as_str().unwrap().into(), + bin: a["bin"].as_str().unwrap().into(), + build: a["build"].as_i64().unwrap(), + }); + let failed = map_from(&c["failed"]); + assert_eq!( + serve::spec_auto_verdict(&cur, attempt.as_ref(), &failed), + (st(c, "why"), b(c, "record")), + "{c}" + ); + } + for (h, want) in fx["help_fit"].as_object().unwrap() { + assert_eq!( + serve::help_supports_fit(h), + want.as_bool().unwrap(), + "{h:?}" + ); + } + for (m, want) in fx["gib"].as_object().unwrap() { + assert_eq!( + serve::gib_text(m.parse().unwrap()), + want.as_str().unwrap(), + "{m}" + ); + } + for c in fx["load_flags"].as_array().unwrap() { + let (out, note) = serve::normalize_load_flags(&strs(&c["args"]), b(c, "modern")); + assert_eq!((out, note), (strs(&c["out"]), st(c, "note")), "{c}"); + } + for c in fx["state_bytes"].as_array().unwrap() { + let g = gguf_from(&c["gguf"]).unwrap(); + let (pt, rec) = serve::gguf_state_bytes(&g, &st(c, "k"), &st(c, "v")); + assert_eq!(pt, c["per_token"].as_f64().unwrap(), "{c}"); + assert_eq!(rec, i(c, "recurrent"), "{c}"); + } +} + +#[test] +fn port_libre() { + let l = std::net::TcpListener::bind("127.0.0.1:0").unwrap(); + let port = l.local_addr().unwrap().port().to_string(); + let err = serve::run::wait_port_free("0.0.0.0", &port, std::time::Duration::from_millis(600)) + .unwrap_err(); + assert!(err.contains("déjà utilisé"), "{err}"); + drop(l); + serve::run::wait_port_free("[::]", &port, std::time::Duration::from_millis(600)).unwrap(); + serve::run::wait_port_free("127.0.0.1", "", std::time::Duration::ZERO).unwrap(); +} + +#[test] +fn cle_de_persistance() { + let tmp = tempfile::tempdir().unwrap(); + let model = tmp.path().join("m.gguf"); + std::fs::write(&model, b"x").unwrap(); + let si = SysInfo { + model: model.to_string_lossy().into_owned(), + ..SysInfo::default() + }; + let args: Vec = ["/b", "-m", "x", "--api-key", "secret"] + .iter() + .map(|s| s.to_string()) + .collect(); + let env = BTreeMap::new(); + let k1 = serve::slot_persist_key(&args, &env, &[], &si, "/b"); + assert_eq!(k1.len(), 16); + // La clé d'API n'entre pas dans l'empreinte. + let mut a2 = args.clone(); + a2[4] = "autre".into(); + assert_eq!(serve::slot_persist_key(&a2, &env, &[], &si, "/b"), k1); + // Une variable du moteur, si. + let pe = vec![("GGML_X".to_string(), "1".to_string())]; + assert_ne!(serve::slot_persist_key(&args, &env, &pe, &si, "/b"), k1); + assert!(serve::slot_persist_file( + "persist-0123456789abcdef-0123456789ab.bin" + )); + assert!(!serve::slot_persist_file( + "persist-0123456789abcdef-0123456789ab.bak" + )); +} + +mod common; + +/// Script exécutable, écrit par un `sh` à part (jamais tenu ouvert en +/// écriture par ce process : pas d'ETXTBSY au lancement). +#[cfg(unix)] +fn script(dir: &std::path::Path, name: &str, body: &str) -> String { + let p = dir.join(name); + let ok = std::process::Command::new("sh") + .args(["-c", "printf %s \"$1\" > \"$2\" && chmod 755 \"$2\""]) + .arg("sh") + .arg(body) + .arg(&p) + .status() + .unwrap() + .success(); + assert!(ok); + p.to_string_lossy().into_owned() +} + +/// `loki-rs engine serve` de bout en bout : configuration lue par l'API +/// interne d'un faux Go, aide d'un faux llama-server, exec de la ligne +/// composée (le faux moteur écrit ses arguments), commande gardée en base. +#[cfg(unix)] +#[tokio::test] +async fn lancement_de_bout_en_bout() { + let tmp = tempfile::tempdir().unwrap(); + let home = tmp.path().to_path_buf(); + std::fs::create_dir_all(home.join("models")).unwrap(); + std::fs::write(home.join("models/m.gguf"), b"pas un vrai gguf").unwrap(); + let out = home.join("argv.txt"); + let bin = script( + &home, + "llama-server", + "#!/bin/sh\nif [ \"$1\" = \"--help\" ]; then echo '-ngl N (default: auto) --load-mode --fit [on|off]'; exit 0; fi\nprintf '%s\\n' \"$@\" > \"$OUT\"\nprintf '%s' \"$LD_LIBRARY_PATH\" > \"$OUT.ld\"\n", + ); + let port = std::net::TcpListener::bind("127.0.0.1:0") + .unwrap() + .local_addr() + .unwrap() + .port(); + let (_facade, go, _stop) = common::fakego::start(home.clone()).await; + go.set("config", "BIN", &bin); + go.set("config", "MODEL", "m.gguf"); + go.set("config", "PORT", &port.to_string()); + go.set("config", "CTX", "8192"); + go.set("config", "EXTRA_ARGS", "--mlock"); + let backend = go.backend.lock().unwrap().unwrap(); + let exe = env!("CARGO_BIN_EXE_loki-rs"); + let st = tokio::process::Command::new(exe) + .args(["engine", "serve"]) + .env("LOKI_HOME", &home) + .env("LOKI_GO_ADDR", backend.to_string()) + .env("LOKI_INTERNAL_TOKEN", common::fakego::JETON) + .env("OUT", &out) + .env_remove("LLAMA_ARG_THREADS") + .output() + .await + .unwrap(); + assert!( + st.status.success(), + "{}", + String::from_utf8_lossy(&st.stderr) + ); + let argv = std::fs::read_to_string(&out).unwrap(); + let model = home.join("models/m.gguf").to_string_lossy().into_owned(); + let want = [ + "-m", + &model, + "-c", + "8192", + "-b", + "2048", + "-ub", + "512", + "--host", + "0.0.0.0", + "--port", + &port.to_string(), + "--parallel", + "1", + "-ngl", + "auto", + "--load-mode", + "mmap+mlock", + ]; + let got: Vec<&str> = argv.lines().collect(); + // -t éventuel (conteneur à l'étroit) : la sonde dépend de la machine. + let got: Vec<&str> = if got.get(4) == Some(&"-t") { + [&got[..4], &got[6..]].concat() + } else { + got + }; + assert_eq!(got, want, "{argv}"); + let ld = std::fs::read_to_string(home.join("argv.txt.ld")).unwrap(); + assert!(ld.starts_with(home.to_str().unwrap()), "{ld}"); + let cmdline = go.get("state", "engine_cmdline").unwrap(); + assert!(cmdline.starts_with(&bin), "{cmdline}"); + assert!(cmdline.contains("--load-mode mmap+mlock"), "{cmdline}"); + + // Port pris : refus en clair, code 1. + let l = std::net::TcpListener::bind(("127.0.0.1", port)).unwrap(); + let st = tokio::process::Command::new(exe) + .args(["engine", "serve"]) + .env("LOKI_HOME", &home) + .env("LOKI_GO_ADDR", backend.to_string()) + .env("LOKI_INTERNAL_TOKEN", common::fakego::JETON) + .output() + .await + .unwrap(); + drop(l); + assert_eq!(st.status.code(), Some(1)); + assert!(String::from_utf8_lossy(&st.stderr).contains("déjà utilisé")); + + // Modèle absent. + go.set("config", "MODEL", "absent.gguf"); + let st = tokio::process::Command::new(exe) + .args(["engine", "serve"]) + .env("LOKI_HOME", &home) + .env("LOKI_GO_ADDR", backend.to_string()) + .env("LOKI_INTERNAL_TOKEN", common::fakego::JETON) + .output() + .await + .unwrap(); + assert_eq!(st.status.code(), Some(1)); + assert!( + String::from_utf8_lossy(&st.stderr).contains("modèle introuvable"), + "{}", + String::from_utf8_lossy(&st.stderr) + ); + + // Preset externe : rien à lancer, sortie propre. + go.set("config", "EXTERNAL", "1"); + let st = tokio::process::Command::new(exe) + .args(["engine", "serve"]) + .env("LOKI_HOME", &home) + .env("LOKI_GO_ADDR", backend.to_string()) + .env("LOKI_INTERNAL_TOKEN", common::fakego::JETON) + .output() + .await + .unwrap(); + assert!(st.status.success()); + assert!(String::from_utf8_lossy(&st.stdout).contains("preset externe")); +} + +/// Strata, essai de l'optimiseur, pas d'API interne : `loki serve` (Go) est +/// lancé à notre place. +#[cfg(unix)] +#[tokio::test] +async fn delegation_au_go() { + let tmp = tempfile::tempdir().unwrap(); + let home = tmp.path().to_path_buf(); + let mark = home.join("go.txt"); + let go_bin = script(&home, "loki", "#!/bin/sh\necho \"go $*\" >> \"$MARK\"\n"); + let exe = env!("CARGO_BIN_EXE_loki-rs"); + // Sans API interne. + let st = tokio::process::Command::new(exe) + .args(["engine", "serve"]) + .env("LOKI_HOME", &home) + .env("LOKI_GO_BIN", &go_bin) + .env("MARK", &mark) + .env_remove("LOKI_GO_ADDR") + .output() + .await + .unwrap(); + assert!( + st.status.success(), + "{}", + String::from_utf8_lossy(&st.stderr) + ); + // Essai de l'optimiseur. + let st = tokio::process::Command::new(exe) + .args(["engine", "serve"]) + .env("LOKI_HOME", &home) + .env("LOKI_GO_BIN", &go_bin) + .env("MARK", &mark) + .env("LOKI_TUNE_TRIAL", "/tmp/essai") + .output() + .await + .unwrap(); + assert!(st.status.success()); + // Preset Strata. + let (_facade, go, _stop) = common::fakego::start(home.clone()).await; + go.set("config", "ENGINE", "strata"); + let backend = go.backend.lock().unwrap().unwrap(); + let st = tokio::process::Command::new(exe) + .args(["engine", "serve"]) + .env("LOKI_HOME", &home) + .env("LOKI_GO_BIN", &go_bin) + .env("MARK", &mark) + .env("LOKI_GO_ADDR", backend.to_string()) + .env("LOKI_INTERNAL_TOKEN", common::fakego::JETON) + .output() + .await + .unwrap(); + assert!(st.status.success()); + assert_eq!( + std::fs::read_to_string(&mark).unwrap(), + "go serve\ngo serve\ngo serve\n" + ); +} From a50b8f3cc6b44549cce4abd2462427e0c1b7abbd Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 15 Oct 2026 18:13:13 +0000 Subject: [PATCH 12/52] =?UTF-8?q?Lot=2015=20:=20presets=20tenus=20par=20la?= =?UTF-8?q?=20fa=C3=A7ade=20(LOKI=5FPRESETS=3Drust)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit La façade sert la liste des presets (pastilles, mesures du bench quand elles correspondent à la configuration), leur ordre, l'éditeur (lecture, graine d'un preset neuf, enregistrement avec nom de fichier unique, prompt système), la suppression (refusée pour l'actif, modèle compris sur demande), les aperçus de l'éditeur (cache de prompts, second slot, avis MoE et de chargement, réutilisant le port de loki serve), la copie en placement auto, les presets externes (la clé n'est jamais rendue, test par un vrai appel de complétion) et la bascule, quand LOKI_PRESETS=rust (expérimental ; le Go reste le défaut). Ce qui touche au moteur reste au Go jusqu'à son lot : facade_presets.go expose /internal/v1/preset/apply (bascule avec état du slot gardé et moteur relancé ou arrêté, preset en service réappliqué) et /internal/v1/tune/guard (verrou de l'optimiseur), et remet les routes du tunnel à la façade. Parité : parity_presets_test.go tire du Go les fonctions pures et 70 appels de route avec presets/, models/ et la base après chacun, rejoués par tests/presets_api.rs ; bascule et application vérifiées de bout en bout contre le faux Go, presetApply côté Go. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_016tcGfJWefW5kUGoC2wL8cv --- README.md | 1 + ...-15-rust-migration-lot15-presets-design.md | 86 + internal/loki/facade_chat.go | 2 + internal/loki/facade_chat_test.go | 25 + internal/loki/facade_presets.go | 136 + internal/loki/parity_presets_test.go | 425 ++++ internal/loki/web_internal.go | 4 + rust/loki-rs/src/api.rs | 3 + rust/loki-rs/src/lib.rs | 1 + rust/loki-rs/src/main.rs | 5 + rust/loki-rs/src/models.rs | 8 +- rust/loki-rs/src/presets_api.rs | 1458 +++++++++++ rust/loki-rs/src/proxy.rs | 6 + rust/loki-rs/src/serve/run.rs | 2 +- rust/loki-rs/tests/common/fakego.rs | 24 + rust/loki-rs/tests/lot2.rs | 1 + .../loki-rs/tests/parity_presets/presets.json | 2206 +++++++++++++++++ rust/loki-rs/tests/presets_api.rs | 414 ++++ rust/loki-rs/tests/proxy.rs | 1 + 19 files changed, 4805 insertions(+), 3 deletions(-) create mode 100644 docs/superpowers/specs/2026-10-15-rust-migration-lot15-presets-design.md create mode 100644 internal/loki/facade_presets.go create mode 100644 internal/loki/parity_presets_test.go create mode 100644 rust/loki-rs/src/presets_api.rs create mode 100644 rust/loki-rs/tests/parity_presets/presets.json create mode 100644 rust/loki-rs/tests/presets_api.rs diff --git a/README.md b/README.md index 03c5ae6..adc92da 100644 --- a/README.md +++ b/README.md @@ -235,6 +235,7 @@ redémarrages (volume `/data`). Variables d'environnement du conteneur : | `LOKI_PUSH` | `rust` : les notifications Web Push (clé VAPID, abonnements, envoi « réponse prête ») sont tenues par la façade Rust (expérimental, migration en cours) | Go | | `LOKI_MEM` | `rust` : la mémoire (éditeur des pages, index, déplacement entre projets, trackers, mode mémoire, interrupteurs du mode agent) est tenue par la façade Rust ; le chiffrement reste au Go (expérimental, migration en cours) | Go | | `LOKI_SERVE` | `rust` : la ligne de commande de llama-server est composée par la façade Rust (`loki-rs engine serve`) au lieu de `loki serve` ; Strata et l'optimiseur restent au Go (expérimental, migration en cours) | Go | +| `LOKI_PRESETS` | `rust` : les presets (liste, éditeur et aperçus, suppression, placement auto, presets externes, bascule) sont tenus par la façade Rust ; l'application au moteur reste au Go (expérimental, migration en cours) | Go | **Accès par nom de domaine.** Sans clé de pilotage, l'API n'accepte que les hôtes locaux (IP, `localhost`, nom sans point, `.local`/`.lan`…) et refuse diff --git a/docs/superpowers/specs/2026-10-15-rust-migration-lot15-presets-design.md b/docs/superpowers/specs/2026-10-15-rust-migration-lot15-presets-design.md new file mode 100644 index 0000000..3364fbb --- /dev/null +++ b/docs/superpowers/specs/2026-10-15-rust-migration-lot15-presets-design.md @@ -0,0 +1,86 @@ +# Lot 15 — presets tenus par la façade + +## Contexte + +Les presets vivaient dans le Go : +- `backend_presets.go` pour l'écriture ; +- les handlers de `web_api.go` et `web_external.go` ; +- les aperçus de l'éditeur (`cacheRAMPreview`, `sideSlotPreview`, + `moePreview`, `autoPlacementPreset`). + +Lot 15 : la façade tient ces routes quand `LOKI_PRESETS=rust` +(`presets_api.rs`, expérimental). Sans la variable, rien ne change. + +| Route | Rôle | +|---|---| +| `GET /api/presets` | liste, avec pastilles (contexte, vision, quantification, raisonnement, Strata, externe) et mesure du bench si elle correspond à la configuration actuelle | +| `POST /api/presets/order` | ordre choisi par glisser-déposer | +| `GET /api/preset?id=` | contenu et prompt système ; sans id, la graine d'un preset neuf (BIN personnalisé, HOST, PORT) | +| `POST /api/preset/save` | crée (nom de fichier unique) ou met à jour ; le preset EN SERVICE est appliqué tout de suite | +| `POST /api/preset/delete` | refusé pour le preset actif ; emporte prompt système et mesure, et le modèle sur demande | +| `POST /api/preset/cacheram`, `/api/preset/moe` | aperçus de l'éditeur, calculés comme au lancement (lot 14) | +| `POST /api/preset/autoplace` | copie en placement automatique d'un preset MoE placé à la main | +| `GET /api/preset/external?id=`, `POST /api/preset/external/save`, `/api/preset/external/test` | presets externes ; la clé n'est jamais rendue, le test fait un vrai appel de complétion | +| `POST /api/switch` | bascule de preset | + +Les presets restent des fichiers `presets/*.env`, et leur prompt système une +clé de la base. Les aperçus réutilisent le port de `loki serve` (lot 14), +sondes comprises. + +## Restent au Go + +Tout ce qui touche au moteur reste au Go, jusqu'au lot qui le portera. Le Go +expose deux routes internes pour cela (`facade_presets.go`). + +`POST /internal/v1/preset/apply {path, mode}` applique un preset à la +configuration vivante : +- `switch` : l'état du slot est relevé avant (SLOT_PERSIST), puis le moteur + est relancé, ou arrêté pour un preset externe ; +- `save` : application, puis relance ; +- `apply` : la configuration seule (preset externe en service modifié). + +`GET /internal/v1/tune/guard` dit si une optimisation tient le moteur. La +réponse est alors un 409 `{ok: false, tuning: true}`, comme le Go. + +## Écarts assumés + +- La suppression d'un preset retire sa mesure sans réécrire les autres avec + leurs champs à zéro, comme le faisait la struct du Go. Le Go les relit + pareil. +- Le message d'un échec de réseau du test externe est celui de reqwest. + +## Parité + +`internal/loki/parity_presets_test.go` tire du Go deux séries. + +Les fonctions pures : +- nom d'affichage, guillemets, format `.env`, noms refusés ; +- quantification, raisonnement, modèle ; +- URL de complétions, contenu d'un preset externe, message d'erreur d'API ; +- arguments rejoints, clé réécrite dans un preset ; +- huit copies en placement auto ; +- empreinte de mesure, moteur imposé par un preset. + +Puis 70 appels de route, avec après chacun les dossiers `presets/` et +`models/`, et les clés touchées en base. Ils couvrent : +- créations, doublons, noms refusés, erreurs JSON ; +- ordre, pastilles et mesures (empreinte, repli sur le modèle, autre + configuration) ; +- preset actif et suppressions (modèle compris, partagé, absent) ; +- presets externes et leur test contre un faux service (succès, 401 au + format OpenAI, 404 brut, 500 vide) ; +- placement auto et aperçus. + +`tests/presets_api.rs` rejoue le tout par la façade, et une mutation fait +échouer le rejeu. + +La bascule et l'enregistrement du preset en service sont vérifiés de bout en +bout contre le faux Go : +- appels d'application, avec leur mode ; +- refus quand une optimisation tourne ; +- erreur d'application rendue ; +- prompt système. + +Côté Go, `TestFacadePresetApply` vérifie `presetApply` : configuration +appliquée, appareils préservés, moteur relancé, chemin hors du dossier +refusé. diff --git a/internal/loki/facade_chat.go b/internal/loki/facade_chat.go index 28de305..c300872 100644 --- a/internal/loki/facade_chat.go +++ b/internal/loki/facade_chat.go @@ -162,6 +162,8 @@ func withFacadeChat(path string, h http.HandlerFunc) http.HandlerFunc { held, unavailable = facadePush, "notifications indisponibles : " case facadeMemRoutes[path]: held, unavailable = facadeMem, "mémoire indisponible : " + case facadePresetsRoutes[path]: + held, unavailable = facadePresets, "presets indisponibles : " default: return h } diff --git a/internal/loki/facade_chat_test.go b/internal/loki/facade_chat_test.go index 18289fb..daddc16 100644 --- a/internal/loki/facade_chat_test.go +++ b/internal/loki/facade_chat_test.go @@ -545,3 +545,28 @@ func TestFacadeMemoire(t *testing.T) { } } } + +func TestFacadePresets(t *testing.T) { + testHome(t) + f := demarrerFacade(t) + t.Setenv(facadeChatEnv, "") + local := func(w http.ResponseWriter, r *http.Request) { io.WriteString(w, "go") } + for p := range facadePresetsRoutes { + w := httptest.NewRecorder() + withFacadeChat(p, local)(w, httptest.NewRequest("GET", p, nil)) + if w.Body.String() != "go" { + t.Fatalf("%s servi ailleurs sans LOKI_PRESETS : %q", p, w.Body.String()) + } + } + t.Setenv(facadePresetsEnv, "rust") + for p := range facadePresetsRoutes { + w := httptest.NewRecorder() + withFacadeChat(p, local)(w, httptest.NewRequest("POST", p, strings.NewReader("{}"))) + if w.Body.String() != f.reply { + t.Fatalf("%s : réponse %q", p, w.Body.String()) + } + } + if n := len(f.recu()); n != len(facadePresetsRoutes) { + t.Fatalf("reçu par la façade : %d appels", n) + } +} diff --git a/internal/loki/facade_presets.go b/internal/loki/facade_presets.go new file mode 100644 index 0000000..19cd213 --- /dev/null +++ b/internal/loki/facade_presets.go @@ -0,0 +1,136 @@ +package loki + +import ( + "fmt" + "net/http" + "os" + "path/filepath" + "strings" +) + +// facade_presets.go — presets tenus par la façade Rust (LOKI_PRESETS=rust, +// lot 15 de la migration ; expérimental, le Go reste le défaut) : liste, +// ordre, lecture, éditeur, suppression, aperçus de l'éditeur, copie en +// placement auto, presets externes, bascule. +// +// Les presets restent des fichiers presets/*.env et leur prompt système une +// clé de la base. Ce qui touche au moteur reste ici jusqu'au lot qui le +// portera : verrou de l'optimiseur (/internal/v1/tune/guard) et application +// d'un preset à la configuration vivante, avec l'état du slot gardé et le +// moteur relancé ou arrêté (/internal/v1/preset/apply). + +const facadePresetsEnv = "LOKI_PRESETS" + +// facadePresets : la façade tient-elle les presets ? +func facadePresets() bool { + return strings.EqualFold(strings.TrimSpace(os.Getenv(facadePresetsEnv)), "rust") && + facadeAddr() != "" && internalToken() != "" +} + +// facadePresetsRoutes : les routes que la façade sert alors (presets_api.rs). +var facadePresetsRoutes = map[string]bool{ + "/api/presets": true, + "/api/presets/order": true, + "/api/preset": true, + "/api/preset/save": true, + "/api/preset/cacheram": true, + "/api/preset/moe": true, + "/api/preset/autoplace": true, + "/api/preset/delete": true, + "/api/preset/external": true, + "/api/preset/external/save": true, + "/api/preset/external/test": true, + "/api/switch": true, +} + +// presetServiceAction : le pilotage du moteur après une application de +// preset. Variable pour les tests (aucun moteur n'y tourne). +var presetServiceAction = serviceAction + +// presetApply applique un preset (chemin dans presets/) à la configuration +// vivante, comme les handlers d'origine : +// +// switch : bascule (handleSwitch) — état du slot relevé avant, moteur arrêté +// pour un preset externe, relancé sinon, en arrière-plan ; +// save : preset en service modifié (handlePresetSave) — relancé ; +// apply : preset externe en service modifié (handlePresetExternalSave) — +// configuration seule. +func presetApply(path, mode string) error { + root, err := filepath.Abs(presetsDir()) + if err != nil { + return err + } + abs, err := filepath.Abs(path) + if err != nil || !strings.HasPrefix(abs, root+string(filepath.Separator)) || !strings.HasSuffix(abs, ".env") { + return fmt.Errorf("preset hors du dossier des presets") + } + switch mode { + case "switch": + persist := slotPersistPrepare() + if err := applyPresetFile(abs); err != nil { + return err + } + fmt.Printf("%s config.env <- %s\n", green("[ok]"), filepath.Base(abs)) + if isExternalConfig(ReadConfig()) { + fmt.Println(dim("[info] preset externe — arrêt du moteur local")) + go func() { + if serviceIsActive() { + persist.save() + if err := presetServiceAction("stop"); err != nil { + fmt.Printf("%s arrêt du moteur après bascule externe : %v\n", red("[ERREUR]"), err) + } + } + }() + return nil + } + fmt.Println(dim("[info] redémarrage du service...")) + go func() { + persist.save() + if err := presetServiceAction("restart"); err != nil { + fmt.Printf("%s redémarrage après bascule: %v\n", red("[ERREUR]"), err) + } + }() + return nil + case "save": + if err := applyPresetFile(abs); err != nil { + return err + } + fmt.Printf("%s config.env <- %s (preset actif modifié)\n", green("[ok]"), filepath.Base(abs)) + fmt.Println(dim("[info] redémarrage du service...")) + go func() { + if err := presetServiceAction("restart"); err != nil { + fmt.Printf("%s redémarrage après modification du preset: %v\n", red("[ERREUR]"), err) + } + }() + return nil + case "apply": + return applyPresetFile(abs) + } + return fmt.Errorf("mode inconnu : %q", mode) +} + +// handleInternalPresetApply : POST {path, mode} → 204, ou 400/500 + {error}. +func handleInternalPresetApply(w http.ResponseWriter, r *http.Request) { + var in struct { + Path string `json:"path"` + Mode string `json:"mode"` + } + if !readInternalJSON(w, r, &in) { + return + } + if err := presetApply(in.Path, in.Mode); err != nil { + sendJSON(w, http.StatusInternalServerError, map[string]any{"error": err.Error()}) + return + } + w.WriteHeader(http.StatusNoContent) +} + +// handleInternalTuneGuard : GET → {busy, error} — une optimisation tient-elle +// le moteur (tuneGuard) ? +func handleInternalTuneGuard(w http.ResponseWriter, r *http.Request) { + if err := tuneGuard(); err != nil { + sendJSON(w, http.StatusOK, map[string]any{"busy": true, "error": err.Error()}) + return + } + sendJSON(w, http.StatusOK, map[string]any{"busy": false}) +} diff --git a/internal/loki/parity_presets_test.go b/internal/loki/parity_presets_test.go new file mode 100644 index 0000000..5d9ffb0 --- /dev/null +++ b/internal/loki/parity_presets_test.go @@ -0,0 +1,425 @@ +package loki + +import ( + "encoding/json" + "io" + "net/http" + "net/http/httptest" + "os" + "path/filepath" + "sort" + "strings" + "testing" +) + +// parity_presets_test.go — journal de référence des presets (lot 15 de la +// migration Rust) : fonctions pures (nom d'affichage, valeurs, environnement, +// quantification, raisonnement, URL des presets externes, copie en placement +// auto…) puis une suite d'appels aux routes (liste et pastilles, ordre, +// éditeur, suppression avec le modèle, presets externes et leur test, aperçus +// de l'éditeur), avec après chacun le dossier presets/ et les clés touchées en +// base. Rangé dans rust/loki-rs/tests/parity_presets/presets.json ; +// tests/presets_api.rs le rejoue par la façade. +// +// La bascule et l'enregistrement du preset EN SERVICE pilotent le moteur : +// ils sont vérifiés à part (TestFacadePresetApply côté Go, essais de bout en +// bout côté Rust). +// +// LOKI_PARITY_UPDATE=1 go test ./internal/loki -run TestParitePresets + +const parityPresetsFile = "../../rust/loki-rs/tests/parity_presets/presets.json" + +type parityPresetStep struct { + Files map[string]string `json:"files,omitempty"` // relatif à LOKI_HOME ; "" = suppression + Store map[string]string `json:"store,omitempty"` // « bucket/clé » ; "" = suppression + Method string `json:"method"` + Path string `json:"path"` + Body string `json:"body,omitempty"` + Status int `json:"status"` + Out any `json:"out"` + Disk map[string]string `json:"disk"` + State map[string]any `json:"state"` +} + +func TestParitePresets(t *testing.T) { + home := testHome(t) + t.Setenv("LOKI_ENGINE_BIN", "") + t.Setenv("LOKI_MODEL_DIRS", "") + for _, k := range append(append(append(append([]string{}, serveArgEnv...), fidelityArgEnv...), cacheArgEnv...), sideSlotArgEnv...) { + t.Setenv(k, "") + os.Unsetenv(k) + } + + var fx struct { + DisplayName []map[string]string `json:"display_name"` + Quote map[string]string `json:"quote"` + FormatEnv string `json:"format_env"` + Names map[string]string `json:"names"` + Quant map[string]string `json:"quant"` + Reasoning map[string]string `json:"reasoning"` + Model map[string]string `json:"model"` + Completions map[string]string `json:"completions"` + External []map[string]any `json:"external"` + APIError map[string]string `json:"api_error"` + Join []map[string]any `json:"join"` + SetKey []map[string]string `json:"set_key"` + Autoplace []map[string]any `json:"autoplace"` + BenchFP []map[string]any `json:"bench_fp"` + Imposes map[string]bool `json:"imposes"` + Seed map[string]string `json:"seed"` + Steps []parityPresetStep `json:"steps"` + } + for _, c := range [][2]string{ + {"MODEL=a.gguf\n", "Alpha"}, {"# NAME=Vieux\nMODEL=a\n", "Neuf"}, {" name = x\nCTX=1\n", "Y"}, + {"#NAME=a\n# name=b\n", "Deux"}, {"MODEL=a\n# NAME=x", "Prix $1 et ${x}"}, {"", "Vide"}, + } { + fx.DisplayName = append(fx.DisplayName, map[string]string{"content": c[0], "name": c[1], "out": withDisplayName(c[0], c[1])}) + } + fx.Quote = map[string]string{} + for _, v := range []string{"", "simple", "a b", "a\tb", `a"b`, "'x'", `"y"`, "'z"} { + fx.Quote[v] = quoteValue(v) + } + fx.FormatEnv = formatEnv(map[string]string{"Z": "1", "A": "deux mots", "M": "", "Q": `q"q`}) + fx.Names = map[string]string{} + for _, n := range []string{"", " ", ".", "..", "a/b", `a\b`, "a\x00b", "a\x01b", "Qwen 3 (27B) é", " pad "} { + if _, err := safePresetPath(n); err != nil { + fx.Names[n] = err.Error() + } else { + fx.Names[n] = "ok" + } + } + fx.Quant, fx.Reasoning, fx.Model = map[string]string{}, map[string]string{}, map[string]string{} + for _, c := range []string{"MODEL=Qwen3-27B-Q4_K_M.gguf\n", "# QUANT=iq3_xs\nMODEL=x-Q8_0.gguf\n", "QUANT='q5'\n", + "MODEL=\"/m/Llama-3-UD-Q6_K_XL-00001-of-00002.gguf\"\nREASONING=On\n", "reasoning = 'off'\n#REASONING=on\n", + "export MODEL=z.gguf\n", "MODEL = C:\\models\\w-BF16.gguf\n", ""} { + fx.Quant[c], fx.Reasoning[c], fx.Model[c] = detectQuant(c), presetReasoning(c), modelFromPresetContent(c) + } + fx.Completions = map[string]string{} + for _, u := range []string{"", " https://api.groq.com/openai/v1/ ", "http://x/chat/completions", "http://loki:8090", "https://o.ai/api/v1"} { + fx.Completions[u] = completionsURL(u) + } + for _, c := range []struct { + url, model, key, ctx string + vision bool + }{{"https://a/v1", "gpt", "", "", false}, {" u ", " m ", " k ", " 128000 ", true}} { + fx.External = append(fx.External, map[string]any{"url": c.url, "model": c.model, "key": c.key, "ctx": c.ctx, + "vision": c.vision, "out": externalPresetContent(c.url, c.model, c.key, c.ctx, c.vision)}) + } + fx.APIError = map[string]string{} + for _, b := range []string{`{"error":{"message":" clé invalide "}}`, `{"error":"x"}`, `pas du json`, `{}`} { + fx.APIError[b] = extractAPIError([]byte(b)) + } + for _, a := range [][]string{{"-ngl", "30"}, {"--chat-template-file", "/a b/t.jinja"}, {"x", "l'apostrophe ici"}, {""}} { + fx.Join = append(fx.Join, map[string]any{"args": a, "out": joinArgs(a)}) + } + for _, c := range [][3]string{ + {"A=1\nB=2\n", "B", "3"}, {"A=1\nexport B=2\nB=4\n", "B", ""}, {"A=1", "C", "deux mots"}, {"", "X", "1"}, + {"NGL=999\n# NGL=1\n", "NGL", ""}, {"K.E=1\nKXE=2\n", "K.E", "v"}, + } { + fx.SetKey = append(fx.SetKey, map[string]string{"content": c[0], "key": c[1], "val": c[2], "out": presetSetKey(c[0], c[1], c[2])}) + } + for _, c := range []string{ + "# NAME=MoE\nMODEL=m.gguf\nCTX=65536\nNGL=999\nEXTRA_ARGS=-ot 'exps=CPU,per_layer_token_embd=CPU,blk\\.0=CUDA0' --n-cpu-moe 0 -ub 512 --mlock --fit off -ctk q8_0\n", + "MODEL=m.gguf\nEXTRA_ARGS=--n-cpu-moe 30 -ts 1,1\n", "MODEL=m.gguf\nEXTRA_ARGS=--cpu-moe -sm row\n", + "MODEL=m.gguf\nEXTRA_ARGS=--cpu-moe\nCTX=0\n", "MODEL=m.gguf\nEXTRA_ARGS=--cpu-moe\nSPLIT_MODE=tensor\n", + "MODEL=m.gguf\nEXTRA_ARGS=-ngl 40\n", "EXTERNAL=1\n", "MODEL=m\nEXTRA_ARGS=--cpu-moe --fit=on -b=1024\nKV_TYPE=q4_0\nUBATCH=1024\n", + } { + out, changes, err := autoPlacementPreset(c) + e := "" + if err != nil { + e = err.Error() + } + fx.Autoplace = append(fx.Autoplace, map[string]any{"in": c, "out": out, "changes": changes, "err": e}) + } + for _, c := range []struct{ cfg, cur map[string]string }{ + {map[string]string{"MODEL": "a.gguf"}, map[string]string{}}, + {map[string]string{"MODEL": "a.gguf", "CUDA_VISIBLE_DEVICES": "1"}, map[string]string{"CUDA_VISIBLE_DEVICES": "0"}}, + {map[string]string{"MODEL": "a.gguf"}, map[string]string{"CUDA_VISIBLE_DEVICES": " 0,1 "}}, + } { + fx.BenchFP = append(fx.BenchFP, map[string]any{"cfg": c.cfg, "cur": c.cur, "out": benchFingerprint(c.cfg, c.cur)}) + } + fx.Imposes = map[string]bool{} + for _, p := range []string{"", "/app/llama-server", filepath.Join(home, "engine", "b1", "llama-server"), + filepath.Join(home, "backends", "llama.cpp-prebuilt", "llama-b2", "llama-server"), "/opt/custom/llama-server"} { + fx.Imposes[strings.ReplaceAll(p, home, "{{HOME}}")] = presetImposesEngine(p) + } + + fx.Seed = map[string]string{ + "config/BIN": "/opt/custom/llama-server", + "config/HOST": "127.0.0.1", + "config/PORT": "8081", + "config/MODEL": "live.gguf", + } + apply := func(store map[string]string) { + keys := make([]string, 0, len(store)) + for k := range store { + keys = append(keys, k) + } + sort.Strings(keys) + for _, k := range keys { + b, key, _ := strings.Cut(k, "/") + if err := putStr(b, key, store[k]); err != nil { + t.Fatal(err) + } + } + } + apply(fx.Seed) + + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + b, _ := io.ReadAll(r.Body) + switch { + case strings.HasPrefix(r.URL.Path, "/ok/"): + if !strings.Contains(string(b), `"max_tokens":1`) || r.Header.Get("Authorization") != "Bearer k1" { + w.WriteHeader(400) + return + } + io.WriteString(w, "{}") + case strings.HasPrefix(r.URL.Path, "/refus/"): + w.WriteHeader(401) + io.WriteString(w, `{"error":{"message":"clé invalide"}}`) + case strings.HasPrefix(r.URL.Path, "/brut/"): + w.WriteHeader(404) + io.WriteString(w, " page absente ") + default: + w.WriteHeader(500) + } + })) + defer srv.Close() + + disk := func() map[string]string { + out := map[string]string{} + for _, d := range []string{"presets", "models"} { + entries, _ := os.ReadDir(filepath.Join(home, d)) + for _, e := range entries { + b, _ := os.ReadFile(filepath.Join(home, d, e.Name())) + out[d+"/"+e.Name()] = string(b) + } + } + return out + } + state := func() map[string]any { + st := map[string]any{} + for k, v := range allKV(bkState) { + switch { + case strings.HasPrefix(k, sysPromptKeyPrefix): + st[k] = v + case k == presetOrderKey || k == "bench_presets": + var x any + _ = json.Unmarshal([]byte(v), &x) + st[k] = x + } + } + return st + } + run := func(st parityPresetStep) parityPresetStep { + for rel, content := range st.Files { + p := filepath.Join(home, filepath.FromSlash(rel)) + if content == "" { + _ = os.Remove(p) + continue + } + _ = os.MkdirAll(filepath.Dir(p), 0o755) + if err := os.WriteFile(p, []byte(content), 0o644); err != nil { + t.Fatal(err) + } + } + apply(st.Store) + body := strings.ReplaceAll(st.Body, "{{URL}}", srv.URL) + w := httptest.NewRecorder() + r := httptest.NewRequest(st.Method, st.Path, strings.NewReader(body)) + switch r.URL.Path { + case "/api/presets": + handlePresets(w, r) + case "/api/presets/order": + handlePresetsOrder(w, r) + case "/api/preset": + handlePreset(w, r) + case "/api/preset/save": + handlePresetSave(w, r) + case "/api/preset/cacheram": + handlePresetCacheRAM(w, r) + case "/api/preset/moe": + handlePresetMoE(w, r) + case "/api/preset/autoplace": + handlePresetAutoPlace(w, r) + case "/api/preset/delete": + handlePresetDelete(w, r) + case "/api/preset/external": + handlePresetExternal(w, r) + case "/api/preset/external/save": + handlePresetExternalSave(w, r) + case "/api/preset/external/test": + handlePresetExternalTest(w, r) + default: + t.Fatalf("route inattendue %s", st.Path) + } + st.Status = w.Code + if err := json.Unmarshal(w.Body.Bytes(), &st.Out); err != nil { + t.Fatalf("%s %s : %v (%s)", st.Method, st.Path, err, w.Body.String()) + } + st.Disk = disk() + st.State = state() + return st + } + + moe := "# NAME=MoE\nMODEL=absent.gguf\nCTX=32768\nEXTRA_ARGS=--n-cpu-moe 20 --load-mode none\n" + for _, st := range []parityPresetStep{ + {Method: "GET", Path: "/api/presets"}, + {Method: "GET", Path: "/api/preset"}, + {Method: "POST", Path: "/api/preset/save", Body: `{"name":"Qwen 27B","content":"MODEL=Qwen3-27B-Q4_K_M.gguf\nCTX=65536\nREASONING=on\n"}`}, + {Method: "POST", Path: "/api/preset/save", Body: `{"name":"Qwen 27B","content":"MODEL=Qwen3-27B-Q8_0.gguf\nMMPROJ=mm.gguf\n","sysprompt":" Réponds en français. "}`}, + {Method: "POST", Path: "/api/preset/save", Body: `{"name":"a/b\\c","content":"MODEL=x.gguf\nCTX=40000\n"}`}, + {Method: "POST", Path: "/api/preset/save", Body: `{"name":" ","content":"X=1"}`}, + {Method: "POST", Path: "/api/preset/save", Body: `{"id":"../evil","name":"E","content":"X=1"}`}, + {Method: "POST", Path: "/api/preset/save", Body: `{"id":"Qwen 27B (2)","name":"Qwen Q8","content":"# NAME=ancien\nMODEL=Qwen3-27B-Q8_0.gguf\nQUANT=q8\n","sysprompt":""}`}, + {Method: "POST", Path: "/api/preset/save", Body: `pas du json`}, + {Method: "POST", Path: "/api/preset/save", Body: `{"deleteModel":"oui"}`}, + {Method: "POST", Path: "/api/preset/save", Body: `{"name":"Strata","content":"ENGINE=strata\nSTRATA_VISION=0\nCTX=1048576\n"}`}, + {Method: "GET", Path: "/api/presets"}, + {Method: "GET", Path: "/api/preset?id=Qwen%2027B"}, + {Method: "GET", Path: "/api/preset?id=Qwen%2027B%20(2)"}, + {Method: "GET", Path: "/api/preset?id=absent"}, + {Method: "GET", Path: "/api/preset?id=../x"}, + {Method: "POST", Path: "/api/presets/order", Body: `{"ids":["Strata","Qwen 27B (2)"]}`}, + {Method: "GET", Path: "/api/presets"}, + {Method: "POST", Path: "/api/presets/order", Body: `{}`}, + {Method: "GET", Path: "/api/presets/order"}, + {Method: "POST", Path: "/api/presets/order", Body: `{"ids":"x"}`}, + {Method: "POST", Path: "/api/presets/order", Body: `{"ids":["a-b-c"]}`}, + // Mesures : empreinte, repli sur le nom du modèle, autre configuration. + {Store: map[string]string{"state/bench_presets": `{"Qwen 27B":{"result":{"prompt_per_second":1234.5,"predicted_per_second":40,"kv":"q8_0","kv_quantized":true,"depth":{"cold":{"new":30000},"decode_per_second":31.25}},"model":"Qwen3-27B-Q4_K_M.gguf","at":1700000000},` + + `"Qwen 27B (2)":{"result":{"prompt_per_second":900,"predicted_per_second":20.5},"model":"autre.gguf","at":1700000001},` + + `"a-b-c":{"result":{"prompt_per_second":1,"predicted_per_second":2},"model":"x.gguf","at":1,"fingerprint":"mauvaise"}}`}, + Method: "GET", Path: "/api/presets"}, + // Preset actif : la configuration est celle de « a-b-c ». + {Store: map[string]string{"config/BIN": "", "config/HOST": "", "config/PORT": "", "config/MODEL": "x.gguf", "config/CTX": "40000", "config/MEM_MODE": "always"}, + Method: "GET", Path: "/api/presets"}, + {Method: "POST", Path: "/api/preset/delete", Body: `{"id":"a-b-c"}`}, + {Store: map[string]string{"config/MODEL": "autre.gguf"}, Method: "POST", Path: "/api/preset/delete", Body: `{"id":"a-b-c"}`}, + {Method: "POST", Path: "/api/preset/delete", Body: `{"id":"absent"}`}, + {Method: "POST", Path: "/api/preset/delete", Body: `{"id":"a/b"}`}, + {Files: map[string]string{"models/Qwen3-27B-Q4_K_M.gguf": "poids"}, Method: "POST", Path: "/api/preset/delete", Body: `{"id":"Qwen 27B","deleteModel":true}`}, + {Files: map[string]string{"models/Qwen3-27B-Q8_0.gguf": "poids8", "presets/Copie.env": "# NAME=Copie\nMODEL=Qwen3-27B-Q8_0.gguf\n"}, + Method: "POST", Path: "/api/preset/delete", Body: `{"id":"Qwen 27B (2)","deleteModel":true}`}, + {Method: "POST", Path: "/api/preset/delete", Body: `{"id":"Copie","deleteModel":true}`}, + {Method: "POST", Path: "/api/preset/delete", Body: `{"id":3}`}, + {Method: "GET", Path: "/api/presets"}, + // Presets externes. + {Method: "GET", Path: "/api/preset/external"}, + {Method: "POST", Path: "/api/preset/external/save", Body: `{"name":" Groq ","url":" https://api.groq.com/openai/v1 ","model":" llama ","key":"k1","ctx":"131072","vision":true}`}, + {Method: "POST", Path: "/api/preset/external/save", Body: `{"name":"X","url":"","model":"m"}`}, + {Method: "POST", Path: "/api/preset/external/save", Body: `{"name":"X","url":"u","model":" "}`}, + {Method: "POST", Path: "/api/preset/external/save", Body: `{"name":"","url":"u","model":"m"}`}, + {Method: "GET", Path: "/api/preset/external?id=Groq"}, + {Method: "POST", Path: "/api/preset/external/save", Body: `{"id":"Groq","name":"Groq 2","url":"https://api.groq.com","model":"llama-3"}`}, + {Method: "GET", Path: "/api/preset/external?id=Groq"}, + {Method: "POST", Path: "/api/preset/external/save", Body: `{"id":"Groq","name":"Groq","url":"https://api.groq.com","model":"llama-3","keyTouched":true,"key":""}`}, + {Method: "GET", Path: "/api/preset/external?id=Groq"}, + {Method: "GET", Path: "/api/preset/external?id=Strata"}, + {Method: "GET", Path: "/api/preset/external?id=absent"}, + {Method: "GET", Path: "/api/presets"}, + {Method: "POST", Path: "/api/preset/external/test", Body: `{"url":"{{URL}}/ok","model":"m","key":"k1"}`}, + {Method: "POST", Path: "/api/preset/external/test", Body: `{"url":"{{URL}}/refus/v1","model":"m"}`}, + {Method: "POST", Path: "/api/preset/external/test", Body: `{"url":"{{URL}}/brut/v1/chat/completions","model":"m"}`}, + {Method: "POST", Path: "/api/preset/external/test", Body: `{"url":"{{URL}}/rien","model":"m"}`}, + {Method: "POST", Path: "/api/preset/external/test", Body: `{"url":"","model":"m"}`}, + {Method: "POST", Path: "/api/preset/external/save", Body: `{"name":"Clé","url":"{{URL}}/ok","model":"m","key":"k1"}`}, + {Method: "POST", Path: "/api/preset/external/test", Body: `{"id":"Clé","url":"{{URL}}/ok","model":"m"}`}, + {Method: "POST", Path: "/api/preset/external/test", Body: `[]`}, + // Placement auto et aperçus de l'éditeur. + {Method: "GET", Path: "/api/preset/autoplace"}, + {Method: "POST", Path: "/api/preset/autoplace", Body: `{"id":"MoE","content":` + jsonString(moe) + `}`}, + {Method: "POST", Path: "/api/preset/autoplace", Body: `{"name":" Mon MoE ","content":"MODEL=m\nEXTRA_ARGS=-ngl 30\n"}`}, + {Method: "POST", Path: "/api/preset/autoplace", Body: `{"content":"MODEL=m\nEXTRA_ARGS=--cpu-moe\n"}`}, + {Method: "POST", Path: "/api/preset/moe", Body: `{"content":` + jsonString(moe) + `}`}, + {Method: "POST", Path: "/api/preset/moe", Body: `{"content":"EXTERNAL=1\n"}`}, + {Method: "POST", Path: "/api/preset/moe", Body: `{"content":"MODEL=absent.gguf\nEXTRA_ARGS=--cpu-moe -sm row\n"}`}, + {Method: "POST", Path: "/api/preset/moe", Body: `{"content":5}`}, + {Method: "POST", Path: "/api/preset/cacheram", Body: `{"content":"EXTERNAL=1\nSIDE_SLOT=on\n"}`}, + {Method: "POST", Path: "/api/preset/cacheram", Body: `{"content":"EXTRA_ARGS=-cram 2048\n"}`}, + {Method: "POST", Path: "/api/preset/cacheram", Body: `{"content":"CACHE_RAM=4096\nSIDE_SLOT=on\nMODEL=absent.gguf\n"}`}, + {Method: "POST", Path: "/api/preset/cacheram", Body: `{"content":"CACHE_RAM=beaucoup\n"}`}, + {Method: "POST", Path: "/api/preset/cacheram", Body: `{"content":"CACHE_RAM=auto\n"}`}, + {Method: "POST", Path: "/api/preset/cacheram", Body: `{"content":"MODEL=absent.gguf\nSIDE_SLOT=on\nPARALLEL=3\n"}`}, + {Method: "POST", Path: "/api/preset/cacheram", Body: `{}`}, + {Method: "GET", Path: "/api/presets"}, + } { + fx.Steps = append(fx.Steps, run(st)) + } + + got, err := parityJSON(fx) + if err != nil { + t.Fatal(err) + } + got = []byte(strings.ReplaceAll(strings.ReplaceAll(string(got), srv.URL, "{{URL}}"), home, "{{HOME}}")) + if os.Getenv("LOKI_PARITY_UPDATE") == "1" { + if err := os.MkdirAll(filepath.Dir(parityPresetsFile), 0o755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(parityPresetsFile, got, 0o644); err != nil { + t.Fatal(err) + } + return + } + want, err := os.ReadFile(parityPresetsFile) + 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 presets ne produisent plus le journal rangé dans %s.\n"+ + "Si c'est voulu : LOKI_PARITY_UPDATE=1 go test ./internal/loki -run TestParitePresets,\n"+ + "puis cargo test --test presets_api dit si le port Rust suit.\n--- obtenu ---\n%s", parityPresetsFile, got) + } +} + +func jsonString(s string) string { + b, _ := json.Marshal(s) + return string(b) +} + +// La bascule et l'enregistrement du preset en service passent par +// presetApply (façade) : configuration appliquée, moteur relancé ou arrêté. +func TestFacadePresetApply(t *testing.T) { + testHome(t) + var actions []string + old := presetServiceAction + done := make(chan struct{}, 4) + presetServiceAction = func(a string) error { actions = append(actions, a); done <- struct{}{}; return nil } + t.Cleanup(func() { presetServiceAction = old }) + if err := os.MkdirAll(presetsDir(), 0o755); err != nil { + t.Fatal(err) + } + local := filepath.Join(presetsDir(), "local.env") + ext := filepath.Join(presetsDir(), "ext.env") + _ = os.WriteFile(local, []byte("MODEL=a.gguf\nCTX=8192\n"), 0o644) + _ = os.WriteFile(ext, []byte("EXTERNAL=1\nEXTERNAL_URL=u\n"), 0o644) + _ = SetConfigKey("HOST", "127.0.0.1") + if err := presetApply(local, "switch"); err != nil { + t.Fatal(err) + } + <-done + if cfg := ReadConfig(); cfg["CTX"] != "8192" || cfg["HOST"] != "127.0.0.1" { + t.Fatalf("configuration après bascule : %v", cfg) + } + if err := presetApply(local, "save"); err != nil { + t.Fatal(err) + } + <-done + if err := presetApply(ext, "apply"); err != nil { + t.Fatal(err) + } + if !isExternalConfig(ReadConfig()) || len(actions) != 2 || actions[0] != "restart" || actions[1] != "restart" { + t.Fatalf("actions %v, config %v", actions, ReadConfig()) + } + if err := presetApply("/etc/passwd", "switch"); err == nil { + t.Fatal("preset hors du dossier accepté") + } + if err := presetApply(local, "bizarre"); err == nil { + t.Fatal("mode inconnu accepté") + } + w := httptest.NewRecorder() + handleInternalTuneGuard(w, httptest.NewRequest("GET", "/internal/v1/tune/guard", nil)) + if w.Body.String() != "{\"busy\":false}\n" { + t.Fatalf("garde : %s", w.Body.String()) + } +} diff --git a/internal/loki/web_internal.go b/internal/loki/web_internal.go index 719f34d..e2d0e41 100644 --- a/internal/loki/web_internal.go +++ b/internal/loki/web_internal.go @@ -109,6 +109,10 @@ func withInternalAPI(next http.Handler) http.Handler { handleInternalConvDropped(w, r) case "/internal/v1/conv/workspace": handleInternalConvWorkspace(w, r) + case "/internal/v1/preset/apply": + handleInternalPresetApply(w, r) + case "/internal/v1/tune/guard": + handleInternalTuneGuard(w, r) default: http.NotFound(w, r) } diff --git a/rust/loki-rs/src/api.rs b/rust/loki-rs/src/api.rs index 9f2a1a5..e44c354 100644 --- a/rust/loki-rs/src/api.rs +++ b/rust/loki-rs/src/api.rs @@ -40,6 +40,8 @@ pub struct Ctx { pub push: Option>, /// Mémoire tenue par la façade (`LOKI_MEM=rust`). pub mem: Option>, + /// Presets tenus par la façade (`LOKI_PRESETS=rust`). + pub presets: bool, /// Jeton de l'API interne : une requête qui le porte vient du Go. pub internal_token: String, } @@ -55,6 +57,7 @@ impl Ctx { || self.files.is_some() || self.push.is_some() || self.mem.is_some() + || self.presets } /// La requête vient du Go (jeton interne valide) : déjà authentifiée par diff --git a/rust/loki-rs/src/lib.rs b/rust/loki-rs/src/lib.rs index b92ecef..91fa5e4 100644 --- a/rust/loki-rs/src/lib.rs +++ b/rust/loki-rs/src/lib.rs @@ -28,6 +28,7 @@ pub mod models; pub mod oai; pub mod paths; pub mod presets; +pub mod presets_api; pub mod proxy; pub mod push; pub mod serve; diff --git a/rust/loki-rs/src/main.rs b/rust/loki-rs/src/main.rs index 343a2d4..da75b67 100644 --- a/rust/loki-rs/src/main.rs +++ b/rust/loki-rs/src/main.rs @@ -145,6 +145,9 @@ fn serve() -> ExitCode { // LOKI_MEM=rust : la mémoire (pages, index, trackers, mode mémoire, // interrupteurs du mode agent). let mem_rust = held_by_facade("LOKI_MEM"); + // LOKI_PRESETS=rust : les presets (liste, éditeur, aperçus, externes, + // bascule ; l'application au moteur reste au Go). + let presets_rust = held_by_facade("LOKI_PRESETS"); let mut env = vec![ (auth::INTERNAL_ENV.to_string(), token.clone()), // Adresse de l'API interne du Go, héritée par ses enfants : @@ -159,6 +162,7 @@ fn serve() -> ExitCode { ("LOKI_FILES", files_rust), ("LOKI_PUSH", push_rust), ("LOKI_MEM", mem_rust), + ("LOKI_PRESETS", presets_rust), ]; for (var, on) in held { if on { @@ -238,6 +242,7 @@ fn serve() -> ExitCode { files, push: push_rust.then(|| std::sync::Arc::new(loki_rs::push::Push::new())), mem: mem_rust.then(|| std::sync::Arc::new(loki_rs::memory::Memory::new(mem_home))), + presets: presets_rust, }; let res = proxy::serve(listener, cfg, shutdown_signal()).await; eprintln!("[loki-rs] arrêt demandé — arrêt du serveur Go"); diff --git a/rust/loki-rs/src/models.rs b/rust/loki-rs/src/models.rs index 338402f..d23bd71 100644 --- a/rust/loki-rs/src/models.rs +++ b/rust/loki-rs/src/models.rs @@ -244,7 +244,7 @@ impl ModelRefs { } /// `modelUsers` : pour UN modèle (nom de fichier ou chemin). - fn users(&self, dirs: &ModelDirs, name: &str) -> (Vec, bool) { + pub(crate) fn users(&self, dirs: &ModelDirs, name: &str) -> (Vec, bool) { let k = dirs.ref_key(name); if k.is_empty() { return (Vec::new(), false); @@ -400,7 +400,11 @@ pub fn delete_model( } /// `deleteModelFile` : rend les octets libérés. -fn delete_model_file(dirs: &ModelDirs, refs: &ModelRefs, name: &str) -> Result { +pub(crate) fn delete_model_file( + dirs: &ModelDirs, + refs: &ModelRefs, + name: &str, +) -> Result { let p = gguf::resolve_model_path(name, &dirs.home, &dirs.stored, dirs.env.as_deref())? .to_string_lossy() .into_owned(); diff --git a/rust/loki-rs/src/presets_api.rs b/rust/loki-rs/src/presets_api.rs new file mode 100644 index 0000000..35fc1ff --- /dev/null +++ b/rust/loki-rs/src/presets_api.rs @@ -0,0 +1,1458 @@ +//! Presets tenus par la façade — port de `backend_presets.go` (écriture), +//! des handlers de presets de `web_api.go`, de `web_external.go`, et des +//! aperçus de l'éditeur (`cacheRAMPreview`, `sideSlotPreview`, `moePreview`, +//! `autoPlacementPreset`) — lot 15 de la migration, servi quand +//! `LOKI_PRESETS=rust` (expérimental ; le Go par défaut) : +//! +//! - `GET /api/presets` (pastilles et mesures), `POST /api/presets/order` ; +//! - `GET /api/preset?id=`, `POST /api/preset/save`, `/api/preset/delete` ; +//! - `POST /api/preset/cacheram`, `/api/preset/moe`, `/api/preset/autoplace` ; +//! - `GET /api/preset/external?id=`, `POST /api/preset/external/save`, +//! `/api/preset/external/test` ; +//! - `POST /api/switch`. +//! +//! Les presets restent des fichiers `presets/*.env`. Ce qui touche au moteur +//! reste au Go jusqu'à son lot : le verrou de l'optimiseur +//! (`/internal/v1/tune/guard`) et l'application d'un preset à la +//! configuration vivante, état du slot gardé et moteur relancé +//! (`/internal/v1/preset/apply`). + +use std::collections::{BTreeMap, HashMap}; +use std::path::{Path, PathBuf}; + +use http_body_util::{BodyExt, Limited}; +use hyper::body::Incoming; +use hyper::{Method, Request, Response, StatusCode}; +use serde_json::{Value, json}; + +use crate::api::Ctx; +use crate::auth::json as send_json; +use crate::gojson::{self, Kind}; +use crate::presets::{ + PRESET_ORDER_KEY, config_fingerprint, list_presets, parse_env, parse_preset_order, preset_ctx, + preset_display_name, preset_fingerprint, presets_dir, unquote_value, +}; +use crate::proxy::Body; +use crate::serve::{self, Cfg, SysInfo}; +use crate::store::{BK_STATE, Store}; + +const SYSPROMPT_PREFIX: &str = "sysprompt:"; +const BENCH_KEY: &str = "bench_presets"; +const BENCH_PROTOCOL: &str = "2"; +/// `previewServeHelp` : l'aide supposée d'un moteur récent. +pub const PREVIEW_SERVE_HELP: &str = "--load-mode --fit [on|off] --fit-target"; + +/// Routes servies ici. +pub fn handles(path: &str) -> bool { + matches!( + path, + "/api/presets" + | "/api/presets/order" + | "/api/preset" + | "/api/preset/save" + | "/api/preset/cacheram" + | "/api/preset/moe" + | "/api/preset/autoplace" + | "/api/preset/delete" + | "/api/preset/external" + | "/api/preset/external/save" + | "/api/preset/external/test" + | "/api/switch" + ) +} + +fn trim(s: &str) -> &str { + s.trim_matches(char::is_whitespace) +} + +fn get<'a>(cfg: &'a Cfg, k: &str) -> &'a str { + cfg.get(k).map_or("", String::as_str) +} + +// --------------------------------------------------------------------------- +// Fonctions pures +// --------------------------------------------------------------------------- + +/// `withDisplayName` : la ligne `# NAME=` remplacée, ou ajoutée en tête. +pub fn with_display_name(content: &str, name: &str) -> String { + let re = regex::Regex::new(r"(?mi)^[ \t]*#?[ \t]*NAME[ \t]*=.*$").expect("motif"); + let line = format!("# NAME={name}"); + if re.is_match(content) { + // ReplaceAllString du Go : « $x » est développé, comme ici. + return re.replace_all(content, line.as_str()).into_owned(); + } + format!("{line}\n{content}") +} + +/// `quoteValue`. +pub fn quote_value(v: &str) -> String { + if v.is_empty() || v.contains('"') { + return v.to_string(); + } + if v.contains([' ', '\t']) || unquote_value(v) != v { + return format!("\"{v}\""); + } + v.to_string() +} + +/// `formatEnv` : clés triées. +pub fn format_env(m: &BTreeMap) -> String { + m.iter() + .map(|(k, v)| format!("{k}={}\n", quote_value(v))) + .collect() +} + +/// `validPresetName`. +pub fn valid_preset_name(name: &str) -> Result<(), String> { + if name.is_empty() { + return Err("nom vide".into()); + } + if name == "." || name == ".." { + return Err("nom réservé".into()); + } + if name.contains(['/', '\\', '\0']) { + return Err("le nom ne peut pas contenir / ni \\".into()); + } + if name.chars().any(|c| (c as u32) < 0x20) { + return Err("le nom contient un caractère de contrôle invalide".into()); + } + Ok(()) +} + +/// `safePresetPath`. +pub fn safe_preset_path(home: &Path, name: &str) -> Result { + let name = trim(name); + valid_preset_name(name)?; + Ok(presets_dir(home).join(format!("{name}.env"))) +} + +/// `uniquePresetID`. +pub fn unique_preset_id(home: &Path, name: &str) -> Result { + let replaced = name.replace(['/', '\\'], "-"); + let mut base = trim(&replaced).to_string(); + if base.is_empty() { + base = "preset".into(); + } + let mut cand = base.clone(); + for n in 2..10000 { + let p = safe_preset_path(home, &cand)?; + if matches!(std::fs::metadata(&p), Err(e) if e.kind() == std::io::ErrorKind::NotFound) { + return Ok(cand); + } + cand = format!("{base} ({n})"); + } + Err("impossible de générer un nom de fichier unique".into()) +} + +/// `ReadPreset`. +pub fn read_preset(home: &Path, id: &str) -> Result { + let p = safe_preset_path(home, id)?; + std::fs::read(&p) + .map(|b| String::from_utf8_lossy(&b).into_owned()) + .map_err(|e| crate::paths::os_err("open", &p.to_string_lossy(), &e)) +} + +fn write_preset(p: &Path, content: &str) -> Result<(), String> { + if let Some(d) = p.parent() { + let _ = std::fs::create_dir_all(d); + } + let mut o = std::fs::OpenOptions::new(); + o.write(true).create(true).truncate(true); + #[cfg(unix)] + { + use std::os::unix::fs::OpenOptionsExt; + o.mode(0o644); + } + use std::io::Write as _; + o.open(p) + .and_then(|mut f| f.write_all(content.as_bytes())) + .map_err(|e| crate::paths::os_err("open", &p.to_string_lossy(), &e)) +} + +/// `SavePreset` : création (id vide, nom de fichier unique) ou mise à jour. +pub fn save_preset(home: &Path, id: &str, name: &str, content: &str) -> Result { + let name = trim(name); + if name.is_empty() { + return Err("nom requis".into()); + } + let content = with_display_name(content, name); + let id = if id.is_empty() { + unique_preset_id(home, name)? + } else { + id.to_string() + }; + let p = safe_preset_path(home, &id)?; + write_preset(&p, &content)?; + Ok(id) +} + +/// `presetReasoning`. +pub fn preset_reasoning(content: &str) -> String { + for line in content.split('\n') { + let s = trim(line); + if s.is_empty() || s.starts_with('#') { + continue; + } + if let Some((k, v)) = s.split_once('=') + && trim(k).eq_ignore_ascii_case("REASONING") + { + return unquote_value(trim(v)).to_string(); + } + } + String::new() +} + +/// `modelFromPresetContent`. +pub fn model_from_preset_content(content: &str) -> String { + for line in content.split('\n') { + let s = trim(line); + if s.is_empty() || s.starts_with('#') { + continue; + } + if let Some((k, v)) = s.split_once('=') + && trim(k) == "MODEL" + { + return unquote_value(trim(v)).to_string(); + } + } + String::new() +} + +/// `detectQuant` : une ligne QUANT= (avec ou sans `#`), sinon le nom du +/// modèle. +pub fn detect_quant(content: &str) -> String { + for line in content.split('\n') { + let t = trim(line); + let s = trim(t.strip_prefix('#').unwrap_or(t)); + if let Some((k, v)) = s.split_once('=') + && trim(k).eq_ignore_ascii_case("QUANT") + { + let v = unquote_value(trim(v)); + if !v.is_empty() { + return v.to_uppercase(); + } + } + } + crate::models::quant_from_name(&crate::paths::base_name(&model_from_preset_content( + content, + ))) +} + +/// `completionsURL`. +pub fn completions_url(raw: &str) -> String { + let u = trim(raw).trim_end_matches('/'); + if u.is_empty() { + return String::new(); + } + if u.ends_with("/chat/completions") { + return u.to_string(); + } + if u.ends_with("/v1") { + return format!("{u}/chat/completions"); + } + format!("{u}/v1/chat/completions") +} + +/// `externalPresetContent`. +pub fn external_preset_content( + url: &str, + model: &str, + key: &str, + ctx: &str, + vision: bool, +) -> String { + let mut m = BTreeMap::new(); + m.insert("EXTERNAL".to_string(), "1".to_string()); + m.insert("EXTERNAL_URL".into(), trim(url).into()); + m.insert("EXTERNAL_MODEL".into(), trim(model).into()); + if !trim(key).is_empty() { + m.insert("EXTERNAL_KEY".into(), trim(key).into()); + } + if vision { + m.insert("EXTERNAL_VISION".into(), "1".into()); + } + if !trim(ctx).is_empty() { + m.insert("CTX".into(), trim(ctx).into()); + } + format_env(&m) +} + +/// `extractAPIError` : `error.message` d'une réponse au format OpenAI. +pub fn extract_api_error(b: &[u8]) -> String { + match serde_json::from_slice::(b) { + Ok(v) => v + .get("error") + .and_then(|e| e.get("message")) + .and_then(Value::as_str) + .map(|s| trim(s).to_string()) + .unwrap_or_default(), + Err(_) => String::new(), + } +} + +/// `presetImposesEngine` : seul un backend personnalisé est imposé par un +/// preset ; le moteur de l'image, un téléchargé ou un précompilé suivent la +/// machine. +pub fn preset_imposes_engine(home: &Path, p: &str) -> bool { + let p = trim(p); + let ep = serve::run::EnginePaths { + home: home.to_path_buf(), + }; + if p.is_empty() || ep.engine_owns(p) || ep.prebuilt_owns(p) { + return false; + } + let img = std::env::var("LOKI_ENGINE_BIN").unwrap_or_default(); + let img = trim(&img); + if !img.is_empty() && serve::run::same_path(p, img) { + return false; + } + true +} + +/// `newPresetSeed` : BIN (backend personnalisé seulement), HOST, PORT. +pub fn new_preset_seed(home: &Path, cur: &Cfg) -> BTreeMap { + let mut seed = BTreeMap::new(); + for k in ["BIN", "HOST", "PORT"] { + let v = get(cur, k); + if v.is_empty() || (k == "BIN" && !preset_imposes_engine(home, v)) { + continue; + } + seed.insert(k.to_string(), v.to_string()); + } + seed +} + +/// `benchFingerprint`. +pub fn bench_fingerprint(cfg: &Cfg, cur: &Cfg) -> String { + let mut gpus = trim(get(cfg, "CUDA_VISIBLE_DEVICES")).to_string(); + if gpus.is_empty() { + gpus = trim(get(cur, "CUDA_VISIBLE_DEVICES")).to_string(); + } + let mut m = HashMap::new(); + m.insert("config".to_string(), config_fingerprint(cfg)); + m.insert("gpus".into(), gpus); + m.insert("protocol".into(), BENCH_PROTOCOL.into()); + config_fingerprint(&m) +} + +/// `benchMatchesPreset`. +pub fn bench_matches_preset(sb: &Value, cfg: &Cfg, cur: &Cfg) -> bool { + let fp = sb.get("fingerprint").and_then(Value::as_str).unwrap_or(""); + if !fp.is_empty() { + return fp == bench_fingerprint(cfg, cur); + } + let m = trim(get(cfg, "MODEL")); + !m.is_empty() && sb.get("model").and_then(Value::as_str) == Some(&crate::paths::base_name(m)) +} + +/// Nombre JSON au rendu d'`encoding/json` (un flottant entier s'écrit sans +/// « .0 »). +fn go_num(f: f64) -> Value { + if f.fract() == 0.0 && f.abs() < 1e15 { + json!(f as i64) + } else { + json!(f) + } +} + +fn num(v: &Value, k: &str) -> f64 { + v.get(k).and_then(Value::as_f64).unwrap_or(0.0) +} + +/// Pastille de mesure d'un preset (`bench` de `handlePresets`). +fn bench_badge(sb: &Value) -> Value { + let r = sb.get("result").cloned().unwrap_or(Value::Null); + let mut b = serde_json::Map::new(); + b.insert("prefill".into(), go_num(num(&r, "prompt_per_second"))); + b.insert("decode".into(), go_num(num(&r, "predicted_per_second"))); + b.insert( + "at".into(), + json!(sb.get("at").and_then(Value::as_i64).unwrap_or(0)), + ); + let kv = r.get("kv").and_then(Value::as_str).unwrap_or(""); + if !kv.is_empty() { + b.insert("kv".into(), json!(kv)); + b.insert( + "kv_quantized".into(), + json!( + r.get("kv_quantized") + .and_then(Value::as_bool) + .unwrap_or(false) + ), + ); + } + if let Some(d) = r.get("depth").filter(|d| d.is_object()) { + let dd = num(d, "decode_per_second"); + if let Some(c) = d.get("cold").filter(|c| c.is_object()) + && dd > 0.0 + { + b.insert( + "depth".into(), + json!(c.get("new").and_then(Value::as_i64).unwrap_or(0)), + ); + b.insert("depth_decode".into(), go_num(dd)); + } + } + Value::Object(b) +} + +/// `joinArgs` : l'inverse de `splitArgs`. +pub fn join_args(args: &[String]) -> String { + args.iter() + .map(|a| { + if a.is_empty() { + "\"\"".to_string() + } else if a.contains([' ', '\t']) { + if a.contains('\'') { + format!("\"{a}\"") + } else { + format!("'{a}'") + } + } else { + a.clone() + } + }) + .collect::>() + .join(" ") +} + +/// `presetSetKey` : lignes KEY= remplacées (toutes), ajoutée à la fin si +/// absente, retirées si `val` est vide. +pub fn preset_set_key(content: &str, key: &str, val: &str) -> String { + let k = regex::escape(key); + if val.is_empty() { + let re = regex::Regex::new(&format!( + r"(?m)^[ \t]*(?:export[ \t]+)?{k}[ \t]*=.*(?:\n|$)" + )) + .expect("motif"); + return re.replace_all(content, regex::NoExpand("")).into_owned(); + } + let re = + regex::Regex::new(&format!(r"(?m)^[ \t]*(?:export[ \t]+)?{k}[ \t]*=.*$")).expect("motif"); + let line = format!("{key}={}", quote_value(val)); + if re.is_match(content) { + return re.replace_all(content, regex::NoExpand(&line)).into_owned(); + } + let mut c = content.to_string(); + if !c.is_empty() && !c.ends_with('\n') { + c.push('\n'); + } + c + &line + "\n" +} + +/// `autoPlacementPreset` : la COPIE d'un preset MoE placé à la main, en +/// placement automatique, et ce qui change. +pub fn auto_placement_preset(content: &str) -> Result<(String, Vec), String> { + let cfg = parse_env(content); + if serve::is_external(&cfg) { + return Err("preset externe : le modèle tourne ailleurs".into()); + } + let extra = crate::models::split_args(get(&cfg, "EXTRA_ARGS")); + let mut kept: Vec = Vec::new(); + let mut changes: Vec = Vec::new(); + let mut migrated = false; + let mut i = 0; + while i < extra.len() { + let a = &extra[i]; + let (name, val, has_eq) = match a.split_once('=') { + Some((n, v)) => (n.to_string(), v.to_string(), true), + None => (a.clone(), String::new(), false), + }; + let mut value = || -> String { + if has_eq { + return val.clone(); + } + if i + 1 < extra.len() { + i += 1; + return extra[i].clone(); + } + String::new() + }; + match name.as_str() { + "-ot" | "--override-tensor" => { + let v = value(); + let mut rest: Vec = Vec::new(); + for el in v.split(',') { + let el = trim(el); + let pat = el.split_once('=').map_or(el, |(p, _)| p); + if el.is_empty() { + } else if serve::ot_targets_experts(pat) { + changes.push(format!("retiré : {name} {el} (experts : --fit les place)")); + migrated = true; + } else if pat.to_lowercase().contains("per_layer_token_embd") + && serve::ot_on_cpu(el) + { + changes.push(format!( + "retiré : {name} {el} (couche d'entrée, toujours au CPU)" + )); + } else { + rest.push(el.to_string()); + } + } + if !rest.is_empty() { + kept.push(name.clone()); + kept.push(rest.join(",")); + } + } + "-ncmoe" | "--n-cpu-moe" => { + let v = value(); + changes.push(format!("retiré : {name} {v}")); + if trim(&v) != "0" { + migrated = true; + } + } + "-cmoe" | "--cpu-moe" => { + changes.push(format!("retiré : {name}")); + migrated = true; + } + "-ngl" | "--n-gpu-layers" | "--gpu-layers" | "-ts" | "--tensor-split" | "-b" + | "--batch-size" | "-ub" | "--ubatch-size" | "--load-mode" | "-lm" => { + let v = value(); + changes.push(format!("retiré : {name} {v}")); + } + "--mlock" | "--no-mmap" | "--mmap" => changes.push(format!("retiré : {name}")), + "-fit" | "--fit" => { + let v = value(); + if matches!( + trim(&v).to_lowercase().as_str(), + "off" | "disabled" | "false" | "0" + ) { + changes.push(format!("retiré : {name} {v}")); + } else { + kept.push(name.clone()); + kept.push(v); + } + } + _ => kept.push(a.clone()), + } + i += 1; + } + if !migrated { + return Err("rien à migrer : ce preset ne place pas d'experts à la main (-ot …exps…, --n-cpu-moe, --cpu-moe)".into()); + } + kept.push("--load-mode".into()); + kept.push("mmap".into()); + let mut nc = cfg.clone(); + nc.insert("EXTRA_ARGS".into(), join_args(&kept)); + nc.insert("UBATCH".into(), "2048".into()); + nc.insert("BATCH".into(), "4096".into()); + nc.remove("NGL"); + let (ctx, ok) = serve::ctx_fixed(&nc, &kept); + if !ok { + return Err(format!( + "contexte « {ctx} » non chiffré : --fit pourrait le réduire pour tenir — donne un CTX chiffré d'abord" + )); + } + if serve::split_mode_key(&nc) == "tensor" { + return Err("SPLIT_MODE=tensor coupe --fit : retire-le d'abord".into()); + } + let si = SysInfo { + help: PREVIEW_SERVE_HELP.into(), + ..SysInfo::default() + }; + let why = serve::fit_blocker(&nc, &kept, &si); + if !why.is_empty() { + return Err(format!( + "--fit resterait inactif ({why}) : retire-le à la main d'abord" + )); + } + let ngl = trim(get(&cfg, "NGL")); + if !ngl.is_empty() { + changes.push(format!( + "NGL={ngl} retiré : le moteur place les couches (-ngl auto)" + )); + } + changes.push(format!( + "UBATCH=2048, BATCH=4096 (avant : {} / {})", + serve_first(&[get(&cfg, "UBATCH"), "512"]), + serve_first(&[get(&cfg, "BATCH"), "2048"]) + )); + changes.push("--load-mode mmap".into()); + changes.push(format!("CTX conservé : {ctx}")); + let (k, v) = serve::serve_kv_types(&nc); + if !k.is_empty() + || !v.is_empty() + || !serve::flag_value(&kept, &["-ctk", "--cache-type-k"]).is_empty() + || !serve::flag_value(&kept, &["-ctv", "--cache-type-v"]).is_empty() + { + changes.push( + "cache KV conservé tel quel — garde-le identique pour comparer les deux presets".into(), + ); + } + let mut out = content.to_string(); + out = preset_set_key(&out, "EXTRA_ARGS", get(&nc, "EXTRA_ARGS")); + out = preset_set_key(&out, "UBATCH", "2048"); + out = preset_set_key(&out, "BATCH", "4096"); + out = preset_set_key(&out, "NGL", ""); + Ok((out, changes)) +} + +fn serve_first<'a>(vals: &[&'a str]) -> &'a str { + vals.iter() + .copied() + .find(|v| !trim(v).is_empty()) + .unwrap_or("") +} + +// --------------------------------------------------------------------------- +// Aperçus de l'éditeur (lectures du monde : GGUF, RAM, nvidia-smi) +// --------------------------------------------------------------------------- + +/// Résolution d'un MODEL= pour les aperçus. +pub struct Resolver { + pub home: PathBuf, + pub stored: Vec, + pub env: Option, +} + +impl Resolver { + fn resolve(&self, m: &str) -> String { + crate::gguf::resolve_serve_model_path(m, &self.home, &self.stored, self.env.as_deref()) + .map(|p| p.to_string_lossy().into_owned()) + .unwrap_or_default() + } +} + +fn lookup_env(si: &mut SysInfo, keys: &[&[&str]], trimmed: bool) { + for group in keys { + for k in *group { + if let Ok(v) = std::env::var(k) { + let keep = if trimmed { + !trim(&v).is_empty() + } else { + !v.is_empty() + }; + if keep { + si.arg_env.insert((*k).into(), v); + } + } + } + } +} + +/// `cacheRAMPreview` : (Mio, pourquoi) ; 0 = défaut du moteur. +pub fn cache_ram_preview(r: &Resolver, content: &str) -> (i64, String) { + let cfg = parse_env(content); + if serve::is_external(&cfg) { + return (0, "preset externe".into()); + } + let extra = crate::models::split_args(get(&cfg, "EXTRA_ARGS")); + let v = serve::flag_value(&extra, &["-cram", "--cache-ram"]); + if !v.is_empty() { + return (v.parse().unwrap_or(0), "fixé par EXTRA_ARGS".into()); + } + let e = std::env::var("LLAMA_ARG_CACHE_RAM").unwrap_or_default(); + if !trim(&e).is_empty() { + return ( + trim(&e).parse().unwrap_or(0), + "fixé par LLAMA_ARG_CACHE_RAM".into(), + ); + } + let (manual, auto, ok) = serve::cache_ram_setting(&cfg); + if !ok { + return (0, "CACHE_RAM illisible".into()); + } + if !auto { + return (manual.parse().unwrap_or(0), "fixé par CACHE_RAM".into()); + } + let mut si = SysInfo::default(); + lookup_env( + &mut si, + &[ + serve::SERVE_ARG_ENV, + serve::FIDELITY_ARG_ENV, + serve::CACHE_ARG_ENV, + ], + false, + ); + let m = trim(get(&cfg, "MODEL")); + if !m.is_empty() { + si.model = r.resolve(m); + } + if si.model.is_empty() { + return (0, "modèle introuvable".into()); + } + serve::run::probe_cache_ram(&serve::run::Env::default(), &cfg, &extra, &mut si); + serve::cache_ram_auto(&cfg, &extra, &si) +} + +/// `sideSlotPreview` : (accepté, Mio, pourquoi). +pub fn side_slot_preview(r: &Resolver, content: &str) -> (bool, i64, String) { + let cfg = parse_env(content); + if !serve::side_slot_on(&cfg) { + return (false, 0, String::new()); + } + if serve::is_external(&cfg) { + return (false, 0, "preset externe".into()); + } + let extra = crate::models::split_args(get(&cfg, "EXTRA_ARGS")); + let mut si = SysInfo { + help: "--no-kv-unified".into(), + ..SysInfo::default() + }; + lookup_env( + &mut si, + &[ + serve::SERVE_ARG_ENV, + serve::FIDELITY_ARG_ENV, + serve::CACHE_ARG_ENV, + serve::SIDE_SLOT_ARG_ENV, + ], + true, + ); + let m = trim(get(&cfg, "MODEL")); + if !m.is_empty() { + si.model = r.resolve(m); + } + if si.model.is_empty() { + return (false, 0, "modèle introuvable".into()); + } + serve::run::probe_cache_ram(&serve::run::Env::default(), &cfg, &extra, &mut si); + let (ctx, mib, why) = serve::side_slot_plan(&cfg, &extra, &si); + (ctx > 0, mib, why) +} + +/// `moePreview` : (notes, avis de chargement). +pub fn moe_preview(r: &Resolver, live_bin: &str, content: &str) -> (Vec, String) { + let cfg = parse_env(content); + if serve::is_external(&cfg) { + return (Vec::new(), String::new()); + } + let mut si = SysInfo { + help: PREVIEW_SERVE_HELP.into(), + ..SysInfo::default() + }; + lookup_env( + &mut si, + &[ + serve::SERVE_ARG_ENV, + serve::FIT_ARG_ENV, + serve::LOAD_ARG_ENV, + ], + true, + ); + let m = trim(get(&cfg, "MODEL")); + if !m.is_empty() { + si.model = r.resolve(m); + } + if si.model.is_empty() { + return (Vec::new(), String::new()); + } + let extra = crate::models::split_args(get(&cfg, "EXTRA_ARGS")); + serve::run::probe_cache_ram(&serve::run::Env::default(), &cfg, &extra, &mut si); + let (norm, _) = serve::normalize_load_flags(&extra, true); + if serve::load_guard_needs_devices(&cfg, &norm, &si) { + let ep = serve::run::EnginePaths { + home: r.home.clone(), + }; + let bin = ep.prebuilt_resolve_bin(serve_first(&[get(&cfg, "BIN"), live_bin])); + let key = format!("{bin}\0{}", get(&cfg, "CUDA_VISIBLE_DEVICES")); + if let Some(devs) = dev_persist_get(&r.home, &key) { + si.engine_vram_mib = serve::engine_devs_vram(&devs); + } + } + let (mut warn, refuse) = serve::load_mode_risk(&cfg, &norm, &si); + if !refuse.is_empty() { + warn = format!("refusé au lancement — {refuse}"); + } + (serve::moe_notes(&cfg, &extra, &si), warn) +} + +/// `devPersistGet` : la dernière liste de cartes donnée à l'éditeur +/// (`devices.json`). +fn dev_persist_get(home: &Path, key: &str) -> Option> { + let b = std::fs::read(home.join("devices.json")).ok()?; + let m: HashMap> = serde_json::from_slice(&b).ok()?; + let list = m.get(key).filter(|l| !l.is_empty())?; + Some( + list.iter() + .map(|d| serve::SplitDev { + id: d.get("id").and_then(Value::as_str).unwrap_or("").into(), + total_mib: d.get("total_mib").and_then(Value::as_f64).unwrap_or(0.0) as i64, + }) + .collect(), + ) +} + +// --------------------------------------------------------------------------- +// Routes +// --------------------------------------------------------------------------- + +fn fail(code: StatusCode, e: impl Into) -> Response { + send_json(code, &json!({ "ok": false, "error": e.into() })) +} + +async fn read_body(body: Incoming, limit: usize) -> Result, String> { + Limited::new(body, limit) + .collect() + .await + .map(|b| b.to_bytes().to_vec()) + .map_err(|_| "http: request body too large".to_string()) +} + +const PRESET_SAVE_REQ: &[(&str, Kind)] = &[ + ("id", Kind::Str), + ("name", Kind::Str), + ("content", Kind::Str), + ("deleteModel", Kind::Bool), + ("sysprompt", Kind::OptStr), +]; + +const EXTERNAL_SAVE_REQ: &[(&str, Kind)] = &[ + ("id", Kind::Str), + ("name", Kind::Str), + ("url", Kind::Str), + ("model", Kind::Str), + ("key", Kind::Str), + ("ctx", Kind::Str), + ("keyTouched", Kind::Bool), + ("vision", Kind::Bool), +]; + +const CONTENT_REQ_TYPE: &str = r#"struct { Content string "json:\"content\"" }"#; +const ORDER_REQ_TYPE: &str = r#"struct { IDs []string "json:\"ids\"" }"#; +const AUTOPLACE_REQ_TYPE: &str = r#"struct { ID string "json:\"id\""; Name string "json:\"name\""; Content string "json:\"content\"" }"#; +const SWITCH_REQ_TYPE: &str = r#"struct { N int "json:\"n\""; ID string "json:\"id\"" }"#; + +/// Le verrou de l'optimiseur (au Go) : `Some(réponse 409)` s'il tient le +/// moteur. +async fn tune_deny(store: &Store) -> Option> { + let v = store + .internal_json(Method::GET, "/internal/v1/tune/guard", None) + .await + .ok()?; + if v.get("busy").and_then(Value::as_bool) == Some(true) { + return Some(send_json( + StatusCode::CONFLICT, + &json!({ "ok": false, "tuning": true, "error": v.get("error").and_then(Value::as_str).unwrap_or("") }), + )); + } + None +} + +/// Applique un preset à la configuration vivante (au Go, voir +/// `facade_presets.go`). +async fn preset_apply(store: &Store, path: &Path, mode: &str) -> Result<(), String> { + let (code, v) = store + .internal_post( + "/internal/v1/preset/apply", + &json!({ "path": path.to_string_lossy(), "mode": mode }), + ) + .await?; + if code.is_success() { + return Ok(()); + } + Err(v + .get("error") + .and_then(Value::as_str) + .map(str::to_string) + .unwrap_or_else(|| format!("application du preset : {code}"))) +} + +struct Live { + cfg: Cfg, + order: Vec, +} + +async fn live(store: &Store) -> Live { + Live { + cfg: store.config().await, + order: parse_preset_order( + store + .get(BK_STATE, PRESET_ORDER_KEY) + .await + .ok() + .flatten() + .as_deref(), + ), + } +} + +/// `SavePresetApplying` : enregistre ; si c'était le preset EN SERVICE (avant +/// l'écriture), l'applique (`mode` : save ou apply). +async fn save_applying( + ctx: &Ctx, + id: &str, + name: &str, + content: &str, + mode: &str, +) -> Result<(String, bool), String> { + let home = &ctx.engine.home; + let mut was_active = false; + if !id.is_empty() + && let Ok(old) = read_preset(home, id) + { + was_active = preset_fingerprint(&old) == config_fingerprint(&ctx.store.config().await); + } + let new_id = save_preset(home, id, name, content)?; + if !was_active { + return Ok((new_id, false)); + } + let p = safe_preset_path(home, &new_id)?; + preset_apply(&ctx.store, &p, mode).await?; + Ok((new_id, true)) +} + +async fn resolver(ctx: &Ctx) -> Resolver { + let d = crate::models::model_dirs(ctx).await; + Resolver { + home: d.home, + stored: d.stored, + env: d.env, + } +} + +/// `from_go` : remise par le Go (tunnel), déjà authentifiée par lui. +pub async fn serve(ctx: &Ctx, req: Request, from_go: bool) -> Response { + let (parts, body) = req.into_parts(); + if !from_go && let Err(refus) = ctx.guard(&parts.headers).await { + return *refus; + } + let store = &ctx.store; + let home = ctx.engine.home.clone(); + let mut query: HashMap = HashMap::new(); + for (k, v) in url::form_urlencoded::parse(parts.uri.query().unwrap_or("").as_bytes()) { + query + .entry(k.into_owned()) + .or_insert_with(|| v.into_owned()); + } + let q = |k: &str| trim(query.get(k).map_or("", String::as_str)).to_string(); + match parts.uri.path() { + "/api/presets" => { + let lv = live(store).await; + let list = match list_presets(&home, &lv.cfg, &lv.order) { + Ok(l) => l, + Err(e) => { + return send_json(StatusCode::INTERNAL_SERVER_ERROR, &json!({ "error": e })); + } + }; + let benches: HashMap = store + .get(BK_STATE, BENCH_KEY) + .await + .ok() + .flatten() + .and_then(|b| serde_json::from_slice(&b).ok()) + .unwrap_or_default(); + let mut out = Vec::new(); + for p in list { + let mut item = serde_json::Map::new(); + item.insert("id".into(), json!(p.id)); + item.insert("name".into(), json!(p.name)); + item.insert("active".into(), json!(p.active)); + let content = read_preset(&home, &p.id).unwrap_or_default(); + let cfg = parse_env(&content); + if !content.is_empty() { + if serve::is_external(&cfg) { + item.insert("external".into(), json!(true)); + let m = trim(get(&cfg, "EXTERNAL_MODEL")); + if !m.is_empty() { + item.insert("model".into(), json!(m)); + } + if trim(get(&cfg, "EXTERNAL_VISION")) == "1" { + item.insert("vision".into(), json!(true)); + } + out.push(Value::Object(item)); + continue; + } + let c = preset_ctx(&content); + if !c.is_empty() { + item.insert("ctx".into(), json!(c)); + } + if !trim(get(&cfg, "MMPROJ")).is_empty() { + item.insert("vision".into(), json!(true)); + } + if serve::is_strata(&cfg) { + item.insert("strata".into(), json!(true)); + if get(&cfg, "STRATA_VISION") != "0" { + item.insert("vision".into(), json!(true)); + } + } + let qn = detect_quant(&content); + if !qn.is_empty() { + item.insert("quant".into(), json!(qn)); + } + let r = preset_reasoning(&content); + if crate::chat::wire::reasoning_active(&r) { + item.insert("reasoning".into(), json!(r.to_lowercase())); + } + } + if let Some(sb) = benches.get(&p.id) + && bench_matches_preset(sb, &cfg, &lv.cfg) + { + item.insert("bench".into(), bench_badge(sb)); + } + out.push(Value::Object(item)); + } + send_json(StatusCode::OK, &Value::Array(out)) + } + "/api/presets/order" => { + if parts.method != Method::POST { + return fail(StatusCode::METHOD_NOT_ALLOWED, "POST attendu"); + } + let b = read_body(body, 8 << 20).await.unwrap_or_default(); + let d = gojson::decode_struct(&b, ORDER_REQ_TYPE, &[("ids", Kind::StrList)]); + if let Some(e) = d.err { + return fail(StatusCode::BAD_REQUEST, e); + } + let ids = d.str_list("ids"); + if let Err(e) = store + .put( + BK_STATE, + PRESET_ORDER_KEY, + gojson::marshal(&ids).into_bytes(), + ) + .await + { + return fail(StatusCode::INTERNAL_SERVER_ERROR, e); + } + send_json(StatusCode::OK, &json!({ "ok": true })) + } + "/api/preset" => { + let id = q("id"); + if id.is_empty() { + let cur = store.config().await; + return send_json( + StatusCode::OK, + &json!({ "id": "", "name": "", "content": format_env(&new_preset_seed(&home, &cur)) }), + ); + } + match read_preset(&home, &id) { + Ok(content) => { + let sp = store + .get_str(BK_STATE, &format!("{SYSPROMPT_PREFIX}{id}")) + .await; + send_json( + StatusCode::OK, + &json!({ "id": id, "name": preset_display_name(&content, &id), "content": content, "sysprompt": sp }), + ) + } + Err(_) => send_json(StatusCode::NOT_FOUND, &json!({ "error": "not found" })), + } + } + "/api/preset/save" => { + if let Some(r) = tune_deny(store).await { + return r; + } + let b = read_body(body, 8 << 20).await.unwrap_or_default(); + let d = gojson::decode_struct(&b, "presetSaveReq", PRESET_SAVE_REQ); + if let Some(e) = d.err.clone() { + return fail(StatusCode::BAD_REQUEST, e); + } + let (new_id, applied) = + match save_applying(ctx, &d.str("id"), &d.str("name"), &d.str("content"), "save") + .await + { + Ok(x) => x, + Err(e) => return fail(StatusCode::BAD_REQUEST, e), + }; + if let Some(sp) = d.opt_str("sysprompt") { + let key = format!("{SYSPROMPT_PREFIX}{new_id}"); + let res = if trim(&sp).is_empty() { + store.del(BK_STATE, &key).await + } else { + store + .put(BK_STATE, &key, trim(&sp).as_bytes().to_vec()) + .await + }; + if let Err(e) = res { + return fail(StatusCode::INTERNAL_SERVER_ERROR, e); + } + } + send_json( + StatusCode::OK, + &json!({ "ok": true, "id": new_id, "name": d.str("name"), "applied": applied }), + ) + } + "/api/preset/cacheram" => { + let b = match read_body(body, 1 << 20).await { + Ok(b) => b, + Err(e) => return fail(StatusCode::BAD_REQUEST, e), + }; + let d = gojson::decode_struct(&b, CONTENT_REQ_TYPE, &[("content", Kind::Str)]); + if let Some(e) = d.err.clone() { + return fail(StatusCode::BAD_REQUEST, e); + } + let r = resolver(ctx).await; + let content = d.str("content"); + let res = tokio::task::spawn_blocking(move || { + ( + cache_ram_preview(&r, &content), + side_slot_preview(&r, &content), + ) + }) + .await; + let Ok(((mib, why), (on, extra, swhy))) = res else { + return fail(StatusCode::INTERNAL_SERVER_ERROR, "aperçu interrompu"); + }; + let mut out = serde_json::Map::new(); + out.insert("ok".into(), json!(true)); + out.insert("mib".into(), json!(mib)); + out.insert("default".into(), json!(serve::ENGINE_CACHE_RAM_DEFAULT)); + out.insert("why".into(), json!(why)); + if on || !swhy.is_empty() { + out.insert( + "side".into(), + json!({ "on": on, "vramMiB": extra, "why": swhy }), + ); + } + send_json(StatusCode::OK, &Value::Object(out)) + } + "/api/preset/moe" => { + let b = match read_body(body, 1 << 20).await { + Ok(b) => b, + Err(e) => return fail(StatusCode::BAD_REQUEST, e), + }; + let d = gojson::decode_struct(&b, CONTENT_REQ_TYPE, &[("content", Kind::Str)]); + if let Some(e) = d.err.clone() { + return fail(StatusCode::BAD_REQUEST, e); + } + let r = resolver(ctx).await; + let live_bin = get(&store.config().await, "BIN").to_string(); + let content = d.str("content"); + let c2 = content.clone(); + let Ok((notes, load)) = + tokio::task::spawn_blocking(move || moe_preview(&r, &live_bin, &c2)).await + else { + return fail(StatusCode::INTERNAL_SERVER_ERROR, "aperçu interrompu"); + }; + let mut out = serde_json::Map::new(); + out.insert("ok".into(), json!(true)); + out.insert( + "notes".into(), + if notes.is_empty() { + Value::Null + } else { + json!(notes) + }, + ); + out.insert("load".into(), json!(load)); + let cfg = parse_env(&content); + if !serve::is_external(&cfg) + && !serve::cpu_experts( + &crate::models::split_args(get(&cfg, "EXTRA_ARGS")), + &HashMap::new(), + ) + .is_empty() + { + out.insert( + "autoplace".into(), + match auto_placement_preset(&content) { + Ok((_, changes)) => json!({ "ok": true, "changes": changes }), + Err(e) => json!({ "ok": false, "why": e }), + }, + ); + } + send_json(StatusCode::OK, &Value::Object(out)) + } + "/api/preset/autoplace" => { + if parts.method != Method::POST { + let mut r = fail( + StatusCode::METHOD_NOT_ALLOWED, + format!("méthode {} refusée : POST attendu", parts.method), + ); + r.headers_mut().insert( + hyper::header::ALLOW, + hyper::header::HeaderValue::from_static("POST"), + ); + return r; + } + let b = match read_body(body, 1 << 20).await { + Ok(b) => b, + Err(e) => return fail(StatusCode::BAD_REQUEST, e), + }; + let d = gojson::decode_struct( + &b, + AUTOPLACE_REQ_TYPE, + &[ + ("id", Kind::Str), + ("name", Kind::Str), + ("content", Kind::Str), + ], + ); + if let Some(e) = d.err.clone() { + return fail(StatusCode::BAD_REQUEST, e); + } + let content = d.str("content"); + let (out, changes) = match auto_placement_preset(&content) { + Ok(x) => x, + Err(e) => return send_json(StatusCode::OK, &json!({ "ok": false, "error": e })), + }; + let shown = preset_display_name(&content, &d.str("id")); + let name = format!( + "{} (placement auto)", + serve_first(&[trim(&d.str("name")), &shown, "preset"]) + ); + match save_preset(&home, "", &name, &out) { + Ok(id) => send_json( + StatusCode::OK, + &json!({ "ok": true, "id": id, "name": name, "changes": changes }), + ), + Err(e) => send_json(StatusCode::OK, &json!({ "ok": false, "error": e })), + } + } + "/api/preset/delete" => { + let b = read_body(body, 8 << 20).await.unwrap_or_default(); + let d = gojson::decode_struct(&b, "presetSaveReq", PRESET_SAVE_REQ); + if let Some(e) = d.err.clone() { + return fail(StatusCode::BAD_REQUEST, e); + } + let id = d.str("id"); + let mut model = String::new(); + if d.bool("deleteModel") + && let Ok(c) = read_preset(&home, &id) + { + model = model_from_preset_content(&c); + } + if let Err(e) = delete_preset(ctx, &id).await { + return fail(StatusCode::BAD_REQUEST, e); + } + let (mut model_deleted, mut model_err, mut freed) = + (String::new(), String::new(), 0i64); + if d.bool("deleteModel") && !model.is_empty() { + let dirs = crate::models::model_dirs(ctx).await; + let lv = live(store).await; + let m2 = model.clone(); + let res = tokio::task::spawn_blocking(move || { + let refs = crate::models::build_refs(&dirs, &lv.cfg, &lv.order); + let (others, _) = refs.users(&dirs, &m2); + (others, crate::models::delete_model_file(&dirs, &refs, &m2)) + }) + .await; + if let Ok((others, r)) = res { + match r { + Err(e) => model_err = e, + Ok(n) => { + model_deleted = model; + freed = n; + if !others.is_empty() { + model_err = format!( + "supprimé, mais il était aussi référencé par : {}", + others.join(", ") + ); + } + } + } + } + } + send_json( + StatusCode::OK, + &json!({ "ok": true, "modelDeleted": model_deleted, "modelError": model_err, "freed": freed }), + ) + } + "/api/preset/external" => { + let id = q("id"); + if id.is_empty() { + return send_json( + StatusCode::OK, + &json!({ "id": "", "name": "", "url": "", "model": "", "hasKey": false }), + ); + } + let Ok(content) = read_preset(&home, &id) else { + return send_json(StatusCode::NOT_FOUND, &json!({ "error": "not found" })); + }; + let cfg = parse_env(&content); + if !serve::is_external(&cfg) { + return send_json( + StatusCode::BAD_REQUEST, + &json!({ "error": "pas un preset externe" }), + ); + } + send_json( + StatusCode::OK, + &json!({ + "id": id, + "name": preset_display_name(&content, &id), + "url": trim(get(&cfg, "EXTERNAL_URL")), + "model": trim(get(&cfg, "EXTERNAL_MODEL")), + "ctx": trim(get(&cfg, "CTX")), + "hasKey": !trim(get(&cfg, "EXTERNAL_KEY")).is_empty(), + "vision": trim(get(&cfg, "EXTERNAL_VISION")) == "1", + }), + ) + } + "/api/preset/external/save" => { + let b = read_body(body, 8 << 20).await.unwrap_or_default(); + let d = gojson::decode_struct(&b, "externalSaveReq", EXTERNAL_SAVE_REQ); + if let Some(e) = d.err.clone() { + return fail(StatusCode::BAD_REQUEST, e); + } + let name = trim(&d.str("name")).to_string(); + let url = trim(&d.str("url")).to_string(); + let model = trim(&d.str("model")).to_string(); + if name.is_empty() { + return fail(StatusCode::BAD_REQUEST, "nom requis"); + } + if url.is_empty() { + return fail(StatusCode::BAD_REQUEST, "URL requise"); + } + if model.is_empty() { + return fail(StatusCode::BAD_REQUEST, "modèle requis"); + } + let id = d.str("id"); + let mut key = d.str("key"); + if !id.is_empty() + && !d.bool("keyTouched") + && let Ok(c) = read_preset(&home, &id) + { + key = trim(get(&parse_env(&c), "EXTERNAL_KEY")).to_string(); + } + let content = + external_preset_content(&url, &model, &key, &d.str("ctx"), d.bool("vision")); + match save_applying(ctx, &id, &name, &content, "apply").await { + Ok((new_id, applied)) => send_json( + StatusCode::OK, + &json!({ "ok": true, "id": new_id, "name": name, "applied": applied }), + ), + Err(e) => fail(StatusCode::BAD_REQUEST, e), + } + } + "/api/preset/external/test" => { + let b = read_body(body, 8 << 20).await.unwrap_or_default(); + let d = gojson::decode_struct(&b, "externalSaveReq", EXTERNAL_SAVE_REQ); + if let Some(e) = d.err.clone() { + return fail(StatusCode::BAD_REQUEST, e); + } + let url = completions_url(&d.str("url")); + let model = trim(&d.str("model")).to_string(); + if url.is_empty() || model.is_empty() { + return send_json( + StatusCode::OK, + &json!({ "ok": false, "error": "URL et modèle requis" }), + ); + } + let mut key = trim(&d.str("key")).to_string(); + let id = d.str("id"); + if key.is_empty() + && !id.is_empty() + && !d.bool("keyTouched") + && let Ok(c) = read_preset(&home, &id) + { + key = trim(get(&parse_env(&c), "EXTERNAL_KEY")).to_string(); + } + send_json(StatusCode::OK, &external_test(&url, &model, &key).await) + } + "/api/switch" => { + if let Some(r) = tune_deny(store).await { + return r; + } + let b = read_body(body, 8 << 20).await.unwrap_or_default(); + let d = + gojson::decode_struct(&b, SWITCH_REQ_TYPE, &[("n", Kind::Int), ("id", Kind::Str)]); + if let Some(e) = d.err.clone() { + return fail(StatusCode::BAD_REQUEST, e); + } + let lv = live(store).await; + let list = match list_presets(&home, &lv.cfg, &lv.order) { + Ok(l) => l, + Err(e) => return fail(StatusCode::INTERNAL_SERVER_ERROR, e), + }; + let id = trim(&d.str("id")).to_string(); + let target = if !id.is_empty() { + match list.into_iter().find(|p| p.id == id) { + Some(p) => p, + None => { + return fail( + StatusCode::NOT_FOUND, + "preset introuvable (supprimé ou renommé ?)", + ); + } + } + } else { + let n = d.int("n"); + if n < 1 || n as usize > list.len() { + return fail(StatusCode::BAD_REQUEST, "index hors limites"); + } + list.into_iter().nth(n as usize - 1).expect("borné") + }; + if let Err(e) = preset_apply(store, &target.path, "switch").await { + return fail(StatusCode::INTERNAL_SERVER_ERROR, e); + } + send_json( + StatusCode::OK, + &json!({ "ok": true, "preset": target.name }), + ) + } + _ => fail(StatusCode::NOT_FOUND, "route inconnue"), + } +} + +/// `DeletePreset` : refusé pour le preset actif ; emporte son prompt système +/// et sa mesure. +async fn delete_preset(ctx: &Ctx, id: &str) -> Result<(), String> { + let home = &ctx.engine.home; + let p = safe_preset_path(home, id)?; + let target = std::fs::read(&p).map_err(|_| "introuvable".to_string())?; + if preset_fingerprint(&String::from_utf8_lossy(&target)) + == config_fingerprint(&ctx.store.config().await) + { + return Err("preset actif, switche d'abord".into()); + } + std::fs::remove_file(&p) + .map_err(|e| crate::paths::os_err("remove", &p.to_string_lossy(), &e))?; + let _ = ctx + .store + .del(BK_STATE, &format!("{SYSPROMPT_PREFIX}{id}")) + .await; + if let Ok(Some(b)) = ctx.store.get(BK_STATE, BENCH_KEY).await + && let Ok(mut m) = serde_json::from_slice::>(&b) + && m.remove(id).is_some() + { + let _ = ctx + .store + .put(BK_STATE, BENCH_KEY, gojson::marshal(&m).into_bytes()) + .await; + } + Ok(()) +} + +/// `handlePresetExternalTest` : un appel de complétion minimal. +pub async fn external_test(url: &str, model: &str, key: &str) -> Value { + crate::chat::engine::install_crypto(); + let client = reqwest::Client::builder() + .timeout(std::time::Duration::from_secs(20)) + .build() + .unwrap_or_default(); + let payload = json!({ + "max_tokens": 1, + "messages": [{ "content": "ping", "role": "user" }], + "model": model, + "stream": false, + }); + let mut rq = client + .post(url) + .header("Content-Type", "application/json") + .body(serde_json::to_vec(&payload).unwrap_or_default()); + if !key.is_empty() { + rq = rq.header("Authorization", format!("Bearer {key}")); + } + let resp = match rq.send().await { + Ok(r) => r, + Err(e) => return json!({ "ok": false, "error": format!("injoignable : {e}") }), + }; + let status = resp.status(); + let mut body = Vec::new(); + let mut stream = resp; + while body.len() < 2000 { + match stream.chunk().await { + Ok(Some(c)) => body.extend_from_slice(&c), + _ => break, + } + } + body.truncate(2000); + if status != reqwest::StatusCode::OK { + let mut msg = trim(&String::from_utf8_lossy(&body)).to_string(); + let m = extract_api_error(&body); + if !m.is_empty() { + msg = m; + } + if msg.is_empty() { + msg = format!( + "{} {}", + status.as_u16(), + status.canonical_reason().unwrap_or("") + ); + } + return json!({ "ok": false, "status": status.as_u16(), "error": msg }); + } + json!({ "ok": true }) +} diff --git a/rust/loki-rs/src/proxy.rs b/rust/loki-rs/src/proxy.rs index 71e77f4..dde5fee 100644 --- a/rust/loki-rs/src/proxy.rs +++ b/rust/loki-rs/src/proxy.rs @@ -77,6 +77,8 @@ pub struct Config { pub push: Option>, /// Mémoire (pages, trackers, mode) tenue par la façade (`LOKI_MEM=rust`). pub mem: Option>, + /// Presets tenus par la façade (`LOKI_PRESETS=rust`). + pub presets: bool, } struct Proxy { @@ -110,6 +112,7 @@ pub async fn serve( files: cfg.files, push: cfg.push, mem: cfg.mem, + presets: cfg.presets, internal_token: cfg.internal_token.clone(), }, }); @@ -197,6 +200,9 @@ impl Proxy { { return chatapi::serve(&self.ctx, chat, req, from_go).await; } + if self.ctx.presets && crate::presets_api::handles(path) { + return crate::presets_api::serve(&self.ctx, req, from_go).await; + } if let Some(m) = &self.ctx.mem && crate::memory::handles(path) { diff --git a/rust/loki-rs/src/serve/run.rs b/rust/loki-rs/src/serve/run.rs index 800f95c..ed73ac3 100644 --- a/rust/loki-rs/src/serve/run.rs +++ b/rust/loki-rs/src/serve/run.rs @@ -384,7 +384,7 @@ fn probe_serve_gpus(env: &Env, cfg: &Cfg, extra: &[String], si: &mut SysInfo) { } /// `probeCacheRAM`. -fn probe_cache_ram(env: &Env, cfg: &Cfg, extra: &[String], si: &mut SysInfo) { +pub(crate) fn probe_cache_ram(env: &Env, cfg: &Cfg, extra: &[String], si: &mut SysInfo) { probe_env(env, si, CACHE_ARG_ENV, true); if !si.model.is_empty() { let mp = Path::new(&si.model); diff --git a/rust/loki-rs/tests/common/fakego.rs b/rust/loki-rs/tests/common/fakego.rs index 6f133d4..cc13f81 100644 --- a/rust/loki-rs/tests/common/fakego.rs +++ b/rust/loki-rs/tests/common/fakego.rs @@ -74,6 +74,10 @@ pub struct FakeGo { pub turn_view: Mutex>, pub turn_verifier: Mutex>, pub tool_results: Mutex>, + /// /internal/v1/tune/guard : message d'une optimisation en cours. + pub tune_busy: Mutex>, + /// /internal/v1/preset/apply reçus : (chemin, mode). + pub preset_applies: Mutex>, /// Comportement de /internal/v1/crypt/* (préfixe « SCELLE: » par défaut). pub crypt_mode: Mutex, /// Réponse de /internal/v1/conv/workspace (None : 404). @@ -325,6 +329,24 @@ async fn handle(st: Arc, req: Request) -> Response *st.go_busy.lock().unwrap() = None; resp(204, "") } + "/internal/v1/tune/guard" => match st.tune_busy.lock().unwrap().clone() { + Some(e) => resp( + 200, + serde_json::json!({ "busy": true, "error": e }).to_string(), + ), + None => resp(200, r#"{"busy":false}"#), + }, + "/internal/v1/preset/apply" => { + let body = req.into_body().collect().await.unwrap().to_bytes(); + let v: serde_json::Value = serde_json::from_slice(&body).unwrap_or_default(); + let path = v["path"].as_str().unwrap_or("").to_string(); + let mode = v["mode"].as_str().unwrap_or("").to_string(); + if mode == "refus" || path.ends_with("refus.env") { + return resp(500, r#"{"error":"application refusée"}"#); + } + st.preset_applies.lock().unwrap().push((path, mode)); + resp(204, "") + } "/internal/v1/conv/workspace" => match st.conv_workspace.lock().unwrap().clone() { Some(v) => resp(200, v.to_string()), None => resp(404, "404 page not found"), @@ -507,6 +529,7 @@ pub struct Opts { pub files: Option>, pub push: Option>, pub mem: Option>, + pub presets: bool, } /// Comme [`start`], avec la conversation tenue par la façade @@ -602,6 +625,7 @@ pub async fn start_with( files: opts.files, push: opts.push, mem: opts.mem, + presets: opts.presets, }; tokio::spawn(loki_rs::proxy::serve(fl, cfg, async move { let _ = rx.await; diff --git a/rust/loki-rs/tests/lot2.rs b/rust/loki-rs/tests/lot2.rs index 166e75e..5c8b14b 100644 --- a/rust/loki-rs/tests/lot2.rs +++ b/rust/loki-rs/tests/lot2.rs @@ -100,6 +100,7 @@ async fn banc(cle: Option<&str>) -> Banc { files: None, push: None, mem: None, + presets: false, }; tokio::spawn(loki_rs::proxy::serve(l, cfg, async move { let _ = rx.await; diff --git a/rust/loki-rs/tests/parity_presets/presets.json b/rust/loki-rs/tests/parity_presets/presets.json new file mode 100644 index 0000000..b62b1cb --- /dev/null +++ b/rust/loki-rs/tests/parity_presets/presets.json @@ -0,0 +1,2206 @@ +{ + "display_name": [ + { + "content": "MODEL=a.gguf\n", + "name": "Alpha", + "out": "# NAME=Alpha\nMODEL=a.gguf\n" + }, + { + "content": "# NAME=Vieux\nMODEL=a\n", + "name": "Neuf", + "out": "# NAME=Neuf\nMODEL=a\n" + }, + { + "content": " name = x\nCTX=1\n", + "name": "Y", + "out": "# NAME=Y\nCTX=1\n" + }, + { + "content": "#NAME=a\n# name=b\n", + "name": "Deux", + "out": "# NAME=Deux\n# NAME=Deux\n" + }, + { + "content": "MODEL=a\n# NAME=x", + "name": "Prix $1 et ${x}", + "out": "MODEL=a\n# NAME=Prix et " + }, + { + "content": "", + "name": "Vide", + "out": "# NAME=Vide\n" + } + ], + "quote": { + "": "", + "\"y\"": "\"y\"", + "'x'": "\"'x'\"", + "'z": "'z", + "a\tb": "\"a\tb\"", + "a b": "\"a b\"", + "a\"b": "a\"b", + "simple": "simple" + }, + "format_env": "A=\"deux mots\"\nM=\nQ=q\"q\nZ=1\n", + "names": { + "": "nom vide", + " ": "nom vide", + " pad ": "ok", + ".": "nom réservé", + "..": "nom réservé", + "Qwen 3 (27B) é": "ok", + "a\u0000b": "le nom ne peut pas contenir / ni \\", + "a\u0001b": "le nom contient un caractère de contrôle invalide", + "a/b": "le nom ne peut pas contenir / ni \\", + "a\\b": "le nom ne peut pas contenir / ni \\" + }, + "quant": { + "": "", + "# QUANT=iq3_xs\nMODEL=x-Q8_0.gguf\n": "IQ3_XS", + "MODEL = C:\\models\\w-BF16.gguf\n": "BF16", + "MODEL=\"/m/Llama-3-UD-Q6_K_XL-00001-of-00002.gguf\"\nREASONING=On\n": "Q6_K_XL", + "MODEL=Qwen3-27B-Q4_K_M.gguf\n": "Q4_K_M", + "QUANT='q5'\n": "Q5", + "export MODEL=z.gguf\n": "", + "reasoning = 'off'\n#REASONING=on\n": "" + }, + "reasoning": { + "": "", + "# QUANT=iq3_xs\nMODEL=x-Q8_0.gguf\n": "", + "MODEL = C:\\models\\w-BF16.gguf\n": "", + "MODEL=\"/m/Llama-3-UD-Q6_K_XL-00001-of-00002.gguf\"\nREASONING=On\n": "On", + "MODEL=Qwen3-27B-Q4_K_M.gguf\n": "", + "QUANT='q5'\n": "", + "export MODEL=z.gguf\n": "", + "reasoning = 'off'\n#REASONING=on\n": "off" + }, + "model": { + "": "", + "# QUANT=iq3_xs\nMODEL=x-Q8_0.gguf\n": "x-Q8_0.gguf", + "MODEL = C:\\models\\w-BF16.gguf\n": "C:\\models\\w-BF16.gguf", + "MODEL=\"/m/Llama-3-UD-Q6_K_XL-00001-of-00002.gguf\"\nREASONING=On\n": "/m/Llama-3-UD-Q6_K_XL-00001-of-00002.gguf", + "MODEL=Qwen3-27B-Q4_K_M.gguf\n": "Qwen3-27B-Q4_K_M.gguf", + "QUANT='q5'\n": "", + "export MODEL=z.gguf\n": "", + "reasoning = 'off'\n#REASONING=on\n": "" + }, + "completions": { + "": "", + " https://api.groq.com/openai/v1/ ": "https://api.groq.com/openai/v1/chat/completions", + "http://loki:8090": "http://loki:8090/v1/chat/completions", + "http://x/chat/completions": "http://x/chat/completions", + "https://o.ai/api/v1": "https://o.ai/api/v1/chat/completions" + }, + "external": [ + { + "ctx": "", + "key": "", + "model": "gpt", + "out": "EXTERNAL=1\nEXTERNAL_MODEL=gpt\nEXTERNAL_URL=https://a/v1\n", + "url": "https://a/v1", + "vision": false + }, + { + "ctx": " 128000 ", + "key": " k ", + "model": " m ", + "out": "CTX=128000\nEXTERNAL=1\nEXTERNAL_KEY=k\nEXTERNAL_MODEL=m\nEXTERNAL_URL=u\nEXTERNAL_VISION=1\n", + "url": " u ", + "vision": true + } + ], + "api_error": { + "pas du json": "", + "{\"error\":\"x\"}": "", + "{\"error\":{\"message\":\" clé invalide \"}}": "clé invalide", + "{}": "" + }, + "join": [ + { + "args": [ + "-ngl", + "30" + ], + "out": "-ngl 30" + }, + { + "args": [ + "--chat-template-file", + "/a b/t.jinja" + ], + "out": "--chat-template-file '/a b/t.jinja'" + }, + { + "args": [ + "x", + "l'apostrophe ici" + ], + "out": "x \"l'apostrophe ici\"" + }, + { + "args": [ + "" + ], + "out": "\"\"" + } + ], + "set_key": [ + { + "content": "A=1\nB=2\n", + "key": "B", + "out": "A=1\nB=3\n", + "val": "3" + }, + { + "content": "A=1\nexport B=2\nB=4\n", + "key": "B", + "out": "A=1\n", + "val": "" + }, + { + "content": "A=1", + "key": "C", + "out": "A=1\nC=\"deux mots\"\n", + "val": "deux mots" + }, + { + "content": "", + "key": "X", + "out": "X=1\n", + "val": "1" + }, + { + "content": "NGL=999\n# NGL=1\n", + "key": "NGL", + "out": "# NGL=1\n", + "val": "" + }, + { + "content": "K.E=1\nKXE=2\n", + "key": "K.E", + "out": "K.E=v\nKXE=2\n", + "val": "v" + } + ], + "autoplace": [ + { + "changes": null, + "err": "--fit resterait inactif (surcharge de tenseurs) : retire-le à la main d'abord", + "in": "# NAME=MoE\nMODEL=m.gguf\nCTX=65536\nNGL=999\nEXTRA_ARGS=-ot 'exps=CPU,per_layer_token_embd=CPU,blk\\.0=CUDA0' --n-cpu-moe 0 -ub 512 --mlock --fit off -ctk q8_0\n", + "out": "" + }, + { + "changes": [ + "retiré : --n-cpu-moe 30", + "retiré : -ts 1,1", + "UBATCH=2048, BATCH=4096 (avant : 512 / 2048)", + "--load-mode mmap", + "CTX conservé : 32768" + ], + "err": "", + "in": "MODEL=m.gguf\nEXTRA_ARGS=--n-cpu-moe 30 -ts 1,1\n", + "out": "MODEL=m.gguf\nEXTRA_ARGS=\"--load-mode mmap\"\nUBATCH=2048\nBATCH=4096\n" + }, + { + "changes": null, + "err": "--fit resterait inactif (-sm row) : retire-le à la main d'abord", + "in": "MODEL=m.gguf\nEXTRA_ARGS=--cpu-moe -sm row\n", + "out": "" + }, + { + "changes": null, + "err": "contexte « 0 » non chiffré : --fit pourrait le réduire pour tenir — donne un CTX chiffré d'abord", + "in": "MODEL=m.gguf\nEXTRA_ARGS=--cpu-moe\nCTX=0\n", + "out": "" + }, + { + "changes": null, + "err": "SPLIT_MODE=tensor coupe --fit : retire-le d'abord", + "in": "MODEL=m.gguf\nEXTRA_ARGS=--cpu-moe\nSPLIT_MODE=tensor\n", + "out": "" + }, + { + "changes": null, + "err": "rien à migrer : ce preset ne place pas d'experts à la main (-ot …exps…, --n-cpu-moe, --cpu-moe)", + "in": "MODEL=m.gguf\nEXTRA_ARGS=-ngl 40\n", + "out": "" + }, + { + "changes": null, + "err": "preset externe : le modèle tourne ailleurs", + "in": "EXTERNAL=1\n", + "out": "" + }, + { + "changes": [ + "retiré : --cpu-moe", + "retiré : -b 1024", + "UBATCH=2048, BATCH=4096 (avant : 1024 / 2048)", + "--load-mode mmap", + "CTX conservé : 32768", + "cache KV conservé tel quel — garde-le identique pour comparer les deux presets" + ], + "err": "", + "in": "MODEL=m\nEXTRA_ARGS=--cpu-moe --fit=on -b=1024\nKV_TYPE=q4_0\nUBATCH=1024\n", + "out": "MODEL=m\nEXTRA_ARGS=\"--fit on --load-mode mmap\"\nKV_TYPE=q4_0\nUBATCH=2048\nBATCH=4096\n" + } + ], + "bench_fp": [ + { + "cfg": { + "MODEL": "a.gguf" + }, + "cur": {}, + "out": "e3fbbb2b984dad580f7fc2c6085660e1013a2d4d" + }, + { + "cfg": { + "CUDA_VISIBLE_DEVICES": "1", + "MODEL": "a.gguf" + }, + "cur": { + "CUDA_VISIBLE_DEVICES": "0" + }, + "out": "0c52a09078a6809ccde29379f7bf6e01c51a9a11" + }, + { + "cfg": { + "MODEL": "a.gguf" + }, + "cur": { + "CUDA_VISIBLE_DEVICES": " 0,1 " + }, + "out": "63d3caa22a8aadb3db007d11f044263fcfacfcc5" + } + ], + "imposes": { + "": false, + "/app/llama-server": true, + "/opt/custom/llama-server": true, + "{{HOME}}/backends/llama.cpp-prebuilt/llama-b2/llama-server": false, + "{{HOME}}/engine/b1/llama-server": false + }, + "seed": { + "config/BIN": "/opt/custom/llama-server", + "config/HOST": "127.0.0.1", + "config/MODEL": "live.gguf", + "config/PORT": "8081" + }, + "steps": [ + { + "method": "GET", + "path": "/api/presets", + "status": 200, + "out": [], + "disk": {}, + "state": {} + }, + { + "method": "GET", + "path": "/api/preset", + "status": 200, + "out": { + "content": "BIN=/opt/custom/llama-server\nHOST=127.0.0.1\nPORT=8081\n", + "id": "", + "name": "" + }, + "disk": {}, + "state": {} + }, + { + "method": "POST", + "path": "/api/preset/save", + "body": "{\"name\":\"Qwen 27B\",\"content\":\"MODEL=Qwen3-27B-Q4_K_M.gguf\\nCTX=65536\\nREASONING=on\\n\"}", + "status": 200, + "out": { + "applied": false, + "id": "Qwen 27B", + "name": "Qwen 27B", + "ok": true + }, + "disk": { + "presets/Qwen 27B.env": "# NAME=Qwen 27B\nMODEL=Qwen3-27B-Q4_K_M.gguf\nCTX=65536\nREASONING=on\n" + }, + "state": {} + }, + { + "method": "POST", + "path": "/api/preset/save", + "body": "{\"name\":\"Qwen 27B\",\"content\":\"MODEL=Qwen3-27B-Q8_0.gguf\\nMMPROJ=mm.gguf\\n\",\"sysprompt\":\" Réponds en français. \"}", + "status": 200, + "out": { + "applied": false, + "id": "Qwen 27B (2)", + "name": "Qwen 27B", + "ok": true + }, + "disk": { + "presets/Qwen 27B (2).env": "# NAME=Qwen 27B\nMODEL=Qwen3-27B-Q8_0.gguf\nMMPROJ=mm.gguf\n", + "presets/Qwen 27B.env": "# NAME=Qwen 27B\nMODEL=Qwen3-27B-Q4_K_M.gguf\nCTX=65536\nREASONING=on\n" + }, + "state": { + "sysprompt:Qwen 27B (2)": "Réponds en français." + } + }, + { + "method": "POST", + "path": "/api/preset/save", + "body": "{\"name\":\"a/b\\\\c\",\"content\":\"MODEL=x.gguf\\nCTX=40000\\n\"}", + "status": 200, + "out": { + "applied": false, + "id": "a-b-c", + "name": "a/b\\c", + "ok": true + }, + "disk": { + "presets/Qwen 27B (2).env": "# NAME=Qwen 27B\nMODEL=Qwen3-27B-Q8_0.gguf\nMMPROJ=mm.gguf\n", + "presets/Qwen 27B.env": "# NAME=Qwen 27B\nMODEL=Qwen3-27B-Q4_K_M.gguf\nCTX=65536\nREASONING=on\n", + "presets/a-b-c.env": "# NAME=a/b\\c\nMODEL=x.gguf\nCTX=40000\n" + }, + "state": { + "sysprompt:Qwen 27B (2)": "Réponds en français." + } + }, + { + "method": "POST", + "path": "/api/preset/save", + "body": "{\"name\":\" \",\"content\":\"X=1\"}", + "status": 400, + "out": { + "error": "nom requis", + "ok": false + }, + "disk": { + "presets/Qwen 27B (2).env": "# NAME=Qwen 27B\nMODEL=Qwen3-27B-Q8_0.gguf\nMMPROJ=mm.gguf\n", + "presets/Qwen 27B.env": "# NAME=Qwen 27B\nMODEL=Qwen3-27B-Q4_K_M.gguf\nCTX=65536\nREASONING=on\n", + "presets/a-b-c.env": "# NAME=a/b\\c\nMODEL=x.gguf\nCTX=40000\n" + }, + "state": { + "sysprompt:Qwen 27B (2)": "Réponds en français." + } + }, + { + "method": "POST", + "path": "/api/preset/save", + "body": "{\"id\":\"../evil\",\"name\":\"E\",\"content\":\"X=1\"}", + "status": 400, + "out": { + "error": "le nom ne peut pas contenir / ni \\", + "ok": false + }, + "disk": { + "presets/Qwen 27B (2).env": "# NAME=Qwen 27B\nMODEL=Qwen3-27B-Q8_0.gguf\nMMPROJ=mm.gguf\n", + "presets/Qwen 27B.env": "# NAME=Qwen 27B\nMODEL=Qwen3-27B-Q4_K_M.gguf\nCTX=65536\nREASONING=on\n", + "presets/a-b-c.env": "# NAME=a/b\\c\nMODEL=x.gguf\nCTX=40000\n" + }, + "state": { + "sysprompt:Qwen 27B (2)": "Réponds en français." + } + }, + { + "method": "POST", + "path": "/api/preset/save", + "body": "{\"id\":\"Qwen 27B (2)\",\"name\":\"Qwen Q8\",\"content\":\"# NAME=ancien\\nMODEL=Qwen3-27B-Q8_0.gguf\\nQUANT=q8\\n\",\"sysprompt\":\"\"}", + "status": 200, + "out": { + "applied": false, + "id": "Qwen 27B (2)", + "name": "Qwen Q8", + "ok": true + }, + "disk": { + "presets/Qwen 27B (2).env": "# NAME=Qwen Q8\nMODEL=Qwen3-27B-Q8_0.gguf\nQUANT=q8\n", + "presets/Qwen 27B.env": "# NAME=Qwen 27B\nMODEL=Qwen3-27B-Q4_K_M.gguf\nCTX=65536\nREASONING=on\n", + "presets/a-b-c.env": "# NAME=a/b\\c\nMODEL=x.gguf\nCTX=40000\n" + }, + "state": {} + }, + { + "method": "POST", + "path": "/api/preset/save", + "body": "pas du json", + "status": 400, + "out": { + "error": "invalid character 'p' looking for beginning of value", + "ok": false + }, + "disk": { + "presets/Qwen 27B (2).env": "# NAME=Qwen Q8\nMODEL=Qwen3-27B-Q8_0.gguf\nQUANT=q8\n", + "presets/Qwen 27B.env": "# NAME=Qwen 27B\nMODEL=Qwen3-27B-Q4_K_M.gguf\nCTX=65536\nREASONING=on\n", + "presets/a-b-c.env": "# NAME=a/b\\c\nMODEL=x.gguf\nCTX=40000\n" + }, + "state": {} + }, + { + "method": "POST", + "path": "/api/preset/save", + "body": "{\"deleteModel\":\"oui\"}", + "status": 400, + "out": { + "error": "json: cannot unmarshal string into Go struct field presetSaveReq.deleteModel of type bool", + "ok": false + }, + "disk": { + "presets/Qwen 27B (2).env": "# NAME=Qwen Q8\nMODEL=Qwen3-27B-Q8_0.gguf\nQUANT=q8\n", + "presets/Qwen 27B.env": "# NAME=Qwen 27B\nMODEL=Qwen3-27B-Q4_K_M.gguf\nCTX=65536\nREASONING=on\n", + "presets/a-b-c.env": "# NAME=a/b\\c\nMODEL=x.gguf\nCTX=40000\n" + }, + "state": {} + }, + { + "method": "POST", + "path": "/api/preset/save", + "body": "{\"name\":\"Strata\",\"content\":\"ENGINE=strata\\nSTRATA_VISION=0\\nCTX=1048576\\n\"}", + "status": 200, + "out": { + "applied": false, + "id": "Strata", + "name": "Strata", + "ok": true + }, + "disk": { + "presets/Qwen 27B (2).env": "# NAME=Qwen Q8\nMODEL=Qwen3-27B-Q8_0.gguf\nQUANT=q8\n", + "presets/Qwen 27B.env": "# NAME=Qwen 27B\nMODEL=Qwen3-27B-Q4_K_M.gguf\nCTX=65536\nREASONING=on\n", + "presets/Strata.env": "# NAME=Strata\nENGINE=strata\nSTRATA_VISION=0\nCTX=1048576\n", + "presets/a-b-c.env": "# NAME=a/b\\c\nMODEL=x.gguf\nCTX=40000\n" + }, + "state": {} + }, + { + "method": "GET", + "path": "/api/presets", + "status": 200, + "out": [ + { + "active": false, + "ctx": "64K", + "id": "Qwen 27B", + "name": "Qwen 27B", + "quant": "Q4_K_M", + "reasoning": "on" + }, + { + "active": false, + "ctx": "32K", + "id": "Qwen 27B (2)", + "name": "Qwen Q8", + "quant": "Q8" + }, + { + "active": false, + "ctx": "1M", + "id": "Strata", + "name": "Strata", + "strata": true + }, + { + "active": false, + "ctx": "40K", + "id": "a-b-c", + "name": "a/b\\c" + } + ], + "disk": { + "presets/Qwen 27B (2).env": "# NAME=Qwen Q8\nMODEL=Qwen3-27B-Q8_0.gguf\nQUANT=q8\n", + "presets/Qwen 27B.env": "# NAME=Qwen 27B\nMODEL=Qwen3-27B-Q4_K_M.gguf\nCTX=65536\nREASONING=on\n", + "presets/Strata.env": "# NAME=Strata\nENGINE=strata\nSTRATA_VISION=0\nCTX=1048576\n", + "presets/a-b-c.env": "# NAME=a/b\\c\nMODEL=x.gguf\nCTX=40000\n" + }, + "state": {} + }, + { + "method": "GET", + "path": "/api/preset?id=Qwen%2027B", + "status": 200, + "out": { + "content": "# NAME=Qwen 27B\nMODEL=Qwen3-27B-Q4_K_M.gguf\nCTX=65536\nREASONING=on\n", + "id": "Qwen 27B", + "name": "Qwen 27B", + "sysprompt": "" + }, + "disk": { + "presets/Qwen 27B (2).env": "# NAME=Qwen Q8\nMODEL=Qwen3-27B-Q8_0.gguf\nQUANT=q8\n", + "presets/Qwen 27B.env": "# NAME=Qwen 27B\nMODEL=Qwen3-27B-Q4_K_M.gguf\nCTX=65536\nREASONING=on\n", + "presets/Strata.env": "# NAME=Strata\nENGINE=strata\nSTRATA_VISION=0\nCTX=1048576\n", + "presets/a-b-c.env": "# NAME=a/b\\c\nMODEL=x.gguf\nCTX=40000\n" + }, + "state": {} + }, + { + "method": "GET", + "path": "/api/preset?id=Qwen%2027B%20(2)", + "status": 200, + "out": { + "content": "# NAME=Qwen Q8\nMODEL=Qwen3-27B-Q8_0.gguf\nQUANT=q8\n", + "id": "Qwen 27B (2)", + "name": "Qwen Q8", + "sysprompt": "" + }, + "disk": { + "presets/Qwen 27B (2).env": "# NAME=Qwen Q8\nMODEL=Qwen3-27B-Q8_0.gguf\nQUANT=q8\n", + "presets/Qwen 27B.env": "# NAME=Qwen 27B\nMODEL=Qwen3-27B-Q4_K_M.gguf\nCTX=65536\nREASONING=on\n", + "presets/Strata.env": "# NAME=Strata\nENGINE=strata\nSTRATA_VISION=0\nCTX=1048576\n", + "presets/a-b-c.env": "# NAME=a/b\\c\nMODEL=x.gguf\nCTX=40000\n" + }, + "state": {} + }, + { + "method": "GET", + "path": "/api/preset?id=absent", + "status": 404, + "out": { + "error": "not found" + }, + "disk": { + "presets/Qwen 27B (2).env": "# NAME=Qwen Q8\nMODEL=Qwen3-27B-Q8_0.gguf\nQUANT=q8\n", + "presets/Qwen 27B.env": "# NAME=Qwen 27B\nMODEL=Qwen3-27B-Q4_K_M.gguf\nCTX=65536\nREASONING=on\n", + "presets/Strata.env": "# NAME=Strata\nENGINE=strata\nSTRATA_VISION=0\nCTX=1048576\n", + "presets/a-b-c.env": "# NAME=a/b\\c\nMODEL=x.gguf\nCTX=40000\n" + }, + "state": {} + }, + { + "method": "GET", + "path": "/api/preset?id=../x", + "status": 404, + "out": { + "error": "not found" + }, + "disk": { + "presets/Qwen 27B (2).env": "# NAME=Qwen Q8\nMODEL=Qwen3-27B-Q8_0.gguf\nQUANT=q8\n", + "presets/Qwen 27B.env": "# NAME=Qwen 27B\nMODEL=Qwen3-27B-Q4_K_M.gguf\nCTX=65536\nREASONING=on\n", + "presets/Strata.env": "# NAME=Strata\nENGINE=strata\nSTRATA_VISION=0\nCTX=1048576\n", + "presets/a-b-c.env": "# NAME=a/b\\c\nMODEL=x.gguf\nCTX=40000\n" + }, + "state": {} + }, + { + "method": "POST", + "path": "/api/presets/order", + "body": "{\"ids\":[\"Strata\",\"Qwen 27B (2)\"]}", + "status": 200, + "out": { + "ok": true + }, + "disk": { + "presets/Qwen 27B (2).env": "# NAME=Qwen Q8\nMODEL=Qwen3-27B-Q8_0.gguf\nQUANT=q8\n", + "presets/Qwen 27B.env": "# NAME=Qwen 27B\nMODEL=Qwen3-27B-Q4_K_M.gguf\nCTX=65536\nREASONING=on\n", + "presets/Strata.env": "# NAME=Strata\nENGINE=strata\nSTRATA_VISION=0\nCTX=1048576\n", + "presets/a-b-c.env": "# NAME=a/b\\c\nMODEL=x.gguf\nCTX=40000\n" + }, + "state": { + "preset_order": [ + "Strata", + "Qwen 27B (2)" + ] + } + }, + { + "method": "GET", + "path": "/api/presets", + "status": 200, + "out": [ + { + "active": false, + "ctx": "1M", + "id": "Strata", + "name": "Strata", + "strata": true + }, + { + "active": false, + "ctx": "32K", + "id": "Qwen 27B (2)", + "name": "Qwen Q8", + "quant": "Q8" + }, + { + "active": false, + "ctx": "64K", + "id": "Qwen 27B", + "name": "Qwen 27B", + "quant": "Q4_K_M", + "reasoning": "on" + }, + { + "active": false, + "ctx": "40K", + "id": "a-b-c", + "name": "a/b\\c" + } + ], + "disk": { + "presets/Qwen 27B (2).env": "# NAME=Qwen Q8\nMODEL=Qwen3-27B-Q8_0.gguf\nQUANT=q8\n", + "presets/Qwen 27B.env": "# NAME=Qwen 27B\nMODEL=Qwen3-27B-Q4_K_M.gguf\nCTX=65536\nREASONING=on\n", + "presets/Strata.env": "# NAME=Strata\nENGINE=strata\nSTRATA_VISION=0\nCTX=1048576\n", + "presets/a-b-c.env": "# NAME=a/b\\c\nMODEL=x.gguf\nCTX=40000\n" + }, + "state": { + "preset_order": [ + "Strata", + "Qwen 27B (2)" + ] + } + }, + { + "method": "POST", + "path": "/api/presets/order", + "body": "{}", + "status": 200, + "out": { + "ok": true + }, + "disk": { + "presets/Qwen 27B (2).env": "# NAME=Qwen Q8\nMODEL=Qwen3-27B-Q8_0.gguf\nQUANT=q8\n", + "presets/Qwen 27B.env": "# NAME=Qwen 27B\nMODEL=Qwen3-27B-Q4_K_M.gguf\nCTX=65536\nREASONING=on\n", + "presets/Strata.env": "# NAME=Strata\nENGINE=strata\nSTRATA_VISION=0\nCTX=1048576\n", + "presets/a-b-c.env": "# NAME=a/b\\c\nMODEL=x.gguf\nCTX=40000\n" + }, + "state": { + "preset_order": null + } + }, + { + "method": "GET", + "path": "/api/presets/order", + "status": 405, + "out": { + "error": "POST attendu", + "ok": false + }, + "disk": { + "presets/Qwen 27B (2).env": "# NAME=Qwen Q8\nMODEL=Qwen3-27B-Q8_0.gguf\nQUANT=q8\n", + "presets/Qwen 27B.env": "# NAME=Qwen 27B\nMODEL=Qwen3-27B-Q4_K_M.gguf\nCTX=65536\nREASONING=on\n", + "presets/Strata.env": "# NAME=Strata\nENGINE=strata\nSTRATA_VISION=0\nCTX=1048576\n", + "presets/a-b-c.env": "# NAME=a/b\\c\nMODEL=x.gguf\nCTX=40000\n" + }, + "state": { + "preset_order": null + } + }, + { + "method": "POST", + "path": "/api/presets/order", + "body": "{\"ids\":\"x\"}", + "status": 400, + "out": { + "error": "json: cannot unmarshal string into Go struct field .ids of type []string", + "ok": false + }, + "disk": { + "presets/Qwen 27B (2).env": "# NAME=Qwen Q8\nMODEL=Qwen3-27B-Q8_0.gguf\nQUANT=q8\n", + "presets/Qwen 27B.env": "# NAME=Qwen 27B\nMODEL=Qwen3-27B-Q4_K_M.gguf\nCTX=65536\nREASONING=on\n", + "presets/Strata.env": "# NAME=Strata\nENGINE=strata\nSTRATA_VISION=0\nCTX=1048576\n", + "presets/a-b-c.env": "# NAME=a/b\\c\nMODEL=x.gguf\nCTX=40000\n" + }, + "state": { + "preset_order": null + } + }, + { + "method": "POST", + "path": "/api/presets/order", + "body": "{\"ids\":[\"a-b-c\"]}", + "status": 200, + "out": { + "ok": true + }, + "disk": { + "presets/Qwen 27B (2).env": "# NAME=Qwen Q8\nMODEL=Qwen3-27B-Q8_0.gguf\nQUANT=q8\n", + "presets/Qwen 27B.env": "# NAME=Qwen 27B\nMODEL=Qwen3-27B-Q4_K_M.gguf\nCTX=65536\nREASONING=on\n", + "presets/Strata.env": "# NAME=Strata\nENGINE=strata\nSTRATA_VISION=0\nCTX=1048576\n", + "presets/a-b-c.env": "# NAME=a/b\\c\nMODEL=x.gguf\nCTX=40000\n" + }, + "state": { + "preset_order": [ + "a-b-c" + ] + } + }, + { + "store": { + "state/bench_presets": "{\"Qwen 27B\":{\"result\":{\"prompt_per_second\":1234.5,\"predicted_per_second\":40,\"kv\":\"q8_0\",\"kv_quantized\":true,\"depth\":{\"cold\":{\"new\":30000},\"decode_per_second\":31.25}},\"model\":\"Qwen3-27B-Q4_K_M.gguf\",\"at\":1700000000},\"Qwen 27B (2)\":{\"result\":{\"prompt_per_second\":900,\"predicted_per_second\":20.5},\"model\":\"autre.gguf\",\"at\":1700000001},\"a-b-c\":{\"result\":{\"prompt_per_second\":1,\"predicted_per_second\":2},\"model\":\"x.gguf\",\"at\":1,\"fingerprint\":\"mauvaise\"}}" + }, + "method": "GET", + "path": "/api/presets", + "status": 200, + "out": [ + { + "active": false, + "ctx": "40K", + "id": "a-b-c", + "name": "a/b\\c" + }, + { + "active": false, + "bench": { + "at": 1700000000, + "decode": 40, + "depth": 30000, + "depth_decode": 31.25, + "kv": "q8_0", + "kv_quantized": true, + "prefill": 1234.5 + }, + "ctx": "64K", + "id": "Qwen 27B", + "name": "Qwen 27B", + "quant": "Q4_K_M", + "reasoning": "on" + }, + { + "active": false, + "ctx": "32K", + "id": "Qwen 27B (2)", + "name": "Qwen Q8", + "quant": "Q8" + }, + { + "active": false, + "ctx": "1M", + "id": "Strata", + "name": "Strata", + "strata": true + } + ], + "disk": { + "presets/Qwen 27B (2).env": "# NAME=Qwen Q8\nMODEL=Qwen3-27B-Q8_0.gguf\nQUANT=q8\n", + "presets/Qwen 27B.env": "# NAME=Qwen 27B\nMODEL=Qwen3-27B-Q4_K_M.gguf\nCTX=65536\nREASONING=on\n", + "presets/Strata.env": "# NAME=Strata\nENGINE=strata\nSTRATA_VISION=0\nCTX=1048576\n", + "presets/a-b-c.env": "# NAME=a/b\\c\nMODEL=x.gguf\nCTX=40000\n" + }, + "state": { + "bench_presets": { + "Qwen 27B": { + "at": 1700000000, + "model": "Qwen3-27B-Q4_K_M.gguf", + "result": { + "depth": { + "cold": { + "new": 30000 + }, + "decode_per_second": 31.25 + }, + "kv": "q8_0", + "kv_quantized": true, + "predicted_per_second": 40, + "prompt_per_second": 1234.5 + } + }, + "Qwen 27B (2)": { + "at": 1700000001, + "model": "autre.gguf", + "result": { + "predicted_per_second": 20.5, + "prompt_per_second": 900 + } + }, + "a-b-c": { + "at": 1, + "fingerprint": "mauvaise", + "model": "x.gguf", + "result": { + "predicted_per_second": 2, + "prompt_per_second": 1 + } + } + }, + "preset_order": [ + "a-b-c" + ] + } + }, + { + "store": { + "config/BIN": "", + "config/CTX": "40000", + "config/HOST": "", + "config/MEM_MODE": "always", + "config/MODEL": "x.gguf", + "config/PORT": "" + }, + "method": "GET", + "path": "/api/presets", + "status": 200, + "out": [ + { + "active": true, + "ctx": "40K", + "id": "a-b-c", + "name": "a/b\\c" + }, + { + "active": false, + "bench": { + "at": 1700000000, + "decode": 40, + "depth": 30000, + "depth_decode": 31.25, + "kv": "q8_0", + "kv_quantized": true, + "prefill": 1234.5 + }, + "ctx": "64K", + "id": "Qwen 27B", + "name": "Qwen 27B", + "quant": "Q4_K_M", + "reasoning": "on" + }, + { + "active": false, + "ctx": "32K", + "id": "Qwen 27B (2)", + "name": "Qwen Q8", + "quant": "Q8" + }, + { + "active": false, + "ctx": "1M", + "id": "Strata", + "name": "Strata", + "strata": true + } + ], + "disk": { + "presets/Qwen 27B (2).env": "# NAME=Qwen Q8\nMODEL=Qwen3-27B-Q8_0.gguf\nQUANT=q8\n", + "presets/Qwen 27B.env": "# NAME=Qwen 27B\nMODEL=Qwen3-27B-Q4_K_M.gguf\nCTX=65536\nREASONING=on\n", + "presets/Strata.env": "# NAME=Strata\nENGINE=strata\nSTRATA_VISION=0\nCTX=1048576\n", + "presets/a-b-c.env": "# NAME=a/b\\c\nMODEL=x.gguf\nCTX=40000\n" + }, + "state": { + "bench_presets": { + "Qwen 27B": { + "at": 1700000000, + "model": "Qwen3-27B-Q4_K_M.gguf", + "result": { + "depth": { + "cold": { + "new": 30000 + }, + "decode_per_second": 31.25 + }, + "kv": "q8_0", + "kv_quantized": true, + "predicted_per_second": 40, + "prompt_per_second": 1234.5 + } + }, + "Qwen 27B (2)": { + "at": 1700000001, + "model": "autre.gguf", + "result": { + "predicted_per_second": 20.5, + "prompt_per_second": 900 + } + }, + "a-b-c": { + "at": 1, + "fingerprint": "mauvaise", + "model": "x.gguf", + "result": { + "predicted_per_second": 2, + "prompt_per_second": 1 + } + } + }, + "preset_order": [ + "a-b-c" + ] + } + }, + { + "method": "POST", + "path": "/api/preset/delete", + "body": "{\"id\":\"a-b-c\"}", + "status": 400, + "out": { + "error": "preset actif, switche d'abord", + "ok": false + }, + "disk": { + "presets/Qwen 27B (2).env": "# NAME=Qwen Q8\nMODEL=Qwen3-27B-Q8_0.gguf\nQUANT=q8\n", + "presets/Qwen 27B.env": "# NAME=Qwen 27B\nMODEL=Qwen3-27B-Q4_K_M.gguf\nCTX=65536\nREASONING=on\n", + "presets/Strata.env": "# NAME=Strata\nENGINE=strata\nSTRATA_VISION=0\nCTX=1048576\n", + "presets/a-b-c.env": "# NAME=a/b\\c\nMODEL=x.gguf\nCTX=40000\n" + }, + "state": { + "bench_presets": { + "Qwen 27B": { + "at": 1700000000, + "model": "Qwen3-27B-Q4_K_M.gguf", + "result": { + "depth": { + "cold": { + "new": 30000 + }, + "decode_per_second": 31.25 + }, + "kv": "q8_0", + "kv_quantized": true, + "predicted_per_second": 40, + "prompt_per_second": 1234.5 + } + }, + "Qwen 27B (2)": { + "at": 1700000001, + "model": "autre.gguf", + "result": { + "predicted_per_second": 20.5, + "prompt_per_second": 900 + } + }, + "a-b-c": { + "at": 1, + "fingerprint": "mauvaise", + "model": "x.gguf", + "result": { + "predicted_per_second": 2, + "prompt_per_second": 1 + } + } + }, + "preset_order": [ + "a-b-c" + ] + } + }, + { + "store": { + "config/MODEL": "autre.gguf" + }, + "method": "POST", + "path": "/api/preset/delete", + "body": "{\"id\":\"a-b-c\"}", + "status": 200, + "out": { + "freed": 0, + "modelDeleted": "", + "modelError": "", + "ok": true + }, + "disk": { + "presets/Qwen 27B (2).env": "# NAME=Qwen Q8\nMODEL=Qwen3-27B-Q8_0.gguf\nQUANT=q8\n", + "presets/Qwen 27B.env": "# NAME=Qwen 27B\nMODEL=Qwen3-27B-Q4_K_M.gguf\nCTX=65536\nREASONING=on\n", + "presets/Strata.env": "# NAME=Strata\nENGINE=strata\nSTRATA_VISION=0\nCTX=1048576\n" + }, + "state": { + "bench_presets": { + "Qwen 27B": { + "at": 1700000000, + "model": "Qwen3-27B-Q4_K_M.gguf", + "result": { + "depth": { + "cold": { + "cached": 0, + "expected": 0, + "new": 30000, + "predicted_ms": 0, + "predicted_n": 0, + "predicted_per_second": 0, + "prompt_ms": 0, + "prompt_per_second": 0 + }, + "ctx": 0, + "decode_per_second": 31.25, + "reuse": 0, + "target": 0 + }, + "elapsed_sec": 0, + "kv": "q8_0", + "kv_quantized": true, + "predicted_ms": 0, + "predicted_n": 0, + "predicted_per_second": 40, + "prompt_ms": 0, + "prompt_n": 0, + "prompt_per_second": 1234.5 + } + }, + "Qwen 27B (2)": { + "at": 1700000001, + "model": "autre.gguf", + "result": { + "elapsed_sec": 0, + "predicted_ms": 0, + "predicted_n": 0, + "predicted_per_second": 20.5, + "prompt_ms": 0, + "prompt_n": 0, + "prompt_per_second": 900 + } + } + }, + "preset_order": [ + "a-b-c" + ] + } + }, + { + "method": "POST", + "path": "/api/preset/delete", + "body": "{\"id\":\"absent\"}", + "status": 400, + "out": { + "error": "introuvable", + "ok": false + }, + "disk": { + "presets/Qwen 27B (2).env": "# NAME=Qwen Q8\nMODEL=Qwen3-27B-Q8_0.gguf\nQUANT=q8\n", + "presets/Qwen 27B.env": "# NAME=Qwen 27B\nMODEL=Qwen3-27B-Q4_K_M.gguf\nCTX=65536\nREASONING=on\n", + "presets/Strata.env": "# NAME=Strata\nENGINE=strata\nSTRATA_VISION=0\nCTX=1048576\n" + }, + "state": { + "bench_presets": { + "Qwen 27B": { + "at": 1700000000, + "model": "Qwen3-27B-Q4_K_M.gguf", + "result": { + "depth": { + "cold": { + "cached": 0, + "expected": 0, + "new": 30000, + "predicted_ms": 0, + "predicted_n": 0, + "predicted_per_second": 0, + "prompt_ms": 0, + "prompt_per_second": 0 + }, + "ctx": 0, + "decode_per_second": 31.25, + "reuse": 0, + "target": 0 + }, + "elapsed_sec": 0, + "kv": "q8_0", + "kv_quantized": true, + "predicted_ms": 0, + "predicted_n": 0, + "predicted_per_second": 40, + "prompt_ms": 0, + "prompt_n": 0, + "prompt_per_second": 1234.5 + } + }, + "Qwen 27B (2)": { + "at": 1700000001, + "model": "autre.gguf", + "result": { + "elapsed_sec": 0, + "predicted_ms": 0, + "predicted_n": 0, + "predicted_per_second": 20.5, + "prompt_ms": 0, + "prompt_n": 0, + "prompt_per_second": 900 + } + } + }, + "preset_order": [ + "a-b-c" + ] + } + }, + { + "method": "POST", + "path": "/api/preset/delete", + "body": "{\"id\":\"a/b\"}", + "status": 400, + "out": { + "error": "le nom ne peut pas contenir / ni \\", + "ok": false + }, + "disk": { + "presets/Qwen 27B (2).env": "# NAME=Qwen Q8\nMODEL=Qwen3-27B-Q8_0.gguf\nQUANT=q8\n", + "presets/Qwen 27B.env": "# NAME=Qwen 27B\nMODEL=Qwen3-27B-Q4_K_M.gguf\nCTX=65536\nREASONING=on\n", + "presets/Strata.env": "# NAME=Strata\nENGINE=strata\nSTRATA_VISION=0\nCTX=1048576\n" + }, + "state": { + "bench_presets": { + "Qwen 27B": { + "at": 1700000000, + "model": "Qwen3-27B-Q4_K_M.gguf", + "result": { + "depth": { + "cold": { + "cached": 0, + "expected": 0, + "new": 30000, + "predicted_ms": 0, + "predicted_n": 0, + "predicted_per_second": 0, + "prompt_ms": 0, + "prompt_per_second": 0 + }, + "ctx": 0, + "decode_per_second": 31.25, + "reuse": 0, + "target": 0 + }, + "elapsed_sec": 0, + "kv": "q8_0", + "kv_quantized": true, + "predicted_ms": 0, + "predicted_n": 0, + "predicted_per_second": 40, + "prompt_ms": 0, + "prompt_n": 0, + "prompt_per_second": 1234.5 + } + }, + "Qwen 27B (2)": { + "at": 1700000001, + "model": "autre.gguf", + "result": { + "elapsed_sec": 0, + "predicted_ms": 0, + "predicted_n": 0, + "predicted_per_second": 20.5, + "prompt_ms": 0, + "prompt_n": 0, + "prompt_per_second": 900 + } + } + }, + "preset_order": [ + "a-b-c" + ] + } + }, + { + "files": { + "models/Qwen3-27B-Q4_K_M.gguf": "poids" + }, + "method": "POST", + "path": "/api/preset/delete", + "body": "{\"id\":\"Qwen 27B\",\"deleteModel\":true}", + "status": 200, + "out": { + "freed": 5, + "modelDeleted": "Qwen3-27B-Q4_K_M.gguf", + "modelError": "", + "ok": true + }, + "disk": { + "presets/Qwen 27B (2).env": "# NAME=Qwen Q8\nMODEL=Qwen3-27B-Q8_0.gguf\nQUANT=q8\n", + "presets/Strata.env": "# NAME=Strata\nENGINE=strata\nSTRATA_VISION=0\nCTX=1048576\n" + }, + "state": { + "bench_presets": { + "Qwen 27B (2)": { + "at": 1700000001, + "model": "autre.gguf", + "result": { + "elapsed_sec": 0, + "predicted_ms": 0, + "predicted_n": 0, + "predicted_per_second": 20.5, + "prompt_ms": 0, + "prompt_n": 0, + "prompt_per_second": 900 + } + } + }, + "preset_order": [ + "a-b-c" + ] + } + }, + { + "files": { + "models/Qwen3-27B-Q8_0.gguf": "poids8", + "presets/Copie.env": "# NAME=Copie\nMODEL=Qwen3-27B-Q8_0.gguf\n" + }, + "method": "POST", + "path": "/api/preset/delete", + "body": "{\"id\":\"Qwen 27B (2)\",\"deleteModel\":true}", + "status": 200, + "out": { + "freed": 6, + "modelDeleted": "Qwen3-27B-Q8_0.gguf", + "modelError": "supprimé, mais il était aussi référencé par : Copie", + "ok": true + }, + "disk": { + "presets/Copie.env": "# NAME=Copie\nMODEL=Qwen3-27B-Q8_0.gguf\n", + "presets/Strata.env": "# NAME=Strata\nENGINE=strata\nSTRATA_VISION=0\nCTX=1048576\n" + }, + "state": { + "bench_presets": {}, + "preset_order": [ + "a-b-c" + ] + } + }, + { + "method": "POST", + "path": "/api/preset/delete", + "body": "{\"id\":\"Copie\",\"deleteModel\":true}", + "status": 200, + "out": { + "freed": 0, + "modelDeleted": "", + "modelError": "modèle introuvable: Qwen3-27B-Q8_0.gguf", + "ok": true + }, + "disk": { + "presets/Strata.env": "# NAME=Strata\nENGINE=strata\nSTRATA_VISION=0\nCTX=1048576\n" + }, + "state": { + "bench_presets": {}, + "preset_order": [ + "a-b-c" + ] + } + }, + { + "method": "POST", + "path": "/api/preset/delete", + "body": "{\"id\":3}", + "status": 400, + "out": { + "error": "json: cannot unmarshal number into Go struct field presetSaveReq.id of type string", + "ok": false + }, + "disk": { + "presets/Strata.env": "# NAME=Strata\nENGINE=strata\nSTRATA_VISION=0\nCTX=1048576\n" + }, + "state": { + "bench_presets": {}, + "preset_order": [ + "a-b-c" + ] + } + }, + { + "method": "GET", + "path": "/api/presets", + "status": 200, + "out": [ + { + "active": false, + "ctx": "1M", + "id": "Strata", + "name": "Strata", + "strata": true + } + ], + "disk": { + "presets/Strata.env": "# NAME=Strata\nENGINE=strata\nSTRATA_VISION=0\nCTX=1048576\n" + }, + "state": { + "bench_presets": {}, + "preset_order": [ + "a-b-c" + ] + } + }, + { + "method": "GET", + "path": "/api/preset/external", + "status": 200, + "out": { + "hasKey": false, + "id": "", + "model": "", + "name": "", + "url": "" + }, + "disk": { + "presets/Strata.env": "# NAME=Strata\nENGINE=strata\nSTRATA_VISION=0\nCTX=1048576\n" + }, + "state": { + "bench_presets": {}, + "preset_order": [ + "a-b-c" + ] + } + }, + { + "method": "POST", + "path": "/api/preset/external/save", + "body": "{\"name\":\" Groq \",\"url\":\" https://api.groq.com/openai/v1 \",\"model\":\" llama \",\"key\":\"k1\",\"ctx\":\"131072\",\"vision\":true}", + "status": 200, + "out": { + "applied": false, + "id": "Groq", + "name": "Groq", + "ok": true + }, + "disk": { + "presets/Groq.env": "# NAME=Groq\nCTX=131072\nEXTERNAL=1\nEXTERNAL_KEY=k1\nEXTERNAL_MODEL=llama\nEXTERNAL_URL=https://api.groq.com/openai/v1\nEXTERNAL_VISION=1\n", + "presets/Strata.env": "# NAME=Strata\nENGINE=strata\nSTRATA_VISION=0\nCTX=1048576\n" + }, + "state": { + "bench_presets": {}, + "preset_order": [ + "a-b-c" + ] + } + }, + { + "method": "POST", + "path": "/api/preset/external/save", + "body": "{\"name\":\"X\",\"url\":\"\",\"model\":\"m\"}", + "status": 400, + "out": { + "error": "URL requise", + "ok": false + }, + "disk": { + "presets/Groq.env": "# NAME=Groq\nCTX=131072\nEXTERNAL=1\nEXTERNAL_KEY=k1\nEXTERNAL_MODEL=llama\nEXTERNAL_URL=https://api.groq.com/openai/v1\nEXTERNAL_VISION=1\n", + "presets/Strata.env": "# NAME=Strata\nENGINE=strata\nSTRATA_VISION=0\nCTX=1048576\n" + }, + "state": { + "bench_presets": {}, + "preset_order": [ + "a-b-c" + ] + } + }, + { + "method": "POST", + "path": "/api/preset/external/save", + "body": "{\"name\":\"X\",\"url\":\"u\",\"model\":\" \"}", + "status": 400, + "out": { + "error": "modèle requis", + "ok": false + }, + "disk": { + "presets/Groq.env": "# NAME=Groq\nCTX=131072\nEXTERNAL=1\nEXTERNAL_KEY=k1\nEXTERNAL_MODEL=llama\nEXTERNAL_URL=https://api.groq.com/openai/v1\nEXTERNAL_VISION=1\n", + "presets/Strata.env": "# NAME=Strata\nENGINE=strata\nSTRATA_VISION=0\nCTX=1048576\n" + }, + "state": { + "bench_presets": {}, + "preset_order": [ + "a-b-c" + ] + } + }, + { + "method": "POST", + "path": "/api/preset/external/save", + "body": "{\"name\":\"\",\"url\":\"u\",\"model\":\"m\"}", + "status": 400, + "out": { + "error": "nom requis", + "ok": false + }, + "disk": { + "presets/Groq.env": "# NAME=Groq\nCTX=131072\nEXTERNAL=1\nEXTERNAL_KEY=k1\nEXTERNAL_MODEL=llama\nEXTERNAL_URL=https://api.groq.com/openai/v1\nEXTERNAL_VISION=1\n", + "presets/Strata.env": "# NAME=Strata\nENGINE=strata\nSTRATA_VISION=0\nCTX=1048576\n" + }, + "state": { + "bench_presets": {}, + "preset_order": [ + "a-b-c" + ] + } + }, + { + "method": "GET", + "path": "/api/preset/external?id=Groq", + "status": 200, + "out": { + "ctx": "131072", + "hasKey": true, + "id": "Groq", + "model": "llama", + "name": "Groq", + "url": "https://api.groq.com/openai/v1", + "vision": true + }, + "disk": { + "presets/Groq.env": "# NAME=Groq\nCTX=131072\nEXTERNAL=1\nEXTERNAL_KEY=k1\nEXTERNAL_MODEL=llama\nEXTERNAL_URL=https://api.groq.com/openai/v1\nEXTERNAL_VISION=1\n", + "presets/Strata.env": "# NAME=Strata\nENGINE=strata\nSTRATA_VISION=0\nCTX=1048576\n" + }, + "state": { + "bench_presets": {}, + "preset_order": [ + "a-b-c" + ] + } + }, + { + "method": "POST", + "path": "/api/preset/external/save", + "body": "{\"id\":\"Groq\",\"name\":\"Groq 2\",\"url\":\"https://api.groq.com\",\"model\":\"llama-3\"}", + "status": 200, + "out": { + "applied": false, + "id": "Groq", + "name": "Groq 2", + "ok": true + }, + "disk": { + "presets/Groq.env": "# NAME=Groq 2\nEXTERNAL=1\nEXTERNAL_KEY=k1\nEXTERNAL_MODEL=llama-3\nEXTERNAL_URL=https://api.groq.com\n", + "presets/Strata.env": "# NAME=Strata\nENGINE=strata\nSTRATA_VISION=0\nCTX=1048576\n" + }, + "state": { + "bench_presets": {}, + "preset_order": [ + "a-b-c" + ] + } + }, + { + "method": "GET", + "path": "/api/preset/external?id=Groq", + "status": 200, + "out": { + "ctx": "", + "hasKey": true, + "id": "Groq", + "model": "llama-3", + "name": "Groq 2", + "url": "https://api.groq.com", + "vision": false + }, + "disk": { + "presets/Groq.env": "# NAME=Groq 2\nEXTERNAL=1\nEXTERNAL_KEY=k1\nEXTERNAL_MODEL=llama-3\nEXTERNAL_URL=https://api.groq.com\n", + "presets/Strata.env": "# NAME=Strata\nENGINE=strata\nSTRATA_VISION=0\nCTX=1048576\n" + }, + "state": { + "bench_presets": {}, + "preset_order": [ + "a-b-c" + ] + } + }, + { + "method": "POST", + "path": "/api/preset/external/save", + "body": "{\"id\":\"Groq\",\"name\":\"Groq\",\"url\":\"https://api.groq.com\",\"model\":\"llama-3\",\"keyTouched\":true,\"key\":\"\"}", + "status": 200, + "out": { + "applied": false, + "id": "Groq", + "name": "Groq", + "ok": true + }, + "disk": { + "presets/Groq.env": "# NAME=Groq\nEXTERNAL=1\nEXTERNAL_MODEL=llama-3\nEXTERNAL_URL=https://api.groq.com\n", + "presets/Strata.env": "# NAME=Strata\nENGINE=strata\nSTRATA_VISION=0\nCTX=1048576\n" + }, + "state": { + "bench_presets": {}, + "preset_order": [ + "a-b-c" + ] + } + }, + { + "method": "GET", + "path": "/api/preset/external?id=Groq", + "status": 200, + "out": { + "ctx": "", + "hasKey": false, + "id": "Groq", + "model": "llama-3", + "name": "Groq", + "url": "https://api.groq.com", + "vision": false + }, + "disk": { + "presets/Groq.env": "# NAME=Groq\nEXTERNAL=1\nEXTERNAL_MODEL=llama-3\nEXTERNAL_URL=https://api.groq.com\n", + "presets/Strata.env": "# NAME=Strata\nENGINE=strata\nSTRATA_VISION=0\nCTX=1048576\n" + }, + "state": { + "bench_presets": {}, + "preset_order": [ + "a-b-c" + ] + } + }, + { + "method": "GET", + "path": "/api/preset/external?id=Strata", + "status": 400, + "out": { + "error": "pas un preset externe" + }, + "disk": { + "presets/Groq.env": "# NAME=Groq\nEXTERNAL=1\nEXTERNAL_MODEL=llama-3\nEXTERNAL_URL=https://api.groq.com\n", + "presets/Strata.env": "# NAME=Strata\nENGINE=strata\nSTRATA_VISION=0\nCTX=1048576\n" + }, + "state": { + "bench_presets": {}, + "preset_order": [ + "a-b-c" + ] + } + }, + { + "method": "GET", + "path": "/api/preset/external?id=absent", + "status": 404, + "out": { + "error": "not found" + }, + "disk": { + "presets/Groq.env": "# NAME=Groq\nEXTERNAL=1\nEXTERNAL_MODEL=llama-3\nEXTERNAL_URL=https://api.groq.com\n", + "presets/Strata.env": "# NAME=Strata\nENGINE=strata\nSTRATA_VISION=0\nCTX=1048576\n" + }, + "state": { + "bench_presets": {}, + "preset_order": [ + "a-b-c" + ] + } + }, + { + "method": "GET", + "path": "/api/presets", + "status": 200, + "out": [ + { + "active": false, + "external": true, + "id": "Groq", + "model": "llama-3", + "name": "Groq" + }, + { + "active": false, + "ctx": "1M", + "id": "Strata", + "name": "Strata", + "strata": true + } + ], + "disk": { + "presets/Groq.env": "# NAME=Groq\nEXTERNAL=1\nEXTERNAL_MODEL=llama-3\nEXTERNAL_URL=https://api.groq.com\n", + "presets/Strata.env": "# NAME=Strata\nENGINE=strata\nSTRATA_VISION=0\nCTX=1048576\n" + }, + "state": { + "bench_presets": {}, + "preset_order": [ + "a-b-c" + ] + } + }, + { + "method": "POST", + "path": "/api/preset/external/test", + "body": "{\"url\":\"{{URL}}/ok\",\"model\":\"m\",\"key\":\"k1\"}", + "status": 200, + "out": { + "ok": true + }, + "disk": { + "presets/Groq.env": "# NAME=Groq\nEXTERNAL=1\nEXTERNAL_MODEL=llama-3\nEXTERNAL_URL=https://api.groq.com\n", + "presets/Strata.env": "# NAME=Strata\nENGINE=strata\nSTRATA_VISION=0\nCTX=1048576\n" + }, + "state": { + "bench_presets": {}, + "preset_order": [ + "a-b-c" + ] + } + }, + { + "method": "POST", + "path": "/api/preset/external/test", + "body": "{\"url\":\"{{URL}}/refus/v1\",\"model\":\"m\"}", + "status": 200, + "out": { + "error": "clé invalide", + "ok": false, + "status": 401 + }, + "disk": { + "presets/Groq.env": "# NAME=Groq\nEXTERNAL=1\nEXTERNAL_MODEL=llama-3\nEXTERNAL_URL=https://api.groq.com\n", + "presets/Strata.env": "# NAME=Strata\nENGINE=strata\nSTRATA_VISION=0\nCTX=1048576\n" + }, + "state": { + "bench_presets": {}, + "preset_order": [ + "a-b-c" + ] + } + }, + { + "method": "POST", + "path": "/api/preset/external/test", + "body": "{\"url\":\"{{URL}}/brut/v1/chat/completions\",\"model\":\"m\"}", + "status": 200, + "out": { + "error": "page absente", + "ok": false, + "status": 404 + }, + "disk": { + "presets/Groq.env": "# NAME=Groq\nEXTERNAL=1\nEXTERNAL_MODEL=llama-3\nEXTERNAL_URL=https://api.groq.com\n", + "presets/Strata.env": "# NAME=Strata\nENGINE=strata\nSTRATA_VISION=0\nCTX=1048576\n" + }, + "state": { + "bench_presets": {}, + "preset_order": [ + "a-b-c" + ] + } + }, + { + "method": "POST", + "path": "/api/preset/external/test", + "body": "{\"url\":\"{{URL}}/rien\",\"model\":\"m\"}", + "status": 200, + "out": { + "error": "500 Internal Server Error", + "ok": false, + "status": 500 + }, + "disk": { + "presets/Groq.env": "# NAME=Groq\nEXTERNAL=1\nEXTERNAL_MODEL=llama-3\nEXTERNAL_URL=https://api.groq.com\n", + "presets/Strata.env": "# NAME=Strata\nENGINE=strata\nSTRATA_VISION=0\nCTX=1048576\n" + }, + "state": { + "bench_presets": {}, + "preset_order": [ + "a-b-c" + ] + } + }, + { + "method": "POST", + "path": "/api/preset/external/test", + "body": "{\"url\":\"\",\"model\":\"m\"}", + "status": 200, + "out": { + "error": "URL et modèle requis", + "ok": false + }, + "disk": { + "presets/Groq.env": "# NAME=Groq\nEXTERNAL=1\nEXTERNAL_MODEL=llama-3\nEXTERNAL_URL=https://api.groq.com\n", + "presets/Strata.env": "# NAME=Strata\nENGINE=strata\nSTRATA_VISION=0\nCTX=1048576\n" + }, + "state": { + "bench_presets": {}, + "preset_order": [ + "a-b-c" + ] + } + }, + { + "method": "POST", + "path": "/api/preset/external/save", + "body": "{\"name\":\"Clé\",\"url\":\"{{URL}}/ok\",\"model\":\"m\",\"key\":\"k1\"}", + "status": 200, + "out": { + "applied": false, + "id": "Clé", + "name": "Clé", + "ok": true + }, + "disk": { + "presets/Clé.env": "# NAME=Clé\nEXTERNAL=1\nEXTERNAL_KEY=k1\nEXTERNAL_MODEL=m\nEXTERNAL_URL={{URL}}/ok\n", + "presets/Groq.env": "# NAME=Groq\nEXTERNAL=1\nEXTERNAL_MODEL=llama-3\nEXTERNAL_URL=https://api.groq.com\n", + "presets/Strata.env": "# NAME=Strata\nENGINE=strata\nSTRATA_VISION=0\nCTX=1048576\n" + }, + "state": { + "bench_presets": {}, + "preset_order": [ + "a-b-c" + ] + } + }, + { + "method": "POST", + "path": "/api/preset/external/test", + "body": "{\"id\":\"Clé\",\"url\":\"{{URL}}/ok\",\"model\":\"m\"}", + "status": 200, + "out": { + "ok": true + }, + "disk": { + "presets/Clé.env": "# NAME=Clé\nEXTERNAL=1\nEXTERNAL_KEY=k1\nEXTERNAL_MODEL=m\nEXTERNAL_URL={{URL}}/ok\n", + "presets/Groq.env": "# NAME=Groq\nEXTERNAL=1\nEXTERNAL_MODEL=llama-3\nEXTERNAL_URL=https://api.groq.com\n", + "presets/Strata.env": "# NAME=Strata\nENGINE=strata\nSTRATA_VISION=0\nCTX=1048576\n" + }, + "state": { + "bench_presets": {}, + "preset_order": [ + "a-b-c" + ] + } + }, + { + "method": "POST", + "path": "/api/preset/external/test", + "body": "[]", + "status": 400, + "out": { + "error": "json: cannot unmarshal array into Go value of type loki.externalSaveReq", + "ok": false + }, + "disk": { + "presets/Clé.env": "# NAME=Clé\nEXTERNAL=1\nEXTERNAL_KEY=k1\nEXTERNAL_MODEL=m\nEXTERNAL_URL={{URL}}/ok\n", + "presets/Groq.env": "# NAME=Groq\nEXTERNAL=1\nEXTERNAL_MODEL=llama-3\nEXTERNAL_URL=https://api.groq.com\n", + "presets/Strata.env": "# NAME=Strata\nENGINE=strata\nSTRATA_VISION=0\nCTX=1048576\n" + }, + "state": { + "bench_presets": {}, + "preset_order": [ + "a-b-c" + ] + } + }, + { + "method": "GET", + "path": "/api/preset/autoplace", + "status": 405, + "out": { + "error": "méthode GET refusée : POST attendu", + "ok": false + }, + "disk": { + "presets/Clé.env": "# NAME=Clé\nEXTERNAL=1\nEXTERNAL_KEY=k1\nEXTERNAL_MODEL=m\nEXTERNAL_URL={{URL}}/ok\n", + "presets/Groq.env": "# NAME=Groq\nEXTERNAL=1\nEXTERNAL_MODEL=llama-3\nEXTERNAL_URL=https://api.groq.com\n", + "presets/Strata.env": "# NAME=Strata\nENGINE=strata\nSTRATA_VISION=0\nCTX=1048576\n" + }, + "state": { + "bench_presets": {}, + "preset_order": [ + "a-b-c" + ] + } + }, + { + "method": "POST", + "path": "/api/preset/autoplace", + "body": "{\"id\":\"MoE\",\"content\":\"# NAME=MoE\\nMODEL=absent.gguf\\nCTX=32768\\nEXTRA_ARGS=--n-cpu-moe 20 --load-mode none\\n\"}", + "status": 200, + "out": { + "changes": [ + "retiré : --n-cpu-moe 20", + "retiré : --load-mode none", + "UBATCH=2048, BATCH=4096 (avant : 512 / 2048)", + "--load-mode mmap", + "CTX conservé : 32768" + ], + "id": "MoE (placement auto)", + "name": "MoE (placement auto)", + "ok": true + }, + "disk": { + "presets/Clé.env": "# NAME=Clé\nEXTERNAL=1\nEXTERNAL_KEY=k1\nEXTERNAL_MODEL=m\nEXTERNAL_URL={{URL}}/ok\n", + "presets/Groq.env": "# NAME=Groq\nEXTERNAL=1\nEXTERNAL_MODEL=llama-3\nEXTERNAL_URL=https://api.groq.com\n", + "presets/MoE (placement auto).env": "# NAME=MoE (placement auto)\nMODEL=absent.gguf\nCTX=32768\nEXTRA_ARGS=\"--load-mode mmap\"\nUBATCH=2048\nBATCH=4096\n", + "presets/Strata.env": "# NAME=Strata\nENGINE=strata\nSTRATA_VISION=0\nCTX=1048576\n" + }, + "state": { + "bench_presets": {}, + "preset_order": [ + "a-b-c" + ] + } + }, + { + "method": "POST", + "path": "/api/preset/autoplace", + "body": "{\"name\":\" Mon MoE \",\"content\":\"MODEL=m\\nEXTRA_ARGS=-ngl 30\\n\"}", + "status": 200, + "out": { + "error": "rien à migrer : ce preset ne place pas d'experts à la main (-ot …exps…, --n-cpu-moe, --cpu-moe)", + "ok": false + }, + "disk": { + "presets/Clé.env": "# NAME=Clé\nEXTERNAL=1\nEXTERNAL_KEY=k1\nEXTERNAL_MODEL=m\nEXTERNAL_URL={{URL}}/ok\n", + "presets/Groq.env": "# NAME=Groq\nEXTERNAL=1\nEXTERNAL_MODEL=llama-3\nEXTERNAL_URL=https://api.groq.com\n", + "presets/MoE (placement auto).env": "# NAME=MoE (placement auto)\nMODEL=absent.gguf\nCTX=32768\nEXTRA_ARGS=\"--load-mode mmap\"\nUBATCH=2048\nBATCH=4096\n", + "presets/Strata.env": "# NAME=Strata\nENGINE=strata\nSTRATA_VISION=0\nCTX=1048576\n" + }, + "state": { + "bench_presets": {}, + "preset_order": [ + "a-b-c" + ] + } + }, + { + "method": "POST", + "path": "/api/preset/autoplace", + "body": "{\"content\":\"MODEL=m\\nEXTRA_ARGS=--cpu-moe\\n\"}", + "status": 200, + "out": { + "changes": [ + "retiré : --cpu-moe", + "UBATCH=2048, BATCH=4096 (avant : 512 / 2048)", + "--load-mode mmap", + "CTX conservé : 32768" + ], + "id": "preset (placement auto)", + "name": "preset (placement auto)", + "ok": true + }, + "disk": { + "presets/Clé.env": "# NAME=Clé\nEXTERNAL=1\nEXTERNAL_KEY=k1\nEXTERNAL_MODEL=m\nEXTERNAL_URL={{URL}}/ok\n", + "presets/Groq.env": "# NAME=Groq\nEXTERNAL=1\nEXTERNAL_MODEL=llama-3\nEXTERNAL_URL=https://api.groq.com\n", + "presets/MoE (placement auto).env": "# NAME=MoE (placement auto)\nMODEL=absent.gguf\nCTX=32768\nEXTRA_ARGS=\"--load-mode mmap\"\nUBATCH=2048\nBATCH=4096\n", + "presets/Strata.env": "# NAME=Strata\nENGINE=strata\nSTRATA_VISION=0\nCTX=1048576\n", + "presets/preset (placement auto).env": "# NAME=preset (placement auto)\nMODEL=m\nEXTRA_ARGS=\"--load-mode mmap\"\nUBATCH=2048\nBATCH=4096\n" + }, + "state": { + "bench_presets": {}, + "preset_order": [ + "a-b-c" + ] + } + }, + { + "method": "POST", + "path": "/api/preset/moe", + "body": "{\"content\":\"# NAME=MoE\\nMODEL=absent.gguf\\nCTX=32768\\nEXTRA_ARGS=--n-cpu-moe 20 --load-mode none\\n\"}", + "status": 200, + "out": { + "autoplace": { + "changes": [ + "retiré : --n-cpu-moe 20", + "retiré : --load-mode none", + "UBATCH=2048, BATCH=4096 (avant : 512 / 2048)", + "--load-mode mmap", + "CTX conservé : 32768" + ], + "ok": true + }, + "load": "", + "notes": null, + "ok": true + }, + "disk": { + "presets/Clé.env": "# NAME=Clé\nEXTERNAL=1\nEXTERNAL_KEY=k1\nEXTERNAL_MODEL=m\nEXTERNAL_URL={{URL}}/ok\n", + "presets/Groq.env": "# NAME=Groq\nEXTERNAL=1\nEXTERNAL_MODEL=llama-3\nEXTERNAL_URL=https://api.groq.com\n", + "presets/MoE (placement auto).env": "# NAME=MoE (placement auto)\nMODEL=absent.gguf\nCTX=32768\nEXTRA_ARGS=\"--load-mode mmap\"\nUBATCH=2048\nBATCH=4096\n", + "presets/Strata.env": "# NAME=Strata\nENGINE=strata\nSTRATA_VISION=0\nCTX=1048576\n", + "presets/preset (placement auto).env": "# NAME=preset (placement auto)\nMODEL=m\nEXTRA_ARGS=\"--load-mode mmap\"\nUBATCH=2048\nBATCH=4096\n" + }, + "state": { + "bench_presets": {}, + "preset_order": [ + "a-b-c" + ] + } + }, + { + "method": "POST", + "path": "/api/preset/moe", + "body": "{\"content\":\"EXTERNAL=1\\n\"}", + "status": 200, + "out": { + "load": "", + "notes": null, + "ok": true + }, + "disk": { + "presets/Clé.env": "# NAME=Clé\nEXTERNAL=1\nEXTERNAL_KEY=k1\nEXTERNAL_MODEL=m\nEXTERNAL_URL={{URL}}/ok\n", + "presets/Groq.env": "# NAME=Groq\nEXTERNAL=1\nEXTERNAL_MODEL=llama-3\nEXTERNAL_URL=https://api.groq.com\n", + "presets/MoE (placement auto).env": "# NAME=MoE (placement auto)\nMODEL=absent.gguf\nCTX=32768\nEXTRA_ARGS=\"--load-mode mmap\"\nUBATCH=2048\nBATCH=4096\n", + "presets/Strata.env": "# NAME=Strata\nENGINE=strata\nSTRATA_VISION=0\nCTX=1048576\n", + "presets/preset (placement auto).env": "# NAME=preset (placement auto)\nMODEL=m\nEXTRA_ARGS=\"--load-mode mmap\"\nUBATCH=2048\nBATCH=4096\n" + }, + "state": { + "bench_presets": {}, + "preset_order": [ + "a-b-c" + ] + } + }, + { + "method": "POST", + "path": "/api/preset/moe", + "body": "{\"content\":\"MODEL=absent.gguf\\nEXTRA_ARGS=--cpu-moe -sm row\\n\"}", + "status": 200, + "out": { + "autoplace": { + "ok": false, + "why": "--fit resterait inactif (-sm row) : retire-le à la main d'abord" + }, + "load": "", + "notes": null, + "ok": true + }, + "disk": { + "presets/Clé.env": "# NAME=Clé\nEXTERNAL=1\nEXTERNAL_KEY=k1\nEXTERNAL_MODEL=m\nEXTERNAL_URL={{URL}}/ok\n", + "presets/Groq.env": "# NAME=Groq\nEXTERNAL=1\nEXTERNAL_MODEL=llama-3\nEXTERNAL_URL=https://api.groq.com\n", + "presets/MoE (placement auto).env": "# NAME=MoE (placement auto)\nMODEL=absent.gguf\nCTX=32768\nEXTRA_ARGS=\"--load-mode mmap\"\nUBATCH=2048\nBATCH=4096\n", + "presets/Strata.env": "# NAME=Strata\nENGINE=strata\nSTRATA_VISION=0\nCTX=1048576\n", + "presets/preset (placement auto).env": "# NAME=preset (placement auto)\nMODEL=m\nEXTRA_ARGS=\"--load-mode mmap\"\nUBATCH=2048\nBATCH=4096\n" + }, + "state": { + "bench_presets": {}, + "preset_order": [ + "a-b-c" + ] + } + }, + { + "method": "POST", + "path": "/api/preset/moe", + "body": "{\"content\":5}", + "status": 400, + "out": { + "error": "json: cannot unmarshal number into Go struct field .content of type string", + "ok": false + }, + "disk": { + "presets/Clé.env": "# NAME=Clé\nEXTERNAL=1\nEXTERNAL_KEY=k1\nEXTERNAL_MODEL=m\nEXTERNAL_URL={{URL}}/ok\n", + "presets/Groq.env": "# NAME=Groq\nEXTERNAL=1\nEXTERNAL_MODEL=llama-3\nEXTERNAL_URL=https://api.groq.com\n", + "presets/MoE (placement auto).env": "# NAME=MoE (placement auto)\nMODEL=absent.gguf\nCTX=32768\nEXTRA_ARGS=\"--load-mode mmap\"\nUBATCH=2048\nBATCH=4096\n", + "presets/Strata.env": "# NAME=Strata\nENGINE=strata\nSTRATA_VISION=0\nCTX=1048576\n", + "presets/preset (placement auto).env": "# NAME=preset (placement auto)\nMODEL=m\nEXTRA_ARGS=\"--load-mode mmap\"\nUBATCH=2048\nBATCH=4096\n" + }, + "state": { + "bench_presets": {}, + "preset_order": [ + "a-b-c" + ] + } + }, + { + "method": "POST", + "path": "/api/preset/cacheram", + "body": "{\"content\":\"EXTERNAL=1\\nSIDE_SLOT=on\\n\"}", + "status": 200, + "out": { + "default": 8192, + "mib": 0, + "ok": true, + "side": { + "on": false, + "vramMiB": 0, + "why": "preset externe" + }, + "why": "preset externe" + }, + "disk": { + "presets/Clé.env": "# NAME=Clé\nEXTERNAL=1\nEXTERNAL_KEY=k1\nEXTERNAL_MODEL=m\nEXTERNAL_URL={{URL}}/ok\n", + "presets/Groq.env": "# NAME=Groq\nEXTERNAL=1\nEXTERNAL_MODEL=llama-3\nEXTERNAL_URL=https://api.groq.com\n", + "presets/MoE (placement auto).env": "# NAME=MoE (placement auto)\nMODEL=absent.gguf\nCTX=32768\nEXTRA_ARGS=\"--load-mode mmap\"\nUBATCH=2048\nBATCH=4096\n", + "presets/Strata.env": "# NAME=Strata\nENGINE=strata\nSTRATA_VISION=0\nCTX=1048576\n", + "presets/preset (placement auto).env": "# NAME=preset (placement auto)\nMODEL=m\nEXTRA_ARGS=\"--load-mode mmap\"\nUBATCH=2048\nBATCH=4096\n" + }, + "state": { + "bench_presets": {}, + "preset_order": [ + "a-b-c" + ] + } + }, + { + "method": "POST", + "path": "/api/preset/cacheram", + "body": "{\"content\":\"EXTRA_ARGS=-cram 2048\\n\"}", + "status": 200, + "out": { + "default": 8192, + "mib": 2048, + "ok": true, + "why": "fixé par EXTRA_ARGS" + }, + "disk": { + "presets/Clé.env": "# NAME=Clé\nEXTERNAL=1\nEXTERNAL_KEY=k1\nEXTERNAL_MODEL=m\nEXTERNAL_URL={{URL}}/ok\n", + "presets/Groq.env": "# NAME=Groq\nEXTERNAL=1\nEXTERNAL_MODEL=llama-3\nEXTERNAL_URL=https://api.groq.com\n", + "presets/MoE (placement auto).env": "# NAME=MoE (placement auto)\nMODEL=absent.gguf\nCTX=32768\nEXTRA_ARGS=\"--load-mode mmap\"\nUBATCH=2048\nBATCH=4096\n", + "presets/Strata.env": "# NAME=Strata\nENGINE=strata\nSTRATA_VISION=0\nCTX=1048576\n", + "presets/preset (placement auto).env": "# NAME=preset (placement auto)\nMODEL=m\nEXTRA_ARGS=\"--load-mode mmap\"\nUBATCH=2048\nBATCH=4096\n" + }, + "state": { + "bench_presets": {}, + "preset_order": [ + "a-b-c" + ] + } + }, + { + "method": "POST", + "path": "/api/preset/cacheram", + "body": "{\"content\":\"CACHE_RAM=4096\\nSIDE_SLOT=on\\nMODEL=absent.gguf\\n\"}", + "status": 200, + "out": { + "default": 8192, + "mib": 4096, + "ok": true, + "side": { + "on": false, + "vramMiB": 0, + "why": "VRAM NVIDIA inconnue (macOS, AMD, --device…)" + }, + "why": "fixé par CACHE_RAM" + }, + "disk": { + "presets/Clé.env": "# NAME=Clé\nEXTERNAL=1\nEXTERNAL_KEY=k1\nEXTERNAL_MODEL=m\nEXTERNAL_URL={{URL}}/ok\n", + "presets/Groq.env": "# NAME=Groq\nEXTERNAL=1\nEXTERNAL_MODEL=llama-3\nEXTERNAL_URL=https://api.groq.com\n", + "presets/MoE (placement auto).env": "# NAME=MoE (placement auto)\nMODEL=absent.gguf\nCTX=32768\nEXTRA_ARGS=\"--load-mode mmap\"\nUBATCH=2048\nBATCH=4096\n", + "presets/Strata.env": "# NAME=Strata\nENGINE=strata\nSTRATA_VISION=0\nCTX=1048576\n", + "presets/preset (placement auto).env": "# NAME=preset (placement auto)\nMODEL=m\nEXTRA_ARGS=\"--load-mode mmap\"\nUBATCH=2048\nBATCH=4096\n" + }, + "state": { + "bench_presets": {}, + "preset_order": [ + "a-b-c" + ] + } + }, + { + "method": "POST", + "path": "/api/preset/cacheram", + "body": "{\"content\":\"CACHE_RAM=beaucoup\\n\"}", + "status": 200, + "out": { + "default": 8192, + "mib": 0, + "ok": true, + "why": "CACHE_RAM illisible" + }, + "disk": { + "presets/Clé.env": "# NAME=Clé\nEXTERNAL=1\nEXTERNAL_KEY=k1\nEXTERNAL_MODEL=m\nEXTERNAL_URL={{URL}}/ok\n", + "presets/Groq.env": "# NAME=Groq\nEXTERNAL=1\nEXTERNAL_MODEL=llama-3\nEXTERNAL_URL=https://api.groq.com\n", + "presets/MoE (placement auto).env": "# NAME=MoE (placement auto)\nMODEL=absent.gguf\nCTX=32768\nEXTRA_ARGS=\"--load-mode mmap\"\nUBATCH=2048\nBATCH=4096\n", + "presets/Strata.env": "# NAME=Strata\nENGINE=strata\nSTRATA_VISION=0\nCTX=1048576\n", + "presets/preset (placement auto).env": "# NAME=preset (placement auto)\nMODEL=m\nEXTRA_ARGS=\"--load-mode mmap\"\nUBATCH=2048\nBATCH=4096\n" + }, + "state": { + "bench_presets": {}, + "preset_order": [ + "a-b-c" + ] + } + }, + { + "method": "POST", + "path": "/api/preset/cacheram", + "body": "{\"content\":\"CACHE_RAM=auto\\n\"}", + "status": 200, + "out": { + "default": 8192, + "mib": 0, + "ok": true, + "why": "modèle introuvable" + }, + "disk": { + "presets/Clé.env": "# NAME=Clé\nEXTERNAL=1\nEXTERNAL_KEY=k1\nEXTERNAL_MODEL=m\nEXTERNAL_URL={{URL}}/ok\n", + "presets/Groq.env": "# NAME=Groq\nEXTERNAL=1\nEXTERNAL_MODEL=llama-3\nEXTERNAL_URL=https://api.groq.com\n", + "presets/MoE (placement auto).env": "# NAME=MoE (placement auto)\nMODEL=absent.gguf\nCTX=32768\nEXTRA_ARGS=\"--load-mode mmap\"\nUBATCH=2048\nBATCH=4096\n", + "presets/Strata.env": "# NAME=Strata\nENGINE=strata\nSTRATA_VISION=0\nCTX=1048576\n", + "presets/preset (placement auto).env": "# NAME=preset (placement auto)\nMODEL=m\nEXTRA_ARGS=\"--load-mode mmap\"\nUBATCH=2048\nBATCH=4096\n" + }, + "state": { + "bench_presets": {}, + "preset_order": [ + "a-b-c" + ] + } + }, + { + "method": "POST", + "path": "/api/preset/cacheram", + "body": "{\"content\":\"MODEL=absent.gguf\\nSIDE_SLOT=on\\nPARALLEL=3\\n\"}", + "status": 200, + "out": { + "default": 8192, + "mib": 0, + "ok": true, + "side": { + "on": false, + "vramMiB": 0, + "why": "PARALLEL=3 (SIDE_SLOT ouvre exactement 2 slots)" + }, + "why": "VRAM NVIDIA inconnue" + }, + "disk": { + "presets/Clé.env": "# NAME=Clé\nEXTERNAL=1\nEXTERNAL_KEY=k1\nEXTERNAL_MODEL=m\nEXTERNAL_URL={{URL}}/ok\n", + "presets/Groq.env": "# NAME=Groq\nEXTERNAL=1\nEXTERNAL_MODEL=llama-3\nEXTERNAL_URL=https://api.groq.com\n", + "presets/MoE (placement auto).env": "# NAME=MoE (placement auto)\nMODEL=absent.gguf\nCTX=32768\nEXTRA_ARGS=\"--load-mode mmap\"\nUBATCH=2048\nBATCH=4096\n", + "presets/Strata.env": "# NAME=Strata\nENGINE=strata\nSTRATA_VISION=0\nCTX=1048576\n", + "presets/preset (placement auto).env": "# NAME=preset (placement auto)\nMODEL=m\nEXTRA_ARGS=\"--load-mode mmap\"\nUBATCH=2048\nBATCH=4096\n" + }, + "state": { + "bench_presets": {}, + "preset_order": [ + "a-b-c" + ] + } + }, + { + "method": "POST", + "path": "/api/preset/cacheram", + "body": "{}", + "status": 200, + "out": { + "default": 8192, + "mib": 0, + "ok": true, + "why": "modèle introuvable" + }, + "disk": { + "presets/Clé.env": "# NAME=Clé\nEXTERNAL=1\nEXTERNAL_KEY=k1\nEXTERNAL_MODEL=m\nEXTERNAL_URL={{URL}}/ok\n", + "presets/Groq.env": "# NAME=Groq\nEXTERNAL=1\nEXTERNAL_MODEL=llama-3\nEXTERNAL_URL=https://api.groq.com\n", + "presets/MoE (placement auto).env": "# NAME=MoE (placement auto)\nMODEL=absent.gguf\nCTX=32768\nEXTRA_ARGS=\"--load-mode mmap\"\nUBATCH=2048\nBATCH=4096\n", + "presets/Strata.env": "# NAME=Strata\nENGINE=strata\nSTRATA_VISION=0\nCTX=1048576\n", + "presets/preset (placement auto).env": "# NAME=preset (placement auto)\nMODEL=m\nEXTRA_ARGS=\"--load-mode mmap\"\nUBATCH=2048\nBATCH=4096\n" + }, + "state": { + "bench_presets": {}, + "preset_order": [ + "a-b-c" + ] + } + }, + { + "method": "GET", + "path": "/api/presets", + "status": 200, + "out": [ + { + "active": false, + "external": true, + "id": "Clé", + "model": "m", + "name": "Clé" + }, + { + "active": false, + "external": true, + "id": "Groq", + "model": "llama-3", + "name": "Groq" + }, + { + "active": false, + "ctx": "32K", + "id": "MoE (placement auto)", + "name": "MoE (placement auto)" + }, + { + "active": false, + "ctx": "1M", + "id": "Strata", + "name": "Strata", + "strata": true + }, + { + "active": false, + "ctx": "32K", + "id": "preset (placement auto)", + "name": "preset (placement auto)" + } + ], + "disk": { + "presets/Clé.env": "# NAME=Clé\nEXTERNAL=1\nEXTERNAL_KEY=k1\nEXTERNAL_MODEL=m\nEXTERNAL_URL={{URL}}/ok\n", + "presets/Groq.env": "# NAME=Groq\nEXTERNAL=1\nEXTERNAL_MODEL=llama-3\nEXTERNAL_URL=https://api.groq.com\n", + "presets/MoE (placement auto).env": "# NAME=MoE (placement auto)\nMODEL=absent.gguf\nCTX=32768\nEXTRA_ARGS=\"--load-mode mmap\"\nUBATCH=2048\nBATCH=4096\n", + "presets/Strata.env": "# NAME=Strata\nENGINE=strata\nSTRATA_VISION=0\nCTX=1048576\n", + "presets/preset (placement auto).env": "# NAME=preset (placement auto)\nMODEL=m\nEXTRA_ARGS=\"--load-mode mmap\"\nUBATCH=2048\nBATCH=4096\n" + }, + "state": { + "bench_presets": {}, + "preset_order": [ + "a-b-c" + ] + } + } + ] +} diff --git a/rust/loki-rs/tests/presets_api.rs b/rust/loki-rs/tests/presets_api.rs new file mode 100644 index 0000000..86e99ef --- /dev/null +++ b/rust/loki-rs/tests/presets_api.rs @@ -0,0 +1,414 @@ +//! Lot 15 : presets tenus par la façade (`LOKI_PRESETS=rust`). +//! +//! Le journal tiré du Go (`internal/loki/parity_presets_test.go` → +//! `tests/parity_presets/presets.json`) est rejoué : fonctions pures, puis la +//! suite d'appels aux routes par la façade, avec après chacun le dossier +//! presets/ (et models/) et les clés touchées en base. La bascule et +//! l'enregistrement du preset en service, qui pilotent le moteur au Go, sont +//! vérifiés de bout en bout contre le faux Go. + +mod common; + +use std::collections::{BTreeMap, HashMap}; +use std::path::Path; + +use common::fakego; +use loki_rs::presets_api as pa; +use serde_json::{Value, json}; + +fn fixture() -> Value { + serde_json::from_str(include_str!("parity_presets/presets.json")).expect("journal") +} + +fn s(v: &Value) -> &str { + v.as_str().unwrap() +} + +fn strs(v: &Value) -> Vec { + v.as_array() + .map(|a| a.iter().map(|x| s(x).to_string()).collect()) + .unwrap_or_default() +} + +fn cfg(v: &Value) -> HashMap { + v.as_object() + .unwrap() + .iter() + .map(|(k, x)| (k.clone(), s(x).to_string())) + .collect() +} + +#[test] +fn parite_des_fonctions_pures() { + let fx = fixture(); + for c in fx["display_name"].as_array().unwrap() { + assert_eq!( + pa::with_display_name(s(&c["content"]), s(&c["name"])), + s(&c["out"]), + "{c}" + ); + } + for (v, want) in fx["quote"].as_object().unwrap() { + assert_eq!(pa::quote_value(v), s(want), "{v:?}"); + } + let m: BTreeMap = [("Z", "1"), ("A", "deux mots"), ("M", ""), ("Q", "q\"q")] + .iter() + .map(|(k, v)| (k.to_string(), v.to_string())) + .collect(); + assert_eq!(pa::format_env(&m), s(&fx["format_env"])); + let home = Path::new("/h"); + for (n, want) in fx["names"].as_object().unwrap() { + let got = match pa::safe_preset_path(home, n) { + Ok(_) => "ok".to_string(), + Err(e) => e, + }; + assert_eq!(got, s(want), "{n:?}"); + } + for (c, want) in fx["quant"].as_object().unwrap() { + assert_eq!(pa::detect_quant(c), s(want), "{c:?}"); + } + for (c, want) in fx["reasoning"].as_object().unwrap() { + assert_eq!(pa::preset_reasoning(c), s(want), "{c:?}"); + } + for (c, want) in fx["model"].as_object().unwrap() { + assert_eq!(pa::model_from_preset_content(c), s(want), "{c:?}"); + } + for (u, want) in fx["completions"].as_object().unwrap() { + assert_eq!(pa::completions_url(u), s(want), "{u:?}"); + } + for c in fx["external"].as_array().unwrap() { + assert_eq!( + pa::external_preset_content( + s(&c["url"]), + s(&c["model"]), + s(&c["key"]), + s(&c["ctx"]), + c["vision"].as_bool().unwrap() + ), + s(&c["out"]) + ); + } + for (b, want) in fx["api_error"].as_object().unwrap() { + assert_eq!(pa::extract_api_error(b.as_bytes()), s(want), "{b:?}"); + } + for c in fx["join"].as_array().unwrap() { + assert_eq!(pa::join_args(&strs(&c["args"])), s(&c["out"]), "{c}"); + } + for c in fx["set_key"].as_array().unwrap() { + assert_eq!( + pa::preset_set_key(s(&c["content"]), s(&c["key"]), s(&c["val"])), + s(&c["out"]), + "{c}" + ); + } + for c in fx["autoplace"].as_array().unwrap() { + match pa::auto_placement_preset(s(&c["in"])) { + Ok((out, changes)) => { + assert_eq!(s(&c["err"]), "", "{c}"); + assert_eq!(out, s(&c["out"]), "{c}"); + assert_eq!(changes, strs(&c["changes"]), "{c}"); + } + Err(e) => assert_eq!(e, s(&c["err"]), "{c}"), + } + } + for c in fx["bench_fp"].as_array().unwrap() { + assert_eq!( + pa::bench_fingerprint(&cfg(&c["cfg"]), &cfg(&c["cur"])), + s(&c["out"]), + "{c}" + ); + } + let tmp = tempfile::tempdir().unwrap(); + for (p, want) in fx["imposes"].as_object().unwrap() { + let p = p.replace("{{HOME}}", tmp.path().to_str().unwrap()); + assert_eq!( + pa::preset_imposes_engine(tmp.path(), &p), + want.as_bool().unwrap(), + "{p:?}" + ); + } +} + +/// Faux service de complétions : mêmes réponses que celui du test Go. +async fn fake_completions() -> String { + use http_body_util::{BodyExt, Full}; + use hyper::service::service_fn; + use hyper_util::rt::TokioIo; + let l = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap(); + let addr = l.local_addr().unwrap(); + tokio::spawn(async move { + while let Ok((sock, _)) = l.accept().await { + tokio::spawn(async move { + let svc = service_fn(|req: hyper::Request| async move { + let path = req.uri().path().to_string(); + let auth = req + .headers() + .get("authorization") + .map(|v| v.to_str().unwrap().to_string()) + .unwrap_or_default(); + let body = req.into_body().collect().await.unwrap().to_bytes(); + let (code, out): (u16, &str) = if path.starts_with("/ok/") { + if String::from_utf8_lossy(&body).contains("\"max_tokens\":1") + && auth == "Bearer k1" + { + (200, "{}") + } else { + (400, "") + } + } else if path.starts_with("/refus/") { + (401, r#"{"error":{"message":"clé invalide"}}"#) + } else if path.starts_with("/brut/") { + (404, " page absente ") + } else { + (500, "") + }; + let mut r = hyper::Response::new(Full::new(bytes::Bytes::from(out))); + *r.status_mut() = hyper::StatusCode::from_u16(code).unwrap(); + Ok::<_, std::convert::Infallible>(r) + }); + let _ = hyper::server::conn::http1::Builder::new() + .serve_connection(TokioIo::new(sock), svc) + .await; + }); + } + }); + format!("http://{addr}") +} + +fn apply_store(go: &fakego::FakeGo, m: &serde_json::Map) { + for (k, v) in m { + let (b, key) = k.split_once('/').unwrap(); + let mut st = go.store.lock().unwrap(); + let bucket = st.entry(b.to_string()).or_default(); + if s(v).is_empty() { + bucket.remove(key); + } else { + bucket.insert(key.to_string(), s(v).as_bytes().to_vec()); + } + } +} + +fn disk(home: &Path) -> BTreeMap { + let mut out = BTreeMap::new(); + for d in ["presets", "models"] { + if let Ok(rd) = std::fs::read_dir(home.join(d)) { + for e in rd.flatten() { + out.insert( + format!("{d}/{}", e.file_name().to_string_lossy()), + String::from_utf8_lossy(&std::fs::read(e.path()).unwrap()).into_owned(), + ); + } + } + } + out +} + +/// Retire récursivement les champs à zéro : le Go réécrit les mesures restantes +/// par sa struct (champs nuls ajoutés), la façade les garde telles quelles — +/// même lecture pour le Go. +fn sans_zeros(v: &Value) -> Value { + match v { + Value::Object(m) => Value::Object( + m.iter() + .filter(|(_, x)| { + !(x.is_null() + || x.as_f64() == Some(0.0) + || x.as_str() == Some("") + || x.as_bool() == Some(false)) + }) + .map(|(k, x)| (k.clone(), sans_zeros(x))) + .collect(), + ), + Value::Array(a) => Value::Array(a.iter().map(sans_zeros).collect()), + x => x.clone(), + } +} + +fn state(go: &fakego::FakeGo) -> Value { + let mut st = serde_json::Map::new(); + if let Some(b) = go.store.lock().unwrap().get("state") { + for (k, v) in b { + let txt = String::from_utf8_lossy(v).into_owned(); + if k.starts_with("sysprompt:") { + st.insert(k.clone(), json!(txt)); + } else if k == "preset_order" || k == "bench_presets" { + st.insert(k.clone(), serde_json::from_str(&txt).unwrap_or(Value::Null)); + } + } + } + Value::Object(st) +} + +#[tokio::test] +async fn parite_des_routes() { + let fx = fixture(); + let tmp = tempfile::tempdir().unwrap(); + let home = tmp.path().to_path_buf(); + let h = home.to_string_lossy().into_owned(); + let url = fake_completions().await; + let opts = fakego::Opts { + presets: true, + ..Default::default() + }; + let (facade, go, _stop, _) = fakego::start_with(home.clone(), opts).await; + apply_store(&go, fx["seed"].as_object().unwrap()); + for (i, st) in fx["steps"].as_array().unwrap().iter().enumerate() { + if let Some(files) = st.get("files").and_then(Value::as_object) { + for (rel, content) in files { + let p = home.join(rel); + if s(content).is_empty() { + let _ = std::fs::remove_file(&p); + } else { + std::fs::create_dir_all(p.parent().unwrap()).unwrap(); + std::fs::write(&p, s(content)).unwrap(); + } + } + } + if let Some(m) = st.get("store").and_then(Value::as_object) { + apply_store(&go, m); + } + let (m, p) = (s(&st["method"]), s(&st["path"])); + let body = st + .get("body") + .and_then(Value::as_str) + .unwrap_or("") + .replace("{{URL}}", &url); + let (code, b) = fakego::send(facade, m, p, &[], &body).await; + let ctx = format!("étape {i} : {m} {p} {body}"); + let txt = String::from_utf8_lossy(&b) + .replace(&url, "{{URL}}") + .replace(&h, "{{HOME}}"); + assert_eq!( + u64::from(code.as_u16()), + st["status"].as_u64().unwrap(), + "{ctx} : {txt}" + ); + let got: Value = serde_json::from_str(&txt).unwrap(); + assert_eq!(got, st["out"], "{ctx}"); + let d: BTreeMap = disk(&home) + .into_iter() + .map(|(k, v)| (k, v.replace(&url, "{{URL}}"))) + .collect(); + let want_disk: BTreeMap = + serde_json::from_value(st["disk"].clone()).unwrap(); + assert_eq!(d, want_disk, "{ctx} (disque)"); + assert_eq!( + sans_zeros(&state(&go)), + sans_zeros(&st["state"]), + "{ctx} (base)" + ); + } + assert!(go.preset_applies.lock().unwrap().is_empty()); +} + +/// Bascule, enregistrement du preset en service, verrou de l'optimiseur : +/// la façade fait appliquer par le Go. +#[tokio::test] +async fn bascule_et_application() { + let tmp = tempfile::tempdir().unwrap(); + let home = tmp.path().to_path_buf(); + let opts = fakego::Opts { + presets: true, + ..Default::default() + }; + let (facade, go, _stop, _) = fakego::start_with(home.clone(), opts).await; + let dir = home.join("presets"); + std::fs::create_dir_all(&dir).unwrap(); + std::fs::write(dir.join("a.env"), "# NAME=Alpha\nMODEL=a.gguf\n").unwrap(); + std::fs::write(dir.join("b.env"), "# NAME=Beta\nMODEL=b.gguf\n").unwrap(); + std::fs::write(dir.join("refus.env"), "# NAME=Zut\nMODEL=z.gguf\n").unwrap(); + go.set("config", "MODEL", "a.gguf"); + + let (c, b) = fakego::send(facade, "POST", "/api/switch", &[], r#"{"id":"b"}"#).await; + assert_eq!(c, 200); + assert_eq!( + serde_json::from_slice::(&b).unwrap(), + json!({ "ok": true, "preset": "Beta" }) + ); + let (c, b) = fakego::send(facade, "POST", "/api/switch", &[], r#"{"n":1}"#).await; + assert_eq!(c, 200, "{}", String::from_utf8_lossy(&b)); + let (c, b) = fakego::send(facade, "POST", "/api/switch", &[], r#"{"id":"zz"}"#).await; + assert_eq!(c, 404); + assert_eq!( + serde_json::from_slice::(&b).unwrap()["error"], + "preset introuvable (supprimé ou renommé ?)" + ); + let (c, _) = fakego::send(facade, "POST", "/api/switch", &[], r#"{"n":9}"#).await; + assert_eq!(c, 400); + let (c, b) = fakego::send(facade, "POST", "/api/switch", &[], r#"{"id":"refus"}"#).await; + assert_eq!(c, 500); + assert_eq!( + serde_json::from_slice::(&b).unwrap()["error"], + "application refusée" + ); + // Le preset en service (a) modifié : appliqué, moteur relancé par le Go. + let (c, b) = fakego::send( + facade, + "POST", + "/api/preset/save", + &[], + r#"{"id":"a","name":"Alpha","content":"MODEL=a.gguf\nCTX=100000\n","sysprompt":"Bref."}"#, + ) + .await; + assert_eq!(c, 200); + assert_eq!( + serde_json::from_slice::(&b).unwrap()["applied"], + true + ); + assert_eq!(go.get("state", "sysprompt:a").as_deref(), Some("Bref.")); + let applies = go.preset_applies.lock().unwrap().clone(); + let names: Vec<(String, String)> = applies + .iter() + .map(|(p, m)| { + ( + Path::new(p) + .file_name() + .unwrap() + .to_string_lossy() + .into_owned(), + m.clone(), + ) + }) + .collect(); + assert_eq!( + names, + vec![ + ("b.env".to_string(), "switch".to_string()), + ("a.env".to_string(), "switch".to_string()), + ("a.env".to_string(), "save".to_string()), + ] + ); + // Optimisation en cours : refus, rien d'appliqué. + *go.tune_busy.lock().unwrap() = Some("une optimisation tient le moteur".into()); + let (c, b) = fakego::send(facade, "POST", "/api/switch", &[], r#"{"id":"b"}"#).await; + assert_eq!(c, 409); + assert_eq!( + serde_json::from_slice::(&b).unwrap(), + json!({ "ok": false, "tuning": true, "error": "une optimisation tient le moteur" }) + ); + let (c, _) = fakego::send( + facade, + "POST", + "/api/preset/save", + &[], + r#"{"name":"N","content":"X=1"}"#, + ) + .await; + assert_eq!(c, 409); + assert_eq!(go.preset_applies.lock().unwrap().len(), 3); +} + +#[tokio::test] +async fn sans_la_variable_les_presets_restent_au_go() { + let tmp = tempfile::tempdir().unwrap(); + let (facade, _go, _stop) = fakego::start(tmp.path().to_path_buf()).await; + for p in [ + "/api/presets", + "/api/preset/save", + "/api/switch", + "/api/preset/external/test", + ] { + let (_, _, b) = fakego::get(facade, p, &[]).await; + assert_eq!(&b[..], format!("go:{p}").as_bytes()); + } +} diff --git a/rust/loki-rs/tests/proxy.rs b/rust/loki-rs/tests/proxy.rs index 8bdaadb..da89148 100644 --- a/rust/loki-rs/tests/proxy.rs +++ b/rust/loki-rs/tests/proxy.rs @@ -106,6 +106,7 @@ async fn facade(backend: SocketAddr) -> (SocketAddr, oneshot::Sender<()>) { files: None, push: None, mem: None, + presets: false, }; tokio::spawn(loki_rs::proxy::serve(l, cfg, async move { let _ = rx.await; From 2da2efc1fdda15ff6bb08ffb3e83664f8493ff21 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 15 Oct 2026 18:33:31 +0000 Subject: [PATCH 13/52] =?UTF-8?q?Lot=2016=20:=20pilotage=20du=20moteur=20t?= =?UTF-8?q?enu=20par=20la=20fa=C3=A7ade=20(LOKI=5FENGINE=3Drust)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit La façade sert /api/start, /api/stop, /api/restart, /api/vram/unload et /api/vram/reload quand LOKI_ENGINE=rust (expérimental ; le Go reste le défaut). Elle lance elle-même « loki-rs engine », le superviseur que le Go appelait déjà en conteneur, avec l'environnement qu'il lui passait et de quoi joindre l'API interne du Go ; sa dernière ligne d'erreur devient l'erreur rendue. Le contrôle d'avant démarrage (preflightEngine : preset externe, Strata, BIN, MODEL, tranches) et le bilan de VRAM du déchargement (gpuSettle) sont portés. La façade ne tient ce lot qu'en conteneur : sous systemd ou avec LOKI_ENGINE_SUPERVISOR=go, loki-rs ne passe pas la variable au Go. Restent au Go derrière son API interne (facade_engine.go) : le verrou de l'optimiseur avant tout démarrage (/internal/v1/service/prestart), la réponse en cours quand il tient la conversation (/internal/v1/chat/generating) et l'arrêt de la dictée quand il la tient (/internal/v1/dictate/stop). Parité : parity_engine_test.go tire du Go dix bilans de VRAM, vingt-six installations passées au préflight, les messages du superviseur et dix réponses de route, rejoués par tests/enginectl.rs ; démarrage, arrêt, redémarrage, déchargement chiffré et rechargement vérifiés de bout en bout avec un faux superviseur et un faux nvidia-smi ; TestFacadeMoteur côté Go. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_016tcGfJWefW5kUGoC2wL8cv --- README.md | 1 + ...0-15-rust-migration-lot16-moteur-design.md | 90 +++ internal/loki/facade_chat.go | 6 +- internal/loki/facade_chat_test.go | 75 +++ internal/loki/facade_engine.go | 90 +++ internal/loki/parity_engine_test.go | 292 +++++++++ internal/loki/web_internal.go | 6 + rust/loki-rs/src/api.rs | 3 + rust/loki-rs/src/enginectl.rs | 561 ++++++++++++++++++ rust/loki-rs/src/gguf.rs | 10 + rust/loki-rs/src/lib.rs | 1 + rust/loki-rs/src/main.rs | 37 ++ rust/loki-rs/src/proxy.rs | 9 + rust/loki-rs/src/sysinfo.rs | 2 +- rust/loki-rs/tests/common/fakego.rs | 21 + rust/loki-rs/tests/enginectl.rs | 470 +++++++++++++++ rust/loki-rs/tests/lot2.rs | 1 + rust/loki-rs/tests/parity_engine/engine.json | 551 +++++++++++++++++ rust/loki-rs/tests/proxy.rs | 1 + 19 files changed, 2225 insertions(+), 2 deletions(-) create mode 100644 docs/superpowers/specs/2026-10-15-rust-migration-lot16-moteur-design.md create mode 100644 internal/loki/facade_engine.go create mode 100644 internal/loki/parity_engine_test.go create mode 100644 rust/loki-rs/src/enginectl.rs create mode 100644 rust/loki-rs/tests/enginectl.rs create mode 100644 rust/loki-rs/tests/parity_engine/engine.json diff --git a/README.md b/README.md index adc92da..1cbf23a 100644 --- a/README.md +++ b/README.md @@ -236,6 +236,7 @@ redémarrages (volume `/data`). Variables d'environnement du conteneur : | `LOKI_MEM` | `rust` : la mémoire (éditeur des pages, index, déplacement entre projets, trackers, mode mémoire, interrupteurs du mode agent) est tenue par la façade Rust ; le chiffrement reste au Go (expérimental, migration en cours) | Go | | `LOKI_SERVE` | `rust` : la ligne de commande de llama-server est composée par la façade Rust (`loki-rs engine serve`) au lieu de `loki serve` ; Strata et l'optimiseur restent au Go (expérimental, migration en cours) | Go | | `LOKI_PRESETS` | `rust` : les presets (liste, éditeur et aperçus, suppression, placement auto, presets externes, bascule) sont tenus par la façade Rust ; l'application au moteur reste au Go (expérimental, migration en cours) | Go | +| `LOKI_ENGINE` | `rust` : le pilotage du moteur (démarrage, arrêt, redémarrage, déchargement et rechargement de la VRAM) est tenu par la façade Rust, en conteneur avec son superviseur ; le verrou de l'optimiseur reste au Go (expérimental, migration en cours) | Go | **Accès par nom de domaine.** Sans clé de pilotage, l'API n'accepte que les hôtes locaux (IP, `localhost`, nom sans point, `.local`/`.lan`…) et refuse diff --git a/docs/superpowers/specs/2026-10-15-rust-migration-lot16-moteur-design.md b/docs/superpowers/specs/2026-10-15-rust-migration-lot16-moteur-design.md new file mode 100644 index 0000000..dc24284 --- /dev/null +++ b/docs/superpowers/specs/2026-10-15-rust-migration-lot16-moteur-design.md @@ -0,0 +1,90 @@ +# Lot 16 — pilotage du moteur tenu par la façade + +## Contexte + +Le Go pilotait le moteur depuis l'interface : +- `svcHandler` (`web_api.go`) pour `/api/start`, `/api/stop` et + `/api/restart` ; +- `handleVramUnload` et `handleVramReload` (`web_vram.go`) pour rendre la + carte graphique à la machine, puis la reprendre ; +- `serviceAction` (`sys_service.go`) : le verrou de l'optimiseur, puis, en + conteneur, le superviseur Rust `loki-rs engine ` (lot 3, + `sys_service_rust_linux.go`) ; +- `preflightEngine` : ce sans quoi le moteur ne peut pas démarrer, dit avant + de le lancer. + +Lot 16 : la façade tient ces routes quand `LOKI_ENGINE=rust` +(`enginectl.rs`, expérimental). Sans la variable, rien ne change. + +| Route | Rôle | +|---|---| +| `/api/start`, `/api/stop`, `/api/restart` | pilotage du moteur ; réponse `{ok, out}` comme le Go | +| `POST /api/vram/unload` | arrête le moteur et la dictée, rapporte la VRAM rendue ; refusé pendant une réponse en cours sauf `{force: true}` | +| `POST /api/vram/reload` | relance le moteur après le contrôle d'avant démarrage ; `{already: true}` s'il tourne | + +La façade lance elle-même `loki-rs engine `, avec l'environnement que +le Go lui passait (`LOKI_GO_BIN`, `LOKI_HOME`, `LOKI_SERVICE`) et de quoi +joindre l'API interne du Go pour `loki-rs engine serve` (`LOKI_GO_ADDR`, +`LOKI_INTERNAL_TOKEN`). Sa dernière ligne d'erreur devient l'erreur rendue, +sans le préfixe du binaire. Un preset externe ne lance rien ; un redémarrage +arrête alors le moteur local. + +Le bilan de VRAM suit `gpuSettle` : +- une lecture ratée est sautée, jamais comptée « 0 Mo » ; +- un palier ne conclut qu'une fois la baisse commencée ; +- les jauges du moniteur sont invalidées après coup. + +## Quand la façade ne tient pas ce lot + +La façade ne prend ce lot qu'en conteneur, avec son superviseur. Sous +systemd, ou avec `LOKI_ENGINE_SUPERVISOR=go`, `loki-rs` ne passe pas +`LOKI_ENGINE` au Go, qui garde les routes (décidé au démarrage, raison dans +le journal). + +## Restent au Go + +Le Go expose trois routes internes (`facade_engine.go`) : +- `POST /internal/v1/service/prestart` : avant tout démarrage, refus pendant + une optimisation, puis ménage d'un verrou périmé (essai orphelin arrêté, + application interrompue défaite). Le verrou de l'optimiseur suit le + processus qui le tient, il reste au Go jusqu'au lot de l'optimiseur ; +- `GET /internal/v1/chat/generating` : la réponse en cours, quand le Go tient + la conversation (sinon la façade lit la sienne) ; +- `POST /internal/v1/dictate/stop` : l'arrêt de la dictée, quand le Go la + tient (sinon la façade arrête la sienne). + +`GET /internal/v1/tune/guard` (lot 15) sert le refus 409 du rechargement. + +Les autres appelants de `serviceAction` côté Go (bascule de preset, +optimiseur, mises à jour du moteur) passent toujours par le Go. Les deux voies +lancent le même superviseur, sur les mêmes fichiers PID et journal. + +## Écarts assumés + +- `engineNeedsStop` regarde aussi les états transitoires de systemd. En + conteneur, seul cas où la façade tient ce lot, il se réduit au PID vivant. +- Un superviseur tué par un signal est décrit comme par le Go (« signal: + killed ») pour les signaux courants, par son numéro sinon. + +## Parité + +`internal/loki/parity_engine_test.go` tire du Go : +- dix bilans de VRAM (palier avant la baisse, lectures ratées, VRAM en + hausse…) ; +- la dernière ligne d'erreur du superviseur et le retrait des couleurs ; +- vingt-six installations passées au contrôle d'avant démarrage (preset + externe, Strata, BIN absent ou relatif, MODEL absent, hors des dossiers, + remontant, en tranches incomplètes…) ; +- dix réponses de route qui ne lancent rien (GET refusé avec `Allow: POST`, + optimisation ou réponse en cours, moteur déjà arrêté, contrôle en échec). + +`tests/enginectl.rs` rejoue le tout par la façade. Il vérifie aussi de bout en +bout, avec un faux superviseur et un faux nvidia-smi : +- démarrage, arrêt, redémarrage, refus pendant une optimisation ; +- échec du démarrage, preset externe ; +- déchargement chiffré, arrêt en échec, VRAM en hausse ; +- rechargement. + +Une mutation de chaque règle fait échouer les tests. + +Côté Go, `TestFacadeMoteur` vérifie le relais et les trois routes internes. diff --git a/internal/loki/facade_chat.go b/internal/loki/facade_chat.go index c300872..b651fbb 100644 --- a/internal/loki/facade_chat.go +++ b/internal/loki/facade_chat.go @@ -143,7 +143,9 @@ var facadeChatRoutes = map[string]bool{ // withFacadeChat remet la requête à la façade quand elle tient la // conversation — ou les routes des modèles (facade_models.go), la dictée // (facade_dictate.go), les réglages partagés (facade_settings.go), les fichiers -// de la discussion (facade_files.go), les notifications (facade_push.go). +// de la discussion (facade_files.go), les notifications (facade_push.go), la +// mémoire (facade_mem.go), les presets (facade_presets.go), le pilotage du +// moteur (facade_engine.go). // Posé APRÈS requireWebAuth : seule une requête authentifiée ici part, avec le // jeton interne qui en tient lieu là-bas. func withFacadeChat(path string, h http.HandlerFunc) http.HandlerFunc { @@ -164,6 +166,8 @@ func withFacadeChat(path string, h http.HandlerFunc) http.HandlerFunc { held, unavailable = facadeMem, "mémoire indisponible : " case facadePresetsRoutes[path]: held, unavailable = facadePresets, "presets indisponibles : " + case facadeEngineRoutes[path]: + held, unavailable = facadeEngine, "pilotage du moteur indisponible : " default: return h } diff --git a/internal/loki/facade_chat_test.go b/internal/loki/facade_chat_test.go index daddc16..2a7485a 100644 --- a/internal/loki/facade_chat_test.go +++ b/internal/loki/facade_chat_test.go @@ -570,3 +570,78 @@ func TestFacadePresets(t *testing.T) { t.Fatalf("reçu par la façade : %d appels", n) } } + +// Le pilotage du moteur (LOKI_ENGINE=rust, lot 16) part à la façade, et l'API +// interne lui rend ce qui reste ici : verrou de l'optimiseur avant un +// démarrage, génération en cours, arrêt de la dictée. +func TestFacadeMoteur(t *testing.T) { + testHome(t) + f := demarrerFacade(t) + t.Setenv(facadeChatEnv, "") + local := func(w http.ResponseWriter, r *http.Request) { io.WriteString(w, "go") } + for p := range facadeEngineRoutes { + w := httptest.NewRecorder() + withFacadeChat(p, local)(w, httptest.NewRequest("POST", p, nil)) + if w.Body.String() != "go" { + t.Fatalf("%s servi ailleurs sans LOKI_ENGINE : %q", p, w.Body.String()) + } + } + t.Setenv(facadeEngineEnv, "rust") + for p := range facadeEngineRoutes { + w := httptest.NewRecorder() + withFacadeChat(p, local)(w, httptest.NewRequest("POST", p, strings.NewReader("{}"))) + if w.Body.String() != f.reply { + t.Fatalf("%s : réponse %q", p, w.Body.String()) + } + } + if n := len(f.recu()); n != len(facadeEngineRoutes) { + t.Fatalf("reçu par la façade : %d appels", n) + } + + // Avant un démarrage : le verrou, puis le ménage. + appels := 0 + old := enginePrestart + t.Cleanup(func() { enginePrestart = old }) + enginePrestart = func() error { + appels++ + if appels == 2 { + return errTuneBusy + } + return nil + } + for i, want := range []string{`{"error":""}`, `{"error":"` + errTuneBusy.Error() + `"}`} { + w := httptest.NewRecorder() + handleInternalServicePrestart(w, httptest.NewRequest("POST", "/internal/v1/service/prestart", nil)) + if got := strings.TrimSpace(w.Body.String()); w.Code != 200 || got != want { + t.Fatalf("prestart %d : %d %s", i, w.Code, got) + } + } + w := httptest.NewRecorder() + handleInternalServicePrestart(w, httptest.NewRequest("GET", "/internal/v1/service/prestart", nil)) + if w.Code != 405 || appels != 2 { + t.Fatalf("prestart en GET : %d (%d appels)", w.Code, appels) + } + + // La génération en cours de la conversation tenue ici. + for _, gen := range []bool{false, true} { + conv.mu.Lock() + conv.Generating = gen + conv.mu.Unlock() + w := httptest.NewRecorder() + handleInternalChatGenerating(w, httptest.NewRequest("GET", "/internal/v1/chat/generating", nil)) + var out struct{ Generating bool } + if err := json.Unmarshal(w.Body.Bytes(), &out); err != nil || out.Generating != gen { + t.Fatalf("generating=%v : %s", gen, w.Body.String()) + } + } + conv.mu.Lock() + conv.Generating = false + conv.mu.Unlock() + + // L'arrêt de la dictée (rien ne tourne : immédiat). + w = httptest.NewRecorder() + handleInternalDictateStop(w, httptest.NewRequest("POST", "/internal/v1/dictate/stop", nil)) + if w.Code != 204 { + t.Fatalf("arrêt de la dictée : %d", w.Code) + } +} diff --git a/internal/loki/facade_engine.go b/internal/loki/facade_engine.go new file mode 100644 index 0000000..cc2732a --- /dev/null +++ b/internal/loki/facade_engine.go @@ -0,0 +1,90 @@ +package loki + +import ( + "net/http" + "os" + "strings" +) + +// facade_engine.go — pilotage du moteur tenu par la façade Rust +// (LOKI_ENGINE=rust, lot 16 de la migration ; expérimental, le Go reste le +// défaut) : démarrage, arrêt, redémarrage (/api/start|stop|restart) et +// déchargement / rechargement de la VRAM (/api/vram/unload|reload). +// +// La façade lance elle-même « loki-rs engine » — le superviseur que +// le Go appelait déjà en conteneur (sys_service_rust_linux.go) — et contrôle +// le moteur avant de le relancer (preflightEngine). Elle ne tient ce lot qu'en +// conteneur avec ce superviseur : sous systemd, ou avec +// LOKI_ENGINE_SUPERVISOR=go, le Go le garde (décidé par loki-rs au démarrage). +// +// Ce qui vit ici reste ici jusqu'au lot qui le portera, derrière l'API +// interne : +// +// - le verrou de l'optimiseur, consulté puis débarrassé d'un essai orphelin +// avant tout démarrage (/internal/v1/service/prestart) ; +// - la génération en cours quand le Go tient la conversation +// (/internal/v1/chat/generating) ; +// - l'arrêt de la dictée quand le Go la tient (/internal/v1/dictate/stop). + +const facadeEngineEnv = "LOKI_ENGINE" + +// facadeEngine : la façade pilote-t-elle le moteur ? +func facadeEngine() bool { + return strings.EqualFold(strings.TrimSpace(os.Getenv(facadeEngineEnv)), "rust") && + facadeAddr() != "" && internalToken() != "" +} + +// facadeEngineRoutes : les routes que la façade sert alors (enginectl.rs). +var facadeEngineRoutes = map[string]bool{ + "/api/start": true, + "/api/stop": true, + "/api/restart": true, + "/api/vram/unload": true, + "/api/vram/reload": true, +} + +// enginePrestart : ce que serviceAction fait avant un démarrage — refus +// pendant une optimisation, puis ménage d'un verrou périmé (essai orphelin +// arrêté, application interrompue défaite). Variable pour les tests. +var enginePrestart = func() error { + if err := tuneGuard(); err != nil { + return err + } + tuneReapStale() + return nil +} + +// handleInternalServicePrestart : POST → 200 {error} ("" : la voie est +// libre). +func handleInternalServicePrestart(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPost { + sendJSON(w, http.StatusMethodNotAllowed, map[string]any{"error": "POST attendu"}) + return + } + msg := "" + if err := enginePrestart(); err != nil { + msg = err.Error() + } + sendJSON(w, http.StatusOK, map[string]any{"error": msg}) +} + +// handleInternalChatGenerating : GET → 200 {generating} — la conversation +// tenue ici écrit-elle une réponse ? +func handleInternalChatGenerating(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodGet { + sendJSON(w, http.StatusMethodNotAllowed, map[string]any{"error": "GET attendu"}) + return + } + sendJSON(w, http.StatusOK, map[string]any{"generating": conv.isGenerating()}) +} + +// handleInternalDictateStop : POST → 204 — arrête la dictée tenue ici, sans +// attendre derrière un démarrage (asrShutdownVite). +func handleInternalDictateStop(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPost { + sendJSON(w, http.StatusMethodNotAllowed, map[string]any{"error": "POST attendu"}) + return + } + asrShutdownVite() + w.WriteHeader(http.StatusNoContent) +} diff --git a/internal/loki/parity_engine_test.go b/internal/loki/parity_engine_test.go new file mode 100644 index 0000000..3d3bf3d --- /dev/null +++ b/internal/loki/parity_engine_test.go @@ -0,0 +1,292 @@ +package loki + +import ( + "encoding/json" + "net/http" + "net/http/httptest" + "os" + "path/filepath" + "strings" + "testing" +) + +// parity_engine_test.go — journal de référence du pilotage du moteur (lot 16 +// de la migration Rust) : le bilan de VRAM après déchargement (gpuSettle), le +// contrôle d'avant démarrage (preflightEngine) sur une série d'installations, +// la dernière ligne d'erreur du superviseur (lastErrorLine) et les réponses +// des routes qui ne lancent aucun processus (GET refusé, optimisation en +// cours, génération en cours, moteur déjà arrêté, préflight en échec). Rangé +// dans rust/loki-rs/tests/parity_engine/engine.json ; tests/enginectl.rs le +// rejoue par la façade. +// +// LOKI_PARITY_UPDATE=1 go test ./internal/loki -run TestPariteMoteur + +const parityEngineFile = "../../rust/loki-rs/tests/parity_engine/engine.json" + +type parityPreflight struct { + Name string `json:"name"` + Config map[string]string `json:"config"` // « {{HOME}} » = LOKI_HOME + Files []string `json:"files,omitempty"` // relatifs à LOKI_HOME ; « / » final = dossier + Dirs []string `json:"dirs,omitempty"` // state/model_dirs, relatifs à LOKI_HOME + Out string `json:"out"` +} + +type parityEngineStep struct { + Name string `json:"name"` + Method string `json:"method"` + Path string `json:"path"` + Body string `json:"body,omitempty"` + Tune bool `json:"tune,omitempty"` // une optimisation tient le moteur + Generating bool `json:"generating,omitempty"` // une réponse est en cours + Config map[string]string `json:"config,omitempty"` + Status int `json:"status"` + Allow string `json:"allow,omitempty"` + Out any `json:"out"` +} + +func TestPariteMoteur(t *testing.T) { + home := testHome(t) + t.Setenv("LOKI_CONTAINER", "1") + t.Setenv("LOKI_MODEL_DIRS", "") + t.Setenv("LOKI_ENGINE_BIN", "") + + var fx struct { + Settle []map[string]any `json:"settle"` + LastError map[string]string `json:"last_error"` + Plain map[string]string `json:"plain"` + Preflight []parityPreflight `json:"preflight"` + Steps []parityEngineStep `json:"steps"` + } + + // --- gpuSettle --- + seq := func(vals []int) func() (int, bool) { + i := 0 + return func() (int, bool) { + if i >= len(vals) { + return vals[len(vals)-1], true + } + v := vals[i] + i++ + if v < 0 { + return 0, false + } + return v, true + } + } + for _, c := range []struct { + before int + seq []int + }{ + {14000, []int{20000, 20000, 12000, 3000, 500, 500}}, + {14000, []int{15000, 8000, 400, 400}}, + {14000, []int{14000, -1, 600, 600}}, + {14000, []int{-1, -1, -1, -1, -1, -1, -1, -1}}, + {14000, []int{14000, 14000, 14000, 14000, 14000, 14000, 14000, 14000}}, + {0, []int{0, 0, 0}}, + {9000, []int{9000, 8999, 8999}}, + {9000, []int{-1, 9000, -1, 9000, 100, -1, 100}}, + {5000, []int{6000, 7000, 7000, 7000, 7000, 7000, 7000, 7000}}, + {5000, []int{4000, 3000, 2000, 1000, 900, 800, 700, 600}}, + } { + fx.Settle = append(fx.Settle, map[string]any{"before": c.before, "seq": c.seq, + "out": gpuSettle(c.before, seq(c.seq), 0)}) + } + + // --- lastErrorLine / plainErr --- + fx.LastError = map[string]string{} + for _, s := range []string{ + "[loki-rs engine] service loki-engine non démarré\n", + "ligne 1\n[loki-rs engine] moteur absent \n\n", + "pas de préfixe", + "", + "[ok] démarré\n[attention] 2 processus du moteur toujours là\n", + } { + fx.LastError[s] = lastErrorLine(s) + } + fx.Plain = map[string]string{} + for _, s := range []string{ + "BIN non défini — installe un moteur : \x1b[1mloki llamacpp install\x1b[0m", + "\x1b[1;36mcouleur\x1b[0m et \x1b[32mvert\x1b[0m", + "rien à retirer", + "\x1b[mvide\x1b[0;1;2m", + } { + fx.Plain[s] = ansiRe.ReplaceAllString(s, "") + } + + // --- preflightEngine --- + writeFiles := func(files []string) { + for _, f := range files { + p := filepath.Join(home, f) + if strings.HasSuffix(f, "/") { + 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, []byte("x"), 0o755); err != nil { + t.Fatal(err) + } + } + } + reset := func() { + for _, d := range []string{"bin", "models", "strata", "ailleurs", "disque"} { + _ = os.RemoveAll(filepath.Join(home, d)) + } + cfg := ReadConfig() + for k := range cfg { + _ = SetConfigKey(k, "") + } + _ = putJSON(bkState, "model_dirs", []string{}) + } + setCfg := func(cfg map[string]string) { + for k, v := range cfg { + if err := SetConfigKey(k, strings.ReplaceAll(v, "{{HOME}}", home)); err != nil { + t.Fatal(err) + } + } + } + bin := "bin/llama-server" + ok := []string{bin, "models/m.gguf"} + for _, c := range []parityPreflight{ + {Name: "externe", Config: map[string]string{"EXTERNAL": "1"}}, + {Name: "externe sans BIN ni MODEL", Config: map[string]string{"EXTERNAL": " 1 ", "BIN": ""}}, + {Name: "strata sans config", Config: map[string]string{"ENGINE": "strata"}}, + {Name: "strata config absente", Config: map[string]string{"ENGINE": "Strata", "STRATA_CONFIG": "{{HOME}}/strata/m/config.json"}}, + {Name: "strata sans venv", Config: map[string]string{"ENGINE": "strata", "STRATA_CONFIG": "{{HOME}}/strata/m/config.json"}, + Files: []string{"strata/m/config.json"}}, + {Name: "strata prêt", Config: map[string]string{"ENGINE": "moe", "STRATA_CONFIG": "{{HOME}}/strata/m/config.json"}, + Files: []string{"strata/m/config.json", "strata/m/.venv/bin/python"}}, + {Name: "BIN vide", Config: map[string]string{"MODEL": "m.gguf"}}, + {Name: "BIN espaces", Config: map[string]string{"BIN": " ", "MODEL": "m.gguf"}}, + {Name: "BIN relatif absent", Config: map[string]string{"BIN": "bin/absent", "MODEL": "m.gguf"}}, + {Name: "BIN absolu absent", Config: map[string]string{"BIN": "{{HOME}}/ailleurs/llama-server", "MODEL": "m.gguf"}}, + {Name: "BIN dossier", Config: map[string]string{"BIN": "bin/", "MODEL": "m.gguf"}, Files: []string{"bin/"}}, + {Name: "MODEL vide", Config: map[string]string{"BIN": bin}, Files: []string{bin}}, + {Name: "MODEL absent", Config: map[string]string{"BIN": bin, "MODEL": "absent.gguf"}, Files: []string{bin}}, + {Name: "MODEL relatif", Config: map[string]string{"BIN": "{{HOME}}/" + bin, "MODEL": "m.gguf"}, Files: ok}, + {Name: "MODEL remonte", Config: map[string]string{"BIN": bin, "MODEL": "../m.gguf"}, Files: ok}, + {Name: "MODEL sous-dossier", Config: map[string]string{"BIN": bin, "MODEL": "sub/m.gguf"}, Files: ok}, + {Name: "MODEL absolu hors dossiers", Config: map[string]string{"BIN": bin, "MODEL": "{{HOME}}/ailleurs/x.gguf"}, + Files: []string{bin, "ailleurs/x.gguf"}}, + {Name: "MODEL absolu pas gguf", Config: map[string]string{"BIN": bin, "MODEL": "{{HOME}}/ailleurs/x.bin"}, + Files: []string{bin, "ailleurs/x.bin"}}, + {Name: "MODEL absolu absent", Config: map[string]string{"BIN": bin, "MODEL": "{{HOME}}/ailleurs/y.gguf"}, + Files: []string{bin}}, + {Name: "MODEL dossier déclaré", Config: map[string]string{"BIN": bin, "MODEL": "d.gguf"}, + Files: []string{bin, "disque/d.gguf"}, Dirs: []string{"disque"}}, + {Name: "MODEL chemin dans dossier déclaré", Config: map[string]string{"BIN": bin, "MODEL": "{{HOME}}/disque/d.gguf"}, + Files: []string{bin, "disque/d.gguf"}, Dirs: []string{"disque"}}, + {Name: "tranches manquantes", Config: map[string]string{"BIN": bin, "MODEL": "big-00001-of-00003.gguf"}, + Files: []string{bin, "models/big-00001-of-00003.gguf", "models/big-00003-of-00003.gguf"}}, + {Name: "tranches dossier", Config: map[string]string{"BIN": bin, "MODEL": "big-00001-of-00002.gguf"}, + Files: []string{bin, "models/big-00001-of-00002.gguf", "models/big-00002-of-00002.gguf/"}}, + {Name: "tranches complètes", Config: map[string]string{"BIN": bin, "MODEL": "big-00001-of-00002.gguf"}, + Files: []string{bin, "models/big-00001-of-00002.gguf", "models/big-00002-of-00002.gguf"}}, + {Name: "tranche 2 désignée", Config: map[string]string{"BIN": bin, "MODEL": "big-00002-of-00002.gguf"}, + Files: []string{bin, "models/big-00002-of-00002.gguf"}}, + {Name: "MODEL guillemets", Config: map[string]string{"BIN": bin, "MODEL": " m.gguf "}, Files: ok}, + } { + reset() + writeFiles(c.Files) + setCfg(c.Config) + if len(c.Dirs) > 0 { + var abs []string + for _, d := range c.Dirs { + abs = append(abs, filepath.Join(home, d)) + } + if err := putJSON(bkState, "model_dirs", abs); err != nil { + t.Fatal(err) + } + } + c.Out = strings.ReplaceAll(plainErr(preflightEngine()), home, "{{HOME}}") + fx.Preflight = append(fx.Preflight, c) + } + + // --- routes sans processus lancé --- + reset() + run := func(st parityEngineStep) parityEngineStep { + reset() + setCfg(st.Config) + var lock *tuneLock + if st.Tune { + l, err := tuneLockAcquire("web", false) + if err != nil { + t.Fatal(err) + } + lock = l + } + conv.mu.Lock() + conv.Generating = st.Generating + conv.mu.Unlock() + defer func() { + if lock != nil { + lock.release() + } + conv.mu.Lock() + conv.Generating = false + conv.mu.Unlock() + }() + h := map[string]http.HandlerFunc{ + "/api/vram/unload": handleVramUnload, + "/api/vram/reload": handleVramReload, + }[st.Path] + rec := httptest.NewRecorder() + h(rec, httptest.NewRequest(st.Method, st.Path, strings.NewReader(st.Body))) + st.Status = rec.Code + st.Allow = rec.Header().Get("Allow") + st.Out = parityDecode(t, rec.Body.String(), home) + return st + } + for _, st := range []parityEngineStep{ + {Name: "déchargement en GET", Method: "GET", Path: "/api/vram/unload"}, + {Name: "rechargement en GET", Method: "GET", Path: "/api/vram/reload"}, + {Name: "rechargement en PUT", Method: "PUT", Path: "/api/vram/reload"}, + {Name: "génération en cours", Method: "POST", Path: "/api/vram/unload", Body: `{}`, Generating: true}, + {Name: "génération en cours, corps illisible", Method: "POST", Path: "/api/vram/unload", Body: `nimp`, Generating: true}, + {Name: "moteur déjà arrêté", Method: "POST", Path: "/api/vram/unload", Body: `{"force":true}`, Generating: true}, + {Name: "moteur arrêté, sans corps", Method: "POST", Path: "/api/vram/unload"}, + {Name: "optimisation en cours", Method: "POST", Path: "/api/vram/reload", Tune: true}, + {Name: "préflight sans BIN", Method: "POST", Path: "/api/vram/reload", Config: map[string]string{"MODEL": "m.gguf"}}, + {Name: "préflight sans MODEL", Method: "POST", Path: "/api/vram/reload", + Config: map[string]string{"BIN": "/bin/sh"}}, + } { + fx.Steps = append(fx.Steps, run(st)) + } + + got, err := parityJSON(fx) + if err != nil { + t.Fatal(err) + } + got = []byte(strings.ReplaceAll(string(got), home, "{{HOME}}")) + if os.Getenv("LOKI_PARITY_UPDATE") == "1" { + if err := os.MkdirAll(filepath.Dir(parityEngineFile), 0o755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(parityEngineFile, got, 0o644); err != nil { + t.Fatal(err) + } + return + } + want, err := os.ReadFile(parityEngineFile) + 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("le pilotage du moteur ne produit plus le journal rangé dans %s.\n"+ + "Si c'est voulu : LOKI_PARITY_UPDATE=1 go test ./internal/loki -run TestPariteMoteur,\n"+ + "puis cargo test --test enginectl dit si le port Rust suit.\n--- obtenu ---\n%s", parityEngineFile, got) + } +} + +// parityDecode : le corps JSON d'une réponse, tel quel s'il n'en est pas un. +func parityDecode(t *testing.T, body, home string) any { + t.Helper() + var v any + if err := json.Unmarshal([]byte(strings.ReplaceAll(body, home, "{{HOME}}")), &v); err != nil { + return body + } + return v +} diff --git a/internal/loki/web_internal.go b/internal/loki/web_internal.go index e2d0e41..2274d93 100644 --- a/internal/loki/web_internal.go +++ b/internal/loki/web_internal.go @@ -113,6 +113,12 @@ func withInternalAPI(next http.Handler) http.Handler { handleInternalPresetApply(w, r) case "/internal/v1/tune/guard": handleInternalTuneGuard(w, r) + case "/internal/v1/service/prestart": + handleInternalServicePrestart(w, r) + case "/internal/v1/chat/generating": + handleInternalChatGenerating(w, r) + case "/internal/v1/dictate/stop": + handleInternalDictateStop(w, r) default: http.NotFound(w, r) } diff --git a/rust/loki-rs/src/api.rs b/rust/loki-rs/src/api.rs index e44c354..0487d3d 100644 --- a/rust/loki-rs/src/api.rs +++ b/rust/loki-rs/src/api.rs @@ -42,6 +42,8 @@ pub struct Ctx { pub mem: Option>, /// Presets tenus par la façade (`LOKI_PRESETS=rust`). pub presets: bool, + /// Pilotage du moteur tenu par la façade (`LOKI_ENGINE=rust`). + pub engine_ctl: Option>, /// Jeton de l'API interne : une requête qui le porte vient du Go. pub internal_token: String, } @@ -58,6 +60,7 @@ impl Ctx { || self.push.is_some() || self.mem.is_some() || self.presets + || self.engine_ctl.is_some() } /// La requête vient du Go (jeton interne valide) : déjà authentifiée par diff --git a/rust/loki-rs/src/enginectl.rs b/rust/loki-rs/src/enginectl.rs new file mode 100644 index 0000000..b3eb06a --- /dev/null +++ b/rust/loki-rs/src/enginectl.rs @@ -0,0 +1,561 @@ +//! Pilotage du moteur tenu par la façade (lot 16 de la migration, +//! `LOKI_ENGINE=rust` ; expérimental, le Go reste le défaut) : +//! +//! - `/api/start|stop|restart` — port de `svcHandler` (`web_api.go`) et de la +//! voie conteneur de `serviceAction` (`sys_service.go`, +//! `sys_service_rust_linux.go`) : la façade lance elle-même +//! `loki-rs engine `, le superviseur que le Go appelait déjà ; +//! - `/api/vram/unload` et `/api/vram/reload` — port de `handleVramUnload` et +//! `handleVramReload` (`web_vram.go`), bilan de VRAM (`gpuSettle`) compris ; +//! - `preflightEngine` (`sys_service.go`) : ce sans quoi le moteur ne PEUT +//! pas démarrer, dit avant de le lancer. +//! +//! Restent au Go, derrière son API interne, jusqu'au lot qui les portera : le +//! verrou de l'optimiseur (`/internal/v1/service/prestart` avant tout +//! démarrage, `/internal/v1/tune/guard`), la génération en cours quand il +//! tient la conversation (`/internal/v1/chat/generating`) et l'arrêt de la +//! dictée quand il la tient (`/internal/v1/dictate/stop`). +//! +//! La façade ne tient ce lot qu'en conteneur, avec son superviseur : sous +//! systemd ou avec `LOKI_ENGINE_SUPERVISOR=go`, le Go le garde ([`usable`]). + +use std::path::{Path, PathBuf}; +use std::sync::LazyLock; +use std::time::Duration; + +use http_body_util::{BodyExt, Limited}; +use hyper::body::Incoming; +use hyper::header::{ALLOW, HeaderValue}; +use hyper::{Method, Request, Response, StatusCode}; +use regex::Regex; +use serde_json::{Value, json}; + +use crate::api::Ctx; +use crate::auth::json as send_json; +use crate::gguf; +use crate::gojson::{self, Kind}; +use crate::paths::{go_dir, go_is_abs, go_join}; +use crate::proxy::Body; +use crate::serve::{self, Cfg}; +use crate::store::Store; +use crate::sysinfo::{self, GpuStat}; + +/// Ce que la façade sait du superviseur du moteur. +pub struct EngineCtl { + /// Le binaire lancé en `engine ` (`loki-rs` lui-même). + pub bin: PathBuf, + /// Variables passées au superviseur, en plus de l'environnement de la + /// façade : `LOKI_GO_BIN`, `LOKI_HOME`, `LOKI_SERVICE` (comme + /// `rustSvcAction`), et l'adresse et le jeton de l'API interne du Go, que + /// `loki-rs engine serve` (`LOKI_SERVE=rust`) lit. + pub env: Vec<(String, String)>, + /// nvidia-smi (un faux dans les tests). + pub nvidia_smi: String, + /// Pause entre deux lectures du bilan de VRAM (500 ms). + pub settle_pause: Duration, +} + +impl EngineCtl { + pub fn new(bin: PathBuf, env: Vec<(String, String)>) -> Self { + EngineCtl { + bin, + env, + nvidia_smi: "nvidia-smi".into(), + settle_pause: Duration::from_millis(500), + } + } + + /// `rustSvcAction` : `loki-rs engine `. Sa sortie s'affiche telle + /// quelle ; en cas d'échec, sa dernière ligne d'erreur devient l'erreur + /// rendue (c'est elle que l'UI montre), sans le préfixe du binaire. + pub async fn run(&self, action: &str) -> Result<(), String> { + use std::io::Write; + let out = tokio::process::Command::new(&self.bin) + .args(["engine", action]) + .envs(self.env.iter().map(|(k, v)| (k.as_str(), v.as_str()))) + .stdin(std::process::Stdio::null()) + .output() + .await + .map_err(|e| format!("loki-rs engine {action} : {e}"))?; + let _ = std::io::stdout().write_all(&out.stdout); + let _ = std::io::stderr().write_all(&out.stderr); + if out.status.success() { + return Ok(()); + } + let msg = last_error_line(&String::from_utf8_lossy(&out.stderr)); + if !msg.is_empty() { + return Err(msg); + } + Err(format!( + "loki-rs engine {action} : {}", + exit_text(&out.status) + )) + } + + /// `gpuStats` : lecture FRAÎCHE, vide sans mesure. + async fn gpus(&self) -> Vec { + sysinfo::nvidia_smi_query_bin(&self.nvidia_smi) + .await + .map(|o| sysinfo::parse_nvidia_smi(&String::from_utf8_lossy(&o))) + .unwrap_or_default() + } +} + +/// `ProcessState.String()` du Go : « exit status 1 », « signal: killed ». +fn exit_text(st: &std::process::ExitStatus) -> String { + if let Some(c) = st.code() { + return format!("exit status {c}"); + } + #[cfg(unix)] + { + use std::os::unix::process::ExitStatusExt; + if let Some(s) = st.signal() { + let name = match s { + 1 => "hangup".to_string(), + 2 => "interrupt".to_string(), + 6 => "aborted".to_string(), + 9 => "killed".to_string(), + 11 => "segmentation fault".to_string(), + 15 => "terminated".to_string(), + n => format!("signal {n}"), + }; + return format!("signal: {name}"); + } + } + st.to_string() +} + +/// La façade peut-elle tenir ce lot ? `Err(raison)` sinon : sous systemd +/// (`systemdAvailable`), le Go pilote son unité ; `LOKI_ENGINE_SUPERVISOR=go` +/// est la porte de sortie du superviseur Rust. +pub fn usable(container_env: Option<&str>, supervisor_env: Option<&str>) -> Result<(), String> { + if supervisor_env.is_some_and(|v| v.trim().eq_ignore_ascii_case("go")) { + return Err("LOKI_ENGINE_SUPERVISOR=go".into()); + } + if container_env != Some("1") && Path::new("/run/systemd/system").is_dir() { + return Err("systemd pilote le moteur".into()); + } + Ok(()) +} + +/// `lastErrorLine`. +pub fn last_error_line(s: &str) -> String { + let t = s.trim(); + let last = t.rsplit('\n').next().unwrap_or("").trim(); + last.strip_prefix("[loki-rs engine]") + .unwrap_or(last) + .trim() + .to_string() +} + +static ANSI: LazyLock = LazyLock::new(|| Regex::new("\x1b\\[[0-9;]*m").expect("motif ANSI")); + +/// `plainErr` : les séquences ANSI des messages écrits pour le terminal +/// retirées. +pub fn plain_err(s: &str) -> String { + ANSI.replace_all(s, "").into_owned() +} + +/// `gpuUsedMB`. +pub fn gpu_used_mb(gpus: &[GpuStat]) -> i64 { + gpus.iter().map(|g| g.used).sum() +} + +/// `gpuSettle` : attend que la VRAM ait fini de redescendre avant de la +/// relire. Une mesure ratée n'est pas « 0 Mo » (sautée) ; un palier ne +/// conclut qu'une fois la baisse commencée ; deux lectures égales de suite +/// après la baisse, et la valeur est rendue — sinon la dernière lecture après +/// huit essais. `sample` rend (Mo occupés, lecture réussie). +pub async fn gpu_settle(before: i64, mut sample: F, pause: Duration) -> i64 +where + F: FnMut() -> Fut, + Fut: std::future::Future, +{ + let (mut last, mut lu) = (before, false); + let (mut baisse, mut palier) = (false, 0); + for _ in 0..8 { + if !pause.is_zero() { + tokio::time::sleep(pause).await; + } + let (cur, ok) = sample().await; + if !ok { + continue; + } + if cur < before { + baisse = true; + } + if lu && cur == last { + palier += 1; + } else { + palier = 0; + } + (last, lu) = (cur, true); + if baisse && palier >= 1 { + return cur; + } + } + last +} + +/// `strataPreflight`. +fn strata_preflight(cfg: &Cfg) -> Result<(), String> { + let path = serve::trim(serve::get(cfg, "STRATA_CONFIG")); + if path.is_empty() { + return Err( + "STRATA_CONFIG non défini : réinstalle le modèle depuis Paramètres → Moteur → Strata" + .into(), + ); + } + if std::fs::metadata(path).is_err() { + return Err(format!( + "configuration de Strata introuvable : {path} — réinstalle le modèle" + )); + } + let py = go_join(&[&go_dir(path), ".venv", "bin", "python"]); + if std::fs::metadata(&py).is_err() { + return Err(format!( + "environnement Python de Strata absent ({py}) : réinstalle le modèle" + )); + } + Ok(()) +} + +/// `preflightEngine` (message déjà débarrassé de ses couleurs, comme +/// `plainErr` le rend à l'UI). Entrées/sorties bloquantes. +/// +/// - `stored_dirs` / `env_model_dirs` : voir +/// [`gguf::resolve_serve_model_path`]. +pub fn preflight_engine( + home: &Path, + cfg: &Cfg, + stored_dirs: &[String], + env_model_dirs: Option<&str>, +) -> Result<(), String> { + // Preset externe : aucun llama-server local à lancer. + if serve::is_external(cfg) { + return Ok(()); + } + // Strata n'a ni BIN ni MODEL : son installeur a tout rangé. + if serve::is_strata(cfg) { + return strata_preflight(cfg); + } + let mut bin = serve::trim(serve::get(cfg, "BIN")).to_string(); + if bin.is_empty() { + return Err( + "BIN non défini — installe un moteur : loki llamacpp install (ou renseigne BIN avec loki edit)" + .into(), + ); + } + if !go_is_abs(&bin) { + bin = go_join(&[&home.to_string_lossy(), &bin]); + } + let eng = serve::run::EnginePaths { + home: home.to_path_buf(), + }; + if std::fs::metadata(eng.prebuilt_resolve_bin(&bin)).is_err() { + return Err(format!( + "moteur introuvable : {bin} — relance loki llamacpp install" + )); + } + let model = serve::trim(serve::get(cfg, "MODEL")); + if model.is_empty() { + return Err(format!( + "MODEL non défini — indique un .gguf avec loki edit (dossier des modèles : {})", + gguf::models_dir(home) + )); + } + let p = gguf::resolve_serve_model_path(model, home, stored_dirs, env_model_dirs) + .map_err(|e| format!("MODEL={model} : {e}"))?; + let p = p.to_string_lossy().into_owned(); + if std::fs::metadata(&p).is_err() { + return Err(format!( + "modèle introuvable : {p} — corrige MODEL avec loki edit" + )); + } + // Modèle en plusieurs fichiers : une tranche manquante ne se voit qu'au + // moment où llama-server réclame un tenseur absent. + let (dir, base) = (go_dir(&p), go_base(&p)); + let missing = gguf::shard_family_missing(Path::new(&dir), &base); + if !missing.is_empty() { + return Err(format!( + "modèle incomplet : il manque {} dans {dir} — ce modèle tient en {} fichiers, télécharge-les tous", + missing.join(", "), + gguf::shard_family(&base).len() + )); + } + Ok(()) +} + +/// `filepath.Base`. +fn go_base(p: &str) -> String { + let t = p.trim_end_matches('/'); + if t.is_empty() { + return if p.is_empty() { ".".into() } else { "/".into() }; + } + t.rsplit('/').next().unwrap_or(t).to_string() +} + +/// Les routes servies par ce module. +pub fn handles(path: &str) -> bool { + matches!( + path, + "/api/start" | "/api/stop" | "/api/restart" | "/api/vram/unload" | "/api/vram/reload" + ) +} + +/// Avant un démarrage : refus pendant une optimisation, puis ménage d'un +/// verrou périmé — au Go (`enginePrestart`). +async fn prestart(store: &Store) -> Result<(), String> { + let (code, v) = store + .internal_post("/internal/v1/service/prestart", &json!({})) + .await + .map_err(|e| format!("verrou de l'optimiseur : {e}"))?; + if !code.is_success() { + return Err(format!("verrou de l'optimiseur : {code}")); + } + match v.get("error").and_then(Value::as_str) { + Some(e) if !e.is_empty() => Err(e.to_string()), + _ => Ok(()), + } +} + +/// `serviceAction` en conteneur : garde-fous du Go avant un démarrage, preset +/// externe (rien à lancer : `loki serve` sortirait aussitôt et le superviseur +/// le prendrait pour un plantage), puis le superviseur. +pub async fn service_action(ctx: &Ctx, ctl: &EngineCtl, action: &str) -> Result<(), String> { + if action == "start" || action == "restart" { + prestart(&ctx.store).await?; + } + match action { + "start" | "restart" => { + if serve::is_external(&ctx.store.config().await) { + if action == "restart" { + let _ = ctl.run("stop").await; + } + println!("[info] preset externe actif : pas de moteur local à démarrer"); + return Ok(()); + } + ctl.run(action).await + } + "stop" | "status" => ctl.run(action).await, + other => Err(format!("action inconnue: {other}")), + } +} + +/// `serviceIsActive` en conteneur : le PID du fichier vit. +fn service_is_active(ctx: &Ctx) -> bool { + pid_alive(crate::engine::read_pid(&ctx.engine.pid())) +} + +#[cfg(target_os = "linux")] +fn pid_alive(pid: i32) -> bool { + crate::engine::proctree::linux::pid_alive(pid) +} + +#[cfg(not(target_os = "linux"))] +fn pid_alive(_: i32) -> bool { + false +} + +/// La conversation écrit-elle une réponse ? Celle de la façade si elle la +/// tient, sinon celle du Go. +async fn generating(ctx: &Ctx) -> bool { + if let Some(chat) = &ctx.chat { + return chat.sessions.conv().is_generating(); + } + ctx.store + .internal_json(Method::GET, "/internal/v1/chat/generating", None) + .await + .ok() + .and_then(|v| v.get("generating").and_then(Value::as_bool)) + .unwrap_or(false) +} + +/// `asrShutdownVite` : la dictée de la façade si elle la tient, sinon celle +/// du Go. +async fn dictate_stop(ctx: &Ctx) { + if let Some(d) = &ctx.dictate { + d.shutdown_fast().await; + return; + } + if let Err(e) = ctx + .store + .internal_post("/internal/v1/dictate/stop", &json!({})) + .await + { + eprintln!("[dictée] arrêt demandé au Go : {e}"); + } +} + +/// Le verrou de l'optimiseur (au Go) : `Some(réponse 409)` s'il tient le +/// moteur. +async fn tune_deny(store: &Store) -> Option> { + let v = store + .internal_json(Method::GET, "/internal/v1/tune/guard", None) + .await + .ok()?; + if v.get("busy").and_then(Value::as_bool) == Some(true) { + return Some(send_json( + StatusCode::CONFLICT, + &json!({ "ok": false, "tuning": true, "error": v.get("error").and_then(Value::as_str).unwrap_or("") }), + )); + } + None +} + +/// `postOnly` : ces routes arrêtent ou relancent des processus ; un simple GET +/// (une balise `` sur une page tierce) ne doit pas y suffire. +fn post_only(method: &Method) -> Option> { + if method == Method::POST { + return None; + } + let mut r = send_json( + StatusCode::METHOD_NOT_ALLOWED, + &json!({ "ok": false, "error": format!("méthode {method} refusée : POST attendu") }), + ); + r.headers_mut() + .insert(ALLOW, HeaderValue::from_static("POST")); + Some(r) +} + +const UNLOAD_REQ_TYPE: &str = r#"struct { Force bool "json:\"force\"" }"#; + +/// `from_go` : remise par le Go (tunnel), déjà authentifiée par lui. +pub async fn serve( + ctx: &Ctx, + ctl: &EngineCtl, + req: Request, + from_go: bool, +) -> Response { + let (parts, body) = req.into_parts(); + if !from_go && let Err(refus) = ctx.guard(&parts.headers).await { + return *refus; + } + match parts.uri.path() { + p @ ("/api/start" | "/api/stop" | "/api/restart") => { + let action = &p["/api/".len()..]; + let res = service_action(ctx, ctl, action).await; + let msg = match &res { + Ok(()) => "ok".to_string(), + Err(e) => e.clone(), + }; + send_json(StatusCode::OK, &json!({ "ok": res.is_ok(), "out": msg })) + } + "/api/vram/unload" => { + if let Some(r) = post_only(&parts.method) { + return r; + } + let raw = Limited::new(body, 1 << 20) + .collect() + .await + .map(|b| b.to_bytes().to_vec()) + .unwrap_or_default(); + let force = gojson::decode_struct(&raw, UNLOAD_REQ_TYPE, &[("force", Kind::Bool)]) + .bool("force"); + if !force && generating(ctx).await { + return send_json( + StatusCode::CONFLICT, + &json!({ "ok": false, "generating": true, + "error": "une réponse est en cours d'écriture — décharger maintenant l'interromprait" }), + ); + } + unload(ctx, ctl).await + } + "/api/vram/reload" => { + if let Some(r) = post_only(&parts.method) { + return r; + } + if let Some(r) = tune_deny(&ctx.store).await { + return r; + } + if service_is_active(ctx) { + return send_json(StatusCode::OK, &json!({ "ok": true, "already": true })); + } + let cfg = ctx.store.config().await; + let d = crate::models::model_dirs(ctx).await; + let pre = tokio::task::spawn_blocking(move || { + preflight_engine(&d.home, &cfg, &d.stored, d.env.as_deref()) + }) + .await + .unwrap_or_else(|e| Err(e.to_string())); + if let Err(e) = pre { + return send_json( + StatusCode::OK, + &json!({ "ok": false, "error": plain_err(&e) }), + ); + } + if let Err(e) = service_action(ctx, ctl, "start").await { + return send_json( + StatusCode::OK, + &json!({ "ok": false, "error": plain_err(&e) }), + ); + } + send_json(StatusCode::OK, &json!({ "ok": true })) + } + _ => send_json(StatusCode::NOT_FOUND, &json!({ "error": "route inconnue" })), + } +} + +/// `handleVramUnload` après les refus : moteur arrêté, dictée coupée, bilan +/// de la VRAM rendue. +async fn unload(ctx: &Ctx, ctl: &EngineCtl) -> Response { + let gpus = ctl.gpus().await; + let before = gpu_used_mb(&gpus); + // `engineNeedsStop` : en conteneur, actif suffit (les états transitoires + // n'existent que sous systemd, où ce lot reste au Go). + let was_active = service_is_active(ctx); + let mut errs = Vec::new(); + if was_active && let Err(e) = service_action(ctx, ctl, "stop").await { + errs.push(format!("arrêt du moteur : {}", plain_err(&e))); + } + dictate_stop(ctx).await; + let mut after = before; + if !gpus.is_empty() { + after = gpu_settle( + before, + || async { + let g = ctl.gpus().await; + (gpu_used_mb(&g), !g.is_empty()) + }, + ctl.settle_pause, + ) + .await; + } + // Les jauges ne doivent pas montrer, deux secondes encore, une carte + // pleine qu'on vient d'annoncer libérée. + sysinfo::GPU_MONITOR.invalidate().await; + let freed = (before - after).max(0); + send_json( + StatusCode::OK, + &json!({ + "ok": errs.is_empty(), + "error": errs.join(" ; "), + "was_active": was_active, + "active": service_is_active(ctx), + "gpu": !gpus.is_empty(), + "before_mb": before, + "used_mb": after, + "freed_mb": freed, + }), + ) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn base_comme_go() { + assert_eq!(go_base("/a/b.gguf"), "b.gguf"); + assert_eq!(go_base("/a/b/"), "b"); + assert_eq!(go_base("/"), "/"); + assert_eq!(go_base(""), "."); + } + + #[test] + fn porte_de_sortie() { + assert!(usable(Some("1"), Some(" Go ")).is_err()); + assert!(usable(Some("1"), None).is_ok()); + assert!(usable(Some("1"), Some("rust")).is_ok()); + } +} diff --git a/rust/loki-rs/src/gguf.rs b/rust/loki-rs/src/gguf.rs index f548cc8..bc26786 100644 --- a/rust/loki-rs/src/gguf.rs +++ b/rust/loki-rs/src/gguf.rs @@ -665,6 +665,16 @@ pub fn shard_family(name: &str) -> Vec { } } +/// `shardFamilyMissing` : tranches ABSENTES de `dir` (un dossier du même nom +/// ne compte pas). Un modèle dont il manque une tranche démarre puis meurt sur +/// une erreur de tenseur introuvable. +pub fn shard_family_missing(dir: &Path, name: &str) -> Vec { + shard_family(name) + .into_iter() + .filter(|n| !std::fs::metadata(dir.join(n)).is_ok_and(|m| !m.is_dir())) + .collect() +} + // --- dossiers de modèles (backend_model_dirs.go) --------------------------- /// `modelsDir()` : `LOKI_HOME/models`. diff --git a/rust/loki-rs/src/lib.rs b/rust/loki-rs/src/lib.rs index 91fa5e4..f1c81c1 100644 --- a/rust/loki-rs/src/lib.rs +++ b/rust/loki-rs/src/lib.rs @@ -19,6 +19,7 @@ pub mod convfiles; pub mod dictate; pub mod downloads; pub mod engine; +pub mod enginectl; pub mod engstatus; pub mod gguf; pub mod gojson; diff --git a/rust/loki-rs/src/main.rs b/rust/loki-rs/src/main.rs index da75b67..506136e 100644 --- a/rust/loki-rs/src/main.rs +++ b/rust/loki-rs/src/main.rs @@ -148,6 +148,20 @@ fn serve() -> ExitCode { // LOKI_PRESETS=rust : les presets (liste, éditeur, aperçus, externes, // bascule ; l'application au moteur reste au Go). let presets_rust = held_by_facade("LOKI_PRESETS"); + // LOKI_ENGINE=rust : le pilotage du moteur (démarrage, arrêt, + // déchargement de la VRAM), en conteneur avec le superviseur Rust + // seulement — sous systemd, le Go pilote son unité. + let engine_rust = held_by_facade("LOKI_ENGINE") + && match loki_rs::enginectl::usable( + std::env::var("LOKI_CONTAINER").ok().as_deref(), + std::env::var("LOKI_ENGINE_SUPERVISOR").ok().as_deref(), + ) { + Ok(()) => true, + Err(why) => { + eprintln!("[loki-rs] pilotage du moteur tenu par le Go ({why})"); + false + } + }; let mut env = vec![ (auth::INTERNAL_ENV.to_string(), token.clone()), // Adresse de l'API interne du Go, héritée par ses enfants : @@ -163,6 +177,7 @@ fn serve() -> ExitCode { ("LOKI_PUSH", push_rust), ("LOKI_MEM", mem_rust), ("LOKI_PRESETS", presets_rust), + ("LOKI_ENGINE", engine_rust), ]; for (var, on) in held { if on { @@ -172,6 +187,7 @@ fn serve() -> ExitCode { if held.iter().any(|(_, on)| *on) { env.push(("LOKI_FACADE_ADDR".into(), format!("127.0.0.1:{web_port}"))); } + let go_bin_path = go_bin.clone(); let sup = child::Supervisor::spawn(go_bin, go_port, env); wait_for(backend, Duration::from_secs(30)).await; let engine_paths = engine::Paths::from_env(); @@ -230,6 +246,26 @@ fn serve() -> ExitCode { }; eprintln!("[loki-rs] façade : http://{addr} → {backend}"); let mem_home = engine_paths.home.clone(); + // Le superviseur reçoit ce que le Go lui passait (rustSvcAction), et de + // quoi joindre l'API interne du Go pour « loki-rs engine serve ». + let engine_ctl = engine_rust.then(|| { + std::sync::Arc::new(loki_rs::enginectl::EngineCtl::new( + exe(), + vec![ + ( + "LOKI_GO_BIN".into(), + go_bin_path.to_string_lossy().into_owned(), + ), + ( + "LOKI_HOME".into(), + engine_paths.home.to_string_lossy().into_owned(), + ), + ("LOKI_SERVICE".into(), engine_paths.service.clone()), + ("LOKI_GO_ADDR".into(), backend.to_string()), + (auth::INTERNAL_ENV.into(), token.clone()), + ], + )) + }); let cfg = proxy::Config { backend, internal_token: token, @@ -243,6 +279,7 @@ fn serve() -> ExitCode { push: push_rust.then(|| std::sync::Arc::new(loki_rs::push::Push::new())), mem: mem_rust.then(|| std::sync::Arc::new(loki_rs::memory::Memory::new(mem_home))), presets: presets_rust, + engine_ctl, }; let res = proxy::serve(listener, cfg, shutdown_signal()).await; eprintln!("[loki-rs] arrêt demandé — arrêt du serveur Go"); diff --git a/rust/loki-rs/src/proxy.rs b/rust/loki-rs/src/proxy.rs index dde5fee..ee26fb6 100644 --- a/rust/loki-rs/src/proxy.rs +++ b/rust/loki-rs/src/proxy.rs @@ -79,6 +79,9 @@ pub struct Config { pub mem: Option>, /// Presets tenus par la façade (`LOKI_PRESETS=rust`). pub presets: bool, + /// Pilotage du moteur tenu par la façade (`LOKI_ENGINE=rust`) : + /// démarrage, arrêt, déchargement de la VRAM ; `None` : au Go. + pub engine_ctl: Option>, } struct Proxy { @@ -113,6 +116,7 @@ pub async fn serve( push: cfg.push, mem: cfg.mem, presets: cfg.presets, + engine_ctl: cfg.engine_ctl, internal_token: cfg.internal_token.clone(), }, }); @@ -200,6 +204,11 @@ impl Proxy { { return chatapi::serve(&self.ctx, chat, req, from_go).await; } + if let Some(e) = &self.ctx.engine_ctl + && crate::enginectl::handles(path) + { + return crate::enginectl::serve(&self.ctx, e, req, from_go).await; + } if self.ctx.presets && crate::presets_api::handles(path) { return crate::presets_api::serve(&self.ctx, req, from_go).await; } diff --git a/rust/loki-rs/src/sysinfo.rs b/rust/loki-rs/src/sysinfo.rs index 8fcee3e..3f69b94 100644 --- a/rust/loki-rs/src/sysinfo.rs +++ b/rust/loki-rs/src/sysinfo.rs @@ -107,7 +107,7 @@ pub async fn nvidia_smi_query() -> io::Result> { } /// [`nvidia_smi_query`] avec un exécutable choisi (tests). -async fn nvidia_smi_query_bin(bin: &str) -> io::Result> { +pub(crate) async fn nvidia_smi_query_bin(bin: &str) -> io::Result> { let mut cmd = tokio::process::Command::new(bin); cmd.args([ "--query-gpu=name,memory.used,memory.total,utilization.gpu,temperature.gpu", diff --git a/rust/loki-rs/tests/common/fakego.rs b/rust/loki-rs/tests/common/fakego.rs index cc13f81..0632d7c 100644 --- a/rust/loki-rs/tests/common/fakego.rs +++ b/rust/loki-rs/tests/common/fakego.rs @@ -78,6 +78,12 @@ pub struct FakeGo { pub tune_busy: Mutex>, /// /internal/v1/preset/apply reçus : (chemin, mode). pub preset_applies: Mutex>, + /// /internal/v1/chat/generating : le Go écrit une réponse. + pub generating: Mutex, + /// /internal/v1/service/prestart reçus. + pub prestarts: Mutex, + /// /internal/v1/dictate/stop reçus. + pub dictate_stops: Mutex, /// Comportement de /internal/v1/crypt/* (préfixe « SCELLE: » par défaut). pub crypt_mode: Mutex, /// Réponse de /internal/v1/conv/workspace (None : 404). @@ -336,6 +342,19 @@ async fn handle(st: Arc, req: Request) -> Response ), None => resp(200, r#"{"busy":false}"#), }, + "/internal/v1/service/prestart" => { + *st.prestarts.lock().unwrap() += 1; + let e = st.tune_busy.lock().unwrap().clone().unwrap_or_default(); + resp(200, serde_json::json!({ "error": e }).to_string()) + } + "/internal/v1/chat/generating" => resp( + 200, + serde_json::json!({ "generating": *st.generating.lock().unwrap() }).to_string(), + ), + "/internal/v1/dictate/stop" => { + *st.dictate_stops.lock().unwrap() += 1; + resp(204, "") + } "/internal/v1/preset/apply" => { let body = req.into_body().collect().await.unwrap().to_bytes(); let v: serde_json::Value = serde_json::from_slice(&body).unwrap_or_default(); @@ -530,6 +549,7 @@ pub struct Opts { pub push: Option>, pub mem: Option>, pub presets: bool, + pub engine_ctl: Option>, } /// Comme [`start`], avec la conversation tenue par la façade @@ -626,6 +646,7 @@ pub async fn start_with( push: opts.push, mem: opts.mem, presets: opts.presets, + engine_ctl: opts.engine_ctl, }; tokio::spawn(loki_rs::proxy::serve(fl, cfg, async move { let _ = rx.await; diff --git a/rust/loki-rs/tests/enginectl.rs b/rust/loki-rs/tests/enginectl.rs new file mode 100644 index 0000000..52bdbb0 --- /dev/null +++ b/rust/loki-rs/tests/enginectl.rs @@ -0,0 +1,470 @@ +//! Lot 16 : pilotage du moteur tenu par la façade (`LOKI_ENGINE=rust`). +//! +//! Le journal tiré du Go (`internal/loki/parity_engine_test.go` → +//! `tests/parity_engine/engine.json`) est rejoué : bilan de VRAM +//! (`gpuSettle`), contrôle d'avant démarrage (`preflightEngine`) sur chaque +//! installation, dernière ligne d'erreur du superviseur, puis les réponses des +//! routes qui ne lancent aucun processus. Le reste — démarrage, arrêt, +//! redémarrage, déchargement avec bilan chiffré, rechargement — est vérifié de +//! bout en bout avec un faux superviseur (`loki-rs engine `) et un +//! faux nvidia-smi. + +mod common; + +use std::collections::HashMap; +use std::path::{Path, PathBuf}; +use std::sync::Arc; +use std::time::Duration; + +use bytes::Bytes; +use common::fakego; +use http_body_util::{BodyExt, Full}; +use hyper::header::HeaderMap; +use hyper::{Request, StatusCode}; +use loki_rs::enginectl::{self as ec, EngineCtl}; +use serde_json::{Value, json}; + +fn fixture() -> Value { + serde_json::from_str(include_str!("parity_engine/engine.json")).expect("journal") +} + +fn s(v: &Value) -> &str { + v.as_str().unwrap() +} + +/// Écrit un script exécutable sans le garder ouvert (ETXTBSY). +fn script(dir: &Path, name: &str, body: &str) -> PathBuf { + let p = dir.join(name); + let ok = std::process::Command::new("sh") + .args(["-c", "printf %s \"$1\" > \"$2\" && chmod 755 \"$2\""]) + .arg("sh") + .arg(body) + .arg(&p) + .status() + .unwrap() + .success(); + assert!(ok); + p +} + +async fn call( + facade: std::net::SocketAddr, + method: &str, + path: &str, + body: &str, +) -> (StatusCode, HeaderMap, Value) { + use hyper_util::client::legacy::Client; + use hyper_util::rt::TokioExecutor; + let req = Request::builder() + .method(method) + .uri(format!("http://{facade}{path}")) + .body(Full::new(Bytes::from(body.to_string()))) + .unwrap(); + let client: Client<_, Full> = Client::builder(TokioExecutor::new()).build_http(); + let r = client.request(req).await.unwrap(); + let (parts, b) = r.into_parts(); + let b = b.collect().await.unwrap().to_bytes(); + let v = serde_json::from_slice(&b) + .unwrap_or_else(|_| Value::String(String::from_utf8_lossy(&b).into_owned())); + (parts.status, parts.headers, v) +} + +#[tokio::test] +async fn parite_du_bilan_de_vram() { + for c in fixture()["settle"].as_array().unwrap() { + let before = c["before"].as_i64().unwrap(); + let seq: Vec = c["seq"] + .as_array() + .unwrap() + .iter() + .map(|x| x.as_i64().unwrap()) + .collect(); + let i = std::sync::Mutex::new(0usize); + let got = ec::gpu_settle( + before, + || { + let mut n = i.lock().unwrap(); + let v = if *n >= seq.len() { + (seq[seq.len() - 1], true) + } else { + let v = seq[*n]; + *n += 1; + if v < 0 { (0, false) } else { (v, true) } + }; + async move { v } + }, + Duration::ZERO, + ) + .await; + assert_eq!(got, c["out"].as_i64().unwrap(), "{c}"); + } +} + +#[test] +fn parite_des_messages() { + let fx = fixture(); + for (k, v) in fx["last_error"].as_object().unwrap() { + assert_eq!(ec::last_error_line(k), s(v), "{k:?}"); + } + for (k, v) in fx["plain"].as_object().unwrap() { + assert_eq!(ec::plain_err(k), s(v), "{k:?}"); + } +} + +#[test] +fn parite_du_preflight() { + for c in fixture()["preflight"].as_array().unwrap() { + let tmp = tempfile::tempdir().unwrap(); + let home = tmp.path(); + let h = home.to_string_lossy().into_owned(); + for f in c["files"].as_array().into_iter().flatten() { + let f = s(f); + let p = home.join(f.trim_end_matches('/')); + if f.ends_with('/') { + std::fs::create_dir_all(&p).unwrap(); + } else { + std::fs::create_dir_all(p.parent().unwrap()).unwrap(); + std::fs::write(&p, "x").unwrap(); + } + } + let cfg: HashMap = c["config"] + .as_object() + .unwrap() + .iter() + .map(|(k, v)| (k.clone(), s(v).replace("{{HOME}}", &h))) + .collect(); + let dirs: Vec = c["dirs"] + .as_array() + .into_iter() + .flatten() + .map(|d| home.join(s(d)).to_string_lossy().into_owned()) + .collect(); + let got = match ec::preflight_engine(home, &cfg, &dirs, None) { + Ok(()) => String::new(), + Err(e) => e.replace(&h, "{{HOME}}"), + }; + assert_eq!(got, s(&c["out"]), "{}", c["name"]); + } +} + +/// Un faux superviseur : note chaque appel, démarre un `sleep` dont le PID +/// va dans le fichier du service, l'arrête sur « stop » ; « start » échoue si +/// `$LOKI_HOME/echec` existe, comme le vrai : dernière ligne d'erreur. +fn fake_supervisor(dir: &Path) -> PathBuf { + script( + dir, + "loki-rs", + r#"#!/bin/sh +echo "$1 $2 $LOKI_HOME $LOKI_SERVICE $LOKI_GO_ADDR $LOKI_INTERNAL_TOKEN" >> "$LOKI_HOME/trace" +pid="$LOKI_HOME/$LOKI_SERVICE.pid" +case "$2" in +start) + if [ -e "$LOKI_HOME/echec" ]; then + echo "[ERREUR] le processus s'est arrêté" + echo "[loki-rs engine] service $LOKI_SERVICE non démarré" >&2 + exit 1 + fi + sleep 60 >/dev/null 2>&1 < /dev/null & + echo $! > "$pid" + ;; +stop) + [ -s "$pid" ] && kill "$(cat "$pid")" 2>/dev/null + rm -f "$pid" + ;; +esac +exit 0 +"#, + ) +} + +struct Banc { + facade: std::net::SocketAddr, + go: Arc, + home: PathBuf, + _stop: tokio::sync::oneshot::Sender<()>, + _tmp: tempfile::TempDir, +} + +impl Banc { + fn trace(&self) -> Vec { + std::fs::read_to_string(self.home.join("trace")) + .unwrap_or_default() + .lines() + .map(|l| l.split(' ').take(2).collect::>().join(" ")) + .collect() + } + fn pid(&self) -> i32 { + loki_rs::engine::read_pid(&self.home.join("loki-engine.pid")) + } +} + +async fn banc(nvidia_smi: Option<&str>) -> Banc { + let tmp = tempfile::tempdir().unwrap(); + let home = tmp.path().to_path_buf(); + let bin = fake_supervisor(&home); + let mut ctl = EngineCtl::new( + bin, + vec![ + ("LOKI_HOME".into(), home.to_string_lossy().into_owned()), + ("LOKI_SERVICE".into(), "loki-engine".into()), + ("LOKI_GO_ADDR".into(), "127.0.0.1:1".into()), + ("LOKI_INTERNAL_TOKEN".into(), "jeton".into()), + ], + ); + ctl.nvidia_smi = match nvidia_smi { + Some(body) => script(&home, "nvidia-smi", body) + .to_string_lossy() + .into_owned(), + None => home + .join("absent-nvidia-smi") + .to_string_lossy() + .into_owned(), + }; + ctl.settle_pause = Duration::ZERO; + let opts = fakego::Opts { + engine_ctl: Some(Arc::new(ctl)), + ..Default::default() + }; + let (facade, go, stop, _) = fakego::start_with(home.clone(), opts).await; + Banc { + facade, + go, + home, + _stop: stop, + _tmp: tmp, + } +} + +#[tokio::test] +async fn parite_des_routes() { + for st in fixture()["steps"].as_array().unwrap() { + let b = banc(None).await; + let h = b.home.to_string_lossy().into_owned(); + if st["tune"].as_bool() == Some(true) { + *b.go.tune_busy.lock().unwrap() = Some( + "optimisation en cours (loki tune) : le moteur est réservé aux essais — attends la fin ou annule-la" + .into(), + ); + } + *b.go.generating.lock().unwrap() = st["generating"].as_bool() == Some(true); + for (k, v) in st["config"].as_object().into_iter().flatten() { + b.go.set("config", k, s(v)); + } + let (code, headers, out) = call( + b.facade, + s(&st["method"]), + s(&st["path"]), + st["body"].as_str().unwrap_or(""), + ) + .await; + let name = &st["name"]; + assert_eq!( + code.as_u16() as i64, + st["status"].as_i64().unwrap(), + "{name}" + ); + assert_eq!( + headers.get("allow").map_or("", |v| v.to_str().unwrap()), + st["allow"].as_str().unwrap_or(""), + "{name}" + ); + let out: Value = serde_json::from_str(&out.to_string().replace(&h, "{{HOME}}")).unwrap(); + assert_eq!(out, st["out"], "{name}"); + // Aucune de ces réponses ne lance quoi que ce soit. + assert!(b.trace().is_empty(), "{name} : {:?}", b.trace()); + } +} + +#[tokio::test] +async fn demarrage_arret_redemarrage() { + let b = banc(None).await; + let (c, _, v) = call(b.facade, "POST", "/api/start", "").await; + assert_eq!( + (c.as_u16(), v.clone()), + (200, json!({"ok": true, "out": "ok"})) + ); + assert_eq!(*b.go.prestarts.lock().unwrap(), 1); + assert!(b.pid() > 0); + // Environnement passé au superviseur. + let line = std::fs::read_to_string(b.home.join("trace")).unwrap(); + assert_eq!( + line.trim(), + format!( + "engine start {} loki-engine 127.0.0.1:1 jeton", + b.home.display() + ) + ); + + // Optimisation en cours : refus, superviseur jamais appelé. + *b.go.tune_busy.lock().unwrap() = Some("optimisation en cours".into()); + let (_, _, v) = call(b.facade, "POST", "/api/restart", "").await; + assert_eq!(v, json!({"ok": false, "out": "optimisation en cours"})); + assert_eq!(b.trace(), ["engine start"]); + // L'arrêt, lui, passe toujours (et sans consulter le verrou). + let (_, _, v) = call(b.facade, "GET", "/api/stop", "").await; + assert_eq!(v, json!({"ok": true, "out": "ok"})); + assert_eq!(*b.go.prestarts.lock().unwrap(), 2); + assert_eq!(b.pid(), 0); + *b.go.tune_busy.lock().unwrap() = None; + + // Échec du démarrage : la dernière ligne d'erreur, sans préfixe. + std::fs::write(b.home.join("echec"), "").unwrap(); + let (_, _, v) = call(b.facade, "POST", "/api/start", "").await; + assert_eq!( + v, + json!({"ok": false, "out": "service loki-engine non démarré"}) + ); + std::fs::remove_file(b.home.join("echec")).unwrap(); + + let (_, _, v) = call(b.facade, "POST", "/api/restart", "").await; + assert_eq!(v, json!({"ok": true, "out": "ok"})); + assert_eq!( + b.trace(), + [ + "engine start", + "engine stop", + "engine start", + "engine restart" + ] + ); + + // Preset externe : rien à lancer ; un redémarrage arrête le moteur local. + b.go.set("config", "EXTERNAL", "1"); + let (_, _, v) = call(b.facade, "POST", "/api/restart", "").await; + assert_eq!(v, json!({"ok": true, "out": "ok"})); + let (_, _, v) = call(b.facade, "POST", "/api/start", "").await; + assert_eq!(v, json!({"ok": true, "out": "ok"})); + assert_eq!(b.trace().last().unwrap(), "engine stop"); + assert_eq!(b.trace().len(), 5); + assert_eq!(b.pid(), 0); +} + +#[tokio::test] +async fn dechargement_avec_bilan() { + // nvidia-smi : 20 Go occupés avant l'arrêt, puis la mémoire part. + let smi = r#"#!/bin/sh +n=$(cat "$0.n" 2>/dev/null || echo 0) +echo $((n+1)) > "$0.n" +case $n in +0) echo "RTX, 20000, 24000, 50, 60" ;; +1) echo "RTX, 20000, 24000, 0, 60" ;; +2) echo "RTX, 9000, 24000, 0, 55" ;; +*) echo "RTX, 700, 24000, 0, 50" ;; +esac +"#; + let b = banc(Some(smi)).await; + let (_, _, v) = call(b.facade, "POST", "/api/start", "").await; + assert_eq!(v["ok"], true); + let pid = b.pid(); + assert!(pid > 0); + + // Une réponse en cours (conversation au Go) : refus, sauf forcé. + *b.go.generating.lock().unwrap() = true; + let (c, _, v) = call(b.facade, "POST", "/api/vram/unload", "{}").await; + assert_eq!(c, 409); + assert_eq!(v["generating"], true); + let (c, _, v) = call(b.facade, "POST", "/api/vram/unload", r#"{"force":true}"#).await; + assert_eq!(c, 200); + assert_eq!( + v, + json!({ + "ok": true, "error": "", "was_active": true, "active": false, "gpu": true, + "before_mb": 20000, "used_mb": 700, "freed_mb": 19300, + }) + ); + assert_eq!(b.trace(), ["engine start", "engine stop"]); + assert_eq!(*b.go.dictate_stops.lock().unwrap(), 1); + assert_eq!(b.pid(), 0); + + // Rechargement : préflight, puis démarrage ; déjà actif ensuite. + let bin = b.home.join("llama-server"); + std::fs::write(&bin, "").unwrap(); + std::fs::create_dir_all(b.home.join("models")).unwrap(); + std::fs::write(b.home.join("models/m.gguf"), "x").unwrap(); + b.go.set("config", "BIN", &bin.to_string_lossy()); + b.go.set("config", "MODEL", "m.gguf"); + let (_, _, v) = call(b.facade, "POST", "/api/vram/reload", "").await; + assert_eq!(v, json!({"ok": true})); + let (_, _, v) = call(b.facade, "POST", "/api/vram/reload", "").await; + assert_eq!(v, json!({"ok": true, "already": true})); + assert_eq!(b.trace(), ["engine start", "engine stop", "engine start"]); + // Démarrage refusé par le verrou après le préflight. + let (_, _, _) = call(b.facade, "POST", "/api/stop", "").await; + *b.go.tune_busy.lock().unwrap() = Some("optimisation en cours".into()); + let (c, _, v) = call(b.facade, "POST", "/api/vram/reload", "").await; + assert_eq!(c, 409); + assert_eq!(v["tuning"], true); + let _ = std::fs::remove_file(b.home.join("loki-engine.pid")); +} + +#[tokio::test] +async fn dechargement_arret_en_echec() { + // Un moteur actif que le superviseur ne sait pas arrêter. + let tmp = tempfile::tempdir().unwrap(); + let home = tmp.path().to_path_buf(); + let bin = script( + &home, + "loki-rs", + "#!/bin/sh\necho \"[loki-rs engine] \x1b[1marrêt impossible\x1b[0m\" >&2\nexit 1\n", + ); + let mut sleeper = std::process::Command::new("sleep") + .arg("30") + .spawn() + .unwrap(); + std::fs::write(home.join("loki-engine.pid"), sleeper.id().to_string()).unwrap(); + let mut ctl = EngineCtl::new(bin, vec![]); + ctl.nvidia_smi = home.join("absent").to_string_lossy().into_owned(); + let opts = fakego::Opts { + engine_ctl: Some(Arc::new(ctl)), + ..Default::default() + }; + let (facade, go, _stop, _) = fakego::start_with(home.clone(), opts).await; + let (_, _, v) = call(facade, "POST", "/api/vram/unload", "").await; + assert_eq!( + v, + json!({ + "ok": false, "error": "arrêt du moteur : arrêt impossible", "was_active": true, + "active": true, "gpu": false, "before_mb": 0, "used_mb": 0, "freed_mb": 0, + }) + ); + assert_eq!(*go.dictate_stops.lock().unwrap(), 1); + let _ = sleeper.kill(); + let _ = sleeper.wait(); +} + +#[tokio::test] +async fn sans_la_variable_le_moteur_reste_au_go() { + let tmp = tempfile::tempdir().unwrap(); + let (facade, _go, _stop) = fakego::start(tmp.path().to_path_buf()).await; + for p in [ + "/api/start", + "/api/stop", + "/api/restart", + "/api/vram/unload", + "/api/vram/reload", + ] { + let (_, _, b) = fakego::get(facade, p, &[]).await; + assert_eq!(&b[..], format!("go:{p}").as_bytes()); + } +} + +#[tokio::test] +async fn dechargement_vram_en_hausse() { + // Une autre application prend la carte pendant le déchargement : jamais + // de « Mo libérés » négatifs. + let smi = r#"#!/bin/sh +n=$(cat "$0.n" 2>/dev/null || echo 0) +echo $((n+1)) > "$0.n" +if [ "$n" = 0 ]; then echo "A, 5000, 24000, 0, 40"; else echo "A, 6000, 24000, 0, 40"; fi +"#; + let b = banc(Some(smi)).await; + let (_, _, v) = call(b.facade, "POST", "/api/vram/unload", "").await; + assert_eq!( + v, + json!({ + "ok": true, "error": "", "was_active": false, "active": false, "gpu": true, + "before_mb": 5000, "used_mb": 6000, "freed_mb": 0, + }) + ); + assert!(b.trace().is_empty()); +} diff --git a/rust/loki-rs/tests/lot2.rs b/rust/loki-rs/tests/lot2.rs index 5c8b14b..fcdffd7 100644 --- a/rust/loki-rs/tests/lot2.rs +++ b/rust/loki-rs/tests/lot2.rs @@ -101,6 +101,7 @@ async fn banc(cle: Option<&str>) -> Banc { push: None, mem: None, presets: false, + engine_ctl: None, }; tokio::spawn(loki_rs::proxy::serve(l, cfg, async move { let _ = rx.await; diff --git a/rust/loki-rs/tests/parity_engine/engine.json b/rust/loki-rs/tests/parity_engine/engine.json new file mode 100644 index 0000000..f561ccf --- /dev/null +++ b/rust/loki-rs/tests/parity_engine/engine.json @@ -0,0 +1,551 @@ +{ + "settle": [ + { + "before": 14000, + "out": 500, + "seq": [ + 20000, + 20000, + 12000, + 3000, + 500, + 500 + ] + }, + { + "before": 14000, + "out": 400, + "seq": [ + 15000, + 8000, + 400, + 400 + ] + }, + { + "before": 14000, + "out": 600, + "seq": [ + 14000, + -1, + 600, + 600 + ] + }, + { + "before": 14000, + "out": 14000, + "seq": [ + -1, + -1, + -1, + -1, + -1, + -1, + -1, + -1 + ] + }, + { + "before": 14000, + "out": 14000, + "seq": [ + 14000, + 14000, + 14000, + 14000, + 14000, + 14000, + 14000, + 14000 + ] + }, + { + "before": 0, + "out": 0, + "seq": [ + 0, + 0, + 0 + ] + }, + { + "before": 9000, + "out": 8999, + "seq": [ + 9000, + 8999, + 8999 + ] + }, + { + "before": 9000, + "out": 100, + "seq": [ + -1, + 9000, + -1, + 9000, + 100, + -1, + 100 + ] + }, + { + "before": 5000, + "out": 7000, + "seq": [ + 6000, + 7000, + 7000, + 7000, + 7000, + 7000, + 7000, + 7000 + ] + }, + { + "before": 5000, + "out": 600, + "seq": [ + 4000, + 3000, + 2000, + 1000, + 900, + 800, + 700, + 600 + ] + } + ], + "last_error": { + "": "", + "[loki-rs engine] service loki-engine non démarré\n": "service loki-engine non démarré", + "[ok] démarré\n[attention] 2 processus du moteur toujours là\n": "[attention] 2 processus du moteur toujours là", + "ligne 1\n[loki-rs engine] moteur absent \n\n": "moteur absent", + "pas de préfixe": "pas de préfixe" + }, + "plain": { + "\u001b[1;36mcouleur\u001b[0m et \u001b[32mvert\u001b[0m": "couleur et vert", + "\u001b[mvide\u001b[0;1;2m": "vide", + "BIN non défini — installe un moteur : \u001b[1mloki llamacpp install\u001b[0m": "BIN non défini — installe un moteur : loki llamacpp install", + "rien à retirer": "rien à retirer" + }, + "preflight": [ + { + "name": "externe", + "config": { + "EXTERNAL": "1" + }, + "out": "" + }, + { + "name": "externe sans BIN ni MODEL", + "config": { + "BIN": "", + "EXTERNAL": " 1 " + }, + "out": "" + }, + { + "name": "strata sans config", + "config": { + "ENGINE": "strata" + }, + "out": "STRATA_CONFIG non défini : réinstalle le modèle depuis Paramètres → Moteur → Strata" + }, + { + "name": "strata config absente", + "config": { + "ENGINE": "Strata", + "STRATA_CONFIG": "{{HOME}}/strata/m/config.json" + }, + "out": "configuration de Strata introuvable : {{HOME}}/strata/m/config.json — réinstalle le modèle" + }, + { + "name": "strata sans venv", + "config": { + "ENGINE": "strata", + "STRATA_CONFIG": "{{HOME}}/strata/m/config.json" + }, + "files": [ + "strata/m/config.json" + ], + "out": "environnement Python de Strata absent ({{HOME}}/strata/m/.venv/bin/python) : réinstalle le modèle" + }, + { + "name": "strata prêt", + "config": { + "ENGINE": "moe", + "STRATA_CONFIG": "{{HOME}}/strata/m/config.json" + }, + "files": [ + "strata/m/config.json", + "strata/m/.venv/bin/python" + ], + "out": "" + }, + { + "name": "BIN vide", + "config": { + "MODEL": "m.gguf" + }, + "out": "BIN non défini — installe un moteur : loki llamacpp install (ou renseigne BIN avec loki edit)" + }, + { + "name": "BIN espaces", + "config": { + "BIN": " ", + "MODEL": "m.gguf" + }, + "out": "BIN non défini — installe un moteur : loki llamacpp install (ou renseigne BIN avec loki edit)" + }, + { + "name": "BIN relatif absent", + "config": { + "BIN": "bin/absent", + "MODEL": "m.gguf" + }, + "out": "moteur introuvable : {{HOME}}/bin/absent — relance loki llamacpp install" + }, + { + "name": "BIN absolu absent", + "config": { + "BIN": "{{HOME}}/ailleurs/llama-server", + "MODEL": "m.gguf" + }, + "out": "moteur introuvable : {{HOME}}/ailleurs/llama-server — relance loki llamacpp install" + }, + { + "name": "BIN dossier", + "config": { + "BIN": "bin/", + "MODEL": "m.gguf" + }, + "files": [ + "bin/" + ], + "out": "modèle introuvable : {{HOME}}/models/m.gguf — corrige MODEL avec loki edit" + }, + { + "name": "MODEL vide", + "config": { + "BIN": "bin/llama-server" + }, + "files": [ + "bin/llama-server" + ], + "out": "MODEL non défini — indique un .gguf avec loki edit (dossier des modèles : {{HOME}}/models)" + }, + { + "name": "MODEL absent", + "config": { + "BIN": "bin/llama-server", + "MODEL": "absent.gguf" + }, + "files": [ + "bin/llama-server" + ], + "out": "modèle introuvable : {{HOME}}/models/absent.gguf — corrige MODEL avec loki edit" + }, + { + "name": "MODEL relatif", + "config": { + "BIN": "{{HOME}}/bin/llama-server", + "MODEL": "m.gguf" + }, + "files": [ + "bin/llama-server", + "models/m.gguf" + ], + "out": "" + }, + { + "name": "MODEL remonte", + "config": { + "BIN": "bin/llama-server", + "MODEL": "../m.gguf" + }, + "files": [ + "bin/llama-server", + "models/m.gguf" + ], + "out": "" + }, + { + "name": "MODEL sous-dossier", + "config": { + "BIN": "bin/llama-server", + "MODEL": "sub/m.gguf" + }, + "files": [ + "bin/llama-server", + "models/m.gguf" + ], + "out": "" + }, + { + "name": "MODEL absolu hors dossiers", + "config": { + "BIN": "bin/llama-server", + "MODEL": "{{HOME}}/ailleurs/x.gguf" + }, + "files": [ + "bin/llama-server", + "ailleurs/x.gguf" + ], + "out": "" + }, + { + "name": "MODEL absolu pas gguf", + "config": { + "BIN": "bin/llama-server", + "MODEL": "{{HOME}}/ailleurs/x.bin" + }, + "files": [ + "bin/llama-server", + "ailleurs/x.bin" + ], + "out": "MODEL={{HOME}}/ailleurs/x.bin : le modèle doit être un fichier .gguf" + }, + { + "name": "MODEL absolu absent", + "config": { + "BIN": "bin/llama-server", + "MODEL": "{{HOME}}/ailleurs/y.gguf" + }, + "files": [ + "bin/llama-server" + ], + "out": "MODEL={{HOME}}/ailleurs/y.gguf : dossier non autorisé : {{HOME}}/ailleurs — ajoute-le dans « Dossiers de modèles »" + }, + { + "name": "MODEL dossier déclaré", + "config": { + "BIN": "bin/llama-server", + "MODEL": "d.gguf" + }, + "files": [ + "bin/llama-server", + "disque/d.gguf" + ], + "dirs": [ + "disque" + ], + "out": "" + }, + { + "name": "MODEL chemin dans dossier déclaré", + "config": { + "BIN": "bin/llama-server", + "MODEL": "{{HOME}}/disque/d.gguf" + }, + "files": [ + "bin/llama-server", + "disque/d.gguf" + ], + "dirs": [ + "disque" + ], + "out": "" + }, + { + "name": "tranches manquantes", + "config": { + "BIN": "bin/llama-server", + "MODEL": "big-00001-of-00003.gguf" + }, + "files": [ + "bin/llama-server", + "models/big-00001-of-00003.gguf", + "models/big-00003-of-00003.gguf" + ], + "out": "modèle incomplet : il manque big-00002-of-00003.gguf dans {{HOME}}/models — ce modèle tient en 3 fichiers, télécharge-les tous" + }, + { + "name": "tranches dossier", + "config": { + "BIN": "bin/llama-server", + "MODEL": "big-00001-of-00002.gguf" + }, + "files": [ + "bin/llama-server", + "models/big-00001-of-00002.gguf", + "models/big-00002-of-00002.gguf/" + ], + "out": "modèle incomplet : il manque big-00002-of-00002.gguf dans {{HOME}}/models — ce modèle tient en 2 fichiers, télécharge-les tous" + }, + { + "name": "tranches complètes", + "config": { + "BIN": "bin/llama-server", + "MODEL": "big-00001-of-00002.gguf" + }, + "files": [ + "bin/llama-server", + "models/big-00001-of-00002.gguf", + "models/big-00002-of-00002.gguf" + ], + "out": "" + }, + { + "name": "tranche 2 désignée", + "config": { + "BIN": "bin/llama-server", + "MODEL": "big-00002-of-00002.gguf" + }, + "files": [ + "bin/llama-server", + "models/big-00002-of-00002.gguf" + ], + "out": "modèle incomplet : il manque big-00001-of-00002.gguf dans {{HOME}}/models — ce modèle tient en 2 fichiers, télécharge-les tous" + }, + { + "name": "MODEL guillemets", + "config": { + "BIN": "bin/llama-server", + "MODEL": " m.gguf " + }, + "files": [ + "bin/llama-server", + "models/m.gguf" + ], + "out": "" + } + ], + "steps": [ + { + "name": "déchargement en GET", + "method": "GET", + "path": "/api/vram/unload", + "status": 405, + "allow": "POST", + "out": { + "error": "méthode GET refusée : POST attendu", + "ok": false + } + }, + { + "name": "rechargement en GET", + "method": "GET", + "path": "/api/vram/reload", + "status": 405, + "allow": "POST", + "out": { + "error": "méthode GET refusée : POST attendu", + "ok": false + } + }, + { + "name": "rechargement en PUT", + "method": "PUT", + "path": "/api/vram/reload", + "status": 405, + "allow": "POST", + "out": { + "error": "méthode PUT refusée : POST attendu", + "ok": false + } + }, + { + "name": "génération en cours", + "method": "POST", + "path": "/api/vram/unload", + "body": "{}", + "generating": true, + "status": 409, + "out": { + "error": "une réponse est en cours d'écriture — décharger maintenant l'interromprait", + "generating": true, + "ok": false + } + }, + { + "name": "génération en cours, corps illisible", + "method": "POST", + "path": "/api/vram/unload", + "body": "nimp", + "generating": true, + "status": 409, + "out": { + "error": "une réponse est en cours d'écriture — décharger maintenant l'interromprait", + "generating": true, + "ok": false + } + }, + { + "name": "moteur déjà arrêté", + "method": "POST", + "path": "/api/vram/unload", + "body": "{\"force\":true}", + "generating": true, + "status": 200, + "out": { + "active": false, + "before_mb": 0, + "error": "", + "freed_mb": 0, + "gpu": false, + "ok": true, + "used_mb": 0, + "was_active": false + } + }, + { + "name": "moteur arrêté, sans corps", + "method": "POST", + "path": "/api/vram/unload", + "status": 200, + "out": { + "active": false, + "before_mb": 0, + "error": "", + "freed_mb": 0, + "gpu": false, + "ok": true, + "used_mb": 0, + "was_active": false + } + }, + { + "name": "optimisation en cours", + "method": "POST", + "path": "/api/vram/reload", + "tune": true, + "status": 409, + "out": { + "error": "optimisation en cours (loki tune) : le moteur est réservé aux essais — attends la fin ou annule-la", + "ok": false, + "tuning": true + } + }, + { + "name": "préflight sans BIN", + "method": "POST", + "path": "/api/vram/reload", + "config": { + "MODEL": "m.gguf" + }, + "status": 200, + "out": { + "error": "BIN non défini — installe un moteur : loki llamacpp install (ou renseigne BIN avec loki edit)", + "ok": false + } + }, + { + "name": "préflight sans MODEL", + "method": "POST", + "path": "/api/vram/reload", + "config": { + "BIN": "/bin/sh" + }, + "status": 200, + "out": { + "error": "MODEL non défini — indique un .gguf avec loki edit (dossier des modèles : {{HOME}}/models)", + "ok": false + } + } + ] +} diff --git a/rust/loki-rs/tests/proxy.rs b/rust/loki-rs/tests/proxy.rs index da89148..6339327 100644 --- a/rust/loki-rs/tests/proxy.rs +++ b/rust/loki-rs/tests/proxy.rs @@ -107,6 +107,7 @@ async fn facade(backend: SocketAddr) -> (SocketAddr, oneshot::Sender<()>) { push: None, mem: None, presets: false, + engine_ctl: None, }; tokio::spawn(loki_rs::proxy::serve(l, cfg, async move { let _ = rx.await; From a4f8bd3ec462b9a8b2fc50fd6bf835807551811f Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 15 Oct 2026 18:51:58 +0000 Subject: [PATCH 14/52] =?UTF-8?q?Lot=2017=20:=20interrupteurs=20syst=C3=A8?= =?UTF-8?q?me=20tenus=20par=20la=20fa=C3=A7ade=20(LOKI=5FSYSTEM=3Drust)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit La façade sert /api/internet (moteur web, serveur Crawl4AI et sa clé jamais rendue, joignabilité avec son cache de 30 s), /api/computer (interrupteur du navigateur, binaire trouvé, vision), /api/apikey (clé de complétion, repli sur config.env, adresse /v1 à coller dans un client tiers) et /api/network (écoute du moteur) quand LOKI_SYSTEM=rust (expérimental ; le Go reste le défaut). Restent au Go derrière son API interne (facade_system.go) : le cache de joignabilité de ses outils web (/internal/v1/web/reach/reset), le Chromium des outils browser_* (/internal/v1/cdp/shutdown) et le redémarrage qui applique une clé neuve quand la façade ne pilote pas le moteur (/internal/v1/service/action). Le relais du tunnel garde l'hôte et X-Forwarded-Proto du client. Correction en passant : maskAPIKey paniquait sur une clé d'un octet, et /api/apikey tombait à chaque lecture. Parité : parity_system_test.go tire du Go 61 appels de route avec la base et les sondes reçues par trois faux Crawl4AI après chacun, et huit dispositions du dossier de Playwright, rejoués par tests/system.rs ; ce qui reste au Go vérifié de bout en bout ; TestFacadeSysteme, TestFacadeRelaisGardeHote et TestMaskAPIKeyCourte côté Go. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_016tcGfJWefW5kUGoC2wL8cv --- README.md | 1 + ...-15-rust-migration-lot17-systeme-design.md | 92 ++ internal/loki/backend_config.go | 7 +- internal/loki/facade_chat.go | 10 +- internal/loki/facade_chat_test.go | 113 ++ internal/loki/facade_system.go | 98 ++ internal/loki/parity_system_test.go | 294 ++++ internal/loki/web_internal.go | 6 + rust/loki-rs/src/api.rs | 3 + rust/loki-rs/src/enginectl.rs | 2 +- rust/loki-rs/src/lib.rs | 1 + rust/loki-rs/src/main.rs | 5 + rust/loki-rs/src/proxy.rs | 9 + rust/loki-rs/src/system.rs | 664 ++++++++ rust/loki-rs/tests/common/fakego.rs | 30 + rust/loki-rs/tests/lot2.rs | 1 + rust/loki-rs/tests/parity_system/system.json | 1407 +++++++++++++++++ rust/loki-rs/tests/proxy.rs | 1 + rust/loki-rs/tests/system.rs | 369 +++++ 19 files changed, 3110 insertions(+), 3 deletions(-) create mode 100644 docs/superpowers/specs/2026-10-15-rust-migration-lot17-systeme-design.md create mode 100644 internal/loki/facade_system.go create mode 100644 internal/loki/parity_system_test.go create mode 100644 rust/loki-rs/src/system.rs create mode 100644 rust/loki-rs/tests/parity_system/system.json create mode 100644 rust/loki-rs/tests/system.rs diff --git a/README.md b/README.md index 1cbf23a..6f3c613 100644 --- a/README.md +++ b/README.md @@ -236,6 +236,7 @@ redémarrages (volume `/data`). Variables d'environnement du conteneur : | `LOKI_MEM` | `rust` : la mémoire (éditeur des pages, index, déplacement entre projets, trackers, mode mémoire, interrupteurs du mode agent) est tenue par la façade Rust ; le chiffrement reste au Go (expérimental, migration en cours) | Go | | `LOKI_SERVE` | `rust` : la ligne de commande de llama-server est composée par la façade Rust (`loki-rs engine serve`) au lieu de `loki serve` ; Strata et l'optimiseur restent au Go (expérimental, migration en cours) | Go | | `LOKI_PRESETS` | `rust` : les presets (liste, éditeur et aperçus, suppression, placement auto, presets externes, bascule) sont tenus par la façade Rust ; l'application au moteur reste au Go (expérimental, migration en cours) | Go | +| `LOKI_SYSTEM` | `rust` : les interrupteurs système — accès internet et Crawl4AI, pilotage du navigateur, clé de complétion, écoute réseau du moteur — sont tenus par la façade Rust ; le navigateur piloté reste au Go (expérimental, migration en cours) | Go | | `LOKI_ENGINE` | `rust` : le pilotage du moteur (démarrage, arrêt, redémarrage, déchargement et rechargement de la VRAM) est tenu par la façade Rust, en conteneur avec son superviseur ; le verrou de l'optimiseur reste au Go (expérimental, migration en cours) | Go | **Accès par nom de domaine.** Sans clé de pilotage, l'API n'accepte que les diff --git a/docs/superpowers/specs/2026-10-15-rust-migration-lot17-systeme-design.md b/docs/superpowers/specs/2026-10-15-rust-migration-lot17-systeme-design.md new file mode 100644 index 0000000..c95b1f3 --- /dev/null +++ b/docs/superpowers/specs/2026-10-15-rust-migration-lot17-systeme-design.md @@ -0,0 +1,92 @@ +# Lot 17 — interrupteurs système tenus par la façade + +## Contexte + +Quatre réglages de l'interface vivaient dans le Go : +- l'accès internet de l'IA : `handleInternet`, `chat_internet.go`, + `web_fetch_go.go` (moteur web, serveur Crawl4AI, clé, joignabilité) ; +- le pilotage du navigateur : `handleComputer`, `chromePath`, + `visionEnabled` ; +- la clé de complétion du moteur : `handleAPIKey`, `backend_config.go`, + `oaiBaseURL` ; +- l'écoute réseau du moteur : `handleNetwork`, `sys_network.go`. + +Lot 17 : la façade tient ces routes quand `LOKI_SYSTEM=rust` (`system.rs`, +expérimental). Sans la variable, rien ne change. + +| Route | Rôle | +|---|---| +| `GET\|POST /api/internet` | interrupteur, moteur web (`go` ou `crawl4ai`), URL et clé du serveur Crawl4AI (la clé n'est jamais rendue, seulement ses quatre derniers caractères), joignabilité | +| `GET\|POST /api/computer` | interrupteur du navigateur, binaire trouvé, vision du modèle | +| `GET\|POST /api/apikey` | clé de complétion : génération, saisie, effacement ; adresse `/v1` à coller dans un client tiers | +| `GET\|POST /api/network` | moteur ouvert au réseau ou à cette machine seulement (`HOST`), adresse de l'endpoint | + +Les réglages restent des clés de la base, écrites comme le Go : une chaîne +vide efface la clé, et l'URL comme les clés ne laissent rien traîner dans la +configuration. + +La joignabilité de Crawl4AI suit `crawlReachable` : +- `/health`, puis `/`, en moins de deux secondes en tout ; +- clé envoyée en `Authorization: Bearer` ; +- moins de 500 = joignable ; +- cache de 30 s par URL, vidé quand l'URL, la clé ou le moteur changent. + +## Restent au Go + +Le Go expose trois routes internes (`facade_system.go`) : +- `POST /internal/v1/web/reach/reset` : le cache de joignabilité des outils + web, encore exécutés par le Go, est vidé à chaque changement ; +- `POST /internal/v1/cdp/shutdown` : le Chromium des outils `browser_*` se + ferme quand l'interrupteur se coupe ; +- `POST /internal/v1/service/action {action, if_active}` : une clé neuve ne + s'applique qu'au redémarrage du moteur. La façade le relance elle-même si + elle le pilote (lot 16), sinon le Go, seulement s'il tourne. + +`GET /internal/v1/tune/guard` (lot 15) refuse un changement de clé pendant une +optimisation. + +Le relais du tunnel vers la façade garde désormais l'hôte et +`X-Forwarded-Proto` du client : l'adresse de la clé de complétion est celle +que le client a tapée, pas celle de la façade. + +## Correction en passant + +`maskAPIKey` paniquait sur une clé d'un seul octet (`k[len(k)-2:]`). +`/api/apikey` tombait alors à chaque lecture tant que la clé restait en place. +Une clé d'un ou deux octets s'affiche maintenant « … », côté Go comme côté +Rust. + +## Écarts assumés + +- Hors Windows, aucun pare-feu n'est piloté : la façade ne tourne qu'en + conteneur Linux, où le Go répondait déjà « inconnu ». +- La clé masquée est coupée sur les octets, comme le Go. Un caractère coupé + devient U+FFFD, octet par octet, comme son encodeur JSON. + +## Parité + +`internal/loki/parity_system_test.go` tire du Go 61 appels de route, avec +après chacun les clés touchées en base et les en-têtes reçus par trois faux +serveurs Crawl4AI (200, 500, 404). Ils couvrent : +- moteurs inconnus, URL avec et sans slashs, injoignable, sans schéma ; +- clés longues, courtes, d'un caractère, résiduelle en configuration ; +- erreurs de type qui laissent passer les autres champs, `null` ; +- vision par projecteur, preset externe ou Strata ; +- clé générée (masquée dans le journal), saisie, de treize octets, repli sur + `config.env`, adresse publique, `X-Forwarded-Proto`, refus pendant une + optimisation ; +- écoute ouverte, fermée, `localhost`, `::1`, port illisible. + +Il tire aussi huit dispositions du dossier de Playwright pour le Chromium +retenu. + +`tests/system.rs` rejoue le tout par une seule façade, cache de +joignabilité compris. Il vérifie de bout en bout ce qui reste au Go : cache +vidé, navigateur fermé, moteur relancé par le Go, ou par le superviseur +quand la façade pilote le moteur. Une mutation de chaque règle fait échouer +les tests. + +Côté Go : +- `TestFacadeSysteme` vérifie le relais et les trois routes internes ; +- `TestFacadeRelaisGardeHote` vérifie l'hôte gardé ; +- `TestMaskAPIKeyCourte` vérifie le masque des clés courtes. diff --git a/internal/loki/backend_config.go b/internal/loki/backend_config.go index 653f195..be0cc4e 100644 --- a/internal/loki/backend_config.go +++ b/internal/loki/backend_config.go @@ -73,11 +73,16 @@ func writeAPIKey(key string) error { } // maskAPIKey renders a key for display: keep the "sk-loki-" prefix and last 4 -// chars, elide the middle. Empty in → empty out. +// chars, elide the middle. Empty in → empty out. Une clé d'un ou deux octets +// n'est pas montrée du tout : k[len(k)-2:] paniquait sur un seul octet, et +// /api/apikey tombait à chaque lecture tant que la clé restait en place. func maskAPIKey(k string) string { if k == "" { return "" } + if len(k) <= 2 { + return "…" + } if len(k) <= 13 { return "…" + k[len(k)-2:] } diff --git a/internal/loki/facade_chat.go b/internal/loki/facade_chat.go index b651fbb..61f9793 100644 --- a/internal/loki/facade_chat.go +++ b/internal/loki/facade_chat.go @@ -145,7 +145,7 @@ var facadeChatRoutes = map[string]bool{ // (facade_dictate.go), les réglages partagés (facade_settings.go), les fichiers // de la discussion (facade_files.go), les notifications (facade_push.go), la // mémoire (facade_mem.go), les presets (facade_presets.go), le pilotage du -// moteur (facade_engine.go). +// moteur (facade_engine.go), les interrupteurs système (facade_system.go). // Posé APRÈS requireWebAuth : seule une requête authentifiée ici part, avec le // jeton interne qui en tient lieu là-bas. func withFacadeChat(path string, h http.HandlerFunc) http.HandlerFunc { @@ -168,6 +168,8 @@ func withFacadeChat(path string, h http.HandlerFunc) http.HandlerFunc { held, unavailable = facadePresets, "presets indisponibles : " case facadeEngineRoutes[path]: held, unavailable = facadeEngine, "pilotage du moteur indisponible : " + case facadeSystemRoutes[path]: + held, unavailable = facadeSystem, "réglages système indisponibles : " default: return h } @@ -186,6 +188,12 @@ func facadeRelayAs(w http.ResponseWriter, r *http.Request, unavailable string) { rp := &httputil.ReverseProxy{ Rewrite: func(pr *httputil.ProxyRequest) { pr.SetURL(&url.URL{Scheme: "http", Host: facadeAddr()}) + // L'hôte et le schéma du client, pas ceux du relais : la clé de + // complétion affiche l'adresse à coller dans un client tiers. + pr.Out.Host = pr.In.Host + if p := pr.In.Header.Get("X-Forwarded-Proto"); p != "" { + pr.Out.Header.Set("X-Forwarded-Proto", p) + } pr.Out.Header.Set(internalTokenHeader, internalToken()) }, FlushInterval: -1, // SSE : chaque trame part aussitôt diff --git a/internal/loki/facade_chat_test.go b/internal/loki/facade_chat_test.go index 2a7485a..3f7e05e 100644 --- a/internal/loki/facade_chat_test.go +++ b/internal/loki/facade_chat_test.go @@ -645,3 +645,116 @@ func TestFacadeMoteur(t *testing.T) { t.Fatalf("arrêt de la dictée : %d", w.Code) } } + +// Une clé de complétion d'un ou deux octets ne fait plus tomber /api/apikey. +func TestMaskAPIKeyCourte(t *testing.T) { + for k, want := range map[string]string{ + "": "", + "k": "…", + "ab": "…", + "abc": "…bc", + "abcdefghijklm": "…lm", + "sk-loki-0123456789abcdef": "sk-loki-0…cdef", + } { + if got := maskAPIKey(k); got != want { + t.Errorf("maskAPIKey(%q) = %q, attendu %q", k, got, want) + } + } +} + +// Les interrupteurs système (LOKI_SYSTEM=rust, lot 17) partent à la façade ; +// l'API interne lui rend ce qui vit ici : cache de joignabilité des outils +// web, navigateur piloté, redémarrage du moteur. +func TestFacadeSysteme(t *testing.T) { + testHome(t) + t.Setenv("LOKI_CONTAINER", "1") + f := demarrerFacade(t) + t.Setenv(facadeChatEnv, "") + local := func(w http.ResponseWriter, r *http.Request) { io.WriteString(w, "go") } + for p := range facadeSystemRoutes { + w := httptest.NewRecorder() + withFacadeChat(p, local)(w, httptest.NewRequest("GET", p, nil)) + if w.Body.String() != "go" { + t.Fatalf("%s servi ailleurs sans LOKI_SYSTEM : %q", p, w.Body.String()) + } + } + t.Setenv(facadeSystemEnv, "rust") + for p := range facadeSystemRoutes { + w := httptest.NewRecorder() + withFacadeChat(p, local)(w, httptest.NewRequest("POST", p, strings.NewReader("{}"))) + if w.Body.String() != f.reply { + t.Fatalf("%s : réponse %q", p, w.Body.String()) + } + } + if n := len(f.recu()); n != len(facadeSystemRoutes) { + t.Fatalf("reçu par la façade : %d appels", n) + } + + // Cache de joignabilité vidé. + reachMu.Lock() + reachURL = "http://ancien" + reachMu.Unlock() + w := httptest.NewRecorder() + handleInternalReachReset(w, httptest.NewRequest("POST", "/internal/v1/web/reach/reset", nil)) + reachMu.Lock() + vide := reachURL == "" + reachMu.Unlock() + if w.Code != 204 || !vide { + t.Fatalf("reach/reset : %d, cache vidé : %v", w.Code, vide) + } + w = httptest.NewRecorder() + handleInternalCDPShutdown(w, httptest.NewRequest("POST", "/internal/v1/cdp/shutdown", nil)) + if w.Code != 204 { + t.Fatalf("cdp/shutdown : %d", w.Code) + } + + // Redémarrage : seulement si le moteur tourne quand if_active est posé. + var actions []string + old := internalServiceAction + t.Cleanup(func() { internalServiceAction = old }) + internalServiceAction = func(a string) error { + actions = append(actions, a) + if a == "start" { + return errTuneBusy + } + return nil + } + for _, c := range []struct { + body, want string + code int + }{ + {`{"action":"restart","if_active":true}`, `{"error":"","ran":false}`, 200}, + {`{"action":"restart"}`, `{"error":"","ran":true}`, 200}, + {`{"action":"start"}`, `{"error":"` + errTuneBusy.Error() + `","ran":true}`, 200}, + {`{"action":"reload"}`, `{"error":"action inconnue : reload"}`, 400}, + {`{"action":`, `{"error":"unexpected EOF"}`, 400}, + } { + w := httptest.NewRecorder() + handleInternalServiceAction(w, httptest.NewRequest("POST", "/internal/v1/service/action", strings.NewReader(c.body))) + if got := strings.TrimSpace(w.Body.String()); w.Code != c.code || got != c.want { + t.Fatalf("%s : %d %s", c.body, w.Code, got) + } + } + if strings.Join(actions, ",") != "restart,start" { + t.Fatalf("actions : %v", actions) + } +} + +// Le relais vers la façade garde l'hôte et le schéma du client : la clé de +// complétion y calcule l'adresse à coller dans un client tiers. +func TestFacadeRelaisGardeHote(t *testing.T) { + var host, proto string + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + host, proto = r.Host, r.Header.Get("X-Forwarded-Proto") + })) + defer srv.Close() + t.Setenv(internalTokenEnv, jetonTest) + t.Setenv(facadeAddrEnv, strings.TrimPrefix(srv.URL, "http://")) + r := httptest.NewRequest("GET", "/api/apikey", nil) + r.Host = "loki.exemple.fr" + r.Header.Set("X-Forwarded-Proto", "https") + facadeRelayAs(httptest.NewRecorder(), r, "") + if host != "loki.exemple.fr" || proto != "https" { + t.Fatalf("hôte %q, schéma %q", host, proto) + } +} diff --git a/internal/loki/facade_system.go b/internal/loki/facade_system.go new file mode 100644 index 0000000..4682466 --- /dev/null +++ b/internal/loki/facade_system.go @@ -0,0 +1,98 @@ +package loki + +import ( + "encoding/json" + "net/http" + "os" + "strings" +) + +// facade_system.go — interrupteurs système tenus par la façade Rust +// (LOKI_SYSTEM=rust, lot 17 de la migration ; expérimental, le Go reste le +// défaut) : accès internet (/api/internet), pilotage du navigateur +// (/api/computer), clé de complétion (/api/apikey) et écoute réseau du moteur +// (/api/network). +// +// Les réglages restent des clés de la base. Ce qui vit dans ce processus reste +// ici, derrière l'API interne : +// +// - le cache de joignabilité de Crawl4AI des outils web, vidé quand l'URL, la +// clé ou le moteur changent (/internal/v1/web/reach/reset) ; +// - le Chromium piloté par les outils browser_*, fermé quand l'interrupteur +// se coupe (/internal/v1/cdp/shutdown) ; +// - le redémarrage du moteur qui applique une nouvelle clé, quand la façade +// ne pilote pas le moteur (/internal/v1/service/action). + +const facadeSystemEnv = "LOKI_SYSTEM" + +// facadeSystem : la façade tient-elle les interrupteurs système ? +func facadeSystem() bool { + return strings.EqualFold(strings.TrimSpace(os.Getenv(facadeSystemEnv)), "rust") && + facadeAddr() != "" && internalToken() != "" +} + +// facadeSystemRoutes : les routes que la façade sert alors (system.rs). +var facadeSystemRoutes = map[string]bool{ + "/api/internet": true, + "/api/computer": true, + "/api/apikey": true, + "/api/network": true, +} + +// handleInternalReachReset : POST → 204 — le prochain tour reteste Crawl4AI. +func handleInternalReachReset(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPost { + sendJSON(w, http.StatusMethodNotAllowed, map[string]any{"error": "POST attendu"}) + return + } + reachMu.Lock() + reachURL = "" + reachMu.Unlock() + w.WriteHeader(http.StatusNoContent) +} + +// handleInternalCDPShutdown : POST → 204 — ferme le navigateur piloté. +func handleInternalCDPShutdown(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPost { + sendJSON(w, http.StatusMethodNotAllowed, map[string]any{"error": "POST attendu"}) + return + } + cdpShutdown() + w.WriteHeader(http.StatusNoContent) +} + +// internalServiceAction : serviceAction, variable pour les tests. +var internalServiceAction = serviceAction + +// handleInternalServiceAction : POST {action, if_active} → 200 {ran, error}. +// if_active : seulement si le moteur tourne (une clé neuve ne s'applique qu'au +// redémarrage, inutile de démarrer un moteur arrêté pour elle). +func handleInternalServiceAction(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPost { + sendJSON(w, http.StatusMethodNotAllowed, map[string]any{"error": "POST attendu"}) + return + } + var in struct { + Action string `json:"action"` + IfActive bool `json:"if_active"` + } + if err := json.NewDecoder(r.Body).Decode(&in); err != nil { + sendJSON(w, http.StatusBadRequest, map[string]any{"error": err.Error()}) + return + } + switch in.Action { + case "start", "stop", "restart": + default: + sendJSON(w, http.StatusBadRequest, map[string]any{"error": "action inconnue : " + in.Action}) + return + } + if in.IfActive && !serviceIsActive() { + sendJSON(w, http.StatusOK, map[string]any{"ran": false, "error": ""}) + return + } + msg := "" + if err := internalServiceAction(in.Action); err != nil { + msg = err.Error() + } + sendJSON(w, http.StatusOK, map[string]any{"ran": true, "error": msg}) +} diff --git a/internal/loki/parity_system_test.go b/internal/loki/parity_system_test.go new file mode 100644 index 0000000..3501d23 --- /dev/null +++ b/internal/loki/parity_system_test.go @@ -0,0 +1,294 @@ +package loki + +import ( + "encoding/json" + "net/http" + "net/http/httptest" + "os" + "path/filepath" + "regexp" + "strings" + "sync" + "testing" +) + +// parity_system_test.go — journal de référence des interrupteurs système +// (lot 17 de la migration Rust) : accès internet et Crawl4AI (moteur, URL, +// clé jamais rendue, joignabilité et son cache), pilotage du navigateur et +// vision, clé de complétion (génération, saisie, effacement, repli sur +// config.env, adresse à coller dans un client tiers, refus pendant une +// optimisation) et écoute réseau du moteur. Après chaque appel : les clés +// touchées en base et les en-têtes reçus par les faux serveurs Crawl4AI. +// Rangé dans rust/loki-rs/tests/parity_system/system.json ; tests/system.rs +// le rejoue par la façade. +// +// LOKI_PARITY_UPDATE=1 go test ./internal/loki -run TestPariteSysteme + +const paritySystemFile = "../../rust/loki-rs/tests/parity_system/system.json" + +type paritySystemStep struct { + Name string `json:"name"` + Config map[string]string `json:"config,omitempty"` // posé avant l'appel ; "" = supprimé + State map[string]string `json:"state,omitempty"` + Tune bool `json:"tune,omitempty"` + Method string `json:"method"` + Path string `json:"path"` + Body string `json:"body,omitempty"` + Headers map[string]string `json:"headers,omitempty"` + Status int `json:"status"` + Out any `json:"out"` + After map[string]string `json:"after"` + Probes []string `json:"probes"` // « chemin Authorization » reçus depuis l'appel précédent +} + +// paritySystemKeys : ce que les routes touchent, relu après chaque appel. +var paritySystemKeys = []string{ + "config/WEB_ENGINE", "config/CRAWL4AI_URL", "config/CRAWL4AI_KEY", "config/API_KEY", "config/HOST", + "state/internet", "state/computer", "state/crawl_key", "state/api_key", "state/public_url", +} + +var ( + parityKeyRe = regexp.MustCompile(`sk-loki-[0-9a-f]{48}`) + parityMaskedRe = regexp.MustCompile(`sk-loki-[0-9a-f]…[0-9a-f]{4}`) +) + +func TestPariteSysteme(t *testing.T) { + home := testHome(t) + t.Setenv("LOKI_CONTAINER", "1") + t.Setenv("LOKI_CHROME", "/opt/faux/chrome") + + var mu sync.Mutex + var probes []string + fake := func(code int) *httptest.Server { + return httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + mu.Lock() + probes = append(probes, strings.TrimSpace(r.URL.Path+" "+r.Header.Get("Authorization"))) + mu.Unlock() + w.WriteHeader(code) + })) + } + ok, down, notFound := fake(200), fake(500), fake(404) + defer ok.Close() + defer down.Close() + defer notFound.Close() + + mask := func(s string) string { + s = strings.ReplaceAll(s, ok.URL, "{{OK}}") + s = strings.ReplaceAll(s, down.URL, "{{DOWN}}") + s = strings.ReplaceAll(s, notFound.URL, "{{NOTFOUND}}") + s = strings.ReplaceAll(s, home, "{{HOME}}") + if ip := localIP(); ip != "" { + s = strings.ReplaceAll(s, "http://"+ip+":", "http://{{IP}}:") + } + s = parityKeyRe.ReplaceAllString(s, "{{KEY}}") + return parityMaskedRe.ReplaceAllString(s, "{{MASKED}}") + } + unmask := func(s string) string { + s = strings.ReplaceAll(s, "{{OK}}", ok.URL) + s = strings.ReplaceAll(s, "{{DOWN}}", down.URL) + return strings.ReplaceAll(s, "{{NOTFOUND}}", notFound.URL) + } + handlers := map[string]http.HandlerFunc{ + "/api/internet": handleInternet, + "/api/computer": handleComputer, + "/api/apikey": handleAPIKey, + "/api/network": handleNetwork, + } + run := func(st paritySystemStep) paritySystemStep { + for k, v := range st.Config { + if err := SetConfigKey(k, unmask(v)); err != nil { + t.Fatal(err) + } + } + for k, v := range st.State { + if err := putStr(bkState, k, unmask(v)); err != nil { + t.Fatal(err) + } + } + var lock *tuneLock + if st.Tune { + l, err := tuneLockAcquire("web", false) + if err != nil { + t.Fatal(err) + } + lock = l + } + mu.Lock() + probes = nil + mu.Unlock() + req := httptest.NewRequest(st.Method, st.Path, strings.NewReader(unmask(st.Body))) + req.Host = "loki.local:8090" + for k, v := range st.Headers { + req.Header.Set(k, v) + } + rec := httptest.NewRecorder() + handlers[st.Path](rec, req) + if lock != nil { + lock.release() + } + st.Status = rec.Code + var out any + if err := json.Unmarshal([]byte(mask(rec.Body.String())), &out); err != nil { + t.Fatalf("%s : corps illisible %q", st.Name, rec.Body.String()) + } + st.Out = out + st.After = map[string]string{} + for _, k := range paritySystemKeys { + b, key, _ := strings.Cut(k, "/") + if v := getStr(b, key); v != "" { + st.After[k] = mask(v) + } + } + mu.Lock() + st.Probes = append([]string{}, probes...) + mu.Unlock() + for i := range st.Probes { + st.Probes[i] = mask(st.Probes[i]) + } + return st + } + + var steps []paritySystemStep + for _, st := range []paritySystemStep{ + // --- accès internet --- + {Name: "internet par défaut", Method: "GET", Path: "/api/internet"}, + {Name: "allumé, moteur intégré", Method: "POST", Path: "/api/internet", Body: `{"enabled":true}`}, + {Name: "moteur crawl4ai sans URL", Method: "POST", Path: "/api/internet", Body: `{"engine":" CRAWL4AI "}`}, + {Name: "moteur inconnu", Method: "POST", Path: "/api/internet", Body: `{"engine":"bing"}`}, + {Name: "moteur inconnu vide", Method: "POST", Path: "/api/internet", Body: `{"engine":" "}`}, + {Name: "URL joignable", Method: "POST", Path: "/api/internet", Body: `{"url":" {{OK}}/// "}`}, + {Name: "relu dans le cache", Method: "GET", Path: "/api/internet"}, + {Name: "clé posée", Method: "POST", Path: "/api/internet", Body: `{"key":" secret-clé-crawl "}`}, + {Name: "clé courte", Method: "POST", Path: "/api/internet", Body: `{"key":"abcd"}`}, + {Name: "clé d'un caractère", Method: "POST", Path: "/api/internet", Body: `{"key":"é"}`}, + {Name: "clé résiduelle en config", Config: map[string]string{"CRAWL4AI_KEY": "vieille"}, + Method: "POST", Path: "/api/internet", Body: `{"key":"neuve12345"}`}, + {Name: "URL en panne", Method: "POST", Path: "/api/internet", Body: `{"url":"{{DOWN}}"}`}, + {Name: "URL 404", Method: "POST", Path: "/api/internet", Body: `{"url":"{{NOTFOUND}}"}`}, + {Name: "URL injoignable", Method: "POST", Path: "/api/internet", Body: `{"url":"http://127.0.0.1:1"}`}, + {Name: "URL sans schéma", Method: "POST", Path: "/api/internet", Body: `{"url":"exemple"}`}, + {Name: "type faux, le reste passe", Method: "POST", Path: "/api/internet", Body: `{"enabled":"oui","url":"{{OK}}"}`}, + {Name: "corps illisible", Method: "POST", Path: "/api/internet", Body: `{"enabled":false`}, + {Name: "éteint, clé effacée", Method: "POST", Path: "/api/internet", Body: `{"enabled":false,"key":""}`}, + {Name: "null ne touche à rien", Method: "POST", Path: "/api/internet", Body: `{"enabled":null,"url":null,"engine":null}`}, + {Name: "URL vidée, moteur choisi gardé", Method: "POST", Path: "/api/internet", Body: `{"url":"","enabled":true}`}, + {Name: "moteur go", Method: "POST", Path: "/api/internet", Body: `{"engine":"Go"}`}, + {Name: "moteur non choisi, URL héritée", Config: map[string]string{"WEB_ENGINE": "", "CRAWL4AI_URL": "{{OK}}"}, + Method: "GET", Path: "/api/internet"}, + {Name: "moteur illisible en config", Config: map[string]string{"WEB_ENGINE": "autre", "CRAWL4AI_URL": ""}, + Method: "GET", Path: "/api/internet"}, + {Name: "URL en config avec slashs", Config: map[string]string{"WEB_ENGINE": "crawl4ai", "CRAWL4AI_URL": " {{DOWN}}// "}, + Method: "GET", Path: "/api/internet"}, + {Name: "internet en PUT", Method: "PUT", Path: "/api/internet", Body: `{"enabled":true}`}, + + // --- navigateur --- + {Name: "navigateur par défaut", Method: "GET", Path: "/api/computer"}, + {Name: "navigateur allumé", Method: "POST", Path: "/api/computer", Body: `{"on":true}`}, + {Name: "navigateur, corps illisible", Method: "POST", Path: "/api/computer", Body: `oui`}, + {Name: "vision par projecteur", Config: map[string]string{"MMPROJ": " mmproj.gguf "}, + Method: "GET", Path: "/api/computer"}, + {Name: "vision externe", Config: map[string]string{"MMPROJ": "", "EXTERNAL": "1", "EXTERNAL_VISION": "1"}, + Method: "GET", Path: "/api/computer"}, + {Name: "externe sans vision", Config: map[string]string{"EXTERNAL_VISION": "0", "MMPROJ": "m.gguf"}, + Method: "GET", Path: "/api/computer"}, + {Name: "vision strata", Config: map[string]string{"EXTERNAL": "", "ENGINE": "strata"}, + Method: "GET", Path: "/api/computer"}, + {Name: "strata sans vision", Config: map[string]string{"STRATA_VISION": "0", "MMPROJ": ""}, + Method: "GET", Path: "/api/computer"}, + {Name: "navigateur éteint", Config: map[string]string{"ENGINE": "", "STRATA_VISION": ""}, + Method: "POST", Path: "/api/computer", Body: `{"on":false}`}, + + // --- clé de complétion --- + {Name: "clé par défaut", Method: "GET", Path: "/api/apikey"}, + {Name: "clé générée", Method: "POST", Path: "/api/apikey", Body: `{"action":"generate"}`}, + {Name: "clé relue", Method: "GET", Path: "/api/apikey", Headers: map[string]string{"X-Forwarded-Proto": "HTTPS, http"}}, + {Name: "clé saisie", Method: "POST", Path: "/api/apikey", Body: `{"action":"set","key":" ma-clé-perso-é "}`}, + {Name: "clé saisie courte", Method: "POST", Path: "/api/apikey", Body: `{"action":"set","key":"abc"}`}, + {Name: "clé de 13 octets", Method: "POST", Path: "/api/apikey", Body: `{"action":"set","key":"abcdefghijklm"}`}, + {Name: "clé effacée", Method: "POST", Path: "/api/apikey", Body: `{"action":"clear"}`}, + {Name: "repli sur config.env", Config: map[string]string{"API_KEY": " résiduelle-config "}, + Method: "GET", Path: "/api/apikey", Headers: map[string]string{"X-Forwarded-Proto": "ftp"}}, + {Name: "adresse publique", State: map[string]string{"public_url": "https://loki.exemple.fr"}, + Method: "POST", Path: "/api/apikey", Body: `{"action":"set","key":"k"}`}, + {Name: "action inconnue", Method: "POST", Path: "/api/apikey", Body: `{"action":"rotate"}`}, + {Name: "action vide", Method: "POST", Path: "/api/apikey", Body: `{}`}, + {Name: "corps vide", Method: "POST", Path: "/api/apikey", Body: ``}, + {Name: "corps illisible", Method: "POST", Path: "/api/apikey", Body: `{"action":`}, + {Name: "type faux", Method: "POST", Path: "/api/apikey", Body: `{"action":1}`}, + {Name: "optimisation en cours", Tune: true, Method: "POST", Path: "/api/apikey", Body: `{"action":"clear"}`}, + {Name: "lecture pendant une optimisation", Tune: true, Method: "GET", Path: "/api/apikey"}, + + // --- réseau --- + {Name: "réseau par défaut", Method: "GET", Path: "/api/network"}, + {Name: "réseau fermé", Method: "POST", Path: "/api/network", Body: `{"exposed":false}`}, + {Name: "réseau ouvert", Method: "POST", Path: "/api/network", Body: `{"exposed":true}`}, + {Name: "réseau sans champ", Method: "POST", Path: "/api/network", Body: `{}`}, + {Name: "réseau illisible", Method: "POST", Path: "/api/network", Body: `{"exposed":1}`}, + {Name: "réseau corps vide", Method: "POST", Path: "/api/network", Body: ``}, + {Name: "HOST localhost", Config: map[string]string{"HOST": " LocalHost ", "PORT": "9090"}, Method: "GET", Path: "/api/network"}, + {Name: "HOST ::1", Config: map[string]string{"HOST": "::1", "PORT": "abc"}, Method: "GET", Path: "/api/network"}, + {Name: "HOST d'une carte", Config: map[string]string{"HOST": "192.168.1.5", "PORT": ""}, Method: "GET", Path: "/api/network"}, + } { + steps = append(steps, run(st)) + } + + // --- le Chromium de Playwright, le plus récent --- + type chromiumCase struct { + Files []string `json:"files"` // relatifs à la racine ; « / » final = dossier + Out string `json:"out"` + } + var chromium []chromiumCase + for _, files := range [][]string{ + {"chromium-1000/chrome-linux/chrome", "chromium-1200/chrome-linux/chrome"}, + {"chromium-1000/chrome-linux/chrome", "chromium-1200/chrome-linux/headless_shell"}, + {"chromium-1200/chrome-linux/headless_shell", "chromium-1200/chrome-linux/chrome"}, + {"chromium-1000/chrome-linux/chrome", "chromium_headless_shell-1100/chrome-linux/headless_shell"}, + {"chromium-1000/chrome-linux/chrome", "chromium-1300/chrome-linux/chrome/"}, + {"chromiumX/chrome-linux/chrome", "chromium-1/chrome-linux/chrome"}, + {"firefox-1/chrome-linux/chrome", "chromium-/chrome-linux/chrome"}, + {"chromium-9/chrome-linux/chrome", "chromium-10/chrome-linux/chrome"}, + } { + root := t.TempDir() + for _, f := range files { + p := filepath.Join(root, f) + if strings.HasSuffix(f, "/") { + 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, nil, 0o755); err != nil { + t.Fatal(err) + } + } + t.Setenv("PLAYWRIGHT_BROWSERS_PATH", root) + chromium = append(chromium, chromiumCase{Files: files, + Out: strings.TrimPrefix(playwrightChromium(), root+"/")}) + } + + got, err := parityJSON(map[string]any{"steps": steps, "chromium": chromium}) + if err != nil { + t.Fatal(err) + } + if os.Getenv("LOKI_PARITY_UPDATE") == "1" { + if err := os.MkdirAll(filepath.Dir(paritySystemFile), 0o755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(paritySystemFile, got, 0o644); err != nil { + t.Fatal(err) + } + return + } + want, err := os.ReadFile(paritySystemFile) + 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 interrupteurs système ne produisent plus le journal rangé dans %s.\n"+ + "Si c'est voulu : LOKI_PARITY_UPDATE=1 go test ./internal/loki -run TestPariteSysteme,\n"+ + "puis cargo test --test system dit si le port Rust suit.\n--- obtenu ---\n%s", paritySystemFile, got) + } +} diff --git a/internal/loki/web_internal.go b/internal/loki/web_internal.go index 2274d93..66d09cf 100644 --- a/internal/loki/web_internal.go +++ b/internal/loki/web_internal.go @@ -119,6 +119,12 @@ func withInternalAPI(next http.Handler) http.Handler { handleInternalChatGenerating(w, r) case "/internal/v1/dictate/stop": handleInternalDictateStop(w, r) + case "/internal/v1/web/reach/reset": + handleInternalReachReset(w, r) + case "/internal/v1/cdp/shutdown": + handleInternalCDPShutdown(w, r) + case "/internal/v1/service/action": + handleInternalServiceAction(w, r) default: http.NotFound(w, r) } diff --git a/rust/loki-rs/src/api.rs b/rust/loki-rs/src/api.rs index 0487d3d..9b7866b 100644 --- a/rust/loki-rs/src/api.rs +++ b/rust/loki-rs/src/api.rs @@ -44,6 +44,8 @@ pub struct Ctx { pub presets: bool, /// Pilotage du moteur tenu par la façade (`LOKI_ENGINE=rust`). pub engine_ctl: Option>, + /// Interrupteurs système tenus par la façade (`LOKI_SYSTEM=rust`). + pub system: Option>, /// Jeton de l'API interne : une requête qui le porte vient du Go. pub internal_token: String, } @@ -61,6 +63,7 @@ impl Ctx { || self.mem.is_some() || self.presets || self.engine_ctl.is_some() + || self.system.is_some() } /// La requête vient du Go (jeton interne valide) : déjà authentifiée par diff --git a/rust/loki-rs/src/enginectl.rs b/rust/loki-rs/src/enginectl.rs index b3eb06a..d112773 100644 --- a/rust/loki-rs/src/enginectl.rs +++ b/rust/loki-rs/src/enginectl.rs @@ -343,7 +343,7 @@ pub async fn service_action(ctx: &Ctx, ctl: &EngineCtl, action: &str) -> Result< } /// `serviceIsActive` en conteneur : le PID du fichier vit. -fn service_is_active(ctx: &Ctx) -> bool { +pub(crate) fn service_is_active(ctx: &Ctx) -> bool { pid_alive(crate::engine::read_pid(&ctx.engine.pid())) } diff --git a/rust/loki-rs/src/lib.rs b/rust/loki-rs/src/lib.rs index f1c81c1..25bd3c4 100644 --- a/rust/loki-rs/src/lib.rs +++ b/rust/loki-rs/src/lib.rs @@ -36,6 +36,7 @@ pub mod serve; pub mod settings; pub mod store; pub mod sysinfo; +pub mod system; pub mod tools; pub mod ui; pub mod update; diff --git a/rust/loki-rs/src/main.rs b/rust/loki-rs/src/main.rs index 506136e..16074f8 100644 --- a/rust/loki-rs/src/main.rs +++ b/rust/loki-rs/src/main.rs @@ -151,6 +151,9 @@ fn serve() -> ExitCode { // LOKI_ENGINE=rust : le pilotage du moteur (démarrage, arrêt, // déchargement de la VRAM), en conteneur avec le superviseur Rust // seulement — sous systemd, le Go pilote son unité. + // LOKI_SYSTEM=rust : les interrupteurs système (accès internet, + // navigateur, clé de complétion, écoute réseau du moteur). + let system_rust = held_by_facade("LOKI_SYSTEM"); let engine_rust = held_by_facade("LOKI_ENGINE") && match loki_rs::enginectl::usable( std::env::var("LOKI_CONTAINER").ok().as_deref(), @@ -178,6 +181,7 @@ fn serve() -> ExitCode { ("LOKI_MEM", mem_rust), ("LOKI_PRESETS", presets_rust), ("LOKI_ENGINE", engine_rust), + ("LOKI_SYSTEM", system_rust), ]; for (var, on) in held { if on { @@ -280,6 +284,7 @@ fn serve() -> ExitCode { mem: mem_rust.then(|| std::sync::Arc::new(loki_rs::memory::Memory::new(mem_home))), presets: presets_rust, engine_ctl, + system: system_rust.then(|| std::sync::Arc::new(loki_rs::system::System::new())), }; let res = proxy::serve(listener, cfg, shutdown_signal()).await; eprintln!("[loki-rs] arrêt demandé — arrêt du serveur Go"); diff --git a/rust/loki-rs/src/proxy.rs b/rust/loki-rs/src/proxy.rs index ee26fb6..6e58743 100644 --- a/rust/loki-rs/src/proxy.rs +++ b/rust/loki-rs/src/proxy.rs @@ -82,6 +82,9 @@ pub struct Config { /// Pilotage du moteur tenu par la façade (`LOKI_ENGINE=rust`) : /// démarrage, arrêt, déchargement de la VRAM ; `None` : au Go. pub engine_ctl: Option>, + /// Interrupteurs système (internet, navigateur, clé de complétion, + /// réseau) tenus par la façade (`LOKI_SYSTEM=rust`) ; `None` : au Go. + pub system: Option>, } struct Proxy { @@ -117,6 +120,7 @@ pub async fn serve( mem: cfg.mem, presets: cfg.presets, engine_ctl: cfg.engine_ctl, + system: cfg.system, internal_token: cfg.internal_token.clone(), }, }); @@ -204,6 +208,11 @@ impl Proxy { { return chatapi::serve(&self.ctx, chat, req, from_go).await; } + if let Some(s) = &self.ctx.system + && crate::system::handles(path) + { + return crate::system::serve(&self.ctx, s, req, from_go).await; + } if let Some(e) = &self.ctx.engine_ctl && crate::enginectl::handles(path) { diff --git a/rust/loki-rs/src/system.rs b/rust/loki-rs/src/system.rs new file mode 100644 index 0000000..018b459 --- /dev/null +++ b/rust/loki-rs/src/system.rs @@ -0,0 +1,664 @@ +//! Interrupteurs système tenus par la façade (lot 17 de la migration, +//! `LOKI_SYSTEM=rust` ; expérimental, le Go reste le défaut) : +//! +//! - `/api/internet` — port de `handleInternet` (`web_api.go`) et de +//! `chat_internet.go` / `web_fetch_go.go` : moteur web, serveur Crawl4AI et +//! sa clé (jamais rendue), joignabilité avec son cache de 30 s ; +//! - `/api/computer` — `handleComputer`, `chromePath`, `visionEnabled` ; +//! - `/api/apikey` — `handleAPIKey` et `backend_config.go` : clé de +//! complétion, repli sur `config.env`, adresse à coller dans un client tiers +//! (`oaiBaseURL`) ; +//! - `/api/network` — `handleNetwork`, `sys_network.go` (en conteneur Linux, +//! aucun pare-feu à piloter : « inconnu »). +//! +//! Les réglages restent des clés de la base. Ce qui vit dans le processus Go +//! reste au Go : son cache de joignabilité (vidé par +//! `/internal/v1/web/reach/reset`), le Chromium des outils `browser_*` +//! (`/internal/v1/cdp/shutdown`) et le redémarrage du moteur quand la façade ne +//! le pilote pas (`/internal/v1/service/action`). + +use std::time::{Duration, Instant}; + +use http_body_util::{BodyExt, Limited}; +use hyper::body::Incoming; +use hyper::header::HOST; +use hyper::{Method, Request, Response, StatusCode}; +use serde_json::{Value, json}; + +use crate::api::Ctx; +use crate::auth::json as send_json; +use crate::gojson::{self, Kind}; +use crate::proxy::Body; +use crate::serve::{self, Cfg}; +use crate::store::{BK_CONFIG, BK_STATE, Store}; + +/// Moteur web intégré (`engineGo`). +pub const ENGINE_GO: &str = "go"; +/// Serveur Crawl4AI (`engineCrawl`). +pub const ENGINE_CRAWL: &str = "crawl4ai"; +/// Durée de vie du cache de joignabilité (`reachTTL`). +pub const REACH_TTL: Duration = Duration::from_secs(30); + +/// État tenu par la façade pour ce lot. +pub struct System { + /// Binaire du navigateur imposé (tests) ; `None` : [`chrome_path`]. + pub chrome: Option, + reach: tokio::sync::Mutex, + client: reqwest::Client, +} + +#[derive(Default)] +struct Reach { + ok: bool, + url: String, + when: Option, +} + +impl Default for System { + fn default() -> Self { + Self::new() + } +} + +impl System { + pub fn new() -> Self { + crate::chat::engine::install_crypto(); + System { + chrome: None, + reach: tokio::sync::Mutex::new(Reach::default()), + client: reqwest::Client::builder().build().unwrap_or_default(), + } + } + + /// Le cache de joignabilité est périmé : ici, et dans le Go (outils web). + async fn reach_reset(&self, store: &Store) { + self.reach.lock().await.url.clear(); + if let Err(e) = store + .internal_post("/internal/v1/web/reach/reset", &json!({})) + .await + { + eprintln!("[internet] cache de joignabilité du Go : {e}"); + } + } + + /// `crawlReachable` : le serveur Crawl4AI répond-il (moins de 500 sur + /// `/health` ou `/`, deux secondes en tout) ? Le moteur intégré n'a rien à + /// joindre. + async fn reachable(&self, cfg: &Cfg, key: &str) -> bool { + if web_engine(cfg) == ENGINE_GO { + return true; + } + let base = crawl4ai_url(cfg); + if base.is_empty() { + return false; + } + let mut r = self.reach.lock().await; + if base == r.url && r.when.is_some_and(|w| w.elapsed() < REACH_TTL) { + return r.ok; + } + let probe = async { + for path in ["/health", "/"] { + let mut rq = self.client.get(format!("{base}{path}")); + if !key.is_empty() { + rq = rq.header("Authorization", format!("Bearer {key}")); + } + if let Ok(resp) = rq.send().await + && resp.status().as_u16() < 500 + { + return true; + } + } + false + }; + let ok = tokio::time::timeout(Duration::from_secs(2), probe) + .await + .unwrap_or(false); + *r = Reach { + ok, + url: base, + when: Some(Instant::now()), + }; + ok + } +} + +// --- Fonctions pures --------------------------------------------------------- + +/// `crawl4aiURL` : sans slash final, vide si non configurée. +pub fn crawl4ai_url(cfg: &Cfg) -> String { + serve::trim(serve::get(cfg, "CRAWL4AI_URL")) + .trim_end_matches('/') + .to_string() +} + +/// `webEngine` : le moteur choisi ; sinon Crawl4AI si une URL est déjà là +/// (installation d'avant le choix), le moteur intégré par défaut. +pub fn web_engine(cfg: &Cfg) -> &'static str { + match serve::trim(serve::get(cfg, "WEB_ENGINE")) + .to_lowercase() + .as_str() + { + ENGINE_GO => return ENGINE_GO, + ENGINE_CRAWL => return ENGINE_CRAWL, + _ => {} + } + if crawl4ai_url(cfg).is_empty() { + ENGINE_GO + } else { + ENGINE_CRAWL + } +} + +/// `internetEnabled` : interrupteur armé ET moteur utilisable. +pub fn internet_enabled(cfg: &Cfg, switch_on: bool) -> bool { + if web_engine(cfg) == ENGINE_CRAWL && crawl4ai_url(cfg).is_empty() { + return false; + } + switch_on +} + +/// Indice de la clé Crawl4AI : ses quatre derniers caractères. +pub fn key_hint(key: &str) -> String { + let runes: Vec = key.chars().collect(); + if runes.len() > 4 { + format!("…{}", runes[runes.len() - 4..].iter().collect::()) + } else if !key.is_empty() { + "…".into() + } else { + String::new() + } +} + +/// Octets → texte comme l'encodeur JSON du Go : chaque octet invalide devient +/// U+FFFD. +fn go_lossy(mut b: &[u8]) -> String { + let mut out = String::new(); + loop { + match std::str::from_utf8(b) { + Ok(s) => { + out.push_str(s); + return out; + } + Err(e) => { + let (ok, rest) = b.split_at(e.valid_up_to()); + out.push_str(std::str::from_utf8(ok).unwrap_or_default()); + let bad = e.error_len().unwrap_or(rest.len()); + out.extend(std::iter::repeat_n('\u{FFFD}', bad)); + b = &rest[bad..]; + } + } + } +} + +/// `maskAPIKey` : préfixe « sk-loki-? » et quatre derniers octets ; une clé de +/// treize octets au plus ne montre que les deux derniers, une clé d'un ou deux +/// octets rien. +pub fn mask_api_key(k: &str) -> String { + let b = k.as_bytes(); + match b.len() { + 0 => String::new(), + 1 | 2 => "…".into(), + n if n <= 13 => format!("…{}", go_lossy(&b[n - 2..])), + n => format!("{}…{}", go_lossy(&b[..9]), go_lossy(&b[n - 4..])), + } +} + +/// `genAPIKey`. +pub fn gen_api_key() -> String { + let mut b = [0u8; 24]; + let _ = getrandom::fill(&mut b); + format!("sk-loki-{}", hex::encode(b)) +} + +/// `visionEnabled` : un projecteur multimodal est-il configuré ? +pub fn vision_enabled(cfg: &Cfg) -> bool { + if serve::is_external(cfg) { + return serve::trim(serve::get(cfg, "EXTERNAL_VISION")) == "1"; + } + if serve::is_strata(cfg) && serve::get(cfg, "STRATA_VISION") != "0" { + return true; + } + !serve::trim(serve::get(cfg, "MMPROJ")).is_empty() +} + +/// `playwrightChromium` : le Chromium déposé par Playwright, le plus récent. +pub fn playwright_chromium(browsers_env: Option<&str>, home: Option<&str>) -> String { + let mut roots = Vec::new(); + if let Some(p) = browsers_env + .map(str::trim) + .filter(|p| !p.is_empty() && *p != "0") + { + roots.push(p.to_string()); + } + roots.push("/opt/pw-browsers".into()); + if let Some(h) = home.filter(|h| !h.is_empty()) { + roots.push(crate::paths::go_join(&[h, ".cache", "ms-playwright"])); + } + for root in roots { + let Ok(rd) = std::fs::read_dir(&root) else { + continue; + }; + let mut dirs: Vec = rd + .filter_map(|e| e.ok()) + .map(|e| e.file_name().to_string_lossy().into_owned()) + .filter(|n| { + n.strip_prefix("chromium") + .is_some_and(|rest| rest.contains('-')) + }) + .map(|n| crate::paths::go_join(&[&root, &n])) + .collect(); + dirs.sort(); + for d in dirs.iter().rev() { + for rel in [ + ["chrome-linux", "chrome"], + ["chrome-linux", "headless_shell"], + ] { + let p = crate::paths::go_join(&[d, rel[0], rel[1]]); + if std::fs::metadata(&p).is_ok_and(|m| !m.is_dir()) { + return p; + } + } + } + } + String::new() +} + +/// `chromePath` : `LOKI_CHROME`, le Chromium de Playwright, puis le PATH. +pub fn chrome_path() -> String { + let env = |k: &str| std::env::var(k).ok(); + if let Some(p) = env("LOKI_CHROME") + .map(|p| p.trim().to_string()) + .filter(|p| !p.is_empty()) + { + return p; + } + let p = playwright_chromium( + env("PLAYWRIGHT_BROWSERS_PATH").as_deref(), + env("HOME").as_deref(), + ); + if !p.is_empty() { + return p; + } + for n in [ + "google-chrome", + "google-chrome-stable", + "chromium", + "chromium-browser", + "microsoft-edge", + "chrome", + ] { + if let Some(p) = crate::paths::look_path(n) { + return p; + } + } + String::new() +} + +/// `engineHost`. +pub fn engine_host(cfg: &Cfg) -> String { + let h = serve::trim(serve::get(cfg, "HOST")); + if h.is_empty() { + "0.0.0.0".into() + } else { + h.into() + } +} + +/// `lanExposed`. +pub fn lan_exposed(cfg: &Cfg) -> bool { + let h = engine_host(cfg); + h != "127.0.0.1" && h != "::1" && !h.eq_ignore_ascii_case("localhost") +} + +/// `localIP` : l'adresse par laquelle cette machine sort sur le réseau. +pub fn local_ip() -> String { + let s = match std::net::UdpSocket::bind("0.0.0.0:0") { + Ok(s) => s, + Err(_) => return "localhost".into(), + }; + if s.connect("8.8.8.8:80").is_err() { + return "localhost".into(); + } + s.local_addr() + .map(|a| a.ip().to_string()) + .unwrap_or_else(|_| "localhost".into()) +} + +/// `networkStatus` (aucun pare-feu piloté hors Windows). +pub fn network_status(cfg: &Cfg) -> Value { + let port = crate::engstatus::llm_port(cfg); + json!({ + "exposed": lan_exposed(cfg), + "host": engine_host(cfg), + "port": port, + "url": format!("http://{}:{port}/v1", local_ip()), + "firewall": "inconnu", + "hint": "", + }) +} + +/// `requestBase` : « schéma://hôte » de la requête (la façade ne termine pas +/// le TLS : le schéma ne vient que de X-Forwarded-Proto). +pub fn request_base(headers: &hyper::HeaderMap, uri: &hyper::Uri) -> String { + let mut scheme = "http".to_string(); + if let Some(fwd) = headers + .get("x-forwarded-proto") + .and_then(|v| v.to_str().ok()) + .filter(|v| !v.is_empty()) + { + let first = fwd.split(',').next().unwrap_or("").trim().to_lowercase(); + if first == "http" || first == "https" { + scheme = first; + } + } + let host = headers + .get(HOST) + .and_then(|v| v.to_str().ok()) + .map(str::to_string) + .or_else(|| uri.authority().map(|a| a.to_string())) + .filter(|h| !h.is_empty()) + .unwrap_or_else(|| "localhost".into()); + format!("{scheme}://{host}") +} + +// --- Routes ------------------------------------------------------------------ + +/// Les routes servies par ce module. +pub fn handles(path: &str) -> bool { + matches!( + path, + "/api/internet" | "/api/computer" | "/api/apikey" | "/api/network" + ) +} + +/// `putStr` : une chaîne vide efface la clé. +async fn put_str(store: &Store, bucket: &str, key: &str, val: &str) -> Result<(), String> { + if val.is_empty() { + store.del(bucket, key).await + } else { + store.put(bucket, key, val.as_bytes().to_vec()).await + } +} + +/// `putBool`. +async fn put_bool(store: &Store, key: &str, on: bool) -> Result<(), String> { + put_str(store, BK_STATE, key, if on { "1" } else { "" }).await +} + +async fn get_bool(store: &Store, key: &str) -> bool { + store.get_str(BK_STATE, key).await == "1" +} + +fn fail(code: StatusCode, e: impl Into) -> Response { + send_json(code, &json!({ "ok": false, "error": e.into() })) +} + +/// Le verrou de l'optimiseur (au Go) : `Some(réponse 409)` s'il tient le +/// moteur. +async fn tune_deny(store: &Store) -> Option> { + let v = store + .internal_json(Method::GET, "/internal/v1/tune/guard", None) + .await + .ok()?; + if v.get("busy").and_then(Value::as_bool) == Some(true) { + return Some(send_json( + StatusCode::CONFLICT, + &json!({ "ok": false, "tuning": true, "error": v.get("error").and_then(Value::as_str).unwrap_or("") }), + )); + } + None +} + +/// La clé ne s'applique qu'au redémarrage du moteur : relancé s'il tourne, +/// par la façade si elle le pilote, sinon par le Go. +async fn restart_if_active(ctx: &Ctx) { + if let Some(ctl) = &ctx.engine_ctl { + if crate::enginectl::service_is_active(ctx) { + let _ = crate::enginectl::service_action(ctx, ctl, "restart").await; + } + return; + } + if let Err(e) = ctx + .store + .internal_post( + "/internal/v1/service/action", + &json!({ "action": "restart", "if_active": true }), + ) + .await + { + eprintln!("[clé] redémarrage du moteur : {e}"); + } +} + +const INTERNET_REQ_TYPE: &str = r#"struct { Enabled *bool "json:\"enabled\""; URL *string "json:\"url\""; Key *string "json:\"key\""; Engine *string "json:\"engine\"" }"#; +const COMPUTER_REQ_TYPE: &str = r#"struct { On bool "json:\"on\"" }"#; +const APIKEY_REQ_TYPE: &str = + r#"struct { Action string "json:\"action\""; Key string "json:\"key\"" }"#; +const NETWORK_REQ_TYPE: &str = r#"struct { Exposed *bool "json:\"exposed\"" }"#; + +/// `from_go` : remise par le Go (tunnel), déjà authentifiée par lui. +pub async fn serve( + ctx: &Ctx, + sys: &System, + req: Request, + from_go: bool, +) -> Response { + let (parts, body) = req.into_parts(); + if !from_go && let Err(refus) = ctx.guard(&parts.headers).await { + return *refus; + } + let post = parts.method == Method::POST; + let raw = if post { + Limited::new(body, 1 << 20) + .collect() + .await + .map(|b| b.to_bytes().to_vec()) + .unwrap_or_default() + } else { + Vec::new() + }; + let store = &ctx.store; + match parts.uri.path() { + "/api/internet" => { + if post { + let d = gojson::decode_struct( + &raw, + INTERNET_REQ_TYPE, + &[ + ("enabled", Kind::OptBool), + ("url", Kind::OptStr), + ("key", Kind::OptStr), + ("engine", Kind::OptStr), + ], + ); + if let Some(engine) = d.opt_str("engine") { + let e = engine.trim().to_lowercase(); + if e != ENGINE_GO && e != ENGINE_CRAWL { + return fail( + StatusCode::BAD_REQUEST, + format!("moteur web inconnu : {}", gojson::quote(&e)), + ); + } + if let Err(err) = store.put(BK_CONFIG, "WEB_ENGINE", e.into_bytes()).await { + return fail(StatusCode::BAD_REQUEST, err); + } + sys.reach_reset(store).await; + } + if let Some(key) = d.opt_str("key") { + // Rien ne traîne dans la configuration. + let _ = store.del(BK_CONFIG, "CRAWL4AI_KEY").await; + if let Err(err) = put_str(store, BK_STATE, "crawl_key", key.trim()).await { + return fail(StatusCode::INTERNAL_SERVER_ERROR, err); + } + sys.reach_reset(store).await; + } + if let Some(u) = d.opt_str("url") { + let u = u.trim().trim_end_matches('/'); + if let Err(err) = put_str(store, BK_CONFIG, "CRAWL4AI_URL", u).await { + return fail(StatusCode::INTERNAL_SERVER_ERROR, err); + } + sys.reach_reset(store).await; + } + if let Some(on) = d.opt_bool("enabled") + && let Err(err) = put_bool(store, "internet", on).await + { + return fail(StatusCode::INTERNAL_SERVER_ERROR, err); + } + } + let cfg = store.config().await; + let key = store.get_str(BK_STATE, "crawl_key").await; + let switch_on = get_bool(store, "internet").await; + send_json( + StatusCode::OK, + &json!({ + "ok": true, + "enabled": internet_enabled(&cfg, switch_on), + "engine": web_engine(&cfg), + "url": crawl4ai_url(&cfg), + "reachable": sys.reachable(&cfg, &key).await, + "key_set": !key.is_empty(), + "key_hint": key_hint(&key), + }), + ) + } + "/api/computer" => { + if post { + let on = gojson::decode_struct(&raw, COMPUTER_REQ_TYPE, &[("on", Kind::Bool)]) + .bool("on"); + if let Err(e) = put_bool(store, "computer", on).await { + return fail(StatusCode::INTERNAL_SERVER_ERROR, e); + } + // Couper l'interrupteur ferme le navigateur (au Go, qui le + // pilote pour les outils browser_*). + if !on + && let Err(e) = store + .internal_post("/internal/v1/cdp/shutdown", &json!({})) + .await + { + eprintln!("[navigateur] fermeture demandée au Go : {e}"); + } + } + let bin = match &sys.chrome { + Some(c) => c.clone(), + None => tokio::task::spawn_blocking(chrome_path) + .await + .unwrap_or_default(), + }; + let cfg = store.config().await; + send_json( + StatusCode::OK, + &json!({ + "ok": true, + "enabled": get_bool(store, "computer").await, + "browser": bin, + "browser_ok": !bin.is_empty(), + "vision": vision_enabled(&cfg), + }), + ) + } + "/api/apikey" => { + if post { + if let Some(r) = tune_deny(store).await { + return r; + } + let d = gojson::decode_struct( + &raw, + APIKEY_REQ_TYPE, + &[("action", Kind::Str), ("key", Kind::Str)], + ); + if let Some(e) = d.err.clone() { + return fail(StatusCode::BAD_REQUEST, e); + } + let key = match d.str("action").as_str() { + "generate" => gen_api_key(), + "set" => d.str("key").trim().to_string(), + "clear" => String::new(), + _ => return fail(StatusCode::BAD_REQUEST, "action inconnue"), + }; + // Aucune ambiguïté avec une valeur résiduelle de config.env. + let _ = store.del(BK_CONFIG, "API_KEY").await; + if let Err(e) = put_str(store, BK_STATE, "api_key", &key).await { + return fail(StatusCode::INTERNAL_SERVER_ERROR, e); + } + restart_if_active(ctx).await; + } + // `effectiveAPIKeyErr` : la clé en base, sinon celle de config.env. + let mut k = store + .get(BK_STATE, "api_key") + .await + .ok() + .flatten() + .map(|b| go_lossy(&b)) + .unwrap_or_default(); + if k.is_empty() { + k = serve::trim(serve::get(&store.config().await, "API_KEY")).to_string(); + } + let public = store.get_str(BK_STATE, "public_url").await; + let base = if public.is_empty() { + request_base(&parts.headers, &parts.uri) + } else { + public.clone() + }; + send_json( + StatusCode::OK, + &json!({ + "ok": true, + "set": !k.is_empty(), + "masked": mask_api_key(&k), + "key": k, + "url": format!("{base}/v1"), + "public_url": public, + }), + ) + } + "/api/network" => { + if post { + let d = + gojson::decode_struct(&raw, NETWORK_REQ_TYPE, &[("exposed", Kind::OptBool)]); + if let Some(e) = d.err.clone() { + return fail(StatusCode::BAD_REQUEST, e); + } + if let Some(on) = d.opt_bool("exposed") { + let host = if on { "0.0.0.0" } else { "127.0.0.1" }; + if let Err(e) = store.put(BK_CONFIG, "HOST", host.as_bytes().to_vec()).await { + return fail(StatusCode::INTERNAL_SERVER_ERROR, e); + } + let cfg = store.config().await; + let st = tokio::task::spawn_blocking(move || network_status(&cfg)) + .await + .unwrap_or_default(); + return send_json(StatusCode::OK, &json!({ "ok": true, "status": st })); + } + } + let cfg = store.config().await; + let st = tokio::task::spawn_blocking(move || network_status(&cfg)) + .await + .unwrap_or_default(); + send_json(StatusCode::OK, &json!({ "ok": true, "status": st })) + } + _ => send_json(StatusCode::NOT_FOUND, &json!({ "error": "route inconnue" })), + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn octets_invalides_comme_go() { + assert_eq!(go_lossy(b"ab"), "ab"); + assert_eq!(go_lossy(&[0x61, 0xC3]), "a\u{FFFD}"); + assert_eq!(go_lossy(&[0xA9, 0x62]), "\u{FFFD}b"); + assert_eq!(go_lossy(&[0xE2, 0x82, 0x41]), "\u{FFFD}\u{FFFD}A"); + } + + #[test] + fn masque_sur_les_octets() { + assert_eq!(mask_api_key("k"), "…"); + assert_eq!(mask_api_key("abc"), "…bc"); + assert_eq!(mask_api_key("sk-loki-0123456789abcdef"), "sk-loki-0…cdef"); + // Coupé au milieu d'un caractère : U+FFFD, comme l'encodeur du Go. + assert_eq!(mask_api_key("abcdefghijé0"), "…\u{FFFD}0"); + assert_eq!(mask_api_key("abcdefgh€xyz0123"), "abcdefgh\u{FFFD}…0123"); + } +} diff --git a/rust/loki-rs/tests/common/fakego.rs b/rust/loki-rs/tests/common/fakego.rs index 0632d7c..2590362 100644 --- a/rust/loki-rs/tests/common/fakego.rs +++ b/rust/loki-rs/tests/common/fakego.rs @@ -84,6 +84,12 @@ pub struct FakeGo { pub prestarts: Mutex, /// /internal/v1/dictate/stop reçus. pub dictate_stops: Mutex, + /// /internal/v1/web/reach/reset reçus. + pub reach_resets: Mutex, + /// /internal/v1/cdp/shutdown reçus. + pub cdp_shutdowns: Mutex, + /// /internal/v1/service/action reçus : (action, if_active). + pub service_actions: Mutex>, /// Comportement de /internal/v1/crypt/* (préfixe « SCELLE: » par défaut). pub crypt_mode: Mutex, /// Réponse de /internal/v1/conv/workspace (None : 404). @@ -99,6 +105,11 @@ impl FakeGo { .or_default() .insert(key.into(), val.as_bytes().to_vec()); } + pub fn del(&self, bucket: &str, key: &str) { + if let Some(b) = self.store.lock().unwrap().get_mut(bucket) { + b.remove(key); + } + } pub fn get(&self, bucket: &str, key: &str) -> Option { let s = self.store.lock().unwrap(); s.get(bucket)? @@ -351,6 +362,23 @@ async fn handle(st: Arc, req: Request) -> Response 200, serde_json::json!({ "generating": *st.generating.lock().unwrap() }).to_string(), ), + "/internal/v1/web/reach/reset" => { + *st.reach_resets.lock().unwrap() += 1; + resp(204, "") + } + "/internal/v1/cdp/shutdown" => { + *st.cdp_shutdowns.lock().unwrap() += 1; + resp(204, "") + } + "/internal/v1/service/action" => { + let body = req.into_body().collect().await.unwrap().to_bytes(); + let v: serde_json::Value = serde_json::from_slice(&body).unwrap_or_default(); + st.service_actions.lock().unwrap().push(( + v["action"].as_str().unwrap_or("").to_string(), + v["if_active"].as_bool().unwrap_or(false), + )); + resp(200, r#"{"ran":true,"error":""}"#) + } "/internal/v1/dictate/stop" => { *st.dictate_stops.lock().unwrap() += 1; resp(204, "") @@ -550,6 +578,7 @@ pub struct Opts { pub mem: Option>, pub presets: bool, pub engine_ctl: Option>, + pub system: Option>, } /// Comme [`start`], avec la conversation tenue par la façade @@ -647,6 +676,7 @@ pub async fn start_with( mem: opts.mem, presets: opts.presets, engine_ctl: opts.engine_ctl, + system: opts.system, }; tokio::spawn(loki_rs::proxy::serve(fl, cfg, async move { let _ = rx.await; diff --git a/rust/loki-rs/tests/lot2.rs b/rust/loki-rs/tests/lot2.rs index fcdffd7..a9f3c29 100644 --- a/rust/loki-rs/tests/lot2.rs +++ b/rust/loki-rs/tests/lot2.rs @@ -102,6 +102,7 @@ async fn banc(cle: Option<&str>) -> Banc { mem: None, presets: false, engine_ctl: None, + system: None, }; tokio::spawn(loki_rs::proxy::serve(l, cfg, async move { let _ = rx.await; diff --git a/rust/loki-rs/tests/parity_system/system.json b/rust/loki-rs/tests/parity_system/system.json new file mode 100644 index 0000000..ec6f436 --- /dev/null +++ b/rust/loki-rs/tests/parity_system/system.json @@ -0,0 +1,1407 @@ +{ + "chromium": [ + { + "files": [ + "chromium-1000/chrome-linux/chrome", + "chromium-1200/chrome-linux/chrome" + ], + "out": "chromium-1200/chrome-linux/chrome" + }, + { + "files": [ + "chromium-1000/chrome-linux/chrome", + "chromium-1200/chrome-linux/headless_shell" + ], + "out": "chromium-1200/chrome-linux/headless_shell" + }, + { + "files": [ + "chromium-1200/chrome-linux/headless_shell", + "chromium-1200/chrome-linux/chrome" + ], + "out": "chromium-1200/chrome-linux/chrome" + }, + { + "files": [ + "chromium-1000/chrome-linux/chrome", + "chromium_headless_shell-1100/chrome-linux/headless_shell" + ], + "out": "chromium_headless_shell-1100/chrome-linux/headless_shell" + }, + { + "files": [ + "chromium-1000/chrome-linux/chrome", + "chromium-1300/chrome-linux/chrome/" + ], + "out": "chromium-1000/chrome-linux/chrome" + }, + { + "files": [ + "chromiumX/chrome-linux/chrome", + "chromium-1/chrome-linux/chrome" + ], + "out": "chromium-1/chrome-linux/chrome" + }, + { + "files": [ + "firefox-1/chrome-linux/chrome", + "chromium-/chrome-linux/chrome" + ], + "out": "chromium-/chrome-linux/chrome" + }, + { + "files": [ + "chromium-9/chrome-linux/chrome", + "chromium-10/chrome-linux/chrome" + ], + "out": "chromium-9/chrome-linux/chrome" + } + ], + "steps": [ + { + "name": "internet par défaut", + "method": "GET", + "path": "/api/internet", + "status": 200, + "out": { + "enabled": false, + "engine": "go", + "key_hint": "", + "key_set": false, + "ok": true, + "reachable": true, + "url": "" + }, + "after": {}, + "probes": [] + }, + { + "name": "allumé, moteur intégré", + "method": "POST", + "path": "/api/internet", + "body": "{\"enabled\":true}", + "status": 200, + "out": { + "enabled": true, + "engine": "go", + "key_hint": "", + "key_set": false, + "ok": true, + "reachable": true, + "url": "" + }, + "after": { + "state/internet": "1" + }, + "probes": [] + }, + { + "name": "moteur crawl4ai sans URL", + "method": "POST", + "path": "/api/internet", + "body": "{\"engine\":\" CRAWL4AI \"}", + "status": 200, + "out": { + "enabled": false, + "engine": "crawl4ai", + "key_hint": "", + "key_set": false, + "ok": true, + "reachable": false, + "url": "" + }, + "after": { + "config/WEB_ENGINE": "crawl4ai", + "state/internet": "1" + }, + "probes": [] + }, + { + "name": "moteur inconnu", + "method": "POST", + "path": "/api/internet", + "body": "{\"engine\":\"bing\"}", + "status": 400, + "out": { + "error": "moteur web inconnu : \"bing\"", + "ok": false + }, + "after": { + "config/WEB_ENGINE": "crawl4ai", + "state/internet": "1" + }, + "probes": [] + }, + { + "name": "moteur inconnu vide", + "method": "POST", + "path": "/api/internet", + "body": "{\"engine\":\" \"}", + "status": 400, + "out": { + "error": "moteur web inconnu : \"\"", + "ok": false + }, + "after": { + "config/WEB_ENGINE": "crawl4ai", + "state/internet": "1" + }, + "probes": [] + }, + { + "name": "URL joignable", + "method": "POST", + "path": "/api/internet", + "body": "{\"url\":\" {{OK}}/// \"}", + "status": 200, + "out": { + "enabled": true, + "engine": "crawl4ai", + "key_hint": "", + "key_set": false, + "ok": true, + "reachable": true, + "url": "{{OK}}" + }, + "after": { + "config/CRAWL4AI_URL": "{{OK}}", + "config/WEB_ENGINE": "crawl4ai", + "state/internet": "1" + }, + "probes": [ + "/health" + ] + }, + { + "name": "relu dans le cache", + "method": "GET", + "path": "/api/internet", + "status": 200, + "out": { + "enabled": true, + "engine": "crawl4ai", + "key_hint": "", + "key_set": false, + "ok": true, + "reachable": true, + "url": "{{OK}}" + }, + "after": { + "config/CRAWL4AI_URL": "{{OK}}", + "config/WEB_ENGINE": "crawl4ai", + "state/internet": "1" + }, + "probes": [] + }, + { + "name": "clé posée", + "method": "POST", + "path": "/api/internet", + "body": "{\"key\":\" secret-clé-crawl \"}", + "status": 200, + "out": { + "enabled": true, + "engine": "crawl4ai", + "key_hint": "…rawl", + "key_set": true, + "ok": true, + "reachable": true, + "url": "{{OK}}" + }, + "after": { + "config/CRAWL4AI_URL": "{{OK}}", + "config/WEB_ENGINE": "crawl4ai", + "state/crawl_key": "secret-clé-crawl", + "state/internet": "1" + }, + "probes": [ + "/health Bearer secret-clé-crawl" + ] + }, + { + "name": "clé courte", + "method": "POST", + "path": "/api/internet", + "body": "{\"key\":\"abcd\"}", + "status": 200, + "out": { + "enabled": true, + "engine": "crawl4ai", + "key_hint": "…", + "key_set": true, + "ok": true, + "reachable": true, + "url": "{{OK}}" + }, + "after": { + "config/CRAWL4AI_URL": "{{OK}}", + "config/WEB_ENGINE": "crawl4ai", + "state/crawl_key": "abcd", + "state/internet": "1" + }, + "probes": [ + "/health Bearer abcd" + ] + }, + { + "name": "clé d'un caractère", + "method": "POST", + "path": "/api/internet", + "body": "{\"key\":\"é\"}", + "status": 200, + "out": { + "enabled": true, + "engine": "crawl4ai", + "key_hint": "…", + "key_set": true, + "ok": true, + "reachable": true, + "url": "{{OK}}" + }, + "after": { + "config/CRAWL4AI_URL": "{{OK}}", + "config/WEB_ENGINE": "crawl4ai", + "state/crawl_key": "é", + "state/internet": "1" + }, + "probes": [ + "/health Bearer é" + ] + }, + { + "name": "clé résiduelle en config", + "config": { + "CRAWL4AI_KEY": "vieille" + }, + "method": "POST", + "path": "/api/internet", + "body": "{\"key\":\"neuve12345\"}", + "status": 200, + "out": { + "enabled": true, + "engine": "crawl4ai", + "key_hint": "…2345", + "key_set": true, + "ok": true, + "reachable": true, + "url": "{{OK}}" + }, + "after": { + "config/CRAWL4AI_URL": "{{OK}}", + "config/WEB_ENGINE": "crawl4ai", + "state/crawl_key": "neuve12345", + "state/internet": "1" + }, + "probes": [ + "/health Bearer neuve12345" + ] + }, + { + "name": "URL en panne", + "method": "POST", + "path": "/api/internet", + "body": "{\"url\":\"{{DOWN}}\"}", + "status": 200, + "out": { + "enabled": true, + "engine": "crawl4ai", + "key_hint": "…2345", + "key_set": true, + "ok": true, + "reachable": false, + "url": "{{DOWN}}" + }, + "after": { + "config/CRAWL4AI_URL": "{{DOWN}}", + "config/WEB_ENGINE": "crawl4ai", + "state/crawl_key": "neuve12345", + "state/internet": "1" + }, + "probes": [ + "/health Bearer neuve12345", + "/ Bearer neuve12345" + ] + }, + { + "name": "URL 404", + "method": "POST", + "path": "/api/internet", + "body": "{\"url\":\"{{NOTFOUND}}\"}", + "status": 200, + "out": { + "enabled": true, + "engine": "crawl4ai", + "key_hint": "…2345", + "key_set": true, + "ok": true, + "reachable": true, + "url": "{{NOTFOUND}}" + }, + "after": { + "config/CRAWL4AI_URL": "{{NOTFOUND}}", + "config/WEB_ENGINE": "crawl4ai", + "state/crawl_key": "neuve12345", + "state/internet": "1" + }, + "probes": [ + "/health Bearer neuve12345" + ] + }, + { + "name": "URL injoignable", + "method": "POST", + "path": "/api/internet", + "body": "{\"url\":\"http://127.0.0.1:1\"}", + "status": 200, + "out": { + "enabled": true, + "engine": "crawl4ai", + "key_hint": "…2345", + "key_set": true, + "ok": true, + "reachable": false, + "url": "http://127.0.0.1:1" + }, + "after": { + "config/CRAWL4AI_URL": "http://127.0.0.1:1", + "config/WEB_ENGINE": "crawl4ai", + "state/crawl_key": "neuve12345", + "state/internet": "1" + }, + "probes": [] + }, + { + "name": "URL sans schéma", + "method": "POST", + "path": "/api/internet", + "body": "{\"url\":\"exemple\"}", + "status": 200, + "out": { + "enabled": true, + "engine": "crawl4ai", + "key_hint": "…2345", + "key_set": true, + "ok": true, + "reachable": false, + "url": "exemple" + }, + "after": { + "config/CRAWL4AI_URL": "exemple", + "config/WEB_ENGINE": "crawl4ai", + "state/crawl_key": "neuve12345", + "state/internet": "1" + }, + "probes": [] + }, + { + "name": "type faux, le reste passe", + "method": "POST", + "path": "/api/internet", + "body": "{\"enabled\":\"oui\",\"url\":\"{{OK}}\"}", + "status": 200, + "out": { + "enabled": false, + "engine": "crawl4ai", + "key_hint": "…2345", + "key_set": true, + "ok": true, + "reachable": true, + "url": "{{OK}}" + }, + "after": { + "config/CRAWL4AI_URL": "{{OK}}", + "config/WEB_ENGINE": "crawl4ai", + "state/crawl_key": "neuve12345" + }, + "probes": [ + "/health Bearer neuve12345" + ] + }, + { + "name": "corps illisible", + "method": "POST", + "path": "/api/internet", + "body": "{\"enabled\":false", + "status": 200, + "out": { + "enabled": false, + "engine": "crawl4ai", + "key_hint": "…2345", + "key_set": true, + "ok": true, + "reachable": true, + "url": "{{OK}}" + }, + "after": { + "config/CRAWL4AI_URL": "{{OK}}", + "config/WEB_ENGINE": "crawl4ai", + "state/crawl_key": "neuve12345" + }, + "probes": [] + }, + { + "name": "éteint, clé effacée", + "method": "POST", + "path": "/api/internet", + "body": "{\"enabled\":false,\"key\":\"\"}", + "status": 200, + "out": { + "enabled": false, + "engine": "crawl4ai", + "key_hint": "", + "key_set": false, + "ok": true, + "reachable": true, + "url": "{{OK}}" + }, + "after": { + "config/CRAWL4AI_URL": "{{OK}}", + "config/WEB_ENGINE": "crawl4ai" + }, + "probes": [ + "/health" + ] + }, + { + "name": "null ne touche à rien", + "method": "POST", + "path": "/api/internet", + "body": "{\"enabled\":null,\"url\":null,\"engine\":null}", + "status": 200, + "out": { + "enabled": false, + "engine": "crawl4ai", + "key_hint": "", + "key_set": false, + "ok": true, + "reachable": true, + "url": "{{OK}}" + }, + "after": { + "config/CRAWL4AI_URL": "{{OK}}", + "config/WEB_ENGINE": "crawl4ai" + }, + "probes": [] + }, + { + "name": "URL vidée, moteur choisi gardé", + "method": "POST", + "path": "/api/internet", + "body": "{\"url\":\"\",\"enabled\":true}", + "status": 200, + "out": { + "enabled": false, + "engine": "crawl4ai", + "key_hint": "", + "key_set": false, + "ok": true, + "reachable": false, + "url": "" + }, + "after": { + "config/WEB_ENGINE": "crawl4ai", + "state/internet": "1" + }, + "probes": [] + }, + { + "name": "moteur go", + "method": "POST", + "path": "/api/internet", + "body": "{\"engine\":\"Go\"}", + "status": 200, + "out": { + "enabled": true, + "engine": "go", + "key_hint": "", + "key_set": false, + "ok": true, + "reachable": true, + "url": "" + }, + "after": { + "config/WEB_ENGINE": "go", + "state/internet": "1" + }, + "probes": [] + }, + { + "name": "moteur non choisi, URL héritée", + "config": { + "CRAWL4AI_URL": "{{OK}}", + "WEB_ENGINE": "" + }, + "method": "GET", + "path": "/api/internet", + "status": 200, + "out": { + "enabled": true, + "engine": "crawl4ai", + "key_hint": "", + "key_set": false, + "ok": true, + "reachable": true, + "url": "{{OK}}" + }, + "after": { + "config/CRAWL4AI_URL": "{{OK}}", + "state/internet": "1" + }, + "probes": [ + "/health" + ] + }, + { + "name": "moteur illisible en config", + "config": { + "CRAWL4AI_URL": "", + "WEB_ENGINE": "autre" + }, + "method": "GET", + "path": "/api/internet", + "status": 200, + "out": { + "enabled": true, + "engine": "go", + "key_hint": "", + "key_set": false, + "ok": true, + "reachable": true, + "url": "" + }, + "after": { + "config/WEB_ENGINE": "autre", + "state/internet": "1" + }, + "probes": [] + }, + { + "name": "URL en config avec slashs", + "config": { + "CRAWL4AI_URL": " {{DOWN}}// ", + "WEB_ENGINE": "crawl4ai" + }, + "method": "GET", + "path": "/api/internet", + "status": 200, + "out": { + "enabled": true, + "engine": "crawl4ai", + "key_hint": "", + "key_set": false, + "ok": true, + "reachable": false, + "url": "{{DOWN}}" + }, + "after": { + "config/CRAWL4AI_URL": " {{DOWN}}// ", + "config/WEB_ENGINE": "crawl4ai", + "state/internet": "1" + }, + "probes": [ + "/health", + "/" + ] + }, + { + "name": "internet en PUT", + "method": "PUT", + "path": "/api/internet", + "body": "{\"enabled\":true}", + "status": 200, + "out": { + "enabled": true, + "engine": "crawl4ai", + "key_hint": "", + "key_set": false, + "ok": true, + "reachable": false, + "url": "{{DOWN}}" + }, + "after": { + "config/CRAWL4AI_URL": " {{DOWN}}// ", + "config/WEB_ENGINE": "crawl4ai", + "state/internet": "1" + }, + "probes": [] + }, + { + "name": "navigateur par défaut", + "method": "GET", + "path": "/api/computer", + "status": 200, + "out": { + "browser": "/opt/faux/chrome", + "browser_ok": true, + "enabled": false, + "ok": true, + "vision": false + }, + "after": { + "config/CRAWL4AI_URL": " {{DOWN}}// ", + "config/WEB_ENGINE": "crawl4ai", + "state/internet": "1" + }, + "probes": [] + }, + { + "name": "navigateur allumé", + "method": "POST", + "path": "/api/computer", + "body": "{\"on\":true}", + "status": 200, + "out": { + "browser": "/opt/faux/chrome", + "browser_ok": true, + "enabled": true, + "ok": true, + "vision": false + }, + "after": { + "config/CRAWL4AI_URL": " {{DOWN}}// ", + "config/WEB_ENGINE": "crawl4ai", + "state/computer": "1", + "state/internet": "1" + }, + "probes": [] + }, + { + "name": "navigateur, corps illisible", + "method": "POST", + "path": "/api/computer", + "body": "oui", + "status": 200, + "out": { + "browser": "/opt/faux/chrome", + "browser_ok": true, + "enabled": false, + "ok": true, + "vision": false + }, + "after": { + "config/CRAWL4AI_URL": " {{DOWN}}// ", + "config/WEB_ENGINE": "crawl4ai", + "state/internet": "1" + }, + "probes": [] + }, + { + "name": "vision par projecteur", + "config": { + "MMPROJ": " mmproj.gguf " + }, + "method": "GET", + "path": "/api/computer", + "status": 200, + "out": { + "browser": "/opt/faux/chrome", + "browser_ok": true, + "enabled": false, + "ok": true, + "vision": true + }, + "after": { + "config/CRAWL4AI_URL": " {{DOWN}}// ", + "config/WEB_ENGINE": "crawl4ai", + "state/internet": "1" + }, + "probes": [] + }, + { + "name": "vision externe", + "config": { + "EXTERNAL": "1", + "EXTERNAL_VISION": "1", + "MMPROJ": "" + }, + "method": "GET", + "path": "/api/computer", + "status": 200, + "out": { + "browser": "/opt/faux/chrome", + "browser_ok": true, + "enabled": false, + "ok": true, + "vision": true + }, + "after": { + "config/CRAWL4AI_URL": " {{DOWN}}// ", + "config/WEB_ENGINE": "crawl4ai", + "state/internet": "1" + }, + "probes": [] + }, + { + "name": "externe sans vision", + "config": { + "EXTERNAL_VISION": "0", + "MMPROJ": "m.gguf" + }, + "method": "GET", + "path": "/api/computer", + "status": 200, + "out": { + "browser": "/opt/faux/chrome", + "browser_ok": true, + "enabled": false, + "ok": true, + "vision": false + }, + "after": { + "config/CRAWL4AI_URL": " {{DOWN}}// ", + "config/WEB_ENGINE": "crawl4ai", + "state/internet": "1" + }, + "probes": [] + }, + { + "name": "vision strata", + "config": { + "ENGINE": "strata", + "EXTERNAL": "" + }, + "method": "GET", + "path": "/api/computer", + "status": 200, + "out": { + "browser": "/opt/faux/chrome", + "browser_ok": true, + "enabled": false, + "ok": true, + "vision": true + }, + "after": { + "config/CRAWL4AI_URL": " {{DOWN}}// ", + "config/WEB_ENGINE": "crawl4ai", + "state/internet": "1" + }, + "probes": [] + }, + { + "name": "strata sans vision", + "config": { + "MMPROJ": "", + "STRATA_VISION": "0" + }, + "method": "GET", + "path": "/api/computer", + "status": 200, + "out": { + "browser": "/opt/faux/chrome", + "browser_ok": true, + "enabled": false, + "ok": true, + "vision": false + }, + "after": { + "config/CRAWL4AI_URL": " {{DOWN}}// ", + "config/WEB_ENGINE": "crawl4ai", + "state/internet": "1" + }, + "probes": [] + }, + { + "name": "navigateur éteint", + "config": { + "ENGINE": "", + "STRATA_VISION": "" + }, + "method": "POST", + "path": "/api/computer", + "body": "{\"on\":false}", + "status": 200, + "out": { + "browser": "/opt/faux/chrome", + "browser_ok": true, + "enabled": false, + "ok": true, + "vision": false + }, + "after": { + "config/CRAWL4AI_URL": " {{DOWN}}// ", + "config/WEB_ENGINE": "crawl4ai", + "state/internet": "1" + }, + "probes": [] + }, + { + "name": "clé par défaut", + "method": "GET", + "path": "/api/apikey", + "status": 200, + "out": { + "key": "", + "masked": "", + "ok": true, + "public_url": "", + "set": false, + "url": "http://loki.local:8090/v1" + }, + "after": { + "config/CRAWL4AI_URL": " {{DOWN}}// ", + "config/WEB_ENGINE": "crawl4ai", + "state/internet": "1" + }, + "probes": [] + }, + { + "name": "clé générée", + "method": "POST", + "path": "/api/apikey", + "body": "{\"action\":\"generate\"}", + "status": 200, + "out": { + "key": "{{KEY}}", + "masked": "{{MASKED}}", + "ok": true, + "public_url": "", + "set": true, + "url": "http://loki.local:8090/v1" + }, + "after": { + "config/CRAWL4AI_URL": " {{DOWN}}// ", + "config/WEB_ENGINE": "crawl4ai", + "state/api_key": "{{KEY}}", + "state/internet": "1" + }, + "probes": [] + }, + { + "name": "clé relue", + "method": "GET", + "path": "/api/apikey", + "headers": { + "X-Forwarded-Proto": "HTTPS, http" + }, + "status": 200, + "out": { + "key": "{{KEY}}", + "masked": "{{MASKED}}", + "ok": true, + "public_url": "", + "set": true, + "url": "https://loki.local:8090/v1" + }, + "after": { + "config/CRAWL4AI_URL": " {{DOWN}}// ", + "config/WEB_ENGINE": "crawl4ai", + "state/api_key": "{{KEY}}", + "state/internet": "1" + }, + "probes": [] + }, + { + "name": "clé saisie", + "method": "POST", + "path": "/api/apikey", + "body": "{\"action\":\"set\",\"key\":\" ma-clé-perso-é \"}", + "status": 200, + "out": { + "key": "ma-clé-perso-é", + "masked": "ma-clé-p…o-é", + "ok": true, + "public_url": "", + "set": true, + "url": "http://loki.local:8090/v1" + }, + "after": { + "config/CRAWL4AI_URL": " {{DOWN}}// ", + "config/WEB_ENGINE": "crawl4ai", + "state/api_key": "ma-clé-perso-é", + "state/internet": "1" + }, + "probes": [] + }, + { + "name": "clé saisie courte", + "method": "POST", + "path": "/api/apikey", + "body": "{\"action\":\"set\",\"key\":\"abc\"}", + "status": 200, + "out": { + "key": "abc", + "masked": "…bc", + "ok": true, + "public_url": "", + "set": true, + "url": "http://loki.local:8090/v1" + }, + "after": { + "config/CRAWL4AI_URL": " {{DOWN}}// ", + "config/WEB_ENGINE": "crawl4ai", + "state/api_key": "abc", + "state/internet": "1" + }, + "probes": [] + }, + { + "name": "clé de 13 octets", + "method": "POST", + "path": "/api/apikey", + "body": "{\"action\":\"set\",\"key\":\"abcdefghijklm\"}", + "status": 200, + "out": { + "key": "abcdefghijklm", + "masked": "…lm", + "ok": true, + "public_url": "", + "set": true, + "url": "http://loki.local:8090/v1" + }, + "after": { + "config/CRAWL4AI_URL": " {{DOWN}}// ", + "config/WEB_ENGINE": "crawl4ai", + "state/api_key": "abcdefghijklm", + "state/internet": "1" + }, + "probes": [] + }, + { + "name": "clé effacée", + "method": "POST", + "path": "/api/apikey", + "body": "{\"action\":\"clear\"}", + "status": 200, + "out": { + "key": "", + "masked": "", + "ok": true, + "public_url": "", + "set": false, + "url": "http://loki.local:8090/v1" + }, + "after": { + "config/CRAWL4AI_URL": " {{DOWN}}// ", + "config/WEB_ENGINE": "crawl4ai", + "state/internet": "1" + }, + "probes": [] + }, + { + "name": "repli sur config.env", + "config": { + "API_KEY": " résiduelle-config " + }, + "method": "GET", + "path": "/api/apikey", + "headers": { + "X-Forwarded-Proto": "ftp" + }, + "status": 200, + "out": { + "key": "résiduelle-config", + "masked": "résiduel…nfig", + "ok": true, + "public_url": "", + "set": true, + "url": "http://loki.local:8090/v1" + }, + "after": { + "config/API_KEY": " résiduelle-config ", + "config/CRAWL4AI_URL": " {{DOWN}}// ", + "config/WEB_ENGINE": "crawl4ai", + "state/internet": "1" + }, + "probes": [] + }, + { + "name": "adresse publique", + "state": { + "public_url": "https://loki.exemple.fr" + }, + "method": "POST", + "path": "/api/apikey", + "body": "{\"action\":\"set\",\"key\":\"k\"}", + "status": 200, + "out": { + "key": "k", + "masked": "…", + "ok": true, + "public_url": "https://loki.exemple.fr", + "set": true, + "url": "https://loki.exemple.fr/v1" + }, + "after": { + "config/CRAWL4AI_URL": " {{DOWN}}// ", + "config/WEB_ENGINE": "crawl4ai", + "state/api_key": "k", + "state/internet": "1", + "state/public_url": "https://loki.exemple.fr" + }, + "probes": [] + }, + { + "name": "action inconnue", + "method": "POST", + "path": "/api/apikey", + "body": "{\"action\":\"rotate\"}", + "status": 400, + "out": { + "error": "action inconnue", + "ok": false + }, + "after": { + "config/CRAWL4AI_URL": " {{DOWN}}// ", + "config/WEB_ENGINE": "crawl4ai", + "state/api_key": "k", + "state/internet": "1", + "state/public_url": "https://loki.exemple.fr" + }, + "probes": [] + }, + { + "name": "action vide", + "method": "POST", + "path": "/api/apikey", + "body": "{}", + "status": 400, + "out": { + "error": "action inconnue", + "ok": false + }, + "after": { + "config/CRAWL4AI_URL": " {{DOWN}}// ", + "config/WEB_ENGINE": "crawl4ai", + "state/api_key": "k", + "state/internet": "1", + "state/public_url": "https://loki.exemple.fr" + }, + "probes": [] + }, + { + "name": "corps vide", + "method": "POST", + "path": "/api/apikey", + "status": 400, + "out": { + "error": "EOF", + "ok": false + }, + "after": { + "config/CRAWL4AI_URL": " {{DOWN}}// ", + "config/WEB_ENGINE": "crawl4ai", + "state/api_key": "k", + "state/internet": "1", + "state/public_url": "https://loki.exemple.fr" + }, + "probes": [] + }, + { + "name": "corps illisible", + "method": "POST", + "path": "/api/apikey", + "body": "{\"action\":", + "status": 400, + "out": { + "error": "unexpected EOF", + "ok": false + }, + "after": { + "config/CRAWL4AI_URL": " {{DOWN}}// ", + "config/WEB_ENGINE": "crawl4ai", + "state/api_key": "k", + "state/internet": "1", + "state/public_url": "https://loki.exemple.fr" + }, + "probes": [] + }, + { + "name": "type faux", + "method": "POST", + "path": "/api/apikey", + "body": "{\"action\":1}", + "status": 400, + "out": { + "error": "json: cannot unmarshal number into Go struct field .action of type string", + "ok": false + }, + "after": { + "config/CRAWL4AI_URL": " {{DOWN}}// ", + "config/WEB_ENGINE": "crawl4ai", + "state/api_key": "k", + "state/internet": "1", + "state/public_url": "https://loki.exemple.fr" + }, + "probes": [] + }, + { + "name": "optimisation en cours", + "tune": true, + "method": "POST", + "path": "/api/apikey", + "body": "{\"action\":\"clear\"}", + "status": 409, + "out": { + "error": "optimisation en cours (loki tune) : le moteur est réservé aux essais — attends la fin ou annule-la", + "ok": false, + "tuning": true + }, + "after": { + "config/CRAWL4AI_URL": " {{DOWN}}// ", + "config/WEB_ENGINE": "crawl4ai", + "state/api_key": "k", + "state/internet": "1", + "state/public_url": "https://loki.exemple.fr" + }, + "probes": [] + }, + { + "name": "lecture pendant une optimisation", + "tune": true, + "method": "GET", + "path": "/api/apikey", + "status": 200, + "out": { + "key": "k", + "masked": "…", + "ok": true, + "public_url": "https://loki.exemple.fr", + "set": true, + "url": "https://loki.exemple.fr/v1" + }, + "after": { + "config/CRAWL4AI_URL": " {{DOWN}}// ", + "config/WEB_ENGINE": "crawl4ai", + "state/api_key": "k", + "state/internet": "1", + "state/public_url": "https://loki.exemple.fr" + }, + "probes": [] + }, + { + "name": "réseau par défaut", + "method": "GET", + "path": "/api/network", + "status": 200, + "out": { + "ok": true, + "status": { + "exposed": true, + "firewall": "inconnu", + "hint": "", + "host": "0.0.0.0", + "port": 8080, + "url": "http://{{IP}}:8080/v1" + } + }, + "after": { + "config/CRAWL4AI_URL": " {{DOWN}}// ", + "config/WEB_ENGINE": "crawl4ai", + "state/api_key": "k", + "state/internet": "1", + "state/public_url": "https://loki.exemple.fr" + }, + "probes": [] + }, + { + "name": "réseau fermé", + "method": "POST", + "path": "/api/network", + "body": "{\"exposed\":false}", + "status": 200, + "out": { + "ok": true, + "status": { + "exposed": false, + "firewall": "inconnu", + "hint": "", + "host": "127.0.0.1", + "port": 8080, + "url": "http://{{IP}}:8080/v1" + } + }, + "after": { + "config/CRAWL4AI_URL": " {{DOWN}}// ", + "config/HOST": "127.0.0.1", + "config/WEB_ENGINE": "crawl4ai", + "state/api_key": "k", + "state/internet": "1", + "state/public_url": "https://loki.exemple.fr" + }, + "probes": [] + }, + { + "name": "réseau ouvert", + "method": "POST", + "path": "/api/network", + "body": "{\"exposed\":true}", + "status": 200, + "out": { + "ok": true, + "status": { + "exposed": true, + "firewall": "inconnu", + "hint": "", + "host": "0.0.0.0", + "port": 8080, + "url": "http://{{IP}}:8080/v1" + } + }, + "after": { + "config/CRAWL4AI_URL": " {{DOWN}}// ", + "config/HOST": "0.0.0.0", + "config/WEB_ENGINE": "crawl4ai", + "state/api_key": "k", + "state/internet": "1", + "state/public_url": "https://loki.exemple.fr" + }, + "probes": [] + }, + { + "name": "réseau sans champ", + "method": "POST", + "path": "/api/network", + "body": "{}", + "status": 200, + "out": { + "ok": true, + "status": { + "exposed": true, + "firewall": "inconnu", + "hint": "", + "host": "0.0.0.0", + "port": 8080, + "url": "http://{{IP}}:8080/v1" + } + }, + "after": { + "config/CRAWL4AI_URL": " {{DOWN}}// ", + "config/HOST": "0.0.0.0", + "config/WEB_ENGINE": "crawl4ai", + "state/api_key": "k", + "state/internet": "1", + "state/public_url": "https://loki.exemple.fr" + }, + "probes": [] + }, + { + "name": "réseau illisible", + "method": "POST", + "path": "/api/network", + "body": "{\"exposed\":1}", + "status": 400, + "out": { + "error": "json: cannot unmarshal number into Go struct field .exposed of type bool", + "ok": false + }, + "after": { + "config/CRAWL4AI_URL": " {{DOWN}}// ", + "config/HOST": "0.0.0.0", + "config/WEB_ENGINE": "crawl4ai", + "state/api_key": "k", + "state/internet": "1", + "state/public_url": "https://loki.exemple.fr" + }, + "probes": [] + }, + { + "name": "réseau corps vide", + "method": "POST", + "path": "/api/network", + "status": 400, + "out": { + "error": "EOF", + "ok": false + }, + "after": { + "config/CRAWL4AI_URL": " {{DOWN}}// ", + "config/HOST": "0.0.0.0", + "config/WEB_ENGINE": "crawl4ai", + "state/api_key": "k", + "state/internet": "1", + "state/public_url": "https://loki.exemple.fr" + }, + "probes": [] + }, + { + "name": "HOST localhost", + "config": { + "HOST": " LocalHost ", + "PORT": "9090" + }, + "method": "GET", + "path": "/api/network", + "status": 200, + "out": { + "ok": true, + "status": { + "exposed": false, + "firewall": "inconnu", + "hint": "", + "host": "LocalHost", + "port": 9090, + "url": "http://{{IP}}:9090/v1" + } + }, + "after": { + "config/CRAWL4AI_URL": " {{DOWN}}// ", + "config/HOST": " LocalHost ", + "config/WEB_ENGINE": "crawl4ai", + "state/api_key": "k", + "state/internet": "1", + "state/public_url": "https://loki.exemple.fr" + }, + "probes": [] + }, + { + "name": "HOST ::1", + "config": { + "HOST": "::1", + "PORT": "abc" + }, + "method": "GET", + "path": "/api/network", + "status": 200, + "out": { + "ok": true, + "status": { + "exposed": false, + "firewall": "inconnu", + "hint": "", + "host": "::1", + "port": 8080, + "url": "http://{{IP}}:8080/v1" + } + }, + "after": { + "config/CRAWL4AI_URL": " {{DOWN}}// ", + "config/HOST": "::1", + "config/WEB_ENGINE": "crawl4ai", + "state/api_key": "k", + "state/internet": "1", + "state/public_url": "https://loki.exemple.fr" + }, + "probes": [] + }, + { + "name": "HOST d'une carte", + "config": { + "HOST": "192.168.1.5", + "PORT": "" + }, + "method": "GET", + "path": "/api/network", + "status": 200, + "out": { + "ok": true, + "status": { + "exposed": true, + "firewall": "inconnu", + "hint": "", + "host": "192.168.1.5", + "port": 8080, + "url": "http://{{IP}}:8080/v1" + } + }, + "after": { + "config/CRAWL4AI_URL": " {{DOWN}}// ", + "config/HOST": "192.168.1.5", + "config/WEB_ENGINE": "crawl4ai", + "state/api_key": "k", + "state/internet": "1", + "state/public_url": "https://loki.exemple.fr" + }, + "probes": [] + } + ] +} diff --git a/rust/loki-rs/tests/proxy.rs b/rust/loki-rs/tests/proxy.rs index 6339327..2253c6b 100644 --- a/rust/loki-rs/tests/proxy.rs +++ b/rust/loki-rs/tests/proxy.rs @@ -108,6 +108,7 @@ async fn facade(backend: SocketAddr) -> (SocketAddr, oneshot::Sender<()>) { mem: None, presets: false, engine_ctl: None, + system: None, }; tokio::spawn(loki_rs::proxy::serve(l, cfg, async move { let _ = rx.await; diff --git a/rust/loki-rs/tests/system.rs b/rust/loki-rs/tests/system.rs new file mode 100644 index 0000000..35f1307 --- /dev/null +++ b/rust/loki-rs/tests/system.rs @@ -0,0 +1,369 @@ +//! Lot 17 : interrupteurs système tenus par la façade (`LOKI_SYSTEM=rust`). +//! +//! Le journal tiré du Go (`internal/loki/parity_system_test.go` → +//! `tests/parity_system/system.json`) est rejoué par une seule façade : accès +//! internet et Crawl4AI (en-têtes reçus par les faux serveurs et cache de +//! joignabilité compris), navigateur et vision, clé de complétion, écoute +//! réseau, avec après chaque appel les clés touchées en base ; puis le +//! Chromium de Playwright. Ce qui reste au Go (cache de ses outils web, +//! Chromium piloté, redémarrage du moteur) est vérifié de bout en bout contre +//! le faux Go. + +mod common; + +use std::collections::BTreeMap; +use std::convert::Infallible; +use std::net::SocketAddr; +use std::sync::{Arc, Mutex}; + +use bytes::Bytes; +use common::fakego; +use http_body_util::{BodyExt, Full}; +use hyper::service::service_fn; +use hyper::{Request, Response}; +use hyper_util::rt::TokioIo; +use loki_rs::system::{self as sy, System}; +use serde_json::{Value, json}; +use tokio::net::TcpListener; + +fn fixture() -> Value { + serde_json::from_str(include_str!("parity_system/system.json")).expect("journal") +} + +fn s(v: &Value) -> &str { + v.as_str().unwrap() +} + +const KEYS: &[&str] = &[ + "config/WEB_ENGINE", + "config/CRAWL4AI_URL", + "config/CRAWL4AI_KEY", + "config/API_KEY", + "config/HOST", + "state/internet", + "state/computer", + "state/crawl_key", + "state/api_key", + "state/public_url", +]; + +/// Un faux Crawl4AI : répond `code` partout, note « chemin Authorization ». +async fn fake_crawl(code: u16, log: Arc>>) -> String { + let l = TcpListener::bind("127.0.0.1:0").await.unwrap(); + let addr = l.local_addr().unwrap(); + tokio::spawn(async move { + while let Ok((st, _)) = l.accept().await { + let log = log.clone(); + tokio::spawn(async move { + let svc = service_fn(move |req: Request| { + let log = log.clone(); + async move { + let auth = req + .headers() + .get("authorization") + .map(|v| String::from_utf8_lossy(v.as_bytes()).into_owned()) + .unwrap_or_default(); + log.lock() + .unwrap() + .push(format!("{} {auth}", req.uri().path()).trim().to_string()); + let mut r = Response::new(Full::new(Bytes::new())); + *r.status_mut() = hyper::StatusCode::from_u16(code).unwrap(); + Ok::<_, Infallible>(r) + } + }); + let _ = hyper::server::conn::http1::Builder::new() + .serve_connection(TokioIo::new(st), svc) + .await; + }); + } + }); + format!("http://{addr}") +} + +async fn call( + facade: SocketAddr, + method: &str, + path: &str, + body: &str, + headers: &[(String, String)], +) -> (u16, String) { + use hyper_util::client::legacy::Client; + use hyper_util::rt::TokioExecutor; + let mut req = Request::builder() + .method(method) + .uri(format!("http://{facade}{path}")) + .header("host", "loki.local:8090"); + for (k, v) in headers { + req = req.header(k.as_str(), v.as_str()); + } + let client: Client<_, Full> = Client::builder(TokioExecutor::new()).build_http(); + let r = client + .request(req.body(Full::new(Bytes::from(body.to_string()))).unwrap()) + .await + .unwrap(); + let code = r.status().as_u16(); + let b = r.into_body().collect().await.unwrap().to_bytes(); + (code, String::from_utf8_lossy(&b).into_owned()) +} + +async fn banc( + opts: fakego::Opts, +) -> ( + SocketAddr, + Arc, + tokio::sync::oneshot::Sender<()>, + tempfile::TempDir, +) { + let tmp = tempfile::tempdir().unwrap(); + let (facade, go, stop, _) = fakego::start_with(tmp.path().to_path_buf(), opts).await; + (facade, go, stop, tmp) +} + +fn system() -> Arc { + let mut s = System::new(); + s.chrome = Some("/opt/faux/chrome".into()); + Arc::new(s) +} + +#[tokio::test] +async fn parite_des_routes() { + let log = Arc::new(Mutex::new(Vec::new())); + let ok = fake_crawl(200, log.clone()).await; + let down = fake_crawl(500, log.clone()).await; + let nf = fake_crawl(404, log.clone()).await; + let (facade, go, _stop, _tmp) = banc(fakego::Opts { + system: Some(system()), + ..Default::default() + }) + .await; + let ip = sy::local_ip(); + let key_re = regex::Regex::new(r"sk-loki-[0-9a-f]{48}").unwrap(); + let masked_re = regex::Regex::new(r"sk-loki-[0-9a-f]…[0-9a-f]{4}").unwrap(); + let unmask = |v: &str| { + v.replace("{{OK}}", &ok) + .replace("{{DOWN}}", &down) + .replace("{{NOTFOUND}}", &nf) + }; + let mask = |v: &str| { + let v = v + .replace(&ok, "{{OK}}") + .replace(&down, "{{DOWN}}") + .replace(&nf, "{{NOTFOUND}}") + .replace(&format!("http://{ip}:"), "http://{{IP}}:"); + let v = key_re.replace_all(&v, "{{KEY}}").into_owned(); + masked_re.replace_all(&v, "{{MASKED}}").into_owned() + }; + for st in fixture()["steps"].as_array().unwrap() { + let name = s(&st["name"]); + for (bucket, field) in [("config", "config"), ("state", "state")] { + for (k, v) in st[field].as_object().into_iter().flatten() { + let v = unmask(s(v)); + if v.is_empty() { + go.del(bucket, k); + } else { + go.set(bucket, k, &v); + } + } + } + *go.tune_busy.lock().unwrap() = (st["tune"].as_bool() == Some(true)).then(|| { + "optimisation en cours (loki tune) : le moteur est réservé aux essais — attends la fin ou annule-la".into() + }); + log.lock().unwrap().clear(); + let headers: Vec<(String, String)> = st["headers"] + .as_object() + .into_iter() + .flatten() + .map(|(k, v)| (k.clone(), s(v).to_string())) + .collect(); + let (code, body) = call( + facade, + s(&st["method"]), + s(&st["path"]), + &unmask(st["body"].as_str().unwrap_or("")), + &headers, + ) + .await; + assert_eq!(code as i64, st["status"].as_i64().unwrap(), "{name}"); + let out: Value = serde_json::from_str(&mask(&body)).unwrap(); + assert_eq!(out, st["out"], "{name}"); + let mut after = BTreeMap::new(); + for k in KEYS { + let (b, key) = k.split_once('/').unwrap(); + if let Some(v) = go.get(b, key).filter(|v| !v.is_empty()) { + after.insert(k.to_string(), Value::String(mask(&v))); + } + } + assert_eq!( + Value::Object(after.into_iter().collect()), + st["after"], + "{name} : base" + ); + let probes: Vec = log + .lock() + .unwrap() + .iter() + .map(|p| Value::String(mask(p))) + .collect(); + assert_eq!(Value::Array(probes), st["probes"], "{name} : sondes"); + } +} + +#[test] +fn parite_du_chromium_de_playwright() { + for c in fixture()["chromium"].as_array().unwrap() { + let tmp = tempfile::tempdir().unwrap(); + let root = tmp.path().to_string_lossy().into_owned(); + for f in c["files"].as_array().unwrap() { + let f = s(f); + let p = tmp.path().join(f.trim_end_matches('/')); + if f.ends_with('/') { + std::fs::create_dir_all(&p).unwrap(); + } else { + std::fs::create_dir_all(p.parent().unwrap()).unwrap(); + std::fs::write(&p, "").unwrap(); + } + } + let got = sy::playwright_chromium(Some(&root), None); + assert_eq!( + got.strip_prefix(&format!("{root}/")).unwrap_or(&got), + s(&c["out"]), + "{c}" + ); + } +} + +#[tokio::test] +async fn ce_qui_reste_au_go() { + let (facade, go, _stop, _tmp) = banc(fakego::Opts { + system: Some(system()), + ..Default::default() + }) + .await; + // Le cache des outils web du Go est vidé à chaque changement d'URL, de clé + // ou de moteur — pas pour l'interrupteur seul, ni en lecture. + for (body, resets) in [ + (r#"{"enabled":true}"#, 0), + (r#"{"url":"http://127.0.0.1:1"}"#, 1), + (r#"{"key":"k1"}"#, 2), + (r#"{"engine":"go"}"#, 3), + (r#"{"engine":"go","key":"","url":""}"#, 6), + (r#"{"engine":"autre","url":"x"}"#, 6), + ] { + call(facade, "POST", "/api/internet", body, &[]).await; + assert_eq!(*go.reach_resets.lock().unwrap(), resets, "{body}"); + } + call(facade, "GET", "/api/internet", "", &[]).await; + assert_eq!(*go.reach_resets.lock().unwrap(), 6); + + // Le navigateur piloté (au Go) se ferme quand l'interrupteur se coupe. + call(facade, "POST", "/api/computer", r#"{"on":true}"#, &[]).await; + assert_eq!(*go.cdp_shutdowns.lock().unwrap(), 0); + call(facade, "POST", "/api/computer", r#"{"on":false}"#, &[]).await; + call(facade, "GET", "/api/computer", "", &[]).await; + assert_eq!(*go.cdp_shutdowns.lock().unwrap(), 1); + + // Une clé neuve : le moteur est relancé s'il tourne, par le Go quand la + // façade ne le pilote pas ; jamais sur une lecture ni sur un refus. + call(facade, "GET", "/api/apikey", "", &[]).await; + call(facade, "POST", "/api/apikey", r#"{"action":"bof"}"#, &[]).await; + assert!(go.service_actions.lock().unwrap().is_empty()); + call( + facade, + "POST", + "/api/apikey", + r#"{"action":"generate"}"#, + &[], + ) + .await; + assert_eq!( + *go.service_actions.lock().unwrap(), + [("restart".to_string(), true)] + ); +} + +/// Faux superviseur du moteur (voir tests/enginectl.rs). +fn supervisor(dir: &std::path::Path) -> std::path::PathBuf { + let p = dir.join("loki-rs"); + let body = "#!/bin/sh\necho \"$2\" >> \"$LOKI_HOME/trace\"\nexit 0\n"; + let ok = std::process::Command::new("sh") + .args(["-c", "printf %s \"$1\" > \"$2\" && chmod 755 \"$2\""]) + .arg("sh") + .arg(body) + .arg(&p) + .status() + .unwrap() + .success(); + assert!(ok); + p +} + +#[tokio::test] +async fn cle_neuve_moteur_pilote_par_la_facade() { + let tmp = tempfile::tempdir().unwrap(); + let home = tmp.path().to_path_buf(); + let ctl = loki_rs::enginectl::EngineCtl::new( + supervisor(&home), + vec![("LOKI_HOME".into(), home.to_string_lossy().into_owned())], + ); + let (facade, go, _stop, _) = fakego::start_with( + home.clone(), + fakego::Opts { + system: Some(system()), + engine_ctl: Some(Arc::new(ctl)), + ..Default::default() + }, + ) + .await; + let trace = || std::fs::read_to_string(home.join("trace")).unwrap_or_default(); + // Moteur arrêté : rien à relancer. + let (_, b) = call( + facade, + "POST", + "/api/apikey", + r#"{"action":"set","key":"abc"}"#, + &[], + ) + .await; + assert_eq!( + serde_json::from_str::(&b).unwrap()["masked"], + json!("…bc") + ); + assert_eq!(trace(), ""); + // Moteur actif : relancé par le superviseur, sans passer par le Go. + let mut sleeper = std::process::Command::new("sleep") + .arg("30") + .spawn() + .unwrap(); + std::fs::write(home.join("loki-engine.pid"), sleeper.id().to_string()).unwrap(); + call(facade, "POST", "/api/apikey", r#"{"action":"clear"}"#, &[]).await; + assert_eq!(trace(), "restart\n"); + assert_eq!(*go.prestarts.lock().unwrap(), 1); + assert!(go.service_actions.lock().unwrap().is_empty()); + let _ = sleeper.kill(); + let _ = sleeper.wait(); +} + +#[tokio::test] +async fn sans_la_variable_les_interrupteurs_restent_au_go() { + let tmp = tempfile::tempdir().unwrap(); + let (facade, _go, _stop) = fakego::start(tmp.path().to_path_buf()).await; + for p in [ + "/api/internet", + "/api/computer", + "/api/apikey", + "/api/network", + ] { + let (_, _, b) = fakego::get(facade, p, &[]).await; + assert_eq!(&b[..], format!("go:{p}").as_bytes()); + } +} + +#[test] +fn fonctions_pures() { + assert_eq!(sy::key_hint("abcdé"), "…bcdé"); + assert_eq!(sy::key_hint("abcd"), "…"); + assert_eq!(sy::key_hint(""), ""); + let k = sy::gen_api_key(); + assert!(k.starts_with("sk-loki-") && k.len() == 56, "{k}"); + assert_ne!(k, sy::gen_api_key()); +} From 2db4cf1090c300a89357fc065abb4af995c8aa90 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 15 Oct 2026 18:53:36 +0000 Subject: [PATCH 15/52] =?UTF-8?q?Parit=C3=A9=20moteur=20et=20syst=C3=A8me?= =?UTF-8?q?=20:=20tests=20r=C3=A9serv=C3=A9s=20=C3=A0=20Linux?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit parity_engine_test.go appelle lastErrorLine (sys_service_rust_linux.go) et suppose la supervision par fichier PID du conteneur ; la façade ne tourne qu'en conteneur Linux. La CI macOS ne compilait plus les tests. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_016tcGfJWefW5kUGoC2wL8cv --- internal/loki/parity_engine_test.go | 2 ++ internal/loki/parity_system_test.go | 2 ++ 2 files changed, 4 insertions(+) diff --git a/internal/loki/parity_engine_test.go b/internal/loki/parity_engine_test.go index 3d3bf3d..8a6b243 100644 --- a/internal/loki/parity_engine_test.go +++ b/internal/loki/parity_engine_test.go @@ -1,3 +1,5 @@ +//go:build linux + package loki import ( diff --git a/internal/loki/parity_system_test.go b/internal/loki/parity_system_test.go index 3501d23..f27cffc 100644 --- a/internal/loki/parity_system_test.go +++ b/internal/loki/parity_system_test.go @@ -1,3 +1,5 @@ +//go:build linux + package loki import ( From ec55f7d3d63814c6c74c57173030c3bf4980f692 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 15 Oct 2026 19:10:49 +0000 Subject: [PATCH 16/52] =?UTF-8?q?Lot=2018=20:=20hub=20des=20projets=20tenu?= =?UTF-8?q?=20par=20la=20fa=C3=A7ade=20(LOKI=5FPROJECTS=3Drust)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit La façade sert /api/projects quand LOKI_PROJECTS=rust (expérimental ; le Go reste le défaut) : liste avec discussions, pages et trackers comptés, création (slug translittéré et unique, index amorcé), renommage, description, suppression (discussions, trackers et mémoire du projet, actif réassigné, dernier projet indestructible) et bascule, refusées pendant une génération. Le projet actif passe toujours par le Go, qui en garde une copie en RAM. Les discussions sont à qui tient la conversation : la façade bascule et supprime elle-même quand elle la tient, sinon le Go garde /internal/v1/project/switch et /internal/v1/project/drop-convs (facade_projects.go). Parité : parity_projects_test.go tire du Go 40 appels sur une base amorcée (pages, discussions, trackers piégés), avec le registre, l'actif, memory/, les trackers et l'index après chacun, rejoués par tests/projects_api.rs contre un faux Go qui bascule et supprime comme le vrai ; bascule et suppression par la façade de bout en bout ; TestFacadeProjets côté Go. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_016tcGfJWefW5kUGoC2wL8cv --- README.md | 1 + ...-15-rust-migration-lot18-projets-design.md | 78 + internal/loki/facade_chat.go | 5 +- internal/loki/facade_chat_test.go | 69 + internal/loki/facade_projects.go | 77 + internal/loki/parity_projects_test.go | 272 + internal/loki/web_internal.go | 4 + rust/loki-rs/src/api.rs | 3 + rust/loki-rs/src/enginectl.rs | 2 +- rust/loki-rs/src/lib.rs | 1 + rust/loki-rs/src/main.rs | 4 + rust/loki-rs/src/memory.rs | 4 +- rust/loki-rs/src/projects_api.rs | 421 ++ rust/loki-rs/src/proxy.rs | 6 + rust/loki-rs/tests/common/fakego.rs | 55 + rust/loki-rs/tests/lot2.rs | 1 + .../tests/parity_projects/projects.json | 4484 +++++++++++++++++ rust/loki-rs/tests/projects_api.rs | 223 + rust/loki-rs/tests/proxy.rs | 1 + 19 files changed, 5707 insertions(+), 4 deletions(-) create mode 100644 docs/superpowers/specs/2026-10-15-rust-migration-lot18-projets-design.md create mode 100644 internal/loki/facade_projects.go create mode 100644 internal/loki/parity_projects_test.go create mode 100644 rust/loki-rs/src/projects_api.rs create mode 100644 rust/loki-rs/tests/parity_projects/projects.json create mode 100644 rust/loki-rs/tests/projects_api.rs diff --git a/README.md b/README.md index 6f3c613..62d42d4 100644 --- a/README.md +++ b/README.md @@ -236,6 +236,7 @@ redémarrages (volume `/data`). Variables d'environnement du conteneur : | `LOKI_MEM` | `rust` : la mémoire (éditeur des pages, index, déplacement entre projets, trackers, mode mémoire, interrupteurs du mode agent) est tenue par la façade Rust ; le chiffrement reste au Go (expérimental, migration en cours) | Go | | `LOKI_SERVE` | `rust` : la ligne de commande de llama-server est composée par la façade Rust (`loki-rs engine serve`) au lieu de `loki serve` ; Strata et l'optimiseur restent au Go (expérimental, migration en cours) | Go | | `LOKI_PRESETS` | `rust` : les presets (liste, éditeur et aperçus, suppression, placement auto, presets externes, bascule) sont tenus par la façade Rust ; l'application au moteur reste au Go (expérimental, migration en cours) | Go | +| `LOKI_PROJECTS` | `rust` : le hub des projets (liste et comptes, création, renommage, description, suppression, bascule) est tenu par la façade Rust ; les discussions restent à qui tient la conversation (expérimental, migration en cours) | Go | | `LOKI_SYSTEM` | `rust` : les interrupteurs système — accès internet et Crawl4AI, pilotage du navigateur, clé de complétion, écoute réseau du moteur — sont tenus par la façade Rust ; le navigateur piloté reste au Go (expérimental, migration en cours) | Go | | `LOKI_ENGINE` | `rust` : le pilotage du moteur (démarrage, arrêt, redémarrage, déchargement et rechargement de la VRAM) est tenu par la façade Rust, en conteneur avec son superviseur ; le verrou de l'optimiseur reste au Go (expérimental, migration en cours) | Go | diff --git a/docs/superpowers/specs/2026-10-15-rust-migration-lot18-projets-design.md b/docs/superpowers/specs/2026-10-15-rust-migration-lot18-projets-design.md new file mode 100644 index 0000000..9790ded --- /dev/null +++ b/docs/superpowers/specs/2026-10-15-rust-migration-lot18-projets-design.md @@ -0,0 +1,78 @@ +# Lot 18 — hub des projets tenu par la façade + +## Contexte + +Le hub des projets vivait dans le Go : +- `handleProjects` et `projectDTOs` (`web_projects.go`) ; +- les mutations de `projects.go` : `createProject`, `renameProject`, + `setProjectDesc`, `deleteProject` ; +- `projectSwitch` (`chat_sessions.go`). + +Lot 18 : la façade tient `/api/projects` quand `LOKI_PROJECTS=rust` +(`projects_api.rs`, expérimental). Sans la variable, rien ne change. + +| Action | Rôle | +|---|---| +| `GET` | les projets, le plus ancien d'abord, avec leurs discussions, pages (`.md` visibles, index exclu) et trackers comptés ; le projet actif | +| `create` | slug translittéré (`Réseau NAS` → `reseau-nas`), unique (`-2`, `-3`…), `projet` à défaut, 40 octets au plus ; nom de 60 runes au plus ; dossier `memory/` et son index amorcé | +| `rename` | le libellé seul : slug, chemins et discussions ne bougent pas | +| `desc` | 2 000 runes au plus, vide = effacée | +| `switch` | la conversation passe sur le projet (la plus récente de ses discussions, ou une neuve) | +| `delete` | discussions, trackers et mémoire du projet ; le projet actif est réassigné ; le dernier projet est indestructible | + +Le registre reste dans la base (`state/projects`, écrit comme `json.Marshal` +avec le HTML échappé), la mémoire de chaque projet dans `memory/`. + +## Restent au Go + +Le projet actif passe toujours par le Go (`/internal/v1/project/activate`), +qui en garde une copie en RAM pour la mémoire, les outils et les tâches. + +Les discussions sont à qui tient la conversation. + +Quand la façade la tient (`LOKI_CHAT=rust`), elle bascule et supprime +elle-même. Avant de supprimer le projet actif, elle enregistre sa discussion +et en ouvre une du premier projet restant. + +Sinon le Go garde deux routes internes (`facade_projects.go`) : +- `POST /internal/v1/project/switch` : `projectSwitch` (discussion du projet + quitté enregistrée, plus récente du projet rejoint ouverte, ou une neuve) ; +- `POST /internal/v1/project/drop-convs` : les discussions du projet + supprimées (`convDelete`, fichiers et résultats compris). + +Une génération en cours refuse la bascule et la suppression (409). La façade +lit la sienne, ou celle du Go (`/internal/v1/chat/generating`, lot 16). + +## Écarts assumés + +- Pendant une tâche planifiée, le Go voit comme actif le projet de la tâche ; + la façade lit le projet actif enregistré. +- Pendant un tour de la façade, une bascule vers un projet inconnu répond 409, + comme quand le Go tient la conversation. Le Go, relayant à la façade, + répondait 400 (« projet introuvable ») dans ce cas. + +## Parité + +`internal/loki/parity_projects_test.go` part d'une base amorcée : +- pages visibles, cachées, hors `.md`, dans un sous-dossier ; +- discussions de Générale, d'avant les projets, d'un projet à venir et d'un + projet disparu ; +- trackers, dont `alphabet/x` qui ne doit pas tomber avec `alpha`. + +Il tire du Go 40 appels, avec après chacun le registre, le projet actif, +l'arbre `memory/`, les trackers et l'index des discussions. Ils couvrent : +- créations (doublons, translittération, sans lettres, longues, vides) ; +- renommages et descriptions (longues, coupées sur une espace, effacées) ; +- bascules (sur l'actif, inconnue, vers un projet sans discussion, pendant + une génération) ; +- suppressions (de l'actif, inconnue, pendant une génération, jusqu'au + dernier projet) ; +- actions inconnues, corps vide ou illisible, erreur de type, méthode + refusée. + +`tests/projects_api.rs` rejoue le tout par la façade, contre un faux Go qui +bascule et supprime les discussions comme le vrai. La bascule et la +suppression quand la façade tient la conversation sont vérifiées de bout en +bout. Une mutation de chaque règle fait échouer les tests. + +Côté Go, `TestFacadeProjets` vérifie le relais et les deux routes internes. diff --git a/internal/loki/facade_chat.go b/internal/loki/facade_chat.go index 61f9793..69a6311 100644 --- a/internal/loki/facade_chat.go +++ b/internal/loki/facade_chat.go @@ -145,7 +145,8 @@ var facadeChatRoutes = map[string]bool{ // (facade_dictate.go), les réglages partagés (facade_settings.go), les fichiers // de la discussion (facade_files.go), les notifications (facade_push.go), la // mémoire (facade_mem.go), les presets (facade_presets.go), le pilotage du -// moteur (facade_engine.go), les interrupteurs système (facade_system.go). +// moteur (facade_engine.go), les interrupteurs système (facade_system.go), les +// projets (facade_projects.go). // Posé APRÈS requireWebAuth : seule une requête authentifiée ici part, avec le // jeton interne qui en tient lieu là-bas. func withFacadeChat(path string, h http.HandlerFunc) http.HandlerFunc { @@ -170,6 +171,8 @@ func withFacadeChat(path string, h http.HandlerFunc) http.HandlerFunc { held, unavailable = facadeEngine, "pilotage du moteur indisponible : " case facadeSystemRoutes[path]: held, unavailable = facadeSystem, "réglages système indisponibles : " + case facadeProjectsRoutes[path]: + held, unavailable = facadeProjects, "projets indisponibles : " default: return h } diff --git a/internal/loki/facade_chat_test.go b/internal/loki/facade_chat_test.go index 3f7e05e..4946f7c 100644 --- a/internal/loki/facade_chat_test.go +++ b/internal/loki/facade_chat_test.go @@ -758,3 +758,72 @@ func TestFacadeRelaisGardeHote(t *testing.T) { t.Fatalf("hôte %q, schéma %q", host, proto) } } + +// Le hub des projets (LOKI_PROJECTS=rust, lot 18) part à la façade ; quand le +// Go tient la conversation, la bascule et les discussions d'un projet +// supprimé restent ici (API interne). +func TestFacadeProjets(t *testing.T) { + testHome(t) + // Le projet actif est gardé en RAM d'un test à l'autre. + activeProjMu.Lock() + activeProjCache = "" + activeProjMu.Unlock() + t.Cleanup(func() { + activeProjMu.Lock() + activeProjCache = "" + activeProjMu.Unlock() + }) + ensureDefaultProject() + f := demarrerFacade(t) + t.Setenv(facadeChatEnv, "") + local := func(w http.ResponseWriter, r *http.Request) { io.WriteString(w, "go") } + w := httptest.NewRecorder() + withFacadeChat("/api/projects", local)(w, httptest.NewRequest("GET", "/api/projects", nil)) + if w.Body.String() != "go" { + t.Fatalf("servi ailleurs sans LOKI_PROJECTS : %q", w.Body.String()) + } + t.Setenv(facadeProjectsEnv, "rust") + w = httptest.NewRecorder() + withFacadeChat("/api/projects", local)(w, httptest.NewRequest("POST", "/api/projects", strings.NewReader("{}"))) + if w.Body.String() != f.reply || len(f.recu()) != 1 { + t.Fatalf("relais : %q, %d appels", w.Body.String(), len(f.recu())) + } + + if _, err := createProject("Alpha"); err != nil { + t.Fatal(err) + } + convIndexSave([]convMeta{{ID: "a1", Project: "alpha", Updated: 1}, {ID: "g1", Project: "generale", Updated: 2}}) + _ = putBytes(bkChat, convKey("a1"), []byte(`{"id":"a1"}`)) + _ = putBytes(bkChat, convKey("g1"), []byte(`{"id":"g1"}`)) + post := func(h http.HandlerFunc, body string) (int, string) { + w := httptest.NewRecorder() + h(w, httptest.NewRequest("POST", "/internal/v1/project/x", strings.NewReader(body))) + return w.Code, strings.TrimSpace(w.Body.String()) + } + if c, b := post(handleInternalProjectSwitch, `{"slug":"nope"}`); c != 400 || b != `{"error":"projet introuvable"}` { + t.Fatalf("bascule inconnue : %d %s", c, b) + } + if c, _ := post(handleInternalProjectSwitch, `{"slug":" alpha "}`); c != 204 || activeProjectSlug() != "alpha" { + t.Fatalf("bascule : %d, actif %q", c, activeProjectSlug()) + } + conv.mu.Lock() + conv.Generating = true + conv.mu.Unlock() + c1, _ := post(handleInternalProjectSwitch, `{"slug":"generale"}`) + c2, _ := post(handleInternalProjectDropConvs, `{"slug":"alpha"}`) + conv.mu.Lock() + conv.Generating = false + conv.mu.Unlock() + if c1 != 409 || c2 != 409 { + t.Fatalf("pendant une génération : %d, %d", c1, c2) + } + if c, b := post(handleInternalProjectDropConvs, `{"slug":"alpha"}`); c != 200 || b != `{"dropped":1}` { + t.Fatalf("discussions supprimées : %d %s", c, b) + } + if n := len(convIndexForProject("alpha")); n != 0 || len(convIndexForProject("generale")) == 0 { + t.Fatalf("index après : %d d'Alpha", n) + } + if c, _ := post(handleInternalProjectDropConvs, `nimp`); c != 400 { + t.Fatalf("corps illisible : %d", c) + } +} diff --git a/internal/loki/facade_projects.go b/internal/loki/facade_projects.go new file mode 100644 index 0000000..f96dcb8 --- /dev/null +++ b/internal/loki/facade_projects.go @@ -0,0 +1,77 @@ +package loki + +import ( + "net/http" + "os" + "strings" +) + +// facade_projects.go — hub des projets tenu par la façade Rust +// (LOKI_PROJECTS=rust, lot 18 de la migration ; expérimental, le Go reste le +// défaut) : liste avec ses comptes, création, renommage, description, +// suppression, bascule (/api/projects). +// +// Le registre reste dans la base (state/projects) et la mémoire de chaque +// projet dans memory/. Le projet actif passe toujours par le Go +// (/internal/v1/project/activate), qui en garde une copie en RAM. Quand le Go +// tient la conversation, ce qui la touche reste ici : +// +// - la bascule : discussion du projet quitté enregistrée, la plus récente +// du projet rejoint ouverte (/internal/v1/project/switch) ; +// - les discussions d'un projet supprimé (/internal/v1/project/drop-convs). +// +// Quand la façade tient la conversation, elle fait les deux elle-même. + +const facadeProjectsEnv = "LOKI_PROJECTS" + +// facadeProjects : la façade tient-elle le hub des projets ? +func facadeProjects() bool { + return strings.EqualFold(strings.TrimSpace(os.Getenv(facadeProjectsEnv)), "rust") && + facadeAddr() != "" && internalToken() != "" +} + +// facadeProjectsRoutes : les routes que la façade sert alors (projects_api.rs). +var facadeProjectsRoutes = map[string]bool{ + "/api/projects": true, +} + +// handleInternalProjectSwitch : POST {slug} → 204, ou 400/409 + {error}. +func handleInternalProjectSwitch(w http.ResponseWriter, r *http.Request) { + var in struct { + Slug string `json:"slug"` + } + if !readInternalJSON(w, r, &in) { + return + } + if conv.isGenerating() { + sendJSON(w, http.StatusConflict, map[string]any{"error": "une génération est en cours"}) + return + } + if err := projectSwitch(strings.TrimSpace(in.Slug)); err != nil { + sendJSON(w, http.StatusBadRequest, map[string]any{"error": err.Error()}) + return + } + w.WriteHeader(http.StatusNoContent) +} + +// handleInternalProjectDropConvs : POST {slug} → 200 {dropped} — supprime les +// discussions du projet, comme deleteProject quand le Go tient la conversation. +func handleInternalProjectDropConvs(w http.ResponseWriter, r *http.Request) { + var in struct { + Slug string `json:"slug"` + } + if !readInternalJSON(w, r, &in) { + return + } + if conv.isGenerating() { + sendJSON(w, http.StatusConflict, map[string]any{"error": "une génération est en cours"}) + return + } + n := 0 + for _, m := range convIndexForProject(strings.TrimSpace(in.Slug)) { + if convDelete(m.ID) == nil { + n++ + } + } + sendJSON(w, http.StatusOK, map[string]any{"dropped": n}) +} diff --git a/internal/loki/parity_projects_test.go b/internal/loki/parity_projects_test.go new file mode 100644 index 0000000..0bd61f2 --- /dev/null +++ b/internal/loki/parity_projects_test.go @@ -0,0 +1,272 @@ +//go:build linux + +package loki + +import ( + "encoding/json" + "net/http/httptest" + "os" + "path/filepath" + "sort" + "strings" + "testing" +) + +// parity_projects_test.go — journal de référence du hub des projets (lot 18 +// de la migration Rust) : liste et comptes (discussions, pages, trackers), +// création (slug translittéré, unique, tronqué), renommage, description, +// bascule et suppression (discussions, trackers et mémoire du projet, actif +// réassigné, dernier projet indestructible), refus pendant une génération, +// erreurs JSON. Après chaque appel : le registre, le projet actif, l'arbre +// memory/, les trackers et l'index des discussions. La conversation est tenue +// par le Go (le Rust le rejoue contre le faux Go, qui fait de même). +// Rangé dans rust/loki-rs/tests/parity_projects/projects.json ; +// tests/projects_api.rs le rejoue par la façade. +// +// LOKI_PARITY_UPDATE=1 go test ./internal/loki -run TestPariteProjets + +const parityProjectsFile = "../../rust/loki-rs/tests/parity_projects/projects.json" + +type parityProjectStep struct { + Name string `json:"name"` + Generating bool `json:"generating,omitempty"` + Method string `json:"method"` + Body string `json:"body,omitempty"` + Status int `json:"status"` + Out any `json:"out"` + After map[string]any `json:"after"` +} + +// parityZeroTimes : les dates de création, propres à chaque exécution. +func parityZeroTimes(v any) any { + switch t := v.(type) { + case map[string]any: + for k, x := range t { + if k == "created" || k == "created_at" || k == "updated" { + t[k] = 0 + } else { + t[k] = parityZeroTimes(x) + } + } + case []any: + for i := range t { + t[i] = parityZeroTimes(t[i]) + } + } + return v +} + +func TestPariteProjets(t *testing.T) { + home := testHome(t) + ensureDefaultProject() + // Le projet actif est gardé en RAM d'un test à l'autre. + activeProjMu.Lock() + activeProjCache = "" + activeProjMu.Unlock() + t.Cleanup(func() { + activeProjMu.Lock() + activeProjCache = "" + activeProjMu.Unlock() + }) + // Date de création fixe : la graine est rangée telle quelle. + if err := saveProjects([]Project{{Slug: defaultProjectSlug, Name: defaultProjectName, CreatedAt: 1}}); err != nil { + t.Fatal(err) + } + // Pages de Générale : trois comptées, le reste non. + for _, f := range []string{"a.md", "b.MD", "z.md", ".cache.md", "notes.txt", "sous/page.md"} { + p := filepath.Join(projectMemoryDir(defaultProjectSlug), f) + _ = os.MkdirAll(filepath.Dir(p), 0o755) + if err := os.WriteFile(p, []byte("# "+f+"\n"), 0o600); err != nil { + t.Fatal(err) + } + } + // Discussions : deux de Générale (dont une d'avant les projets), trois + // d'Alpha (créé plus loin), une d'un projet disparu. + idx := []convMeta{ + {ID: "g1", Title: "g1", Created: 1, Updated: 10, Project: "generale"}, + {ID: "o1", Title: "o1", Created: 2, Updated: 20}, + {ID: "a1", Title: "a1", Created: 3, Updated: 30, Project: "alpha"}, + {ID: "a2", Title: "a2", Created: 4, Updated: 50, Project: "alpha"}, + {ID: "a3", Title: "a3", Created: 5, Updated: 40, Project: "alpha"}, + {ID: "x1", Title: "x1", Created: 6, Updated: 60, Project: "ailleurs"}, + } + convIndexSave(idx) + for _, m := range idx { + _ = putBytes(bkChat, convKey(m.ID), []byte(`{"id":"`+m.ID+`","log":[]}`)) + } + // La discussion ouverte, comme au démarrage du Go (convEnsureActive). + _ = putStr(bkChat, ckActive, "g1") + convActivate("g1", getBytes(bkChat, convKey("g1"))) + seen := map[string]bool{} + for _, m := range idx { + seen[m.ID] = true + } + for _, k := range []string{"generale/poids", "alpha/sommeil", "alpha/pas", "alphabet/x"} { + _ = putStr(bkTracker, k, `{"name":"`+k+`","events":[]}`) + } + + snapshot := func() map[string]any { + st := map[string]any{} + var reg any + _ = json.Unmarshal(getBytes(bkState, stProjectsKey), ®) + st["projects"] = parityZeroTimes(reg) + st["active"] = getStr(bkState, stActiveProjectKey) + var tree []string + _ = filepath.Walk(filepath.Join(home, "memory"), func(p string, fi os.FileInfo, err error) error { + if err != nil { + return nil + } + rel, _ := filepath.Rel(home, p) + if fi.IsDir() { + rel += "/" + } + tree = append(tree, rel) + return nil + }) + sort.Strings(tree) + st["tree"] = tree + var trk []string + for k := range allKV(bkTracker) { + trk = append(trk, k) + } + sort.Strings(trk) + st["trackers"] = trk + var convs []string + for _, m := range convIndex() { + id := m.ID + if !seen[id] { + id = "{{NEW}}" // discussion créée par la bascule, identifiant tiré au hasard + } + convs = append(convs, id+":"+m.Project) + } + sort.Strings(convs) + st["convs"] = convs + return st + } + seed := snapshot() + seedMem := map[string]string{} + _ = filepath.Walk(filepath.Join(home, "memory"), func(p string, fi os.FileInfo, err error) error { + if err == nil && !fi.IsDir() { + b, _ := os.ReadFile(p) + rel, _ := filepath.Rel(home, p) + seedMem[rel] = string(b) + } + return nil + }) + seedStore := map[string]string{ + "state/projects": string(getBytes(bkState, stProjectsKey)), + "state/active_project": getStr(bkState, stActiveProjectKey), + "chat/index": string(getBytes(bkChat, ckIndex)), + } + for _, m := range idx { + seedStore["chat/"+convKey(m.ID)] = string(getBytes(bkChat, convKey(m.ID))) + } + for k, v := range allKV(bkTracker) { + seedStore["tracker/"+k] = string(v) + } + + long := strings.Repeat("é", 2100) + " fin" + var steps []parityProjectStep + for _, st := range []parityProjectStep{ + {Name: "liste", Method: "GET"}, + {Name: "création", Method: "POST", Body: `{"action":"create","name":"Alpha"}`}, + {Name: "création, même nom", Method: "POST", Body: `{"action":"create","name":"Alpha"}`}, + {Name: "création translittérée", Method: "POST", Body: `{"action":" CREATE ","name":" Réseau NAS — ça ñ "}`}, + {Name: "création sans lettres", Method: "POST", Body: `{"action":"create","name":"!!!"}`}, + {Name: "création sans lettres bis", Method: "POST", Body: `{"action":"create","name":"???"}`}, + {Name: "création longue", Method: "POST", + Body: `{"action":"create","name":"` + strings.Repeat("Ab-", 30) + `"}`}, + {Name: "création très longue", Method: "POST", + Body: `{"action":"create","name":"` + strings.Repeat("ü", 70) + `"}`}, + {Name: "création vide", Method: "POST", Body: `{"action":"create","name":" "}`}, + {Name: "renommage", Method: "POST", Body: `{"action":"rename","slug":" alpha-2 ","name":" Bêta "}`}, + {Name: "renommage vide", Method: "POST", Body: `{"action":"rename","slug":"alpha-2","name":""}`}, + {Name: "renommage inconnu", Method: "POST", Body: `{"action":"rename","slug":"nope","name":"X"}`}, + {Name: "description", Method: "POST", Body: `{"action":"desc","slug":"alpha","desc":" Le projet Alpha. "}`}, + {Name: "description longue", Method: "POST", Body: `{"action":"desc","slug":"alpha-2","desc":"` + long + `"}`}, + {Name: "description coupée sur une espace", Method: "POST", + Body: `{"action":"desc","slug":"projet","desc":"` + strings.Repeat("é", 1999) + ` suite"}`}, + {Name: "description effacée", Method: "POST", Body: `{"action":"desc","slug":"alpha-2","desc":" "}`}, + {Name: "description inconnue", Method: "POST", Body: `{"action":"desc","slug":"nope","desc":"x"}`}, + {Name: "bascule", Method: "POST", Body: `{"action":"switch","slug":"alpha"}`}, + {Name: "bascule sur l'actif", Method: "POST", Body: `{"action":"switch","slug":"alpha"}`}, + {Name: "bascule inconnue", Method: "POST", Body: `{"action":"switch","slug":"nope"}`}, + {Name: "bascule pendant une génération", Generating: true, Method: "POST", Body: `{"action":"switch","slug":"generale"}`}, + {Name: "suppression pendant une génération", Generating: true, Method: "POST", Body: `{"action":"delete","slug":"alpha"}`}, + {Name: "suppression inconnue pendant une génération", Generating: true, Method: "POST", Body: `{"action":"delete","slug":"nope"}`}, + {Name: "bascule inconnue pendant une génération", Generating: true, Method: "POST", Body: `{"action":"switch","slug":"nope"}`}, + {Name: "suppression de l'actif", Method: "POST", Body: `{"action":"delete","slug":"alpha"}`}, + {Name: "suppression inconnue", Method: "POST", Body: `{"action":"delete","slug":"nope"}`}, + {Name: "suppression", Method: "POST", Body: `{"action":"delete","slug":"projet"}`}, + {Name: "bascule vers un projet sans discussion", Method: "POST", Body: `{"action":"switch","slug":"projet-2"}`}, + {Name: "suppression bis", Method: "POST", Body: `{"action":"delete","slug":"alpha-2"}`}, + {Name: "suppression ter", Method: "POST", Body: `{"action":"delete","slug":"reseau-nas-ca-n"}`}, + {Name: "liste après", Method: "GET"}, + {Name: "action inconnue", Method: "POST", Body: `{"action":"Nuke","slug":"generale"}`}, + {Name: "action vide", Method: "POST", Body: `{}`}, + {Name: "corps vide", Method: "POST", Body: ``}, + {Name: "corps illisible", Method: "POST", Body: `{"action":"create"`}, + {Name: "type faux", Method: "POST", Body: `{"action":"create","name":7}`}, + {Name: "méthode refusée", Method: "PUT", Body: `{}`}, + } { + conv.mu.Lock() + conv.Generating = st.Generating + conv.mu.Unlock() + rec := httptest.NewRecorder() + handleProjects(rec, httptest.NewRequest(st.Method, "/api/projects", strings.NewReader(st.Body))) + conv.mu.Lock() + conv.Generating = false + conv.mu.Unlock() + st.Status = rec.Code + var out any + if err := json.Unmarshal(rec.Body.Bytes(), &out); err != nil { + t.Fatalf("%s : %q", st.Name, rec.Body.String()) + } + st.Out = parityZeroTimes(out) + st.After = snapshot() + steps = append(steps, st) + } + // Les dernières suppressions, jusqu'au dernier projet. + for _, p := range listProjects() { + if p.Slug == defaultProjectSlug { + continue + } + rec := httptest.NewRecorder() + handleProjects(rec, httptest.NewRequest("POST", "/api/projects", + strings.NewReader(`{"action":"delete","slug":"`+p.Slug+`"}`))) + var out any + _ = json.Unmarshal(rec.Body.Bytes(), &out) + steps = append(steps, parityProjectStep{Name: "suppression de " + p.Slug, Method: "POST", + Body: `{"action":"delete","slug":"` + p.Slug + `"}`, Status: rec.Code, Out: parityZeroTimes(out), After: snapshot()}) + } + rec := httptest.NewRecorder() + handleProjects(rec, httptest.NewRequest("POST", "/api/projects", strings.NewReader(`{"action":"delete","slug":"generale"}`))) + var out any + _ = json.Unmarshal(rec.Body.Bytes(), &out) + steps = append(steps, parityProjectStep{Name: "dernier projet", Method: "POST", + Body: `{"action":"delete","slug":"generale"}`, Status: rec.Code, Out: parityZeroTimes(out), After: snapshot()}) + + got, err := parityJSON(map[string]any{"seed": seed, "seed_store": seedStore, "seed_mem": seedMem, "steps": steps}) + if err != nil { + t.Fatal(err) + } + got = []byte(strings.ReplaceAll(string(got), home, "{{HOME}}")) + if os.Getenv("LOKI_PARITY_UPDATE") == "1" { + if err := os.MkdirAll(filepath.Dir(parityProjectsFile), 0o755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(parityProjectsFile, got, 0o644); err != nil { + t.Fatal(err) + } + return + } + want, err := os.ReadFile(parityProjectsFile) + 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("le hub des projets ne produit plus le journal rangé dans %s.\n"+ + "Si c'est voulu : LOKI_PARITY_UPDATE=1 go test ./internal/loki -run TestPariteProjets,\n"+ + "puis cargo test --test projects_api dit si le port Rust suit.\n--- obtenu ---\n%s", parityProjectsFile, got) + } +} diff --git a/internal/loki/web_internal.go b/internal/loki/web_internal.go index 66d09cf..6d71694 100644 --- a/internal/loki/web_internal.go +++ b/internal/loki/web_internal.go @@ -125,6 +125,10 @@ func withInternalAPI(next http.Handler) http.Handler { handleInternalCDPShutdown(w, r) case "/internal/v1/service/action": handleInternalServiceAction(w, r) + case "/internal/v1/project/switch": + handleInternalProjectSwitch(w, r) + case "/internal/v1/project/drop-convs": + handleInternalProjectDropConvs(w, r) default: http.NotFound(w, r) } diff --git a/rust/loki-rs/src/api.rs b/rust/loki-rs/src/api.rs index 9b7866b..71e7c0e 100644 --- a/rust/loki-rs/src/api.rs +++ b/rust/loki-rs/src/api.rs @@ -46,6 +46,8 @@ pub struct Ctx { pub engine_ctl: Option>, /// Interrupteurs système tenus par la façade (`LOKI_SYSTEM=rust`). pub system: Option>, + /// Hub des projets tenu par la façade (`LOKI_PROJECTS=rust`). + pub projects: bool, /// Jeton de l'API interne : une requête qui le porte vient du Go. pub internal_token: String, } @@ -64,6 +66,7 @@ impl Ctx { || self.presets || self.engine_ctl.is_some() || self.system.is_some() + || self.projects } /// La requête vient du Go (jeton interne valide) : déjà authentifiée par diff --git a/rust/loki-rs/src/enginectl.rs b/rust/loki-rs/src/enginectl.rs index d112773..7bc9978 100644 --- a/rust/loki-rs/src/enginectl.rs +++ b/rust/loki-rs/src/enginectl.rs @@ -359,7 +359,7 @@ fn pid_alive(_: i32) -> bool { /// La conversation écrit-elle une réponse ? Celle de la façade si elle la /// tient, sinon celle du Go. -async fn generating(ctx: &Ctx) -> bool { +pub(crate) async fn generating(ctx: &Ctx) -> bool { if let Some(chat) = &ctx.chat { return chat.sessions.conv().is_generating(); } diff --git a/rust/loki-rs/src/lib.rs b/rust/loki-rs/src/lib.rs index 25bd3c4..ed8b0dc 100644 --- a/rust/loki-rs/src/lib.rs +++ b/rust/loki-rs/src/lib.rs @@ -30,6 +30,7 @@ pub mod oai; pub mod paths; pub mod presets; pub mod presets_api; +pub mod projects_api; pub mod proxy; pub mod push; pub mod serve; diff --git a/rust/loki-rs/src/main.rs b/rust/loki-rs/src/main.rs index 16074f8..78e1b8c 100644 --- a/rust/loki-rs/src/main.rs +++ b/rust/loki-rs/src/main.rs @@ -154,6 +154,8 @@ fn serve() -> ExitCode { // LOKI_SYSTEM=rust : les interrupteurs système (accès internet, // navigateur, clé de complétion, écoute réseau du moteur). let system_rust = held_by_facade("LOKI_SYSTEM"); + // LOKI_PROJECTS=rust : le hub des projets. + let projects_rust = held_by_facade("LOKI_PROJECTS"); let engine_rust = held_by_facade("LOKI_ENGINE") && match loki_rs::enginectl::usable( std::env::var("LOKI_CONTAINER").ok().as_deref(), @@ -182,6 +184,7 @@ fn serve() -> ExitCode { ("LOKI_PRESETS", presets_rust), ("LOKI_ENGINE", engine_rust), ("LOKI_SYSTEM", system_rust), + ("LOKI_PROJECTS", projects_rust), ]; for (var, on) in held { if on { @@ -285,6 +288,7 @@ fn serve() -> ExitCode { presets: presets_rust, engine_ctl, system: system_rust.then(|| std::sync::Arc::new(loki_rs::system::System::new())), + projects: projects_rust, }; let res = proxy::serve(listener, cfg, shutdown_signal()).await; eprintln!("[loki-rs] arrêt demandé — arrêt du serveur Go"); diff --git a/rust/loki-rs/src/memory.rs b/rust/loki-rs/src/memory.rs index 2b94a09..287617b 100644 --- a/rust/loki-rs/src/memory.rs +++ b/rust/loki-rs/src/memory.rs @@ -48,7 +48,7 @@ pub const MEM_INDEX_FILE: &str = "MEMORY.md"; /// Magic des contenus chiffrés (`memPageMagic`). pub const PAGE_MAGIC: &[u8] = b"LOKIMEMv1"; pub const BK_TRACKER: &str = "tracker"; -const ST_PROJECTS: &str = "projects"; +pub(crate) const ST_PROJECTS: &str = "projects"; const ST_ACTIVE_PROJECT: &str = "active_project"; const ST_AGENT: &str = "agent"; const DEFAULT_PROJECT_SLUG: &str = "generale"; @@ -400,7 +400,7 @@ pub fn parse_when(when: &str, now_ms: i64, tz: Tz) -> Result<(i64, bool), String )) } -fn now_ms() -> i64 { +pub(crate) fn now_ms() -> i64 { SystemTime::now() .duration_since(UNIX_EPOCH) .map_or(0, |d| d.as_millis() as i64) diff --git a/rust/loki-rs/src/projects_api.rs b/rust/loki-rs/src/projects_api.rs new file mode 100644 index 0000000..dee6559 --- /dev/null +++ b/rust/loki-rs/src/projects_api.rs @@ -0,0 +1,421 @@ +//! Hub des projets tenu par la façade (lot 18 de la migration, +//! `LOKI_PROJECTS=rust` ; expérimental, le Go reste le défaut) — port de +//! `handleProjects` / `projectDTOs` (`web_projects.go`) et des mutations de +//! `projects.go` : création (slug translittéré, unique), renommage, +//! description, suppression (discussions, trackers et mémoire du projet, actif +//! réassigné, dernier projet indestructible) et bascule. +//! +//! Le registre reste dans la base (`state/projects`), la mémoire de chaque +//! projet dans `memory/`. Le projet actif passe toujours par le Go +//! (`/internal/v1/project/activate`), qui en garde une copie en RAM. Les +//! discussions sont à qui tient la conversation : la façade les bascule et +//! les supprime elle-même quand elle la tient, sinon le Go +//! (`/internal/v1/project/switch`, `/internal/v1/project/drop-convs`). + +use std::path::Path; + +use http_body_util::{BodyExt, Limited}; +use hyper::body::Incoming; +use hyper::{Method, Request, Response, StatusCode}; +use serde_json::{Value, json}; + +use crate::api::Ctx; +use crate::auth::json as send_json; +use crate::chat::persist::{BK_CHAT, CK_INDEX, decode_index}; +use crate::gojson::{self, Kind}; +use crate::memory::{self, BK_TRACKER, Project, ST_PROJECTS, active_project, list_projects}; +use crate::paths::DEFAULT_PROJECT_SLUG; +use crate::proxy::Body; +use crate::store::{BK_STATE, Store}; + +/// Taille maximale d'un nom de projet, en runes. +pub const MAX_NAME: usize = 60; +/// Taille maximale d'une description, en runes. +pub const MAX_DESC: usize = 2000; + +/// Une erreur de route : (code, message). +type Fail = (StatusCode, String); + +fn bad(e: impl Into) -> Fail { + (StatusCode::BAD_REQUEST, e.into()) +} + +/// Les `n` premières runes. +fn runes(s: &str, n: usize) -> String { + s.chars().take(n).collect() +} + +/// `saveProjects` (`json.Marshal`, HTML échappé comme le Go). +async fn save_projects(store: &Store, list: &[Project]) -> Result<(), String> { + store + .put(BK_STATE, ST_PROJECTS, gojson::marshal(list).into_bytes()) + .await +} + +/// `projectMemoryDir`. +pub fn project_memory_dir(home: &Path, slug: &str) -> std::path::PathBuf { + let slug = if slug.is_empty() { + DEFAULT_PROJECT_SLUG + } else { + slug + }; + home.join("memory").join(slug) +} + +/// Pages d'un projet (`MemList` sans l'index) : fichiers `.md` visibles. +pub fn count_pages(dir: &Path) -> usize { + let Ok(rd) = std::fs::read_dir(dir) else { + return 0; + }; + rd.filter_map(|e| e.ok()) + .filter(|e| !e.file_type().is_ok_and(|t| t.is_dir())) + .map(|e| e.file_name().to_string_lossy().into_owned()) + .filter(|n| { + !n.starts_with('.') && n.to_lowercase().ends_with(".md") && !memory::is_index_file(n) + }) + .count() +} + +/// `slugifyIdent` puis le repli « projet » et le suffixe -2, -3… d'un slug +/// déjà pris. +pub fn unique_slug(name: &str, list: &[Project]) -> String { + let mut base = memory::slugify_ident(name); + if base.is_empty() { + base = "projet".into(); + } + let taken = |s: &str| list.iter().any(|p| p.slug == s); + let mut slug = base.clone(); + let mut i = 2; + while taken(&slug) { + slug = format!("{base}-{i}"); + i += 1; + } + slug +} + +/// `createProject`. +async fn create_project(store: &Store, home: &Path, name: &str) -> Result { + let name = name.trim(); + if name.is_empty() { + return Err("nom vide".into()); + } + let name = runes(name, MAX_NAME); + let mut list = list_projects(store).await; + let slug = unique_slug(&name, &list); + let dir = project_memory_dir(home, &slug); + std::fs::create_dir_all(&dir).map_err(|e| e.to_string())?; + let idx = dir.join(memory::MEM_INDEX_FILE); + if !idx.exists() { + let _ = write_private(&idx, memory::memory_seed(&name).as_bytes()); + } + let p = Project { + slug, + name, + created_at: memory::now_ms(), + desc: String::new(), + mem_mode: String::new(), + }; + list.push(p.clone()); + save_projects(store, &list).await?; + Ok(p) +} + +/// Fichier en 0600, comme `os.WriteFile(…, 0o600)`. +fn write_private(p: &Path, b: &[u8]) -> std::io::Result<()> { + use std::io::Write; + let mut o = std::fs::OpenOptions::new(); + o.write(true).create(true).truncate(true); + #[cfg(unix)] + { + use std::os::unix::fs::OpenOptionsExt; + o.mode(0o600); + } + o.open(p)?.write_all(b) +} + +/// `renameProject`. +async fn rename_project(store: &Store, slug: &str, name: &str) -> Result<(), String> { + let name = name.trim(); + if name.is_empty() { + return Err("nom vide".into()); + } + let name = runes(name, MAX_NAME); + let mut list = list_projects(store).await; + let Some(p) = list.iter_mut().find(|p| p.slug == slug) else { + return Err("projet introuvable".into()); + }; + p.name = name; + save_projects(store, &list).await +} + +/// `setProjectDesc` : tronquée à 2 000 runes (contexte injecté à chaque +/// conversation, pas un cahier des charges) ; vide = effacée. +async fn set_project_desc(store: &Store, slug: &str, desc: &str) -> Result<(), String> { + let mut desc = desc.trim().to_string(); + if desc.chars().count() > MAX_DESC { + desc = runes(&desc, MAX_DESC).trim().to_string(); + } + let mut list = list_projects(store).await; + let Some(p) = list.iter_mut().find(|p| p.slug == slug) else { + return Err("projet introuvable".into()); + }; + p.desc = desc; + save_projects(store, &list).await +} + +/// Le projet actif, par le Go (qui en garde une copie en RAM). +async fn activate(store: &Store, slug: &str) -> Result<(), Fail> { + let (code, v) = store + .internal_post("/internal/v1/project/activate", &json!({ "slug": slug })) + .await + .map_err(bad)?; + if code.is_success() { + return Ok(()); + } + Err(go_fail(code, &v)) +} + +/// Une réponse d'erreur du Go : 409 gardé, le reste en 400. +fn go_fail(code: StatusCode, v: &Value) -> Fail { + let msg = v + .get("error") + .and_then(Value::as_str) + .map(str::to_string) + .unwrap_or_else(|| code.to_string()); + let code = if code == StatusCode::CONFLICT { + StatusCode::CONFLICT + } else { + StatusCode::BAD_REQUEST + }; + (code, msg) +} + +fn refused(r: crate::chat::generate::Refused) -> Fail { + let code = if r.status == 409 { + StatusCode::CONFLICT + } else { + StatusCode::BAD_REQUEST + }; + (code, r.error) +} + +/// Bascule de la conversation sur un projet : la façade si elle la tient, +/// sinon le Go. +async fn switch_conversation(ctx: &Ctx, slug: &str) -> Result<(), Fail> { + if let Some(chat) = &ctx.chat { + return chat.project_switch(slug).await.map_err(refused); + } + let (code, v) = ctx + .store + .internal_post("/internal/v1/project/switch", &json!({ "slug": slug })) + .await + .map_err(bad)?; + if code.is_success() { + Ok(()) + } else { + Err(go_fail(code, &v)) + } +} + +/// `deleteProject`. +async fn delete_project(ctx: &Ctx, slug: &str) -> Result<(), Fail> { + let store = &ctx.store; + let list = list_projects(store).await; + if list.len() <= 1 { + return Err(bad("impossible de supprimer le dernier projet")); + } + let kept: Vec = list.iter().filter(|p| p.slug != slug).cloned().collect(); + if kept.len() == list.len() { + return Err(bad("projet introuvable")); + } + // Discussions du projet. Quand la façade tient la conversation, elle + // quitte d'abord le projet s'il est actif (sa discussion enregistrée, une + // du projet restant ouverte), puis supprime les autres ; sinon le Go. + if let Some(chat) = &ctx.chat { + if active_project(store).await == slug { + chat.project_switch(&kept[0].slug).await.map_err(refused)?; + } + chat.drop_project(slug).await.map_err(refused)?; + } else { + let (code, v) = store + .internal_post("/internal/v1/project/drop-convs", &json!({ "slug": slug })) + .await + .map_err(bad)?; + if !code.is_success() { + return Err(go_fail(code, &v)); + } + } + // Trackers du projet. + let prefix = format!("{slug}/"); + if let Ok(all) = store.all(BK_TRACKER).await { + for k in all.keys().filter(|k| k.starts_with(&prefix)) { + let _ = store.del(BK_TRACKER, k).await; + } + } + // Dossier disque du projet. + let _ = std::fs::remove_dir_all(project_memory_dir(&ctx.engine.home, slug)); + save_projects(store, &kept).await.map_err(bad)?; + if active_project(store).await == slug { + let _ = activate(store, &kept[0].slug).await; + } + Ok(()) +} + +/// `projectDTOs` : le registre et ses comptes, plus le projet actif. +async fn listing(ctx: &Ctx) -> Value { + let store = &ctx.store; + let active = active_project(store).await; + let mut convs: std::collections::HashMap = std::collections::HashMap::new(); + if let Ok(Some(b)) = store.get_open(BK_CHAT, CK_INDEX).await { + for m in decode_index(&b) { + let p = match m.project.trim() { + "" => DEFAULT_PROJECT_SLUG.to_string(), + s => s.to_string(), + }; + *convs.entry(p).or_default() += 1; + } + } + let trackers: Vec = store + .all(BK_TRACKER) + .await + .map(|m| m.into_keys().collect()) + .unwrap_or_default(); + let home = ctx.engine.home.clone(); + let list = list_projects(store).await; + let pages: Vec = { + let dirs: Vec<_> = list + .iter() + .map(|p| project_memory_dir(&home, &p.slug)) + .collect(); + tokio::task::spawn_blocking(move || dirs.iter().map(|d| count_pages(d)).collect()) + .await + .unwrap_or_default() + }; + let projects: Vec = list + .iter() + .enumerate() + .map(|(i, p)| { + let prefix = format!("{}/", p.slug); + let mut d = json!({ + "slug": p.slug, + "name": p.name, + "created": p.created_at, + "convs": convs.get(&p.slug).copied().unwrap_or(0), + "pages": pages.get(i).copied().unwrap_or(0), + "trackers": trackers.iter().filter(|k| k.starts_with(&prefix)).count(), + "active": p.slug == active, + }); + if !p.desc.is_empty() { + d["desc"] = json!(p.desc); + } + d + }) + .collect(); + json!({ "ok": true, "projects": projects, "active": active }) +} + +/// Les routes servies par ce module. +pub fn handles(path: &str) -> bool { + path == "/api/projects" +} + +const PROJECT_REQ_TYPE: &str = r#"struct { Action string "json:\"action\""; Slug string "json:\"slug\""; Name string "json:\"name\""; Desc string "json:\"desc\"" }"#; + +/// `from_go` : remise par le Go (tunnel), déjà authentifiée par lui. +pub async fn serve(ctx: &Ctx, req: Request, from_go: bool) -> Response { + let (parts, body) = req.into_parts(); + if !from_go && let Err(refus) = ctx.guard(&parts.headers).await { + return *refus; + } + match parts.method { + Method::GET => send_json(StatusCode::OK, &listing(ctx).await), + Method::POST => { + let raw = Limited::new(body, 1 << 20) + .collect() + .await + .map(|b| b.to_bytes().to_vec()) + .unwrap_or_default(); + let d = gojson::decode_struct( + &raw, + PROJECT_REQ_TYPE, + &[ + ("action", Kind::Str), + ("slug", Kind::Str), + ("name", Kind::Str), + ("desc", Kind::Str), + ], + ); + if let Some(e) = d.err.clone() { + return send_json(StatusCode::BAD_REQUEST, &json!({ "ok": false, "error": e })); + } + let slug = d.str("slug").trim().to_string(); + let store = &ctx.store; + let home = ctx.engine.home.clone(); + let action = d.str("action"); + let res: Result<(), Fail> = match action.trim().to_lowercase().as_str() { + "create" => create_project(store, &home, &d.str("name")) + .await + .map(|_| ()) + .map_err(bad), + "rename" => rename_project(store, &slug, &d.str("name")) + .await + .map_err(bad), + "desc" => set_project_desc(store, &slug, &d.str("desc")) + .await + .map_err(bad), + "delete" => { + // Une génération travaille dans la mémoire du projet actif : + // supprimer sous ses pieds la laisserait écrire dans le vide. + if crate::enginectl::generating(ctx).await { + Err((StatusCode::CONFLICT, "une génération est en cours".into())) + } else { + delete_project(ctx, &slug).await + } + } + "switch" => { + if crate::enginectl::generating(ctx).await { + Err((StatusCode::CONFLICT, "une génération est en cours".into())) + } else if ctx.chat.is_some() + && !list_projects(store).await.iter().any(|p| p.slug == slug) + { + Err(bad("projet introuvable")) + } else { + switch_conversation(ctx, &slug).await + } + } + _ => Err(bad(format!("action inconnue : {}", gojson::quote(&action)))), + }; + match res { + Ok(()) => send_json(StatusCode::OK, &listing(ctx).await), + Err((code, e)) => send_json(code, &json!({ "ok": false, "error": e })), + } + } + _ => send_json( + StatusCode::METHOD_NOT_ALLOWED, + &json!({ "ok": false, "error": "méthode non autorisée" }), + ), + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn p(slug: &str) -> Project { + Project { + slug: slug.into(), + name: slug.into(), + created_at: 0, + desc: String::new(), + mem_mode: String::new(), + } + } + + #[test] + fn slugs_uniques() { + let list = vec![p("alpha"), p("alpha-2"), p("projet")]; + assert_eq!(unique_slug("Alpha", &list), "alpha-3"); + assert_eq!(unique_slug("!!!", &list), "projet-2"); + assert_eq!(unique_slug("Bêta", &list), "beta"); + } +} diff --git a/rust/loki-rs/src/proxy.rs b/rust/loki-rs/src/proxy.rs index 6e58743..99927ae 100644 --- a/rust/loki-rs/src/proxy.rs +++ b/rust/loki-rs/src/proxy.rs @@ -85,6 +85,8 @@ pub struct Config { /// Interrupteurs système (internet, navigateur, clé de complétion, /// réseau) tenus par la façade (`LOKI_SYSTEM=rust`) ; `None` : au Go. pub system: Option>, + /// Hub des projets tenu par la façade (`LOKI_PROJECTS=rust`). + pub projects: bool, } struct Proxy { @@ -121,6 +123,7 @@ pub async fn serve( presets: cfg.presets, engine_ctl: cfg.engine_ctl, system: cfg.system, + projects: cfg.projects, internal_token: cfg.internal_token.clone(), }, }); @@ -208,6 +211,9 @@ impl Proxy { { return chatapi::serve(&self.ctx, chat, req, from_go).await; } + if self.ctx.projects && crate::projects_api::handles(path) { + return crate::projects_api::serve(&self.ctx, req, from_go).await; + } if let Some(s) = &self.ctx.system && crate::system::handles(path) { diff --git a/rust/loki-rs/tests/common/fakego.rs b/rust/loki-rs/tests/common/fakego.rs index 2590362..71c89da 100644 --- a/rust/loki-rs/tests/common/fakego.rs +++ b/rust/loki-rs/tests/common/fakego.rs @@ -321,6 +321,59 @@ async fn handle(st: Arc, req: Request) -> Response st.set("state", "active_project", &slug); resp(204, "") } + // Conversation tenue par le Go (lot 18) : bascule (projectSwitch) et + // discussions d'un projet supprimé (convDelete), sur l'index en base. + "/internal/v1/project/switch" | "/internal/v1/project/drop-convs" => { + let body = req.into_body().collect().await.unwrap().to_bytes(); + let v: serde_json::Value = serde_json::from_slice(&body).unwrap_or_default(); + let slug = v["slug"].as_str().unwrap_or_default().trim().to_string(); + if *st.generating.lock().unwrap() { + return resp(409, r#"{"error":"une génération est en cours"}"#); + } + let mut idx: Vec = st + .get("chat", "index") + .and_then(|s| serde_json::from_str(&s).ok()) + .unwrap_or_default(); + let proj = |m: &serde_json::Value| match m["project"].as_str().unwrap_or("").trim() { + "" => "generale".to_string(), + s => s.to_string(), + }; + if path.ends_with("drop-convs") { + let gone: Vec = idx + .iter() + .filter(|m| proj(m) == slug) + .map(|m| m["id"].as_str().unwrap_or("").to_string()) + .collect(); + idx.retain(|m| proj(m) != slug); + st.set("chat", "index", &serde_json::to_string(&idx).unwrap()); + for id in &gone { + st.del("chat", &format!("conv:{id}")); + } + return resp( + 200, + serde_json::json!({ "dropped": gone.len() }).to_string(), + ); + } + let known = st + .get("state", "projects") + .and_then(|s| serde_json::from_str::>(&s).ok()) + .unwrap_or_default() + .iter() + .any(|p| p["slug"] == slug.as_str()); + if !known { + return resp(400, r#"{"error":"projet introuvable"}"#); + } + if st.get("state", "active_project").as_deref() == Some(slug.as_str()) { + return resp(204, ""); + } + st.set("state", "active_project", &slug); + if !idx.iter().any(|m| proj(m) == slug) { + let id = format!("c{}", idx.len() + 1_000_000); + idx.push(serde_json::json!({ "id": id, "title": "", "created": 0, "updated": 0, "turns": 0, "project": slug })); + st.set("chat", "index", &serde_json::to_string(&idx).unwrap()); + } + resp(204, "") + } "/internal/v1/turn/verifier" => { let body = req.into_body().collect().await.unwrap().to_bytes(); let v: serde_json::Value = serde_json::from_slice(&body).unwrap_or_default(); @@ -579,6 +632,7 @@ pub struct Opts { pub presets: bool, pub engine_ctl: Option>, pub system: Option>, + pub projects: bool, } /// Comme [`start`], avec la conversation tenue par la façade @@ -677,6 +731,7 @@ pub async fn start_with( presets: opts.presets, engine_ctl: opts.engine_ctl, system: opts.system, + projects: opts.projects, }; tokio::spawn(loki_rs::proxy::serve(fl, cfg, async move { let _ = rx.await; diff --git a/rust/loki-rs/tests/lot2.rs b/rust/loki-rs/tests/lot2.rs index a9f3c29..0a76390 100644 --- a/rust/loki-rs/tests/lot2.rs +++ b/rust/loki-rs/tests/lot2.rs @@ -103,6 +103,7 @@ async fn banc(cle: Option<&str>) -> Banc { presets: false, engine_ctl: None, system: None, + projects: false, }; tokio::spawn(loki_rs::proxy::serve(l, cfg, async move { let _ = rx.await; diff --git a/rust/loki-rs/tests/parity_projects/projects.json b/rust/loki-rs/tests/parity_projects/projects.json new file mode 100644 index 0000000..8db6b04 --- /dev/null +++ b/rust/loki-rs/tests/parity_projects/projects.json @@ -0,0 +1,4484 @@ +{ + "seed": { + "active": "generale", + "convs": [ + "a1:alpha", + "a2:alpha", + "a3:alpha", + "g1:generale", + "o1:", + "x1:ailleurs" + ], + "projects": [ + { + "created_at": 0, + "name": "Générale", + "slug": "generale" + } + ], + "trackers": [ + "alpha/pas", + "alpha/sommeil", + "alphabet/x", + "generale/poids" + ], + "tree": [ + "memory/", + "memory/generale/", + "memory/generale/.cache.md", + "memory/generale/MEMORY.md", + "memory/generale/a.md", + "memory/generale/b.MD", + "memory/generale/notes.txt", + "memory/generale/sous/", + "memory/generale/sous/page.md", + "memory/generale/z.md" + ] + }, + "seed_mem": { + "memory/generale/.cache.md": "# .cache.md\n", + "memory/generale/MEMORY.md": "# Index mémoire — Générale\n\nUne ligne par page de ce projet (`- [titre](fichier.md) — accroche`). Tenu à jour automatiquement à la création et à la suppression d'une page ; l'accroche après le titre, elle, est libre.\n", + "memory/generale/a.md": "# a.md\n", + "memory/generale/b.MD": "# b.MD\n", + "memory/generale/notes.txt": "# notes.txt\n", + "memory/generale/sous/page.md": "# sous/page.md\n", + "memory/generale/z.md": "# z.md\n" + }, + "seed_store": { + "chat/conv:a1": "{\"id\":\"a1\",\"log\":[]}", + "chat/conv:a2": "{\"id\":\"a2\",\"log\":[]}", + "chat/conv:a3": "{\"id\":\"a3\",\"log\":[]}", + "chat/conv:g1": "{\"id\":\"g1\",\"log\":[]}", + "chat/conv:o1": "{\"id\":\"o1\",\"log\":[]}", + "chat/conv:x1": "{\"id\":\"x1\",\"log\":[]}", + "chat/index": "[{\"id\":\"g1\",\"title\":\"g1\",\"created\":1,\"updated\":10,\"turns\":0,\"project\":\"generale\"},{\"id\":\"o1\",\"title\":\"o1\",\"created\":2,\"updated\":20,\"turns\":0},{\"id\":\"a1\",\"title\":\"a1\",\"created\":3,\"updated\":30,\"turns\":0,\"project\":\"alpha\"},{\"id\":\"a2\",\"title\":\"a2\",\"created\":4,\"updated\":50,\"turns\":0,\"project\":\"alpha\"},{\"id\":\"a3\",\"title\":\"a3\",\"created\":5,\"updated\":40,\"turns\":0,\"project\":\"alpha\"},{\"id\":\"x1\",\"title\":\"x1\",\"created\":6,\"updated\":60,\"turns\":0,\"project\":\"ailleurs\"}]", + "state/active_project": "generale", + "state/projects": "[{\"slug\":\"generale\",\"name\":\"Générale\",\"created_at\":1}]", + "tracker/alpha/pas": "{\"name\":\"alpha/pas\",\"events\":[]}", + "tracker/alpha/sommeil": "{\"name\":\"alpha/sommeil\",\"events\":[]}", + "tracker/alphabet/x": "{\"name\":\"alphabet/x\",\"events\":[]}", + "tracker/generale/poids": "{\"name\":\"generale/poids\",\"events\":[]}" + }, + "steps": [ + { + "name": "liste", + "method": "GET", + "status": 200, + "out": { + "active": "generale", + "ok": true, + "projects": [ + { + "active": true, + "convs": 2, + "created": 0, + "name": "Générale", + "pages": 3, + "slug": "generale", + "trackers": 1 + } + ] + }, + "after": { + "active": "generale", + "convs": [ + "a1:alpha", + "a2:alpha", + "a3:alpha", + "g1:generale", + "o1:", + "x1:ailleurs" + ], + "projects": [ + { + "created_at": 0, + "name": "Générale", + "slug": "generale" + } + ], + "trackers": [ + "alpha/pas", + "alpha/sommeil", + "alphabet/x", + "generale/poids" + ], + "tree": [ + "memory/", + "memory/generale/", + "memory/generale/.cache.md", + "memory/generale/MEMORY.md", + "memory/generale/a.md", + "memory/generale/b.MD", + "memory/generale/notes.txt", + "memory/generale/sous/", + "memory/generale/sous/page.md", + "memory/generale/z.md" + ] + } + }, + { + "name": "création", + "method": "POST", + "body": "{\"action\":\"create\",\"name\":\"Alpha\"}", + "status": 200, + "out": { + "active": "generale", + "ok": true, + "projects": [ + { + "active": true, + "convs": 2, + "created": 0, + "name": "Générale", + "pages": 3, + "slug": "generale", + "trackers": 1 + }, + { + "active": false, + "convs": 3, + "created": 0, + "name": "Alpha", + "pages": 0, + "slug": "alpha", + "trackers": 2 + } + ] + }, + "after": { + "active": "generale", + "convs": [ + "a1:alpha", + "a2:alpha", + "a3:alpha", + "g1:generale", + "o1:", + "x1:ailleurs" + ], + "projects": [ + { + "created_at": 0, + "name": "Générale", + "slug": "generale" + }, + { + "created_at": 0, + "name": "Alpha", + "slug": "alpha" + } + ], + "trackers": [ + "alpha/pas", + "alpha/sommeil", + "alphabet/x", + "generale/poids" + ], + "tree": [ + "memory/", + "memory/alpha/", + "memory/alpha/MEMORY.md", + "memory/generale/", + "memory/generale/.cache.md", + "memory/generale/MEMORY.md", + "memory/generale/a.md", + "memory/generale/b.MD", + "memory/generale/notes.txt", + "memory/generale/sous/", + "memory/generale/sous/page.md", + "memory/generale/z.md" + ] + } + }, + { + "name": "création, même nom", + "method": "POST", + "body": "{\"action\":\"create\",\"name\":\"Alpha\"}", + "status": 200, + "out": { + "active": "generale", + "ok": true, + "projects": [ + { + "active": true, + "convs": 2, + "created": 0, + "name": "Générale", + "pages": 3, + "slug": "generale", + "trackers": 1 + }, + { + "active": false, + "convs": 3, + "created": 0, + "name": "Alpha", + "pages": 0, + "slug": "alpha", + "trackers": 2 + }, + { + "active": false, + "convs": 0, + "created": 0, + "name": "Alpha", + "pages": 0, + "slug": "alpha-2", + "trackers": 0 + } + ] + }, + "after": { + "active": "generale", + "convs": [ + "a1:alpha", + "a2:alpha", + "a3:alpha", + "g1:generale", + "o1:", + "x1:ailleurs" + ], + "projects": [ + { + "created_at": 0, + "name": "Générale", + "slug": "generale" + }, + { + "created_at": 0, + "name": "Alpha", + "slug": "alpha" + }, + { + "created_at": 0, + "name": "Alpha", + "slug": "alpha-2" + } + ], + "trackers": [ + "alpha/pas", + "alpha/sommeil", + "alphabet/x", + "generale/poids" + ], + "tree": [ + "memory/", + "memory/alpha-2/", + "memory/alpha-2/MEMORY.md", + "memory/alpha/", + "memory/alpha/MEMORY.md", + "memory/generale/", + "memory/generale/.cache.md", + "memory/generale/MEMORY.md", + "memory/generale/a.md", + "memory/generale/b.MD", + "memory/generale/notes.txt", + "memory/generale/sous/", + "memory/generale/sous/page.md", + "memory/generale/z.md" + ] + } + }, + { + "name": "création translittérée", + "method": "POST", + "body": "{\"action\":\" CREATE \",\"name\":\" Réseau NAS — ça ñ \"}", + "status": 200, + "out": { + "active": "generale", + "ok": true, + "projects": [ + { + "active": true, + "convs": 2, + "created": 0, + "name": "Générale", + "pages": 3, + "slug": "generale", + "trackers": 1 + }, + { + "active": false, + "convs": 3, + "created": 0, + "name": "Alpha", + "pages": 0, + "slug": "alpha", + "trackers": 2 + }, + { + "active": false, + "convs": 0, + "created": 0, + "name": "Alpha", + "pages": 0, + "slug": "alpha-2", + "trackers": 0 + }, + { + "active": false, + "convs": 0, + "created": 0, + "name": "Réseau NAS — ça ñ", + "pages": 0, + "slug": "reseau-nas-ca-n", + "trackers": 0 + } + ] + }, + "after": { + "active": "generale", + "convs": [ + "a1:alpha", + "a2:alpha", + "a3:alpha", + "g1:generale", + "o1:", + "x1:ailleurs" + ], + "projects": [ + { + "created_at": 0, + "name": "Générale", + "slug": "generale" + }, + { + "created_at": 0, + "name": "Alpha", + "slug": "alpha" + }, + { + "created_at": 0, + "name": "Alpha", + "slug": "alpha-2" + }, + { + "created_at": 0, + "name": "Réseau NAS — ça ñ", + "slug": "reseau-nas-ca-n" + } + ], + "trackers": [ + "alpha/pas", + "alpha/sommeil", + "alphabet/x", + "generale/poids" + ], + "tree": [ + "memory/", + "memory/alpha-2/", + "memory/alpha-2/MEMORY.md", + "memory/alpha/", + "memory/alpha/MEMORY.md", + "memory/generale/", + "memory/generale/.cache.md", + "memory/generale/MEMORY.md", + "memory/generale/a.md", + "memory/generale/b.MD", + "memory/generale/notes.txt", + "memory/generale/sous/", + "memory/generale/sous/page.md", + "memory/generale/z.md", + "memory/reseau-nas-ca-n/", + "memory/reseau-nas-ca-n/MEMORY.md" + ] + } + }, + { + "name": "création sans lettres", + "method": "POST", + "body": "{\"action\":\"create\",\"name\":\"!!!\"}", + "status": 200, + "out": { + "active": "generale", + "ok": true, + "projects": [ + { + "active": true, + "convs": 2, + "created": 0, + "name": "Générale", + "pages": 3, + "slug": "generale", + "trackers": 1 + }, + { + "active": false, + "convs": 3, + "created": 0, + "name": "Alpha", + "pages": 0, + "slug": "alpha", + "trackers": 2 + }, + { + "active": false, + "convs": 0, + "created": 0, + "name": "Alpha", + "pages": 0, + "slug": "alpha-2", + "trackers": 0 + }, + { + "active": false, + "convs": 0, + "created": 0, + "name": "Réseau NAS — ça ñ", + "pages": 0, + "slug": "reseau-nas-ca-n", + "trackers": 0 + }, + { + "active": false, + "convs": 0, + "created": 0, + "name": "!!!", + "pages": 0, + "slug": "projet", + "trackers": 0 + } + ] + }, + "after": { + "active": "generale", + "convs": [ + "a1:alpha", + "a2:alpha", + "a3:alpha", + "g1:generale", + "o1:", + "x1:ailleurs" + ], + "projects": [ + { + "created_at": 0, + "name": "Générale", + "slug": "generale" + }, + { + "created_at": 0, + "name": "Alpha", + "slug": "alpha" + }, + { + "created_at": 0, + "name": "Alpha", + "slug": "alpha-2" + }, + { + "created_at": 0, + "name": "Réseau NAS — ça ñ", + "slug": "reseau-nas-ca-n" + }, + { + "created_at": 0, + "name": "!!!", + "slug": "projet" + } + ], + "trackers": [ + "alpha/pas", + "alpha/sommeil", + "alphabet/x", + "generale/poids" + ], + "tree": [ + "memory/", + "memory/alpha-2/", + "memory/alpha-2/MEMORY.md", + "memory/alpha/", + "memory/alpha/MEMORY.md", + "memory/generale/", + "memory/generale/.cache.md", + "memory/generale/MEMORY.md", + "memory/generale/a.md", + "memory/generale/b.MD", + "memory/generale/notes.txt", + "memory/generale/sous/", + "memory/generale/sous/page.md", + "memory/generale/z.md", + "memory/projet/", + "memory/projet/MEMORY.md", + "memory/reseau-nas-ca-n/", + "memory/reseau-nas-ca-n/MEMORY.md" + ] + } + }, + { + "name": "création sans lettres bis", + "method": "POST", + "body": "{\"action\":\"create\",\"name\":\"???\"}", + "status": 200, + "out": { + "active": "generale", + "ok": true, + "projects": [ + { + "active": true, + "convs": 2, + "created": 0, + "name": "Générale", + "pages": 3, + "slug": "generale", + "trackers": 1 + }, + { + "active": false, + "convs": 3, + "created": 0, + "name": "Alpha", + "pages": 0, + "slug": "alpha", + "trackers": 2 + }, + { + "active": false, + "convs": 0, + "created": 0, + "name": "Alpha", + "pages": 0, + "slug": "alpha-2", + "trackers": 0 + }, + { + "active": false, + "convs": 0, + "created": 0, + "name": "Réseau NAS — ça ñ", + "pages": 0, + "slug": "reseau-nas-ca-n", + "trackers": 0 + }, + { + "active": false, + "convs": 0, + "created": 0, + "name": "!!!", + "pages": 0, + "slug": "projet", + "trackers": 0 + }, + { + "active": false, + "convs": 0, + "created": 0, + "name": "???", + "pages": 0, + "slug": "projet-2", + "trackers": 0 + } + ] + }, + "after": { + "active": "generale", + "convs": [ + "a1:alpha", + "a2:alpha", + "a3:alpha", + "g1:generale", + "o1:", + "x1:ailleurs" + ], + "projects": [ + { + "created_at": 0, + "name": "Générale", + "slug": "generale" + }, + { + "created_at": 0, + "name": "Alpha", + "slug": "alpha" + }, + { + "created_at": 0, + "name": "Alpha", + "slug": "alpha-2" + }, + { + "created_at": 0, + "name": "Réseau NAS — ça ñ", + "slug": "reseau-nas-ca-n" + }, + { + "created_at": 0, + "name": "!!!", + "slug": "projet" + }, + { + "created_at": 0, + "name": "???", + "slug": "projet-2" + } + ], + "trackers": [ + "alpha/pas", + "alpha/sommeil", + "alphabet/x", + "generale/poids" + ], + "tree": [ + "memory/", + "memory/alpha-2/", + "memory/alpha-2/MEMORY.md", + "memory/alpha/", + "memory/alpha/MEMORY.md", + "memory/generale/", + "memory/generale/.cache.md", + "memory/generale/MEMORY.md", + "memory/generale/a.md", + "memory/generale/b.MD", + "memory/generale/notes.txt", + "memory/generale/sous/", + "memory/generale/sous/page.md", + "memory/generale/z.md", + "memory/projet-2/", + "memory/projet-2/MEMORY.md", + "memory/projet/", + "memory/projet/MEMORY.md", + "memory/reseau-nas-ca-n/", + "memory/reseau-nas-ca-n/MEMORY.md" + ] + } + }, + { + "name": "création longue", + "method": "POST", + "body": "{\"action\":\"create\",\"name\":\"Ab-Ab-Ab-Ab-Ab-Ab-Ab-Ab-Ab-Ab-Ab-Ab-Ab-Ab-Ab-Ab-Ab-Ab-Ab-Ab-Ab-Ab-Ab-Ab-Ab-Ab-Ab-Ab-Ab-Ab-\"}", + "status": 200, + "out": { + "active": "generale", + "ok": true, + "projects": [ + { + "active": true, + "convs": 2, + "created": 0, + "name": "Générale", + "pages": 3, + "slug": "generale", + "trackers": 1 + }, + { + "active": false, + "convs": 3, + "created": 0, + "name": "Alpha", + "pages": 0, + "slug": "alpha", + "trackers": 2 + }, + { + "active": false, + "convs": 0, + "created": 0, + "name": "Alpha", + "pages": 0, + "slug": "alpha-2", + "trackers": 0 + }, + { + "active": false, + "convs": 0, + "created": 0, + "name": "Réseau NAS — ça ñ", + "pages": 0, + "slug": "reseau-nas-ca-n", + "trackers": 0 + }, + { + "active": false, + "convs": 0, + "created": 0, + "name": "!!!", + "pages": 0, + "slug": "projet", + "trackers": 0 + }, + { + "active": false, + "convs": 0, + "created": 0, + "name": "???", + "pages": 0, + "slug": "projet-2", + "trackers": 0 + }, + { + "active": false, + "convs": 0, + "created": 0, + "name": "Ab-Ab-Ab-Ab-Ab-Ab-Ab-Ab-Ab-Ab-Ab-Ab-Ab-Ab-Ab-Ab-Ab-Ab-Ab-Ab-", + "pages": 0, + "slug": "ab-ab-ab-ab-ab-ab-ab-ab-ab-ab-ab-ab-ab-a", + "trackers": 0 + } + ] + }, + "after": { + "active": "generale", + "convs": [ + "a1:alpha", + "a2:alpha", + "a3:alpha", + "g1:generale", + "o1:", + "x1:ailleurs" + ], + "projects": [ + { + "created_at": 0, + "name": "Générale", + "slug": "generale" + }, + { + "created_at": 0, + "name": "Alpha", + "slug": "alpha" + }, + { + "created_at": 0, + "name": "Alpha", + "slug": "alpha-2" + }, + { + "created_at": 0, + "name": "Réseau NAS — ça ñ", + "slug": "reseau-nas-ca-n" + }, + { + "created_at": 0, + "name": "!!!", + "slug": "projet" + }, + { + "created_at": 0, + "name": "???", + "slug": "projet-2" + }, + { + "created_at": 0, + "name": "Ab-Ab-Ab-Ab-Ab-Ab-Ab-Ab-Ab-Ab-Ab-Ab-Ab-Ab-Ab-Ab-Ab-Ab-Ab-Ab-", + "slug": "ab-ab-ab-ab-ab-ab-ab-ab-ab-ab-ab-ab-ab-a" + } + ], + "trackers": [ + "alpha/pas", + "alpha/sommeil", + "alphabet/x", + "generale/poids" + ], + "tree": [ + "memory/", + "memory/ab-ab-ab-ab-ab-ab-ab-ab-ab-ab-ab-ab-ab-a/", + "memory/ab-ab-ab-ab-ab-ab-ab-ab-ab-ab-ab-ab-ab-a/MEMORY.md", + "memory/alpha-2/", + "memory/alpha-2/MEMORY.md", + "memory/alpha/", + "memory/alpha/MEMORY.md", + "memory/generale/", + "memory/generale/.cache.md", + "memory/generale/MEMORY.md", + "memory/generale/a.md", + "memory/generale/b.MD", + "memory/generale/notes.txt", + "memory/generale/sous/", + "memory/generale/sous/page.md", + "memory/generale/z.md", + "memory/projet-2/", + "memory/projet-2/MEMORY.md", + "memory/projet/", + "memory/projet/MEMORY.md", + "memory/reseau-nas-ca-n/", + "memory/reseau-nas-ca-n/MEMORY.md" + ] + } + }, + { + "name": "création très longue", + "method": "POST", + "body": "{\"action\":\"create\",\"name\":\"üüüüüüüüüüüüüüüüüüüüüüüüüüüüüüüüüüüüüüüüüüüüüüüüüüüüüüüüüüüüüüüüüüüüüü\"}", + "status": 200, + "out": { + "active": "generale", + "ok": true, + "projects": [ + { + "active": true, + "convs": 2, + "created": 0, + "name": "Générale", + "pages": 3, + "slug": "generale", + "trackers": 1 + }, + { + "active": false, + "convs": 3, + "created": 0, + "name": "Alpha", + "pages": 0, + "slug": "alpha", + "trackers": 2 + }, + { + "active": false, + "convs": 0, + "created": 0, + "name": "Alpha", + "pages": 0, + "slug": "alpha-2", + "trackers": 0 + }, + { + "active": false, + "convs": 0, + "created": 0, + "name": "Réseau NAS — ça ñ", + "pages": 0, + "slug": "reseau-nas-ca-n", + "trackers": 0 + }, + { + "active": false, + "convs": 0, + "created": 0, + "name": "!!!", + "pages": 0, + "slug": "projet", + "trackers": 0 + }, + { + "active": false, + "convs": 0, + "created": 0, + "name": "???", + "pages": 0, + "slug": "projet-2", + "trackers": 0 + }, + { + "active": false, + "convs": 0, + "created": 0, + "name": "Ab-Ab-Ab-Ab-Ab-Ab-Ab-Ab-Ab-Ab-Ab-Ab-Ab-Ab-Ab-Ab-Ab-Ab-Ab-Ab-", + "pages": 0, + "slug": "ab-ab-ab-ab-ab-ab-ab-ab-ab-ab-ab-ab-ab-a", + "trackers": 0 + }, + { + "active": false, + "convs": 0, + "created": 0, + "name": "üüüüüüüüüüüüüüüüüüüüüüüüüüüüüüüüüüüüüüüüüüüüüüüüüüüüüüüüüüüü", + "pages": 0, + "slug": "uuuuuuuuuuuuuuuuuuuuuuuuuuuuuuuuuuuuuuuu", + "trackers": 0 + } + ] + }, + "after": { + "active": "generale", + "convs": [ + "a1:alpha", + "a2:alpha", + "a3:alpha", + "g1:generale", + "o1:", + "x1:ailleurs" + ], + "projects": [ + { + "created_at": 0, + "name": "Générale", + "slug": "generale" + }, + { + "created_at": 0, + "name": "Alpha", + "slug": "alpha" + }, + { + "created_at": 0, + "name": "Alpha", + "slug": "alpha-2" + }, + { + "created_at": 0, + "name": "Réseau NAS — ça ñ", + "slug": "reseau-nas-ca-n" + }, + { + "created_at": 0, + "name": "!!!", + "slug": "projet" + }, + { + "created_at": 0, + "name": "???", + "slug": "projet-2" + }, + { + "created_at": 0, + "name": "Ab-Ab-Ab-Ab-Ab-Ab-Ab-Ab-Ab-Ab-Ab-Ab-Ab-Ab-Ab-Ab-Ab-Ab-Ab-Ab-", + "slug": "ab-ab-ab-ab-ab-ab-ab-ab-ab-ab-ab-ab-ab-a" + }, + { + "created_at": 0, + "name": "üüüüüüüüüüüüüüüüüüüüüüüüüüüüüüüüüüüüüüüüüüüüüüüüüüüüüüüüüüüü", + "slug": "uuuuuuuuuuuuuuuuuuuuuuuuuuuuuuuuuuuuuuuu" + } + ], + "trackers": [ + "alpha/pas", + "alpha/sommeil", + "alphabet/x", + "generale/poids" + ], + "tree": [ + "memory/", + "memory/ab-ab-ab-ab-ab-ab-ab-ab-ab-ab-ab-ab-ab-a/", + "memory/ab-ab-ab-ab-ab-ab-ab-ab-ab-ab-ab-ab-ab-a/MEMORY.md", + "memory/alpha-2/", + "memory/alpha-2/MEMORY.md", + "memory/alpha/", + "memory/alpha/MEMORY.md", + "memory/generale/", + "memory/generale/.cache.md", + "memory/generale/MEMORY.md", + "memory/generale/a.md", + "memory/generale/b.MD", + "memory/generale/notes.txt", + "memory/generale/sous/", + "memory/generale/sous/page.md", + "memory/generale/z.md", + "memory/projet-2/", + "memory/projet-2/MEMORY.md", + "memory/projet/", + "memory/projet/MEMORY.md", + "memory/reseau-nas-ca-n/", + "memory/reseau-nas-ca-n/MEMORY.md", + "memory/uuuuuuuuuuuuuuuuuuuuuuuuuuuuuuuuuuuuuuuu/", + "memory/uuuuuuuuuuuuuuuuuuuuuuuuuuuuuuuuuuuuuuuu/MEMORY.md" + ] + } + }, + { + "name": "création vide", + "method": "POST", + "body": "{\"action\":\"create\",\"name\":\" \"}", + "status": 400, + "out": { + "error": "nom vide", + "ok": false + }, + "after": { + "active": "generale", + "convs": [ + "a1:alpha", + "a2:alpha", + "a3:alpha", + "g1:generale", + "o1:", + "x1:ailleurs" + ], + "projects": [ + { + "created_at": 0, + "name": "Générale", + "slug": "generale" + }, + { + "created_at": 0, + "name": "Alpha", + "slug": "alpha" + }, + { + "created_at": 0, + "name": "Alpha", + "slug": "alpha-2" + }, + { + "created_at": 0, + "name": "Réseau NAS — ça ñ", + "slug": "reseau-nas-ca-n" + }, + { + "created_at": 0, + "name": "!!!", + "slug": "projet" + }, + { + "created_at": 0, + "name": "???", + "slug": "projet-2" + }, + { + "created_at": 0, + "name": "Ab-Ab-Ab-Ab-Ab-Ab-Ab-Ab-Ab-Ab-Ab-Ab-Ab-Ab-Ab-Ab-Ab-Ab-Ab-Ab-", + "slug": "ab-ab-ab-ab-ab-ab-ab-ab-ab-ab-ab-ab-ab-a" + }, + { + "created_at": 0, + "name": "üüüüüüüüüüüüüüüüüüüüüüüüüüüüüüüüüüüüüüüüüüüüüüüüüüüüüüüüüüüü", + "slug": "uuuuuuuuuuuuuuuuuuuuuuuuuuuuuuuuuuuuuuuu" + } + ], + "trackers": [ + "alpha/pas", + "alpha/sommeil", + "alphabet/x", + "generale/poids" + ], + "tree": [ + "memory/", + "memory/ab-ab-ab-ab-ab-ab-ab-ab-ab-ab-ab-ab-ab-a/", + "memory/ab-ab-ab-ab-ab-ab-ab-ab-ab-ab-ab-ab-ab-a/MEMORY.md", + "memory/alpha-2/", + "memory/alpha-2/MEMORY.md", + "memory/alpha/", + "memory/alpha/MEMORY.md", + "memory/generale/", + "memory/generale/.cache.md", + "memory/generale/MEMORY.md", + "memory/generale/a.md", + "memory/generale/b.MD", + "memory/generale/notes.txt", + "memory/generale/sous/", + "memory/generale/sous/page.md", + "memory/generale/z.md", + "memory/projet-2/", + "memory/projet-2/MEMORY.md", + "memory/projet/", + "memory/projet/MEMORY.md", + "memory/reseau-nas-ca-n/", + "memory/reseau-nas-ca-n/MEMORY.md", + "memory/uuuuuuuuuuuuuuuuuuuuuuuuuuuuuuuuuuuuuuuu/", + "memory/uuuuuuuuuuuuuuuuuuuuuuuuuuuuuuuuuuuuuuuu/MEMORY.md" + ] + } + }, + { + "name": "renommage", + "method": "POST", + "body": "{\"action\":\"rename\",\"slug\":\" alpha-2 \",\"name\":\" Bêta \"}", + "status": 200, + "out": { + "active": "generale", + "ok": true, + "projects": [ + { + "active": true, + "convs": 2, + "created": 0, + "name": "Générale", + "pages": 3, + "slug": "generale", + "trackers": 1 + }, + { + "active": false, + "convs": 3, + "created": 0, + "name": "Alpha", + "pages": 0, + "slug": "alpha", + "trackers": 2 + }, + { + "active": false, + "convs": 0, + "created": 0, + "name": "Bêta", + "pages": 0, + "slug": "alpha-2", + "trackers": 0 + }, + { + "active": false, + "convs": 0, + "created": 0, + "name": "Réseau NAS — ça ñ", + "pages": 0, + "slug": "reseau-nas-ca-n", + "trackers": 0 + }, + { + "active": false, + "convs": 0, + "created": 0, + "name": "!!!", + "pages": 0, + "slug": "projet", + "trackers": 0 + }, + { + "active": false, + "convs": 0, + "created": 0, + "name": "???", + "pages": 0, + "slug": "projet-2", + "trackers": 0 + }, + { + "active": false, + "convs": 0, + "created": 0, + "name": "Ab-Ab-Ab-Ab-Ab-Ab-Ab-Ab-Ab-Ab-Ab-Ab-Ab-Ab-Ab-Ab-Ab-Ab-Ab-Ab-", + "pages": 0, + "slug": "ab-ab-ab-ab-ab-ab-ab-ab-ab-ab-ab-ab-ab-a", + "trackers": 0 + }, + { + "active": false, + "convs": 0, + "created": 0, + "name": "üüüüüüüüüüüüüüüüüüüüüüüüüüüüüüüüüüüüüüüüüüüüüüüüüüüüüüüüüüüü", + "pages": 0, + "slug": "uuuuuuuuuuuuuuuuuuuuuuuuuuuuuuuuuuuuuuuu", + "trackers": 0 + } + ] + }, + "after": { + "active": "generale", + "convs": [ + "a1:alpha", + "a2:alpha", + "a3:alpha", + "g1:generale", + "o1:", + "x1:ailleurs" + ], + "projects": [ + { + "created_at": 0, + "name": "Générale", + "slug": "generale" + }, + { + "created_at": 0, + "name": "Alpha", + "slug": "alpha" + }, + { + "created_at": 0, + "name": "Bêta", + "slug": "alpha-2" + }, + { + "created_at": 0, + "name": "Réseau NAS — ça ñ", + "slug": "reseau-nas-ca-n" + }, + { + "created_at": 0, + "name": "!!!", + "slug": "projet" + }, + { + "created_at": 0, + "name": "???", + "slug": "projet-2" + }, + { + "created_at": 0, + "name": "Ab-Ab-Ab-Ab-Ab-Ab-Ab-Ab-Ab-Ab-Ab-Ab-Ab-Ab-Ab-Ab-Ab-Ab-Ab-Ab-", + "slug": "ab-ab-ab-ab-ab-ab-ab-ab-ab-ab-ab-ab-ab-a" + }, + { + "created_at": 0, + "name": "üüüüüüüüüüüüüüüüüüüüüüüüüüüüüüüüüüüüüüüüüüüüüüüüüüüüüüüüüüüü", + "slug": "uuuuuuuuuuuuuuuuuuuuuuuuuuuuuuuuuuuuuuuu" + } + ], + "trackers": [ + "alpha/pas", + "alpha/sommeil", + "alphabet/x", + "generale/poids" + ], + "tree": [ + "memory/", + "memory/ab-ab-ab-ab-ab-ab-ab-ab-ab-ab-ab-ab-ab-a/", + "memory/ab-ab-ab-ab-ab-ab-ab-ab-ab-ab-ab-ab-ab-a/MEMORY.md", + "memory/alpha-2/", + "memory/alpha-2/MEMORY.md", + "memory/alpha/", + "memory/alpha/MEMORY.md", + "memory/generale/", + "memory/generale/.cache.md", + "memory/generale/MEMORY.md", + "memory/generale/a.md", + "memory/generale/b.MD", + "memory/generale/notes.txt", + "memory/generale/sous/", + "memory/generale/sous/page.md", + "memory/generale/z.md", + "memory/projet-2/", + "memory/projet-2/MEMORY.md", + "memory/projet/", + "memory/projet/MEMORY.md", + "memory/reseau-nas-ca-n/", + "memory/reseau-nas-ca-n/MEMORY.md", + "memory/uuuuuuuuuuuuuuuuuuuuuuuuuuuuuuuuuuuuuuuu/", + "memory/uuuuuuuuuuuuuuuuuuuuuuuuuuuuuuuuuuuuuuuu/MEMORY.md" + ] + } + }, + { + "name": "renommage vide", + "method": "POST", + "body": "{\"action\":\"rename\",\"slug\":\"alpha-2\",\"name\":\"\"}", + "status": 400, + "out": { + "error": "nom vide", + "ok": false + }, + "after": { + "active": "generale", + "convs": [ + "a1:alpha", + "a2:alpha", + "a3:alpha", + "g1:generale", + "o1:", + "x1:ailleurs" + ], + "projects": [ + { + "created_at": 0, + "name": "Générale", + "slug": "generale" + }, + { + "created_at": 0, + "name": "Alpha", + "slug": "alpha" + }, + { + "created_at": 0, + "name": "Bêta", + "slug": "alpha-2" + }, + { + "created_at": 0, + "name": "Réseau NAS — ça ñ", + "slug": "reseau-nas-ca-n" + }, + { + "created_at": 0, + "name": "!!!", + "slug": "projet" + }, + { + "created_at": 0, + "name": "???", + "slug": "projet-2" + }, + { + "created_at": 0, + "name": "Ab-Ab-Ab-Ab-Ab-Ab-Ab-Ab-Ab-Ab-Ab-Ab-Ab-Ab-Ab-Ab-Ab-Ab-Ab-Ab-", + "slug": "ab-ab-ab-ab-ab-ab-ab-ab-ab-ab-ab-ab-ab-a" + }, + { + "created_at": 0, + "name": "üüüüüüüüüüüüüüüüüüüüüüüüüüüüüüüüüüüüüüüüüüüüüüüüüüüüüüüüüüüü", + "slug": "uuuuuuuuuuuuuuuuuuuuuuuuuuuuuuuuuuuuuuuu" + } + ], + "trackers": [ + "alpha/pas", + "alpha/sommeil", + "alphabet/x", + "generale/poids" + ], + "tree": [ + "memory/", + "memory/ab-ab-ab-ab-ab-ab-ab-ab-ab-ab-ab-ab-ab-a/", + "memory/ab-ab-ab-ab-ab-ab-ab-ab-ab-ab-ab-ab-ab-a/MEMORY.md", + "memory/alpha-2/", + "memory/alpha-2/MEMORY.md", + "memory/alpha/", + "memory/alpha/MEMORY.md", + "memory/generale/", + "memory/generale/.cache.md", + "memory/generale/MEMORY.md", + "memory/generale/a.md", + "memory/generale/b.MD", + "memory/generale/notes.txt", + "memory/generale/sous/", + "memory/generale/sous/page.md", + "memory/generale/z.md", + "memory/projet-2/", + "memory/projet-2/MEMORY.md", + "memory/projet/", + "memory/projet/MEMORY.md", + "memory/reseau-nas-ca-n/", + "memory/reseau-nas-ca-n/MEMORY.md", + "memory/uuuuuuuuuuuuuuuuuuuuuuuuuuuuuuuuuuuuuuuu/", + "memory/uuuuuuuuuuuuuuuuuuuuuuuuuuuuuuuuuuuuuuuu/MEMORY.md" + ] + } + }, + { + "name": "renommage inconnu", + "method": "POST", + "body": "{\"action\":\"rename\",\"slug\":\"nope\",\"name\":\"X\"}", + "status": 400, + "out": { + "error": "projet introuvable", + "ok": false + }, + "after": { + "active": "generale", + "convs": [ + "a1:alpha", + "a2:alpha", + "a3:alpha", + "g1:generale", + "o1:", + "x1:ailleurs" + ], + "projects": [ + { + "created_at": 0, + "name": "Générale", + "slug": "generale" + }, + { + "created_at": 0, + "name": "Alpha", + "slug": "alpha" + }, + { + "created_at": 0, + "name": "Bêta", + "slug": "alpha-2" + }, + { + "created_at": 0, + "name": "Réseau NAS — ça ñ", + "slug": "reseau-nas-ca-n" + }, + { + "created_at": 0, + "name": "!!!", + "slug": "projet" + }, + { + "created_at": 0, + "name": "???", + "slug": "projet-2" + }, + { + "created_at": 0, + "name": "Ab-Ab-Ab-Ab-Ab-Ab-Ab-Ab-Ab-Ab-Ab-Ab-Ab-Ab-Ab-Ab-Ab-Ab-Ab-Ab-", + "slug": "ab-ab-ab-ab-ab-ab-ab-ab-ab-ab-ab-ab-ab-a" + }, + { + "created_at": 0, + "name": "üüüüüüüüüüüüüüüüüüüüüüüüüüüüüüüüüüüüüüüüüüüüüüüüüüüüüüüüüüüü", + "slug": "uuuuuuuuuuuuuuuuuuuuuuuuuuuuuuuuuuuuuuuu" + } + ], + "trackers": [ + "alpha/pas", + "alpha/sommeil", + "alphabet/x", + "generale/poids" + ], + "tree": [ + "memory/", + "memory/ab-ab-ab-ab-ab-ab-ab-ab-ab-ab-ab-ab-ab-a/", + "memory/ab-ab-ab-ab-ab-ab-ab-ab-ab-ab-ab-ab-ab-a/MEMORY.md", + "memory/alpha-2/", + "memory/alpha-2/MEMORY.md", + "memory/alpha/", + "memory/alpha/MEMORY.md", + "memory/generale/", + "memory/generale/.cache.md", + "memory/generale/MEMORY.md", + "memory/generale/a.md", + "memory/generale/b.MD", + "memory/generale/notes.txt", + "memory/generale/sous/", + "memory/generale/sous/page.md", + "memory/generale/z.md", + "memory/projet-2/", + "memory/projet-2/MEMORY.md", + "memory/projet/", + "memory/projet/MEMORY.md", + "memory/reseau-nas-ca-n/", + "memory/reseau-nas-ca-n/MEMORY.md", + "memory/uuuuuuuuuuuuuuuuuuuuuuuuuuuuuuuuuuuuuuuu/", + "memory/uuuuuuuuuuuuuuuuuuuuuuuuuuuuuuuuuuuuuuuu/MEMORY.md" + ] + } + }, + { + "name": "description", + "method": "POST", + "body": "{\"action\":\"desc\",\"slug\":\"alpha\",\"desc\":\" Le projet Alpha. \"}", + "status": 200, + "out": { + "active": "generale", + "ok": true, + "projects": [ + { + "active": true, + "convs": 2, + "created": 0, + "name": "Générale", + "pages": 3, + "slug": "generale", + "trackers": 1 + }, + { + "active": false, + "convs": 3, + "created": 0, + "desc": "Le projet Alpha.", + "name": "Alpha", + "pages": 0, + "slug": "alpha", + "trackers": 2 + }, + { + "active": false, + "convs": 0, + "created": 0, + "name": "Bêta", + "pages": 0, + "slug": "alpha-2", + "trackers": 0 + }, + { + "active": false, + "convs": 0, + "created": 0, + "name": "Réseau NAS — ça ñ", + "pages": 0, + "slug": "reseau-nas-ca-n", + "trackers": 0 + }, + { + "active": false, + "convs": 0, + "created": 0, + "name": "!!!", + "pages": 0, + "slug": "projet", + "trackers": 0 + }, + { + "active": false, + "convs": 0, + "created": 0, + "name": "???", + "pages": 0, + "slug": "projet-2", + "trackers": 0 + }, + { + "active": false, + "convs": 0, + "created": 0, + "name": "Ab-Ab-Ab-Ab-Ab-Ab-Ab-Ab-Ab-Ab-Ab-Ab-Ab-Ab-Ab-Ab-Ab-Ab-Ab-Ab-", + "pages": 0, + "slug": "ab-ab-ab-ab-ab-ab-ab-ab-ab-ab-ab-ab-ab-a", + "trackers": 0 + }, + { + "active": false, + "convs": 0, + "created": 0, + "name": "üüüüüüüüüüüüüüüüüüüüüüüüüüüüüüüüüüüüüüüüüüüüüüüüüüüüüüüüüüüü", + "pages": 0, + "slug": "uuuuuuuuuuuuuuuuuuuuuuuuuuuuuuuuuuuuuuuu", + "trackers": 0 + } + ] + }, + "after": { + "active": "generale", + "convs": [ + "a1:alpha", + "a2:alpha", + "a3:alpha", + "g1:generale", + "o1:", + "x1:ailleurs" + ], + "projects": [ + { + "created_at": 0, + "name": "Générale", + "slug": "generale" + }, + { + "created_at": 0, + "desc": "Le projet Alpha.", + "name": "Alpha", + "slug": "alpha" + }, + { + "created_at": 0, + "name": "Bêta", + "slug": "alpha-2" + }, + { + "created_at": 0, + "name": "Réseau NAS — ça ñ", + "slug": "reseau-nas-ca-n" + }, + { + "created_at": 0, + "name": "!!!", + "slug": "projet" + }, + { + "created_at": 0, + "name": "???", + "slug": "projet-2" + }, + { + "created_at": 0, + "name": "Ab-Ab-Ab-Ab-Ab-Ab-Ab-Ab-Ab-Ab-Ab-Ab-Ab-Ab-Ab-Ab-Ab-Ab-Ab-Ab-", + "slug": "ab-ab-ab-ab-ab-ab-ab-ab-ab-ab-ab-ab-ab-a" + }, + { + "created_at": 0, + "name": "üüüüüüüüüüüüüüüüüüüüüüüüüüüüüüüüüüüüüüüüüüüüüüüüüüüüüüüüüüüü", + "slug": "uuuuuuuuuuuuuuuuuuuuuuuuuuuuuuuuuuuuuuuu" + } + ], + "trackers": [ + "alpha/pas", + "alpha/sommeil", + "alphabet/x", + "generale/poids" + ], + "tree": [ + "memory/", + "memory/ab-ab-ab-ab-ab-ab-ab-ab-ab-ab-ab-ab-ab-a/", + "memory/ab-ab-ab-ab-ab-ab-ab-ab-ab-ab-ab-ab-ab-a/MEMORY.md", + "memory/alpha-2/", + "memory/alpha-2/MEMORY.md", + "memory/alpha/", + "memory/alpha/MEMORY.md", + "memory/generale/", + "memory/generale/.cache.md", + "memory/generale/MEMORY.md", + "memory/generale/a.md", + "memory/generale/b.MD", + "memory/generale/notes.txt", + "memory/generale/sous/", + "memory/generale/sous/page.md", + "memory/generale/z.md", + "memory/projet-2/", + "memory/projet-2/MEMORY.md", + "memory/projet/", + "memory/projet/MEMORY.md", + "memory/reseau-nas-ca-n/", + "memory/reseau-nas-ca-n/MEMORY.md", + "memory/uuuuuuuuuuuuuuuuuuuuuuuuuuuuuuuuuuuuuuuu/", + "memory/uuuuuuuuuuuuuuuuuuuuuuuuuuuuuuuuuuuuuuuu/MEMORY.md" + ] + } + }, + { + "name": "description longue", + "method": "POST", + "body": "{\"action\":\"desc\",\"slug\":\"alpha-2\",\"desc\":\"éééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééé fin\"}", + "status": 200, + "out": { + "active": "generale", + "ok": true, + "projects": [ + { + "active": true, + "convs": 2, + "created": 0, + "name": "Générale", + "pages": 3, + "slug": "generale", + "trackers": 1 + }, + { + "active": false, + "convs": 3, + "created": 0, + "desc": "Le projet Alpha.", + "name": "Alpha", + "pages": 0, + "slug": "alpha", + "trackers": 2 + }, + { + "active": false, + "convs": 0, + "created": 0, + "desc": "éééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééé", + "name": "Bêta", + "pages": 0, + "slug": "alpha-2", + "trackers": 0 + }, + { + "active": false, + "convs": 0, + "created": 0, + "name": "Réseau NAS — ça ñ", + "pages": 0, + "slug": "reseau-nas-ca-n", + "trackers": 0 + }, + { + "active": false, + "convs": 0, + "created": 0, + "name": "!!!", + "pages": 0, + "slug": "projet", + "trackers": 0 + }, + { + "active": false, + "convs": 0, + "created": 0, + "name": "???", + "pages": 0, + "slug": "projet-2", + "trackers": 0 + }, + { + "active": false, + "convs": 0, + "created": 0, + "name": "Ab-Ab-Ab-Ab-Ab-Ab-Ab-Ab-Ab-Ab-Ab-Ab-Ab-Ab-Ab-Ab-Ab-Ab-Ab-Ab-", + "pages": 0, + "slug": "ab-ab-ab-ab-ab-ab-ab-ab-ab-ab-ab-ab-ab-a", + "trackers": 0 + }, + { + "active": false, + "convs": 0, + "created": 0, + "name": "üüüüüüüüüüüüüüüüüüüüüüüüüüüüüüüüüüüüüüüüüüüüüüüüüüüüüüüüüüüü", + "pages": 0, + "slug": "uuuuuuuuuuuuuuuuuuuuuuuuuuuuuuuuuuuuuuuu", + "trackers": 0 + } + ] + }, + "after": { + "active": "generale", + "convs": [ + "a1:alpha", + "a2:alpha", + "a3:alpha", + "g1:generale", + "o1:", + "x1:ailleurs" + ], + "projects": [ + { + "created_at": 0, + "name": "Générale", + "slug": "generale" + }, + { + "created_at": 0, + "desc": "Le projet Alpha.", + "name": "Alpha", + "slug": "alpha" + }, + { + "created_at": 0, + "desc": "éééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééé", + "name": "Bêta", + "slug": "alpha-2" + }, + { + "created_at": 0, + "name": "Réseau NAS — ça ñ", + "slug": "reseau-nas-ca-n" + }, + { + "created_at": 0, + "name": "!!!", + "slug": "projet" + }, + { + "created_at": 0, + "name": "???", + "slug": "projet-2" + }, + { + "created_at": 0, + "name": "Ab-Ab-Ab-Ab-Ab-Ab-Ab-Ab-Ab-Ab-Ab-Ab-Ab-Ab-Ab-Ab-Ab-Ab-Ab-Ab-", + "slug": "ab-ab-ab-ab-ab-ab-ab-ab-ab-ab-ab-ab-ab-a" + }, + { + "created_at": 0, + "name": "üüüüüüüüüüüüüüüüüüüüüüüüüüüüüüüüüüüüüüüüüüüüüüüüüüüüüüüüüüüü", + "slug": "uuuuuuuuuuuuuuuuuuuuuuuuuuuuuuuuuuuuuuuu" + } + ], + "trackers": [ + "alpha/pas", + "alpha/sommeil", + "alphabet/x", + "generale/poids" + ], + "tree": [ + "memory/", + "memory/ab-ab-ab-ab-ab-ab-ab-ab-ab-ab-ab-ab-ab-a/", + "memory/ab-ab-ab-ab-ab-ab-ab-ab-ab-ab-ab-ab-ab-a/MEMORY.md", + "memory/alpha-2/", + "memory/alpha-2/MEMORY.md", + "memory/alpha/", + "memory/alpha/MEMORY.md", + "memory/generale/", + "memory/generale/.cache.md", + "memory/generale/MEMORY.md", + "memory/generale/a.md", + "memory/generale/b.MD", + "memory/generale/notes.txt", + "memory/generale/sous/", + "memory/generale/sous/page.md", + "memory/generale/z.md", + "memory/projet-2/", + "memory/projet-2/MEMORY.md", + "memory/projet/", + "memory/projet/MEMORY.md", + "memory/reseau-nas-ca-n/", + "memory/reseau-nas-ca-n/MEMORY.md", + "memory/uuuuuuuuuuuuuuuuuuuuuuuuuuuuuuuuuuuuuuuu/", + "memory/uuuuuuuuuuuuuuuuuuuuuuuuuuuuuuuuuuuuuuuu/MEMORY.md" + ] + } + }, + { + "name": "description coupée sur une espace", + "method": "POST", + "body": "{\"action\":\"desc\",\"slug\":\"projet\",\"desc\":\"ééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééé suite\"}", + "status": 200, + "out": { + "active": "generale", + "ok": true, + "projects": [ + { + "active": true, + "convs": 2, + "created": 0, + "name": "Générale", + "pages": 3, + "slug": "generale", + "trackers": 1 + }, + { + "active": false, + "convs": 3, + "created": 0, + "desc": "Le projet Alpha.", + "name": "Alpha", + "pages": 0, + "slug": "alpha", + "trackers": 2 + }, + { + "active": false, + "convs": 0, + "created": 0, + "desc": "éééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééé", + "name": "Bêta", + "pages": 0, + "slug": "alpha-2", + "trackers": 0 + }, + { + "active": false, + "convs": 0, + "created": 0, + "name": "Réseau NAS — ça ñ", + "pages": 0, + "slug": "reseau-nas-ca-n", + "trackers": 0 + }, + { + "active": false, + "convs": 0, + "created": 0, + "desc": "ééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééé", + "name": "!!!", + "pages": 0, + "slug": "projet", + "trackers": 0 + }, + { + "active": false, + "convs": 0, + "created": 0, + "name": "???", + "pages": 0, + "slug": "projet-2", + "trackers": 0 + }, + { + "active": false, + "convs": 0, + "created": 0, + "name": "Ab-Ab-Ab-Ab-Ab-Ab-Ab-Ab-Ab-Ab-Ab-Ab-Ab-Ab-Ab-Ab-Ab-Ab-Ab-Ab-", + "pages": 0, + "slug": "ab-ab-ab-ab-ab-ab-ab-ab-ab-ab-ab-ab-ab-a", + "trackers": 0 + }, + { + "active": false, + "convs": 0, + "created": 0, + "name": "üüüüüüüüüüüüüüüüüüüüüüüüüüüüüüüüüüüüüüüüüüüüüüüüüüüüüüüüüüüü", + "pages": 0, + "slug": "uuuuuuuuuuuuuuuuuuuuuuuuuuuuuuuuuuuuuuuu", + "trackers": 0 + } + ] + }, + "after": { + "active": "generale", + "convs": [ + "a1:alpha", + "a2:alpha", + "a3:alpha", + "g1:generale", + "o1:", + "x1:ailleurs" + ], + "projects": [ + { + "created_at": 0, + "name": "Générale", + "slug": "generale" + }, + { + "created_at": 0, + "desc": "Le projet Alpha.", + "name": "Alpha", + "slug": "alpha" + }, + { + "created_at": 0, + "desc": "éééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééé", + "name": "Bêta", + "slug": "alpha-2" + }, + { + "created_at": 0, + "name": "Réseau NAS — ça ñ", + "slug": "reseau-nas-ca-n" + }, + { + "created_at": 0, + "desc": "ééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééé", + "name": "!!!", + "slug": "projet" + }, + { + "created_at": 0, + "name": "???", + "slug": "projet-2" + }, + { + "created_at": 0, + "name": "Ab-Ab-Ab-Ab-Ab-Ab-Ab-Ab-Ab-Ab-Ab-Ab-Ab-Ab-Ab-Ab-Ab-Ab-Ab-Ab-", + "slug": "ab-ab-ab-ab-ab-ab-ab-ab-ab-ab-ab-ab-ab-a" + }, + { + "created_at": 0, + "name": "üüüüüüüüüüüüüüüüüüüüüüüüüüüüüüüüüüüüüüüüüüüüüüüüüüüüüüüüüüüü", + "slug": "uuuuuuuuuuuuuuuuuuuuuuuuuuuuuuuuuuuuuuuu" + } + ], + "trackers": [ + "alpha/pas", + "alpha/sommeil", + "alphabet/x", + "generale/poids" + ], + "tree": [ + "memory/", + "memory/ab-ab-ab-ab-ab-ab-ab-ab-ab-ab-ab-ab-ab-a/", + "memory/ab-ab-ab-ab-ab-ab-ab-ab-ab-ab-ab-ab-ab-a/MEMORY.md", + "memory/alpha-2/", + "memory/alpha-2/MEMORY.md", + "memory/alpha/", + "memory/alpha/MEMORY.md", + "memory/generale/", + "memory/generale/.cache.md", + "memory/generale/MEMORY.md", + "memory/generale/a.md", + "memory/generale/b.MD", + "memory/generale/notes.txt", + "memory/generale/sous/", + "memory/generale/sous/page.md", + "memory/generale/z.md", + "memory/projet-2/", + "memory/projet-2/MEMORY.md", + "memory/projet/", + "memory/projet/MEMORY.md", + "memory/reseau-nas-ca-n/", + "memory/reseau-nas-ca-n/MEMORY.md", + "memory/uuuuuuuuuuuuuuuuuuuuuuuuuuuuuuuuuuuuuuuu/", + "memory/uuuuuuuuuuuuuuuuuuuuuuuuuuuuuuuuuuuuuuuu/MEMORY.md" + ] + } + }, + { + "name": "description effacée", + "method": "POST", + "body": "{\"action\":\"desc\",\"slug\":\"alpha-2\",\"desc\":\" \"}", + "status": 200, + "out": { + "active": "generale", + "ok": true, + "projects": [ + { + "active": true, + "convs": 2, + "created": 0, + "name": "Générale", + "pages": 3, + "slug": "generale", + "trackers": 1 + }, + { + "active": false, + "convs": 3, + "created": 0, + "desc": "Le projet Alpha.", + "name": "Alpha", + "pages": 0, + "slug": "alpha", + "trackers": 2 + }, + { + "active": false, + "convs": 0, + "created": 0, + "name": "Bêta", + "pages": 0, + "slug": "alpha-2", + "trackers": 0 + }, + { + "active": false, + "convs": 0, + "created": 0, + "name": "Réseau NAS — ça ñ", + "pages": 0, + "slug": "reseau-nas-ca-n", + "trackers": 0 + }, + { + "active": false, + "convs": 0, + "created": 0, + "desc": "ééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééé", + "name": "!!!", + "pages": 0, + "slug": "projet", + "trackers": 0 + }, + { + "active": false, + "convs": 0, + "created": 0, + "name": "???", + "pages": 0, + "slug": "projet-2", + "trackers": 0 + }, + { + "active": false, + "convs": 0, + "created": 0, + "name": "Ab-Ab-Ab-Ab-Ab-Ab-Ab-Ab-Ab-Ab-Ab-Ab-Ab-Ab-Ab-Ab-Ab-Ab-Ab-Ab-", + "pages": 0, + "slug": "ab-ab-ab-ab-ab-ab-ab-ab-ab-ab-ab-ab-ab-a", + "trackers": 0 + }, + { + "active": false, + "convs": 0, + "created": 0, + "name": "üüüüüüüüüüüüüüüüüüüüüüüüüüüüüüüüüüüüüüüüüüüüüüüüüüüüüüüüüüüü", + "pages": 0, + "slug": "uuuuuuuuuuuuuuuuuuuuuuuuuuuuuuuuuuuuuuuu", + "trackers": 0 + } + ] + }, + "after": { + "active": "generale", + "convs": [ + "a1:alpha", + "a2:alpha", + "a3:alpha", + "g1:generale", + "o1:", + "x1:ailleurs" + ], + "projects": [ + { + "created_at": 0, + "name": "Générale", + "slug": "generale" + }, + { + "created_at": 0, + "desc": "Le projet Alpha.", + "name": "Alpha", + "slug": "alpha" + }, + { + "created_at": 0, + "name": "Bêta", + "slug": "alpha-2" + }, + { + "created_at": 0, + "name": "Réseau NAS — ça ñ", + "slug": "reseau-nas-ca-n" + }, + { + "created_at": 0, + "desc": "ééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééé", + "name": "!!!", + "slug": "projet" + }, + { + "created_at": 0, + "name": "???", + "slug": "projet-2" + }, + { + "created_at": 0, + "name": "Ab-Ab-Ab-Ab-Ab-Ab-Ab-Ab-Ab-Ab-Ab-Ab-Ab-Ab-Ab-Ab-Ab-Ab-Ab-Ab-", + "slug": "ab-ab-ab-ab-ab-ab-ab-ab-ab-ab-ab-ab-ab-a" + }, + { + "created_at": 0, + "name": "üüüüüüüüüüüüüüüüüüüüüüüüüüüüüüüüüüüüüüüüüüüüüüüüüüüüüüüüüüüü", + "slug": "uuuuuuuuuuuuuuuuuuuuuuuuuuuuuuuuuuuuuuuu" + } + ], + "trackers": [ + "alpha/pas", + "alpha/sommeil", + "alphabet/x", + "generale/poids" + ], + "tree": [ + "memory/", + "memory/ab-ab-ab-ab-ab-ab-ab-ab-ab-ab-ab-ab-ab-a/", + "memory/ab-ab-ab-ab-ab-ab-ab-ab-ab-ab-ab-ab-ab-a/MEMORY.md", + "memory/alpha-2/", + "memory/alpha-2/MEMORY.md", + "memory/alpha/", + "memory/alpha/MEMORY.md", + "memory/generale/", + "memory/generale/.cache.md", + "memory/generale/MEMORY.md", + "memory/generale/a.md", + "memory/generale/b.MD", + "memory/generale/notes.txt", + "memory/generale/sous/", + "memory/generale/sous/page.md", + "memory/generale/z.md", + "memory/projet-2/", + "memory/projet-2/MEMORY.md", + "memory/projet/", + "memory/projet/MEMORY.md", + "memory/reseau-nas-ca-n/", + "memory/reseau-nas-ca-n/MEMORY.md", + "memory/uuuuuuuuuuuuuuuuuuuuuuuuuuuuuuuuuuuuuuuu/", + "memory/uuuuuuuuuuuuuuuuuuuuuuuuuuuuuuuuuuuuuuuu/MEMORY.md" + ] + } + }, + { + "name": "description inconnue", + "method": "POST", + "body": "{\"action\":\"desc\",\"slug\":\"nope\",\"desc\":\"x\"}", + "status": 400, + "out": { + "error": "projet introuvable", + "ok": false + }, + "after": { + "active": "generale", + "convs": [ + "a1:alpha", + "a2:alpha", + "a3:alpha", + "g1:generale", + "o1:", + "x1:ailleurs" + ], + "projects": [ + { + "created_at": 0, + "name": "Générale", + "slug": "generale" + }, + { + "created_at": 0, + "desc": "Le projet Alpha.", + "name": "Alpha", + "slug": "alpha" + }, + { + "created_at": 0, + "name": "Bêta", + "slug": "alpha-2" + }, + { + "created_at": 0, + "name": "Réseau NAS — ça ñ", + "slug": "reseau-nas-ca-n" + }, + { + "created_at": 0, + "desc": "ééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééé", + "name": "!!!", + "slug": "projet" + }, + { + "created_at": 0, + "name": "???", + "slug": "projet-2" + }, + { + "created_at": 0, + "name": "Ab-Ab-Ab-Ab-Ab-Ab-Ab-Ab-Ab-Ab-Ab-Ab-Ab-Ab-Ab-Ab-Ab-Ab-Ab-Ab-", + "slug": "ab-ab-ab-ab-ab-ab-ab-ab-ab-ab-ab-ab-ab-a" + }, + { + "created_at": 0, + "name": "üüüüüüüüüüüüüüüüüüüüüüüüüüüüüüüüüüüüüüüüüüüüüüüüüüüüüüüüüüüü", + "slug": "uuuuuuuuuuuuuuuuuuuuuuuuuuuuuuuuuuuuuuuu" + } + ], + "trackers": [ + "alpha/pas", + "alpha/sommeil", + "alphabet/x", + "generale/poids" + ], + "tree": [ + "memory/", + "memory/ab-ab-ab-ab-ab-ab-ab-ab-ab-ab-ab-ab-ab-a/", + "memory/ab-ab-ab-ab-ab-ab-ab-ab-ab-ab-ab-ab-ab-a/MEMORY.md", + "memory/alpha-2/", + "memory/alpha-2/MEMORY.md", + "memory/alpha/", + "memory/alpha/MEMORY.md", + "memory/generale/", + "memory/generale/.cache.md", + "memory/generale/MEMORY.md", + "memory/generale/a.md", + "memory/generale/b.MD", + "memory/generale/notes.txt", + "memory/generale/sous/", + "memory/generale/sous/page.md", + "memory/generale/z.md", + "memory/projet-2/", + "memory/projet-2/MEMORY.md", + "memory/projet/", + "memory/projet/MEMORY.md", + "memory/reseau-nas-ca-n/", + "memory/reseau-nas-ca-n/MEMORY.md", + "memory/uuuuuuuuuuuuuuuuuuuuuuuuuuuuuuuuuuuuuuuu/", + "memory/uuuuuuuuuuuuuuuuuuuuuuuuuuuuuuuuuuuuuuuu/MEMORY.md" + ] + } + }, + { + "name": "bascule", + "method": "POST", + "body": "{\"action\":\"switch\",\"slug\":\"alpha\"}", + "status": 200, + "out": { + "active": "alpha", + "ok": true, + "projects": [ + { + "active": false, + "convs": 2, + "created": 0, + "name": "Générale", + "pages": 3, + "slug": "generale", + "trackers": 1 + }, + { + "active": true, + "convs": 3, + "created": 0, + "desc": "Le projet Alpha.", + "name": "Alpha", + "pages": 0, + "slug": "alpha", + "trackers": 2 + }, + { + "active": false, + "convs": 0, + "created": 0, + "name": "Bêta", + "pages": 0, + "slug": "alpha-2", + "trackers": 0 + }, + { + "active": false, + "convs": 0, + "created": 0, + "name": "Réseau NAS — ça ñ", + "pages": 0, + "slug": "reseau-nas-ca-n", + "trackers": 0 + }, + { + "active": false, + "convs": 0, + "created": 0, + "desc": "ééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééé", + "name": "!!!", + "pages": 0, + "slug": "projet", + "trackers": 0 + }, + { + "active": false, + "convs": 0, + "created": 0, + "name": "???", + "pages": 0, + "slug": "projet-2", + "trackers": 0 + }, + { + "active": false, + "convs": 0, + "created": 0, + "name": "Ab-Ab-Ab-Ab-Ab-Ab-Ab-Ab-Ab-Ab-Ab-Ab-Ab-Ab-Ab-Ab-Ab-Ab-Ab-Ab-", + "pages": 0, + "slug": "ab-ab-ab-ab-ab-ab-ab-ab-ab-ab-ab-ab-ab-a", + "trackers": 0 + }, + { + "active": false, + "convs": 0, + "created": 0, + "name": "üüüüüüüüüüüüüüüüüüüüüüüüüüüüüüüüüüüüüüüüüüüüüüüüüüüüüüüüüüüü", + "pages": 0, + "slug": "uuuuuuuuuuuuuuuuuuuuuuuuuuuuuuuuuuuuuuuu", + "trackers": 0 + } + ] + }, + "after": { + "active": "alpha", + "convs": [ + "a1:alpha", + "a2:alpha", + "a3:alpha", + "g1:generale", + "o1:", + "x1:ailleurs" + ], + "projects": [ + { + "created_at": 0, + "name": "Générale", + "slug": "generale" + }, + { + "created_at": 0, + "desc": "Le projet Alpha.", + "name": "Alpha", + "slug": "alpha" + }, + { + "created_at": 0, + "name": "Bêta", + "slug": "alpha-2" + }, + { + "created_at": 0, + "name": "Réseau NAS — ça ñ", + "slug": "reseau-nas-ca-n" + }, + { + "created_at": 0, + "desc": "ééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééé", + "name": "!!!", + "slug": "projet" + }, + { + "created_at": 0, + "name": "???", + "slug": "projet-2" + }, + { + "created_at": 0, + "name": "Ab-Ab-Ab-Ab-Ab-Ab-Ab-Ab-Ab-Ab-Ab-Ab-Ab-Ab-Ab-Ab-Ab-Ab-Ab-Ab-", + "slug": "ab-ab-ab-ab-ab-ab-ab-ab-ab-ab-ab-ab-ab-a" + }, + { + "created_at": 0, + "name": "üüüüüüüüüüüüüüüüüüüüüüüüüüüüüüüüüüüüüüüüüüüüüüüüüüüüüüüüüüüü", + "slug": "uuuuuuuuuuuuuuuuuuuuuuuuuuuuuuuuuuuuuuuu" + } + ], + "trackers": [ + "alpha/pas", + "alpha/sommeil", + "alphabet/x", + "generale/poids" + ], + "tree": [ + "memory/", + "memory/ab-ab-ab-ab-ab-ab-ab-ab-ab-ab-ab-ab-ab-a/", + "memory/ab-ab-ab-ab-ab-ab-ab-ab-ab-ab-ab-ab-ab-a/MEMORY.md", + "memory/alpha-2/", + "memory/alpha-2/MEMORY.md", + "memory/alpha/", + "memory/alpha/MEMORY.md", + "memory/generale/", + "memory/generale/.cache.md", + "memory/generale/MEMORY.md", + "memory/generale/a.md", + "memory/generale/b.MD", + "memory/generale/notes.txt", + "memory/generale/sous/", + "memory/generale/sous/page.md", + "memory/generale/z.md", + "memory/projet-2/", + "memory/projet-2/MEMORY.md", + "memory/projet/", + "memory/projet/MEMORY.md", + "memory/reseau-nas-ca-n/", + "memory/reseau-nas-ca-n/MEMORY.md", + "memory/uuuuuuuuuuuuuuuuuuuuuuuuuuuuuuuuuuuuuuuu/", + "memory/uuuuuuuuuuuuuuuuuuuuuuuuuuuuuuuuuuuuuuuu/MEMORY.md" + ] + } + }, + { + "name": "bascule sur l'actif", + "method": "POST", + "body": "{\"action\":\"switch\",\"slug\":\"alpha\"}", + "status": 200, + "out": { + "active": "alpha", + "ok": true, + "projects": [ + { + "active": false, + "convs": 2, + "created": 0, + "name": "Générale", + "pages": 3, + "slug": "generale", + "trackers": 1 + }, + { + "active": true, + "convs": 3, + "created": 0, + "desc": "Le projet Alpha.", + "name": "Alpha", + "pages": 0, + "slug": "alpha", + "trackers": 2 + }, + { + "active": false, + "convs": 0, + "created": 0, + "name": "Bêta", + "pages": 0, + "slug": "alpha-2", + "trackers": 0 + }, + { + "active": false, + "convs": 0, + "created": 0, + "name": "Réseau NAS — ça ñ", + "pages": 0, + "slug": "reseau-nas-ca-n", + "trackers": 0 + }, + { + "active": false, + "convs": 0, + "created": 0, + "desc": "ééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééé", + "name": "!!!", + "pages": 0, + "slug": "projet", + "trackers": 0 + }, + { + "active": false, + "convs": 0, + "created": 0, + "name": "???", + "pages": 0, + "slug": "projet-2", + "trackers": 0 + }, + { + "active": false, + "convs": 0, + "created": 0, + "name": "Ab-Ab-Ab-Ab-Ab-Ab-Ab-Ab-Ab-Ab-Ab-Ab-Ab-Ab-Ab-Ab-Ab-Ab-Ab-Ab-", + "pages": 0, + "slug": "ab-ab-ab-ab-ab-ab-ab-ab-ab-ab-ab-ab-ab-a", + "trackers": 0 + }, + { + "active": false, + "convs": 0, + "created": 0, + "name": "üüüüüüüüüüüüüüüüüüüüüüüüüüüüüüüüüüüüüüüüüüüüüüüüüüüüüüüüüüüü", + "pages": 0, + "slug": "uuuuuuuuuuuuuuuuuuuuuuuuuuuuuuuuuuuuuuuu", + "trackers": 0 + } + ] + }, + "after": { + "active": "alpha", + "convs": [ + "a1:alpha", + "a2:alpha", + "a3:alpha", + "g1:generale", + "o1:", + "x1:ailleurs" + ], + "projects": [ + { + "created_at": 0, + "name": "Générale", + "slug": "generale" + }, + { + "created_at": 0, + "desc": "Le projet Alpha.", + "name": "Alpha", + "slug": "alpha" + }, + { + "created_at": 0, + "name": "Bêta", + "slug": "alpha-2" + }, + { + "created_at": 0, + "name": "Réseau NAS — ça ñ", + "slug": "reseau-nas-ca-n" + }, + { + "created_at": 0, + "desc": "ééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééé", + "name": "!!!", + "slug": "projet" + }, + { + "created_at": 0, + "name": "???", + "slug": "projet-2" + }, + { + "created_at": 0, + "name": "Ab-Ab-Ab-Ab-Ab-Ab-Ab-Ab-Ab-Ab-Ab-Ab-Ab-Ab-Ab-Ab-Ab-Ab-Ab-Ab-", + "slug": "ab-ab-ab-ab-ab-ab-ab-ab-ab-ab-ab-ab-ab-a" + }, + { + "created_at": 0, + "name": "üüüüüüüüüüüüüüüüüüüüüüüüüüüüüüüüüüüüüüüüüüüüüüüüüüüüüüüüüüüü", + "slug": "uuuuuuuuuuuuuuuuuuuuuuuuuuuuuuuuuuuuuuuu" + } + ], + "trackers": [ + "alpha/pas", + "alpha/sommeil", + "alphabet/x", + "generale/poids" + ], + "tree": [ + "memory/", + "memory/ab-ab-ab-ab-ab-ab-ab-ab-ab-ab-ab-ab-ab-a/", + "memory/ab-ab-ab-ab-ab-ab-ab-ab-ab-ab-ab-ab-ab-a/MEMORY.md", + "memory/alpha-2/", + "memory/alpha-2/MEMORY.md", + "memory/alpha/", + "memory/alpha/MEMORY.md", + "memory/generale/", + "memory/generale/.cache.md", + "memory/generale/MEMORY.md", + "memory/generale/a.md", + "memory/generale/b.MD", + "memory/generale/notes.txt", + "memory/generale/sous/", + "memory/generale/sous/page.md", + "memory/generale/z.md", + "memory/projet-2/", + "memory/projet-2/MEMORY.md", + "memory/projet/", + "memory/projet/MEMORY.md", + "memory/reseau-nas-ca-n/", + "memory/reseau-nas-ca-n/MEMORY.md", + "memory/uuuuuuuuuuuuuuuuuuuuuuuuuuuuuuuuuuuuuuuu/", + "memory/uuuuuuuuuuuuuuuuuuuuuuuuuuuuuuuuuuuuuuuu/MEMORY.md" + ] + } + }, + { + "name": "bascule inconnue", + "method": "POST", + "body": "{\"action\":\"switch\",\"slug\":\"nope\"}", + "status": 400, + "out": { + "error": "projet introuvable", + "ok": false + }, + "after": { + "active": "alpha", + "convs": [ + "a1:alpha", + "a2:alpha", + "a3:alpha", + "g1:generale", + "o1:", + "x1:ailleurs" + ], + "projects": [ + { + "created_at": 0, + "name": "Générale", + "slug": "generale" + }, + { + "created_at": 0, + "desc": "Le projet Alpha.", + "name": "Alpha", + "slug": "alpha" + }, + { + "created_at": 0, + "name": "Bêta", + "slug": "alpha-2" + }, + { + "created_at": 0, + "name": "Réseau NAS — ça ñ", + "slug": "reseau-nas-ca-n" + }, + { + "created_at": 0, + "desc": "ééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééé", + "name": "!!!", + "slug": "projet" + }, + { + "created_at": 0, + "name": "???", + "slug": "projet-2" + }, + { + "created_at": 0, + "name": "Ab-Ab-Ab-Ab-Ab-Ab-Ab-Ab-Ab-Ab-Ab-Ab-Ab-Ab-Ab-Ab-Ab-Ab-Ab-Ab-", + "slug": "ab-ab-ab-ab-ab-ab-ab-ab-ab-ab-ab-ab-ab-a" + }, + { + "created_at": 0, + "name": "üüüüüüüüüüüüüüüüüüüüüüüüüüüüüüüüüüüüüüüüüüüüüüüüüüüüüüüüüüüü", + "slug": "uuuuuuuuuuuuuuuuuuuuuuuuuuuuuuuuuuuuuuuu" + } + ], + "trackers": [ + "alpha/pas", + "alpha/sommeil", + "alphabet/x", + "generale/poids" + ], + "tree": [ + "memory/", + "memory/ab-ab-ab-ab-ab-ab-ab-ab-ab-ab-ab-ab-ab-a/", + "memory/ab-ab-ab-ab-ab-ab-ab-ab-ab-ab-ab-ab-ab-a/MEMORY.md", + "memory/alpha-2/", + "memory/alpha-2/MEMORY.md", + "memory/alpha/", + "memory/alpha/MEMORY.md", + "memory/generale/", + "memory/generale/.cache.md", + "memory/generale/MEMORY.md", + "memory/generale/a.md", + "memory/generale/b.MD", + "memory/generale/notes.txt", + "memory/generale/sous/", + "memory/generale/sous/page.md", + "memory/generale/z.md", + "memory/projet-2/", + "memory/projet-2/MEMORY.md", + "memory/projet/", + "memory/projet/MEMORY.md", + "memory/reseau-nas-ca-n/", + "memory/reseau-nas-ca-n/MEMORY.md", + "memory/uuuuuuuuuuuuuuuuuuuuuuuuuuuuuuuuuuuuuuuu/", + "memory/uuuuuuuuuuuuuuuuuuuuuuuuuuuuuuuuuuuuuuuu/MEMORY.md" + ] + } + }, + { + "name": "bascule pendant une génération", + "generating": true, + "method": "POST", + "body": "{\"action\":\"switch\",\"slug\":\"generale\"}", + "status": 409, + "out": { + "error": "une génération est en cours", + "ok": false + }, + "after": { + "active": "alpha", + "convs": [ + "a1:alpha", + "a2:alpha", + "a3:alpha", + "g1:generale", + "o1:", + "x1:ailleurs" + ], + "projects": [ + { + "created_at": 0, + "name": "Générale", + "slug": "generale" + }, + { + "created_at": 0, + "desc": "Le projet Alpha.", + "name": "Alpha", + "slug": "alpha" + }, + { + "created_at": 0, + "name": "Bêta", + "slug": "alpha-2" + }, + { + "created_at": 0, + "name": "Réseau NAS — ça ñ", + "slug": "reseau-nas-ca-n" + }, + { + "created_at": 0, + "desc": "ééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééé", + "name": "!!!", + "slug": "projet" + }, + { + "created_at": 0, + "name": "???", + "slug": "projet-2" + }, + { + "created_at": 0, + "name": "Ab-Ab-Ab-Ab-Ab-Ab-Ab-Ab-Ab-Ab-Ab-Ab-Ab-Ab-Ab-Ab-Ab-Ab-Ab-Ab-", + "slug": "ab-ab-ab-ab-ab-ab-ab-ab-ab-ab-ab-ab-ab-a" + }, + { + "created_at": 0, + "name": "üüüüüüüüüüüüüüüüüüüüüüüüüüüüüüüüüüüüüüüüüüüüüüüüüüüüüüüüüüüü", + "slug": "uuuuuuuuuuuuuuuuuuuuuuuuuuuuuuuuuuuuuuuu" + } + ], + "trackers": [ + "alpha/pas", + "alpha/sommeil", + "alphabet/x", + "generale/poids" + ], + "tree": [ + "memory/", + "memory/ab-ab-ab-ab-ab-ab-ab-ab-ab-ab-ab-ab-ab-a/", + "memory/ab-ab-ab-ab-ab-ab-ab-ab-ab-ab-ab-ab-ab-a/MEMORY.md", + "memory/alpha-2/", + "memory/alpha-2/MEMORY.md", + "memory/alpha/", + "memory/alpha/MEMORY.md", + "memory/generale/", + "memory/generale/.cache.md", + "memory/generale/MEMORY.md", + "memory/generale/a.md", + "memory/generale/b.MD", + "memory/generale/notes.txt", + "memory/generale/sous/", + "memory/generale/sous/page.md", + "memory/generale/z.md", + "memory/projet-2/", + "memory/projet-2/MEMORY.md", + "memory/projet/", + "memory/projet/MEMORY.md", + "memory/reseau-nas-ca-n/", + "memory/reseau-nas-ca-n/MEMORY.md", + "memory/uuuuuuuuuuuuuuuuuuuuuuuuuuuuuuuuuuuuuuuu/", + "memory/uuuuuuuuuuuuuuuuuuuuuuuuuuuuuuuuuuuuuuuu/MEMORY.md" + ] + } + }, + { + "name": "suppression pendant une génération", + "generating": true, + "method": "POST", + "body": "{\"action\":\"delete\",\"slug\":\"alpha\"}", + "status": 409, + "out": { + "error": "une génération est en cours", + "ok": false + }, + "after": { + "active": "alpha", + "convs": [ + "a1:alpha", + "a2:alpha", + "a3:alpha", + "g1:generale", + "o1:", + "x1:ailleurs" + ], + "projects": [ + { + "created_at": 0, + "name": "Générale", + "slug": "generale" + }, + { + "created_at": 0, + "desc": "Le projet Alpha.", + "name": "Alpha", + "slug": "alpha" + }, + { + "created_at": 0, + "name": "Bêta", + "slug": "alpha-2" + }, + { + "created_at": 0, + "name": "Réseau NAS — ça ñ", + "slug": "reseau-nas-ca-n" + }, + { + "created_at": 0, + "desc": "ééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééé", + "name": "!!!", + "slug": "projet" + }, + { + "created_at": 0, + "name": "???", + "slug": "projet-2" + }, + { + "created_at": 0, + "name": "Ab-Ab-Ab-Ab-Ab-Ab-Ab-Ab-Ab-Ab-Ab-Ab-Ab-Ab-Ab-Ab-Ab-Ab-Ab-Ab-", + "slug": "ab-ab-ab-ab-ab-ab-ab-ab-ab-ab-ab-ab-ab-a" + }, + { + "created_at": 0, + "name": "üüüüüüüüüüüüüüüüüüüüüüüüüüüüüüüüüüüüüüüüüüüüüüüüüüüüüüüüüüüü", + "slug": "uuuuuuuuuuuuuuuuuuuuuuuuuuuuuuuuuuuuuuuu" + } + ], + "trackers": [ + "alpha/pas", + "alpha/sommeil", + "alphabet/x", + "generale/poids" + ], + "tree": [ + "memory/", + "memory/ab-ab-ab-ab-ab-ab-ab-ab-ab-ab-ab-ab-ab-a/", + "memory/ab-ab-ab-ab-ab-ab-ab-ab-ab-ab-ab-ab-ab-a/MEMORY.md", + "memory/alpha-2/", + "memory/alpha-2/MEMORY.md", + "memory/alpha/", + "memory/alpha/MEMORY.md", + "memory/generale/", + "memory/generale/.cache.md", + "memory/generale/MEMORY.md", + "memory/generale/a.md", + "memory/generale/b.MD", + "memory/generale/notes.txt", + "memory/generale/sous/", + "memory/generale/sous/page.md", + "memory/generale/z.md", + "memory/projet-2/", + "memory/projet-2/MEMORY.md", + "memory/projet/", + "memory/projet/MEMORY.md", + "memory/reseau-nas-ca-n/", + "memory/reseau-nas-ca-n/MEMORY.md", + "memory/uuuuuuuuuuuuuuuuuuuuuuuuuuuuuuuuuuuuuuuu/", + "memory/uuuuuuuuuuuuuuuuuuuuuuuuuuuuuuuuuuuuuuuu/MEMORY.md" + ] + } + }, + { + "name": "suppression inconnue pendant une génération", + "generating": true, + "method": "POST", + "body": "{\"action\":\"delete\",\"slug\":\"nope\"}", + "status": 409, + "out": { + "error": "une génération est en cours", + "ok": false + }, + "after": { + "active": "alpha", + "convs": [ + "a1:alpha", + "a2:alpha", + "a3:alpha", + "g1:generale", + "o1:", + "x1:ailleurs" + ], + "projects": [ + { + "created_at": 0, + "name": "Générale", + "slug": "generale" + }, + { + "created_at": 0, + "desc": "Le projet Alpha.", + "name": "Alpha", + "slug": "alpha" + }, + { + "created_at": 0, + "name": "Bêta", + "slug": "alpha-2" + }, + { + "created_at": 0, + "name": "Réseau NAS — ça ñ", + "slug": "reseau-nas-ca-n" + }, + { + "created_at": 0, + "desc": "ééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééé", + "name": "!!!", + "slug": "projet" + }, + { + "created_at": 0, + "name": "???", + "slug": "projet-2" + }, + { + "created_at": 0, + "name": "Ab-Ab-Ab-Ab-Ab-Ab-Ab-Ab-Ab-Ab-Ab-Ab-Ab-Ab-Ab-Ab-Ab-Ab-Ab-Ab-", + "slug": "ab-ab-ab-ab-ab-ab-ab-ab-ab-ab-ab-ab-ab-a" + }, + { + "created_at": 0, + "name": "üüüüüüüüüüüüüüüüüüüüüüüüüüüüüüüüüüüüüüüüüüüüüüüüüüüüüüüüüüüü", + "slug": "uuuuuuuuuuuuuuuuuuuuuuuuuuuuuuuuuuuuuuuu" + } + ], + "trackers": [ + "alpha/pas", + "alpha/sommeil", + "alphabet/x", + "generale/poids" + ], + "tree": [ + "memory/", + "memory/ab-ab-ab-ab-ab-ab-ab-ab-ab-ab-ab-ab-ab-a/", + "memory/ab-ab-ab-ab-ab-ab-ab-ab-ab-ab-ab-ab-ab-a/MEMORY.md", + "memory/alpha-2/", + "memory/alpha-2/MEMORY.md", + "memory/alpha/", + "memory/alpha/MEMORY.md", + "memory/generale/", + "memory/generale/.cache.md", + "memory/generale/MEMORY.md", + "memory/generale/a.md", + "memory/generale/b.MD", + "memory/generale/notes.txt", + "memory/generale/sous/", + "memory/generale/sous/page.md", + "memory/generale/z.md", + "memory/projet-2/", + "memory/projet-2/MEMORY.md", + "memory/projet/", + "memory/projet/MEMORY.md", + "memory/reseau-nas-ca-n/", + "memory/reseau-nas-ca-n/MEMORY.md", + "memory/uuuuuuuuuuuuuuuuuuuuuuuuuuuuuuuuuuuuuuuu/", + "memory/uuuuuuuuuuuuuuuuuuuuuuuuuuuuuuuuuuuuuuuu/MEMORY.md" + ] + } + }, + { + "name": "bascule inconnue pendant une génération", + "generating": true, + "method": "POST", + "body": "{\"action\":\"switch\",\"slug\":\"nope\"}", + "status": 409, + "out": { + "error": "une génération est en cours", + "ok": false + }, + "after": { + "active": "alpha", + "convs": [ + "a1:alpha", + "a2:alpha", + "a3:alpha", + "g1:generale", + "o1:", + "x1:ailleurs" + ], + "projects": [ + { + "created_at": 0, + "name": "Générale", + "slug": "generale" + }, + { + "created_at": 0, + "desc": "Le projet Alpha.", + "name": "Alpha", + "slug": "alpha" + }, + { + "created_at": 0, + "name": "Bêta", + "slug": "alpha-2" + }, + { + "created_at": 0, + "name": "Réseau NAS — ça ñ", + "slug": "reseau-nas-ca-n" + }, + { + "created_at": 0, + "desc": "ééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééé", + "name": "!!!", + "slug": "projet" + }, + { + "created_at": 0, + "name": "???", + "slug": "projet-2" + }, + { + "created_at": 0, + "name": "Ab-Ab-Ab-Ab-Ab-Ab-Ab-Ab-Ab-Ab-Ab-Ab-Ab-Ab-Ab-Ab-Ab-Ab-Ab-Ab-", + "slug": "ab-ab-ab-ab-ab-ab-ab-ab-ab-ab-ab-ab-ab-a" + }, + { + "created_at": 0, + "name": "üüüüüüüüüüüüüüüüüüüüüüüüüüüüüüüüüüüüüüüüüüüüüüüüüüüüüüüüüüüü", + "slug": "uuuuuuuuuuuuuuuuuuuuuuuuuuuuuuuuuuuuuuuu" + } + ], + "trackers": [ + "alpha/pas", + "alpha/sommeil", + "alphabet/x", + "generale/poids" + ], + "tree": [ + "memory/", + "memory/ab-ab-ab-ab-ab-ab-ab-ab-ab-ab-ab-ab-ab-a/", + "memory/ab-ab-ab-ab-ab-ab-ab-ab-ab-ab-ab-ab-ab-a/MEMORY.md", + "memory/alpha-2/", + "memory/alpha-2/MEMORY.md", + "memory/alpha/", + "memory/alpha/MEMORY.md", + "memory/generale/", + "memory/generale/.cache.md", + "memory/generale/MEMORY.md", + "memory/generale/a.md", + "memory/generale/b.MD", + "memory/generale/notes.txt", + "memory/generale/sous/", + "memory/generale/sous/page.md", + "memory/generale/z.md", + "memory/projet-2/", + "memory/projet-2/MEMORY.md", + "memory/projet/", + "memory/projet/MEMORY.md", + "memory/reseau-nas-ca-n/", + "memory/reseau-nas-ca-n/MEMORY.md", + "memory/uuuuuuuuuuuuuuuuuuuuuuuuuuuuuuuuuuuuuuuu/", + "memory/uuuuuuuuuuuuuuuuuuuuuuuuuuuuuuuuuuuuuuuu/MEMORY.md" + ] + } + }, + { + "name": "suppression de l'actif", + "method": "POST", + "body": "{\"action\":\"delete\",\"slug\":\"alpha\"}", + "status": 200, + "out": { + "active": "generale", + "ok": true, + "projects": [ + { + "active": true, + "convs": 2, + "created": 0, + "name": "Générale", + "pages": 3, + "slug": "generale", + "trackers": 1 + }, + { + "active": false, + "convs": 0, + "created": 0, + "name": "Bêta", + "pages": 0, + "slug": "alpha-2", + "trackers": 0 + }, + { + "active": false, + "convs": 0, + "created": 0, + "name": "Réseau NAS — ça ñ", + "pages": 0, + "slug": "reseau-nas-ca-n", + "trackers": 0 + }, + { + "active": false, + "convs": 0, + "created": 0, + "desc": "ééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééé", + "name": "!!!", + "pages": 0, + "slug": "projet", + "trackers": 0 + }, + { + "active": false, + "convs": 0, + "created": 0, + "name": "???", + "pages": 0, + "slug": "projet-2", + "trackers": 0 + }, + { + "active": false, + "convs": 0, + "created": 0, + "name": "Ab-Ab-Ab-Ab-Ab-Ab-Ab-Ab-Ab-Ab-Ab-Ab-Ab-Ab-Ab-Ab-Ab-Ab-Ab-Ab-", + "pages": 0, + "slug": "ab-ab-ab-ab-ab-ab-ab-ab-ab-ab-ab-ab-ab-a", + "trackers": 0 + }, + { + "active": false, + "convs": 0, + "created": 0, + "name": "üüüüüüüüüüüüüüüüüüüüüüüüüüüüüüüüüüüüüüüüüüüüüüüüüüüüüüüüüüüü", + "pages": 0, + "slug": "uuuuuuuuuuuuuuuuuuuuuuuuuuuuuuuuuuuuuuuu", + "trackers": 0 + } + ] + }, + "after": { + "active": "generale", + "convs": [ + "g1:generale", + "o1:", + "x1:ailleurs" + ], + "projects": [ + { + "created_at": 0, + "name": "Générale", + "slug": "generale" + }, + { + "created_at": 0, + "name": "Bêta", + "slug": "alpha-2" + }, + { + "created_at": 0, + "name": "Réseau NAS — ça ñ", + "slug": "reseau-nas-ca-n" + }, + { + "created_at": 0, + "desc": "ééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééé", + "name": "!!!", + "slug": "projet" + }, + { + "created_at": 0, + "name": "???", + "slug": "projet-2" + }, + { + "created_at": 0, + "name": "Ab-Ab-Ab-Ab-Ab-Ab-Ab-Ab-Ab-Ab-Ab-Ab-Ab-Ab-Ab-Ab-Ab-Ab-Ab-Ab-", + "slug": "ab-ab-ab-ab-ab-ab-ab-ab-ab-ab-ab-ab-ab-a" + }, + { + "created_at": 0, + "name": "üüüüüüüüüüüüüüüüüüüüüüüüüüüüüüüüüüüüüüüüüüüüüüüüüüüüüüüüüüüü", + "slug": "uuuuuuuuuuuuuuuuuuuuuuuuuuuuuuuuuuuuuuuu" + } + ], + "trackers": [ + "alphabet/x", + "generale/poids" + ], + "tree": [ + "memory/", + "memory/ab-ab-ab-ab-ab-ab-ab-ab-ab-ab-ab-ab-ab-a/", + "memory/ab-ab-ab-ab-ab-ab-ab-ab-ab-ab-ab-ab-ab-a/MEMORY.md", + "memory/alpha-2/", + "memory/alpha-2/MEMORY.md", + "memory/generale/", + "memory/generale/.cache.md", + "memory/generale/MEMORY.md", + "memory/generale/a.md", + "memory/generale/b.MD", + "memory/generale/notes.txt", + "memory/generale/sous/", + "memory/generale/sous/page.md", + "memory/generale/z.md", + "memory/projet-2/", + "memory/projet-2/MEMORY.md", + "memory/projet/", + "memory/projet/MEMORY.md", + "memory/reseau-nas-ca-n/", + "memory/reseau-nas-ca-n/MEMORY.md", + "memory/uuuuuuuuuuuuuuuuuuuuuuuuuuuuuuuuuuuuuuuu/", + "memory/uuuuuuuuuuuuuuuuuuuuuuuuuuuuuuuuuuuuuuuu/MEMORY.md" + ] + } + }, + { + "name": "suppression inconnue", + "method": "POST", + "body": "{\"action\":\"delete\",\"slug\":\"nope\"}", + "status": 400, + "out": { + "error": "projet introuvable", + "ok": false + }, + "after": { + "active": "generale", + "convs": [ + "g1:generale", + "o1:", + "x1:ailleurs" + ], + "projects": [ + { + "created_at": 0, + "name": "Générale", + "slug": "generale" + }, + { + "created_at": 0, + "name": "Bêta", + "slug": "alpha-2" + }, + { + "created_at": 0, + "name": "Réseau NAS — ça ñ", + "slug": "reseau-nas-ca-n" + }, + { + "created_at": 0, + "desc": "ééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééé", + "name": "!!!", + "slug": "projet" + }, + { + "created_at": 0, + "name": "???", + "slug": "projet-2" + }, + { + "created_at": 0, + "name": "Ab-Ab-Ab-Ab-Ab-Ab-Ab-Ab-Ab-Ab-Ab-Ab-Ab-Ab-Ab-Ab-Ab-Ab-Ab-Ab-", + "slug": "ab-ab-ab-ab-ab-ab-ab-ab-ab-ab-ab-ab-ab-a" + }, + { + "created_at": 0, + "name": "üüüüüüüüüüüüüüüüüüüüüüüüüüüüüüüüüüüüüüüüüüüüüüüüüüüüüüüüüüüü", + "slug": "uuuuuuuuuuuuuuuuuuuuuuuuuuuuuuuuuuuuuuuu" + } + ], + "trackers": [ + "alphabet/x", + "generale/poids" + ], + "tree": [ + "memory/", + "memory/ab-ab-ab-ab-ab-ab-ab-ab-ab-ab-ab-ab-ab-a/", + "memory/ab-ab-ab-ab-ab-ab-ab-ab-ab-ab-ab-ab-ab-a/MEMORY.md", + "memory/alpha-2/", + "memory/alpha-2/MEMORY.md", + "memory/generale/", + "memory/generale/.cache.md", + "memory/generale/MEMORY.md", + "memory/generale/a.md", + "memory/generale/b.MD", + "memory/generale/notes.txt", + "memory/generale/sous/", + "memory/generale/sous/page.md", + "memory/generale/z.md", + "memory/projet-2/", + "memory/projet-2/MEMORY.md", + "memory/projet/", + "memory/projet/MEMORY.md", + "memory/reseau-nas-ca-n/", + "memory/reseau-nas-ca-n/MEMORY.md", + "memory/uuuuuuuuuuuuuuuuuuuuuuuuuuuuuuuuuuuuuuuu/", + "memory/uuuuuuuuuuuuuuuuuuuuuuuuuuuuuuuuuuuuuuuu/MEMORY.md" + ] + } + }, + { + "name": "suppression", + "method": "POST", + "body": "{\"action\":\"delete\",\"slug\":\"projet\"}", + "status": 200, + "out": { + "active": "generale", + "ok": true, + "projects": [ + { + "active": true, + "convs": 2, + "created": 0, + "name": "Générale", + "pages": 3, + "slug": "generale", + "trackers": 1 + }, + { + "active": false, + "convs": 0, + "created": 0, + "name": "Bêta", + "pages": 0, + "slug": "alpha-2", + "trackers": 0 + }, + { + "active": false, + "convs": 0, + "created": 0, + "name": "Réseau NAS — ça ñ", + "pages": 0, + "slug": "reseau-nas-ca-n", + "trackers": 0 + }, + { + "active": false, + "convs": 0, + "created": 0, + "name": "???", + "pages": 0, + "slug": "projet-2", + "trackers": 0 + }, + { + "active": false, + "convs": 0, + "created": 0, + "name": "Ab-Ab-Ab-Ab-Ab-Ab-Ab-Ab-Ab-Ab-Ab-Ab-Ab-Ab-Ab-Ab-Ab-Ab-Ab-Ab-", + "pages": 0, + "slug": "ab-ab-ab-ab-ab-ab-ab-ab-ab-ab-ab-ab-ab-a", + "trackers": 0 + }, + { + "active": false, + "convs": 0, + "created": 0, + "name": "üüüüüüüüüüüüüüüüüüüüüüüüüüüüüüüüüüüüüüüüüüüüüüüüüüüüüüüüüüüü", + "pages": 0, + "slug": "uuuuuuuuuuuuuuuuuuuuuuuuuuuuuuuuuuuuuuuu", + "trackers": 0 + } + ] + }, + "after": { + "active": "generale", + "convs": [ + "g1:generale", + "o1:", + "x1:ailleurs" + ], + "projects": [ + { + "created_at": 0, + "name": "Générale", + "slug": "generale" + }, + { + "created_at": 0, + "name": "Bêta", + "slug": "alpha-2" + }, + { + "created_at": 0, + "name": "Réseau NAS — ça ñ", + "slug": "reseau-nas-ca-n" + }, + { + "created_at": 0, + "name": "???", + "slug": "projet-2" + }, + { + "created_at": 0, + "name": "Ab-Ab-Ab-Ab-Ab-Ab-Ab-Ab-Ab-Ab-Ab-Ab-Ab-Ab-Ab-Ab-Ab-Ab-Ab-Ab-", + "slug": "ab-ab-ab-ab-ab-ab-ab-ab-ab-ab-ab-ab-ab-a" + }, + { + "created_at": 0, + "name": "üüüüüüüüüüüüüüüüüüüüüüüüüüüüüüüüüüüüüüüüüüüüüüüüüüüüüüüüüüüü", + "slug": "uuuuuuuuuuuuuuuuuuuuuuuuuuuuuuuuuuuuuuuu" + } + ], + "trackers": [ + "alphabet/x", + "generale/poids" + ], + "tree": [ + "memory/", + "memory/ab-ab-ab-ab-ab-ab-ab-ab-ab-ab-ab-ab-ab-a/", + "memory/ab-ab-ab-ab-ab-ab-ab-ab-ab-ab-ab-ab-ab-a/MEMORY.md", + "memory/alpha-2/", + "memory/alpha-2/MEMORY.md", + "memory/generale/", + "memory/generale/.cache.md", + "memory/generale/MEMORY.md", + "memory/generale/a.md", + "memory/generale/b.MD", + "memory/generale/notes.txt", + "memory/generale/sous/", + "memory/generale/sous/page.md", + "memory/generale/z.md", + "memory/projet-2/", + "memory/projet-2/MEMORY.md", + "memory/reseau-nas-ca-n/", + "memory/reseau-nas-ca-n/MEMORY.md", + "memory/uuuuuuuuuuuuuuuuuuuuuuuuuuuuuuuuuuuuuuuu/", + "memory/uuuuuuuuuuuuuuuuuuuuuuuuuuuuuuuuuuuuuuuu/MEMORY.md" + ] + } + }, + { + "name": "bascule vers un projet sans discussion", + "method": "POST", + "body": "{\"action\":\"switch\",\"slug\":\"projet-2\"}", + "status": 200, + "out": { + "active": "projet-2", + "ok": true, + "projects": [ + { + "active": false, + "convs": 2, + "created": 0, + "name": "Générale", + "pages": 3, + "slug": "generale", + "trackers": 1 + }, + { + "active": false, + "convs": 0, + "created": 0, + "name": "Bêta", + "pages": 0, + "slug": "alpha-2", + "trackers": 0 + }, + { + "active": false, + "convs": 0, + "created": 0, + "name": "Réseau NAS — ça ñ", + "pages": 0, + "slug": "reseau-nas-ca-n", + "trackers": 0 + }, + { + "active": true, + "convs": 1, + "created": 0, + "name": "???", + "pages": 0, + "slug": "projet-2", + "trackers": 0 + }, + { + "active": false, + "convs": 0, + "created": 0, + "name": "Ab-Ab-Ab-Ab-Ab-Ab-Ab-Ab-Ab-Ab-Ab-Ab-Ab-Ab-Ab-Ab-Ab-Ab-Ab-Ab-", + "pages": 0, + "slug": "ab-ab-ab-ab-ab-ab-ab-ab-ab-ab-ab-ab-ab-a", + "trackers": 0 + }, + { + "active": false, + "convs": 0, + "created": 0, + "name": "üüüüüüüüüüüüüüüüüüüüüüüüüüüüüüüüüüüüüüüüüüüüüüüüüüüüüüüüüüüü", + "pages": 0, + "slug": "uuuuuuuuuuuuuuuuuuuuuuuuuuuuuuuuuuuuuuuu", + "trackers": 0 + } + ] + }, + "after": { + "active": "projet-2", + "convs": [ + "g1:generale", + "o1:", + "x1:ailleurs", + "{{NEW}}:projet-2" + ], + "projects": [ + { + "created_at": 0, + "name": "Générale", + "slug": "generale" + }, + { + "created_at": 0, + "name": "Bêta", + "slug": "alpha-2" + }, + { + "created_at": 0, + "name": "Réseau NAS — ça ñ", + "slug": "reseau-nas-ca-n" + }, + { + "created_at": 0, + "name": "???", + "slug": "projet-2" + }, + { + "created_at": 0, + "name": "Ab-Ab-Ab-Ab-Ab-Ab-Ab-Ab-Ab-Ab-Ab-Ab-Ab-Ab-Ab-Ab-Ab-Ab-Ab-Ab-", + "slug": "ab-ab-ab-ab-ab-ab-ab-ab-ab-ab-ab-ab-ab-a" + }, + { + "created_at": 0, + "name": "üüüüüüüüüüüüüüüüüüüüüüüüüüüüüüüüüüüüüüüüüüüüüüüüüüüüüüüüüüüü", + "slug": "uuuuuuuuuuuuuuuuuuuuuuuuuuuuuuuuuuuuuuuu" + } + ], + "trackers": [ + "alphabet/x", + "generale/poids" + ], + "tree": [ + "memory/", + "memory/ab-ab-ab-ab-ab-ab-ab-ab-ab-ab-ab-ab-ab-a/", + "memory/ab-ab-ab-ab-ab-ab-ab-ab-ab-ab-ab-ab-ab-a/MEMORY.md", + "memory/alpha-2/", + "memory/alpha-2/MEMORY.md", + "memory/generale/", + "memory/generale/.cache.md", + "memory/generale/MEMORY.md", + "memory/generale/a.md", + "memory/generale/b.MD", + "memory/generale/notes.txt", + "memory/generale/sous/", + "memory/generale/sous/page.md", + "memory/generale/z.md", + "memory/projet-2/", + "memory/projet-2/MEMORY.md", + "memory/reseau-nas-ca-n/", + "memory/reseau-nas-ca-n/MEMORY.md", + "memory/uuuuuuuuuuuuuuuuuuuuuuuuuuuuuuuuuuuuuuuu/", + "memory/uuuuuuuuuuuuuuuuuuuuuuuuuuuuuuuuuuuuuuuu/MEMORY.md" + ] + } + }, + { + "name": "suppression bis", + "method": "POST", + "body": "{\"action\":\"delete\",\"slug\":\"alpha-2\"}", + "status": 200, + "out": { + "active": "projet-2", + "ok": true, + "projects": [ + { + "active": false, + "convs": 2, + "created": 0, + "name": "Générale", + "pages": 3, + "slug": "generale", + "trackers": 1 + }, + { + "active": false, + "convs": 0, + "created": 0, + "name": "Réseau NAS — ça ñ", + "pages": 0, + "slug": "reseau-nas-ca-n", + "trackers": 0 + }, + { + "active": true, + "convs": 1, + "created": 0, + "name": "???", + "pages": 0, + "slug": "projet-2", + "trackers": 0 + }, + { + "active": false, + "convs": 0, + "created": 0, + "name": "Ab-Ab-Ab-Ab-Ab-Ab-Ab-Ab-Ab-Ab-Ab-Ab-Ab-Ab-Ab-Ab-Ab-Ab-Ab-Ab-", + "pages": 0, + "slug": "ab-ab-ab-ab-ab-ab-ab-ab-ab-ab-ab-ab-ab-a", + "trackers": 0 + }, + { + "active": false, + "convs": 0, + "created": 0, + "name": "üüüüüüüüüüüüüüüüüüüüüüüüüüüüüüüüüüüüüüüüüüüüüüüüüüüüüüüüüüüü", + "pages": 0, + "slug": "uuuuuuuuuuuuuuuuuuuuuuuuuuuuuuuuuuuuuuuu", + "trackers": 0 + } + ] + }, + "after": { + "active": "projet-2", + "convs": [ + "g1:generale", + "o1:", + "x1:ailleurs", + "{{NEW}}:projet-2" + ], + "projects": [ + { + "created_at": 0, + "name": "Générale", + "slug": "generale" + }, + { + "created_at": 0, + "name": "Réseau NAS — ça ñ", + "slug": "reseau-nas-ca-n" + }, + { + "created_at": 0, + "name": "???", + "slug": "projet-2" + }, + { + "created_at": 0, + "name": "Ab-Ab-Ab-Ab-Ab-Ab-Ab-Ab-Ab-Ab-Ab-Ab-Ab-Ab-Ab-Ab-Ab-Ab-Ab-Ab-", + "slug": "ab-ab-ab-ab-ab-ab-ab-ab-ab-ab-ab-ab-ab-a" + }, + { + "created_at": 0, + "name": "üüüüüüüüüüüüüüüüüüüüüüüüüüüüüüüüüüüüüüüüüüüüüüüüüüüüüüüüüüüü", + "slug": "uuuuuuuuuuuuuuuuuuuuuuuuuuuuuuuuuuuuuuuu" + } + ], + "trackers": [ + "alphabet/x", + "generale/poids" + ], + "tree": [ + "memory/", + "memory/ab-ab-ab-ab-ab-ab-ab-ab-ab-ab-ab-ab-ab-a/", + "memory/ab-ab-ab-ab-ab-ab-ab-ab-ab-ab-ab-ab-ab-a/MEMORY.md", + "memory/generale/", + "memory/generale/.cache.md", + "memory/generale/MEMORY.md", + "memory/generale/a.md", + "memory/generale/b.MD", + "memory/generale/notes.txt", + "memory/generale/sous/", + "memory/generale/sous/page.md", + "memory/generale/z.md", + "memory/projet-2/", + "memory/projet-2/MEMORY.md", + "memory/reseau-nas-ca-n/", + "memory/reseau-nas-ca-n/MEMORY.md", + "memory/uuuuuuuuuuuuuuuuuuuuuuuuuuuuuuuuuuuuuuuu/", + "memory/uuuuuuuuuuuuuuuuuuuuuuuuuuuuuuuuuuuuuuuu/MEMORY.md" + ] + } + }, + { + "name": "suppression ter", + "method": "POST", + "body": "{\"action\":\"delete\",\"slug\":\"reseau-nas-ca-n\"}", + "status": 200, + "out": { + "active": "projet-2", + "ok": true, + "projects": [ + { + "active": false, + "convs": 2, + "created": 0, + "name": "Générale", + "pages": 3, + "slug": "generale", + "trackers": 1 + }, + { + "active": true, + "convs": 1, + "created": 0, + "name": "???", + "pages": 0, + "slug": "projet-2", + "trackers": 0 + }, + { + "active": false, + "convs": 0, + "created": 0, + "name": "Ab-Ab-Ab-Ab-Ab-Ab-Ab-Ab-Ab-Ab-Ab-Ab-Ab-Ab-Ab-Ab-Ab-Ab-Ab-Ab-", + "pages": 0, + "slug": "ab-ab-ab-ab-ab-ab-ab-ab-ab-ab-ab-ab-ab-a", + "trackers": 0 + }, + { + "active": false, + "convs": 0, + "created": 0, + "name": "üüüüüüüüüüüüüüüüüüüüüüüüüüüüüüüüüüüüüüüüüüüüüüüüüüüüüüüüüüüü", + "pages": 0, + "slug": "uuuuuuuuuuuuuuuuuuuuuuuuuuuuuuuuuuuuuuuu", + "trackers": 0 + } + ] + }, + "after": { + "active": "projet-2", + "convs": [ + "g1:generale", + "o1:", + "x1:ailleurs", + "{{NEW}}:projet-2" + ], + "projects": [ + { + "created_at": 0, + "name": "Générale", + "slug": "generale" + }, + { + "created_at": 0, + "name": "???", + "slug": "projet-2" + }, + { + "created_at": 0, + "name": "Ab-Ab-Ab-Ab-Ab-Ab-Ab-Ab-Ab-Ab-Ab-Ab-Ab-Ab-Ab-Ab-Ab-Ab-Ab-Ab-", + "slug": "ab-ab-ab-ab-ab-ab-ab-ab-ab-ab-ab-ab-ab-a" + }, + { + "created_at": 0, + "name": "üüüüüüüüüüüüüüüüüüüüüüüüüüüüüüüüüüüüüüüüüüüüüüüüüüüüüüüüüüüü", + "slug": "uuuuuuuuuuuuuuuuuuuuuuuuuuuuuuuuuuuuuuuu" + } + ], + "trackers": [ + "alphabet/x", + "generale/poids" + ], + "tree": [ + "memory/", + "memory/ab-ab-ab-ab-ab-ab-ab-ab-ab-ab-ab-ab-ab-a/", + "memory/ab-ab-ab-ab-ab-ab-ab-ab-ab-ab-ab-ab-ab-a/MEMORY.md", + "memory/generale/", + "memory/generale/.cache.md", + "memory/generale/MEMORY.md", + "memory/generale/a.md", + "memory/generale/b.MD", + "memory/generale/notes.txt", + "memory/generale/sous/", + "memory/generale/sous/page.md", + "memory/generale/z.md", + "memory/projet-2/", + "memory/projet-2/MEMORY.md", + "memory/uuuuuuuuuuuuuuuuuuuuuuuuuuuuuuuuuuuuuuuu/", + "memory/uuuuuuuuuuuuuuuuuuuuuuuuuuuuuuuuuuuuuuuu/MEMORY.md" + ] + } + }, + { + "name": "liste après", + "method": "GET", + "status": 200, + "out": { + "active": "projet-2", + "ok": true, + "projects": [ + { + "active": false, + "convs": 2, + "created": 0, + "name": "Générale", + "pages": 3, + "slug": "generale", + "trackers": 1 + }, + { + "active": true, + "convs": 1, + "created": 0, + "name": "???", + "pages": 0, + "slug": "projet-2", + "trackers": 0 + }, + { + "active": false, + "convs": 0, + "created": 0, + "name": "Ab-Ab-Ab-Ab-Ab-Ab-Ab-Ab-Ab-Ab-Ab-Ab-Ab-Ab-Ab-Ab-Ab-Ab-Ab-Ab-", + "pages": 0, + "slug": "ab-ab-ab-ab-ab-ab-ab-ab-ab-ab-ab-ab-ab-a", + "trackers": 0 + }, + { + "active": false, + "convs": 0, + "created": 0, + "name": "üüüüüüüüüüüüüüüüüüüüüüüüüüüüüüüüüüüüüüüüüüüüüüüüüüüüüüüüüüüü", + "pages": 0, + "slug": "uuuuuuuuuuuuuuuuuuuuuuuuuuuuuuuuuuuuuuuu", + "trackers": 0 + } + ] + }, + "after": { + "active": "projet-2", + "convs": [ + "g1:generale", + "o1:", + "x1:ailleurs", + "{{NEW}}:projet-2" + ], + "projects": [ + { + "created_at": 0, + "name": "Générale", + "slug": "generale" + }, + { + "created_at": 0, + "name": "???", + "slug": "projet-2" + }, + { + "created_at": 0, + "name": "Ab-Ab-Ab-Ab-Ab-Ab-Ab-Ab-Ab-Ab-Ab-Ab-Ab-Ab-Ab-Ab-Ab-Ab-Ab-Ab-", + "slug": "ab-ab-ab-ab-ab-ab-ab-ab-ab-ab-ab-ab-ab-a" + }, + { + "created_at": 0, + "name": "üüüüüüüüüüüüüüüüüüüüüüüüüüüüüüüüüüüüüüüüüüüüüüüüüüüüüüüüüüüü", + "slug": "uuuuuuuuuuuuuuuuuuuuuuuuuuuuuuuuuuuuuuuu" + } + ], + "trackers": [ + "alphabet/x", + "generale/poids" + ], + "tree": [ + "memory/", + "memory/ab-ab-ab-ab-ab-ab-ab-ab-ab-ab-ab-ab-ab-a/", + "memory/ab-ab-ab-ab-ab-ab-ab-ab-ab-ab-ab-ab-ab-a/MEMORY.md", + "memory/generale/", + "memory/generale/.cache.md", + "memory/generale/MEMORY.md", + "memory/generale/a.md", + "memory/generale/b.MD", + "memory/generale/notes.txt", + "memory/generale/sous/", + "memory/generale/sous/page.md", + "memory/generale/z.md", + "memory/projet-2/", + "memory/projet-2/MEMORY.md", + "memory/uuuuuuuuuuuuuuuuuuuuuuuuuuuuuuuuuuuuuuuu/", + "memory/uuuuuuuuuuuuuuuuuuuuuuuuuuuuuuuuuuuuuuuu/MEMORY.md" + ] + } + }, + { + "name": "action inconnue", + "method": "POST", + "body": "{\"action\":\"Nuke\",\"slug\":\"generale\"}", + "status": 400, + "out": { + "error": "action inconnue : \"Nuke\"", + "ok": false + }, + "after": { + "active": "projet-2", + "convs": [ + "g1:generale", + "o1:", + "x1:ailleurs", + "{{NEW}}:projet-2" + ], + "projects": [ + { + "created_at": 0, + "name": "Générale", + "slug": "generale" + }, + { + "created_at": 0, + "name": "???", + "slug": "projet-2" + }, + { + "created_at": 0, + "name": "Ab-Ab-Ab-Ab-Ab-Ab-Ab-Ab-Ab-Ab-Ab-Ab-Ab-Ab-Ab-Ab-Ab-Ab-Ab-Ab-", + "slug": "ab-ab-ab-ab-ab-ab-ab-ab-ab-ab-ab-ab-ab-a" + }, + { + "created_at": 0, + "name": "üüüüüüüüüüüüüüüüüüüüüüüüüüüüüüüüüüüüüüüüüüüüüüüüüüüüüüüüüüüü", + "slug": "uuuuuuuuuuuuuuuuuuuuuuuuuuuuuuuuuuuuuuuu" + } + ], + "trackers": [ + "alphabet/x", + "generale/poids" + ], + "tree": [ + "memory/", + "memory/ab-ab-ab-ab-ab-ab-ab-ab-ab-ab-ab-ab-ab-a/", + "memory/ab-ab-ab-ab-ab-ab-ab-ab-ab-ab-ab-ab-ab-a/MEMORY.md", + "memory/generale/", + "memory/generale/.cache.md", + "memory/generale/MEMORY.md", + "memory/generale/a.md", + "memory/generale/b.MD", + "memory/generale/notes.txt", + "memory/generale/sous/", + "memory/generale/sous/page.md", + "memory/generale/z.md", + "memory/projet-2/", + "memory/projet-2/MEMORY.md", + "memory/uuuuuuuuuuuuuuuuuuuuuuuuuuuuuuuuuuuuuuuu/", + "memory/uuuuuuuuuuuuuuuuuuuuuuuuuuuuuuuuuuuuuuuu/MEMORY.md" + ] + } + }, + { + "name": "action vide", + "method": "POST", + "body": "{}", + "status": 400, + "out": { + "error": "action inconnue : \"\"", + "ok": false + }, + "after": { + "active": "projet-2", + "convs": [ + "g1:generale", + "o1:", + "x1:ailleurs", + "{{NEW}}:projet-2" + ], + "projects": [ + { + "created_at": 0, + "name": "Générale", + "slug": "generale" + }, + { + "created_at": 0, + "name": "???", + "slug": "projet-2" + }, + { + "created_at": 0, + "name": "Ab-Ab-Ab-Ab-Ab-Ab-Ab-Ab-Ab-Ab-Ab-Ab-Ab-Ab-Ab-Ab-Ab-Ab-Ab-Ab-", + "slug": "ab-ab-ab-ab-ab-ab-ab-ab-ab-ab-ab-ab-ab-a" + }, + { + "created_at": 0, + "name": "üüüüüüüüüüüüüüüüüüüüüüüüüüüüüüüüüüüüüüüüüüüüüüüüüüüüüüüüüüüü", + "slug": "uuuuuuuuuuuuuuuuuuuuuuuuuuuuuuuuuuuuuuuu" + } + ], + "trackers": [ + "alphabet/x", + "generale/poids" + ], + "tree": [ + "memory/", + "memory/ab-ab-ab-ab-ab-ab-ab-ab-ab-ab-ab-ab-ab-a/", + "memory/ab-ab-ab-ab-ab-ab-ab-ab-ab-ab-ab-ab-ab-a/MEMORY.md", + "memory/generale/", + "memory/generale/.cache.md", + "memory/generale/MEMORY.md", + "memory/generale/a.md", + "memory/generale/b.MD", + "memory/generale/notes.txt", + "memory/generale/sous/", + "memory/generale/sous/page.md", + "memory/generale/z.md", + "memory/projet-2/", + "memory/projet-2/MEMORY.md", + "memory/uuuuuuuuuuuuuuuuuuuuuuuuuuuuuuuuuuuuuuuu/", + "memory/uuuuuuuuuuuuuuuuuuuuuuuuuuuuuuuuuuuuuuuu/MEMORY.md" + ] + } + }, + { + "name": "corps vide", + "method": "POST", + "status": 400, + "out": { + "error": "EOF", + "ok": false + }, + "after": { + "active": "projet-2", + "convs": [ + "g1:generale", + "o1:", + "x1:ailleurs", + "{{NEW}}:projet-2" + ], + "projects": [ + { + "created_at": 0, + "name": "Générale", + "slug": "generale" + }, + { + "created_at": 0, + "name": "???", + "slug": "projet-2" + }, + { + "created_at": 0, + "name": "Ab-Ab-Ab-Ab-Ab-Ab-Ab-Ab-Ab-Ab-Ab-Ab-Ab-Ab-Ab-Ab-Ab-Ab-Ab-Ab-", + "slug": "ab-ab-ab-ab-ab-ab-ab-ab-ab-ab-ab-ab-ab-a" + }, + { + "created_at": 0, + "name": "üüüüüüüüüüüüüüüüüüüüüüüüüüüüüüüüüüüüüüüüüüüüüüüüüüüüüüüüüüüü", + "slug": "uuuuuuuuuuuuuuuuuuuuuuuuuuuuuuuuuuuuuuuu" + } + ], + "trackers": [ + "alphabet/x", + "generale/poids" + ], + "tree": [ + "memory/", + "memory/ab-ab-ab-ab-ab-ab-ab-ab-ab-ab-ab-ab-ab-a/", + "memory/ab-ab-ab-ab-ab-ab-ab-ab-ab-ab-ab-ab-ab-a/MEMORY.md", + "memory/generale/", + "memory/generale/.cache.md", + "memory/generale/MEMORY.md", + "memory/generale/a.md", + "memory/generale/b.MD", + "memory/generale/notes.txt", + "memory/generale/sous/", + "memory/generale/sous/page.md", + "memory/generale/z.md", + "memory/projet-2/", + "memory/projet-2/MEMORY.md", + "memory/uuuuuuuuuuuuuuuuuuuuuuuuuuuuuuuuuuuuuuuu/", + "memory/uuuuuuuuuuuuuuuuuuuuuuuuuuuuuuuuuuuuuuuu/MEMORY.md" + ] + } + }, + { + "name": "corps illisible", + "method": "POST", + "body": "{\"action\":\"create\"", + "status": 400, + "out": { + "error": "unexpected EOF", + "ok": false + }, + "after": { + "active": "projet-2", + "convs": [ + "g1:generale", + "o1:", + "x1:ailleurs", + "{{NEW}}:projet-2" + ], + "projects": [ + { + "created_at": 0, + "name": "Générale", + "slug": "generale" + }, + { + "created_at": 0, + "name": "???", + "slug": "projet-2" + }, + { + "created_at": 0, + "name": "Ab-Ab-Ab-Ab-Ab-Ab-Ab-Ab-Ab-Ab-Ab-Ab-Ab-Ab-Ab-Ab-Ab-Ab-Ab-Ab-", + "slug": "ab-ab-ab-ab-ab-ab-ab-ab-ab-ab-ab-ab-ab-a" + }, + { + "created_at": 0, + "name": "üüüüüüüüüüüüüüüüüüüüüüüüüüüüüüüüüüüüüüüüüüüüüüüüüüüüüüüüüüüü", + "slug": "uuuuuuuuuuuuuuuuuuuuuuuuuuuuuuuuuuuuuuuu" + } + ], + "trackers": [ + "alphabet/x", + "generale/poids" + ], + "tree": [ + "memory/", + "memory/ab-ab-ab-ab-ab-ab-ab-ab-ab-ab-ab-ab-ab-a/", + "memory/ab-ab-ab-ab-ab-ab-ab-ab-ab-ab-ab-ab-ab-a/MEMORY.md", + "memory/generale/", + "memory/generale/.cache.md", + "memory/generale/MEMORY.md", + "memory/generale/a.md", + "memory/generale/b.MD", + "memory/generale/notes.txt", + "memory/generale/sous/", + "memory/generale/sous/page.md", + "memory/generale/z.md", + "memory/projet-2/", + "memory/projet-2/MEMORY.md", + "memory/uuuuuuuuuuuuuuuuuuuuuuuuuuuuuuuuuuuuuuuu/", + "memory/uuuuuuuuuuuuuuuuuuuuuuuuuuuuuuuuuuuuuuuu/MEMORY.md" + ] + } + }, + { + "name": "type faux", + "method": "POST", + "body": "{\"action\":\"create\",\"name\":7}", + "status": 400, + "out": { + "error": "json: cannot unmarshal number into Go struct field .name of type string", + "ok": false + }, + "after": { + "active": "projet-2", + "convs": [ + "g1:generale", + "o1:", + "x1:ailleurs", + "{{NEW}}:projet-2" + ], + "projects": [ + { + "created_at": 0, + "name": "Générale", + "slug": "generale" + }, + { + "created_at": 0, + "name": "???", + "slug": "projet-2" + }, + { + "created_at": 0, + "name": "Ab-Ab-Ab-Ab-Ab-Ab-Ab-Ab-Ab-Ab-Ab-Ab-Ab-Ab-Ab-Ab-Ab-Ab-Ab-Ab-", + "slug": "ab-ab-ab-ab-ab-ab-ab-ab-ab-ab-ab-ab-ab-a" + }, + { + "created_at": 0, + "name": "üüüüüüüüüüüüüüüüüüüüüüüüüüüüüüüüüüüüüüüüüüüüüüüüüüüüüüüüüüüü", + "slug": "uuuuuuuuuuuuuuuuuuuuuuuuuuuuuuuuuuuuuuuu" + } + ], + "trackers": [ + "alphabet/x", + "generale/poids" + ], + "tree": [ + "memory/", + "memory/ab-ab-ab-ab-ab-ab-ab-ab-ab-ab-ab-ab-ab-a/", + "memory/ab-ab-ab-ab-ab-ab-ab-ab-ab-ab-ab-ab-ab-a/MEMORY.md", + "memory/generale/", + "memory/generale/.cache.md", + "memory/generale/MEMORY.md", + "memory/generale/a.md", + "memory/generale/b.MD", + "memory/generale/notes.txt", + "memory/generale/sous/", + "memory/generale/sous/page.md", + "memory/generale/z.md", + "memory/projet-2/", + "memory/projet-2/MEMORY.md", + "memory/uuuuuuuuuuuuuuuuuuuuuuuuuuuuuuuuuuuuuuuu/", + "memory/uuuuuuuuuuuuuuuuuuuuuuuuuuuuuuuuuuuuuuuu/MEMORY.md" + ] + } + }, + { + "name": "méthode refusée", + "method": "PUT", + "body": "{}", + "status": 405, + "out": { + "error": "méthode non autorisée", + "ok": false + }, + "after": { + "active": "projet-2", + "convs": [ + "g1:generale", + "o1:", + "x1:ailleurs", + "{{NEW}}:projet-2" + ], + "projects": [ + { + "created_at": 0, + "name": "Générale", + "slug": "generale" + }, + { + "created_at": 0, + "name": "???", + "slug": "projet-2" + }, + { + "created_at": 0, + "name": "Ab-Ab-Ab-Ab-Ab-Ab-Ab-Ab-Ab-Ab-Ab-Ab-Ab-Ab-Ab-Ab-Ab-Ab-Ab-Ab-", + "slug": "ab-ab-ab-ab-ab-ab-ab-ab-ab-ab-ab-ab-ab-a" + }, + { + "created_at": 0, + "name": "üüüüüüüüüüüüüüüüüüüüüüüüüüüüüüüüüüüüüüüüüüüüüüüüüüüüüüüüüüüü", + "slug": "uuuuuuuuuuuuuuuuuuuuuuuuuuuuuuuuuuuuuuuu" + } + ], + "trackers": [ + "alphabet/x", + "generale/poids" + ], + "tree": [ + "memory/", + "memory/ab-ab-ab-ab-ab-ab-ab-ab-ab-ab-ab-ab-ab-a/", + "memory/ab-ab-ab-ab-ab-ab-ab-ab-ab-ab-ab-ab-ab-a/MEMORY.md", + "memory/generale/", + "memory/generale/.cache.md", + "memory/generale/MEMORY.md", + "memory/generale/a.md", + "memory/generale/b.MD", + "memory/generale/notes.txt", + "memory/generale/sous/", + "memory/generale/sous/page.md", + "memory/generale/z.md", + "memory/projet-2/", + "memory/projet-2/MEMORY.md", + "memory/uuuuuuuuuuuuuuuuuuuuuuuuuuuuuuuuuuuuuuuu/", + "memory/uuuuuuuuuuuuuuuuuuuuuuuuuuuuuuuuuuuuuuuu/MEMORY.md" + ] + } + }, + { + "name": "suppression de projet-2", + "method": "POST", + "body": "{\"action\":\"delete\",\"slug\":\"projet-2\"}", + "status": 200, + "out": { + "active": "generale", + "ok": true, + "projects": [ + { + "active": true, + "convs": 2, + "created": 0, + "name": "Générale", + "pages": 3, + "slug": "generale", + "trackers": 1 + }, + { + "active": false, + "convs": 0, + "created": 0, + "name": "Ab-Ab-Ab-Ab-Ab-Ab-Ab-Ab-Ab-Ab-Ab-Ab-Ab-Ab-Ab-Ab-Ab-Ab-Ab-Ab-", + "pages": 0, + "slug": "ab-ab-ab-ab-ab-ab-ab-ab-ab-ab-ab-ab-ab-a", + "trackers": 0 + }, + { + "active": false, + "convs": 0, + "created": 0, + "name": "üüüüüüüüüüüüüüüüüüüüüüüüüüüüüüüüüüüüüüüüüüüüüüüüüüüüüüüüüüüü", + "pages": 0, + "slug": "uuuuuuuuuuuuuuuuuuuuuuuuuuuuuuuuuuuuuuuu", + "trackers": 0 + } + ] + }, + "after": { + "active": "generale", + "convs": [ + "g1:generale", + "o1:", + "x1:ailleurs" + ], + "projects": [ + { + "created_at": 0, + "name": "Générale", + "slug": "generale" + }, + { + "created_at": 0, + "name": "Ab-Ab-Ab-Ab-Ab-Ab-Ab-Ab-Ab-Ab-Ab-Ab-Ab-Ab-Ab-Ab-Ab-Ab-Ab-Ab-", + "slug": "ab-ab-ab-ab-ab-ab-ab-ab-ab-ab-ab-ab-ab-a" + }, + { + "created_at": 0, + "name": "üüüüüüüüüüüüüüüüüüüüüüüüüüüüüüüüüüüüüüüüüüüüüüüüüüüüüüüüüüüü", + "slug": "uuuuuuuuuuuuuuuuuuuuuuuuuuuuuuuuuuuuuuuu" + } + ], + "trackers": [ + "alphabet/x", + "generale/poids" + ], + "tree": [ + "memory/", + "memory/ab-ab-ab-ab-ab-ab-ab-ab-ab-ab-ab-ab-ab-a/", + "memory/ab-ab-ab-ab-ab-ab-ab-ab-ab-ab-ab-ab-ab-a/MEMORY.md", + "memory/generale/", + "memory/generale/.cache.md", + "memory/generale/MEMORY.md", + "memory/generale/a.md", + "memory/generale/b.MD", + "memory/generale/notes.txt", + "memory/generale/sous/", + "memory/generale/sous/page.md", + "memory/generale/z.md", + "memory/uuuuuuuuuuuuuuuuuuuuuuuuuuuuuuuuuuuuuuuu/", + "memory/uuuuuuuuuuuuuuuuuuuuuuuuuuuuuuuuuuuuuuuu/MEMORY.md" + ] + } + }, + { + "name": "suppression de ab-ab-ab-ab-ab-ab-ab-ab-ab-ab-ab-ab-ab-a", + "method": "POST", + "body": "{\"action\":\"delete\",\"slug\":\"ab-ab-ab-ab-ab-ab-ab-ab-ab-ab-ab-ab-ab-a\"}", + "status": 200, + "out": { + "active": "generale", + "ok": true, + "projects": [ + { + "active": true, + "convs": 2, + "created": 0, + "name": "Générale", + "pages": 3, + "slug": "generale", + "trackers": 1 + }, + { + "active": false, + "convs": 0, + "created": 0, + "name": "üüüüüüüüüüüüüüüüüüüüüüüüüüüüüüüüüüüüüüüüüüüüüüüüüüüüüüüüüüüü", + "pages": 0, + "slug": "uuuuuuuuuuuuuuuuuuuuuuuuuuuuuuuuuuuuuuuu", + "trackers": 0 + } + ] + }, + "after": { + "active": "generale", + "convs": [ + "g1:generale", + "o1:", + "x1:ailleurs" + ], + "projects": [ + { + "created_at": 0, + "name": "Générale", + "slug": "generale" + }, + { + "created_at": 0, + "name": "üüüüüüüüüüüüüüüüüüüüüüüüüüüüüüüüüüüüüüüüüüüüüüüüüüüüüüüüüüüü", + "slug": "uuuuuuuuuuuuuuuuuuuuuuuuuuuuuuuuuuuuuuuu" + } + ], + "trackers": [ + "alphabet/x", + "generale/poids" + ], + "tree": [ + "memory/", + "memory/generale/", + "memory/generale/.cache.md", + "memory/generale/MEMORY.md", + "memory/generale/a.md", + "memory/generale/b.MD", + "memory/generale/notes.txt", + "memory/generale/sous/", + "memory/generale/sous/page.md", + "memory/generale/z.md", + "memory/uuuuuuuuuuuuuuuuuuuuuuuuuuuuuuuuuuuuuuuu/", + "memory/uuuuuuuuuuuuuuuuuuuuuuuuuuuuuuuuuuuuuuuu/MEMORY.md" + ] + } + }, + { + "name": "suppression de uuuuuuuuuuuuuuuuuuuuuuuuuuuuuuuuuuuuuuuu", + "method": "POST", + "body": "{\"action\":\"delete\",\"slug\":\"uuuuuuuuuuuuuuuuuuuuuuuuuuuuuuuuuuuuuuuu\"}", + "status": 200, + "out": { + "active": "generale", + "ok": true, + "projects": [ + { + "active": true, + "convs": 2, + "created": 0, + "name": "Générale", + "pages": 3, + "slug": "generale", + "trackers": 1 + } + ] + }, + "after": { + "active": "generale", + "convs": [ + "g1:generale", + "o1:", + "x1:ailleurs" + ], + "projects": [ + { + "created_at": 0, + "name": "Générale", + "slug": "generale" + } + ], + "trackers": [ + "alphabet/x", + "generale/poids" + ], + "tree": [ + "memory/", + "memory/generale/", + "memory/generale/.cache.md", + "memory/generale/MEMORY.md", + "memory/generale/a.md", + "memory/generale/b.MD", + "memory/generale/notes.txt", + "memory/generale/sous/", + "memory/generale/sous/page.md", + "memory/generale/z.md" + ] + } + }, + { + "name": "dernier projet", + "method": "POST", + "body": "{\"action\":\"delete\",\"slug\":\"generale\"}", + "status": 400, + "out": { + "error": "impossible de supprimer le dernier projet", + "ok": false + }, + "after": { + "active": "generale", + "convs": [ + "g1:generale", + "o1:", + "x1:ailleurs" + ], + "projects": [ + { + "created_at": 0, + "name": "Générale", + "slug": "generale" + } + ], + "trackers": [ + "alphabet/x", + "generale/poids" + ], + "tree": [ + "memory/", + "memory/generale/", + "memory/generale/.cache.md", + "memory/generale/MEMORY.md", + "memory/generale/a.md", + "memory/generale/b.MD", + "memory/generale/notes.txt", + "memory/generale/sous/", + "memory/generale/sous/page.md", + "memory/generale/z.md" + ] + } + } + ] +} diff --git a/rust/loki-rs/tests/projects_api.rs b/rust/loki-rs/tests/projects_api.rs new file mode 100644 index 0000000..5e1f5a1 --- /dev/null +++ b/rust/loki-rs/tests/projects_api.rs @@ -0,0 +1,223 @@ +//! Lot 18 : hub des projets tenu par la façade (`LOKI_PROJECTS=rust`). +//! +//! Le journal tiré du Go (`internal/loki/parity_projects_test.go` → +//! `tests/parity_projects/projects.json`) est rejoué par la façade, la +//! conversation tenue par le (faux) Go : liste et comptes, création, +//! renommage, description, bascule, suppression, refus et erreurs, avec après +//! chaque appel le registre, le projet actif, l'arbre memory/, les trackers et +//! l'index des discussions. La bascule et la suppression quand la façade tient +//! la conversation sont vérifiées de bout en bout. + +mod common; + +use std::collections::BTreeSet; +use std::path::Path; + +use common::fakego::{self, CryptMode}; +use serde_json::{Value, json}; + +fn fixture() -> Value { + serde_json::from_str(include_str!("parity_projects/projects.json")).expect("journal") +} + +fn s(v: &Value) -> &str { + v.as_str().unwrap() +} + +/// Les dates propres à chaque exécution, à zéro (comme le journal). +fn zero_times(v: &mut Value) { + match v { + Value::Object(o) => { + for (k, x) in o.iter_mut() { + if matches!(k.as_str(), "created" | "created_at" | "updated") { + *x = json!(0); + } else { + zero_times(x); + } + } + } + Value::Array(a) => a.iter_mut().for_each(zero_times), + _ => {} + } +} + +fn tree(home: &Path) -> Vec { + fn walk(root: &Path, p: &Path, out: &mut Vec) { + let rel = p.strip_prefix(root).unwrap().to_string_lossy().into_owned(); + if p.is_dir() { + out.push(format!("{rel}/")); + for e in std::fs::read_dir(p).unwrap().flatten() { + walk(root, &e.path(), out); + } + } else { + out.push(rel); + } + } + let mut out = Vec::new(); + if home.join("memory").exists() { + walk(home, &home.join("memory"), &mut out); + } + out.sort(); + out +} + +fn snapshot(go: &fakego::FakeGo, home: &Path, seen: &BTreeSet) -> Value { + let mut reg: Value = go + .get("state", "projects") + .and_then(|s| serde_json::from_str(&s).ok()) + .unwrap_or(Value::Null); + zero_times(&mut reg); + let st = go.store.lock().unwrap(); + let mut trackers: Vec = st + .get("tracker") + .map(|b| b.keys().cloned().collect()) + .unwrap_or_default(); + trackers.sort(); + drop(st); + let idx: Vec = go + .get("chat", "index") + .and_then(|s| serde_json::from_str(&s).ok()) + .unwrap_or_default(); + let mut convs: Vec = idx + .iter() + .map(|m| { + let id = s(&m["id"]); + let id = if seen.contains(id) { id } else { "{{NEW}}" }; + format!("{id}:{}", m["project"].as_str().unwrap_or("")) + }) + .collect(); + convs.sort(); + json!({ + "projects": reg, + "active": go.get("state", "active_project").unwrap_or_default(), + "tree": tree(home), + "trackers": trackers, + "convs": convs, + }) +} + +#[tokio::test] +async fn parite_du_hub() { + let fx = fixture(); + let tmp = tempfile::tempdir().unwrap(); + let home = tmp.path().to_path_buf(); + for (rel, body) in fx["seed_mem"].as_object().unwrap() { + let p = home.join(rel); + std::fs::create_dir_all(p.parent().unwrap()).unwrap(); + std::fs::write(p, s(body)).unwrap(); + } + // Le dossier vide de la graine (sous/ a une page : rien d'autre à créer). + let (facade, go, _stop, _) = fakego::start_with( + home.clone(), + fakego::Opts { + projects: true, + ..Default::default() + }, + ) + .await; + *go.crypt_mode.lock().unwrap() = CryptMode::Plain; + for (k, v) in fx["seed_store"].as_object().unwrap() { + let (b, key) = k.split_once('/').unwrap(); + go.set(b, key, s(v)); + } + let seen: BTreeSet = + serde_json::from_str::>(s(&fx["seed_store"]["chat/index"])) + .unwrap() + .iter() + .map(|m| s(&m["id"]).to_string()) + .collect(); + assert_eq!(snapshot(&go, &home, &seen), fx["seed"], "graine"); + for st in fx["steps"].as_array().unwrap() { + let name = s(&st["name"]); + *go.generating.lock().unwrap() = st["generating"].as_bool() == Some(true); + let (code, body) = fakego::send( + facade, + s(&st["method"]), + "/api/projects", + &[], + st["body"].as_str().unwrap_or(""), + ) + .await; + *go.generating.lock().unwrap() = false; + assert_eq!( + code.as_u16() as i64, + st["status"].as_i64().unwrap(), + "{name}" + ); + let mut out: Value = serde_json::from_slice(&body).unwrap(); + zero_times(&mut out); + assert_eq!(out, st["out"], "{name}"); + assert_eq!(snapshot(&go, &home, &seen), st["after"], "{name} : état"); + } +} + +#[tokio::test] +async fn bascule_et_suppression_par_la_facade() { + let tmp = tempfile::tempdir().unwrap(); + let home = tmp.path().to_path_buf(); + let (facade, go, _stop, chat) = fakego::start_with( + home.clone(), + fakego::Opts { + chat: true, + projects: true, + ..Default::default() + }, + ) + .await; + let chat = chat.unwrap(); + let post = |body: &'static str| async move { + let (c, b) = fakego::send(facade, "POST", "/api/projects", &[], body).await; + (c.as_u16(), serde_json::from_slice::(&b).unwrap()) + }; + let (c, _) = post(r#"{"action":"create","name":"Alpha"}"#).await; + assert_eq!(c, 200); + let (c, v) = post(r#"{"action":"switch","slug":"alpha"}"#).await; + assert_eq!((c, v["active"].clone()), (200, json!("alpha"))); + // La conversation de la façade a suivi : une discussion d'Alpha ouverte. + let idx = chat.sessions.read_index().await; + let open = chat.sessions.conv().id(); + assert!( + idx.iter().any(|m| m.id == open && m.project == "alpha"), + "{idx:?}" + ); + assert!( + go.conv_log + .lock() + .unwrap() + .contains(&"project alpha".to_string()) + ); + // Projet introuvable : refusé par la façade, sans toucher à la conversation. + let (c, v) = post(r#"{"action":"switch","slug":"nope"}"#).await; + assert_eq!((c, v["error"].clone()), (400, json!("projet introuvable"))); + // Suppression du projet actif : la façade revient sur Générale et supprime + // les discussions d'Alpha ; le Go n'y touche pas. + let (c, v) = post(r#"{"action":"delete","slug":"alpha"}"#).await; + assert_eq!((c, v["active"].clone()), (200, json!("generale")), "{v}"); + let idx = chat.sessions.read_index().await; + assert!(idx.iter().all(|m| m.project != "alpha"), "{idx:?}"); + assert!(!home.join("memory/alpha").exists()); + // Pendant un tour de la façade : 409. + let (c, _) = post(r#"{"action":"create","name":"Beta"}"#).await; + assert_eq!(c, 200); + let ep = chat.sessions.conv().begin_turn(Box::new(|| {})).unwrap(); + let (c, v) = post(r#"{"action":"delete","slug":"beta"}"#).await; + assert_eq!( + (c, v["error"].clone()), + (409, json!("une génération est en cours")) + ); + let (c, _) = post(r#"{"action":"switch","slug":"beta"}"#).await; + assert_eq!(c, 409); + // Le tour passe avant tout le reste, projet inconnu compris (comme quand + // le Go tient la conversation). + let (c, _) = post(r#"{"action":"switch","slug":"nope"}"#).await; + assert_eq!(c, 409); + chat.sessions.conv().release_turn(ep); +} + +#[tokio::test] +async fn sans_la_variable_le_hub_reste_au_go() { + let tmp = tempfile::tempdir().unwrap(); + let (facade, _go, _stop) = fakego::start(tmp.path().to_path_buf()).await; + let (_, _, b) = fakego::get(facade, "/api/projects", &[]).await; + assert_eq!(&b[..], b"go:/api/projects"); +} diff --git a/rust/loki-rs/tests/proxy.rs b/rust/loki-rs/tests/proxy.rs index 2253c6b..175e7a2 100644 --- a/rust/loki-rs/tests/proxy.rs +++ b/rust/loki-rs/tests/proxy.rs @@ -109,6 +109,7 @@ async fn facade(backend: SocketAddr) -> (SocketAddr, oneshot::Sender<()>) { presets: false, engine_ctl: None, system: None, + projects: false, }; tokio::spawn(loki_rs::proxy::serve(l, cfg, async move { let _ = rx.await; From 3fc36a3acc17e0ee8da86762442f21eab2eb3b29 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 15 Oct 2026 19:26:27 +0000 Subject: [PATCH 17/52] =?UTF-8?q?Lot=2019=20:=20export,=20mode=20Code=20et?= =?UTF-8?q?=20crit=C3=A8res=20tenus=20par=20la=20fa=C3=A7ade=20(avec=20LOK?= =?UTF-8?q?I=5FCHAT=3Drust)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Quand la façade tient la conversation, /api/chat/export, /api/chat/mode et /api/chat/criteria rejoignent ses routes (chatx.rs, facadeChatRoutes côté Go), sans nouvelle variable. L'export Markdown ou JSON part de la conversation vivante (le Go relisait la copie enregistrée, en retard d'un tour au besoin) : raisonnement replié, outils dans leur état final, compaction signalée, export partiel annoncé, JSON indenté et HTML échappé, sans mise à jour de contexte ni image en base64. Le mode et les critères (scellés, vingt au plus, statuts ramenés) vont sur le fil de la façade. Le reste du mode Code (indice, travaux de fond, outil criteria) reste au Go et lit les mêmes clés. Parité : parity_chatx_test.go tire du Go l'export d'une conversation riche sous 22 jeux d'options et 23 appels au mode et aux critères (base et fil après chacun, erreurs JSON imbriquées comprises), rejoués octet pour octet par tests/chatx.rs ; tests du relais mis à jour. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_016tcGfJWefW5kUGoC2wL8cv --- README.md | 2 +- ...rust-migration-lot19-export-mode-design.md | 65 + internal/loki/facade_chat.go | 8 +- internal/loki/facade_chat_test.go | 16 +- internal/loki/parity_chatx_test.go | 233 +++ rust/loki-rs/src/chatx.rs | 789 ++++++++++ rust/loki-rs/src/lib.rs | 1 + rust/loki-rs/src/proxy.rs | 5 + rust/loki-rs/tests/chatx.rs | 186 +++ rust/loki-rs/tests/lot6.rs | 14 +- rust/loki-rs/tests/parity_chatx/chatx.json | 1317 +++++++++++++++++ 11 files changed, 2625 insertions(+), 11 deletions(-) create mode 100644 docs/superpowers/specs/2026-10-15-rust-migration-lot19-export-mode-design.md create mode 100644 internal/loki/parity_chatx_test.go create mode 100644 rust/loki-rs/src/chatx.rs create mode 100644 rust/loki-rs/tests/chatx.rs create mode 100644 rust/loki-rs/tests/parity_chatx/chatx.json diff --git a/README.md b/README.md index 62d42d4..88d8153 100644 --- a/README.md +++ b/README.md @@ -227,7 +227,7 @@ redémarrages (volume `/data`). Variables d'environnement du conteneur : | `LOKI_AUTO_UPDATE` | `0` : pas de mise à jour des binaires au démarrage | `1` | | `LOKI_FACADE` | `0` : sans façade Rust, le Go sert directement le port public (porte de sortie) | `1` | | `LOKI_GO_PORT` | port local (127.0.0.1) du serveur Go derrière la façade | `8091` | -| `LOKI_CHAT` | `rust` : la conversation est tenue par la façade Rust (expérimental, migration en cours), outils fichiers et shell compris. Le Go garde les autres outils, les pièces jointes, les fichiers et l'export. Avec `PROJ_SNAPSHOT`, `KEEP_TURN_IMAGES`, `NUDGE_IN_TOOL`, `SIDE_SLOT`, `SLOT_PERSIST`, `PREWARM` ou des motifs `retry_patterns`, le Go garde la conversation (décidé au démarrage) | Go | +| `LOKI_CHAT` | `rust` : la conversation est tenue par la façade Rust (expérimental, migration en cours), outils fichiers et shell compris, avec l'export, le mode Code et les critères de la discussion. Le Go garde les autres outils et le reste du mode Code. Avec `PROJ_SNAPSHOT`, `KEEP_TURN_IMAGES`, `NUDGE_IN_TOOL`, `SIDE_SLOT`, `SLOT_PERSIST`, `PREWARM` ou des motifs `retry_patterns`, le Go garde la conversation (décidé au démarrage) | Go | | `LOKI_MODELS` | `rust` : les routes des modèles — liste, dossiers, suppression, téléchargements, catalogue Hugging Face — sont servies par la façade Rust (expérimental, migration en cours) | Go | | `LOKI_DICTATE` | `rust` : la dictée vocale (`/api/transcribe`, `/api/dictate/*`) et son serveur de transcription sont tenus par la façade Rust (expérimental, migration en cours) | Go | | `LOKI_SETTINGS` | `rust` : les réglages partagés — préférences d'affichage, prompt système, intensité du raisonnement, adresse publique — sont servis par la façade Rust (expérimental, migration en cours) | Go | diff --git a/docs/superpowers/specs/2026-10-15-rust-migration-lot19-export-mode-design.md b/docs/superpowers/specs/2026-10-15-rust-migration-lot19-export-mode-design.md new file mode 100644 index 0000000..dd38df5 --- /dev/null +++ b/docs/superpowers/specs/2026-10-15-rust-migration-lot19-export-mode-design.md @@ -0,0 +1,65 @@ +# Lot 19 — export, mode Code et critères de la discussion + +## Contexte + +Avec `LOKI_CHAT=rust`, la façade tient la conversation (lot 6), mais trois +routes de la discussion ouverte restaient au Go : +- `/api/chat/export` (`chat_export.go`). Il relisait la copie enregistrée, + qui peut avoir un temps de retard sur la conversation vivante ; +- `/api/chat/mode` (`code_mode.go`) : mode Chat ou Code de la discussion, et + ses critères ; +- `/api/chat/criteria` : critères édités à la main. + +Lot 19 : ces trois routes rejoignent celles de la conversation. La façade +les sert quand elle la tient (`chatx.rs`, `facadeChatRoutes` côté Go), sans +nouvelle variable. Sans `LOKI_CHAT=rust`, rien ne change. + +| Route | Rôle | +|---|---| +| `GET /api/chat/export` | Markdown (fait pour être lu) ou JSON (fait pour être retraité), tiré de la conversation vivante ; options `format`, `reasoning`, `tools`, `results`, `turns` | +| `GET\|POST /api/chat/mode` | mode de la discussion ouverte (`chat/mode:`, absent = chat) et ses critères ; un changement part sur le fil (`{mode}`) | +| `POST /api/chat/criteria` | critères édités : vides écartés, vingt au plus, ids posés, statuts ramenés à `pending`/`passed`/`failed` ; scellés comme le contenu des discussions (`chat/crit:`), sur le fil (`{criteria, ts}`) | + +L'export suit `chat_export.go` : +- Markdown : un bloc par tour, le raisonnement replié dans un `
`, + les outils en liste (seul l'état final d'un outil compte, comme au + rejeu), la compaction signalée, un export partiel annoncé en tête ; +- JSON : indenté et HTML échappé comme `json.MarshalIndent`, vue modèle sans + mise à jour de contexte (`PROJ_SNAPSHOT`) ni image en base64 (élidée avec + sa taille), journal filtré par les mêmes cases ; +- les derniers échanges comptent à partir des messages de l'utilisateur, pas + des rappels de Loki. + +## Restent au Go + +Le reste du mode Code reste au Go et lit les mêmes clés : l'indice « ça +ressemble à du code », les travaux de fond et l'outil `criteria` de l'agent. + +## Écarts assumés + +- L'export part de la conversation vivante, pas de sa copie enregistrée : + le dernier tour y est, même tout juste fini. +- Les images d'un tour en cours restent dans l'export JSON, élidées. Une + conversation relue par le Go les avait déjà perdues. + +## Parité + +`internal/loki/parity_chatx_test.go` tire du Go l'export d'une conversation +riche sous 22 jeux d'options lus dans la requête. La conversation contient : +- pièces jointes, raisonnement en deltas ; +- outils finis, en cours, avec et sans sortie, labels piégés pour le + Markdown ; +- statistiques, compaction ; +- images en base64 et en URL, rappel de Loki ; +- mise à jour de contexte marquée, et une tapée par l'utilisateur. + +Plus les exports d'une conversation vide. + +Il tire aussi 23 appels aux routes du mode et des critères, avec la base et +les événements du fil après chacun : erreurs JSON, y compris celles d'un +critère mal typé (`Criterion.criteria.id`), et au-delà de vingt critères. + +`tests/chatx.rs` rejoue l'export octet pour octet et les routes par la +façade. Il vérifie aussi les en-têtes du téléchargement et le maintien des +routes au Go sans la conversation. Une mutation de chaque règle fait échouer +les tests. diff --git a/internal/loki/facade_chat.go b/internal/loki/facade_chat.go index 69a6311..fc108e3 100644 --- a/internal/loki/facade_chat.go +++ b/internal/loki/facade_chat.go @@ -123,8 +123,8 @@ func handleInternalChatHolder(w http.ResponseWriter, r *http.Request) { func facadeAddr() string { return strings.TrimSpace(os.Getenv(facadeAddrEnv)) } // facadeChatRoutes : les routes que la façade sert quand elle tient la -// conversation (chatapi.rs). Le reste (mode Code, critères, pièces jointes, -// fichiers, export) reste ici. +// conversation (chatapi.rs, chatx.rs). Le reste du mode Code (indice, travaux +// de fond, outil criteria) reste ici et lit les mêmes clés. var facadeChatRoutes = map[string]bool{ "/api/chat": true, "/api/chat/send": true, @@ -138,6 +138,10 @@ var facadeChatRoutes = map[string]bool{ "/api/conversations/switch": true, "/api/conversations/rename": true, "/api/conversations/delete": true, + // Lot 19 : export, mode Code et critères de la discussion ouverte. + "/api/chat/export": true, + "/api/chat/mode": true, + "/api/chat/criteria": true, } // withFacadeChat remet la requête à la façade quand elle tient la diff --git a/internal/loki/facade_chat_test.go b/internal/loki/facade_chat_test.go index 4946f7c..1c17e99 100644 --- a/internal/loki/facade_chat_test.go +++ b/internal/loki/facade_chat_test.go @@ -96,17 +96,25 @@ func TestFacadeRelaieLesRoutesDuChat(t *testing.T) { if got := f.recu(); len(got) != 1 || got[0] != `POST /api/chat/send jeton {"message":"salut"}` { t.Fatalf("reçu par la façade : %v", got) } - // Mode Code : reste au Go. + // Mode Code, critères, export : à la façade aussi (lot 19). + for _, p := range []string{"/api/chat/mode", "/api/chat/criteria", "/api/chat/export"} { + w = httptest.NewRecorder() + withFacadeChat(p, local)(w, httptest.NewRequest("POST", p, nil)) + if w.Body.String() != f.reply { + t.Fatalf("%s non relayé : %q", p, w.Body.String()) + } + } + // L'aperçu du navigateur piloté reste ici. w = httptest.NewRecorder() - withFacadeChat("/api/chat/mode", local)(w, httptest.NewRequest("POST", "/api/chat/mode", nil)) + withFacadeChat("/api/chat/cushot", local)(w, httptest.NewRequest("GET", "/api/chat/cushot", nil)) if w.Body.String() != "go" { - t.Fatalf("mode relayé : %q", w.Body.String()) + t.Fatalf("aperçu relayé : %q", w.Body.String()) } // Sans LOKI_CHAT=rust : tout reste ici. t.Setenv(facadeChatEnv, "") w = httptest.NewRecorder() withFacadeChat("/api/chat/send", local)(w, httptest.NewRequest("POST", "/api/chat/send", nil)) - if w.Body.String() != "go" || len(f.recu()) != 1 { + if w.Body.String() != "go" || len(f.recu()) != 4 { t.Fatalf("relayé sans la variable : %q", w.Body.String()) } } diff --git a/internal/loki/parity_chatx_test.go b/internal/loki/parity_chatx_test.go new file mode 100644 index 0000000..e60ade9 --- /dev/null +++ b/internal/loki/parity_chatx_test.go @@ -0,0 +1,233 @@ +package loki + +import ( + "encoding/json" + "net/http/httptest" + "net/url" + "os" + "path/filepath" + "regexp" + "strings" + "testing" +) + +// parity_chatx_test.go — journal de référence de l'export, du mode Code et +// des critères de la discussion (lot 19 de la migration Rust) : l'export +// Markdown et JSON d'une conversation riche (pièces jointes, raisonnement en +// deltas, outils avec et sans sortie, compaction, images en base64, rappels +// de Loki, mise à jour de contexte) sous une vingtaine de jeux d'options lus +// dans la requête, puis les routes du mode et des critères avec la base après +// chaque appel. Rangé dans rust/loki-rs/tests/parity_chatx/chatx.json ; +// tests/chatx.rs le rejoue par la façade (conversation tenue par elle). +// +// LOKI_PARITY_UPDATE=1 go test ./internal/loki -run TestPariteChatX + +const parityChatXFile = "../../rust/loki-rs/tests/parity_chatx/chatx.json" + +var ( + parityExportDateRe = regexp.MustCompile(`Exportée le \d{2}/\d{2}/\d{4} à \d{2}:\d{2}`) + parityExportedAtRe = regexp.MustCompile(`"exported_at": "[^"]+"`) +) + +// parityExportMask : la date de l'export et la version, propres à chaque +// exécution et à chaque binaire. +func parityExportMask(s string) string { + s = parityExportDateRe.ReplaceAllString(s, "Exportée le {{DATE}}") + s = parityExportedAtRe.ReplaceAllString(s, `"exported_at": "{{DATE}}"`) + return strings.ReplaceAll(s, Version, "{{VERSION}}") +} + +type parityChatXStep struct { + Name string `json:"name"` + Method string `json:"method"` + Path string `json:"path"` + Body string `json:"body,omitempty"` + Status int `json:"status"` + Out any `json:"out"` + Mode string `json:"mode"` // chat/mode: après l'appel + Crit any `json:"criteria"` // chat/crit: après l'appel + Deltas []any `json:"deltas"` // événements ajoutés au fil +} + +func TestPariteChatX(t *testing.T) { + testHome(t) + t.Setenv(facadeChatEnv, "") + img := "data:image/png;base64," + strings.Repeat("QUJD", 600) + msgs := []Message{ + {Role: "user", CtxUpd: true, Content: ctxUpdOpen + ` v="1">projet` + "\n\nBonjour & Cie"}, + {Role: "user", Content: ctxUpdOpen + ` v="2">tapé par l'utilisateur`}, + {Role: "assistant", Content: "", ToolCalls: []ToolCall{{ID: "c1", Type: "function", + Function: ToolCallFunc{Name: "bash", Arguments: `{"cmd":"ls"}`}}}}, + {Role: "tool", Content: "a.txt\nb.txt", ToolCallID: "c1"}, + {Role: "assistant", Content: "Voilà.", ReasoningContent: "je liste", ReasoningModel: "m"}, + {Role: "user", Content: thinkNudgeFirst}, + {Role: "user", Content: []any{ + map[string]any{"type": "text", "text": "Regarde"}, + map[string]any{"type": "image_url", "image_url": map[string]any{"url": img, "detail": "low"}}, + map[string]any{"type": "image_url", "image_url": map[string]any{"url": "https://x/y.png"}}, + "brut", + }}, + {Role: "assistant", Content: "Une image."}, + {Role: "user", Content: "Dernier tour"}, + {Role: "assistant", Content: "Fin."}, + } + ev := func(seq int, d map[string]any) LogEvent { return LogEvent{Seq: seq, TS: int64(1000 + seq), Delta: d} } + log := []LogEvent{ + ev(1, map[string]any{"user": "Bonjour & Cie", "files": []any{map[string]any{"name": "rapport.pdf", "size": 10}, map[string]any{"name": ""}, "x"}}), + ev(2, map[string]any{"reasoning_content": "je "}), + ev(3, map[string]any{"reasoning_content": "liste\n"}), + ev(4, map[string]any{"tool_used": map[string]any{"name": "bash", "label": "ls *.txt\n[a]", "result": "a.txt\nb.txt\n\n", "done": true}}), + ev(5, map[string]any{"tool_used": map[string]any{"name": "mem_read", "result": " ", "done": true}}), + ev(5, map[string]any{"tool_used": map[string]any{"name": "git_`log`", "label": " `--oneline` [main] ", "done": true}}), + ev(5, map[string]any{"tool_used": map[string]any{"name": "en_cours", "label": "pas fini"}}), + ev(6, map[string]any{"content": "Voi"}), + ev(7, map[string]any{"content": "là.\n"}), + ev(8, map[string]any{"stats": map[string]any{"tok_s": 12.5}}), + ev(9, map[string]any{"compacted": true}), + ev(10, map[string]any{"user": "", "files": []any{map[string]any{"name": "photo.png"}}}), + ev(11, map[string]any{"content": "Une image."}), + ev(12, map[string]any{"user": "Dernier tour"}), + ev(13, map[string]any{"tool_used": map[string]any{"name": "web_open", "label": "https://ex.com/a_b*c", "result": "titre"}}), + ev(14, map[string]any{"content": "Fin."}), + } + c := &Conversation{Messages: msgs, Log: log, Seq: 14, CtxUsed: 4321} + raw, err := json.Marshal(c) + if err != nil { + t.Fatal(err) + } + + type exportCase struct { + Query string `json:"query"` + Opts any `json:"opts"` + Body string `json:"body"` + Filename string `json:"filename_ext"` + } + var exports []exportCase + for _, q := range []string{ + "", "format=json", "format=md", "format=JSON", "reasoning=0", "reasoning=true&tools=off", + "tools=0", "tools=0&format=json", "results=0", "results=0&format=json", "reasoning=0&format=json", + "turns=1", "turns=2&format=json", "turns=9", "turns=0", "turns=-2", "turns=abc", "turns=1&results=0&reasoning=0", + "tools=on&results=yes", "format=json&format=md", "turns=3&format=json", "turns=4&format=json", + } { + vals, _ := url.ParseQuery(q) + o := exportOptsFromQuery(vals) + var body []byte + var ext string + if o.Format == "json" { + body, err = c.ExportJSON(o) + if err != nil { + t.Fatal(err) + } + ext = "json" + } else { + body = []byte(c.ExportMarkdown(o)) + ext = "md" + } + exports = append(exports, exportCase{Query: q, Opts: map[string]any{"format": o.Format, "reasoning": o.Reasoning, + "tools": o.Tools, "results": o.Results, "turns": o.Turns}, Body: parityExportMask(string(body)), Filename: ext}) + } + empty := &Conversation{} + emptyMD := parityExportMask(empty.ExportMarkdown(defaultExportOpts())) + emptyJSON, _ := empty.ExportJSON(defaultExportOpts()) + + // --- mode et critères, conversation tenue ici --- + id := convEnsureActive() + conv.mu.Lock() + conv.Log = nil + conv.mu.Unlock() + run := func(st parityChatXStep) parityChatXStep { + conv.mu.Lock() + before := len(conv.Log) + conv.mu.Unlock() + rec := httptest.NewRecorder() + req := httptest.NewRequest(st.Method, st.Path, strings.NewReader(st.Body)) + if st.Path == "/api/chat/mode" { + handleChatMode(rec, req) + } else { + handleChatCriteria(rec, req) + } + st.Status = rec.Code + _ = json.Unmarshal(rec.Body.Bytes(), &st.Out) + st.Mode = getStr(bkChat, modeKey(id)) + var crit any + if b := getBytes(bkChat, critKey(id)); len(b) > 0 { + _ = json.Unmarshal(b, &crit) + } + st.Crit = crit + conv.mu.Lock() + for _, e := range conv.Log[before:] { + d := map[string]any{} + for k, v := range e.Delta { + if k != "ts" { + d[k] = v + } + } + b, _ := json.Marshal(d) + var x any + _ = json.Unmarshal(b, &x) + st.Deltas = append(st.Deltas, x) + } + conv.mu.Unlock() + return st + } + long := make([]string, 0, 25) + for i := 0; i < 25; i++ { + long = append(long, `{"text":"c`+string(rune('a'+i))+`"}`) + } + var steps []parityChatXStep + for _, st := range []parityChatXStep{ + {Name: "mode par défaut", Method: "GET", Path: "/api/chat/mode"}, + {Name: "mode code", Method: "POST", Path: "/api/chat/mode", Body: `{"mode":"code"}`}, + {Name: "mode relu", Method: "GET", Path: "/api/chat/mode"}, + {Name: "mode inconnu", Method: "POST", Path: "/api/chat/mode", Body: `{"mode":"Code"}`}, + {Name: "mode corps illisible", Method: "POST", Path: "/api/chat/mode", Body: `{"mode":`}, + {Name: "mode type faux", Method: "POST", Path: "/api/chat/mode", Body: `{"mode":true}`}, + {Name: "mode en PUT", Method: "PUT", Path: "/api/chat/mode", Body: `{"mode":"chat"}`}, + {Name: "critères en GET", Method: "GET", Path: "/api/chat/criteria"}, + {Name: "critères", Method: "POST", Path: "/api/chat/criteria", + Body: `{"criteria":[{"text":"compile"},{"id":5,"text":"tests & verts","status":"passed","note":"vu"},{"text":" "},{"id":0,"text":"doc","status":"bizarre"},{"id":2,"text":"lint","status":"failed"},{"text":"perf","status":"completed"}]}`}, + {Name: "mode relu avec critères", Method: "GET", Path: "/api/chat/mode"}, + {Name: "critères au-delà de vingt", Method: "POST", Path: "/api/chat/criteria", + Body: `{"criteria":[` + strings.Join(long, ",") + `]}`}, + {Name: "critères vidés", Method: "POST", Path: "/api/chat/criteria", Body: `{"criteria":[]}`}, + {Name: "critères absents", Method: "POST", Path: "/api/chat/criteria", Body: `{}`}, + {Name: "critères null", Method: "POST", Path: "/api/chat/criteria", Body: `{"criteria":null}`}, + {Name: "critères corps vide", Method: "POST", Path: "/api/chat/criteria", Body: ``}, + {Name: "critères illisibles", Method: "POST", Path: "/api/chat/criteria", Body: `{"criteria":[`}, + {Name: "critère id texte", Method: "POST", Path: "/api/chat/criteria", Body: `{"criteria":[{"id":"2","text":"x"}]}`}, + {Name: "critère texte nombre", Method: "POST", Path: "/api/chat/criteria", Body: `{"criteria":[{"text":3}]}`}, + {Name: "critères pas une liste", Method: "POST", Path: "/api/chat/criteria", Body: `{"criteria":"a"}`}, + {Name: "critère pas un objet", Method: "POST", Path: "/api/chat/criteria", Body: `{"criteria":["a",{"text":"b"}]}`}, + {Name: "critère id décimal", Method: "POST", Path: "/api/chat/criteria", Body: `{"criteria":[{"id":1.5,"text":"x"}]}`}, + {Name: "critère clés en majuscules", Method: "POST", Path: "/api/chat/criteria", Body: `{"criteria":[{"ID":3,"TEXT":"maj","Status":"passed"},null]}`}, + {Name: "mode chat", Method: "POST", Path: "/api/chat/mode", Body: `{"mode":"chat"}`}, + } { + steps = append(steps, run(st)) + } + + got, err := parityJSON(map[string]any{ + "conversation": json.RawMessage(raw), "exports": exports, + "empty_md": emptyMD, "empty_json": parityExportMask(string(emptyJSON)), "steps": steps, + }) + if err != nil { + t.Fatal(err) + } + if os.Getenv("LOKI_PARITY_UPDATE") == "1" { + if err := os.MkdirAll(filepath.Dir(parityChatXFile), 0o755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(parityChatXFile, got, 0o644); err != nil { + t.Fatal(err) + } + return + } + want, err := os.ReadFile(parityChatXFile) + 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("l'export, le mode ou les critères ne produisent plus le journal rangé dans %s.\n"+ + "Si c'est voulu : LOKI_PARITY_UPDATE=1 go test ./internal/loki -run TestPariteChatX,\n"+ + "puis cargo test --test chatx dit si le port Rust suit.\n--- obtenu ---\n%s", parityChatXFile, got) + } +} diff --git a/rust/loki-rs/src/chatx.rs b/rust/loki-rs/src/chatx.rs new file mode 100644 index 0000000..bf93572 --- /dev/null +++ b/rust/loki-rs/src/chatx.rs @@ -0,0 +1,789 @@ +//! Export, mode Code et critères de la discussion, tenus par la façade quand +//! elle tient la conversation (`LOKI_CHAT=rust`, lot 19 de la migration) : +//! +//! - `/api/chat/export` — port de `chat_export.go` : Markdown (fait pour être +//! lu) ou JSON (fait pour être retraité), mêmes options pour les deux +//! (raisonnements, outils, sorties, derniers échanges), tirés de la +//! conversation VIVANTE (le Go relisait la copie enregistrée) ; +//! - `/api/chat/mode` — `handleChatMode` (`code_mode.go`) : mode de la +//! discussion ouverte (`chat/mode:`) et ses critères ; +//! - `/api/chat/criteria` — `handleChatCriteria` : critères édités à la main +//! (`chat/crit:`, scellés comme le contenu des discussions). +//! +//! Les changements partent sur le fil de la façade (deltas `mode`, +//! `criteria`), comme le Go les lui envoyait. Le reste du mode Code (indice, +//! travaux de fond, outil `criteria`) reste au Go, qui lit les mêmes clés. + +use std::time::{SystemTime, UNIX_EPOCH}; + +use http_body_util::{BodyExt, Full, Limited}; +use hyper::body::Incoming; +use hyper::header::{CONTENT_DISPOSITION, CONTENT_LENGTH, CONTENT_TYPE, HeaderValue}; +use hyper::{Method, Request, Response, StatusCode}; +use serde::{Deserialize, Serialize}; +use serde_json::{Map, Value, json}; + +use crate::api::Ctx; +use crate::auth::json as send_json; +use crate::chat::compact::{is_loki_injected, strip_context_updates}; +use crate::chat::generate::ChatService; +use crate::chat::journal::coalesce_replay; +use crate::chat::persist::BK_CHAT; +use crate::chat::types::{LogEvent, Message}; +use crate::gojson::{self, Kind}; +use crate::proxy::Body; + +// --------------------------------------------------------------------------- +// Export +// --------------------------------------------------------------------------- + +/// `exportOpts`. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct ExportOpts { + /// « md » ou « json ». + pub format: String, + pub reasoning: bool, + pub tools: bool, + pub results: bool, + /// 0 = tout le fil ; N = les N derniers échanges. + pub turns: i64, +} + +impl Default for ExportOpts { + /// `defaultExportOpts`. + fn default() -> Self { + ExportOpts { + format: "md".into(), + reasoning: true, + tools: true, + results: true, + turns: 0, + } + } +} + +/// `exportOptsFromQuery` : toute clé absente garde sa valeur par défaut (la +/// première valeur d'une clé répétée compte). +pub fn opts_from_query(query: &str) -> ExportOpts { + let mut first: std::collections::HashMap = std::collections::HashMap::new(); + for (k, v) in url::form_urlencoded::parse(query.as_bytes()) { + first + .entry(k.into_owned()) + .or_insert_with(|| v.into_owned()); + } + let mut o = ExportOpts::default(); + let flag = |k: &str, cur: bool| match first.get(k) { + None => cur, + Some(v) => v == "1" || v == "true" || v == "on", + }; + if first.get("format").is_some_and(|v| v == "json") { + o.format = "json".into(); + } + o.reasoning = flag("reasoning", o.reasoning); + o.tools = flag("tools", o.tools); + o.results = flag("results", o.results); + if let Some(n) = first + .get("turns") + .and_then(|v| go_atoi(v)) + .filter(|n| *n > 0) + { + o.turns = n; + } + // Sans les bulles d'outils, leur sortie n'a nulle part où aller. + if !o.tools { + o.results = false; + } + o +} + +/// `strconv.Atoi` (signe optionnel, chiffres ASCII seulement). +fn go_atoi(s: &str) -> Option { + let digits = s.strip_prefix(['+', '-']).unwrap_or(s); + if digits.is_empty() || !digits.bytes().all(|b| b.is_ascii_digit()) { + return None; + } + s.parse().ok() +} + +/// `filterLog` : les mêmes cases que le Markdown, appliquées au journal (sur +/// une copie : les événements appartiennent à la conversation vivante). +pub fn filter_log(log: &[LogEvent], o: &ExportOpts) -> Vec { + if o.reasoning && o.tools && o.results { + return log.to_vec(); + } + let mut out = Vec::with_capacity(log.len()); + for ev in log { + if ev.delta.contains_key("reasoning_content") && !o.reasoning { + continue; + } + if let Some(Value::Object(tu)) = ev.delta.get("tool_used") { + if !o.tools { + continue; + } + if !o.results { + let mut cp = tu.clone(); + cp.remove("result"); + let mut ev = ev.clone(); + ev.delta.insert("tool_used".into(), Value::Object(cp)); + out.push(ev); + continue; + } + } + out.push(ev.clone()); + } + out +} + +/// `lastTurnsMessages` : les `n` derniers échanges, un échange commençant à un +/// message `user` (un rappel de Loki n'en ouvre pas). +pub fn last_turns_messages(msgs: &[Message], n: i64) -> Vec { + let mut seen = 0; + for i in (0..msgs.len()).rev() { + if msgs[i].role != "user" || is_loki_injected(&msgs[i]) { + continue; + } + seen += 1; + if seen == n { + return msgs[i..].to_vec(); + } + } + msgs.to_vec() +} + +/// `lastTurnsLog` : idem sur le journal, où un échange commence à un événement +/// `user`. +pub fn last_turns_log(log: &[LogEvent], n: i64) -> Vec { + let mut seen = 0; + for i in (0..log.len()).rev() { + if !log[i].delta.contains_key("user") { + continue; + } + seen += 1; + if seen == n { + return log[i..].to_vec(); + } + } + log.to_vec() +} + +/// `elideDataURI` : l'en-tête « data:;base64, » gardé, le corps +/// remplacé par sa taille. +pub fn elide_data_uri(url: &str) -> String { + match url.find(',') { + Some(i) => format!( + "{}<élidé — {}>", + &url[..=i], + crate::downloads::human_bytes((url.len() - i - 1) as i64) + ), + None => "".into(), + } +} + +/// `stripInlineImages` : les images base64 des messages multimodaux élidées +/// (un export JSON embarquait plusieurs Mo par image). +pub fn strip_inline_images(msgs: Vec) -> Vec { + msgs.into_iter() + .map(|mut m| { + if let Some(Value::Array(parts)) = &mut m.content { + for p in parts.iter_mut() { + if let Value::Object(part) = p + && let Some(Value::Object(iu)) = part.get_mut("image_url") + && let Some(Value::String(url)) = iu.get("url") + && url.starts_with("data:") + { + let e = elide_data_uri(url); + iu.insert("url".into(), Value::String(e)); + } + } + } + m + }) + .collect() +} + +/// `exportNote` : ce que l'export ne contient PAS, en une ligne. +pub fn export_note(o: &ExportOpts) -> String { + let mut parts = Vec::new(); + if o.turns > 0 { + parts.push(format!("{} derniers échanges seulement", o.turns)); + } + if !o.reasoning { + parts.push("raisonnements retirés".into()); + } + if !o.tools { + parts.push("appels d'outils retirés".into()); + } else if !o.results { + parts.push("sorties d'outils retirées".into()); + } + if parts.is_empty() { + return String::new(); + } + format!("_Export partiel : {}._", parts.join(", ")) +} + +/// `mdText`. +fn md_text(v: Option<&Value>) -> String { + v.and_then(Value::as_str) + .unwrap_or("") + .trim_end_matches('\n') + .to_string() +} + +/// `mdInline` : ce qui casserait une ligne Markdown neutralisé. +pub fn md_inline(s: &str) -> String { + let s = s.replace('\n', " "); + let mut out = String::with_capacity(s.len()); + for c in s.chars() { + if matches!(c, '*' | '_' | '`' | '[' | ']') { + out.push('\\'); + } + out.push(c); + } + out.trim().to_string() +} + +/// `exportFileNames`. +fn export_file_names(v: Option<&Value>) -> Vec { + match v { + Some(Value::Array(files)) => files + .iter() + .filter_map(|f| f.get("name").and_then(Value::as_str)) + .filter(|n| !n.is_empty()) + .map(str::to_string) + .collect(), + _ => Vec::new(), + } +} + +fn present<'a>(ev: &'a Map, k: &str) -> Option<&'a Value> { + ev.get(k).filter(|v| !v.is_null()) +} + +/// `ExportMarkdown` : un bloc par tour, le raisonnement replié dans un +/// `
`, les outils en liste. `date` : « 02/01/2006 à 15:04 ». +pub fn export_markdown(log: &[LogEvent], o: &ExportOpts, date: &str, version: &str) -> String { + let snapshot = if o.turns > 0 { + last_turns_log(log, o.turns) + } else { + log.to_vec() + }; + let mut b = format!("# Conversation Loki\n\nExportée le {date} par Loki {version}.\n"); + let note = export_note(o); + if !note.is_empty() { + b.push_str(&format!("\n{note}\n")); + } + let mut open_bubble = false; + let head = |b: &mut String, open: &mut bool| { + if !*open { + b.push_str("\n## Loki\n"); + *open = true; + } + }; + for ev in coalesce_replay(&snapshot, 0) { + if let Some(u) = present(&ev, "user") { + open_bubble = false; + b.push_str(&format!("\n---\n\n## Vous\n\n{}\n", md_text(Some(u)))); + let names = export_file_names(ev.get("files")); + if !names.is_empty() { + b.push_str(&format!("\nFichiers joints : {}\n", names.join(", "))); + } + } else if let Some(r) = present(&ev, "reasoning_content") { + if o.reasoning { + head(&mut b, &mut open_bubble); + b.push_str(&format!( + "\n
\nRaisonnement\n\n{}\n\n
\n", + md_text(Some(r)) + )); + } + } else if let Some(c) = present(&ev, "content") { + head(&mut b, &mut open_bubble); + b.push_str(&format!("\n{}\n", md_text(Some(c)))); + } else if let Some(tu) = present(&ev, "tool_used") { + if !o.tools { + continue; + } + let Value::Object(tu) = tu else { + continue; + }; + head(&mut b, &mut open_bubble); + let name = tu.get("name").and_then(Value::as_str).unwrap_or(""); + let label = match tu.get("label").and_then(Value::as_str).unwrap_or("") { + "" => name, + l => l, + }; + b.push_str(&format!( + "\n> 🔧 **{}** — {}\n", + md_inline(name), + md_inline(label) + )); + if let Some(res) = tu.get("result").and_then(Value::as_str) + && o.results + && !res.trim().is_empty() + { + b.push_str(&format!("\n```\n{}\n```\n", res.trim_end_matches('\n'))); + } + } else if present(&ev, "compacted").is_some() { + b.push_str("\n_(contexte compacté à cet endroit : les tours précédents ont été résumés pour le modèle, le fil ci-dessus reste complet)_\n"); + } + } + if !open_bubble && snapshot.is_empty() { + b.push_str("\n_Conversation vide._\n"); + } + b +} + +#[derive(Serialize)] +struct ExportPayload<'a> { + loki_version: &'a str, + exported_at: &'a str, + ctx_used: i64, + messages: Vec, + #[serde(skip_serializing_if = "Vec::is_empty")] + log: Vec, +} + +/// `json.MarshalIndent(v, "", " ")` : indenté de deux espaces, HTML échappé. +pub fn marshal_indent(v: &T) -> Vec { + let s = serde_json::to_string_pretty(v).unwrap_or_default(); + let mut out = String::with_capacity(s.len()); + for c in s.chars() { + match c { + '<' => out.push_str("\\u003c"), + '>' => out.push_str("\\u003e"), + '&' => out.push_str("\\u0026"), + '\u{2028}' => out.push_str("\\u2028"), + '\u{2029}' => out.push_str("\\u2029"), + c => out.push(c), + } + } + out.into_bytes() +} + +/// `ExportJSON` : la vue modèle (sans mises à jour de contexte ni images +/// base64) et le journal d'affichage filtré. +pub fn export_json( + messages: &[Message], + log: &[LogEvent], + ctx_used: i64, + o: &ExportOpts, + exported_at: &str, + version: &str, +) -> Vec { + let (msgs, log) = if o.turns > 0 { + ( + last_turns_messages(messages, o.turns), + last_turns_log(log, o.turns), + ) + } else { + (messages.to_vec(), log.to_vec()) + }; + let p = ExportPayload { + loki_version: version, + exported_at, + ctx_used, + messages: strip_inline_images(strip_context_updates(msgs)), + log: filter_log(&log, o), + }; + marshal_indent(&p) +} + +/// Heure locale : (année, mois, jour, heure, minute, seconde, décalage en s). +fn local_now() -> (i64, u32, u32, u32, u32, u32, i64) { + let secs = SystemTime::now() + .duration_since(UNIX_EPOCH) + .map_or(0, |d| d.as_secs() as i64); + #[cfg(unix)] + { + let t = secs as libc::time_t; + let mut tm: libc::tm = unsafe { std::mem::zeroed() }; + // SAFETY : pointeurs valides, structure de sortie initialisée. + if !unsafe { libc::localtime_r(&t, &mut tm) }.is_null() { + return ( + i64::from(tm.tm_year) + 1900, + (tm.tm_mon + 1) as u32, + tm.tm_mday as u32, + tm.tm_hour as u32, + tm.tm_min as u32, + tm.tm_sec as u32, + tm.tm_gmtoff as i64, + ); + } + } + let days = secs.div_euclid(86400); + let rem = secs.rem_euclid(86400); + let (y, m, d) = civil(days); + ( + y, + m, + d, + (rem / 3600) as u32, + (rem % 3600 / 60) as u32, + (rem % 60) as u32, + 0, + ) +} + +/// Jours depuis 1970 → (année, mois, jour). +fn civil(days: i64) -> (i64, u32, u32) { + let z = days + 719468; + let era = z.div_euclid(146097); + let doe = z.rem_euclid(146097); + let yoe = (doe - doe / 1460 + doe / 36524 - doe / 146096) / 365; + let doy = doe - (365 * yoe + yoe / 4 - yoe / 100); + let mp = (5 * doy + 2) / 153; + let d = (doy - (153 * mp + 2) / 5 + 1) as u32; + let m = if mp < 10 { mp + 3 } else { mp - 9 } as u32; + (era * 400 + yoe + i64::from(m <= 2), m, d) +} + +/// `time.RFC3339` en heure locale (« Z » à l'heure UTC, comme le Go). +fn rfc3339_now() -> String { + let (y, mo, d, h, mi, s, off) = local_now(); + let zone = if off == 0 { + "Z".to_string() + } else { + let sign = if off < 0 { '-' } else { '+' }; + let a = off.abs(); + format!("{sign}{:02}:{:02}", a / 3600, a % 3600 / 60) + }; + format!("{y:04}-{mo:02}-{d:02}T{h:02}:{mi:02}:{s:02}{zone}") +} + +/// « 02/01/2006 à 15:04 ». +fn fr_date_now() -> String { + let (y, mo, d, h, mi, _, _) = local_now(); + format!("{d:02}/{mo:02}/{y:04} à {h:02}:{mi:02}") +} + +/// `exportFilename` : horodaté pour que deux exports ne s'écrasent pas. +fn export_filename(ext: &str) -> String { + let (y, mo, d, h, mi, _, _) = local_now(); + format!("loki-conversation-{y:04}-{mo:02}-{d:02}-{h:02}{mi:02}.{ext}") +} + +// --------------------------------------------------------------------------- +// Mode et critères +// --------------------------------------------------------------------------- + +/// `Criterion`. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Default)] +pub struct Criterion { + pub id: i64, + pub text: String, + pub status: String, + #[serde(default, skip_serializing_if = "String::is_empty")] + pub note: String, +} + +/// `critMaxCount`. +pub const CRIT_MAX_COUNT: usize = 20; + +fn mode_key(id: &str) -> String { + format!("mode:{id}") +} + +fn crit_key(id: &str) -> String { + format!("crit:{id}") +} + +/// Nom de type JSON d'une valeur, comme `UnmarshalTypeError.Value`. +fn json_kind(v: &Value) -> &'static str { + match v { + Value::String(_) => "string", + Value::Number(_) => "number", + Value::Bool(_) => "bool", + Value::Array(_) => "array", + Value::Object(_) => "object", + Value::Null => "null", + } +} + +const MODE_REQ_TYPE: &str = r#"struct { Mode string "json:\"mode\"" }"#; +const CRIT_REQ_TYPE: &str = r#"struct { Criteria []loki.Criterion "json:\"criteria\"" }"#; + +/// Décode `{criteria: []Criterion}` comme `json.NewDecoder(r.Body).Decode` +/// (liste absente ou nulle : vide). +pub fn decode_criteria(body: &[u8]) -> Result, String> { + let d = gojson::decode_struct(body, CRIT_REQ_TYPE, &[("criteria", Kind::Any)]); + if let Some(e) = d.err.clone() { + return Err(e); + } + let Some(raw) = d.fields.get("criteria") else { + return Ok(Vec::new()); + }; + let items = match raw { + Value::Null => return Ok(Vec::new()), + Value::Array(a) => a.clone(), + v => { + return Err(format!( + "json: cannot unmarshal {} into Go struct field .criteria of type []loki.Criterion", + json_kind(v) + )); + } + }; + let mut out = Vec::with_capacity(items.len()); + let mut first_err: Option = None; + for it in items { + match it { + Value::Null => out.push(Criterion::default()), + Value::Object(_) => { + let b = serde_json::to_vec(&it).unwrap_or_default(); + let d = gojson::decode_struct( + &b, + "Criterion", + &[ + ("id", Kind::Int), + ("text", Kind::Str), + ("status", Kind::Str), + ("note", Kind::Str), + ], + ); + if let Some(e) = d.err.clone() + && first_err.is_none() + { + first_err = Some(e.replacen( + "Go struct field Criterion.", + "Go struct field Criterion.criteria.", + 1, + )); + } + out.push(Criterion { + id: d.int("id"), + text: d.str("text"), + status: d.str("status"), + note: d.str("note"), + }); + } + v => { + if first_err.is_none() { + first_err = Some(format!( + "json: cannot unmarshal {} into Go struct field .criteria of type loki.Criterion", + json_kind(&v) + )); + } + out.push(Criterion::default()); + } + } + } + match first_err { + Some(e) => Err(e), + None => Ok(out), + } +} + +/// `handleChatCriteria` : vides écartés, vingt au plus, ids posés, +/// statuts ramenés à pending/passed/failed. +pub fn clean_criteria(list: Vec) -> Vec { + let mut clean: Vec = Vec::new(); + let mut next = 1; + for mut c in list { + if c.text.trim().is_empty() || clean.len() >= CRIT_MAX_COUNT { + continue; + } + if c.id <= 0 { + c.id = next; + } + if c.id >= next { + next = c.id + 1; + } + if !matches!(c.status.as_str(), "pending" | "passed" | "failed") { + c.status = "pending".into(); + } + clean.push(c); + } + clean +} + +/// `critList` : `null` sans critères. +async fn crit_list(ctx: &Ctx, conv_id: &str) -> Value { + match ctx.store.get_open(BK_CHAT, &crit_key(conv_id)).await { + Ok(Some(b)) if !b.is_empty() => serde_json::from_slice(&b).unwrap_or(Value::Null), + _ => Value::Null, + } +} + +fn now_ms() -> i64 { + SystemTime::now() + .duration_since(UNIX_EPOCH) + .map_or(0, |d| d.as_millis() as i64) +} + +// --------------------------------------------------------------------------- +// Routes +// --------------------------------------------------------------------------- + +/// Les routes servies par ce module (avec la conversation). +pub fn handles(path: &str) -> bool { + matches!( + path, + "/api/chat/export" | "/api/chat/mode" | "/api/chat/criteria" + ) +} + +fn fail(code: StatusCode, e: impl Into) -> Response { + send_json(code, &json!({ "ok": false, "error": e.into() })) +} + +/// `from_go` : remise par le Go (tunnel), déjà authentifiée par lui. +pub async fn serve( + ctx: &Ctx, + chat: &ChatService, + req: Request, + from_go: bool, +) -> Response { + let (parts, body) = req.into_parts(); + let version = if from_go { + ctx.internal + .auth() + .await + .map(|i| i.version) + .unwrap_or_default() + } else { + match ctx.guard(&parts.headers).await { + Ok(v) => v, + Err(refus) => return *refus, + } + }; + let conv = chat.sessions.conv(); + let conv_id = conv.id(); + let read = || async { + Limited::new(body, 1 << 20) + .collect() + .await + .map(|b| b.to_bytes().to_vec()) + .unwrap_or_default() + }; + match parts.uri.path() { + "/api/chat/export" => { + let o = opts_from_query(parts.uri.query().unwrap_or("")); + let (messages, log, ctx_used) = + conv.with_state(|st| (st.messages.clone(), st.log.clone(), st.ctx_used)); + let (body, ctype, ext) = if o.format == "json" { + ( + export_json(&messages, &log, ctx_used, &o, &rfc3339_now(), &version), + "application/json; charset=utf-8", + "json", + ) + } else { + ( + export_markdown(&log, &o, &fr_date_now(), &version).into_bytes(), + "text/markdown; charset=utf-8", + "md", + ) + }; + let len = body.len(); + let mut r = Response::new( + Full::new(bytes::Bytes::from(body)) + .map_err(|n| match n {}) + .boxed(), + ); + let h = r.headers_mut(); + h.insert(CONTENT_TYPE, HeaderValue::from_static(ctype)); + if let Ok(v) = HeaderValue::from_str(&format!( + "attachment; filename=\"{}\"", + export_filename(ext) + )) { + h.insert(CONTENT_DISPOSITION, v); + } + h.insert(CONTENT_LENGTH, HeaderValue::from(len)); + r + } + "/api/chat/mode" if parts.method == Method::POST => { + let raw = read().await; + let d = gojson::decode_struct(&raw, MODE_REQ_TYPE, &[("mode", Kind::Str)]); + if let Some(e) = d.err.clone() { + return fail(StatusCode::BAD_REQUEST, e); + } + let mode = d.str("mode"); + if mode != "code" && mode != "chat" { + return fail(StatusCode::BAD_REQUEST, "mode inconnu (chat|code)"); + } + // chat = défaut = clé absente. + let res = if mode == "code" { + ctx.store + .put(BK_CHAT, &mode_key(&conv_id), b"code".to_vec()) + .await + } else { + ctx.store.del(BK_CHAT, &mode_key(&conv_id)).await + }; + if let Err(e) = res { + return fail(StatusCode::INTERNAL_SERVER_ERROR, e); + } + let mut d = Map::new(); + d.insert("mode".into(), json!(mode)); + chat.push_delta(d); + send_json(StatusCode::OK, &json!({ "ok": true, "mode": mode })) + } + "/api/chat/mode" => { + let code = ctx.store.get_str(BK_CHAT, &mode_key(&conv_id)).await == "code"; + send_json( + StatusCode::OK, + &json!({ + "ok": true, + "mode": if code { "code" } else { "chat" }, + "criteria": crit_list(ctx, &conv_id).await, + }), + ) + } + "/api/chat/criteria" => { + if parts.method != Method::POST { + return fail(StatusCode::METHOD_NOT_ALLOWED, "POST attendu"); + } + let raw = read().await; + let list = match decode_criteria(&raw) { + Ok(l) => l, + Err(e) => return fail(StatusCode::BAD_REQUEST, e), + }; + let clean = clean_criteria(list); + // critSave : une liste vide supprime la clé. + let _ = if clean.is_empty() { + ctx.store.del(BK_CHAT, &crit_key(&conv_id)).await + } else { + ctx.store + .put_seal( + BK_CHAT, + &crit_key(&conv_id), + gojson::marshal(&clean).into_bytes(), + ) + .await + .map_err(|e| format!("{e:?}")) + }; + // critNotify. + let mut d = Map::new(); + d.insert("criteria".into(), json!(clean)); + d.insert("ts".into(), json!(now_ms())); + chat.push_delta(d); + let out = if clean.is_empty() { + Value::Null + } else { + json!(clean) + }; + send_json(StatusCode::OK, &json!({ "ok": true, "criteria": out })) + } + _ => fail(StatusCode::NOT_FOUND, "route inconnue"), + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn atoi_comme_go() { + assert_eq!(go_atoi("12"), Some(12)); + assert_eq!(go_atoi("+3"), Some(3)); + assert_eq!(go_atoi("-2"), Some(-2)); + assert_eq!(go_atoi("1.5"), None); + assert_eq!(go_atoi(" 1"), None); + assert_eq!(go_atoi(""), None); + } + + #[test] + fn indentation_html_echappee() { + let b = marshal_indent(&json!({"a": " & c", "l": []})); + assert_eq!( + String::from_utf8(b).unwrap(), + "{\n \"a\": \"\\u003cb\\u003e \\u0026 c\",\n \"l\": []\n}" + ); + } +} diff --git a/rust/loki-rs/src/lib.rs b/rust/loki-rs/src/lib.rs index ed8b0dc..b558f16 100644 --- a/rust/loki-rs/src/lib.rs +++ b/rust/loki-rs/src/lib.rs @@ -14,6 +14,7 @@ pub mod api; pub mod auth; pub mod chat; pub mod chatapi; +pub mod chatx; pub mod child; pub mod convfiles; pub mod dictate; diff --git a/rust/loki-rs/src/proxy.rs b/rust/loki-rs/src/proxy.rs index 99927ae..ec8a8bf 100644 --- a/rust/loki-rs/src/proxy.rs +++ b/rust/loki-rs/src/proxy.rs @@ -206,6 +206,11 @@ impl Proxy { if api::handles(req.method(), path) { return api::serve(&self.ctx, req).await; } + if let Some(chat) = &self.ctx.chat + && crate::chatx::handles(path) + { + return crate::chatx::serve(&self.ctx, chat, req, from_go).await; + } if let Some(chat) = &self.ctx.chat && chatapi::handles(path) { diff --git a/rust/loki-rs/tests/chatx.rs b/rust/loki-rs/tests/chatx.rs new file mode 100644 index 0000000..de50edc --- /dev/null +++ b/rust/loki-rs/tests/chatx.rs @@ -0,0 +1,186 @@ +//! Lot 19 : export, mode Code et critères de la discussion tenus par la +//! façade quand elle tient la conversation (`LOKI_CHAT=rust`). +//! +//! Le journal tiré du Go (`internal/loki/parity_chatx_test.go` → +//! `tests/parity_chatx/chatx.json`) est rejoué : l'export Markdown et JSON de +//! la même conversation sous chaque jeu d'options, puis les appels aux routes +//! du mode et des critères par la façade, avec la base et les événements du +//! fil après chacun. + +mod common; + +use common::fakego::{self, CryptMode}; +use loki_rs::chat::persist::decode_state; +use loki_rs::chatx::{self as cx, ExportOpts}; +use serde_json::{Value, json}; + +fn fixture() -> Value { + serde_json::from_str(include_str!("parity_chatx/chatx.json")).expect("journal") +} + +fn s(v: &Value) -> &str { + v.as_str().unwrap() +} + +#[test] +fn parite_de_l_export() { + let fx = fixture(); + // La conversation VIVANTE, images comprises (decode_state, comme le + // rechargement du Go, les retire d'un fil relu). + let c = &fx["conversation"]; + let conv = decode_state(c.to_string().as_bytes()); + let messages: Vec = + serde_json::from_value(c["messages"].clone()).unwrap(); + let log: Vec = + serde_json::from_value(c["log"].clone()).unwrap(); + assert_eq!(conv.log, log); + for c in fx["exports"].as_array().unwrap() { + let q = s(&c["query"]); + let o = cx::opts_from_query(q); + let want = &c["opts"]; + assert_eq!( + o, + ExportOpts { + format: s(&want["format"]).into(), + reasoning: want["reasoning"].as_bool().unwrap(), + tools: want["tools"].as_bool().unwrap(), + results: want["results"].as_bool().unwrap(), + turns: want["turns"].as_i64().unwrap(), + }, + "options de {q:?}" + ); + let body = if o.format == "json" { + String::from_utf8(cx::export_json( + &messages, + &log, + conv.ctx_used, + &o, + "{{DATE}}", + "{{VERSION}}", + )) + .unwrap() + } else { + cx::export_markdown(&log, &o, "{{DATE}}", "{{VERSION}}") + }; + assert_eq!(body, s(&c["body"]), "export de {q:?}"); + } + let o = ExportOpts::default(); + assert_eq!( + cx::export_markdown(&[], &o, "{{DATE}}", "{{VERSION}}"), + s(&fx["empty_md"]) + ); + assert_eq!( + String::from_utf8(cx::export_json(&[], &[], 0, &o, "{{DATE}}", "{{VERSION}}")).unwrap(), + s(&fx["empty_json"]) + ); +} + +#[tokio::test] +async fn parite_du_mode_et_des_criteres() { + let tmp = tempfile::tempdir().unwrap(); + let (facade, go, _stop, chat) = fakego::start_with( + tmp.path().to_path_buf(), + fakego::Opts { + chat: true, + ..Default::default() + }, + ) + .await; + let chat = chat.unwrap(); + *go.crypt_mode.lock().unwrap() = CryptMode::Plain; + let conv = chat.sessions.conv(); + let id = conv.id(); + let log_len = || conv.with_state(|st| st.log.len()); + for st in fixture()["steps"].as_array().unwrap() { + let name = s(&st["name"]); + let before = log_len(); + let (code, body) = fakego::send( + facade, + s(&st["method"]), + s(&st["path"]), + &[], + st["body"].as_str().unwrap_or(""), + ) + .await; + assert_eq!( + code.as_u16() as i64, + st["status"].as_i64().unwrap(), + "{name}" + ); + let out: Value = serde_json::from_slice(&body).unwrap(); + assert_eq!(out, st["out"], "{name}"); + assert_eq!( + go.get("chat", &format!("mode:{id}")).unwrap_or_default(), + s(&st["mode"]), + "{name} : mode" + ); + let crit: Value = go + .get("chat", &format!("crit:{id}")) + .map(|v| serde_json::from_str(&v).unwrap()) + .unwrap_or(Value::Null); + assert_eq!(crit, st["criteria"], "{name} : critères"); + let deltas: Vec = conv.with_state(|s| { + s.log[before..] + .iter() + .map(|e| { + let mut d = e.delta.clone(); + d.remove("ts"); + Value::Object(d) + }) + .collect() + }); + let want = st["deltas"].as_array().cloned().unwrap_or_default(); + assert_eq!(deltas, want, "{name} : fil"); + } +} + +#[tokio::test] +async fn export_par_la_route() { + let tmp = tempfile::tempdir().unwrap(); + let (facade, _go, _stop, chat) = fakego::start_with( + tmp.path().to_path_buf(), + fakego::Opts { + chat: true, + ..Default::default() + }, + ) + .await; + let chat = chat.unwrap(); + let conv = chat.sessions.conv(); + let mut d = serde_json::Map::new(); + d.insert("user".into(), json!("Salut")); + conv.append_delta(conv.epoch(), d); + let mut d = serde_json::Map::new(); + d.insert("content".into(), json!("Bonjour !")); + conv.append_delta(conv.epoch(), d); + let (code, headers, body) = + fakego::get(facade, "/api/chat/export?format=json&reasoning=0", &[]).await; + assert_eq!(code, 200); + assert_eq!(headers["content-type"], "application/json; charset=utf-8"); + let disp = headers["content-disposition"].to_str().unwrap(); + assert!( + disp.starts_with("attachment; filename=\"loki-conversation-") && disp.ends_with(".json\""), + "{disp}" + ); + assert_eq!(headers["content-length"], body.len().to_string().as_str()); + let v: Value = serde_json::from_slice(&body).unwrap(); + assert_eq!(v["log"].as_array().unwrap().len(), 2); + assert!(v["exported_at"].as_str().unwrap().contains('T')); + let (_, headers, body) = fakego::get(facade, "/api/chat/export", &[]).await; + assert_eq!(headers["content-type"], "text/markdown; charset=utf-8"); + let md = String::from_utf8(body.to_vec()).unwrap(); + assert!( + md.contains("## Vous\n\nSalut\n") && md.contains("## Loki\n\nBonjour !\n"), + "{md}" + ); +} + +#[tokio::test] +async fn sans_la_conversation_ces_routes_restent_au_go() { + let tmp = tempfile::tempdir().unwrap(); + let (facade, _go, _stop) = fakego::start(tmp.path().to_path_buf()).await; + for p in ["/api/chat/export", "/api/chat/mode", "/api/chat/criteria"] { + let (_, _, b) = fakego::get(facade, p, &[]).await; + assert_eq!(&b[..], format!("go:{p}").as_bytes()); + } +} diff --git a/rust/loki-rs/tests/lot6.rs b/rust/loki-rs/tests/lot6.rs index c63b16b..b7a44eb 100644 --- a/rust/loki-rs/tests/lot6.rs +++ b/rust/loki-rs/tests/lot6.rs @@ -1352,8 +1352,8 @@ async fn routes_du_chat_par_la_facade() { let (_, list) = call(facade, "GET", "/api/conversations", "").await; assert_eq!(list["conversations"][0]["title"], "Mon fil"); - // Mode Code : servi par le Go, qui pousse son événement au journal d'ici - // par l'API interne de la façade (jeton interne exigé). + // Mode Code : servi par la façade (lot 19), son événement au journal + // d'ici, sans passer par le Go. assert_eq!( call(facade, "POST", "/api/chat/mode", r#"{"mode":"code"}"#) .await @@ -1361,11 +1361,14 @@ async fn routes_du_chat_par_la_facade() { 200 ); assert!( - go.relayed + !go.relayed .lock() .unwrap() .contains(&"/api/chat/mode".to_string()) ); + assert_eq!(deltas(&chat, "mode"), [serde_json::json!("code")]); + // Le Go pousse encore ses propres événements (indice de code, outil + // criteria) par l'API interne de la façade (jeton interne exigé). let (code, _) = call( facade, "POST", @@ -1376,7 +1379,10 @@ async fn routes_du_chat_par_la_facade() { assert_eq!(code, 404, "sans le jeton, l'API interne n'existe pas"); let code = call_internal(facade, "/internal/facade/v1/delta", r#"{"mode":"code"}"#).await; assert_eq!(code, 204); - assert_eq!(deltas(&chat, "mode"), [serde_json::json!("code")]); + assert_eq!( + deltas(&chat, "mode"), + [serde_json::json!("code"), serde_json::json!("code")] + ); // Fil déjà rempli : le déverrouillage ne le recharge pas. assert_eq!( call_internal(facade, "/internal/facade/v1/reload", "").await, diff --git a/rust/loki-rs/tests/parity_chatx/chatx.json b/rust/loki-rs/tests/parity_chatx/chatx.json new file mode 100644 index 0000000..ef32148 --- /dev/null +++ b/rust/loki-rs/tests/parity_chatx/chatx.json @@ -0,0 +1,1317 @@ +{ + "conversation": { + "messages": [ + { + "role": "user", + "content": "\u003ccontext_update from=\"loki\" v=\"1\"\u003eprojet\u003c/context_update\u003e\n\nBonjour \u003cb\u003e \u0026 Cie", + "ctx_upd": true + }, + { + "role": "user", + "content": "\u003ccontext_update from=\"loki\" v=\"2\"\u003etapé\u003c/context_update\u003e par l'utilisateur" + }, + { + "role": "assistant", + "content": "", + "tool_calls": [ + { + "id": "c1", + "type": "function", + "function": { + "name": "bash", + "arguments": "{\"cmd\":\"ls\"}" + } + } + ] + }, + { + "role": "tool", + "content": "a.txt\nb.txt", + "tool_call_id": "c1" + }, + { + "role": "assistant", + "content": "Voilà.", + "reasoning_content": "je liste", + "reasoning_model": "m" + }, + { + "role": "user", + "content": "You reasoned but did not call a tool or answer. Act NOW: call the appropriate tool directly (e.g. mem_search/mem_read/bash), or give your final answer if you already have the info. Don't explain, act." + }, + { + "role": "user", + "content": [ + { + "text": "Regarde", + "type": "text" + }, + { + "image_url": { + "detail": "low", + "url": "data:image/png;base64,QUJDQUJDQUJDQUJDQUJDQUJDQUJDQUJDQUJDQUJDQUJDQUJDQUJDQUJDQUJDQUJDQUJDQUJDQUJDQUJDQUJDQUJDQUJDQUJDQUJDQUJDQUJDQUJDQUJDQUJDQUJDQUJDQUJDQUJDQUJDQUJDQUJDQUJDQUJDQUJDQUJDQUJDQUJDQUJDQUJDQUJDQUJDQUJDQUJDQUJDQUJDQUJDQUJDQUJDQUJDQUJDQUJDQUJDQUJDQUJDQUJDQUJDQUJDQUJDQUJDQUJDQUJDQUJDQUJDQUJDQUJDQUJDQUJDQUJDQUJDQUJDQUJDQUJDQUJDQUJDQUJDQUJDQUJDQUJDQUJDQUJDQUJDQUJDQUJDQUJDQUJDQUJDQUJDQUJDQUJDQUJDQUJDQUJDQUJDQUJDQUJDQUJDQUJDQUJDQUJDQUJDQUJDQUJDQUJDQUJDQUJDQUJDQUJDQUJDQUJDQUJDQUJDQUJDQUJDQUJDQUJDQUJDQUJDQUJDQUJDQUJDQUJDQUJDQUJDQUJDQUJDQUJDQUJDQUJDQUJDQUJDQUJDQUJDQUJDQUJDQUJDQUJDQUJDQUJDQUJDQUJDQUJDQUJDQUJDQUJDQUJDQUJDQUJDQUJDQUJDQUJDQUJDQUJDQUJDQUJDQUJDQUJDQUJDQUJDQUJDQUJDQUJDQUJDQUJDQUJDQUJDQUJDQUJDQUJDQUJDQUJDQUJDQUJDQUJDQUJDQUJDQUJDQUJDQUJDQUJDQUJDQUJDQUJDQUJDQUJDQUJDQUJDQUJDQUJDQUJDQUJDQUJDQUJDQUJDQUJDQUJDQUJDQUJDQUJDQUJDQUJDQUJDQUJDQUJDQUJDQUJDQUJDQUJDQUJDQUJDQUJDQUJDQUJDQUJDQUJDQUJDQUJDQUJDQUJDQUJDQUJDQUJDQUJDQUJDQUJDQUJDQUJDQUJDQUJDQUJDQUJDQUJDQUJDQUJDQUJDQUJDQUJDQUJDQUJDQUJDQUJDQUJDQUJDQUJDQUJDQUJDQUJDQUJDQUJDQUJDQUJDQUJDQUJDQUJDQUJDQUJDQUJDQUJDQUJDQUJDQUJDQUJDQUJDQUJDQUJDQUJDQUJDQUJDQUJDQUJDQUJDQUJDQUJDQUJDQUJDQUJDQUJDQUJDQUJDQUJDQUJDQUJDQUJDQUJDQUJDQUJDQUJDQUJDQUJDQUJDQUJDQUJDQUJDQUJDQUJDQUJDQUJDQUJDQUJDQUJDQUJDQUJDQUJDQUJDQUJDQUJDQUJDQUJDQUJDQUJDQUJDQUJDQUJDQUJDQUJDQUJDQUJDQUJDQUJDQUJDQUJDQUJDQUJDQUJDQUJDQUJDQUJDQUJDQUJDQUJDQUJDQUJDQUJDQUJDQUJDQUJDQUJDQUJDQUJDQUJDQUJDQUJDQUJDQUJDQUJDQUJDQUJDQUJDQUJDQUJDQUJDQUJDQUJDQUJDQUJDQUJDQUJDQUJDQUJDQUJDQUJDQUJDQUJDQUJDQUJDQUJDQUJDQUJDQUJDQUJDQUJDQUJDQUJDQUJDQUJDQUJDQUJDQUJDQUJDQUJDQUJDQUJDQUJDQUJDQUJDQUJDQUJDQUJDQUJDQUJDQUJDQUJDQUJDQUJDQUJDQUJDQUJDQUJDQUJDQUJDQUJDQUJDQUJDQUJDQUJDQUJDQUJDQUJDQUJDQUJDQUJDQUJDQUJDQUJDQUJDQUJDQUJDQUJDQUJDQUJDQUJDQUJDQUJDQUJDQUJDQUJDQUJDQUJDQUJDQUJDQUJDQUJDQUJDQUJDQUJDQUJDQUJDQUJDQUJDQUJDQUJDQUJDQUJDQUJDQUJDQUJDQUJDQUJDQUJDQUJDQUJDQUJDQUJDQUJDQUJDQUJDQUJDQUJDQUJDQUJDQUJDQUJDQUJDQUJDQUJDQUJDQUJDQUJDQUJDQUJDQUJDQUJDQUJDQUJDQUJDQUJDQUJDQUJDQUJDQUJDQUJDQUJDQUJDQUJDQUJDQUJDQUJDQUJDQUJDQUJDQUJDQUJDQUJDQUJDQUJDQUJDQUJDQUJDQUJDQUJDQUJDQUJDQUJDQUJDQUJDQUJDQUJDQUJDQUJDQUJDQUJDQUJDQUJDQUJDQUJDQUJDQUJDQUJDQUJDQUJDQUJDQUJDQUJDQUJDQUJDQUJDQUJDQUJDQUJDQUJDQUJDQUJDQUJDQUJDQUJDQUJDQUJDQUJDQUJDQUJDQUJDQUJDQUJDQUJDQUJDQUJDQUJDQUJDQUJDQUJDQUJDQUJDQUJDQUJDQUJDQUJDQUJDQUJDQUJDQUJDQUJDQUJDQUJDQUJDQUJDQUJDQUJDQUJDQUJDQUJDQUJDQUJDQUJDQUJDQUJDQUJDQUJDQUJDQUJDQUJDQUJDQUJDQUJDQUJDQUJDQUJDQUJDQUJDQUJDQUJDQUJDQUJDQUJDQUJDQUJD" + }, + "type": "image_url" + }, + { + "image_url": { + "url": "https://x/y.png" + }, + "type": "image_url" + }, + "brut" + ] + }, + { + "role": "assistant", + "content": "Une image." + }, + { + "role": "user", + "content": "Dernier tour" + }, + { + "role": "assistant", + "content": "Fin." + } + ], + "log": [ + { + "seq": 1, + "ts": 1001, + "delta": { + "files": [ + { + "name": "rapport.pdf", + "size": 10 + }, + { + "name": "" + }, + "x" + ], + "user": "Bonjour \u003cb\u003e \u0026 Cie" + } + }, + { + "seq": 2, + "ts": 1002, + "delta": { + "reasoning_content": "je " + } + }, + { + "seq": 3, + "ts": 1003, + "delta": { + "reasoning_content": "liste\n" + } + }, + { + "seq": 4, + "ts": 1004, + "delta": { + "tool_used": { + "done": true, + "label": "ls *.txt\n[a]", + "name": "bash", + "result": "a.txt\nb.txt\n\n" + } + } + }, + { + "seq": 5, + "ts": 1005, + "delta": { + "tool_used": { + "done": true, + "name": "mem_read", + "result": " " + } + } + }, + { + "seq": 5, + "ts": 1005, + "delta": { + "tool_used": { + "done": true, + "label": " `--oneline` [main] ", + "name": "git_`log`" + } + } + }, + { + "seq": 5, + "ts": 1005, + "delta": { + "tool_used": { + "label": "pas fini", + "name": "en_cours" + } + } + }, + { + "seq": 6, + "ts": 1006, + "delta": { + "content": "Voi" + } + }, + { + "seq": 7, + "ts": 1007, + "delta": { + "content": "là.\n" + } + }, + { + "seq": 8, + "ts": 1008, + "delta": { + "stats": { + "tok_s": 12.5 + } + } + }, + { + "seq": 9, + "ts": 1009, + "delta": { + "compacted": true + } + }, + { + "seq": 10, + "ts": 1010, + "delta": { + "files": [ + { + "name": "photo.png" + } + ], + "user": "" + } + }, + { + "seq": 11, + "ts": 1011, + "delta": { + "content": "Une image." + } + }, + { + "seq": 12, + "ts": 1012, + "delta": { + "user": "Dernier tour" + } + }, + { + "seq": 13, + "ts": 1013, + "delta": { + "tool_used": { + "label": "https://ex.com/a_b*c", + "name": "web_open", + "result": "titre" + } + } + }, + { + "seq": 14, + "ts": 1014, + "delta": { + "content": "Fin." + } + } + ], + "seq": 14, + "ctx_used": 4321 + }, + "empty_json": "{\n \"loki_version\": \"{{VERSION}}\",\n \"exported_at\": \"{{DATE}}\",\n \"ctx_used\": 0,\n \"messages\": []\n}", + "empty_md": "# Conversation Loki\n\nExportée le {{DATE}} par Loki {{VERSION}}.\n\n_Conversation vide._\n", + "exports": [ + { + "query": "", + "opts": { + "format": "md", + "reasoning": true, + "results": true, + "tools": true, + "turns": 0 + }, + "body": "# Conversation Loki\n\nExportée le {{DATE}} par Loki {{VERSION}}.\n\n---\n\n## Vous\n\nBonjour & Cie\n\nFichiers joints : rapport.pdf\n\n## Loki\n\n
\nRaisonnement\n\nje liste\n\n
\n\n> 🔧 **bash** — ls \\*.txt \\[a\\]\n\n```\na.txt\nb.txt\n```\n\n> 🔧 **mem\\_read** — mem\\_read\n\n> 🔧 **git\\_\\`log\\`** — \\`--oneline\\` \\[main\\]\n\nVoilà.\n\n_(contexte compacté à cet endroit : les tours précédents ont été résumés pour le modèle, le fil ci-dessus reste complet)_\n\n---\n\n## Vous\n\n\n\nFichiers joints : photo.png\n\n## Loki\n\nUne image.\n\n---\n\n## Vous\n\nDernier tour\n\n## Loki\n\nFin.\n\n> 🔧 **web\\_open** — https://ex.com/a\\_b\\*c\n\n```\ntitre\n```\n", + "filename_ext": "md" + }, + { + "query": "format=json", + "opts": { + "format": "json", + "reasoning": true, + "results": true, + "tools": true, + "turns": 0 + }, + "body": "{\n \"loki_version\": \"{{VERSION}}\",\n \"exported_at\": \"{{DATE}}\",\n \"ctx_used\": 4321,\n \"messages\": [\n {\n \"role\": \"user\",\n \"content\": \"Bonjour \\u003cb\\u003e \\u0026 Cie\"\n },\n {\n \"role\": \"user\",\n \"content\": \"\\u003ccontext_update from=\\\"loki\\\" v=\\\"2\\\"\\u003etapé\\u003c/context_update\\u003e par l'utilisateur\"\n },\n {\n \"role\": \"assistant\",\n \"content\": \"\",\n \"tool_calls\": [\n {\n \"id\": \"c1\",\n \"type\": \"function\",\n \"function\": {\n \"name\": \"bash\",\n \"arguments\": \"{\\\"cmd\\\":\\\"ls\\\"}\"\n }\n }\n ]\n },\n {\n \"role\": \"tool\",\n \"content\": \"a.txt\\nb.txt\",\n \"tool_call_id\": \"c1\"\n },\n {\n \"role\": \"assistant\",\n \"content\": \"Voilà.\",\n \"reasoning_content\": \"je liste\",\n \"reasoning_model\": \"m\"\n },\n {\n \"role\": \"user\",\n \"content\": \"You reasoned but did not call a tool or answer. Act NOW: call the appropriate tool directly (e.g. mem_search/mem_read/bash), or give your final answer if you already have the info. Don't explain, act.\"\n },\n {\n \"role\": \"user\",\n \"content\": [\n {\n \"text\": \"Regarde\",\n \"type\": \"text\"\n },\n {\n \"image_url\": {\n \"detail\": \"low\",\n \"url\": \"data:image/png;base64,\\u003célidé — 2400 o\\u003e\"\n },\n \"type\": \"image_url\"\n },\n {\n \"image_url\": {\n \"url\": \"https://x/y.png\"\n },\n \"type\": \"image_url\"\n },\n \"brut\"\n ]\n },\n {\n \"role\": \"assistant\",\n \"content\": \"Une image.\"\n },\n {\n \"role\": \"user\",\n \"content\": \"Dernier tour\"\n },\n {\n \"role\": \"assistant\",\n \"content\": \"Fin.\"\n }\n ],\n \"log\": [\n {\n \"seq\": 1,\n \"ts\": 1001,\n \"delta\": {\n \"files\": [\n {\n \"name\": \"rapport.pdf\",\n \"size\": 10\n },\n {\n \"name\": \"\"\n },\n \"x\"\n ],\n \"user\": \"Bonjour \\u003cb\\u003e \\u0026 Cie\"\n }\n },\n {\n \"seq\": 2,\n \"ts\": 1002,\n \"delta\": {\n \"reasoning_content\": \"je \"\n }\n },\n {\n \"seq\": 3,\n \"ts\": 1003,\n \"delta\": {\n \"reasoning_content\": \"liste\\n\"\n }\n },\n {\n \"seq\": 4,\n \"ts\": 1004,\n \"delta\": {\n \"tool_used\": {\n \"done\": true,\n \"label\": \"ls *.txt\\n[a]\",\n \"name\": \"bash\",\n \"result\": \"a.txt\\nb.txt\\n\\n\"\n }\n }\n },\n {\n \"seq\": 5,\n \"ts\": 1005,\n \"delta\": {\n \"tool_used\": {\n \"done\": true,\n \"name\": \"mem_read\",\n \"result\": \" \"\n }\n }\n },\n {\n \"seq\": 5,\n \"ts\": 1005,\n \"delta\": {\n \"tool_used\": {\n \"done\": true,\n \"label\": \" `--oneline` [main] \",\n \"name\": \"git_`log`\"\n }\n }\n },\n {\n \"seq\": 5,\n \"ts\": 1005,\n \"delta\": {\n \"tool_used\": {\n \"label\": \"pas fini\",\n \"name\": \"en_cours\"\n }\n }\n },\n {\n \"seq\": 6,\n \"ts\": 1006,\n \"delta\": {\n \"content\": \"Voi\"\n }\n },\n {\n \"seq\": 7,\n \"ts\": 1007,\n \"delta\": {\n \"content\": \"là.\\n\"\n }\n },\n {\n \"seq\": 8,\n \"ts\": 1008,\n \"delta\": {\n \"stats\": {\n \"tok_s\": 12.5\n }\n }\n },\n {\n \"seq\": 9,\n \"ts\": 1009,\n \"delta\": {\n \"compacted\": true\n }\n },\n {\n \"seq\": 10,\n \"ts\": 1010,\n \"delta\": {\n \"files\": [\n {\n \"name\": \"photo.png\"\n }\n ],\n \"user\": \"\"\n }\n },\n {\n \"seq\": 11,\n \"ts\": 1011,\n \"delta\": {\n \"content\": \"Une image.\"\n }\n },\n {\n \"seq\": 12,\n \"ts\": 1012,\n \"delta\": {\n \"user\": \"Dernier tour\"\n }\n },\n {\n \"seq\": 13,\n \"ts\": 1013,\n \"delta\": {\n \"tool_used\": {\n \"label\": \"https://ex.com/a_b*c\",\n \"name\": \"web_open\",\n \"result\": \"titre\"\n }\n }\n },\n {\n \"seq\": 14,\n \"ts\": 1014,\n \"delta\": {\n \"content\": \"Fin.\"\n }\n }\n ]\n}", + "filename_ext": "json" + }, + { + "query": "format=md", + "opts": { + "format": "md", + "reasoning": true, + "results": true, + "tools": true, + "turns": 0 + }, + "body": "# Conversation Loki\n\nExportée le {{DATE}} par Loki {{VERSION}}.\n\n---\n\n## Vous\n\nBonjour & Cie\n\nFichiers joints : rapport.pdf\n\n## Loki\n\n
\nRaisonnement\n\nje liste\n\n
\n\n> 🔧 **bash** — ls \\*.txt \\[a\\]\n\n```\na.txt\nb.txt\n```\n\n> 🔧 **mem\\_read** — mem\\_read\n\n> 🔧 **git\\_\\`log\\`** — \\`--oneline\\` \\[main\\]\n\nVoilà.\n\n_(contexte compacté à cet endroit : les tours précédents ont été résumés pour le modèle, le fil ci-dessus reste complet)_\n\n---\n\n## Vous\n\n\n\nFichiers joints : photo.png\n\n## Loki\n\nUne image.\n\n---\n\n## Vous\n\nDernier tour\n\n## Loki\n\nFin.\n\n> 🔧 **web\\_open** — https://ex.com/a\\_b\\*c\n\n```\ntitre\n```\n", + "filename_ext": "md" + }, + { + "query": "format=JSON", + "opts": { + "format": "md", + "reasoning": true, + "results": true, + "tools": true, + "turns": 0 + }, + "body": "# Conversation Loki\n\nExportée le {{DATE}} par Loki {{VERSION}}.\n\n---\n\n## Vous\n\nBonjour & Cie\n\nFichiers joints : rapport.pdf\n\n## Loki\n\n
\nRaisonnement\n\nje liste\n\n
\n\n> 🔧 **bash** — ls \\*.txt \\[a\\]\n\n```\na.txt\nb.txt\n```\n\n> 🔧 **mem\\_read** — mem\\_read\n\n> 🔧 **git\\_\\`log\\`** — \\`--oneline\\` \\[main\\]\n\nVoilà.\n\n_(contexte compacté à cet endroit : les tours précédents ont été résumés pour le modèle, le fil ci-dessus reste complet)_\n\n---\n\n## Vous\n\n\n\nFichiers joints : photo.png\n\n## Loki\n\nUne image.\n\n---\n\n## Vous\n\nDernier tour\n\n## Loki\n\nFin.\n\n> 🔧 **web\\_open** — https://ex.com/a\\_b\\*c\n\n```\ntitre\n```\n", + "filename_ext": "md" + }, + { + "query": "reasoning=0", + "opts": { + "format": "md", + "reasoning": false, + "results": true, + "tools": true, + "turns": 0 + }, + "body": "# Conversation Loki\n\nExportée le {{DATE}} par Loki {{VERSION}}.\n\n_Export partiel : raisonnements retirés._\n\n---\n\n## Vous\n\nBonjour & Cie\n\nFichiers joints : rapport.pdf\n\n## Loki\n\n> 🔧 **bash** — ls \\*.txt \\[a\\]\n\n```\na.txt\nb.txt\n```\n\n> 🔧 **mem\\_read** — mem\\_read\n\n> 🔧 **git\\_\\`log\\`** — \\`--oneline\\` \\[main\\]\n\nVoilà.\n\n_(contexte compacté à cet endroit : les tours précédents ont été résumés pour le modèle, le fil ci-dessus reste complet)_\n\n---\n\n## Vous\n\n\n\nFichiers joints : photo.png\n\n## Loki\n\nUne image.\n\n---\n\n## Vous\n\nDernier tour\n\n## Loki\n\nFin.\n\n> 🔧 **web\\_open** — https://ex.com/a\\_b\\*c\n\n```\ntitre\n```\n", + "filename_ext": "md" + }, + { + "query": "reasoning=true&tools=off", + "opts": { + "format": "md", + "reasoning": true, + "results": false, + "tools": false, + "turns": 0 + }, + "body": "# Conversation Loki\n\nExportée le {{DATE}} par Loki {{VERSION}}.\n\n_Export partiel : appels d'outils retirés._\n\n---\n\n## Vous\n\nBonjour & Cie\n\nFichiers joints : rapport.pdf\n\n## Loki\n\n
\nRaisonnement\n\nje liste\n\n
\n\nVoilà.\n\n_(contexte compacté à cet endroit : les tours précédents ont été résumés pour le modèle, le fil ci-dessus reste complet)_\n\n---\n\n## Vous\n\n\n\nFichiers joints : photo.png\n\n## Loki\n\nUne image.\n\n---\n\n## Vous\n\nDernier tour\n\n## Loki\n\nFin.\n", + "filename_ext": "md" + }, + { + "query": "tools=0", + "opts": { + "format": "md", + "reasoning": true, + "results": false, + "tools": false, + "turns": 0 + }, + "body": "# Conversation Loki\n\nExportée le {{DATE}} par Loki {{VERSION}}.\n\n_Export partiel : appels d'outils retirés._\n\n---\n\n## Vous\n\nBonjour & Cie\n\nFichiers joints : rapport.pdf\n\n## Loki\n\n
\nRaisonnement\n\nje liste\n\n
\n\nVoilà.\n\n_(contexte compacté à cet endroit : les tours précédents ont été résumés pour le modèle, le fil ci-dessus reste complet)_\n\n---\n\n## Vous\n\n\n\nFichiers joints : photo.png\n\n## Loki\n\nUne image.\n\n---\n\n## Vous\n\nDernier tour\n\n## Loki\n\nFin.\n", + "filename_ext": "md" + }, + { + "query": "tools=0&format=json", + "opts": { + "format": "json", + "reasoning": true, + "results": false, + "tools": false, + "turns": 0 + }, + "body": "{\n \"loki_version\": \"{{VERSION}}\",\n \"exported_at\": \"{{DATE}}\",\n \"ctx_used\": 4321,\n \"messages\": [\n {\n \"role\": \"user\",\n \"content\": \"Bonjour \\u003cb\\u003e \\u0026 Cie\"\n },\n {\n \"role\": \"user\",\n \"content\": \"\\u003ccontext_update from=\\\"loki\\\" v=\\\"2\\\"\\u003etapé\\u003c/context_update\\u003e par l'utilisateur\"\n },\n {\n \"role\": \"assistant\",\n \"content\": \"\",\n \"tool_calls\": [\n {\n \"id\": \"c1\",\n \"type\": \"function\",\n \"function\": {\n \"name\": \"bash\",\n \"arguments\": \"{\\\"cmd\\\":\\\"ls\\\"}\"\n }\n }\n ]\n },\n {\n \"role\": \"tool\",\n \"content\": \"a.txt\\nb.txt\",\n \"tool_call_id\": \"c1\"\n },\n {\n \"role\": \"assistant\",\n \"content\": \"Voilà.\",\n \"reasoning_content\": \"je liste\",\n \"reasoning_model\": \"m\"\n },\n {\n \"role\": \"user\",\n \"content\": \"You reasoned but did not call a tool or answer. Act NOW: call the appropriate tool directly (e.g. mem_search/mem_read/bash), or give your final answer if you already have the info. Don't explain, act.\"\n },\n {\n \"role\": \"user\",\n \"content\": [\n {\n \"text\": \"Regarde\",\n \"type\": \"text\"\n },\n {\n \"image_url\": {\n \"detail\": \"low\",\n \"url\": \"data:image/png;base64,\\u003célidé — 2400 o\\u003e\"\n },\n \"type\": \"image_url\"\n },\n {\n \"image_url\": {\n \"url\": \"https://x/y.png\"\n },\n \"type\": \"image_url\"\n },\n \"brut\"\n ]\n },\n {\n \"role\": \"assistant\",\n \"content\": \"Une image.\"\n },\n {\n \"role\": \"user\",\n \"content\": \"Dernier tour\"\n },\n {\n \"role\": \"assistant\",\n \"content\": \"Fin.\"\n }\n ],\n \"log\": [\n {\n \"seq\": 1,\n \"ts\": 1001,\n \"delta\": {\n \"files\": [\n {\n \"name\": \"rapport.pdf\",\n \"size\": 10\n },\n {\n \"name\": \"\"\n },\n \"x\"\n ],\n \"user\": \"Bonjour \\u003cb\\u003e \\u0026 Cie\"\n }\n },\n {\n \"seq\": 2,\n \"ts\": 1002,\n \"delta\": {\n \"reasoning_content\": \"je \"\n }\n },\n {\n \"seq\": 3,\n \"ts\": 1003,\n \"delta\": {\n \"reasoning_content\": \"liste\\n\"\n }\n },\n {\n \"seq\": 6,\n \"ts\": 1006,\n \"delta\": {\n \"content\": \"Voi\"\n }\n },\n {\n \"seq\": 7,\n \"ts\": 1007,\n \"delta\": {\n \"content\": \"là.\\n\"\n }\n },\n {\n \"seq\": 8,\n \"ts\": 1008,\n \"delta\": {\n \"stats\": {\n \"tok_s\": 12.5\n }\n }\n },\n {\n \"seq\": 9,\n \"ts\": 1009,\n \"delta\": {\n \"compacted\": true\n }\n },\n {\n \"seq\": 10,\n \"ts\": 1010,\n \"delta\": {\n \"files\": [\n {\n \"name\": \"photo.png\"\n }\n ],\n \"user\": \"\"\n }\n },\n {\n \"seq\": 11,\n \"ts\": 1011,\n \"delta\": {\n \"content\": \"Une image.\"\n }\n },\n {\n \"seq\": 12,\n \"ts\": 1012,\n \"delta\": {\n \"user\": \"Dernier tour\"\n }\n },\n {\n \"seq\": 14,\n \"ts\": 1014,\n \"delta\": {\n \"content\": \"Fin.\"\n }\n }\n ]\n}", + "filename_ext": "json" + }, + { + "query": "results=0", + "opts": { + "format": "md", + "reasoning": true, + "results": false, + "tools": true, + "turns": 0 + }, + "body": "# Conversation Loki\n\nExportée le {{DATE}} par Loki {{VERSION}}.\n\n_Export partiel : sorties d'outils retirées._\n\n---\n\n## Vous\n\nBonjour & Cie\n\nFichiers joints : rapport.pdf\n\n## Loki\n\n
\nRaisonnement\n\nje liste\n\n
\n\n> 🔧 **bash** — ls \\*.txt \\[a\\]\n\n> 🔧 **mem\\_read** — mem\\_read\n\n> 🔧 **git\\_\\`log\\`** — \\`--oneline\\` \\[main\\]\n\nVoilà.\n\n_(contexte compacté à cet endroit : les tours précédents ont été résumés pour le modèle, le fil ci-dessus reste complet)_\n\n---\n\n## Vous\n\n\n\nFichiers joints : photo.png\n\n## Loki\n\nUne image.\n\n---\n\n## Vous\n\nDernier tour\n\n## Loki\n\nFin.\n\n> 🔧 **web\\_open** — https://ex.com/a\\_b\\*c\n", + "filename_ext": "md" + }, + { + "query": "results=0&format=json", + "opts": { + "format": "json", + "reasoning": true, + "results": false, + "tools": true, + "turns": 0 + }, + "body": "{\n \"loki_version\": \"{{VERSION}}\",\n \"exported_at\": \"{{DATE}}\",\n \"ctx_used\": 4321,\n \"messages\": [\n {\n \"role\": \"user\",\n \"content\": \"Bonjour \\u003cb\\u003e \\u0026 Cie\"\n },\n {\n \"role\": \"user\",\n \"content\": \"\\u003ccontext_update from=\\\"loki\\\" v=\\\"2\\\"\\u003etapé\\u003c/context_update\\u003e par l'utilisateur\"\n },\n {\n \"role\": \"assistant\",\n \"content\": \"\",\n \"tool_calls\": [\n {\n \"id\": \"c1\",\n \"type\": \"function\",\n \"function\": {\n \"name\": \"bash\",\n \"arguments\": \"{\\\"cmd\\\":\\\"ls\\\"}\"\n }\n }\n ]\n },\n {\n \"role\": \"tool\",\n \"content\": \"a.txt\\nb.txt\",\n \"tool_call_id\": \"c1\"\n },\n {\n \"role\": \"assistant\",\n \"content\": \"Voilà.\",\n \"reasoning_content\": \"je liste\",\n \"reasoning_model\": \"m\"\n },\n {\n \"role\": \"user\",\n \"content\": \"You reasoned but did not call a tool or answer. Act NOW: call the appropriate tool directly (e.g. mem_search/mem_read/bash), or give your final answer if you already have the info. Don't explain, act.\"\n },\n {\n \"role\": \"user\",\n \"content\": [\n {\n \"text\": \"Regarde\",\n \"type\": \"text\"\n },\n {\n \"image_url\": {\n \"detail\": \"low\",\n \"url\": \"data:image/png;base64,\\u003célidé — 2400 o\\u003e\"\n },\n \"type\": \"image_url\"\n },\n {\n \"image_url\": {\n \"url\": \"https://x/y.png\"\n },\n \"type\": \"image_url\"\n },\n \"brut\"\n ]\n },\n {\n \"role\": \"assistant\",\n \"content\": \"Une image.\"\n },\n {\n \"role\": \"user\",\n \"content\": \"Dernier tour\"\n },\n {\n \"role\": \"assistant\",\n \"content\": \"Fin.\"\n }\n ],\n \"log\": [\n {\n \"seq\": 1,\n \"ts\": 1001,\n \"delta\": {\n \"files\": [\n {\n \"name\": \"rapport.pdf\",\n \"size\": 10\n },\n {\n \"name\": \"\"\n },\n \"x\"\n ],\n \"user\": \"Bonjour \\u003cb\\u003e \\u0026 Cie\"\n }\n },\n {\n \"seq\": 2,\n \"ts\": 1002,\n \"delta\": {\n \"reasoning_content\": \"je \"\n }\n },\n {\n \"seq\": 3,\n \"ts\": 1003,\n \"delta\": {\n \"reasoning_content\": \"liste\\n\"\n }\n },\n {\n \"seq\": 4,\n \"ts\": 1004,\n \"delta\": {\n \"tool_used\": {\n \"done\": true,\n \"label\": \"ls *.txt\\n[a]\",\n \"name\": \"bash\"\n }\n }\n },\n {\n \"seq\": 5,\n \"ts\": 1005,\n \"delta\": {\n \"tool_used\": {\n \"done\": true,\n \"name\": \"mem_read\"\n }\n }\n },\n {\n \"seq\": 5,\n \"ts\": 1005,\n \"delta\": {\n \"tool_used\": {\n \"done\": true,\n \"label\": \" `--oneline` [main] \",\n \"name\": \"git_`log`\"\n }\n }\n },\n {\n \"seq\": 5,\n \"ts\": 1005,\n \"delta\": {\n \"tool_used\": {\n \"label\": \"pas fini\",\n \"name\": \"en_cours\"\n }\n }\n },\n {\n \"seq\": 6,\n \"ts\": 1006,\n \"delta\": {\n \"content\": \"Voi\"\n }\n },\n {\n \"seq\": 7,\n \"ts\": 1007,\n \"delta\": {\n \"content\": \"là.\\n\"\n }\n },\n {\n \"seq\": 8,\n \"ts\": 1008,\n \"delta\": {\n \"stats\": {\n \"tok_s\": 12.5\n }\n }\n },\n {\n \"seq\": 9,\n \"ts\": 1009,\n \"delta\": {\n \"compacted\": true\n }\n },\n {\n \"seq\": 10,\n \"ts\": 1010,\n \"delta\": {\n \"files\": [\n {\n \"name\": \"photo.png\"\n }\n ],\n \"user\": \"\"\n }\n },\n {\n \"seq\": 11,\n \"ts\": 1011,\n \"delta\": {\n \"content\": \"Une image.\"\n }\n },\n {\n \"seq\": 12,\n \"ts\": 1012,\n \"delta\": {\n \"user\": \"Dernier tour\"\n }\n },\n {\n \"seq\": 13,\n \"ts\": 1013,\n \"delta\": {\n \"tool_used\": {\n \"label\": \"https://ex.com/a_b*c\",\n \"name\": \"web_open\"\n }\n }\n },\n {\n \"seq\": 14,\n \"ts\": 1014,\n \"delta\": {\n \"content\": \"Fin.\"\n }\n }\n ]\n}", + "filename_ext": "json" + }, + { + "query": "reasoning=0&format=json", + "opts": { + "format": "json", + "reasoning": false, + "results": true, + "tools": true, + "turns": 0 + }, + "body": "{\n \"loki_version\": \"{{VERSION}}\",\n \"exported_at\": \"{{DATE}}\",\n \"ctx_used\": 4321,\n \"messages\": [\n {\n \"role\": \"user\",\n \"content\": \"Bonjour \\u003cb\\u003e \\u0026 Cie\"\n },\n {\n \"role\": \"user\",\n \"content\": \"\\u003ccontext_update from=\\\"loki\\\" v=\\\"2\\\"\\u003etapé\\u003c/context_update\\u003e par l'utilisateur\"\n },\n {\n \"role\": \"assistant\",\n \"content\": \"\",\n \"tool_calls\": [\n {\n \"id\": \"c1\",\n \"type\": \"function\",\n \"function\": {\n \"name\": \"bash\",\n \"arguments\": \"{\\\"cmd\\\":\\\"ls\\\"}\"\n }\n }\n ]\n },\n {\n \"role\": \"tool\",\n \"content\": \"a.txt\\nb.txt\",\n \"tool_call_id\": \"c1\"\n },\n {\n \"role\": \"assistant\",\n \"content\": \"Voilà.\",\n \"reasoning_content\": \"je liste\",\n \"reasoning_model\": \"m\"\n },\n {\n \"role\": \"user\",\n \"content\": \"You reasoned but did not call a tool or answer. Act NOW: call the appropriate tool directly (e.g. mem_search/mem_read/bash), or give your final answer if you already have the info. Don't explain, act.\"\n },\n {\n \"role\": \"user\",\n \"content\": [\n {\n \"text\": \"Regarde\",\n \"type\": \"text\"\n },\n {\n \"image_url\": {\n \"detail\": \"low\",\n \"url\": \"data:image/png;base64,\\u003célidé — 2400 o\\u003e\"\n },\n \"type\": \"image_url\"\n },\n {\n \"image_url\": {\n \"url\": \"https://x/y.png\"\n },\n \"type\": \"image_url\"\n },\n \"brut\"\n ]\n },\n {\n \"role\": \"assistant\",\n \"content\": \"Une image.\"\n },\n {\n \"role\": \"user\",\n \"content\": \"Dernier tour\"\n },\n {\n \"role\": \"assistant\",\n \"content\": \"Fin.\"\n }\n ],\n \"log\": [\n {\n \"seq\": 1,\n \"ts\": 1001,\n \"delta\": {\n \"files\": [\n {\n \"name\": \"rapport.pdf\",\n \"size\": 10\n },\n {\n \"name\": \"\"\n },\n \"x\"\n ],\n \"user\": \"Bonjour \\u003cb\\u003e \\u0026 Cie\"\n }\n },\n {\n \"seq\": 4,\n \"ts\": 1004,\n \"delta\": {\n \"tool_used\": {\n \"done\": true,\n \"label\": \"ls *.txt\\n[a]\",\n \"name\": \"bash\",\n \"result\": \"a.txt\\nb.txt\\n\\n\"\n }\n }\n },\n {\n \"seq\": 5,\n \"ts\": 1005,\n \"delta\": {\n \"tool_used\": {\n \"done\": true,\n \"name\": \"mem_read\",\n \"result\": \" \"\n }\n }\n },\n {\n \"seq\": 5,\n \"ts\": 1005,\n \"delta\": {\n \"tool_used\": {\n \"done\": true,\n \"label\": \" `--oneline` [main] \",\n \"name\": \"git_`log`\"\n }\n }\n },\n {\n \"seq\": 5,\n \"ts\": 1005,\n \"delta\": {\n \"tool_used\": {\n \"label\": \"pas fini\",\n \"name\": \"en_cours\"\n }\n }\n },\n {\n \"seq\": 6,\n \"ts\": 1006,\n \"delta\": {\n \"content\": \"Voi\"\n }\n },\n {\n \"seq\": 7,\n \"ts\": 1007,\n \"delta\": {\n \"content\": \"là.\\n\"\n }\n },\n {\n \"seq\": 8,\n \"ts\": 1008,\n \"delta\": {\n \"stats\": {\n \"tok_s\": 12.5\n }\n }\n },\n {\n \"seq\": 9,\n \"ts\": 1009,\n \"delta\": {\n \"compacted\": true\n }\n },\n {\n \"seq\": 10,\n \"ts\": 1010,\n \"delta\": {\n \"files\": [\n {\n \"name\": \"photo.png\"\n }\n ],\n \"user\": \"\"\n }\n },\n {\n \"seq\": 11,\n \"ts\": 1011,\n \"delta\": {\n \"content\": \"Une image.\"\n }\n },\n {\n \"seq\": 12,\n \"ts\": 1012,\n \"delta\": {\n \"user\": \"Dernier tour\"\n }\n },\n {\n \"seq\": 13,\n \"ts\": 1013,\n \"delta\": {\n \"tool_used\": {\n \"label\": \"https://ex.com/a_b*c\",\n \"name\": \"web_open\",\n \"result\": \"titre\"\n }\n }\n },\n {\n \"seq\": 14,\n \"ts\": 1014,\n \"delta\": {\n \"content\": \"Fin.\"\n }\n }\n ]\n}", + "filename_ext": "json" + }, + { + "query": "turns=1", + "opts": { + "format": "md", + "reasoning": true, + "results": true, + "tools": true, + "turns": 1 + }, + "body": "# Conversation Loki\n\nExportée le {{DATE}} par Loki {{VERSION}}.\n\n_Export partiel : 1 derniers échanges seulement._\n\n---\n\n## Vous\n\nDernier tour\n\n## Loki\n\nFin.\n\n> 🔧 **web\\_open** — https://ex.com/a\\_b\\*c\n\n```\ntitre\n```\n", + "filename_ext": "md" + }, + { + "query": "turns=2&format=json", + "opts": { + "format": "json", + "reasoning": true, + "results": true, + "tools": true, + "turns": 2 + }, + "body": "{\n \"loki_version\": \"{{VERSION}}\",\n \"exported_at\": \"{{DATE}}\",\n \"ctx_used\": 4321,\n \"messages\": [\n {\n \"role\": \"user\",\n \"content\": [\n {\n \"text\": \"Regarde\",\n \"type\": \"text\"\n },\n {\n \"image_url\": {\n \"detail\": \"low\",\n \"url\": \"data:image/png;base64,\\u003célidé — 2400 o\\u003e\"\n },\n \"type\": \"image_url\"\n },\n {\n \"image_url\": {\n \"url\": \"https://x/y.png\"\n },\n \"type\": \"image_url\"\n },\n \"brut\"\n ]\n },\n {\n \"role\": \"assistant\",\n \"content\": \"Une image.\"\n },\n {\n \"role\": \"user\",\n \"content\": \"Dernier tour\"\n },\n {\n \"role\": \"assistant\",\n \"content\": \"Fin.\"\n }\n ],\n \"log\": [\n {\n \"seq\": 10,\n \"ts\": 1010,\n \"delta\": {\n \"files\": [\n {\n \"name\": \"photo.png\"\n }\n ],\n \"user\": \"\"\n }\n },\n {\n \"seq\": 11,\n \"ts\": 1011,\n \"delta\": {\n \"content\": \"Une image.\"\n }\n },\n {\n \"seq\": 12,\n \"ts\": 1012,\n \"delta\": {\n \"user\": \"Dernier tour\"\n }\n },\n {\n \"seq\": 13,\n \"ts\": 1013,\n \"delta\": {\n \"tool_used\": {\n \"label\": \"https://ex.com/a_b*c\",\n \"name\": \"web_open\",\n \"result\": \"titre\"\n }\n }\n },\n {\n \"seq\": 14,\n \"ts\": 1014,\n \"delta\": {\n \"content\": \"Fin.\"\n }\n }\n ]\n}", + "filename_ext": "json" + }, + { + "query": "turns=9", + "opts": { + "format": "md", + "reasoning": true, + "results": true, + "tools": true, + "turns": 9 + }, + "body": "# Conversation Loki\n\nExportée le {{DATE}} par Loki {{VERSION}}.\n\n_Export partiel : 9 derniers échanges seulement._\n\n---\n\n## Vous\n\nBonjour & Cie\n\nFichiers joints : rapport.pdf\n\n## Loki\n\n
\nRaisonnement\n\nje liste\n\n
\n\n> 🔧 **bash** — ls \\*.txt \\[a\\]\n\n```\na.txt\nb.txt\n```\n\n> 🔧 **mem\\_read** — mem\\_read\n\n> 🔧 **git\\_\\`log\\`** — \\`--oneline\\` \\[main\\]\n\nVoilà.\n\n_(contexte compacté à cet endroit : les tours précédents ont été résumés pour le modèle, le fil ci-dessus reste complet)_\n\n---\n\n## Vous\n\n\n\nFichiers joints : photo.png\n\n## Loki\n\nUne image.\n\n---\n\n## Vous\n\nDernier tour\n\n## Loki\n\nFin.\n\n> 🔧 **web\\_open** — https://ex.com/a\\_b\\*c\n\n```\ntitre\n```\n", + "filename_ext": "md" + }, + { + "query": "turns=0", + "opts": { + "format": "md", + "reasoning": true, + "results": true, + "tools": true, + "turns": 0 + }, + "body": "# Conversation Loki\n\nExportée le {{DATE}} par Loki {{VERSION}}.\n\n---\n\n## Vous\n\nBonjour & Cie\n\nFichiers joints : rapport.pdf\n\n## Loki\n\n
\nRaisonnement\n\nje liste\n\n
\n\n> 🔧 **bash** — ls \\*.txt \\[a\\]\n\n```\na.txt\nb.txt\n```\n\n> 🔧 **mem\\_read** — mem\\_read\n\n> 🔧 **git\\_\\`log\\`** — \\`--oneline\\` \\[main\\]\n\nVoilà.\n\n_(contexte compacté à cet endroit : les tours précédents ont été résumés pour le modèle, le fil ci-dessus reste complet)_\n\n---\n\n## Vous\n\n\n\nFichiers joints : photo.png\n\n## Loki\n\nUne image.\n\n---\n\n## Vous\n\nDernier tour\n\n## Loki\n\nFin.\n\n> 🔧 **web\\_open** — https://ex.com/a\\_b\\*c\n\n```\ntitre\n```\n", + "filename_ext": "md" + }, + { + "query": "turns=-2", + "opts": { + "format": "md", + "reasoning": true, + "results": true, + "tools": true, + "turns": 0 + }, + "body": "# Conversation Loki\n\nExportée le {{DATE}} par Loki {{VERSION}}.\n\n---\n\n## Vous\n\nBonjour & Cie\n\nFichiers joints : rapport.pdf\n\n## Loki\n\n
\nRaisonnement\n\nje liste\n\n
\n\n> 🔧 **bash** — ls \\*.txt \\[a\\]\n\n```\na.txt\nb.txt\n```\n\n> 🔧 **mem\\_read** — mem\\_read\n\n> 🔧 **git\\_\\`log\\`** — \\`--oneline\\` \\[main\\]\n\nVoilà.\n\n_(contexte compacté à cet endroit : les tours précédents ont été résumés pour le modèle, le fil ci-dessus reste complet)_\n\n---\n\n## Vous\n\n\n\nFichiers joints : photo.png\n\n## Loki\n\nUne image.\n\n---\n\n## Vous\n\nDernier tour\n\n## Loki\n\nFin.\n\n> 🔧 **web\\_open** — https://ex.com/a\\_b\\*c\n\n```\ntitre\n```\n", + "filename_ext": "md" + }, + { + "query": "turns=abc", + "opts": { + "format": "md", + "reasoning": true, + "results": true, + "tools": true, + "turns": 0 + }, + "body": "# Conversation Loki\n\nExportée le {{DATE}} par Loki {{VERSION}}.\n\n---\n\n## Vous\n\nBonjour & Cie\n\nFichiers joints : rapport.pdf\n\n## Loki\n\n
\nRaisonnement\n\nje liste\n\n
\n\n> 🔧 **bash** — ls \\*.txt \\[a\\]\n\n```\na.txt\nb.txt\n```\n\n> 🔧 **mem\\_read** — mem\\_read\n\n> 🔧 **git\\_\\`log\\`** — \\`--oneline\\` \\[main\\]\n\nVoilà.\n\n_(contexte compacté à cet endroit : les tours précédents ont été résumés pour le modèle, le fil ci-dessus reste complet)_\n\n---\n\n## Vous\n\n\n\nFichiers joints : photo.png\n\n## Loki\n\nUne image.\n\n---\n\n## Vous\n\nDernier tour\n\n## Loki\n\nFin.\n\n> 🔧 **web\\_open** — https://ex.com/a\\_b\\*c\n\n```\ntitre\n```\n", + "filename_ext": "md" + }, + { + "query": "turns=1&results=0&reasoning=0", + "opts": { + "format": "md", + "reasoning": false, + "results": false, + "tools": true, + "turns": 1 + }, + "body": "# Conversation Loki\n\nExportée le {{DATE}} par Loki {{VERSION}}.\n\n_Export partiel : 1 derniers échanges seulement, raisonnements retirés, sorties d'outils retirées._\n\n---\n\n## Vous\n\nDernier tour\n\n## Loki\n\nFin.\n\n> 🔧 **web\\_open** — https://ex.com/a\\_b\\*c\n", + "filename_ext": "md" + }, + { + "query": "tools=on&results=yes", + "opts": { + "format": "md", + "reasoning": true, + "results": false, + "tools": true, + "turns": 0 + }, + "body": "# Conversation Loki\n\nExportée le {{DATE}} par Loki {{VERSION}}.\n\n_Export partiel : sorties d'outils retirées._\n\n---\n\n## Vous\n\nBonjour & Cie\n\nFichiers joints : rapport.pdf\n\n## Loki\n\n
\nRaisonnement\n\nje liste\n\n
\n\n> 🔧 **bash** — ls \\*.txt \\[a\\]\n\n> 🔧 **mem\\_read** — mem\\_read\n\n> 🔧 **git\\_\\`log\\`** — \\`--oneline\\` \\[main\\]\n\nVoilà.\n\n_(contexte compacté à cet endroit : les tours précédents ont été résumés pour le modèle, le fil ci-dessus reste complet)_\n\n---\n\n## Vous\n\n\n\nFichiers joints : photo.png\n\n## Loki\n\nUne image.\n\n---\n\n## Vous\n\nDernier tour\n\n## Loki\n\nFin.\n\n> 🔧 **web\\_open** — https://ex.com/a\\_b\\*c\n", + "filename_ext": "md" + }, + { + "query": "format=json&format=md", + "opts": { + "format": "json", + "reasoning": true, + "results": true, + "tools": true, + "turns": 0 + }, + "body": "{\n \"loki_version\": \"{{VERSION}}\",\n \"exported_at\": \"{{DATE}}\",\n \"ctx_used\": 4321,\n \"messages\": [\n {\n \"role\": \"user\",\n \"content\": \"Bonjour \\u003cb\\u003e \\u0026 Cie\"\n },\n {\n \"role\": \"user\",\n \"content\": \"\\u003ccontext_update from=\\\"loki\\\" v=\\\"2\\\"\\u003etapé\\u003c/context_update\\u003e par l'utilisateur\"\n },\n {\n \"role\": \"assistant\",\n \"content\": \"\",\n \"tool_calls\": [\n {\n \"id\": \"c1\",\n \"type\": \"function\",\n \"function\": {\n \"name\": \"bash\",\n \"arguments\": \"{\\\"cmd\\\":\\\"ls\\\"}\"\n }\n }\n ]\n },\n {\n \"role\": \"tool\",\n \"content\": \"a.txt\\nb.txt\",\n \"tool_call_id\": \"c1\"\n },\n {\n \"role\": \"assistant\",\n \"content\": \"Voilà.\",\n \"reasoning_content\": \"je liste\",\n \"reasoning_model\": \"m\"\n },\n {\n \"role\": \"user\",\n \"content\": \"You reasoned but did not call a tool or answer. Act NOW: call the appropriate tool directly (e.g. mem_search/mem_read/bash), or give your final answer if you already have the info. Don't explain, act.\"\n },\n {\n \"role\": \"user\",\n \"content\": [\n {\n \"text\": \"Regarde\",\n \"type\": \"text\"\n },\n {\n \"image_url\": {\n \"detail\": \"low\",\n \"url\": \"data:image/png;base64,\\u003célidé — 2400 o\\u003e\"\n },\n \"type\": \"image_url\"\n },\n {\n \"image_url\": {\n \"url\": \"https://x/y.png\"\n },\n \"type\": \"image_url\"\n },\n \"brut\"\n ]\n },\n {\n \"role\": \"assistant\",\n \"content\": \"Une image.\"\n },\n {\n \"role\": \"user\",\n \"content\": \"Dernier tour\"\n },\n {\n \"role\": \"assistant\",\n \"content\": \"Fin.\"\n }\n ],\n \"log\": [\n {\n \"seq\": 1,\n \"ts\": 1001,\n \"delta\": {\n \"files\": [\n {\n \"name\": \"rapport.pdf\",\n \"size\": 10\n },\n {\n \"name\": \"\"\n },\n \"x\"\n ],\n \"user\": \"Bonjour \\u003cb\\u003e \\u0026 Cie\"\n }\n },\n {\n \"seq\": 2,\n \"ts\": 1002,\n \"delta\": {\n \"reasoning_content\": \"je \"\n }\n },\n {\n \"seq\": 3,\n \"ts\": 1003,\n \"delta\": {\n \"reasoning_content\": \"liste\\n\"\n }\n },\n {\n \"seq\": 4,\n \"ts\": 1004,\n \"delta\": {\n \"tool_used\": {\n \"done\": true,\n \"label\": \"ls *.txt\\n[a]\",\n \"name\": \"bash\",\n \"result\": \"a.txt\\nb.txt\\n\\n\"\n }\n }\n },\n {\n \"seq\": 5,\n \"ts\": 1005,\n \"delta\": {\n \"tool_used\": {\n \"done\": true,\n \"name\": \"mem_read\",\n \"result\": \" \"\n }\n }\n },\n {\n \"seq\": 5,\n \"ts\": 1005,\n \"delta\": {\n \"tool_used\": {\n \"done\": true,\n \"label\": \" `--oneline` [main] \",\n \"name\": \"git_`log`\"\n }\n }\n },\n {\n \"seq\": 5,\n \"ts\": 1005,\n \"delta\": {\n \"tool_used\": {\n \"label\": \"pas fini\",\n \"name\": \"en_cours\"\n }\n }\n },\n {\n \"seq\": 6,\n \"ts\": 1006,\n \"delta\": {\n \"content\": \"Voi\"\n }\n },\n {\n \"seq\": 7,\n \"ts\": 1007,\n \"delta\": {\n \"content\": \"là.\\n\"\n }\n },\n {\n \"seq\": 8,\n \"ts\": 1008,\n \"delta\": {\n \"stats\": {\n \"tok_s\": 12.5\n }\n }\n },\n {\n \"seq\": 9,\n \"ts\": 1009,\n \"delta\": {\n \"compacted\": true\n }\n },\n {\n \"seq\": 10,\n \"ts\": 1010,\n \"delta\": {\n \"files\": [\n {\n \"name\": \"photo.png\"\n }\n ],\n \"user\": \"\"\n }\n },\n {\n \"seq\": 11,\n \"ts\": 1011,\n \"delta\": {\n \"content\": \"Une image.\"\n }\n },\n {\n \"seq\": 12,\n \"ts\": 1012,\n \"delta\": {\n \"user\": \"Dernier tour\"\n }\n },\n {\n \"seq\": 13,\n \"ts\": 1013,\n \"delta\": {\n \"tool_used\": {\n \"label\": \"https://ex.com/a_b*c\",\n \"name\": \"web_open\",\n \"result\": \"titre\"\n }\n }\n },\n {\n \"seq\": 14,\n \"ts\": 1014,\n \"delta\": {\n \"content\": \"Fin.\"\n }\n }\n ]\n}", + "filename_ext": "json" + }, + { + "query": "turns=3&format=json", + "opts": { + "format": "json", + "reasoning": true, + "results": true, + "tools": true, + "turns": 3 + }, + "body": "{\n \"loki_version\": \"{{VERSION}}\",\n \"exported_at\": \"{{DATE}}\",\n \"ctx_used\": 4321,\n \"messages\": [\n {\n \"role\": \"user\",\n \"content\": \"\\u003ccontext_update from=\\\"loki\\\" v=\\\"2\\\"\\u003etapé\\u003c/context_update\\u003e par l'utilisateur\"\n },\n {\n \"role\": \"assistant\",\n \"content\": \"\",\n \"tool_calls\": [\n {\n \"id\": \"c1\",\n \"type\": \"function\",\n \"function\": {\n \"name\": \"bash\",\n \"arguments\": \"{\\\"cmd\\\":\\\"ls\\\"}\"\n }\n }\n ]\n },\n {\n \"role\": \"tool\",\n \"content\": \"a.txt\\nb.txt\",\n \"tool_call_id\": \"c1\"\n },\n {\n \"role\": \"assistant\",\n \"content\": \"Voilà.\",\n \"reasoning_content\": \"je liste\",\n \"reasoning_model\": \"m\"\n },\n {\n \"role\": \"user\",\n \"content\": \"You reasoned but did not call a tool or answer. Act NOW: call the appropriate tool directly (e.g. mem_search/mem_read/bash), or give your final answer if you already have the info. Don't explain, act.\"\n },\n {\n \"role\": \"user\",\n \"content\": [\n {\n \"text\": \"Regarde\",\n \"type\": \"text\"\n },\n {\n \"image_url\": {\n \"detail\": \"low\",\n \"url\": \"data:image/png;base64,\\u003célidé — 2400 o\\u003e\"\n },\n \"type\": \"image_url\"\n },\n {\n \"image_url\": {\n \"url\": \"https://x/y.png\"\n },\n \"type\": \"image_url\"\n },\n \"brut\"\n ]\n },\n {\n \"role\": \"assistant\",\n \"content\": \"Une image.\"\n },\n {\n \"role\": \"user\",\n \"content\": \"Dernier tour\"\n },\n {\n \"role\": \"assistant\",\n \"content\": \"Fin.\"\n }\n ],\n \"log\": [\n {\n \"seq\": 1,\n \"ts\": 1001,\n \"delta\": {\n \"files\": [\n {\n \"name\": \"rapport.pdf\",\n \"size\": 10\n },\n {\n \"name\": \"\"\n },\n \"x\"\n ],\n \"user\": \"Bonjour \\u003cb\\u003e \\u0026 Cie\"\n }\n },\n {\n \"seq\": 2,\n \"ts\": 1002,\n \"delta\": {\n \"reasoning_content\": \"je \"\n }\n },\n {\n \"seq\": 3,\n \"ts\": 1003,\n \"delta\": {\n \"reasoning_content\": \"liste\\n\"\n }\n },\n {\n \"seq\": 4,\n \"ts\": 1004,\n \"delta\": {\n \"tool_used\": {\n \"done\": true,\n \"label\": \"ls *.txt\\n[a]\",\n \"name\": \"bash\",\n \"result\": \"a.txt\\nb.txt\\n\\n\"\n }\n }\n },\n {\n \"seq\": 5,\n \"ts\": 1005,\n \"delta\": {\n \"tool_used\": {\n \"done\": true,\n \"name\": \"mem_read\",\n \"result\": \" \"\n }\n }\n },\n {\n \"seq\": 5,\n \"ts\": 1005,\n \"delta\": {\n \"tool_used\": {\n \"done\": true,\n \"label\": \" `--oneline` [main] \",\n \"name\": \"git_`log`\"\n }\n }\n },\n {\n \"seq\": 5,\n \"ts\": 1005,\n \"delta\": {\n \"tool_used\": {\n \"label\": \"pas fini\",\n \"name\": \"en_cours\"\n }\n }\n },\n {\n \"seq\": 6,\n \"ts\": 1006,\n \"delta\": {\n \"content\": \"Voi\"\n }\n },\n {\n \"seq\": 7,\n \"ts\": 1007,\n \"delta\": {\n \"content\": \"là.\\n\"\n }\n },\n {\n \"seq\": 8,\n \"ts\": 1008,\n \"delta\": {\n \"stats\": {\n \"tok_s\": 12.5\n }\n }\n },\n {\n \"seq\": 9,\n \"ts\": 1009,\n \"delta\": {\n \"compacted\": true\n }\n },\n {\n \"seq\": 10,\n \"ts\": 1010,\n \"delta\": {\n \"files\": [\n {\n \"name\": \"photo.png\"\n }\n ],\n \"user\": \"\"\n }\n },\n {\n \"seq\": 11,\n \"ts\": 1011,\n \"delta\": {\n \"content\": \"Une image.\"\n }\n },\n {\n \"seq\": 12,\n \"ts\": 1012,\n \"delta\": {\n \"user\": \"Dernier tour\"\n }\n },\n {\n \"seq\": 13,\n \"ts\": 1013,\n \"delta\": {\n \"tool_used\": {\n \"label\": \"https://ex.com/a_b*c\",\n \"name\": \"web_open\",\n \"result\": \"titre\"\n }\n }\n },\n {\n \"seq\": 14,\n \"ts\": 1014,\n \"delta\": {\n \"content\": \"Fin.\"\n }\n }\n ]\n}", + "filename_ext": "json" + }, + { + "query": "turns=4&format=json", + "opts": { + "format": "json", + "reasoning": true, + "results": true, + "tools": true, + "turns": 4 + }, + "body": "{\n \"loki_version\": \"{{VERSION}}\",\n \"exported_at\": \"{{DATE}}\",\n \"ctx_used\": 4321,\n \"messages\": [\n {\n \"role\": \"user\",\n \"content\": \"Bonjour \\u003cb\\u003e \\u0026 Cie\"\n },\n {\n \"role\": \"user\",\n \"content\": \"\\u003ccontext_update from=\\\"loki\\\" v=\\\"2\\\"\\u003etapé\\u003c/context_update\\u003e par l'utilisateur\"\n },\n {\n \"role\": \"assistant\",\n \"content\": \"\",\n \"tool_calls\": [\n {\n \"id\": \"c1\",\n \"type\": \"function\",\n \"function\": {\n \"name\": \"bash\",\n \"arguments\": \"{\\\"cmd\\\":\\\"ls\\\"}\"\n }\n }\n ]\n },\n {\n \"role\": \"tool\",\n \"content\": \"a.txt\\nb.txt\",\n \"tool_call_id\": \"c1\"\n },\n {\n \"role\": \"assistant\",\n \"content\": \"Voilà.\",\n \"reasoning_content\": \"je liste\",\n \"reasoning_model\": \"m\"\n },\n {\n \"role\": \"user\",\n \"content\": \"You reasoned but did not call a tool or answer. Act NOW: call the appropriate tool directly (e.g. mem_search/mem_read/bash), or give your final answer if you already have the info. Don't explain, act.\"\n },\n {\n \"role\": \"user\",\n \"content\": [\n {\n \"text\": \"Regarde\",\n \"type\": \"text\"\n },\n {\n \"image_url\": {\n \"detail\": \"low\",\n \"url\": \"data:image/png;base64,\\u003célidé — 2400 o\\u003e\"\n },\n \"type\": \"image_url\"\n },\n {\n \"image_url\": {\n \"url\": \"https://x/y.png\"\n },\n \"type\": \"image_url\"\n },\n \"brut\"\n ]\n },\n {\n \"role\": \"assistant\",\n \"content\": \"Une image.\"\n },\n {\n \"role\": \"user\",\n \"content\": \"Dernier tour\"\n },\n {\n \"role\": \"assistant\",\n \"content\": \"Fin.\"\n }\n ],\n \"log\": [\n {\n \"seq\": 1,\n \"ts\": 1001,\n \"delta\": {\n \"files\": [\n {\n \"name\": \"rapport.pdf\",\n \"size\": 10\n },\n {\n \"name\": \"\"\n },\n \"x\"\n ],\n \"user\": \"Bonjour \\u003cb\\u003e \\u0026 Cie\"\n }\n },\n {\n \"seq\": 2,\n \"ts\": 1002,\n \"delta\": {\n \"reasoning_content\": \"je \"\n }\n },\n {\n \"seq\": 3,\n \"ts\": 1003,\n \"delta\": {\n \"reasoning_content\": \"liste\\n\"\n }\n },\n {\n \"seq\": 4,\n \"ts\": 1004,\n \"delta\": {\n \"tool_used\": {\n \"done\": true,\n \"label\": \"ls *.txt\\n[a]\",\n \"name\": \"bash\",\n \"result\": \"a.txt\\nb.txt\\n\\n\"\n }\n }\n },\n {\n \"seq\": 5,\n \"ts\": 1005,\n \"delta\": {\n \"tool_used\": {\n \"done\": true,\n \"name\": \"mem_read\",\n \"result\": \" \"\n }\n }\n },\n {\n \"seq\": 5,\n \"ts\": 1005,\n \"delta\": {\n \"tool_used\": {\n \"done\": true,\n \"label\": \" `--oneline` [main] \",\n \"name\": \"git_`log`\"\n }\n }\n },\n {\n \"seq\": 5,\n \"ts\": 1005,\n \"delta\": {\n \"tool_used\": {\n \"label\": \"pas fini\",\n \"name\": \"en_cours\"\n }\n }\n },\n {\n \"seq\": 6,\n \"ts\": 1006,\n \"delta\": {\n \"content\": \"Voi\"\n }\n },\n {\n \"seq\": 7,\n \"ts\": 1007,\n \"delta\": {\n \"content\": \"là.\\n\"\n }\n },\n {\n \"seq\": 8,\n \"ts\": 1008,\n \"delta\": {\n \"stats\": {\n \"tok_s\": 12.5\n }\n }\n },\n {\n \"seq\": 9,\n \"ts\": 1009,\n \"delta\": {\n \"compacted\": true\n }\n },\n {\n \"seq\": 10,\n \"ts\": 1010,\n \"delta\": {\n \"files\": [\n {\n \"name\": \"photo.png\"\n }\n ],\n \"user\": \"\"\n }\n },\n {\n \"seq\": 11,\n \"ts\": 1011,\n \"delta\": {\n \"content\": \"Une image.\"\n }\n },\n {\n \"seq\": 12,\n \"ts\": 1012,\n \"delta\": {\n \"user\": \"Dernier tour\"\n }\n },\n {\n \"seq\": 13,\n \"ts\": 1013,\n \"delta\": {\n \"tool_used\": {\n \"label\": \"https://ex.com/a_b*c\",\n \"name\": \"web_open\",\n \"result\": \"titre\"\n }\n }\n },\n {\n \"seq\": 14,\n \"ts\": 1014,\n \"delta\": {\n \"content\": \"Fin.\"\n }\n }\n ]\n}", + "filename_ext": "json" + } + ], + "steps": [ + { + "name": "mode par défaut", + "method": "GET", + "path": "/api/chat/mode", + "status": 200, + "out": { + "criteria": null, + "mode": "chat", + "ok": true + }, + "mode": "", + "criteria": null, + "deltas": null + }, + { + "name": "mode code", + "method": "POST", + "path": "/api/chat/mode", + "body": "{\"mode\":\"code\"}", + "status": 200, + "out": { + "mode": "code", + "ok": true + }, + "mode": "code", + "criteria": null, + "deltas": [ + { + "mode": "code" + } + ] + }, + { + "name": "mode relu", + "method": "GET", + "path": "/api/chat/mode", + "status": 200, + "out": { + "criteria": null, + "mode": "code", + "ok": true + }, + "mode": "code", + "criteria": null, + "deltas": null + }, + { + "name": "mode inconnu", + "method": "POST", + "path": "/api/chat/mode", + "body": "{\"mode\":\"Code\"}", + "status": 400, + "out": { + "error": "mode inconnu (chat|code)", + "ok": false + }, + "mode": "code", + "criteria": null, + "deltas": null + }, + { + "name": "mode corps illisible", + "method": "POST", + "path": "/api/chat/mode", + "body": "{\"mode\":", + "status": 400, + "out": { + "error": "unexpected EOF", + "ok": false + }, + "mode": "code", + "criteria": null, + "deltas": null + }, + { + "name": "mode type faux", + "method": "POST", + "path": "/api/chat/mode", + "body": "{\"mode\":true}", + "status": 400, + "out": { + "error": "json: cannot unmarshal bool into Go struct field .mode of type string", + "ok": false + }, + "mode": "code", + "criteria": null, + "deltas": null + }, + { + "name": "mode en PUT", + "method": "PUT", + "path": "/api/chat/mode", + "body": "{\"mode\":\"chat\"}", + "status": 200, + "out": { + "criteria": null, + "mode": "code", + "ok": true + }, + "mode": "code", + "criteria": null, + "deltas": null + }, + { + "name": "critères en GET", + "method": "GET", + "path": "/api/chat/criteria", + "status": 405, + "out": { + "error": "POST attendu", + "ok": false + }, + "mode": "code", + "criteria": null, + "deltas": null + }, + { + "name": "critères", + "method": "POST", + "path": "/api/chat/criteria", + "body": "{\"criteria\":[{\"text\":\"compile\"},{\"id\":5,\"text\":\"tests & verts\",\"status\":\"passed\",\"note\":\"vu\"},{\"text\":\" \"},{\"id\":0,\"text\":\"doc\",\"status\":\"bizarre\"},{\"id\":2,\"text\":\"lint\",\"status\":\"failed\"},{\"text\":\"perf\",\"status\":\"completed\"}]}", + "status": 200, + "out": { + "criteria": [ + { + "id": 1, + "status": "pending", + "text": "compile" + }, + { + "id": 5, + "note": "vu", + "status": "passed", + "text": "tests & verts" + }, + { + "id": 6, + "status": "pending", + "text": "doc" + }, + { + "id": 2, + "status": "failed", + "text": "lint" + }, + { + "id": 7, + "status": "pending", + "text": "perf" + } + ], + "ok": true + }, + "mode": "code", + "criteria": [ + { + "id": 1, + "status": "pending", + "text": "compile" + }, + { + "id": 5, + "note": "vu", + "status": "passed", + "text": "tests & verts" + }, + { + "id": 6, + "status": "pending", + "text": "doc" + }, + { + "id": 2, + "status": "failed", + "text": "lint" + }, + { + "id": 7, + "status": "pending", + "text": "perf" + } + ], + "deltas": [ + { + "criteria": [ + { + "id": 1, + "status": "pending", + "text": "compile" + }, + { + "id": 5, + "note": "vu", + "status": "passed", + "text": "tests & verts" + }, + { + "id": 6, + "status": "pending", + "text": "doc" + }, + { + "id": 2, + "status": "failed", + "text": "lint" + }, + { + "id": 7, + "status": "pending", + "text": "perf" + } + ] + } + ] + }, + { + "name": "mode relu avec critères", + "method": "GET", + "path": "/api/chat/mode", + "status": 200, + "out": { + "criteria": [ + { + "id": 1, + "status": "pending", + "text": "compile" + }, + { + "id": 5, + "note": "vu", + "status": "passed", + "text": "tests & verts" + }, + { + "id": 6, + "status": "pending", + "text": "doc" + }, + { + "id": 2, + "status": "failed", + "text": "lint" + }, + { + "id": 7, + "status": "pending", + "text": "perf" + } + ], + "mode": "code", + "ok": true + }, + "mode": "code", + "criteria": [ + { + "id": 1, + "status": "pending", + "text": "compile" + }, + { + "id": 5, + "note": "vu", + "status": "passed", + "text": "tests & verts" + }, + { + "id": 6, + "status": "pending", + "text": "doc" + }, + { + "id": 2, + "status": "failed", + "text": "lint" + }, + { + "id": 7, + "status": "pending", + "text": "perf" + } + ], + "deltas": null + }, + { + "name": "critères au-delà de vingt", + "method": "POST", + "path": "/api/chat/criteria", + "body": "{\"criteria\":[{\"text\":\"ca\"},{\"text\":\"cb\"},{\"text\":\"cc\"},{\"text\":\"cd\"},{\"text\":\"ce\"},{\"text\":\"cf\"},{\"text\":\"cg\"},{\"text\":\"ch\"},{\"text\":\"ci\"},{\"text\":\"cj\"},{\"text\":\"ck\"},{\"text\":\"cl\"},{\"text\":\"cm\"},{\"text\":\"cn\"},{\"text\":\"co\"},{\"text\":\"cp\"},{\"text\":\"cq\"},{\"text\":\"cr\"},{\"text\":\"cs\"},{\"text\":\"ct\"},{\"text\":\"cu\"},{\"text\":\"cv\"},{\"text\":\"cw\"},{\"text\":\"cx\"},{\"text\":\"cy\"}]}", + "status": 200, + "out": { + "criteria": [ + { + "id": 1, + "status": "pending", + "text": "ca" + }, + { + "id": 2, + "status": "pending", + "text": "cb" + }, + { + "id": 3, + "status": "pending", + "text": "cc" + }, + { + "id": 4, + "status": "pending", + "text": "cd" + }, + { + "id": 5, + "status": "pending", + "text": "ce" + }, + { + "id": 6, + "status": "pending", + "text": "cf" + }, + { + "id": 7, + "status": "pending", + "text": "cg" + }, + { + "id": 8, + "status": "pending", + "text": "ch" + }, + { + "id": 9, + "status": "pending", + "text": "ci" + }, + { + "id": 10, + "status": "pending", + "text": "cj" + }, + { + "id": 11, + "status": "pending", + "text": "ck" + }, + { + "id": 12, + "status": "pending", + "text": "cl" + }, + { + "id": 13, + "status": "pending", + "text": "cm" + }, + { + "id": 14, + "status": "pending", + "text": "cn" + }, + { + "id": 15, + "status": "pending", + "text": "co" + }, + { + "id": 16, + "status": "pending", + "text": "cp" + }, + { + "id": 17, + "status": "pending", + "text": "cq" + }, + { + "id": 18, + "status": "pending", + "text": "cr" + }, + { + "id": 19, + "status": "pending", + "text": "cs" + }, + { + "id": 20, + "status": "pending", + "text": "ct" + } + ], + "ok": true + }, + "mode": "code", + "criteria": [ + { + "id": 1, + "status": "pending", + "text": "ca" + }, + { + "id": 2, + "status": "pending", + "text": "cb" + }, + { + "id": 3, + "status": "pending", + "text": "cc" + }, + { + "id": 4, + "status": "pending", + "text": "cd" + }, + { + "id": 5, + "status": "pending", + "text": "ce" + }, + { + "id": 6, + "status": "pending", + "text": "cf" + }, + { + "id": 7, + "status": "pending", + "text": "cg" + }, + { + "id": 8, + "status": "pending", + "text": "ch" + }, + { + "id": 9, + "status": "pending", + "text": "ci" + }, + { + "id": 10, + "status": "pending", + "text": "cj" + }, + { + "id": 11, + "status": "pending", + "text": "ck" + }, + { + "id": 12, + "status": "pending", + "text": "cl" + }, + { + "id": 13, + "status": "pending", + "text": "cm" + }, + { + "id": 14, + "status": "pending", + "text": "cn" + }, + { + "id": 15, + "status": "pending", + "text": "co" + }, + { + "id": 16, + "status": "pending", + "text": "cp" + }, + { + "id": 17, + "status": "pending", + "text": "cq" + }, + { + "id": 18, + "status": "pending", + "text": "cr" + }, + { + "id": 19, + "status": "pending", + "text": "cs" + }, + { + "id": 20, + "status": "pending", + "text": "ct" + } + ], + "deltas": [ + { + "criteria": [ + { + "id": 1, + "status": "pending", + "text": "ca" + }, + { + "id": 2, + "status": "pending", + "text": "cb" + }, + { + "id": 3, + "status": "pending", + "text": "cc" + }, + { + "id": 4, + "status": "pending", + "text": "cd" + }, + { + "id": 5, + "status": "pending", + "text": "ce" + }, + { + "id": 6, + "status": "pending", + "text": "cf" + }, + { + "id": 7, + "status": "pending", + "text": "cg" + }, + { + "id": 8, + "status": "pending", + "text": "ch" + }, + { + "id": 9, + "status": "pending", + "text": "ci" + }, + { + "id": 10, + "status": "pending", + "text": "cj" + }, + { + "id": 11, + "status": "pending", + "text": "ck" + }, + { + "id": 12, + "status": "pending", + "text": "cl" + }, + { + "id": 13, + "status": "pending", + "text": "cm" + }, + { + "id": 14, + "status": "pending", + "text": "cn" + }, + { + "id": 15, + "status": "pending", + "text": "co" + }, + { + "id": 16, + "status": "pending", + "text": "cp" + }, + { + "id": 17, + "status": "pending", + "text": "cq" + }, + { + "id": 18, + "status": "pending", + "text": "cr" + }, + { + "id": 19, + "status": "pending", + "text": "cs" + }, + { + "id": 20, + "status": "pending", + "text": "ct" + } + ] + } + ] + }, + { + "name": "critères vidés", + "method": "POST", + "path": "/api/chat/criteria", + "body": "{\"criteria\":[]}", + "status": 200, + "out": { + "criteria": null, + "ok": true + }, + "mode": "code", + "criteria": null, + "deltas": [ + { + "criteria": [] + } + ] + }, + { + "name": "critères absents", + "method": "POST", + "path": "/api/chat/criteria", + "body": "{}", + "status": 200, + "out": { + "criteria": null, + "ok": true + }, + "mode": "code", + "criteria": null, + "deltas": [ + { + "criteria": [] + } + ] + }, + { + "name": "critères null", + "method": "POST", + "path": "/api/chat/criteria", + "body": "{\"criteria\":null}", + "status": 200, + "out": { + "criteria": null, + "ok": true + }, + "mode": "code", + "criteria": null, + "deltas": [ + { + "criteria": [] + } + ] + }, + { + "name": "critères corps vide", + "method": "POST", + "path": "/api/chat/criteria", + "status": 400, + "out": { + "error": "EOF", + "ok": false + }, + "mode": "code", + "criteria": null, + "deltas": null + }, + { + "name": "critères illisibles", + "method": "POST", + "path": "/api/chat/criteria", + "body": "{\"criteria\":[", + "status": 400, + "out": { + "error": "unexpected EOF", + "ok": false + }, + "mode": "code", + "criteria": null, + "deltas": null + }, + { + "name": "critère id texte", + "method": "POST", + "path": "/api/chat/criteria", + "body": "{\"criteria\":[{\"id\":\"2\",\"text\":\"x\"}]}", + "status": 400, + "out": { + "error": "json: cannot unmarshal string into Go struct field Criterion.criteria.id of type int", + "ok": false + }, + "mode": "code", + "criteria": null, + "deltas": null + }, + { + "name": "critère texte nombre", + "method": "POST", + "path": "/api/chat/criteria", + "body": "{\"criteria\":[{\"text\":3}]}", + "status": 400, + "out": { + "error": "json: cannot unmarshal number into Go struct field Criterion.criteria.text of type string", + "ok": false + }, + "mode": "code", + "criteria": null, + "deltas": null + }, + { + "name": "critères pas une liste", + "method": "POST", + "path": "/api/chat/criteria", + "body": "{\"criteria\":\"a\"}", + "status": 400, + "out": { + "error": "json: cannot unmarshal string into Go struct field .criteria of type []loki.Criterion", + "ok": false + }, + "mode": "code", + "criteria": null, + "deltas": null + }, + { + "name": "critère pas un objet", + "method": "POST", + "path": "/api/chat/criteria", + "body": "{\"criteria\":[\"a\",{\"text\":\"b\"}]}", + "status": 400, + "out": { + "error": "json: cannot unmarshal string into Go struct field .criteria of type loki.Criterion", + "ok": false + }, + "mode": "code", + "criteria": null, + "deltas": null + }, + { + "name": "critère id décimal", + "method": "POST", + "path": "/api/chat/criteria", + "body": "{\"criteria\":[{\"id\":1.5,\"text\":\"x\"}]}", + "status": 400, + "out": { + "error": "json: cannot unmarshal number 1.5 into Go struct field Criterion.criteria.id of type int", + "ok": false + }, + "mode": "code", + "criteria": null, + "deltas": null + }, + { + "name": "critère clés en majuscules", + "method": "POST", + "path": "/api/chat/criteria", + "body": "{\"criteria\":[{\"ID\":3,\"TEXT\":\"maj\",\"Status\":\"passed\"},null]}", + "status": 200, + "out": { + "criteria": [ + { + "id": 3, + "status": "passed", + "text": "maj" + } + ], + "ok": true + }, + "mode": "code", + "criteria": [ + { + "id": 3, + "status": "passed", + "text": "maj" + } + ], + "deltas": [ + { + "criteria": [ + { + "id": 3, + "status": "passed", + "text": "maj" + } + ] + } + ] + }, + { + "name": "mode chat", + "method": "POST", + "path": "/api/chat/mode", + "body": "{\"mode\":\"chat\"}", + "status": 200, + "out": { + "mode": "chat", + "ok": true + }, + "mode": "", + "criteria": [ + { + "id": 3, + "status": "passed", + "text": "maj" + } + ], + "deltas": [ + { + "mode": "chat" + } + ] + } + ] +} From 090f15615eb6cad7aa2b7afe116291e3a3c389e7 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 15 Oct 2026 19:56:11 +0000 Subject: [PATCH 18/52] =?UTF-8?q?Lot=2020=20:=20serveurs=20MCP=20tenus=20p?= =?UTF-8?q?ar=20la=20fa=C3=A7ade=20(LOKI=5FMCP=3Drust)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit La façade tient les serveurs MCP quand LOKI_MCP=rust (expérimental ; le Go reste le défaut) : configuration (state/mcp), catalogue embarqué (le même fichier que le Go, copié dans l'étape Rust de l'image), état et routes /api/mcp*. Un client MCP en Rust (mcp/client.rs) parle JSON-RPC sur stdio et en Streamable HTTP (réponses JSON ou SSE, session suivie, en-têtes configurés, ping du serveur servi). Le pool suit mcp_client.go : session gardée entre les tours, connexions parallèles et partagées, session invalidée pendant sa connexion jamais republiée, délais npx/uvx, appel borné avec une reconnexion, sortie plafonnée à la fenêtre. Un seul pool, celui de la façade : le Go, qui exécute encore les outils de l'agent, lui demande les outils MCP, leurs appels et le pré-chauffage (/internal/facade/v1/mcp/*, facade_mcp.go). Parité : parity_mcp_test.go tire du Go les fonctions pures, seize mutations de configuration, et un vrai serveur du SDK MCP Go (onze outils) : ce qu'il met sur le fil et ce que le client Go en tire (état, outils annoncés, appels). tests/mcp.rs le rejoue contre un faux serveur qui resert ces réponses, et vérifie stdio, reconnexion, routes, catalogue et session invalidée de bout en bout ; TestFacadeMCP côté Go. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_016tcGfJWefW5kUGoC2wL8cv --- Dockerfile | 2 + README.md | 1 + ...6-10-15-rust-migration-lot20-mcp-design.md | 93 +++ internal/loki/facade_chat.go | 4 +- internal/loki/facade_chat_test.go | 70 ++ internal/loki/facade_mcp.go | 105 +++ internal/loki/mcp_client.go | 10 + internal/loki/parity_mcp_test.go | 338 ++++++++ rust/loki-rs/src/api.rs | 3 + rust/loki-rs/src/lib.rs | 1 + rust/loki-rs/src/main.rs | 8 +- rust/loki-rs/src/mcp/client.rs | 440 ++++++++++ rust/loki-rs/src/mcp/config.rs | 483 +++++++++++ rust/loki-rs/src/mcp/mod.rs | 684 ++++++++++++++++ rust/loki-rs/src/proxy.rs | 15 + rust/loki-rs/tests/common/fakego.rs | 2 + rust/loki-rs/tests/lot2.rs | 1 + rust/loki-rs/tests/mcp.rs | 651 +++++++++++++++ rust/loki-rs/tests/parity_mcp/mcp.json | 760 ++++++++++++++++++ rust/loki-rs/tests/proxy.rs | 1 + 20 files changed, 3670 insertions(+), 2 deletions(-) create mode 100644 docs/superpowers/specs/2026-10-15-rust-migration-lot20-mcp-design.md create mode 100644 internal/loki/facade_mcp.go create mode 100644 internal/loki/parity_mcp_test.go create mode 100644 rust/loki-rs/src/mcp/client.rs create mode 100644 rust/loki-rs/src/mcp/config.rs create mode 100644 rust/loki-rs/src/mcp/mod.rs create mode 100644 rust/loki-rs/tests/mcp.rs create mode 100644 rust/loki-rs/tests/parity_mcp/mcp.json diff --git a/Dockerfile b/Dockerfile index a319813..1d5fc34 100644 --- a/Dockerfile +++ b/Dockerfile @@ -53,6 +53,8 @@ WORKDIR /src # L'UI est embarquée par build.rs depuis la même source que le go:embed : les # deux binaires d'une image servent toujours la même interface. COPY internal/loki/ui/ internal/loki/ui/ +# Catalogue MCP embarqué par loki-rs (lot 20), le même que le Go. +COPY internal/loki/mcp_catalog.json internal/loki/mcp_catalog.json COPY rust/ rust/ RUN cd rust && cargo build --release --locked \ && mkdir -p /out && cp target/release/loki-rs /out/loki-rs diff --git a/README.md b/README.md index 88d8153..ae8eb86 100644 --- a/README.md +++ b/README.md @@ -236,6 +236,7 @@ redémarrages (volume `/data`). Variables d'environnement du conteneur : | `LOKI_MEM` | `rust` : la mémoire (éditeur des pages, index, déplacement entre projets, trackers, mode mémoire, interrupteurs du mode agent) est tenue par la façade Rust ; le chiffrement reste au Go (expérimental, migration en cours) | Go | | `LOKI_SERVE` | `rust` : la ligne de commande de llama-server est composée par la façade Rust (`loki-rs engine serve`) au lieu de `loki serve` ; Strata et l'optimiseur restent au Go (expérimental, migration en cours) | Go | | `LOKI_PRESETS` | `rust` : les presets (liste, éditeur et aperçus, suppression, placement auto, presets externes, bascule) sont tenus par la façade Rust ; l'application au moteur reste au Go (expérimental, migration en cours) | Go | +| `LOKI_MCP` | `rust` : les serveurs MCP — configuration, catalogue, état, pool de sessions stdio et HTTP — sont tenus par la façade Rust ; le Go lui demande les outils MCP de l'agent et leurs appels (expérimental, migration en cours) | Go | | `LOKI_PROJECTS` | `rust` : le hub des projets (liste et comptes, création, renommage, description, suppression, bascule) est tenu par la façade Rust ; les discussions restent à qui tient la conversation (expérimental, migration en cours) | Go | | `LOKI_SYSTEM` | `rust` : les interrupteurs système — accès internet et Crawl4AI, pilotage du navigateur, clé de complétion, écoute réseau du moteur — sont tenus par la façade Rust ; le navigateur piloté reste au Go (expérimental, migration en cours) | Go | | `LOKI_ENGINE` | `rust` : le pilotage du moteur (démarrage, arrêt, redémarrage, déchargement et rechargement de la VRAM) est tenu par la façade Rust, en conteneur avec son superviseur ; le verrou de l'optimiseur reste au Go (expérimental, migration en cours) | Go | diff --git a/docs/superpowers/specs/2026-10-15-rust-migration-lot20-mcp-design.md b/docs/superpowers/specs/2026-10-15-rust-migration-lot20-mcp-design.md new file mode 100644 index 0000000..d164ee0 --- /dev/null +++ b/docs/superpowers/specs/2026-10-15-rust-migration-lot20-mcp-design.md @@ -0,0 +1,93 @@ +# Lot 20 — serveurs MCP tenus par la façade + +## Contexte + +Le Go tenait les serveurs MCP (Model Context Protocol) : +- `mcp_config.go` : la configuration, dans `state/mcp` ; +- `mcp_catalog.go` : le catalogue embarqué ; +- `mcp_client.go` : le pool de sessions, avec le SDK MCP Go ; +- les routes `/api/mcp*` de `web_api.go`. + +Le pool sert deux usages : l'état affiché dans l'interface, et les outils +`mcp____` annoncés au modèle et exécutés pour l'agent. + +Lot 20 : la façade tient tout cela quand `LOKI_MCP=rust` (`mcp/`, +expérimental). Sans la variable, rien ne change. + +| Route | Rôle | +|---|---| +| `GET /api/mcp` | état de chaque serveur, connexion tentée pour les activés (en parallèle) | +| `GET /api/mcp/catalog` | catalogue embarqué (le même fichier que le Go), `{workspace}` résolu, entrées déjà configurées marquées, lanceur (npx, uvx) présent ou non | +| `POST /api/mcp/save` | ajoute ou remplace (actif par défaut) ; nom contrôlé, un seul transport | +| `POST /api/mcp/delete`, `/api/mcp/toggle`, `/api/mcp/tool` | retire, active ou éteint, masque ou démasque un outil | +| `POST /api/mcp/test` | reconnexion forcée | + +## Client MCP en Rust + +`mcp/client.rs` parle JSON-RPC 2.0 sur deux transports, comme le SDK Go : +- stdio : un processus local, un message par ligne ; +- Streamable HTTP : un POST par message, réponse en JSON ou en flux SSE, + session suivie par `Mcp-Session-Id`, en-têtes configurés ajoutés. + +La session s'ouvre par `initialize` puis `notifications/initialized`. Elle +sert `tools/list` (première page, comme le Go) et `tools/call`. Un `ping` du +serveur reçoit sa réponse. + +Le pool (`mcp/mod.rs`) suit `mcp_client.go` : +- une session gardée d'un tour à l'autre ; +- une entrée publiée avant la connexion, pour que deux appelants partagent + la même tentative ; +- une session invalidée pendant sa connexion n'est jamais republiée ; +- délai de 3 min pour npx et uvx (premier téléchargement), 20 s sinon ; +- un appel borné à 120 s, une reconnexion si la session est morte ; +- la sortie plafonnée à la fenêtre de contexte (12 000 au moins), une erreur + d'outil signalée, une sortie vide rendue « [ok] (aucune sortie) ». + +## Le Go + +Un seul pool, celui de la façade. Le Go, qui exécute encore les outils de +l'agent, lui demande les outils à annoncer, leurs appels et le +pré-chauffage (`/internal/facade/v1/mcp/{tools,call,prewarm}`, +`facade_mcp.go`), quelle que soit la conversation qui tourne. Son propre +pool reste vide. + +L'image copie `internal/loki/mcp_catalog.json` dans l'étape de compilation +Rust : un seul catalogue pour les deux binaires. + +## Écarts assumés + +- Les erreurs de transport (connexion refusée, flux coupé) ont les messages + du client Rust. Une erreur JSON-RPC et un binaire introuvable ont ceux du + Go. +- Un contenu « autre » (lien de ressource, ressource intégrée) est rendu en + JSON dans l'ordre des champs du SDK. Les annotations éventuelles sont dans + l'ordre alphabétique. + +## Parité + +`internal/loki/parity_mcp_test.go` tire du Go : +- les fonctions pures : noms exposés, schémas, libellés d'appel, serveur + d'origine d'un outil, ligne du préambule, délais et leurs messages, + aplatissement de neuf résultats ; +- la lecture de la configuration et seize mutations avec leurs erreurs et la + base après chacune ; +- ce qu'un vrai serveur du SDK MCP Go met sur le fil en Streamable HTTP : + onze outils, et le résultat de chacun ; +- ce que le client Go en tire : état de quatre serveurs (joignable, + binaire introuvable, désactivé, mal configuré), outils annoncés (un + masqué), appels. + +`tests/mcp.rs` rejoue les fonctions et la configuration. Un faux serveur +resert ce que le serveur du SDK a répondu (appels en SSE) : l'état, les +outils et les appels tirés par la façade doivent être ceux du Go, sur une +seule session. Sont aussi vérifiés de bout en bout : +- le transport stdio : environnement, ping du serveur, serveur mort en plein + appel puis reconnexion ; +- les routes de configuration, en-têtes compris ; +- le catalogue ; +- une session invalidée pendant sa connexion, sans processus laissé + derrière. + +Une mutation de chaque règle fait échouer les tests. Côté Go, +`TestFacadeMCP` vérifie le relais, les outils, les appels, le +pré-chauffage et une façade injoignable. diff --git a/internal/loki/facade_chat.go b/internal/loki/facade_chat.go index fc108e3..9279121 100644 --- a/internal/loki/facade_chat.go +++ b/internal/loki/facade_chat.go @@ -150,7 +150,7 @@ var facadeChatRoutes = map[string]bool{ // de la discussion (facade_files.go), les notifications (facade_push.go), la // mémoire (facade_mem.go), les presets (facade_presets.go), le pilotage du // moteur (facade_engine.go), les interrupteurs système (facade_system.go), les -// projets (facade_projects.go). +// projets (facade_projects.go), les serveurs MCP (facade_mcp.go). // Posé APRÈS requireWebAuth : seule une requête authentifiée ici part, avec le // jeton interne qui en tient lieu là-bas. func withFacadeChat(path string, h http.HandlerFunc) http.HandlerFunc { @@ -177,6 +177,8 @@ func withFacadeChat(path string, h http.HandlerFunc) http.HandlerFunc { held, unavailable = facadeSystem, "réglages système indisponibles : " case facadeProjectsRoutes[path]: held, unavailable = facadeProjects, "projets indisponibles : " + case facadeMCPRoutes[path]: + held, unavailable = facadeMCP, "serveurs MCP indisponibles : " default: return h } diff --git a/internal/loki/facade_chat_test.go b/internal/loki/facade_chat_test.go index 1c17e99..847ef57 100644 --- a/internal/loki/facade_chat_test.go +++ b/internal/loki/facade_chat_test.go @@ -10,6 +10,7 @@ import ( "strings" "sync" "testing" + "time" ) // fausseFacade : enregistre ce qui lui arrive ; /api/chat rend un flux. @@ -835,3 +836,72 @@ func TestFacadeProjets(t *testing.T) { t.Fatalf("corps illisible : %d", c) } } + +// Les serveurs MCP (LOKI_MCP=rust, lot 20) : routes relayées, et le Go +// demande à la façade les outils MCP et leurs appels (un seul pool). +func TestFacadeMCP(t *testing.T) { + testHome(t) + var calls []string + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Header.Get(internalTokenHeader) != jetonTest { + w.WriteHeader(403) + return + } + b, _ := io.ReadAll(r.Body) + calls = append(calls, r.URL.Path+" "+string(b)) + switch r.URL.Path { + case "/internal/facade/v1/mcp/tools": + io.WriteString(w, `{"tools":[{"type":"function","function":{"name":"mcp__a__t","description":"[MCP: a] d","parameters":{"type":"object"}}}]}`) + case "/internal/facade/v1/mcp/call": + io.WriteString(w, `{"result":"fait"}`) + case "/internal/facade/v1/mcp/prewarm": + io.WriteString(w, `{"ok":true}`) + default: + io.WriteString(w, `{"ok":true,"de":"facade"}`) + } + })) + defer srv.Close() + t.Setenv(internalTokenEnv, jetonTest) + t.Setenv(facadeAddrEnv, strings.TrimPrefix(srv.URL, "http://")) + local := func(w http.ResponseWriter, r *http.Request) { io.WriteString(w, "go") } + + t.Setenv(facadeMCPEnv, "") + w := httptest.NewRecorder() + withFacadeChat("/api/mcp", local)(w, httptest.NewRequest("GET", "/api/mcp", nil)) + if w.Body.String() != "go" || len(calls) != 0 { + t.Fatalf("sans LOKI_MCP : %q", w.Body.String()) + } + t.Setenv(facadeMCPEnv, "rust") + for p := range facadeMCPRoutes { + w := httptest.NewRecorder() + withFacadeChat(p, local)(w, httptest.NewRequest("POST", p, strings.NewReader("{}"))) + if !strings.Contains(w.Body.String(), "facade") { + t.Fatalf("%s non relayé : %q", p, w.Body.String()) + } + } + tools := mcpTools() + if len(tools) != 1 || tools[0].Function.Name != "mcp__a__t" || mcpPromptLine(tools) == "" { + t.Fatalf("outils : %+v", tools) + } + if got := mcpCall("mcp__a__t", map[string]any{"x": 1}); got != "fait" { + t.Fatalf("appel : %q", got) + } + if got := mcpCall("mcp__a__t", nil); got != "fait" || !strings.HasSuffix(calls[len(calls)-1], `{"args":{},"name":"mcp__a__t"}`) { + t.Fatalf("appel sans arguments : %q (%v)", got, calls) + } + MCPPrewarm() + for i := 0; i < 50 && !strings.HasPrefix(calls[len(calls)-1], "/internal/facade/v1/mcp/prewarm"); i++ { + time.Sleep(20 * time.Millisecond) + } + if !strings.HasPrefix(calls[len(calls)-1], "/internal/facade/v1/mcp/prewarm") { + t.Fatalf("pré-chauffage non demandé : %v", calls) + } + // Façade injoignable : une erreur lisible, pas de panique. + t.Setenv(facadeAddrEnv, "127.0.0.1:1") + if got := mcpCall("mcp__a__t", nil); !strings.HasPrefix(got, "[erreur MCP] façade injoignable") { + t.Fatalf("façade injoignable : %q", got) + } + if tools := mcpTools(); tools != nil { + t.Fatalf("outils sans façade : %v", tools) + } +} diff --git a/internal/loki/facade_mcp.go b/internal/loki/facade_mcp.go new file mode 100644 index 0000000..b4fa329 --- /dev/null +++ b/internal/loki/facade_mcp.go @@ -0,0 +1,105 @@ +package loki + +import ( + "bytes" + "encoding/json" + "fmt" + "net/http" + "os" + "strings" + "time" +) + +// facade_mcp.go — serveurs MCP tenus par la façade Rust (LOKI_MCP=rust, lot 20 +// de la migration ; expérimental, le Go reste le défaut) : configuration, +// catalogue, état et pool de sessions (rust/loki-rs/src/mcp). +// +// Un seul pool : celui de la façade. Le Go, qui exécute encore les outils de +// l'agent, lui demande les outils MCP à annoncer au modèle et leurs appels +// (/internal/facade/v1/mcp/*), quelle que soit la conversation qui tourne ; +// son propre pool reste vide. + +const facadeMCPEnv = "LOKI_MCP" + +// facadeMCP : la façade tient-elle les serveurs MCP ? +func facadeMCP() bool { + return strings.EqualFold(strings.TrimSpace(os.Getenv(facadeMCPEnv)), "rust") && + facadeAddr() != "" && internalToken() != "" +} + +// facadeMCPRoutes : les routes que la façade sert alors (mcp/mod.rs). +var facadeMCPRoutes = map[string]bool{ + "/api/mcp": true, + "/api/mcp/catalog": true, + "/api/mcp/save": true, + "/api/mcp/delete": true, + "/api/mcp/toggle": true, + "/api/mcp/tool": true, + "/api/mcp/test": true, +} + +// facadeMCPJSON appelle l'API interne MCP de la façade et décode sa réponse. +// Délai large : un appel d'outil MCP peut durer (mcpCallTimeout), une +// première connexion npx/uvx aussi (mcpConnectTimeoutCold). +func facadeMCPJSON(path string, body any, out any) error { + b, err := json.Marshal(body) + if err != nil { + return err + } + req, err := http.NewRequest(http.MethodPost, "http://"+facadeAddr()+path, bytes.NewReader(b)) + if err != nil { + return err + } + req.Header.Set("Content-Type", "application/json") + req.Header.Set(internalTokenHeader, internalToken()) + resp, err := (&http.Client{Timeout: mcpConnectTimeoutCold + mcpCallTimeout}).Do(req) + if err != nil { + return err + } + defer resp.Body.Close() + if resp.StatusCode >= 300 { + return fmt.Errorf("façade : %s", resp.Status) + } + return json.NewDecoder(resp.Body).Decode(out) +} + +// facadeMCPTools : les outils MCP à annoncer, tenus par la façade. +func facadeMCPTools() []Tool { + var out struct { + Tools []Tool `json:"tools"` + } + if err := facadeMCPJSON("/internal/facade/v1/mcp/tools", map[string]any{}, &out); err != nil { + fmt.Fprintf(os.Stderr, "[mcp] outils demandés à la façade : %v\n", err) + return nil + } + return out.Tools +} + +// facadeMCPCall : un outil MCP exécuté par la façade. +func facadeMCPCall(name string, args map[string]any) string { + if args == nil { + args = map[string]any{} + } + var out struct { + Result string `json:"result"` + } + if err := facadeMCPJSON("/internal/facade/v1/mcp/call", map[string]any{"name": name, "args": args}, &out); err != nil { + return "[erreur MCP] façade injoignable : " + err.Error() + } + return out.Result +} + +// facadeMCPPrewarm : la façade ouvre ses sessions en tâche de fond. +func facadeMCPPrewarm() { + go func() { + var out map[string]any + // La façade démarre le Go puis attend qu'il réponde : elle peut ne pas + // écouter encore au tout premier instant. + for i := 0; i < 10; i++ { + if facadeMCPJSON("/internal/facade/v1/mcp/prewarm", map[string]any{}, &out) == nil { + return + } + time.Sleep(time.Second) + } + }() +} diff --git a/internal/loki/mcp_client.go b/internal/loki/mcp_client.go index 14a532b..ee2bc72 100644 --- a/internal/loki/mcp_client.go +++ b/internal/loki/mcp_client.go @@ -256,6 +256,10 @@ func mcpEnsureAll(servers map[string]MCPServerConfig) { // qui donnait l'impression d'une IA très lente à démarrer. Sans mode agent les // outils MCP ne sont de toute façon pas exposés : inutile de lancer les process. func MCPPrewarm() { + if facadeMCP() { // le pool est celui de la façade (facade_mcp.go) + facadeMCPPrewarm() + return + } if !agentEnabled() { return } @@ -282,6 +286,9 @@ func mcpExposedName(server, tool string) string { // mcpTools connecte les serveurs activés et renvoie leurs outils, prêts à être // annoncés au modèle. Réservé au mode agent (appelé par EnabledTools). func mcpTools() []Tool { + if facadeMCP() { // le pool est celui de la façade (facade_mcp.go) + return facadeMCPTools() + } servers, err := LoadMCPConfig() if err != nil || len(servers) == 0 { return nil @@ -368,6 +375,9 @@ func isMCPTool(name string) bool { // mcpCall exécute un outil MCP à partir de son nom exposé et renvoie une chaîne // résultat (texte aplati), cappée. Reconnecte une fois si la session est morte. func mcpCall(name string, args map[string]any) string { + if facadeMCP() { // le pool est celui de la façade (facade_mcp.go) + return facadeMCPCall(name, args) + } mcpMgr.mu.Lock() ref, ok := mcpMgr.registry[name] mcpMgr.mu.Unlock() diff --git a/internal/loki/parity_mcp_test.go b/internal/loki/parity_mcp_test.go new file mode 100644 index 0000000..837a7ab --- /dev/null +++ b/internal/loki/parity_mcp_test.go @@ -0,0 +1,338 @@ +//go:build linux + +package loki + +import ( + "context" + "encoding/json" + "errors" + "net/http" + "net/http/httptest" + "os" + "path/filepath" + "strings" + "testing" + + mcpsdk "github.com/modelcontextprotocol/go-sdk/mcp" +) + +// parity_mcp_test.go — journal de référence des serveurs MCP (lot 20 de la +// migration Rust) : fonctions pures du client (noms exposés, schémas, +// libellés, ligne du préambule, délais, aplatissement des résultats), lecture +// et mutations de la configuration, puis un vrai serveur du SDK MCP Go servi +// en Streamable HTTP : ce qu'il annonce et renvoie (tel que sur le fil), et ce +// que le client Go en tire (état, outils annoncés au modèle, appels). Rangé +// dans rust/loki-rs/tests/parity_mcp/mcp.json ; tests/mcp.rs le rejoue contre +// un faux serveur qui sert les mêmes réponses. +// +// LOKI_PARITY_UPDATE=1 go test ./internal/loki -run TestPariteMCP + +const parityMCPFile = "../../rust/loki-rs/tests/parity_mcp/mcp.json" + +type parityMCPMutation struct { + Op string `json:"op"` + Name string `json:"name"` + Tool string `json:"tool,omitempty"` + On bool `json:"on,omitempty"` + Cfg string `json:"cfg,omitempty"` // MCPServerConfig en JSON + Error string `json:"error"` + State string `json:"state"` // state/mcp après l'opération +} + +func TestPariteMCP(t *testing.T) { + testHome(t) + t.Cleanup(mcpCloseAll) + t.Setenv(facadeMCPEnv, "") + fx := map[string]any{} + + // --- fonctions pures --- + san := map[string]string{} + for _, s := range []string{"alpha", "mon serveur!", "__x__", "a.b/c", "é-t_é", "", "...", "A-B_c9"} { + san[s] = mcpSanitize(s) + } + fx["sanitize"] = san + var schemas []any + for _, raw := range []string{`null`, `{"type":"object","properties":{"a":{"type":"string"}}}`, `true`, `[1]`, `{}`, `"x"`} { + var v any + _ = json.Unmarshal([]byte(raw), &v) + schemas = append(schemas, map[string]any{"in": raw, "out": mcpNormalizeSchema(v)}) + } + fx["schemas"] = schemas + var labels []any + for _, raw := range []string{`{}`, `{"path":"/a","query":"q"}`, `{"query":"","url":"http://x"}`, `{"name":"n","command":"c"}`, + `{"x":1,"y":""}`, `{"long":"` + strings.Repeat("é", 200) + `"}`, `{"path":3,"file":"f"}`, + `{"file":"f","path":"p"}`, `{"moyen":"` + strings.Repeat("a", 150) + `"}`} { + var m map[string]any + _ = json.Unmarshal([]byte(raw), &m) + labels = append(labels, map[string]any{"in": raw, "out": mcpArgLabel(m)}) + } + fx["labels"] = labels + var servers []any + for _, c := range [][2]string{ + {"mcp__alpha__x", "[MCP: alpha] desc"}, {"mcp__a_b__x", "[MCP: a]b] desc"}, {"mcp__a_b_c__x", "[MCP: a] b] c] d"}, + {"mcp__x__y", "pas de préfixe"}, {"autre", "[MCP: autre] d"}, {"mcp__b__x", "[MCP: a] d"}, + } { + s, ok := mcpToolServer(Tool{Function: ToolFunction{Name: c[0], Description: c[1]}}) + servers = append(servers, map[string]any{"name": c[0], "desc": c[1], "server": s, "ok": ok}) + } + fx["tool_server"] = servers + var lines []any + for _, set := range [][][2]string{ + {}, + {{"bash", "shell"}}, + {{"mcp__b__x", "[MCP: b] d"}, {"mcp__a__y", "[MCP: a] d"}, {"mcp__a__z", "[MCP: a] e"}}, + {{"mcp__a_b__x", "[MCP: a]b] d"}}, + } { + var tools []Tool + for _, p := range set { + tools = append(tools, Tool{Function: ToolFunction{Name: p[0], Description: p[1]}}) + } + lines = append(lines, map[string]any{"tools": set, "out": mcpPromptLine(tools)}) + } + fx["prompt_line"] = lines + timeouts := map[string]any{} + for _, cmd := range []string{"npx", "/usr/local/bin/NPX", "uvx.exe", "npx.cmd", "node", "", "uvx-x", "C:/bin/uvx.EXE"} { + cfg := MCPServerConfig{Command: cmd} + timeouts[cmd] = map[string]any{"secs": int(mcpTimeoutFor(cfg).Seconds()), + "err": mcpFriendlyErr(context.DeadlineExceeded, cfg).Error()} + } + fx["timeouts"] = timeouts + fx["friendly_other"] = mcpFriendlyErr(errors.New("autre"), MCPServerConfig{Command: "npx"}).Error() + var flat []any + for _, raw := range []string{ + `{"content":[{"type":"text","text":" un "},{"type":"text","text":"deux\n"}]}`, + `{"content":[{"type":"image","data":"cG5n","mimeType":"image/png"},{"type":"audio","data":"d2F2","mimeType":"audio/wav"}]}`, + `{"content":[{"type":"resource_link","uri":"file:///a.txt","name":"a.txt","mimeType":"text/plain","size":12,"title":"A "}]}`, + `{"content":[{"type":"resource","resource":{"uri":"file:///b","mimeType":"text/plain","text":"corps"}}]}`, + `{"content":[],"structuredContent":{"b":[1,2],"a":" & y"}}`, + `{"content":[{"type":"text","text":" "}],"structuredContent":{"k":1}}`, + `{"content":[{"type":"text","text":"t"}],"structuredContent":{"k":1}}`, + `{"content":[]}`, + `{"content":[{"type":"text","text":"boum"}],"isError":true}`, + } { + var res mcpsdk.CallToolResult + if err := json.Unmarshal([]byte(raw), &res); err != nil { + t.Fatalf("%s : %v", raw, err) + } + flat = append(flat, map[string]any{"in": raw, "out": flattenMCPContent(&res)}) + } + fx["flatten"] = flat + + // --- configuration --- + var decoded []any + for _, raw := range []string{ + `{"a":{"command":"npx","args":["-y","x"],"env":{"K":"v"}}}`, + `{"b":{"url":"http://h/mcp","headers":{"Authorization":"Bearer t"},"enabled":false,"disabledTools":["t1"]}}`, + `{"c":{"command":"x","enabled":null}}`, + `null`, `{}`, + } { + var m map[string]MCPServerConfig + _ = json.Unmarshal([]byte(raw), &m) + out, _ := json.Marshal(m) + tr := map[string]string{} + for n, c := range m { + tr[n] = c.Transport() + "|" + errString(c.Validate()) + } + decoded = append(decoded, map[string]any{"in": raw, "out": string(out), "transports": tr}) + } + fx["decode"] = decoded + var muts []parityMCPMutation + cfgOf := func(raw string) MCPServerConfig { + var c MCPServerConfig + _ = json.Unmarshal([]byte(raw), &c) + return c + } + for _, m := range []parityMCPMutation{ + {Op: "set", Name: " fs ", Cfg: `{"command":"npx","args":["-y","@x/fs",""],"env":{"A":"&"}}`}, + {Op: "set", Name: "", Cfg: `{"command":"x"}`}, + {Op: "set", Name: "a__b", Cfg: `{"command":"x"}`}, + {Op: "set", Name: "both", Cfg: `{"command":"x","url":"http://u"}`}, + {Op: "set", Name: "none", Cfg: `{"enabled":true}`}, + {Op: "set", Name: "web", Cfg: `{"url":"http://u/mcp","headers":{"X":"1"},"enabled":false}`}, + {Op: "enable", Name: "web", On: true}, + {Op: "enable", Name: "nope", On: true}, + {Op: "tool", Name: "fs", Tool: "write", On: false}, + {Op: "tool", Name: "fs", Tool: "read", On: false}, + {Op: "tool", Name: "fs", Tool: "write", On: true}, + {Op: "tool", Name: "fs", Tool: "read", On: false}, + {Op: "tool", Name: "nope", Tool: "x", On: false}, + {Op: "delete", Name: "web"}, + {Op: "delete", Name: "web"}, + {Op: "delete", Name: " fs"}, + } { + var err error + switch m.Op { + case "set": + err = SetMCPServer(m.Name, cfgOf(m.Cfg)) + case "enable": + err = SetMCPServerEnabled(m.Name, m.On) + case "tool": + err = SetMCPToolEnabled(m.Name, m.Tool, m.On) + case "delete": + err = DeleteMCPServer(m.Name) + } + m.Error = errString(err) + m.State = getStr(bkState, "mcp") + muts = append(muts, m) + } + fx["mutations"] = muts + + // --- un vrai serveur MCP (SDK Go), en Streamable HTTP --- + srvMCP := mcpsdk.NewServer(&mcpsdk.Implementation{Name: "parite", Version: "1"}, nil) + schema := map[string]any{"type": "object", "properties": map[string]any{"text": map[string]any{"type": "string"}}} + text := func(s string) []mcpsdk.Content { return []mcpsdk.Content{&mcpsdk.TextContent{Text: s}} } + tools := []struct { + tool *mcpsdk.Tool + res func(args map[string]any) *mcpsdk.CallToolResult + }{ + {&mcpsdk.Tool{Name: "echo", Description: "Répète le texte ", InputSchema: schema}, + func(a map[string]any) *mcpsdk.CallToolResult { + s, _ := a["text"].(string) + return &mcpsdk.CallToolResult{Content: text(s)} + }}, + {&mcpsdk.Tool{Name: "multi", Description: "Deux blocs", InputSchema: map[string]any{"type": "object"}}, + func(map[string]any) *mcpsdk.CallToolResult { + return &mcpsdk.CallToolResult{Content: []mcpsdk.Content{&mcpsdk.TextContent{Text: "un"}, &mcpsdk.TextContent{Text: "deux"}}} + }}, + {&mcpsdk.Tool{Name: "media", Description: "Image et son", InputSchema: map[string]any{"type": "object"}}, + func(map[string]any) *mcpsdk.CallToolResult { + return &mcpsdk.CallToolResult{Content: []mcpsdk.Content{&mcpsdk.ImageContent{Data: []byte("png"), MIMEType: "image/png"}, + &mcpsdk.AudioContent{Data: []byte("wav"), MIMEType: "audio/wav"}}} + }}, + {&mcpsdk.Tool{Name: "link", Description: "Un lien", InputSchema: map[string]any{"type": "object"}}, + func(map[string]any) *mcpsdk.CallToolResult { + sz := int64(12) + return &mcpsdk.CallToolResult{Content: []mcpsdk.Content{&mcpsdk.ResourceLink{URI: "file:///a.txt", Name: "a.txt", Title: "A", MIMEType: "text/plain", Size: &sz}}} + }}, + {&mcpsdk.Tool{Name: "struct", Description: "Structuré", InputSchema: map[string]any{"type": "object"}}, + func(map[string]any) *mcpsdk.CallToolResult { + return &mcpsdk.CallToolResult{Content: []mcpsdk.Content{}, StructuredContent: map[string]any{"b": []any{1, 2}, "a": " & y"}} + }}, + {&mcpsdk.Tool{Name: "fail", Description: "Échoue", InputSchema: map[string]any{"type": "object"}}, + func(map[string]any) *mcpsdk.CallToolResult { + return &mcpsdk.CallToolResult{Content: text("boum"), IsError: true} + }}, + {&mcpsdk.Tool{Name: "empty", Description: "Rien", InputSchema: map[string]any{"type": "object"}}, + func(map[string]any) *mcpsdk.CallToolResult { + return &mcpsdk.CallToolResult{Content: []mcpsdk.Content{}} + }}, + {&mcpsdk.Tool{Name: "big", Description: "Long", InputSchema: map[string]any{"type": "object"}}, + func(map[string]any) *mcpsdk.CallToolResult { + return &mcpsdk.CallToolResult{Content: text(strings.Repeat("é", 40000))} + }}, + {&mcpsdk.Tool{Name: "titled", Title: "Titre seul", InputSchema: map[string]any{"type": "object"}}, + func(map[string]any) *mcpsdk.CallToolResult { return &mcpsdk.CallToolResult{Content: text("ok")} }}, + {&mcpsdk.Tool{Name: "secret", Description: "Masqué", InputSchema: map[string]any{"type": "object"}}, + func(map[string]any) *mcpsdk.CallToolResult { return &mcpsdk.CallToolResult{Content: text("chut")} }}, + {&mcpsdk.Tool{Name: "dot.tool", Description: "Nom pointé", InputSchema: map[string]any{"type": "object", "properties": map[string]any{}}}, + func(map[string]any) *mcpsdk.CallToolResult { return &mcpsdk.CallToolResult{Content: text("point")} }}, + } + for _, tl := range tools { + res := tl.res + srvMCP.AddTool(tl.tool, func(_ context.Context, req *mcpsdk.CallToolRequest) (*mcpsdk.CallToolResult, error) { + var args map[string]any + _ = json.Unmarshal(req.Params.Arguments, &args) + return res(args), nil + }) + } + ts := httptest.NewServer(mcpsdk.NewStreamableHTTPHandler(func(*http.Request) *mcpsdk.Server { return srvMCP }, nil)) + // Les sessions du pool et du serveur tiennent des connexions ouvertes : + // fermées avant le serveur, sans quoi Close attend indéfiniment. + defer func() { + mcpCloseAll() + for ss := range srvMCP.Sessions() { + _ = ss.Close() + } + ts.CloseClientConnections() + ts.Close() + }() + + // Ce que le serveur met sur le fil (le faux serveur Rust le resservira). + cl := mcpsdk.NewClient(&mcpsdk.Implementation{Name: "t", Version: "1"}, nil) + sess, err := cl.Connect(context.Background(), &mcpsdk.StreamableClientTransport{Endpoint: ts.URL}, nil) + if err != nil { + t.Fatal(err) + } + lt, err := sess.ListTools(context.Background(), nil) + if err != nil { + t.Fatal(err) + } + wire := map[string]any{} + b, _ := json.Marshal(lt) + var ltv any + _ = json.Unmarshal(b, <v) + wire["tools/list"] = ltv + calls := map[string]any{} + callArgs := map[string]map[string]any{"echo": {"text": "salut "}} + for _, tl := range tools { + res, err := sess.CallTool(context.Background(), &mcpsdk.CallToolParams{Name: tl.tool.Name, Arguments: callArgs[tl.tool.Name]}) + if err != nil { + t.Fatal(err) + } + b, _ := json.Marshal(res) + var v any + _ = json.Unmarshal(b, &v) + calls[tl.tool.Name] = v + } + wire["tools/call"] = calls + _ = sess.Close() + fx["wire"] = wire + + // Ce que le client de Loki en tire. + _ = putStr(bkState, "mcp", "") + if err := SetMCPServer("alpha", MCPServerConfig{URL: ts.URL, Enabled: true}); err != nil { + t.Fatal(err) + } + _ = SetMCPToolEnabled("alpha", "secret", false) + _ = SetMCPServer("beta", MCPServerConfig{Command: "loki-mcp-absent-pour-test", Enabled: true}) + _ = SetMCPServer("gamma", MCPServerConfig{URL: ts.URL, Enabled: false}) + raw := getStr(bkState, "mcp") + raw = strings.Replace(raw, `}}`, `},"delta":{"enabled":true}}`, 1) + _ = putStr(bkState, "mcp", raw) + fx["store"] = strings.ReplaceAll(getStr(bkState, "mcp"), ts.URL, "{{URL}}") + st, err := MCPStatus() + if err != nil { + t.Fatal(err) + } + fx["status"] = st + fx["tools"] = mcpTools() + results := map[string]string{} + for _, n := range []string{"echo", "multi", "media", "link", "struct", "fail", "empty", "big", "titled", "secret", "dot_tool", "nope"} { + name := mcpExposedName("alpha", n) + results[name] = mcpCall(name, callArgs[n]) + } + results["mcp__zeta__x"] = mcpCall("mcp__zeta__x", nil) + fx["calls"] = results + fx["prompt_line_live"] = mcpPromptLine(mcpTools()) + + got, err := parityJSON(fx) + if err != nil { + t.Fatal(err) + } + got = []byte(strings.ReplaceAll(string(got), ts.URL, "{{URL}}")) + if os.Getenv("LOKI_PARITY_UPDATE") == "1" { + if err := os.MkdirAll(filepath.Dir(parityMCPFile), 0o755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(parityMCPFile, got, 0o644); err != nil { + t.Fatal(err) + } + return + } + want, err := os.ReadFile(parityMCPFile) + 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("le client MCP ne produit plus le journal rangé dans %s.\n"+ + "Si c'est voulu : LOKI_PARITY_UPDATE=1 go test ./internal/loki -run TestPariteMCP,\n"+ + "puis cargo test --test mcp dit si le port Rust suit.\n--- obtenu ---\n%s", parityMCPFile, got) + } +} + +func errString(err error) string { + if err == nil { + return "" + } + return err.Error() +} diff --git a/rust/loki-rs/src/api.rs b/rust/loki-rs/src/api.rs index 71e7c0e..a0b13f7 100644 --- a/rust/loki-rs/src/api.rs +++ b/rust/loki-rs/src/api.rs @@ -48,6 +48,8 @@ pub struct Ctx { pub system: Option>, /// Hub des projets tenu par la façade (`LOKI_PROJECTS=rust`). pub projects: bool, + /// Serveurs MCP tenus par la façade (`LOKI_MCP=rust`). + pub mcp: Option>, /// Jeton de l'API interne : une requête qui le porte vient du Go. pub internal_token: String, } @@ -67,6 +69,7 @@ impl Ctx { || self.engine_ctl.is_some() || self.system.is_some() || self.projects + || self.mcp.is_some() } /// La requête vient du Go (jeton interne valide) : déjà authentifiée par diff --git a/rust/loki-rs/src/lib.rs b/rust/loki-rs/src/lib.rs index b558f16..eb95de7 100644 --- a/rust/loki-rs/src/lib.rs +++ b/rust/loki-rs/src/lib.rs @@ -25,6 +25,7 @@ pub mod engstatus; pub mod gguf; pub mod gojson; pub mod hf; +pub mod mcp; pub mod memory; pub mod models; pub mod oai; diff --git a/rust/loki-rs/src/main.rs b/rust/loki-rs/src/main.rs index 78e1b8c..a150f4c 100644 --- a/rust/loki-rs/src/main.rs +++ b/rust/loki-rs/src/main.rs @@ -156,6 +156,9 @@ fn serve() -> ExitCode { let system_rust = held_by_facade("LOKI_SYSTEM"); // LOKI_PROJECTS=rust : le hub des projets. let projects_rust = held_by_facade("LOKI_PROJECTS"); + // LOKI_MCP=rust : les serveurs MCP (le Go demande alors à la façade + // les outils MCP et leurs appels). + let mcp_rust = held_by_facade("LOKI_MCP"); let engine_rust = held_by_facade("LOKI_ENGINE") && match loki_rs::enginectl::usable( std::env::var("LOKI_CONTAINER").ok().as_deref(), @@ -185,6 +188,7 @@ fn serve() -> ExitCode { ("LOKI_ENGINE", engine_rust), ("LOKI_SYSTEM", system_rust), ("LOKI_PROJECTS", projects_rust), + ("LOKI_MCP", mcp_rust), ]; for (var, on) in held { if on { @@ -284,11 +288,13 @@ fn serve() -> ExitCode { settings: settings_rust, files, push: push_rust.then(|| std::sync::Arc::new(loki_rs::push::Push::new())), - mem: mem_rust.then(|| std::sync::Arc::new(loki_rs::memory::Memory::new(mem_home))), + mem: mem_rust + .then(|| std::sync::Arc::new(loki_rs::memory::Memory::new(mem_home.clone()))), presets: presets_rust, engine_ctl, system: system_rust.then(|| std::sync::Arc::new(loki_rs::system::System::new())), projects: projects_rust, + mcp: mcp_rust.then(|| std::sync::Arc::new(loki_rs::mcp::Mcp::new(mem_home))), }; let res = proxy::serve(listener, cfg, shutdown_signal()).await; eprintln!("[loki-rs] arrêt demandé — arrêt du serveur Go"); diff --git a/rust/loki-rs/src/mcp/client.rs b/rust/loki-rs/src/mcp/client.rs new file mode 100644 index 0000000..c009e32 --- /dev/null +++ b/rust/loki-rs/src/mcp/client.rs @@ -0,0 +1,440 @@ +//! Client MCP (Model Context Protocol) : JSON-RPC 2.0 sur deux transports, +//! comme le SDK Go qu'utilisait `mcp_client.go` : +//! +//! - stdio : un processus local, un message JSON par ligne sur stdin/stdout ; +//! - Streamable HTTP : un POST par message, réponse en JSON ou en flux SSE, +//! session suivie par l'en-tête `Mcp-Session-Id`. +//! +//! La session s'ouvre par `initialize` puis `notifications/initialized` ; le +//! client ne sert que `tools/list` (première page, comme le Go) et +//! `tools/call`. Une requête du serveur (`ping`) reçoit une réponse vide, les +//! autres « méthode inconnue ». + +use std::collections::{BTreeMap, HashMap}; +use std::sync::atomic::{AtomicBool, AtomicI64, Ordering}; +use std::sync::{Arc, Mutex}; + +use serde_json::{Map, Value, json}; +use tokio::io::{AsyncBufReadExt, AsyncWriteExt, BufReader}; +use tokio::process::{Child, ChildStdin}; +use tokio::sync::oneshot; + +use super::config::ServerConfig; + +/// Version du protocole demandée (celle du SDK Go 1.6). +pub const PROTOCOL_VERSION: &str = "2025-11-25"; + +type Pending = Arc>>>; + +struct Stdio { + stdin: Arc>, + pending: Pending, + child: tokio::sync::Mutex>, + closed: Arc, +} + +struct Http { + client: reqwest::Client, + url: String, + headers: BTreeMap, + session_id: Mutex>, + protocol: Mutex>, +} + +enum Transport { + Stdio(Stdio), + Http(Http), +} + +/// Une session MCP ouverte. +pub struct Session { + transport: Transport, + next_id: AtomicI64, +} + +/// Le message d'erreur d'`exec` du Go pour un binaire introuvable. +fn spawn_error(cmd: &str, e: &std::io::Error) -> String { + if e.kind() == std::io::ErrorKind::NotFound { + if cmd.contains('/') { + return format!("fork/exec {cmd}: no such file or directory"); + } + return format!("exec: \"{cmd}\": executable file not found in $PATH"); + } + e.to_string() +} + +/// Message d'une erreur JSON-RPC (`{code, message}`). +fn rpc_error(method: &str, err: &Value) -> String { + let msg = err + .get("message") + .and_then(Value::as_str) + .unwrap_or("erreur JSON-RPC"); + format!("calling \"{method}\": {msg}") +} + +impl Session { + /// Ouvre la session : processus ou point d'accès HTTP, puis la poignée de + /// main. `version` : celle annoncée dans `clientInfo`. + pub async fn connect(cfg: &ServerConfig, version: &str) -> Result { + let transport = match cfg.transport() { + "stdio" => Transport::Stdio(spawn(cfg)?), + "http" => { + crate::chat::engine::install_crypto(); + Transport::Http(Http { + client: reqwest::Client::builder() + .build() + .map_err(|e| e.to_string())?, + url: cfg.url.trim().to_string(), + headers: cfg.headers.clone(), + session_id: Mutex::new(None), + protocol: Mutex::new(None), + }) + } + _ => return Err("serveur MCP mal configuré (ni command ni url)".into()), + }; + let s = Session { + transport, + next_id: AtomicI64::new(1), + }; + let init = s + .request( + "initialize", + json!({ + "protocolVersion": PROTOCOL_VERSION, + "capabilities": {}, + "clientInfo": { "name": "loki", "version": version }, + }), + ) + .await; + let init = match init { + Ok(v) => v, + Err(e) => { + s.close().await; + return Err(e); + } + }; + if let Transport::Http(h) = &s.transport { + let v = init + .get("protocolVersion") + .and_then(Value::as_str) + .unwrap_or(PROTOCOL_VERSION) + .to_string(); + *h.protocol.lock().unwrap_or_else(|e| e.into_inner()) = Some(v); + } + if let Err(e) = s.notify("notifications/initialized", json!({})).await { + s.close().await; + return Err(e); + } + Ok(s) + } + + /// `tools/list` (première page, comme le client Go). + pub async fn list_tools(&self) -> Result, String> { + let r = self.request("tools/list", json!({})).await?; + Ok(r.get("tools") + .and_then(Value::as_array) + .cloned() + .unwrap_or_default()) + } + + /// `tools/call` : le résultat brut (`content`, `structuredContent`, + /// `isError`). + pub async fn call_tool(&self, name: &str, args: &Map) -> Result { + self.request( + "tools/call", + json!({ "name": name, "arguments": Value::Object(args.clone()) }), + ) + .await + } + + /// La session est-elle tombée (processus mort) ? + pub fn is_closed(&self) -> bool { + match &self.transport { + Transport::Stdio(s) => s.closed.load(Ordering::SeqCst), + Transport::Http(_) => false, + } + } + + /// Ferme la session : processus tué, ou session HTTP terminée. + pub async fn close(&self) { + match &self.transport { + Transport::Stdio(s) => { + s.closed.store(true, Ordering::SeqCst); + if let Some(mut c) = s.child.lock().await.take() { + let _ = c.kill().await; + } + } + Transport::Http(h) => { + let sid = h + .session_id + .lock() + .unwrap_or_else(|e| e.into_inner()) + .clone(); + if let Some(sid) = sid { + let mut rq = h.client.delete(&h.url).header("Mcp-Session-Id", sid); + for (k, v) in &h.headers { + rq = rq.header(k.as_str(), v.as_str()); + } + let _ = + tokio::time::timeout(std::time::Duration::from_secs(5), rq.send()).await; + } + } + } + } + + async fn request(&self, method: &str, params: Value) -> Result { + let id = self.next_id.fetch_add(1, Ordering::SeqCst); + let msg = json!({ "jsonrpc": "2.0", "id": id, "method": method, "params": params }); + let resp = match &self.transport { + Transport::Stdio(s) => s.request(id, method, &msg).await?, + Transport::Http(h) => h.request(id, method, &msg).await?, + }; + if let Some(err) = resp.get("error").filter(|e| !e.is_null()) { + return Err(rpc_error(method, err)); + } + Ok(resp.get("result").cloned().unwrap_or(Value::Null)) + } + + async fn notify(&self, method: &str, params: Value) -> Result<(), String> { + let msg = json!({ "jsonrpc": "2.0", "method": method, "params": params }); + match &self.transport { + Transport::Stdio(s) => s.write(&msg).await, + Transport::Http(h) => h.post(&msg).await.map(|_| ()), + } + } +} + +impl Drop for Session { + fn drop(&mut self) { + if let Transport::Stdio(s) = &self.transport { + s.closed.store(true, Ordering::SeqCst); + if let Ok(mut g) = s.child.try_lock() + && let Some(c) = g.as_mut() + { + let _ = c.start_kill(); + } + } + } +} + +// --------------------------------------------------------------------------- +// stdio +// --------------------------------------------------------------------------- + +fn spawn(cfg: &ServerConfig) -> Result { + let mut cmd = tokio::process::Command::new(&cfg.command); + cmd.args(&cfg.args) + .envs(&cfg.env) + .stdin(std::process::Stdio::piped()) + .stdout(std::process::Stdio::piped()) + .stderr(std::process::Stdio::null()) + .kill_on_drop(true); + let mut child = cmd.spawn().map_err(|e| spawn_error(&cfg.command, &e))?; + let stdin = Arc::new(tokio::sync::Mutex::new( + child.stdin.take().ok_or("stdin indisponible")?, + )); + let stdout = child.stdout.take().ok_or("stdout indisponible")?; + let pending: Pending = Arc::new(Mutex::new(HashMap::new())); + let closed = Arc::new(AtomicBool::new(false)); + let (p2, c2, w2) = (pending.clone(), closed.clone(), stdin.clone()); + tokio::spawn(async move { + let mut lines = BufReader::new(stdout).lines(); + while let Ok(Some(line)) = lines.next_line().await { + let Ok(v) = serde_json::from_str::(&line) else { + continue; + }; + if let Some(reply) = dispatch(v, &p2) { + let mut b = serde_json::to_vec(&reply).unwrap_or_default(); + b.push(b'\n'); + let mut w = w2.lock().await; + let _ = w.write_all(&b).await; + let _ = w.flush().await; + } + } + c2.store(true, Ordering::SeqCst); + // Les appels en attente échouent : leur émetteur disparaît. + p2.lock().unwrap_or_else(|e| e.into_inner()).clear(); + }); + Ok(Stdio { + stdin, + pending, + child: tokio::sync::Mutex::new(Some(child)), + closed, + }) +} + +/// Une ligne lue : réponse à un appel en attente (rien à renvoyer), requête +/// du serveur (la réponse à lui écrire), ou notification (ignorée). +fn dispatch(v: Value, pending: &Pending) -> Option { + let has_method = v.get("method").is_some(); + match (v.get("id"), has_method) { + (Some(id), false) => { + if let Some(n) = id.as_i64() + && let Some(tx) = pending.lock().unwrap_or_else(|e| e.into_inner()).remove(&n) + { + let _ = tx.send(v); + } + None + } + (Some(id), true) => Some(if v.get("method").and_then(Value::as_str) == Some("ping") { + json!({ "jsonrpc": "2.0", "id": id, "result": {} }) + } else { + json!({ "jsonrpc": "2.0", "id": id, + "error": { "code": -32601, "message": "method not found" } }) + }), + _ => None, + } +} + +impl Stdio { + async fn write(&self, msg: &Value) -> Result<(), String> { + if self.closed.load(Ordering::SeqCst) { + return Err("connection closed".into()); + } + let mut line = serde_json::to_vec(msg).map_err(|e| e.to_string())?; + line.push(b'\n'); + let mut w = self.stdin.lock().await; + w.write_all(&line).await.map_err(|e| e.to_string())?; + w.flush().await.map_err(|e| e.to_string()) + } + + async fn request(&self, id: i64, method: &str, msg: &Value) -> Result { + let (tx, rx) = oneshot::channel(); + self.pending + .lock() + .unwrap_or_else(|e| e.into_inner()) + .insert(id, tx); + if let Err(e) = self.write(msg).await { + self.pending + .lock() + .unwrap_or_else(|e| e.into_inner()) + .remove(&id); + return Err(format!("calling \"{method}\": {e}")); + } + rx.await + .map_err(|_| format!("calling \"{method}\": connection closed")) + } +} + +// --------------------------------------------------------------------------- +// Streamable HTTP +// --------------------------------------------------------------------------- + +impl Http { + fn builder(&self, msg: &Value) -> reqwest::RequestBuilder { + let mut rq = self + .client + .post(&self.url) + .header("Content-Type", "application/json") + .header("Accept", "application/json, text/event-stream") + .body(serde_json::to_vec(msg).unwrap_or_default()); + if let Some(sid) = self + .session_id + .lock() + .unwrap_or_else(|e| e.into_inner()) + .clone() + { + rq = rq.header("Mcp-Session-Id", sid); + } + if let Some(p) = self + .protocol + .lock() + .unwrap_or_else(|e| e.into_inner()) + .clone() + { + rq = rq.header("MCP-Protocol-Version", p); + } + for (k, v) in &self.headers { + rq = rq.header(k.as_str(), v.as_str()); + } + rq + } + + async fn post(&self, msg: &Value) -> Result { + let resp = self.builder(msg).send().await.map_err(|e| e.to_string())?; + if let Some(sid) = resp + .headers() + .get("mcp-session-id") + .and_then(|v| v.to_str().ok()) + { + *self.session_id.lock().unwrap_or_else(|e| e.into_inner()) = Some(sid.to_string()); + } + if !resp.status().is_success() { + let st = resp.status(); + let body = resp.text().await.unwrap_or_default(); + let body = body.trim(); + return Err(if body.is_empty() { + st.to_string() + } else { + format!("{st}: {body}") + }); + } + Ok(resp) + } + + async fn request(&self, id: i64, method: &str, msg: &Value) -> Result { + let resp = self + .post(msg) + .await + .map_err(|e| format!("calling \"{method}\": {e}"))?; + let ctype = resp + .headers() + .get("content-type") + .and_then(|v| v.to_str().ok()) + .unwrap_or("") + .to_ascii_lowercase(); + let body = resp + .text() + .await + .map_err(|e| format!("calling \"{method}\": {e}"))?; + let mut candidates: Vec = Vec::new(); + if ctype.starts_with("text/event-stream") { + let mut data = String::new(); + for line in body.lines().chain(std::iter::once("")) { + if line.is_empty() { + if !data.is_empty() { + if let Ok(v) = serde_json::from_str::(&data) { + candidates.push(v); + } + data.clear(); + } + } else if let Some(d) = line.strip_prefix("data:") { + if !data.is_empty() { + data.push('\n'); + } + data.push_str(d.strip_prefix(' ').unwrap_or(d)); + } + } + } else if let Ok(v) = serde_json::from_str::(&body) { + match v { + Value::Array(a) => candidates.extend(a), + v => candidates.push(v), + } + } + candidates + .into_iter() + .find(|v| v.get("id").and_then(Value::as_i64) == Some(id) && v.get("method").is_none()) + .ok_or_else(|| format!("calling \"{method}\": réponse absente")) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn erreurs_comme_go() { + let e = std::io::Error::from(std::io::ErrorKind::NotFound); + assert_eq!( + spawn_error("bidon", &e), + "exec: \"bidon\": executable file not found in $PATH" + ); + assert_eq!( + rpc_error( + "tools/call", + &json!({"code": -32602, "message": "outil inconnu"}) + ), + "calling \"tools/call\": outil inconnu" + ); + } +} diff --git a/rust/loki-rs/src/mcp/config.rs b/rust/loki-rs/src/mcp/config.rs new file mode 100644 index 0000000..f7eaf48 --- /dev/null +++ b/rust/loki-rs/src/mcp/config.rs @@ -0,0 +1,483 @@ +//! Configuration des serveurs MCP (`mcp_config.go`) et fonctions pures du +//! client (`mcp_client.go`) : nommage des outils, schémas, libellés, ligne du +//! préambule, délais. + +use std::collections::BTreeMap; +use std::time::Duration; + +use serde::{Deserialize, Serialize}; +use serde_json::{Map, Value, json}; + +/// `MCPServerConfig` : un seul transport renseigné, `command` (stdio) ou +/// `url` (Streamable HTTP). +#[derive(Debug, Clone, PartialEq, Eq, Serialize)] +pub struct ServerConfig { + #[serde(skip_serializing_if = "String::is_empty")] + pub command: String, + #[serde(skip_serializing_if = "Vec::is_empty")] + pub args: Vec, + #[serde(skip_serializing_if = "BTreeMap::is_empty")] + pub env: BTreeMap, + #[serde(skip_serializing_if = "String::is_empty")] + pub url: String, + #[serde(skip_serializing_if = "BTreeMap::is_empty")] + pub headers: BTreeMap, + pub enabled: bool, + #[serde(rename = "disabledTools", skip_serializing_if = "Vec::is_empty")] + pub disabled_tools: Vec, +} + +impl Default for ServerConfig { + fn default() -> Self { + ServerConfig { + command: String::new(), + args: Vec::new(), + env: BTreeMap::new(), + url: String::new(), + headers: BTreeMap::new(), + enabled: true, + disabled_tools: Vec::new(), + } + } +} + +/// Lecture tolérante : `enabled` vaut vrai quand la clé est absente (fichiers +/// à la Claude Desktop), les champs mal typés restent vides. +impl<'de> Deserialize<'de> for ServerConfig { + fn deserialize>(d: D) -> Result { + let v = Map::::deserialize(d)?; + let s = |k: &str| v.get(k).and_then(Value::as_str).unwrap_or("").to_string(); + let list = |k: &str| -> Vec { + v.get(k) + .and_then(Value::as_array) + .map(|a| { + a.iter() + .filter_map(|x| x.as_str().map(str::to_string)) + .collect() + }) + .unwrap_or_default() + }; + let map = |k: &str| -> BTreeMap { + v.get(k) + .and_then(Value::as_object) + .map(|o| { + o.iter() + .filter_map(|(k, x)| x.as_str().map(|s| (k.clone(), s.to_string()))) + .collect() + }) + .unwrap_or_default() + }; + Ok(ServerConfig { + command: s("command"), + args: list("args"), + env: map("env"), + url: s("url"), + headers: map("headers"), + enabled: match v.get("enabled") { + None => true, + Some(x) => x.as_bool().unwrap_or(false), + }, + disabled_tools: list("disabledTools"), + }) + } +} + +impl ServerConfig { + /// `Transport` : « stdio », « http » ou « » (mal configuré). + pub fn transport(&self) -> &'static str { + if !self.command.trim().is_empty() { + "stdio" + } else if !self.url.trim().is_empty() { + "http" + } else { + "" + } + } + + /// `Validate` : exactement un transport. + pub fn validate(&self) -> Result<(), String> { + let (cmd, url) = (!self.command.trim().is_empty(), !self.url.trim().is_empty()); + match (cmd, url) { + (true, true) => Err( + "un serveur MCP ne peut avoir à la fois 'command' (stdio) et 'url' (http)".into(), + ), + (false, false) => { + Err("un serveur MCP doit avoir soit 'command' (stdio) soit 'url' (http)".into()) + } + _ => Ok(()), + } + } + + /// `ToolDisabled`. + pub fn tool_disabled(&self, tool: &str) -> bool { + self.disabled_tools.iter().any(|t| t == tool) + } +} + +/// Les serveurs déclarés (`state/mcp`), triés par nom. +pub type Servers = BTreeMap; + +/// `loadMCPConfigLocked` : illisible ou absent → aucun serveur. +pub fn decode_servers(raw: Option<&[u8]>) -> Servers { + raw.and_then(|b| serde_json::from_slice::>(b).ok()) + .flatten() + .unwrap_or_default() +} + +/// `SetMCPServer` (sans l'invalidation du pool) : nom contrôlé, transport +/// validé. +pub fn set_server(servers: &mut Servers, name: &str, cfg: ServerConfig) -> Result<(), String> { + let name = name.trim(); + if name.is_empty() { + return Err("nom de serveur vide".into()); + } + if name.contains("__") { + return Err("le nom ne peut pas contenir '__' (réservé au namespacing des outils)".into()); + } + cfg.validate()?; + servers.insert(name.to_string(), cfg); + Ok(()) +} + +fn unknown(name: &str) -> String { + format!("serveur MCP inconnu: {name}") +} + +/// `DeleteMCPServer`. +pub fn delete_server(servers: &mut Servers, name: &str) -> Result<(), String> { + servers + .remove(name) + .map(|_| ()) + .ok_or_else(|| unknown(name)) +} + +/// `SetMCPServerEnabled`. +pub fn set_enabled(servers: &mut Servers, name: &str, on: bool) -> Result<(), String> { + let cfg = servers.get_mut(name).ok_or_else(|| unknown(name))?; + cfg.enabled = on; + Ok(()) +} + +/// `SetMCPToolEnabled` : la liste reconstruite sans l'outil, puis l'outil +/// ajouté en fin s'il est masqué. +pub fn set_tool_enabled( + servers: &mut Servers, + server: &str, + tool: &str, + on: bool, +) -> Result<(), String> { + let cfg = servers.get_mut(server).ok_or_else(|| unknown(server))?; + cfg.disabled_tools.retain(|t| t != tool); + if !on { + cfg.disabled_tools.push(tool.to_string()); + } + Ok(()) +} + +// --------------------------------------------------------------------------- +// Fonctions pures du client +// --------------------------------------------------------------------------- + +/// `mcpSanitize` : `[^a-zA-Z0-9_-]+` → `_`, puis `_` retirés aux bords. +pub fn sanitize(s: &str) -> String { + let mut out = String::with_capacity(s.len()); + let mut in_run = false; + for c in s.chars() { + if c.is_ascii_alphanumeric() || c == '_' || c == '-' { + out.push(c); + in_run = false; + } else if !in_run { + out.push('_'); + in_run = true; + } + } + out.trim_matches('_').to_string() +} + +/// `mcpExposedName` : `mcp____`. +pub fn exposed_name(server: &str, tool: &str) -> String { + format!("mcp__{}__{}", sanitize(server), sanitize(tool)) +} + +/// `isMCPTool`. +pub fn is_mcp_tool(name: &str) -> bool { + name.starts_with("mcp__") +} + +/// `mcpNormalizeSchema` : un objet tel quel, sinon un objet sans propriété. +pub fn normalize_schema(schema: Option<&Value>) -> Value { + match schema { + Some(Value::Object(m)) => Value::Object(m.clone()), + _ => json!({ "type": "object", "properties": {} }), + } +} + +/// `mcpArgLabel` : une clé parlante, sinon le JSON des arguments (120 runes). +pub fn arg_label(args: &Map) -> String { + if args.is_empty() { + return String::new(); + } + for k in ["path", "file", "query", "command", "url", "name"] { + if let Some(Value::String(v)) = args.get(k) + && !v.is_empty() + { + return v.clone(); + } + } + let s = crate::gojson::marshal(args); + if s.chars().count() > 120 { + return s.chars().take(120).collect::() + "…"; + } + s +} + +/// `mcpTagDescription`. +pub fn tag_description(server: &str, desc: &str) -> String { + format!("[MCP: {server}] {desc}") +} + +/// `mcpToolServer` : le serveur d'origine d'un outil annoncé, d'après le +/// préfixe de sa description (la première coupure dont la forme assainie +/// figure bien dans le nom de l'outil). +pub fn tool_server(name: &str, description: &str) -> Option { + if !name.starts_with("mcp__") { + return None; + } + let rest = description.strip_prefix("[MCP: ")?; + let mut i = 0; + loop { + let j = rest[i..].find("] ")?; + let server = &rest[..i + j]; + if name.starts_with(&format!("mcp__{}__", sanitize(server))) { + return Some(server.to_string()); + } + i += j + 1; + } +} + +/// `mcpPromptLine` : les serveurs des outils annoncés ce tour-ci, et leur +/// nombre d'outils. `tools` : (nom, description). +pub fn prompt_line(tools: &[(String, String)]) -> String { + let mut counts: BTreeMap = BTreeMap::new(); + for (n, d) in tools { + if let Some(s) = tool_server(n, d) { + *counts.entry(s).or_default() += 1; + } + } + if counts.is_empty() { + return String::new(); + } + let mut names: Vec = counts.iter().map(|(s, n)| format!("{s} ({n})")).collect(); + names.sort(); + format!( + "\n\nMCP servers connected (their tools are prefixed mcp____): {}.", + names.join(", ") + ) +} + +/// `mcpConnectTimeout`. +pub const CONNECT_TIMEOUT: Duration = Duration::from_secs(20); +/// `mcpConnectTimeoutCold` : npx et uvx téléchargent le paquet au premier +/// lancement. +pub const CONNECT_TIMEOUT_COLD: Duration = Duration::from_secs(180); +/// `mcpCallTimeout`. +pub const CALL_TIMEOUT: Duration = Duration::from_secs(120); + +/// Nom du lanceur (`filepath.Base`), en minuscules, sans `.exe` puis sans +/// `.cmd`. +fn launcher(cfg: &ServerConfig) -> String { + let t = cfg.command.trim_end_matches('/'); + let base = if t.is_empty() { + if cfg.command.is_empty() { "." } else { "/" } + } else { + t.rsplit('/').next().unwrap_or(t) + }; + let base = base.to_lowercase(); + let b = base.strip_suffix(".exe").unwrap_or(&base); + b.strip_suffix(".cmd").unwrap_or(b).to_string() +} + +/// `mcpTimeoutFor`. +pub fn timeout_for(cfg: &ServerConfig) -> Duration { + match launcher(cfg).as_str() { + "npx" | "uvx" => CONNECT_TIMEOUT_COLD, + _ => CONNECT_TIMEOUT, + } +} + +/// `mcpFriendlyErr` pour un délai dépassé. +pub fn timeout_error(cfg: &ServerConfig) -> String { + let l = launcher(cfg); + if l == "npx" || l == "uvx" { + return format!( + "délai dépassé — {l} télécharge le paquet du serveur au premier lancement, ce qui peut être long ; clique « tester » pour réessayer (le paquet reste en cache), et vérifie que le conteneur atteint le registre (npm/PyPI)" + ); + } + "délai dépassé — le serveur n'a pas répondu au handshake en 20s".into() +} + +/// `mcpOutputCap` : environ un caractère par jeton de contexte, jamais sous +/// 12 000. +pub fn output_cap(ctx: i64) -> usize { + 12000.max(ctx.max(0) as usize) +} + +/// `ctxWindow` : `CTX` de la configuration, 32 768 à défaut. +pub fn ctx_window(cfg: &std::collections::HashMap) -> i64 { + cfg.get("CTX") + .and_then(|v| v.parse::().ok()) + .filter(|n| *n > 0) + .unwrap_or(32768) +} + +/// Le texte d'un résultat d'outil, tronqué au plafond (en runes). +pub fn cap_output(out: String, lim: usize) -> String { + let n = out.chars().count(); + if n <= lim { + return out; + } + let kept: String = out.chars().take(lim).collect(); + format!("{kept}\n…[tronqué : {n} caractères au total, {lim} gardés]") +} + +/// `flattenMCPContent` : blocs texte concaténés, images et sons résumés, les +/// autres rendus en JSON ; le contenu structuré en JSON indenté s'il n'y a pas +/// de texte. +pub fn flatten_content(result: &Value) -> String { + let mut parts: Vec = Vec::new(); + for c in result + .get("content") + .and_then(Value::as_array) + .into_iter() + .flatten() + { + let kind = c.get("type").and_then(Value::as_str).unwrap_or(""); + let mime = c.get("mimeType").and_then(Value::as_str).unwrap_or(""); + match kind { + "text" => parts.push(c.get("text").and_then(Value::as_str).unwrap_or("").into()), + "image" => parts.push(format!("[image {mime}]")), + "audio" => parts.push(format!("[audio {mime}]")), + _ => parts.push(wire_content_json(c)), + } + } + let text = parts.join("\n").trim().to_string(); + if text.is_empty() + && let Some(sc) = result.get("structuredContent").filter(|v| !v.is_null()) + { + return String::from_utf8(crate::chatx::marshal_indent(sc)).unwrap_or_default(); + } + text +} + +/// `json.Marshal` d'un contenu « autre » par le SDK Go (`wireContent`) : +/// champs dans l'ordre de sa struct, vides omis. +fn wire_content_json(c: &Value) -> String { + let order = [ + "type", + "text", + "mimeType", + "data", + "resource", + "uri", + "name", + "title", + "description", + "size", + "_meta", + "annotations", + "icons", + "id", + "input", + "toolUseId", + "content", + "structuredContent", + "isError", + ]; + let mut out = String::from("{"); + let mut first = true; + for k in order { + let Some(v) = c.get(k) else { continue }; + let empty = match v { + Value::Null => true, + Value::String(s) => s.is_empty(), + Value::Bool(b) => !b, + Value::Array(a) => a.is_empty(), + Value::Object(o) => o.is_empty() && k != "resource", + Value::Number(_) => false, + }; + if empty && k != "type" { + continue; + } + if !first { + out.push(','); + } + first = false; + out.push_str(&crate::gojson::marshal(k)); + out.push(':'); + if k == "resource" { + out.push_str(&resource_contents_json(v)); + } else { + out.push_str(&crate::gojson::marshal(v)); + } + } + out.push('}'); + out +} + +/// `ResourceContents` du SDK Go : `uri` toujours, puis `mimeType`, `text`, +/// `blob`, `_meta` s'ils sont renseignés. +fn resource_contents_json(v: &Value) -> String { + let mut out = String::from("{"); + let mut first = true; + for (k, always) in [ + ("uri", true), + ("mimeType", false), + ("text", false), + ("blob", false), + ("_meta", false), + ] { + let x = v.get(k).cloned().unwrap_or(Value::Null); + let empty = match &x { + Value::Null => true, + Value::String(s) => s.is_empty(), + Value::Object(o) => o.is_empty(), + _ => false, + }; + if empty && !always { + continue; + } + if !first { + out.push(','); + } + first = false; + out.push_str(&crate::gojson::marshal(k)); + out.push(':'); + let x = if x.is_null() && always { json!("") } else { x }; + out.push_str(&crate::gojson::marshal(&x)); + } + out.push('}'); + out +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn noms() { + assert_eq!(sanitize("mon serveur!"), "mon_serveur"); + assert_eq!(exposed_name("a b", "c.d"), "mcp__a_b__c_d"); + } + + #[test] + fn lanceurs() { + let c = |cmd: &str| ServerConfig { + command: cmd.into(), + ..Default::default() + }; + assert_eq!(timeout_for(&c("/usr/bin/npx")), CONNECT_TIMEOUT_COLD); + assert_eq!(timeout_for(&c("UVX.EXE")), CONNECT_TIMEOUT_COLD); + assert_eq!(timeout_for(&c("npx.cmd")), CONNECT_TIMEOUT_COLD); + assert_eq!(timeout_for(&c("node")), CONNECT_TIMEOUT); + } +} diff --git a/rust/loki-rs/src/mcp/mod.rs b/rust/loki-rs/src/mcp/mod.rs new file mode 100644 index 0000000..312a932 --- /dev/null +++ b/rust/loki-rs/src/mcp/mod.rs @@ -0,0 +1,684 @@ +//! Serveurs MCP tenus par la façade (lot 20 de la migration, +//! `LOKI_MCP=rust` ; expérimental, le Go reste le défaut) — port de +//! `mcp_client.go`, `mcp_config.go`, `mcp_catalog.go` et des routes +//! `/api/mcp*` de `web_api.go`. +//! +//! Le pool de sessions vit ici : une session reste ouverte d'un tour à +//! l'autre (un serveur stdio garde son processus), les connexions se font en +//! parallèle, deux appelants simultanés partagent la même tentative. Le Go, +//! qui exécute encore les outils de l'agent, demande à la façade la liste des +//! outils MCP et leurs appels (`/internal/facade/v1/mcp/*`) : un seul pool, +//! quelle que soit la conversation qui tourne. + +pub mod client; +pub mod config; + +use std::collections::HashMap; +use std::path::{Path, PathBuf}; +use std::sync::{Arc, LazyLock, Mutex}; + +use futures_util::future::join_all; +use http_body_util::{BodyExt, Limited}; +use hyper::body::Incoming; +use hyper::{Request, Response, StatusCode}; +use serde_json::{Map, Value, json}; + +use crate::api::Ctx; +use crate::auth::json as send_json; +use crate::gojson::{self, Kind}; +use crate::proxy::Body; +use crate::store::{BK_STATE, Store}; +use client::Session; +use config::{ServerConfig, Servers}; + +/// Clé de la configuration (`state/mcp`). +pub const ST_MCP: &str = "mcp"; + +/// Chemins de l'API interne de la façade, pour le Go. +pub const INTERNAL_TOOLS: &str = "/internal/facade/v1/mcp/tools"; +pub const INTERNAL_CALL: &str = "/internal/facade/v1/mcp/call"; +pub const INTERNAL_PREWARM: &str = "/internal/facade/v1/mcp/prewarm"; + +/// Issue d'une tentative de connexion. +#[derive(Default)] +struct SlotState { + sess: Option>, + tools: Vec, + err: Option, +} + +/// Une entrée du pool, publiée AVANT la connexion : un second appelant attend +/// son issue au lieu de lancer un deuxième processus. +struct Slot { + cfg: ServerConfig, + state: tokio::sync::OnceCell, +} + +/// Le pool des sessions MCP et le registre nom exposé → (serveur, outil). +pub struct Mcp { + home: PathBuf, + slots: Mutex>>, + registry: Mutex>, + cfg_lock: tokio::sync::Mutex<()>, +} + +fn lock(m: &Mutex) -> std::sync::MutexGuard<'_, T> { + m.lock().unwrap_or_else(|e| e.into_inner()) +} + +impl Mcp { + pub fn new(home: PathBuf) -> Self { + Mcp { + home, + slots: Mutex::new(HashMap::new()), + registry: Mutex::new(HashMap::new()), + cfg_lock: tokio::sync::Mutex::new(()), + } + } + + /// `LoadMCPConfig`. + pub async fn load(store: &Store) -> Servers { + config::decode_servers(store.get(BK_STATE, ST_MCP).await.ok().flatten().as_deref()) + } + + async fn save(store: &Store, servers: &Servers) -> Result<(), String> { + store + .put(BK_STATE, ST_MCP, gojson::marshal(servers).into_bytes()) + .await + } + + /// Une mutation de la configuration, sérialisée, puis l'invalidation du + /// serveur touché. + async fn mutate( + &self, + store: &Store, + name: &str, + f: impl FnOnce(&mut Servers) -> Result<(), String>, + ) -> Result<(), String> { + { + let _g = self.cfg_lock.lock().await; + let mut servers = Self::load(store).await; + f(&mut servers)?; + Self::save(store, &servers).await?; + } + self.invalidate(name).await; + Ok(()) + } + + /// `mcpInvalidate` : la session du serveur fermée et oubliée, ses outils + /// retirés du registre. + pub async fn invalidate(&self, name: &str) { + let slot = lock(&self.slots).remove(name); + lock(&self.registry).retain(|_, (server, _)| server != name); + // Une session encore en cours de connexion se refermera d'elle-même en + // constatant qu'elle n'est plus dans le pool. + if let Some(s) = slot + && let Some(st) = s.state.get() + && let Some(sess) = &st.sess + { + sess.close().await; + } + } + + /// `mcpCloseAll`. + pub async fn close_all(&self) { + let slots: Vec<_> = lock(&self.slots).drain().map(|(_, s)| s).collect(); + lock(&self.registry).clear(); + for s in slots { + if let Some(st) = s.state.get() + && let Some(sess) = &st.sess + { + sess.close().await; + } + } + } + + /// `ensure` : la session du serveur, connectée ou en erreur. + async fn ensure(&self, name: &str, cfg: &ServerConfig, version: &str) -> Arc { + let slot = lock(&self.slots) + .entry(name.to_string()) + .or_insert_with(|| { + Arc::new(Slot { + cfg: cfg.clone(), + state: tokio::sync::OnceCell::new(), + }) + }) + .clone(); + slot.state + .get_or_init(|| async { + let cfg = &slot.cfg; + let mut st = SlotState::default(); + let work = async { + let sess = Session::connect(cfg, version).await?; + let tools = sess.list_tools().await; + Ok::<_, String>((sess, tools)) + }; + match tokio::time::timeout(config::timeout_for(cfg), work).await { + Err(_) => st.err = Some(config::timeout_error(cfg)), + Ok(Err(e)) => st.err = Some(e), + Ok(Ok((sess, tools))) => { + match tools { + Ok(t) => st.tools = t, + Err(e) => st.err = Some(e), + } + st.sess = Some(Arc::new(sess)); + } + } + // Invalidée pendant la connexion : jamais republiée, refermée. + let current = lock(&self.slots) + .get(name) + .is_some_and(|s| Arc::ptr_eq(s, &slot)); + if current { + let mut reg = lock(&self.registry); + reg.retain(|_, (server, _)| server != name); + for t in &st.tools { + let tn = t.get("name").and_then(Value::as_str).unwrap_or(""); + reg.insert( + config::exposed_name(name, tn), + (name.to_string(), tn.to_string()), + ); + } + } else if let Some(sess) = st.sess.take() { + sess.close().await; + } + st + }) + .await; + slot + } + + /// `mcpEnsureAll` : les serveurs activés connectés en parallèle. + async fn ensure_all(&self, servers: &Servers, version: &str) { + join_all( + servers + .iter() + .filter(|(_, c)| c.enabled && !c.transport().is_empty()) + .map(|(n, c)| self.ensure(n, c, version)), + ) + .await; + } + + /// `mcpTools` : les outils des serveurs activés, prêts à être annoncés au + /// modèle (forme `Tool` du Go). + pub async fn tools(&self, store: &Store, version: &str) -> Vec { + let servers = Self::load(store).await; + if servers.is_empty() { + return Vec::new(); + } + self.ensure_all(&servers, version).await; + let mut out = Vec::new(); + for (name, cfg) in &servers { + if !cfg.enabled || cfg.transport().is_empty() { + continue; + } + let slot = self.ensure(name, cfg, version).await; + let Some(st) = slot.state.get() else { continue }; + if st.sess.is_none() { + continue; + } + for t in &st.tools { + let tn = t.get("name").and_then(Value::as_str).unwrap_or(""); + if cfg.tool_disabled(tn) { + continue; + } + let mut desc = t.get("description").and_then(Value::as_str).unwrap_or(""); + if desc.is_empty() { + desc = t.get("title").and_then(Value::as_str).unwrap_or(""); + } + out.push(json!({ + "type": "function", + "function": { + "name": config::exposed_name(name, tn), + "description": config::tag_description(name, desc), + "parameters": config::normalize_schema(t.get("inputSchema")), + }, + })); + } + } + out + } + + /// `mcpCall` : un outil par son nom exposé ; une reconnexion si la + /// session est morte. + pub async fn call( + &self, + store: &Store, + name: &str, + args: &Map, + version: &str, + ) -> String { + let Some((server, tool)) = lock(&self.registry).get(name).cloned() else { + return format!("[erreur] outil MCP inconnu ou serveur non connecté: {name}"); + }; + let cap = config::output_cap(config::ctx_window(&store.config().await)); + let mut err = match self.call_once(&server, &tool, args, cap).await { + Ok(r) => return r, + Err(e) => e, + }; + let servers = Self::load(store).await; + if let Some(cfg) = servers.get(&server).filter(|c| c.enabled) { + self.invalidate(&server).await; + self.ensure(&server, cfg, version).await; + match self.call_once(&server, &tool, args, cap).await { + Ok(r) => return r, + Err(e) => err = e, + } + } + format!("[erreur MCP] {err}") + } + + async fn call_once( + &self, + server: &str, + tool: &str, + args: &Map, + cap: usize, + ) -> Result { + let slot = lock(&self.slots).get(server).cloned(); + let sess = slot + .as_ref() + .and_then(|s| s.state.get()) + .and_then(|st| st.sess.clone()) + .filter(|s| !s.is_closed()) + .ok_or_else(|| format!("serveur {server} non connecté"))?; + let res = tokio::time::timeout(config::CALL_TIMEOUT, sess.call_tool(tool, args)) + .await + .map_err(|_| "context deadline exceeded".to_string())??; + let out = config::cap_output(config::flatten_content(&res), cap); + if res.get("isError").and_then(Value::as_bool) == Some(true) { + return Ok(format!("[l'outil a renvoyé une erreur]\n{out}")); + } + if out.trim().is_empty() { + return Ok("[ok] (aucune sortie)".into()); + } + Ok(out) + } + + /// `MCPStatus` : l'état de chaque serveur, connexion tentée pour les + /// activés. + pub async fn status(&self, store: &Store, version: &str) -> Vec { + let servers = Self::load(store).await; + self.ensure_all(&servers, version).await; + let mut out = Vec::new(); + for (name, cfg) in &servers { + let mut st = json!({ + "name": name, + "transport": cfg.transport(), + "enabled": cfg.enabled, + "connected": false, + "tools": [], + "disabled": cfg.disabled_tools, + }); + for (k, v) in [ + ("command", json!(cfg.command)), + ("args", json!(cfg.args)), + ("env", json!(cfg.env)), + ("url", json!(cfg.url)), + ("headers", json!(cfg.headers)), + ] { + let empty = match &v { + Value::String(s) => s.is_empty(), + Value::Array(a) => a.is_empty(), + Value::Object(o) => o.is_empty(), + _ => false, + }; + if !empty { + st[k] = v; + } + } + if cfg.enabled && !cfg.transport().is_empty() { + let slot = self.ensure(name, cfg, version).await; + if let Some(s) = slot.state.get() { + if s.sess.is_some() { + st["connected"] = json!(true); + st["tools"] = json!( + s.tools + .iter() + .map(|t| t.get("name").and_then(Value::as_str).unwrap_or("")) + .collect::>() + ); + } + if let Some(e) = &s.err { + st["error"] = json!(e); + } + } + } + out.push(st); + } + out + } + + /// `MCPPrewarm` : les sessions ouvertes en tâche de fond, en mode agent + /// seulement (sans lui, aucun outil MCP n'est annoncé). + pub fn prewarm(self: &Arc, store: Store, version: String) { + let me = self.clone(); + tokio::spawn(async move { + if store.get_str(BK_STATE, "agent").await != "1" { + return; + } + let servers = Self::load(&store).await; + if !servers.is_empty() { + me.ensure_all(&servers, &version).await; + } + }); + } + + /// `MCPCatalog` : le catalogue embarqué, chemins résolus, entrées déjà + /// configurées marquées. + pub async fn catalog(&self, store: &Store) -> Result, String> { + let entries = catalog_entries()?; + let configured = Self::load(store).await; + let ws = workspace_dir(&self.home); + let expand = |s: &str| s.replace("{workspace}", &ws); + let mut out = Vec::new(); + for e in entries { + let mut e = e.clone(); + let obj = e.as_object_mut().ok_or("entrée du catalogue illisible")?; + if let Some(Value::Array(a)) = obj.get_mut("args") { + for x in a.iter_mut() { + if let Value::String(s) = x { + *s = expand(s); + } + } + } + if let Some(Value::Object(m)) = obj.get_mut("env") { + for x in m.values_mut() { + if let Value::String(s) = x { + *s = expand(s); + } + } + } + for k in ["homepage", "runtime"] { + if obj.get(k).and_then(Value::as_str) == Some("") { + obj.remove(k); + } + } + for k in ["args", "secrets"] { + if obj + .get(k) + .and_then(Value::as_array) + .is_some_and(|a| a.is_empty()) + { + obj.remove(k); + } + } + if obj + .get("env") + .and_then(Value::as_object) + .is_some_and(|m| m.is_empty()) + { + obj.remove("env"); + } + let id = obj.get("id").and_then(Value::as_str).unwrap_or(""); + let runtime = obj.get("runtime").and_then(Value::as_str).unwrap_or(""); + let (installed, runtime_ok) = (configured.contains_key(id), runtime_available(runtime)); + obj.insert("installed".into(), json!(installed)); + obj.insert("runtimeOk".into(), json!(runtime_ok)); + out.push(e); + } + Ok(out) + } +} + +/// `workspaceDir` : le dossier de travail de l'agent. +fn workspace_dir(home: &Path) -> String { + crate::paths::go_join(&[&home.to_string_lossy(), "workspace"]) +} + +/// `mcpRuntimeAvailable`. +fn runtime_available(runtime: &str) -> bool { + match runtime { + "node" => crate::paths::look_path("npx").is_some(), + "python" => crate::paths::look_path("uvx").is_some(), + _ => true, + } +} + +/// Le catalogue embarqué (celui du Go, `mcp_catalog.json`). +static CATALOG: LazyLock, String>> = LazyLock::new(|| { + let raw = include_str!("../../../../internal/loki/mcp_catalog.json"); + let v: Value = + serde_json::from_str(raw).map_err(|e| format!("catalogue MCP illisible : {e}"))?; + Ok(v.get("servers") + .and_then(Value::as_array) + .cloned() + .unwrap_or_default() + .into_iter() + .map(|mut e| { + // Les champs du type Go, dans son ordre (le reste est ignoré). + let mut o = Map::new(); + for (k, def) in [ + ("id", json!("")), + ("name", json!("")), + ("category", json!("")), + ("description", json!("")), + ("homepage", json!("")), + ("runtime", json!("")), + ("command", json!("")), + ("args", json!([])), + ("env", json!({})), + ("secrets", json!([])), + ] { + o.insert(k.into(), e.get_mut(k).map(Value::take).unwrap_or(def)); + } + Value::Object(o) + }) + .collect()) +}); + +fn catalog_entries() -> Result<&'static Vec, String> { + CATALOG.as_ref().map_err(Clone::clone) +} + +// --------------------------------------------------------------------------- +// Routes +// --------------------------------------------------------------------------- + +/// Les routes servies par ce module. +pub fn handles(path: &str) -> bool { + matches!( + path, + "/api/mcp" + | "/api/mcp/catalog" + | "/api/mcp/save" + | "/api/mcp/delete" + | "/api/mcp/toggle" + | "/api/mcp/tool" + | "/api/mcp/test" + ) +} + +fn fail(code: StatusCode, e: impl Into) -> Response { + send_json(code, &json!({ "ok": false, "error": e.into() })) +} + +const SAVE_REQ_TYPE: &str = "mcpSaveReq"; +const NAME_REQ_TYPE: &str = r#"struct { Name string "json:\"name\"" }"#; +const TOGGLE_REQ_TYPE: &str = r#"struct { Name string "json:\"name\""; On bool "json:\"on\"" }"#; +const TOOL_REQ_TYPE: &str = + r#"struct { Name string "json:\"name\""; Tool string "json:\"tool\""; On bool "json:\"on\"" }"#; + +async fn read_body(body: Incoming) -> Vec { + Limited::new(body, 1 << 20) + .collect() + .await + .map(|b| b.to_bytes().to_vec()) + .unwrap_or_default() +} + +/// La version du Go (annoncée aux serveurs dans `clientInfo`). +async fn go_version(ctx: &Ctx) -> String { + ctx.internal + .auth() + .await + .map(|i| i.version) + .unwrap_or_default() +} + +/// `from_go` : remise par le Go (tunnel), déjà authentifiée par lui. +pub async fn serve( + ctx: &Ctx, + mcp: &Arc, + req: Request, + from_go: bool, +) -> Response { + let (parts, body) = req.into_parts(); + let version = if from_go { + go_version(ctx).await + } else { + match ctx.guard(&parts.headers).await { + Ok(v) => v, + Err(refus) => return *refus, + } + }; + let store = &ctx.store; + let servers_ok = + |list: Vec| send_json(StatusCode::OK, &json!({ "ok": true, "servers": list })); + match parts.uri.path() { + "/api/mcp" => servers_ok(mcp.status(store, &version).await), + "/api/mcp/catalog" => match mcp.catalog(store).await { + Ok(c) => send_json(StatusCode::OK, &json!({ "ok": true, "catalog": c })), + Err(e) => fail(StatusCode::INTERNAL_SERVER_ERROR, e), + }, + "/api/mcp/save" => { + let raw = read_body(body).await; + let d = gojson::decode_struct( + &raw, + SAVE_REQ_TYPE, + &[ + ("name", Kind::Str), + ("command", Kind::Str), + ("args", Kind::StrList), + ("env", Kind::StrMap), + ("url", Kind::Str), + ("headers", Kind::StrMap), + ("enabled", Kind::OptBool), + ], + ); + if let Some(e) = d.err.clone() { + return fail(StatusCode::BAD_REQUEST, e); + } + let cfg = ServerConfig { + command: d.str("command").trim().to_string(), + args: d.str_list("args").unwrap_or_default(), + env: d.str_map("env").unwrap_or_default().into_iter().collect(), + url: d.str("url").trim().to_string(), + headers: d + .str_map("headers") + .unwrap_or_default() + .into_iter() + .collect(), + enabled: d.opt_bool("enabled").unwrap_or(true), + disabled_tools: Vec::new(), + }; + let name = d.str("name"); + let res = mcp + .mutate(store, name.trim(), |s| config::set_server(s, &name, cfg)) + .await; + if let Err(e) = res { + return fail(StatusCode::BAD_REQUEST, e); + } + servers_ok(mcp.status(store, &version).await) + } + "/api/mcp/delete" => { + let raw = read_body(body).await; + let d = gojson::decode_struct(&raw, NAME_REQ_TYPE, &[("name", Kind::Str)]); + if let Some(e) = d.err.clone() { + return fail(StatusCode::BAD_REQUEST, e); + } + let name = d.str("name"); + if let Err(e) = mcp + .mutate(store, &name, |s| config::delete_server(s, &name)) + .await + { + return fail(StatusCode::BAD_REQUEST, e); + } + send_json(StatusCode::OK, &json!({ "ok": true })) + } + "/api/mcp/toggle" => { + let raw = read_body(body).await; + let d = gojson::decode_struct( + &raw, + TOGGLE_REQ_TYPE, + &[("name", Kind::Str), ("on", Kind::Bool)], + ); + if let Some(e) = d.err.clone() { + return fail(StatusCode::BAD_REQUEST, e); + } + let (name, on) = (d.str("name"), d.bool("on")); + if let Err(e) = mcp + .mutate(store, &name, |s| config::set_enabled(s, &name, on)) + .await + { + return fail(StatusCode::BAD_REQUEST, e); + } + servers_ok(mcp.status(store, &version).await) + } + "/api/mcp/tool" => { + let raw = read_body(body).await; + let d = gojson::decode_struct( + &raw, + TOOL_REQ_TYPE, + &[("name", Kind::Str), ("tool", Kind::Str), ("on", Kind::Bool)], + ); + if let Some(e) = d.err.clone() { + return fail(StatusCode::BAD_REQUEST, e); + } + let (name, tool, on) = (d.str("name"), d.str("tool"), d.bool("on")); + if let Err(e) = mcp + .mutate(store, &name, |s| { + config::set_tool_enabled(s, &name, &tool, on) + }) + .await + { + return fail(StatusCode::BAD_REQUEST, e); + } + servers_ok(mcp.status(store, &version).await) + } + "/api/mcp/test" => { + let raw = read_body(body).await; + let d = gojson::decode_struct(&raw, NAME_REQ_TYPE, &[("name", Kind::Str)]); + if let Some(e) = d.err.clone() { + return fail(StatusCode::BAD_REQUEST, e); + } + mcp.invalidate(&d.str("name")).await; + servers_ok(mcp.status(store, &version).await) + } + _ => fail(StatusCode::NOT_FOUND, "route inconnue"), + } +} + +/// L'API interne de la façade pour le Go (jeton interne vérifié par +/// l'appelant) : outils annoncés, appels, pré-chauffage. +pub async fn serve_internal(ctx: &Ctx, mcp: &Arc, req: Request) -> Response { + let path = req.uri().path().to_string(); + let raw = read_body(req.into_body()).await; + let version = go_version(ctx).await; + match path.as_str() { + INTERNAL_TOOLS => send_json( + StatusCode::OK, + &json!({ "tools": mcp.tools(&ctx.store, &version).await }), + ), + INTERNAL_CALL => { + let v: Value = serde_json::from_slice(&raw).unwrap_or_default(); + let name = v.get("name").and_then(Value::as_str).unwrap_or(""); + let args = v + .get("args") + .and_then(Value::as_object) + .cloned() + .unwrap_or_default(); + let out = mcp.call(&ctx.store, name, &args, &version).await; + send_json(StatusCode::OK, &json!({ "result": out })) + } + INTERNAL_PREWARM => { + mcp.prewarm(ctx.store.clone(), version); + send_json(StatusCode::OK, &json!({ "ok": true })) + } + _ => fail(StatusCode::NOT_FOUND, "route inconnue"), + } +} + +/// Les chemins de l'API interne de ce module. +pub fn is_internal(path: &str) -> bool { + matches!(path, INTERNAL_TOOLS | INTERNAL_CALL | INTERNAL_PREWARM) +} diff --git a/rust/loki-rs/src/proxy.rs b/rust/loki-rs/src/proxy.rs index ec8a8bf..b6a56db 100644 --- a/rust/loki-rs/src/proxy.rs +++ b/rust/loki-rs/src/proxy.rs @@ -87,6 +87,9 @@ pub struct Config { pub system: Option>, /// Hub des projets tenu par la façade (`LOKI_PROJECTS=rust`). pub projects: bool, + /// Serveurs MCP (pool de sessions, configuration, catalogue) tenus par la + /// façade (`LOKI_MCP=rust`) ; `None` : au Go. + pub mcp: Option>, } struct Proxy { @@ -124,6 +127,7 @@ pub async fn serve( engine_ctl: cfg.engine_ctl, system: cfg.system, projects: cfg.projects, + mcp: cfg.mcp, internal_token: cfg.internal_token.clone(), }, }); @@ -192,6 +196,12 @@ impl Proxy { d.shutdown_fast().await; return plain(StatusCode::NO_CONTENT, ""); } + if from_go + && let Some(m) = &self.ctx.mcp + && crate::mcp::is_internal(path) + { + return crate::mcp::serve_internal(&self.ctx, m, req).await; + } // L'API interne du Go n'est jamais joignable de l'extérieur. if path.starts_with("/internal/") { return plain(StatusCode::NOT_FOUND, "404 page not found"); @@ -216,6 +226,11 @@ impl Proxy { { return chatapi::serve(&self.ctx, chat, req, from_go).await; } + if let Some(m) = &self.ctx.mcp + && crate::mcp::handles(path) + { + return crate::mcp::serve(&self.ctx, m, req, from_go).await; + } if self.ctx.projects && crate::projects_api::handles(path) { return crate::projects_api::serve(&self.ctx, req, from_go).await; } diff --git a/rust/loki-rs/tests/common/fakego.rs b/rust/loki-rs/tests/common/fakego.rs index 71c89da..4466dcf 100644 --- a/rust/loki-rs/tests/common/fakego.rs +++ b/rust/loki-rs/tests/common/fakego.rs @@ -633,6 +633,7 @@ pub struct Opts { pub engine_ctl: Option>, pub system: Option>, pub projects: bool, + pub mcp: Option>, } /// Comme [`start`], avec la conversation tenue par la façade @@ -732,6 +733,7 @@ pub async fn start_with( engine_ctl: opts.engine_ctl, system: opts.system, projects: opts.projects, + mcp: opts.mcp, }; tokio::spawn(loki_rs::proxy::serve(fl, cfg, async move { let _ = rx.await; diff --git a/rust/loki-rs/tests/lot2.rs b/rust/loki-rs/tests/lot2.rs index 0a76390..e57a6f0 100644 --- a/rust/loki-rs/tests/lot2.rs +++ b/rust/loki-rs/tests/lot2.rs @@ -104,6 +104,7 @@ async fn banc(cle: Option<&str>) -> Banc { engine_ctl: None, system: None, projects: false, + mcp: None, }; tokio::spawn(loki_rs::proxy::serve(l, cfg, async move { let _ = rx.await; diff --git a/rust/loki-rs/tests/mcp.rs b/rust/loki-rs/tests/mcp.rs new file mode 100644 index 0000000..87fd3d5 --- /dev/null +++ b/rust/loki-rs/tests/mcp.rs @@ -0,0 +1,651 @@ +//! Lot 20 : serveurs MCP tenus par la façade (`LOKI_MCP=rust`). +//! +//! Le journal tiré du Go (`internal/loki/parity_mcp_test.go` → +//! `tests/parity_mcp/mcp.json`) est rejoué : fonctions pures, lecture et +//! mutations de la configuration, puis un faux serveur MCP (Streamable HTTP) +//! qui resert ce qu'un vrai serveur du SDK Go a mis sur le fil — l'état, les +//! outils annoncés au modèle et les résultats d'appel tirés par la façade +//! doivent être ceux du client Go. Le transport stdio, les routes de +//! configuration et l'API interne pour le Go sont vérifiés de bout en bout. + +mod common; + +use std::collections::BTreeMap; +use std::convert::Infallible; +use std::sync::{Arc, Mutex}; + +use bytes::Bytes; +use common::fakego; +use http_body_util::{BodyExt, Full}; +use hyper::service::service_fn; +use hyper::{Request, Response}; +use hyper_util::rt::TokioIo; +use loki_rs::mcp::{self, Mcp, config as cf}; +use serde_json::{Map, Value, json}; +use tokio::net::TcpListener; + +fn fixture() -> Value { + serde_json::from_str(include_str!("parity_mcp/mcp.json")).expect("journal") +} + +fn s(v: &Value) -> &str { + v.as_str().unwrap() +} + +#[test] +fn parite_des_fonctions_pures() { + let fx = fixture(); + for (k, v) in fx["sanitize"].as_object().unwrap() { + assert_eq!(cf::sanitize(k), s(v), "{k:?}"); + } + for c in fx["schemas"].as_array().unwrap() { + let v: Value = serde_json::from_str(s(&c["in"])).unwrap(); + assert_eq!(cf::normalize_schema(Some(&v)), c["out"], "{}", c["in"]); + } + assert_eq!( + cf::normalize_schema(None), + json!({"type":"object","properties":{}}) + ); + for c in fx["labels"].as_array().unwrap() { + let m: Map = serde_json::from_str(s(&c["in"])).unwrap(); + assert_eq!(cf::arg_label(&m), s(&c["out"]), "{}", c["in"]); + } + for c in fx["tool_server"].as_array().unwrap() { + let got = cf::tool_server(s(&c["name"]), s(&c["desc"])); + assert_eq!(got.is_some(), c["ok"].as_bool().unwrap(), "{c}"); + assert_eq!(got.unwrap_or_default(), s(&c["server"]), "{c}"); + } + for c in fx["prompt_line"].as_array().unwrap() { + let tools: Vec<(String, String)> = c["tools"] + .as_array() + .map(|a| { + a.iter() + .map(|p| (s(&p[0]).to_string(), s(&p[1]).to_string())) + .collect() + }) + .unwrap_or_default(); + assert_eq!(cf::prompt_line(&tools), s(&c["out"]), "{c}"); + } + for (cmd, v) in fx["timeouts"].as_object().unwrap() { + let cfg = cf::ServerConfig { + command: cmd.clone(), + ..Default::default() + }; + assert_eq!( + cf::timeout_for(&cfg).as_secs() as i64, + v["secs"].as_i64().unwrap(), + "{cmd}" + ); + assert_eq!(cf::timeout_error(&cfg), s(&v["err"]), "{cmd}"); + } + for c in fx["flatten"].as_array().unwrap() { + let v: Value = serde_json::from_str(s(&c["in"])).unwrap(); + assert_eq!(cf::flatten_content(&v), s(&c["out"]), "{}", c["in"]); + } +} + +#[test] +fn parite_de_la_configuration() { + let fx = fixture(); + for c in fx["decode"].as_array().unwrap() { + let m = cf::decode_servers(Some(s(&c["in"]).as_bytes())); + let out = if s(&c["in"]) == "null" && m.is_empty() { + "null".to_string() + } else { + loki_rs::gojson::marshal(&m) + }; + assert_eq!(out, s(&c["out"]), "{}", c["in"]); + for (n, want) in c["transports"].as_object().unwrap() { + let cfg = &m[n]; + let got = format!( + "{}|{}", + cfg.transport(), + cfg.validate().err().unwrap_or_default() + ); + assert_eq!(got, s(want), "{n}"); + } + } + let mut servers = cf::Servers::new(); + let mut saved = false; + for m in fx["mutations"].as_array().unwrap() { + let name = s(&m["name"]); + let res = match s(&m["op"]) { + "set" => { + let cfg: cf::ServerConfig = serde_json::from_str(s(&m["cfg"])).unwrap(); + cf::set_server(&mut servers, name, cfg) + } + "enable" => cf::set_enabled(&mut servers, name, m["on"].as_bool().unwrap_or(false)), + "tool" => cf::set_tool_enabled( + &mut servers, + name, + s(&m["tool"]), + m["on"].as_bool().unwrap_or(false), + ), + "delete" => cf::delete_server(&mut servers, name), + op => panic!("{op}"), + }; + saved |= res.is_ok(); + assert_eq!(res.err().unwrap_or_default(), s(&m["error"]), "{m}"); + let state = if saved { + loki_rs::gojson::marshal(&servers) + } else { + String::new() + }; + assert_eq!(state, s(&m["state"]), "{m}"); + } +} + +/// Un faux serveur MCP en Streamable HTTP : resert ce que le serveur du SDK +/// Go a répondu. `tools/call` en flux SSE, le reste en JSON. +async fn fake_server(wire: Value, seen: Arc>>) -> String { + let l = TcpListener::bind("127.0.0.1:0").await.unwrap(); + let addr = l.local_addr().unwrap(); + tokio::spawn(async move { + while let Ok((st, _)) = l.accept().await { + let (wire, seen) = (wire.clone(), seen.clone()); + tokio::spawn(async move { + let svc = service_fn(move |req: Request| { + let (wire, seen) = (wire.clone(), seen.clone()); + async move { + let sid = req + .headers() + .get("mcp-session-id") + .map(|v| v.to_str().unwrap().to_string()); + let auth = req + .headers() + .get("x-cle") + .map(|v| v.to_str().unwrap().to_string()); + let method = req.method().clone(); + let b = req.into_body().collect().await.unwrap().to_bytes(); + let mut r = Response::new(Full::new(Bytes::new())); + if method == hyper::Method::DELETE { + seen.lock().unwrap().push("DELETE".into()); + return Ok::<_, Infallible>(r); + } + let v: Value = serde_json::from_slice(&b).unwrap(); + let m = v["method"].as_str().unwrap_or("").to_string(); + seen.lock().unwrap().push(format!( + "{m} {} {}", + sid.as_deref().unwrap_or("-"), + auth.as_deref().unwrap_or("-") + )); + let result = match m.as_str() { + "initialize" => { + r.headers_mut() + .insert("Mcp-Session-Id", "sess-1".parse().unwrap()); + json!({ "protocolVersion": "2025-06-18", + "capabilities": { "tools": {} }, + "serverInfo": { "name": "faux", "version": "1" } }) + } + "tools/list" => wire["tools/list"].clone(), + "tools/call" => { + let name = v["params"]["name"].as_str().unwrap(); + let res = wire["tools/call"][name].clone(); + let body = format!( + "event: message\ndata: {}\n\n", + json!({ "jsonrpc": "2.0", "id": v["id"], "result": res }) + ); + r.headers_mut() + .insert("content-type", "text/event-stream".parse().unwrap()); + *r.body_mut() = Full::new(Bytes::from(body)); + return Ok(r); + } + _ => { + *r.status_mut() = hyper::StatusCode::ACCEPTED; + return Ok(r); + } + }; + r.headers_mut() + .insert("content-type", "application/json".parse().unwrap()); + *r.body_mut() = Full::new(Bytes::from( + json!({ "jsonrpc": "2.0", "id": v["id"], "result": result }) + .to_string(), + )); + Ok(r) + } + }); + let _ = hyper::server::conn::http1::Builder::new() + .serve_connection(TokioIo::new(st), svc) + .await; + }); + } + }); + format!("http://{addr}") +} + +async fn internal(facade: std::net::SocketAddr, path: &str, body: Value) -> Value { + let (code, b) = fakego::send( + facade, + "POST", + path, + &[(loki_rs::auth::INTERNAL_HEADER, fakego::JETON)], + &body.to_string(), + ) + .await; + assert_eq!(code, 200, "{path}"); + serde_json::from_slice(&b).unwrap() +} + +#[tokio::test] +async fn parite_contre_le_serveur_du_sdk() { + let fx = fixture(); + let seen = Arc::new(Mutex::new(Vec::new())); + let url = fake_server(fx["wire"].clone(), seen.clone()).await; + let tmp = tempfile::tempdir().unwrap(); + let pool = Arc::new(Mcp::new(tmp.path().to_path_buf())); + let (facade, go, _stop, _) = fakego::start_with( + tmp.path().to_path_buf(), + fakego::Opts { + mcp: Some(pool.clone()), + ..Default::default() + }, + ) + .await; + go.set("state", "mcp", &s(&fx["store"]).replace("{{URL}}", &url)); + let mask = |v: &Value| -> Value { + serde_json::from_str(&v.to_string().replace(&url, "{{URL}}")).unwrap() + }; + + let (_, b) = fakego::send(facade, "GET", "/api/mcp", &[], "").await; + let v: Value = serde_json::from_slice(&b).unwrap(); + assert_eq!(v["ok"], true); + assert_eq!(mask(&v["servers"]), fx["status"]); + + let tools = internal(facade, mcp::INTERNAL_TOOLS, json!({})).await; + assert_eq!(tools["tools"], fx["tools"]); + let pairs: Vec<(String, String)> = tools["tools"] + .as_array() + .unwrap() + .iter() + .map(|t| { + ( + s(&t["function"]["name"]).to_string(), + s(&t["function"]["description"]).to_string(), + ) + }) + .collect(); + assert_eq!(cf::prompt_line(&pairs), s(&fx["prompt_line_live"])); + + for (name, want) in fx["calls"].as_object().unwrap() { + let args = if name == "mcp__alpha__echo" { + json!({ "text": "salut " }) + } else { + json!({}) + }; + let r = internal( + facade, + mcp::INTERNAL_CALL, + json!({ "name": name, "args": args }), + ) + .await; + assert_eq!(r["result"], *want, "{name}"); + } + // Une seule session, partagée par l'état, les outils et les appels. + let seen = seen.lock().unwrap().clone(); + assert_eq!( + seen.iter().filter(|l| l.starts_with("initialize")).count(), + 1, + "{seen:?}" + ); + assert!(seen.iter().any(|l| l == "tools/call sess-1 -"), "{seen:?}"); +} + +/// Un faux serveur MCP en stdio : sh, un message par ligne. +fn stdio_server(dir: &std::path::Path) -> String { + let p = dir.join("faux-mcp"); + let body = r#"#!/bin/sh +while IFS= read -r line; do + id=$(printf '%s' "$line" | sed -n 's/.*"id":\([0-9]*\).*/\1/p') + case "$line" in + *'"method":"initialize"'*) + printf '{"jsonrpc":"2.0","method":"notifications/message","params":{}}\n' + printf '{"jsonrpc":"2.0","id":%s,"result":{"protocolVersion":"2025-06-18","capabilities":{},"serverInfo":{"name":"sh","version":"1"}}}\n' "$id" ;; + *'"method":"tools/list"'*) + printf '{"jsonrpc":"2.0","id":99,"method":"ping"}\n' + printf '{"jsonrpc":"2.0","id":%s,"result":{"tools":[{"name":"date","description":"Heure","inputSchema":{"type":"object"}},{"name":"crash","inputSchema":{"type":"object"}}]}}\n' "$id" ;; + *'"name":"crash"'*) exit 3 ;; + *'"method":"tools/call"'*) + printf '{"jsonrpc":"2.0","id":%s,"result":{"content":[{"type":"text","text":"%s"}]}}\n' "$id" "$FAUX_ENV" ;; + *'"result":{}'*) echo "pong reçu" >> "$TRACE" ;; + esac +done +"#; + let ok = std::process::Command::new("sh") + .args(["-c", "printf %s \"$1\" > \"$2\" && chmod 755 \"$2\""]) + .arg("sh") + .arg(body) + .arg(&p) + .status() + .unwrap() + .success(); + assert!(ok); + p.to_string_lossy().into_owned() +} + +#[tokio::test] +async fn transport_stdio_et_reconnexion() { + let tmp = tempfile::tempdir().unwrap(); + let bin = stdio_server(tmp.path()); + let trace = tmp.path().join("trace"); + let pool = Arc::new(Mcp::new(tmp.path().to_path_buf())); + let (facade, go, _stop, _) = fakego::start_with( + tmp.path().to_path_buf(), + fakego::Opts { + mcp: Some(pool.clone()), + ..Default::default() + }, + ) + .await; + let mut env = BTreeMap::new(); + env.insert("FAUX_ENV".to_string(), "valeur d'env".to_string()); + env.insert("TRACE".to_string(), trace.to_string_lossy().into_owned()); + let mut servers = cf::Servers::new(); + servers.insert( + "sh".into(), + cf::ServerConfig { + command: bin, + env, + ..Default::default() + }, + ); + go.set("state", "mcp", &loki_rs::gojson::marshal(&servers)); + let (_, b) = fakego::send(facade, "GET", "/api/mcp", &[], "").await; + let v: Value = serde_json::from_slice(&b).unwrap(); + assert_eq!(v["servers"][0]["connected"], true, "{v}"); + assert_eq!(v["servers"][0]["tools"], json!(["date", "crash"])); + let r = internal( + facade, + mcp::INTERNAL_CALL, + json!({ "name": "mcp__sh__date", "args": {} }), + ) + .await; + assert_eq!(r["result"], "valeur d'env"); + // Le serveur meurt en plein appel : une reconnexion, puis l'erreur. + let r = internal( + facade, + mcp::INTERNAL_CALL, + json!({ "name": "mcp__sh__crash", "args": {} }), + ) + .await; + let out = s(&r["result"]); + assert!( + out.starts_with("[erreur MCP] calling \"tools/call\""), + "{out}" + ); + // Après la reconnexion, le serveur répond de nouveau. + let r = internal( + facade, + mcp::INTERNAL_CALL, + json!({ "name": "mcp__sh__date", "args": {} }), + ) + .await; + assert_eq!(r["result"], "valeur d'env"); + // Le ping du serveur a reçu sa réponse. + let t = std::fs::read_to_string(&trace).unwrap_or_default(); + assert!(t.contains("pong reçu"), "{t:?}"); + pool.close_all().await; +} + +#[tokio::test] +async fn routes_de_configuration() { + let tmp = tempfile::tempdir().unwrap(); + let seen = Arc::new(Mutex::new(Vec::new())); + let fx = fixture(); + let url = fake_server(fx["wire"].clone(), seen.clone()).await; + let pool = Arc::new(Mcp::new(tmp.path().to_path_buf())); + let (facade, go, _stop, _) = fakego::start_with( + tmp.path().to_path_buf(), + fakego::Opts { + mcp: Some(pool.clone()), + ..Default::default() + }, + ) + .await; + let post = |path: &'static str, body: String| async move { + let (c, b) = fakego::send(facade, "POST", path, &[], &body).await; + (c.as_u16(), serde_json::from_slice::(&b).unwrap()) + }; + // Ajout : actif par défaut, en-têtes envoyés, état rendu. + let (c, v) = post( + "/api/mcp/save", + json!({ "name": " web ", "url": format!(" {url} "), "headers": { "X-Cle": "k" } }) + .to_string(), + ) + .await; + assert_eq!(c, 200, "{v}"); + assert_eq!(v["servers"][0]["name"], "web"); + assert_eq!(v["servers"][0]["connected"], true); + assert!( + seen.lock().unwrap().iter().any(|l| l == "initialize - k"), + "{:?}", + seen.lock().unwrap() + ); + let stored: Value = serde_json::from_str(&go.get("state", "mcp").unwrap()).unwrap(); + assert_eq!(stored["web"]["url"], json!(url)); + // Erreurs de la configuration et du JSON. + let (c, v) = post("/api/mcp/save", r#"{"name":"a__b","command":"x"}"#.into()).await; + assert_eq!( + (c, v["error"].clone()), + ( + 400, + json!("le nom ne peut pas contenir '__' (réservé au namespacing des outils)") + ) + ); + let (c, v) = post( + "/api/mcp/save", + r#"{"name":"x","args":"pas une liste"}"#.into(), + ) + .await; + assert_eq!( + (c, v["error"].clone()), + ( + 400, + json!( + "json: cannot unmarshal string into Go struct field mcpSaveReq.args of type []string" + ) + ) + ); + // Un outil masqué : connu de l'état, absent des outils annoncés. + let (c, v) = post( + "/api/mcp/tool", + r#"{"name":"web","tool":"secret","on":false}"#.into(), + ) + .await; + assert_eq!( + (c, v["servers"][0]["disabled"].clone()), + (200, json!(["secret"])) + ); + let tools = internal(facade, mcp::INTERNAL_TOOLS, json!({})).await; + assert!( + !tools["tools"] + .as_array() + .unwrap() + .iter() + .any(|t| t["function"]["name"] == "mcp__web__secret") + ); + // Éteint : plus de connexion, plus d'outils ; la session est fermée. + let (c, v) = post("/api/mcp/toggle", r#"{"name":"web","on":false}"#.into()).await; + assert_eq!( + (c, v["servers"][0]["connected"].clone()), + (200, json!(false)) + ); + assert!(seen.lock().unwrap().iter().any(|l| l == "DELETE")); + let tools = internal(facade, mcp::INTERNAL_TOOLS, json!({})).await; + assert_eq!(tools["tools"], json!([])); + let (c, v) = post("/api/mcp/toggle", r#"{"name":"nope","on":true}"#.into()).await; + assert_eq!( + (c, v["error"].clone()), + (400, json!("serveur MCP inconnu: nope")) + ); + // Tester : reconnexion. + post("/api/mcp/toggle", r#"{"name":"web","on":true}"#.into()).await; + let before = seen + .lock() + .unwrap() + .iter() + .filter(|l| l.starts_with("initialize")) + .count(); + let (c, _) = post("/api/mcp/test", r#"{"name":"web"}"#.into()).await; + assert_eq!(c, 200); + let after = seen + .lock() + .unwrap() + .iter() + .filter(|l| l.starts_with("initialize")) + .count(); + assert_eq!(after, before + 1); + // Suppression. + let (c, v) = post("/api/mcp/delete", r#"{"name":"web"}"#.into()).await; + assert_eq!((c, v), (200, json!({"ok": true}))); + assert_eq!(go.get("state", "mcp").unwrap(), "{}"); + let (c, v) = post("/api/mcp/delete", r#"{"name":"web"}"#.into()).await; + assert_eq!( + (c, v["error"].clone()), + (400, json!("serveur MCP inconnu: web")) + ); + pool.close_all().await; +} + +#[tokio::test] +async fn catalogue_embarque() { + let tmp = tempfile::tempdir().unwrap(); + let pool = Arc::new(Mcp::new(tmp.path().to_path_buf())); + let (facade, go, _stop, _) = fakego::start_with( + tmp.path().to_path_buf(), + fakego::Opts { + mcp: Some(pool), + ..Default::default() + }, + ) + .await; + go.set("state", "mcp", r#"{"filesystem":{"command":"npx"}}"#); + let (_, b) = fakego::send(facade, "GET", "/api/mcp/catalog", &[], "").await; + let v: Value = serde_json::from_slice(&b).unwrap(); + let cat = v["catalog"].as_array().unwrap(); + assert!(cat.len() > 10); + let fs = cat.iter().find(|e| e["id"] == "filesystem").unwrap(); + assert_eq!(fs["installed"], true); + let ws = format!("{}/workspace", tmp.path().display()); + assert!(fs["args"].as_array().unwrap().contains(&json!(ws)), "{fs}"); + assert!( + fs.get("env").is_none() && fs.get("secrets").is_none(), + "{fs}" + ); + assert!(cat.iter().all(|e| e.get("runtimeOk").is_some())); + assert!(cat.iter().filter(|e| e["installed"] == true).count() == 1); +} + +#[tokio::test] +async fn sans_la_variable_mcp_reste_au_go() { + let tmp = tempfile::tempdir().unwrap(); + let (facade, _go, _stop) = fakego::start(tmp.path().to_path_buf()).await; + for p in [ + "/api/mcp", + "/api/mcp/save", + "/api/mcp/catalog", + "/api/mcp/test", + ] { + let (_, _, b) = fakego::get(facade, p, &[]).await; + assert_eq!(&b[..], format!("go:{p}").as_bytes()); + } + // L'API interne MCP de la façade n'existe pas sans le lot. + let (c, _) = fakego::send( + facade, + "POST", + mcp::INTERNAL_TOOLS, + &[(loki_rs::auth::INTERNAL_HEADER, fakego::JETON)], + "{}", + ) + .await; + assert_eq!(c, 404); +} + +/// Processus vivants dont la ligne de commande contient `needle`. +fn alive(needle: &str) -> usize { + std::fs::read_dir("/proc") + .unwrap() + .flatten() + .filter(|e| { + e.file_name() + .to_string_lossy() + .bytes() + .all(|b| b.is_ascii_digit()) + }) + .filter(|e| { + std::fs::read(e.path().join("cmdline")) + .map(|b| String::from_utf8_lossy(&b).contains(needle)) + .unwrap_or(false) + }) + .filter(|e| { + // Un zombie n'est plus un serveur vivant. + std::fs::read_to_string(e.path().join("stat")) + .map(|s| !s.contains(") Z ")) + .unwrap_or(false) + }) + .count() +} + +#[tokio::test] +async fn session_invalidee_pendant_la_connexion() { + let tmp = tempfile::tempdir().unwrap(); + let p = tmp.path().join("lent-mcp"); + let body = r#"#!/bin/sh +while IFS= read -r line; do + id=$(printf '%s' "$line" | sed -n 's/.*"id":\([0-9]*\).*/\1/p') + case "$line" in + *'"method":"initialize"'*) + sleep 1 + printf '{"jsonrpc":"2.0","id":%s,"result":{"protocolVersion":"2025-06-18","capabilities":{},"serverInfo":{"name":"lent","version":"1"}}}\n' "$id" ;; + *'"method":"tools/list"'*) + printf '{"jsonrpc":"2.0","id":%s,"result":{"tools":[{"name":"t","inputSchema":{"type":"object"}}]}}\n' "$id" ;; + esac +done +"#; + assert!( + std::process::Command::new("sh") + .args(["-c", "printf %s \"$1\" > \"$2\" && chmod 755 \"$2\""]) + .arg("sh") + .arg(body) + .arg(&p) + .status() + .unwrap() + .success() + ); + let bin = p.to_string_lossy().into_owned(); + let pool = Arc::new(Mcp::new(tmp.path().to_path_buf())); + let (facade, go, _stop, _) = fakego::start_with( + tmp.path().to_path_buf(), + fakego::Opts { + mcp: Some(pool.clone()), + ..Default::default() + }, + ) + .await; + let mut servers = cf::Servers::new(); + servers.insert( + "lent".into(), + cf::ServerConfig { + command: bin.clone(), + ..Default::default() + }, + ); + go.set("state", "mcp", &loki_rs::gojson::marshal(&servers)); + // Une première connexion part, puis le serveur est invalidé avant qu'elle + // aboutisse (« tester ») : elle ne doit jamais être republiée. + let first = tokio::spawn(async move { fakego::send(facade, "GET", "/api/mcp", &[], "").await }); + tokio::time::sleep(std::time::Duration::from_millis(300)).await; + let (c, b) = fakego::send(facade, "POST", "/api/mcp/test", &[], r#"{"name":"lent"}"#).await; + assert_eq!(c, 200); + let v: Value = serde_json::from_slice(&b).unwrap(); + assert_eq!(v["servers"][0]["connected"], true); + let _ = first.await; + tokio::time::sleep(std::time::Duration::from_millis(200)).await; + assert_eq!( + alive(&bin), + 1, + "un seul serveur vivant après l'invalidation" + ); + pool.close_all().await; + tokio::time::sleep(std::time::Duration::from_millis(200)).await; + assert_eq!(alive(&bin), 0); +} diff --git a/rust/loki-rs/tests/parity_mcp/mcp.json b/rust/loki-rs/tests/parity_mcp/mcp.json new file mode 100644 index 0000000..b744a9b --- /dev/null +++ b/rust/loki-rs/tests/parity_mcp/mcp.json @@ -0,0 +1,760 @@ +{ + "calls": { + "mcp__alpha__big": "éééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééé\n…[tronqué : 40000 caractères au total, 32768 gardés]", + "mcp__alpha__dot_tool": "point", + "mcp__alpha__echo": "salut ", + "mcp__alpha__empty": "[ok] (aucune sortie)", + "mcp__alpha__fail": "[l'outil a renvoyé une erreur]\nboum", + "mcp__alpha__link": "{\"type\":\"resource_link\",\"mimeType\":\"text/plain\",\"uri\":\"file:///a.txt\",\"name\":\"a.txt\",\"title\":\"A\",\"size\":12}", + "mcp__alpha__media": "[image image/png]\n[audio audio/wav]", + "mcp__alpha__multi": "un\ndeux", + "mcp__alpha__nope": "[erreur] outil MCP inconnu ou serveur non connecté: mcp__alpha__nope", + "mcp__alpha__secret": "chut", + "mcp__alpha__struct": "{\n \"a\": \"\\u003cx\\u003e \\u0026 y\",\n \"b\": [\n 1,\n 2\n ]\n}", + "mcp__alpha__titled": "ok", + "mcp__zeta__x": "[erreur] outil MCP inconnu ou serveur non connecté: mcp__zeta__x" + }, + "decode": [ + { + "in": "{\"a\":{\"command\":\"npx\",\"args\":[\"-y\",\"x\"],\"env\":{\"K\":\"v\"}}}", + "out": "{\"a\":{\"command\":\"npx\",\"args\":[\"-y\",\"x\"],\"env\":{\"K\":\"v\"},\"enabled\":true}}", + "transports": { + "a": "stdio|" + } + }, + { + "in": "{\"b\":{\"url\":\"http://h/mcp\",\"headers\":{\"Authorization\":\"Bearer t\"},\"enabled\":false,\"disabledTools\":[\"t1\"]}}", + "out": "{\"b\":{\"url\":\"http://h/mcp\",\"headers\":{\"Authorization\":\"Bearer t\"},\"enabled\":false,\"disabledTools\":[\"t1\"]}}", + "transports": { + "b": "http|" + } + }, + { + "in": "{\"c\":{\"command\":\"x\",\"enabled\":null}}", + "out": "{\"c\":{\"command\":\"x\",\"enabled\":false}}", + "transports": { + "c": "stdio|" + } + }, + { + "in": "null", + "out": "null", + "transports": {} + }, + { + "in": "{}", + "out": "{}", + "transports": {} + } + ], + "flatten": [ + { + "in": "{\"content\":[{\"type\":\"text\",\"text\":\" un \"},{\"type\":\"text\",\"text\":\"deux\\n\"}]}", + "out": "un \ndeux" + }, + { + "in": "{\"content\":[{\"type\":\"image\",\"data\":\"cG5n\",\"mimeType\":\"image/png\"},{\"type\":\"audio\",\"data\":\"d2F2\",\"mimeType\":\"audio/wav\"}]}", + "out": "[image image/png]\n[audio audio/wav]" + }, + { + "in": "{\"content\":[{\"type\":\"resource_link\",\"uri\":\"file:///a.txt\",\"name\":\"a.txt\",\"mimeType\":\"text/plain\",\"size\":12,\"title\":\"A \"}]}", + "out": "{\"type\":\"resource_link\",\"mimeType\":\"text/plain\",\"uri\":\"file:///a.txt\",\"name\":\"a.txt\",\"title\":\"A \\u003cb\\u003e\",\"size\":12}" + }, + { + "in": "{\"content\":[{\"type\":\"resource\",\"resource\":{\"uri\":\"file:///b\",\"mimeType\":\"text/plain\",\"text\":\"corps\"}}]}", + "out": "{\"type\":\"resource\",\"resource\":{\"uri\":\"file:///b\",\"mimeType\":\"text/plain\",\"text\":\"corps\"}}" + }, + { + "in": "{\"content\":[],\"structuredContent\":{\"b\":[1,2],\"a\":\" & y\"}}", + "out": "{\n \"a\": \"\\u003cx\\u003e \\u0026 y\",\n \"b\": [\n 1,\n 2\n ]\n}" + }, + { + "in": "{\"content\":[{\"type\":\"text\",\"text\":\" \"}],\"structuredContent\":{\"k\":1}}", + "out": "{\n \"k\": 1\n}" + }, + { + "in": "{\"content\":[{\"type\":\"text\",\"text\":\"t\"}],\"structuredContent\":{\"k\":1}}", + "out": "t" + }, + { + "in": "{\"content\":[]}", + "out": "" + }, + { + "in": "{\"content\":[{\"type\":\"text\",\"text\":\"boum\"}],\"isError\":true}", + "out": "boum" + } + ], + "friendly_other": "autre", + "labels": [ + { + "in": "{}", + "out": "" + }, + { + "in": "{\"path\":\"/a\",\"query\":\"q\"}", + "out": "/a" + }, + { + "in": "{\"query\":\"\",\"url\":\"http://x\"}", + "out": "http://x" + }, + { + "in": "{\"name\":\"n\",\"command\":\"c\"}", + "out": "c" + }, + { + "in": "{\"x\":1,\"y\":\"\"}", + "out": "{\"x\":1,\"y\":\"\\u003cb\\u003e\"}" + }, + { + "in": "{\"long\":\"éééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééé\"}", + "out": "{\"long\":\"ééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééé…" + }, + { + "in": "{\"path\":3,\"file\":\"f\"}", + "out": "f" + }, + { + "in": "{\"file\":\"f\",\"path\":\"p\"}", + "out": "p" + }, + { + "in": "{\"moyen\":\"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa\"}", + "out": "{\"moyen\":\"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa…" + } + ], + "mutations": [ + { + "op": "set", + "name": " fs ", + "cfg": "{\"command\":\"npx\",\"args\":[\"-y\",\"@x/fs\",\"\"],\"env\":{\"A\":\"&\"}}", + "error": "", + "state": "{\"fs\":{\"command\":\"npx\",\"args\":[\"-y\",\"@x/fs\",\"\\u003cw\\u003e\"],\"env\":{\"A\":\"\\u0026\"},\"enabled\":true}}" + }, + { + "op": "set", + "name": "", + "cfg": "{\"command\":\"x\"}", + "error": "nom de serveur vide", + "state": "{\"fs\":{\"command\":\"npx\",\"args\":[\"-y\",\"@x/fs\",\"\\u003cw\\u003e\"],\"env\":{\"A\":\"\\u0026\"},\"enabled\":true}}" + }, + { + "op": "set", + "name": "a__b", + "cfg": "{\"command\":\"x\"}", + "error": "le nom ne peut pas contenir '__' (réservé au namespacing des outils)", + "state": "{\"fs\":{\"command\":\"npx\",\"args\":[\"-y\",\"@x/fs\",\"\\u003cw\\u003e\"],\"env\":{\"A\":\"\\u0026\"},\"enabled\":true}}" + }, + { + "op": "set", + "name": "both", + "cfg": "{\"command\":\"x\",\"url\":\"http://u\"}", + "error": "un serveur MCP ne peut avoir à la fois 'command' (stdio) et 'url' (http)", + "state": "{\"fs\":{\"command\":\"npx\",\"args\":[\"-y\",\"@x/fs\",\"\\u003cw\\u003e\"],\"env\":{\"A\":\"\\u0026\"},\"enabled\":true}}" + }, + { + "op": "set", + "name": "none", + "cfg": "{\"enabled\":true}", + "error": "un serveur MCP doit avoir soit 'command' (stdio) soit 'url' (http)", + "state": "{\"fs\":{\"command\":\"npx\",\"args\":[\"-y\",\"@x/fs\",\"\\u003cw\\u003e\"],\"env\":{\"A\":\"\\u0026\"},\"enabled\":true}}" + }, + { + "op": "set", + "name": "web", + "cfg": "{\"url\":\"http://u/mcp\",\"headers\":{\"X\":\"1\"},\"enabled\":false}", + "error": "", + "state": "{\"fs\":{\"command\":\"npx\",\"args\":[\"-y\",\"@x/fs\",\"\\u003cw\\u003e\"],\"env\":{\"A\":\"\\u0026\"},\"enabled\":true},\"web\":{\"url\":\"http://u/mcp\",\"headers\":{\"X\":\"1\"},\"enabled\":false}}" + }, + { + "op": "enable", + "name": "web", + "on": true, + "error": "", + "state": "{\"fs\":{\"command\":\"npx\",\"args\":[\"-y\",\"@x/fs\",\"\\u003cw\\u003e\"],\"env\":{\"A\":\"\\u0026\"},\"enabled\":true},\"web\":{\"url\":\"http://u/mcp\",\"headers\":{\"X\":\"1\"},\"enabled\":true}}" + }, + { + "op": "enable", + "name": "nope", + "on": true, + "error": "serveur MCP inconnu: nope", + "state": "{\"fs\":{\"command\":\"npx\",\"args\":[\"-y\",\"@x/fs\",\"\\u003cw\\u003e\"],\"env\":{\"A\":\"\\u0026\"},\"enabled\":true},\"web\":{\"url\":\"http://u/mcp\",\"headers\":{\"X\":\"1\"},\"enabled\":true}}" + }, + { + "op": "tool", + "name": "fs", + "tool": "write", + "error": "", + "state": "{\"fs\":{\"command\":\"npx\",\"args\":[\"-y\",\"@x/fs\",\"\\u003cw\\u003e\"],\"env\":{\"A\":\"\\u0026\"},\"enabled\":true,\"disabledTools\":[\"write\"]},\"web\":{\"url\":\"http://u/mcp\",\"headers\":{\"X\":\"1\"},\"enabled\":true}}" + }, + { + "op": "tool", + "name": "fs", + "tool": "read", + "error": "", + "state": "{\"fs\":{\"command\":\"npx\",\"args\":[\"-y\",\"@x/fs\",\"\\u003cw\\u003e\"],\"env\":{\"A\":\"\\u0026\"},\"enabled\":true,\"disabledTools\":[\"write\",\"read\"]},\"web\":{\"url\":\"http://u/mcp\",\"headers\":{\"X\":\"1\"},\"enabled\":true}}" + }, + { + "op": "tool", + "name": "fs", + "tool": "write", + "on": true, + "error": "", + "state": "{\"fs\":{\"command\":\"npx\",\"args\":[\"-y\",\"@x/fs\",\"\\u003cw\\u003e\"],\"env\":{\"A\":\"\\u0026\"},\"enabled\":true,\"disabledTools\":[\"read\"]},\"web\":{\"url\":\"http://u/mcp\",\"headers\":{\"X\":\"1\"},\"enabled\":true}}" + }, + { + "op": "tool", + "name": "fs", + "tool": "read", + "error": "", + "state": "{\"fs\":{\"command\":\"npx\",\"args\":[\"-y\",\"@x/fs\",\"\\u003cw\\u003e\"],\"env\":{\"A\":\"\\u0026\"},\"enabled\":true,\"disabledTools\":[\"read\"]},\"web\":{\"url\":\"http://u/mcp\",\"headers\":{\"X\":\"1\"},\"enabled\":true}}" + }, + { + "op": "tool", + "name": "nope", + "tool": "x", + "error": "serveur MCP inconnu: nope", + "state": "{\"fs\":{\"command\":\"npx\",\"args\":[\"-y\",\"@x/fs\",\"\\u003cw\\u003e\"],\"env\":{\"A\":\"\\u0026\"},\"enabled\":true,\"disabledTools\":[\"read\"]},\"web\":{\"url\":\"http://u/mcp\",\"headers\":{\"X\":\"1\"},\"enabled\":true}}" + }, + { + "op": "delete", + "name": "web", + "error": "", + "state": "{\"fs\":{\"command\":\"npx\",\"args\":[\"-y\",\"@x/fs\",\"\\u003cw\\u003e\"],\"env\":{\"A\":\"\\u0026\"},\"enabled\":true,\"disabledTools\":[\"read\"]}}" + }, + { + "op": "delete", + "name": "web", + "error": "serveur MCP inconnu: web", + "state": "{\"fs\":{\"command\":\"npx\",\"args\":[\"-y\",\"@x/fs\",\"\\u003cw\\u003e\"],\"env\":{\"A\":\"\\u0026\"},\"enabled\":true,\"disabledTools\":[\"read\"]}}" + }, + { + "op": "delete", + "name": " fs", + "error": "serveur MCP inconnu: fs", + "state": "{\"fs\":{\"command\":\"npx\",\"args\":[\"-y\",\"@x/fs\",\"\\u003cw\\u003e\"],\"env\":{\"A\":\"\\u0026\"},\"enabled\":true,\"disabledTools\":[\"read\"]}}" + } + ], + "prompt_line": [ + { + "out": "", + "tools": [] + }, + { + "out": "", + "tools": [ + [ + "bash", + "shell" + ] + ] + }, + { + "out": "\n\nMCP servers connected (their tools are prefixed mcp____): a (2), b (1).", + "tools": [ + [ + "mcp__b__x", + "[MCP: b] d" + ], + [ + "mcp__a__y", + "[MCP: a] d" + ], + [ + "mcp__a__z", + "[MCP: a] e" + ] + ] + }, + { + "out": "\n\nMCP servers connected (their tools are prefixed mcp____): a]b (1).", + "tools": [ + [ + "mcp__a_b__x", + "[MCP: a]b] d" + ] + ] + } + ], + "prompt_line_live": "\n\nMCP servers connected (their tools are prefixed mcp____): alpha (10).", + "sanitize": { + "": "", + "...": "", + "A-B_c9": "A-B_c9", + "__x__": "x", + "a.b/c": "a_b_c", + "alpha": "alpha", + "mon serveur!": "mon_serveur", + "é-t_é": "-t" + }, + "schemas": [ + { + "in": "null", + "out": { + "properties": {}, + "type": "object" + } + }, + { + "in": "{\"type\":\"object\",\"properties\":{\"a\":{\"type\":\"string\"}}}", + "out": { + "properties": { + "a": { + "type": "string" + } + }, + "type": "object" + } + }, + { + "in": "true", + "out": { + "properties": {}, + "type": "object" + } + }, + { + "in": "[1]", + "out": { + "properties": {}, + "type": "object" + } + }, + { + "in": "{}", + "out": {} + }, + { + "in": "\"x\"", + "out": { + "properties": {}, + "type": "object" + } + } + ], + "status": [ + { + "name": "alpha", + "transport": "http", + "enabled": true, + "connected": true, + "tools": [ + "big", + "dot.tool", + "echo", + "empty", + "fail", + "link", + "media", + "multi", + "secret", + "struct", + "titled" + ], + "disabled": [ + "secret" + ], + "url": "{{URL}}" + }, + { + "name": "beta", + "transport": "stdio", + "enabled": true, + "connected": false, + "error": "exec: \"loki-mcp-absent-pour-test\": executable file not found in $PATH", + "tools": [], + "disabled": [], + "command": "loki-mcp-absent-pour-test" + }, + { + "name": "delta", + "transport": "", + "enabled": true, + "connected": false, + "tools": [], + "disabled": [] + }, + { + "name": "gamma", + "transport": "http", + "enabled": false, + "connected": false, + "tools": [], + "disabled": [], + "url": "{{URL}}" + } + ], + "store": "{\"alpha\":{\"url\":\"{{URL}}\",\"enabled\":true,\"disabledTools\":[\"secret\"]},\"beta\":{\"command\":\"loki-mcp-absent-pour-test\",\"enabled\":true},\"gamma\":{\"url\":\"{{URL}}\",\"enabled\":false},\"delta\":{\"enabled\":true}}", + "timeouts": { + "": { + "err": "délai dépassé — le serveur n'a pas répondu au handshake en 20s", + "secs": 20 + }, + "/usr/local/bin/NPX": { + "err": "délai dépassé — npx télécharge le paquet du serveur au premier lancement, ce qui peut être long ; clique « tester » pour réessayer (le paquet reste en cache), et vérifie que le conteneur atteint le registre (npm/PyPI)", + "secs": 180 + }, + "C:/bin/uvx.EXE": { + "err": "délai dépassé — uvx télécharge le paquet du serveur au premier lancement, ce qui peut être long ; clique « tester » pour réessayer (le paquet reste en cache), et vérifie que le conteneur atteint le registre (npm/PyPI)", + "secs": 180 + }, + "node": { + "err": "délai dépassé — le serveur n'a pas répondu au handshake en 20s", + "secs": 20 + }, + "npx": { + "err": "délai dépassé — npx télécharge le paquet du serveur au premier lancement, ce qui peut être long ; clique « tester » pour réessayer (le paquet reste en cache), et vérifie que le conteneur atteint le registre (npm/PyPI)", + "secs": 180 + }, + "npx.cmd": { + "err": "délai dépassé — npx télécharge le paquet du serveur au premier lancement, ce qui peut être long ; clique « tester » pour réessayer (le paquet reste en cache), et vérifie que le conteneur atteint le registre (npm/PyPI)", + "secs": 180 + }, + "uvx-x": { + "err": "délai dépassé — le serveur n'a pas répondu au handshake en 20s", + "secs": 20 + }, + "uvx.exe": { + "err": "délai dépassé — uvx télécharge le paquet du serveur au premier lancement, ce qui peut être long ; clique « tester » pour réessayer (le paquet reste en cache), et vérifie que le conteneur atteint le registre (npm/PyPI)", + "secs": 180 + } + }, + "tool_server": [ + { + "desc": "[MCP: alpha] desc", + "name": "mcp__alpha__x", + "ok": true, + "server": "alpha" + }, + { + "desc": "[MCP: a]b] desc", + "name": "mcp__a_b__x", + "ok": true, + "server": "a]b" + }, + { + "desc": "[MCP: a] b] c] d", + "name": "mcp__a_b_c__x", + "ok": true, + "server": "a] b] c" + }, + { + "desc": "pas de préfixe", + "name": "mcp__x__y", + "ok": false, + "server": "" + }, + { + "desc": "[MCP: autre] d", + "name": "autre", + "ok": false, + "server": "" + }, + { + "desc": "[MCP: a] d", + "name": "mcp__b__x", + "ok": false, + "server": "" + } + ], + "tools": [ + { + "type": "function", + "function": { + "name": "mcp__alpha__big", + "description": "[MCP: alpha] Long", + "parameters": { + "type": "object" + } + } + }, + { + "type": "function", + "function": { + "name": "mcp__alpha__dot_tool", + "description": "[MCP: alpha] Nom pointé", + "parameters": { + "properties": {}, + "type": "object" + } + } + }, + { + "type": "function", + "function": { + "name": "mcp__alpha__echo", + "description": "[MCP: alpha] Répète le texte ", + "parameters": { + "properties": { + "text": { + "type": "string" + } + }, + "type": "object" + } + } + }, + { + "type": "function", + "function": { + "name": "mcp__alpha__empty", + "description": "[MCP: alpha] Rien", + "parameters": { + "type": "object" + } + } + }, + { + "type": "function", + "function": { + "name": "mcp__alpha__fail", + "description": "[MCP: alpha] Échoue", + "parameters": { + "type": "object" + } + } + }, + { + "type": "function", + "function": { + "name": "mcp__alpha__link", + "description": "[MCP: alpha] Un lien", + "parameters": { + "type": "object" + } + } + }, + { + "type": "function", + "function": { + "name": "mcp__alpha__media", + "description": "[MCP: alpha] Image et son", + "parameters": { + "type": "object" + } + } + }, + { + "type": "function", + "function": { + "name": "mcp__alpha__multi", + "description": "[MCP: alpha] Deux blocs", + "parameters": { + "type": "object" + } + } + }, + { + "type": "function", + "function": { + "name": "mcp__alpha__struct", + "description": "[MCP: alpha] Structuré", + "parameters": { + "type": "object" + } + } + }, + { + "type": "function", + "function": { + "name": "mcp__alpha__titled", + "description": "[MCP: alpha] Titre seul", + "parameters": { + "type": "object" + } + } + } + ], + "wire": { + "tools/call": { + "big": { + "content": [ + { + "text": "éééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééé", + "type": "text" + } + ] + }, + "dot.tool": { + "content": [ + { + "text": "point", + "type": "text" + } + ] + }, + "echo": { + "content": [ + { + "text": "salut ", + "type": "text" + } + ] + }, + "empty": { + "content": [] + }, + "fail": { + "content": [ + { + "text": "boum", + "type": "text" + } + ], + "isError": true + }, + "link": { + "content": [ + { + "mimeType": "text/plain", + "name": "a.txt", + "size": 12, + "title": "A", + "type": "resource_link", + "uri": "file:///a.txt" + } + ] + }, + "media": { + "content": [ + { + "data": "cG5n", + "mimeType": "image/png", + "type": "image" + }, + { + "data": "d2F2", + "mimeType": "audio/wav", + "type": "audio" + } + ] + }, + "multi": { + "content": [ + { + "text": "un", + "type": "text" + }, + { + "text": "deux", + "type": "text" + } + ] + }, + "secret": { + "content": [ + { + "text": "chut", + "type": "text" + } + ] + }, + "struct": { + "content": [], + "structuredContent": { + "a": " & y", + "b": [ + 1, + 2 + ] + } + }, + "titled": { + "content": [ + { + "text": "ok", + "type": "text" + } + ] + } + }, + "tools/list": { + "tools": [ + { + "description": "Long", + "inputSchema": { + "type": "object" + }, + "name": "big" + }, + { + "description": "Nom pointé", + "inputSchema": { + "properties": {}, + "type": "object" + }, + "name": "dot.tool" + }, + { + "description": "Répète le texte ", + "inputSchema": { + "properties": { + "text": { + "type": "string" + } + }, + "type": "object" + }, + "name": "echo" + }, + { + "description": "Rien", + "inputSchema": { + "type": "object" + }, + "name": "empty" + }, + { + "description": "Échoue", + "inputSchema": { + "type": "object" + }, + "name": "fail" + }, + { + "description": "Un lien", + "inputSchema": { + "type": "object" + }, + "name": "link" + }, + { + "description": "Image et son", + "inputSchema": { + "type": "object" + }, + "name": "media" + }, + { + "description": "Deux blocs", + "inputSchema": { + "type": "object" + }, + "name": "multi" + }, + { + "description": "Masqué", + "inputSchema": { + "type": "object" + }, + "name": "secret" + }, + { + "description": "Structuré", + "inputSchema": { + "type": "object" + }, + "name": "struct" + }, + { + "inputSchema": { + "type": "object" + }, + "name": "titled", + "title": "Titre seul" + } + ] + } + } +} diff --git a/rust/loki-rs/tests/proxy.rs b/rust/loki-rs/tests/proxy.rs index 175e7a2..09bbf47 100644 --- a/rust/loki-rs/tests/proxy.rs +++ b/rust/loki-rs/tests/proxy.rs @@ -110,6 +110,7 @@ async fn facade(backend: SocketAddr) -> (SocketAddr, oneshot::Sender<()>) { engine_ctl: None, system: None, projects: false, + mcp: None, }; tokio::spawn(loki_rs::proxy::serve(l, cfg, async move { let _ = rx.await; From 25e31c1c26e51ea20a4d4545672c406a0f2e94dc Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 15 Oct 2026 20:01:37 +0000 Subject: [PATCH 19/52] =?UTF-8?q?Lot=2021=20:=20mise=20=C3=A0=20jour=20de?= =?UTF-8?q?=20Loki=20tenue=20par=20la=20fa=C3=A7ade=20(LOKI=5FUPDATE=3Drus?= =?UTF-8?q?t)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit En conteneur, l'image se met à jour par Docker : /api/update rend la version du Go sans mise à jour disponible et la note Docker, /api/update/apply la refuse (409). La façade sert ces deux routes quand LOKI_UPDATE=rust et LOKI_CONTAINER=1 (sysupdate.rs) ; hors conteneur, loki-rs ne passe pas la variable et le Go garde le remplacement du binaire. Parité : parity_update_test.go tire du Go les quatre réponses en conteneur, rejouées par tests/sysupdate.rs ; TestFacadeMiseAJour côté Go. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_016tcGfJWefW5kUGoC2wL8cv --- README.md | 1 + ...rust-migration-lot21-mise-a-jour-design.md | 26 ++++++++ internal/loki/facade_chat.go | 5 +- internal/loki/facade_chat_test.go | 22 +++++++ internal/loki/facade_update.go | 26 ++++++++ internal/loki/parity_update_test.go | 65 +++++++++++++++++++ rust/loki-rs/src/api.rs | 3 + rust/loki-rs/src/lib.rs | 1 + rust/loki-rs/src/main.rs | 6 ++ rust/loki-rs/src/proxy.rs | 7 ++ rust/loki-rs/src/sysupdate.rs | 56 ++++++++++++++++ rust/loki-rs/tests/common/fakego.rs | 2 + rust/loki-rs/tests/lot2.rs | 1 + rust/loki-rs/tests/parity_update/update.json | 42 ++++++++++++ rust/loki-rs/tests/proxy.rs | 1 + rust/loki-rs/tests/sysupdate.rs | 55 ++++++++++++++++ 16 files changed, 318 insertions(+), 1 deletion(-) create mode 100644 docs/superpowers/specs/2026-10-15-rust-migration-lot21-mise-a-jour-design.md create mode 100644 internal/loki/facade_update.go create mode 100644 internal/loki/parity_update_test.go create mode 100644 rust/loki-rs/src/sysupdate.rs create mode 100644 rust/loki-rs/tests/parity_update/update.json create mode 100644 rust/loki-rs/tests/sysupdate.rs diff --git a/README.md b/README.md index ae8eb86..cd63ba7 100644 --- a/README.md +++ b/README.md @@ -236,6 +236,7 @@ redémarrages (volume `/data`). Variables d'environnement du conteneur : | `LOKI_MEM` | `rust` : la mémoire (éditeur des pages, index, déplacement entre projets, trackers, mode mémoire, interrupteurs du mode agent) est tenue par la façade Rust ; le chiffrement reste au Go (expérimental, migration en cours) | Go | | `LOKI_SERVE` | `rust` : la ligne de commande de llama-server est composée par la façade Rust (`loki-rs engine serve`) au lieu de `loki serve` ; Strata et l'optimiseur restent au Go (expérimental, migration en cours) | Go | | `LOKI_PRESETS` | `rust` : les presets (liste, éditeur et aperçus, suppression, placement auto, presets externes, bascule) sont tenus par la façade Rust ; l'application au moteur reste au Go (expérimental, migration en cours) | Go | +| `LOKI_UPDATE` | `rust` : la mise à jour de Loki (`/api/update`, `/api/update/apply`) est servie par la façade Rust ; en conteneur, l'image se met à jour par Docker (expérimental, migration en cours) | Go | | `LOKI_MCP` | `rust` : les serveurs MCP — configuration, catalogue, état, pool de sessions stdio et HTTP — sont tenus par la façade Rust ; le Go lui demande les outils MCP de l'agent et leurs appels (expérimental, migration en cours) | Go | | `LOKI_PROJECTS` | `rust` : le hub des projets (liste et comptes, création, renommage, description, suppression, bascule) est tenu par la façade Rust ; les discussions restent à qui tient la conversation (expérimental, migration en cours) | Go | | `LOKI_SYSTEM` | `rust` : les interrupteurs système — accès internet et Crawl4AI, pilotage du navigateur, clé de complétion, écoute réseau du moteur — sont tenus par la façade Rust ; le navigateur piloté reste au Go (expérimental, migration en cours) | Go | diff --git a/docs/superpowers/specs/2026-10-15-rust-migration-lot21-mise-a-jour-design.md b/docs/superpowers/specs/2026-10-15-rust-migration-lot21-mise-a-jour-design.md new file mode 100644 index 0000000..d0bb674 --- /dev/null +++ b/docs/superpowers/specs/2026-10-15-rust-migration-lot21-mise-a-jour-design.md @@ -0,0 +1,26 @@ +# Lot 21 — mise à jour de Loki tenue par la façade + +## Contexte + +`/api/update` (version disponible) et `/api/update/apply` (téléchargement et +remplacement du binaire) vivaient dans `sys_update.go`. En conteneur +(`LOKI_CONTAINER=1`), le Go n'y fait rien. L'image se met à jour par Docker, +et les deux routes le disent (`containerUpdateNote`) : +- `/api/update` : `{current, available: false, note}` ; +- `/api/update/apply` : 409 `{ok: false, error: note}`. + +## Le lot + +La façade ne tourne qu'en conteneur. Elle tient ces deux routes quand +`LOKI_UPDATE=rust` (`sysupdate.rs`, expérimental) et `LOKI_CONTAINER=1`. +Sinon `loki-rs` ne passe pas la variable au Go, qui garde le téléchargement +et le remplacement du binaire hors conteneur. + +La version annoncée est celle du Go (`/internal/v1/auth`). Les binaires de +la façade suivent toujours `loki-rs self-update`. + +## Parité + +`internal/loki/parity_update_test.go` tire du Go les quatre réponses en +conteneur (GET et POST sur chaque route). `tests/sysupdate.rs` les rejoue par +la façade, version du Go comprise. diff --git a/internal/loki/facade_chat.go b/internal/loki/facade_chat.go index 9279121..75bb605 100644 --- a/internal/loki/facade_chat.go +++ b/internal/loki/facade_chat.go @@ -150,7 +150,8 @@ var facadeChatRoutes = map[string]bool{ // de la discussion (facade_files.go), les notifications (facade_push.go), la // mémoire (facade_mem.go), les presets (facade_presets.go), le pilotage du // moteur (facade_engine.go), les interrupteurs système (facade_system.go), les -// projets (facade_projects.go), les serveurs MCP (facade_mcp.go). +// projets (facade_projects.go), les serveurs MCP (facade_mcp.go), la mise à +// jour (facade_update.go). // Posé APRÈS requireWebAuth : seule une requête authentifiée ici part, avec le // jeton interne qui en tient lieu là-bas. func withFacadeChat(path string, h http.HandlerFunc) http.HandlerFunc { @@ -179,6 +180,8 @@ func withFacadeChat(path string, h http.HandlerFunc) http.HandlerFunc { held, unavailable = facadeProjects, "projets indisponibles : " case facadeMCPRoutes[path]: held, unavailable = facadeMCP, "serveurs MCP indisponibles : " + case facadeUpdateRoutes[path]: + held, unavailable = facadeUpdate, "mise à jour indisponible : " default: return h } diff --git a/internal/loki/facade_chat_test.go b/internal/loki/facade_chat_test.go index 847ef57..7d8b627 100644 --- a/internal/loki/facade_chat_test.go +++ b/internal/loki/facade_chat_test.go @@ -905,3 +905,25 @@ func TestFacadeMCP(t *testing.T) { t.Fatalf("outils sans façade : %v", tools) } } + +// La mise à jour (LOKI_UPDATE=rust, lot 21) part à la façade. +func TestFacadeMiseAJour(t *testing.T) { + testHome(t) + f := demarrerFacade(t) + t.Setenv(facadeChatEnv, "") + local := func(w http.ResponseWriter, r *http.Request) { io.WriteString(w, "go") } + for _, on := range []string{"", "rust"} { + t.Setenv(facadeUpdateEnv, on) + for p := range facadeUpdateRoutes { + w := httptest.NewRecorder() + withFacadeChat(p, local)(w, httptest.NewRequest("GET", p, nil)) + want := "go" + if on == "rust" { + want = f.reply + } + if w.Body.String() != want { + t.Fatalf("%s (LOKI_UPDATE=%q) : %q", p, on, w.Body.String()) + } + } + } +} diff --git a/internal/loki/facade_update.go b/internal/loki/facade_update.go new file mode 100644 index 0000000..528b577 --- /dev/null +++ b/internal/loki/facade_update.go @@ -0,0 +1,26 @@ +package loki + +import ( + "os" + "strings" +) + +// facade_update.go — mise à jour de Loki tenue par la façade Rust +// (LOKI_UPDATE=rust, lot 21 de la migration ; expérimental, le Go reste le +// défaut) : /api/update et /api/update/apply. La façade ne tourne qu'en +// conteneur, où l'image se met à jour par Docker : les deux routes le disent +// (containerUpdateNote). Hors conteneur, loki-rs ne passe pas la variable. + +const facadeUpdateEnv = "LOKI_UPDATE" + +// facadeUpdate : la façade tient-elle la mise à jour ? +func facadeUpdate() bool { + return strings.EqualFold(strings.TrimSpace(os.Getenv(facadeUpdateEnv)), "rust") && + facadeAddr() != "" && internalToken() != "" +} + +// facadeUpdateRoutes : les routes que la façade sert alors (sysupdate.rs). +var facadeUpdateRoutes = map[string]bool{ + "/api/update": true, + "/api/update/apply": true, +} diff --git a/internal/loki/parity_update_test.go b/internal/loki/parity_update_test.go new file mode 100644 index 0000000..7e776a0 --- /dev/null +++ b/internal/loki/parity_update_test.go @@ -0,0 +1,65 @@ +package loki + +import ( + "encoding/json" + "net/http/httptest" + "os" + "path/filepath" + "strings" + "testing" +) + +// parity_update_test.go — journal de référence de la mise à jour de Loki en +// conteneur (lot 21 de la migration Rust) : l'image se met à jour par Docker, +// les deux routes le disent. Rangé dans +// rust/loki-rs/tests/parity_update/update.json ; tests/sysupdate.rs le rejoue +// par la façade. +// +// LOKI_PARITY_UPDATE=1 go test ./internal/loki -run TestPariteMiseAJour + +const parityUpdateFile = "../../rust/loki-rs/tests/parity_update/update.json" + +func TestPariteMiseAJour(t *testing.T) { + testHome(t) + t.Setenv("LOKI_CONTAINER", "1") + var steps []map[string]any + for _, c := range []struct{ method, path, body string }{ + {"GET", "/api/update", ""}, + {"POST", "/api/update", "{}"}, + {"POST", "/api/update/apply", "{}"}, + {"GET", "/api/update/apply", ""}, + } { + rec := httptest.NewRecorder() + req := httptest.NewRequest(c.method, c.path, strings.NewReader(c.body)) + if c.path == "/api/update" { + handleUpdateCheck(rec, req) + } else { + handleUpdateApply(rec, req) + } + var out any + _ = json.Unmarshal([]byte(strings.ReplaceAll(rec.Body.String(), Version, "{{VERSION}}")), &out) + steps = append(steps, map[string]any{"method": c.method, "path": c.path, "status": rec.Code, "out": out}) + } + got, err := parityJSON(map[string]any{"steps": steps}) + if err != nil { + t.Fatal(err) + } + if os.Getenv("LOKI_PARITY_UPDATE") == "1" { + if err := os.MkdirAll(filepath.Dir(parityUpdateFile), 0o755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(parityUpdateFile, got, 0o644); err != nil { + t.Fatal(err) + } + return + } + want, err := os.ReadFile(parityUpdateFile) + 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("la mise à jour en conteneur ne produit plus le journal rangé dans %s.\n"+ + "Si c'est voulu : LOKI_PARITY_UPDATE=1 go test ./internal/loki -run TestPariteMiseAJour.\n--- obtenu ---\n%s", + parityUpdateFile, got) + } +} diff --git a/rust/loki-rs/src/api.rs b/rust/loki-rs/src/api.rs index a0b13f7..d3a5567 100644 --- a/rust/loki-rs/src/api.rs +++ b/rust/loki-rs/src/api.rs @@ -50,6 +50,8 @@ pub struct Ctx { pub projects: bool, /// Serveurs MCP tenus par la façade (`LOKI_MCP=rust`). pub mcp: Option>, + /// Mise à jour de Loki tenue par la façade (`LOKI_UPDATE=rust`). + pub update: bool, /// Jeton de l'API interne : une requête qui le porte vient du Go. pub internal_token: String, } @@ -70,6 +72,7 @@ impl Ctx { || self.system.is_some() || self.projects || self.mcp.is_some() + || self.update } /// La requête vient du Go (jeton interne valide) : déjà authentifiée par diff --git a/rust/loki-rs/src/lib.rs b/rust/loki-rs/src/lib.rs index eb95de7..56424dc 100644 --- a/rust/loki-rs/src/lib.rs +++ b/rust/loki-rs/src/lib.rs @@ -40,6 +40,7 @@ pub mod settings; pub mod store; pub mod sysinfo; pub mod system; +pub mod sysupdate; pub mod tools; pub mod ui; pub mod update; diff --git a/rust/loki-rs/src/main.rs b/rust/loki-rs/src/main.rs index a150f4c..099d51f 100644 --- a/rust/loki-rs/src/main.rs +++ b/rust/loki-rs/src/main.rs @@ -159,6 +159,10 @@ fn serve() -> ExitCode { // LOKI_MCP=rust : les serveurs MCP (le Go demande alors à la façade // les outils MCP et leurs appels). let mcp_rust = held_by_facade("LOKI_MCP"); + // LOKI_UPDATE=rust : la mise à jour de Loki, en conteneur seulement + // (l'image se met à jour par Docker). + let update_rust = held_by_facade("LOKI_UPDATE") + && loki_rs::sysupdate::usable(std::env::var("LOKI_CONTAINER").ok().as_deref()); let engine_rust = held_by_facade("LOKI_ENGINE") && match loki_rs::enginectl::usable( std::env::var("LOKI_CONTAINER").ok().as_deref(), @@ -189,6 +193,7 @@ fn serve() -> ExitCode { ("LOKI_SYSTEM", system_rust), ("LOKI_PROJECTS", projects_rust), ("LOKI_MCP", mcp_rust), + ("LOKI_UPDATE", update_rust), ]; for (var, on) in held { if on { @@ -294,6 +299,7 @@ fn serve() -> ExitCode { engine_ctl, system: system_rust.then(|| std::sync::Arc::new(loki_rs::system::System::new())), projects: projects_rust, + update: update_rust, mcp: mcp_rust.then(|| std::sync::Arc::new(loki_rs::mcp::Mcp::new(mem_home))), }; let res = proxy::serve(listener, cfg, shutdown_signal()).await; diff --git a/rust/loki-rs/src/proxy.rs b/rust/loki-rs/src/proxy.rs index b6a56db..d014718 100644 --- a/rust/loki-rs/src/proxy.rs +++ b/rust/loki-rs/src/proxy.rs @@ -90,6 +90,9 @@ pub struct Config { /// Serveurs MCP (pool de sessions, configuration, catalogue) tenus par la /// façade (`LOKI_MCP=rust`) ; `None` : au Go. pub mcp: Option>, + /// Mise à jour de Loki tenue par la façade (`LOKI_UPDATE=rust`, en + /// conteneur). + pub update: bool, } struct Proxy { @@ -128,6 +131,7 @@ pub async fn serve( system: cfg.system, projects: cfg.projects, mcp: cfg.mcp, + update: cfg.update, internal_token: cfg.internal_token.clone(), }, }); @@ -226,6 +230,9 @@ impl Proxy { { return chatapi::serve(&self.ctx, chat, req, from_go).await; } + if self.ctx.update && crate::sysupdate::handles(path) { + return crate::sysupdate::serve(&self.ctx, req, from_go).await; + } if let Some(m) = &self.ctx.mcp && crate::mcp::handles(path) { diff --git a/rust/loki-rs/src/sysupdate.rs b/rust/loki-rs/src/sysupdate.rs new file mode 100644 index 0000000..e342178 --- /dev/null +++ b/rust/loki-rs/src/sysupdate.rs @@ -0,0 +1,56 @@ +//! Mise à jour de Loki tenue par la façade (lot 21 de la migration, +//! `LOKI_UPDATE=rust` ; expérimental, le Go reste le défaut) — port de +//! `handleUpdateCheck` et `handleUpdateApply` (`sys_update.go`) en conteneur. +//! +//! La façade ne tourne qu'en conteneur (`LOKI_CONTAINER=1`), où l'image se met +//! à jour par Docker : les deux routes le disent, sans rien télécharger. Les +//! binaires de la façade, eux, suivent `loki-rs self-update`. Hors conteneur, +//! la façade ne tient pas ce lot (décidé au démarrage). + +use hyper::body::Incoming; +use hyper::{Request, Response, StatusCode}; +use serde_json::json; + +use crate::api::Ctx; +use crate::auth::json as send_json; +use crate::proxy::Body; + +/// `containerUpdateNote`. +pub const CONTAINER_UPDATE_NOTE: &str = + "Mise à jour par l'image Docker : docker compose pull && docker compose up -d"; + +/// La façade peut-elle tenir ce lot (`containerManaged`) ? +pub fn usable(container_env: Option<&str>) -> bool { + container_env == Some("1") +} + +/// Les routes servies par ce module. +pub fn handles(path: &str) -> bool { + matches!(path, "/api/update" | "/api/update/apply") +} + +/// `from_go` : remise par le Go (tunnel), déjà authentifiée par lui. +pub async fn serve(ctx: &Ctx, req: Request, from_go: bool) -> Response { + let version = if from_go { + ctx.internal + .auth() + .await + .map(|i| i.version) + .unwrap_or_default() + } else { + match ctx.guard(req.headers()).await { + Ok(v) => v, + Err(refus) => return *refus, + } + }; + if req.uri().path() == "/api/update" { + return send_json( + StatusCode::OK, + &json!({ "current": version, "available": false, "note": CONTAINER_UPDATE_NOTE }), + ); + } + send_json( + StatusCode::CONFLICT, + &json!({ "ok": false, "error": CONTAINER_UPDATE_NOTE }), + ) +} diff --git a/rust/loki-rs/tests/common/fakego.rs b/rust/loki-rs/tests/common/fakego.rs index 4466dcf..17f5603 100644 --- a/rust/loki-rs/tests/common/fakego.rs +++ b/rust/loki-rs/tests/common/fakego.rs @@ -634,6 +634,7 @@ pub struct Opts { pub system: Option>, pub projects: bool, pub mcp: Option>, + pub update: bool, } /// Comme [`start`], avec la conversation tenue par la façade @@ -734,6 +735,7 @@ pub async fn start_with( system: opts.system, projects: opts.projects, mcp: opts.mcp, + update: opts.update, }; tokio::spawn(loki_rs::proxy::serve(fl, cfg, async move { let _ = rx.await; diff --git a/rust/loki-rs/tests/lot2.rs b/rust/loki-rs/tests/lot2.rs index e57a6f0..26d8c2e 100644 --- a/rust/loki-rs/tests/lot2.rs +++ b/rust/loki-rs/tests/lot2.rs @@ -105,6 +105,7 @@ async fn banc(cle: Option<&str>) -> Banc { system: None, projects: false, mcp: None, + update: false, }; tokio::spawn(loki_rs::proxy::serve(l, cfg, async move { let _ = rx.await; diff --git a/rust/loki-rs/tests/parity_update/update.json b/rust/loki-rs/tests/parity_update/update.json new file mode 100644 index 0000000..245e479 --- /dev/null +++ b/rust/loki-rs/tests/parity_update/update.json @@ -0,0 +1,42 @@ +{ + "steps": [ + { + "method": "GET", + "out": { + "available": false, + "current": "{{VERSION}}", + "note": "Mise à jour par l'image Docker : docker compose pull && docker compose up -d" + }, + "path": "/api/update", + "status": 200 + }, + { + "method": "POST", + "out": { + "available": false, + "current": "{{VERSION}}", + "note": "Mise à jour par l'image Docker : docker compose pull && docker compose up -d" + }, + "path": "/api/update", + "status": 200 + }, + { + "method": "POST", + "out": { + "error": "Mise à jour par l'image Docker : docker compose pull && docker compose up -d", + "ok": false + }, + "path": "/api/update/apply", + "status": 409 + }, + { + "method": "GET", + "out": { + "error": "Mise à jour par l'image Docker : docker compose pull && docker compose up -d", + "ok": false + }, + "path": "/api/update/apply", + "status": 409 + } + ] +} diff --git a/rust/loki-rs/tests/proxy.rs b/rust/loki-rs/tests/proxy.rs index 09bbf47..7ce058c 100644 --- a/rust/loki-rs/tests/proxy.rs +++ b/rust/loki-rs/tests/proxy.rs @@ -111,6 +111,7 @@ async fn facade(backend: SocketAddr) -> (SocketAddr, oneshot::Sender<()>) { system: None, projects: false, mcp: None, + update: false, }; tokio::spawn(loki_rs::proxy::serve(l, cfg, async move { let _ = rx.await; diff --git a/rust/loki-rs/tests/sysupdate.rs b/rust/loki-rs/tests/sysupdate.rs new file mode 100644 index 0000000..d56c316 --- /dev/null +++ b/rust/loki-rs/tests/sysupdate.rs @@ -0,0 +1,55 @@ +//! Lot 21 : mise à jour de Loki tenue par la façade (`LOKI_UPDATE=rust`, en +//! conteneur). Le journal tiré du Go (`internal/loki/parity_update_test.go` → +//! `tests/parity_update/update.json`) est rejoué par la façade. + +mod common; + +use common::fakego; +use serde_json::Value; + +#[tokio::test] +async fn parite_en_conteneur() { + let fx: Value = serde_json::from_str(include_str!("parity_update/update.json")).unwrap(); + let tmp = tempfile::tempdir().unwrap(); + let (facade, _go, _stop, _) = fakego::start_with( + tmp.path().to_path_buf(), + fakego::Opts { + update: true, + ..Default::default() + }, + ) + .await; + for st in fx["steps"].as_array().unwrap() { + let (code, b) = fakego::send( + facade, + st["method"].as_str().unwrap(), + st["path"].as_str().unwrap(), + &[], + "", + ) + .await; + assert_eq!(code.as_u16() as i64, st["status"].as_i64().unwrap(), "{st}"); + // La version est celle du Go (0.15.0 pour le faux Go). + let out: Value = + serde_json::from_str(&String::from_utf8_lossy(&b).replace("0.15.0", "{{VERSION}}")) + .unwrap(); + assert_eq!(out, st["out"], "{st}"); + } +} + +#[test] +fn seulement_en_conteneur() { + assert!(loki_rs::sysupdate::usable(Some("1"))); + assert!(!loki_rs::sysupdate::usable(Some("0"))); + assert!(!loki_rs::sysupdate::usable(None)); +} + +#[tokio::test] +async fn sans_la_variable_la_mise_a_jour_reste_au_go() { + let tmp = tempfile::tempdir().unwrap(); + let (facade, _go, _stop) = fakego::start(tmp.path().to_path_buf()).await; + for p in ["/api/update", "/api/update/apply"] { + let (_, _, b) = fakego::get(facade, p, &[]).await; + assert_eq!(&b[..], format!("go:{p}").as_bytes()); + } +} From 2c926a8274cbe2a9bd5f06a178f6dd9275f4ea3c Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 15 Oct 2026 20:58:28 +0000 Subject: [PATCH 20/52] =?UTF-8?q?Lot=2022=20:=20backends=20llama.cpp=20et?= =?UTF-8?q?=20job=20des=20moteurs=20tenus=20par=20la=20fa=C3=A7ade=20(LOKI?= =?UTF-8?q?=5FBACKENDS=3Drust)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit La façade (rust/loki-rs/src/backends) sert le statut du moteur llama.cpp (dépôt git, binaire, moteur de l'image, précompilé, moteur téléchargé, plan de build et conseil), la vérification git, la dernière release et l'installation des binaires précompilés (extraction avec liens, VERSION, ménage), la bascule fast/opt, les backends custom et leur désinstallation, et les GPU vus par un moteur (caches mémoire et disque). Elle détient LE job des moteurs (state/build_job + loki-build.log, repris au démarrage). Les jobs que le Go fait encore tourner (compilation depuis les sources, moteur de l'image, Strata) lui rapportent chaque étape par /internal/facade/v1/build-job/* ; le Go garde le garde de l'optimiseur et ne restaure plus de job quand la façade tient le lot. Correction du Go : prebuiltVersionFormat lisait le format comme 3e champ du marqueur VERSION ; hors CUDA Windows (« b300 fmt2 ») il n'y en a que deux, et chaque vérification retéléchargeait la release au lieu de dire « déjà à jour ». Le port suit. Parité : parity_backends_test.go (Linux) → tests/parity_backends, rejoué par tests/backends.rs (fausses machines dans le PATH, faux GitHub, dépôt git reproductible) ; TestFacadeBackends pour les étapes transmises. Mutations : 55 tuées sur 57, deux inobservables sous Linux (cudart Windows, garde d'un avertissement). Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_016tcGfJWefW5kUGoC2wL8cv --- README.md | 1 + ...15-rust-migration-lot22-backends-design.md | 111 + internal/loki/backend_prebuilt.go | 36 +- internal/loki/facade_backends.go | 154 + internal/loki/facade_chat.go | 4 +- internal/loki/facade_chat_test.go | 137 + internal/loki/parity_backends_test.go | 922 +++ internal/loki/web_llamacpp.go | 30 +- internal/loki/web_server.go | 4 +- rust/loki-rs/src/api.rs | 5 + rust/loki-rs/src/auth.rs | 1 + rust/loki-rs/src/backends/devices.rs | 327 + rust/loki-rs/src/backends/job.rs | 414 ++ rust/loki-rs/src/backends/mod.rs | 936 +++ rust/loki-rs/src/backends/prebuilt.rs | 559 ++ rust/loki-rs/src/backends/probe.rs | 428 ++ rust/loki-rs/src/lib.rs | 1 + rust/loki-rs/src/main.rs | 21 + rust/loki-rs/src/proxy.rs | 15 + rust/loki-rs/src/serve/run.rs | 4 +- rust/loki-rs/src/system.rs | 2 +- rust/loki-rs/tests/backends.rs | 786 +++ rust/loki-rs/tests/common/fakego.rs | 16 +- rust/loki-rs/tests/lot2.rs | 1 + .../tests/parity_backends/backends.json | 6010 +++++++++++++++++ rust/loki-rs/tests/proxy.rs | 1 + 26 files changed, 10896 insertions(+), 30 deletions(-) create mode 100644 docs/superpowers/specs/2026-10-15-rust-migration-lot22-backends-design.md create mode 100644 internal/loki/facade_backends.go create mode 100644 internal/loki/parity_backends_test.go create mode 100644 rust/loki-rs/src/backends/devices.rs create mode 100644 rust/loki-rs/src/backends/job.rs create mode 100644 rust/loki-rs/src/backends/mod.rs create mode 100644 rust/loki-rs/src/backends/prebuilt.rs create mode 100644 rust/loki-rs/src/backends/probe.rs create mode 100644 rust/loki-rs/tests/backends.rs create mode 100644 rust/loki-rs/tests/parity_backends/backends.json diff --git a/README.md b/README.md index cd63ba7..29fd6fb 100644 --- a/README.md +++ b/README.md @@ -238,6 +238,7 @@ redémarrages (volume `/data`). Variables d'environnement du conteneur : | `LOKI_PRESETS` | `rust` : les presets (liste, éditeur et aperçus, suppression, placement auto, presets externes, bascule) sont tenus par la façade Rust ; l'application au moteur reste au Go (expérimental, migration en cours) | Go | | `LOKI_UPDATE` | `rust` : la mise à jour de Loki (`/api/update`, `/api/update/apply`) est servie par la façade Rust ; en conteneur, l'image se met à jour par Docker (expérimental, migration en cours) | Go | | `LOKI_MCP` | `rust` : les serveurs MCP — configuration, catalogue, état, pool de sessions stdio et HTTP — sont tenus par la façade Rust ; le Go lui demande les outils MCP de l'agent et leurs appels (expérimental, migration en cours) | Go | +| `LOKI_BACKENDS` | `rust` : les backends llama.cpp — statut, vérification git, binaires précompilés, bascule fast/opt, backends custom, GPU vus par un moteur — et le job des moteurs sont tenus par la façade Rust ; les jobs du Go (compilation, moteur de l'image, Strata) lui rapportent leurs étapes (expérimental, migration en cours) | Go | | `LOKI_PROJECTS` | `rust` : le hub des projets (liste et comptes, création, renommage, description, suppression, bascule) est tenu par la façade Rust ; les discussions restent à qui tient la conversation (expérimental, migration en cours) | Go | | `LOKI_SYSTEM` | `rust` : les interrupteurs système — accès internet et Crawl4AI, pilotage du navigateur, clé de complétion, écoute réseau du moteur — sont tenus par la façade Rust ; le navigateur piloté reste au Go (expérimental, migration en cours) | Go | | `LOKI_ENGINE` | `rust` : le pilotage du moteur (démarrage, arrêt, redémarrage, déchargement et rechargement de la VRAM) est tenu par la façade Rust, en conteneur avec son superviseur ; le verrou de l'optimiseur reste au Go (expérimental, migration en cours) | Go | diff --git a/docs/superpowers/specs/2026-10-15-rust-migration-lot22-backends-design.md b/docs/superpowers/specs/2026-10-15-rust-migration-lot22-backends-design.md new file mode 100644 index 0000000..52fc856 --- /dev/null +++ b/docs/superpowers/specs/2026-10-15-rust-migration-lot22-backends-design.md @@ -0,0 +1,111 @@ +# Lot 22 — backends llama.cpp et job des moteurs tenus par la façade + +## Contexte + +Le panneau Moteur s'appuie sur une famille de routes du Go : +- `web_llamacpp.go` : statut, vérification git, bascule fast/opt, job ; +- `backend_prebuilt.go` : binaires précompilés officiels ; +- `web_api.go` : backends custom et leur désinstallation ; +- `web_devices.go` : GPU vus par un moteur. + +Elles partagent **un** job (`lcJob`) avec la mise à jour du moteur de l'image +(`web_engine.go`) et l'installation de Strata (`backend_strata.go`). L'entête +du job est en base (`state/build_job`), ses lignes dans +`$LOKI_HOME/loki-build.log`. Au démarrage, un job noté « en cours » est déclaré +interrompu (`lcRestore`). + +## Le lot + +Avec `LOKI_BACKENDS=rust` (expérimental), la façade (`rust/loki-rs/src/backends`) +sert les routes suivantes : + +| Route | Rôle | +| --- | --- | +| `/api/llamacpp` | statut : dépôt git, binaire, moteur de l'image, précompilé, moteur téléchargé, plan de build, conseil, job | +| `/api/llamacpp/check` | `git fetch`, puis le retard sur origin | +| `/api/llamacpp/prebuilt/check` | dernière release officielle comparée à la version installée | +| `/api/llamacpp/prebuilt` | job : téléchargement, extraction (liens compris), VERSION, ménage, BIN, service arrêté puis relancé | +| `/api/llamacpp/use` | bascule fast/opt, refusée pendant une optimisation | +| `/api/llamacpp/job` | progression et lignes à partir d'un décalage | +| `/api/llamacpp/uninstall-custom` | désinstallation d'un backend custom | +| `/api/backends`, `/api/backends/custom` | les backends présents | +| `/api/backends/devices` | GPU vus par un moteur, avec un cache mémoire et un cache disque | + +**Le job vit dans la façade** (`backends/job.rs`) : même entête en base, même +journal, même reprise au démarrage. Les jobs que le Go fait encore tourner lui +rapportent chaque étape par l'API interne `/internal/facade/v1/build-job/*` +(`start`, `line`, `phase`, `fail`, `done`, `commits`, `end`, `snapshot`). +Ces jobs sont : +- la compilation depuis les sources (`install`, `update`, `install-custom`, + relayées au Go qui garde son refus 409 quand l'image fournit le moteur) ; +- la mise à jour du moteur de l'image ; +- Strata. + +Côté Go (`facade_backends.go`), quand la façade tient le lot : +- `startLcJob`, `lcAppend`, `lcPhase`, `lcFail`, `lcDone`, `lcSetCommit` et + `lcJobSnapshot` passent par la façade ; +- le garde de l'optimiseur reste au Go, avant le démarrage ; +- le Go ne restaure plus de job au démarrage ; +- les routes reçues par le tunnel sont relayées à la façade. + +Pour arrêter et relancer le service, la façade pilote le moteur si elle le +tient (lot 16), sinon elle le demande au Go (`/internal/v1/service/action`, +avec `if_active`). Les sondes de la machine passent par un PATH et une racine +injectables (`backends/probe.rs`) : +- `nvidia-smi`, `nvcc`, `hipcc`, `/opt/rocm` ; +- `glslc`, `vulkaninfo`, `ldconfig` et les chemins de `libvulkan`. + +## Correction du Go + +`prebuiltVersionFormat` lisait le format comme 3ᵉ champ du marqueur VERSION. +Hors CUDA Windows, la version CUDA est vide : le marqueur +« `b300 fmt2` » n'a alors que deux champs. Le format n'était donc jamais +reconnu, et chaque vérification retéléchargeait la release sous Linux et +macOS au lieu de dire « déjà à jour ». Le format est désormais le dernier +champ, et la version CUDA ignore ce champ. Le port Rust suit. + +## Parité + +`internal/loki/parity_backends_test.go` (Linux) tire du Go un journal rejoué +par `tests/backends.rs`. Le journal est sauté si `/usr/local/cuda*` ou +`/opt/rocm` existent sur la machine. + +Le journal contient : +- **le statut** dans tous ses états : + - dépôt git reproductible (dates et identité fixées), retard connu ou vérifié, tête détachée ; + - origine perdue, BIN dans un fork ; + - précompilé versionné, moteur téléchargé, moteur de l'image ; +- **les backends custom** et chaque refus de désinstallation ; +- **la bascule fast/opt** ; +- **la dernière release** : + - refus HTTP, tag vide, JSON illisible ; + - aucun asset adapté ; +- **le job précompilé** : + - installation, « déjà à jour », ancien format réextrait ; + - optimisation en cours, GitHub qui refuse ; + - archive absente, archive piégée (`../`), archive sans serveur ; +- **les GPU** : + - liaison PCIe, mémoire complétée par nvidia-smi ; + - cache, mémoire nulle non figée, repli sur le disque ; + - moteur planté, tué ou non exécutable, pilote ancien, cartes de même nom ; +- **après chaque appel** : BIN, entête en base, journal, `devices.json` et l'arborescence de `backends/` et `engine/` ; +- **machine par machine** (dix fausses machines) : le plan de build, le conseil et la variante de release ; +- **le job pas à pas** : + - phases déduites des lignes de build, troncature à 4000 lignes, vues à partir d'un décalage ; + - écritures en base limitées à une toutes les 2 s ; + - refus d'un second job ; + - reprise au démarrage dans tous les cas : absent, illisible, fini, interrompu, journal vide ou trop long. + +Autres tests : +- le Go (`TestFacadeBackends`) vérifie que ses jobs rapportent chaque étape à + la façade, dans l'ordre, et que la compilation n'est pas relayée ; +- la façade (`api_interne_du_job`, `service_arrete_puis_relance`) vérifie + l'API interne et le service arrêté puis relancé autour de l'installation. + +## Écarts assumés + +- Les erreurs réseau (GitHub injoignable) gardent le texte de `reqwest`. +- Quand un asset de la release a un champ de mauvais type, le message n'est + pas celui du Go. +- `git fetch` lancé par `/api/llamacpp/check` pendant un job du Go n'écrit + plus ses lignes dans ce job. diff --git a/internal/loki/backend_prebuilt.go b/internal/loki/backend_prebuilt.go index 6488521..9cb0b28 100644 --- a/internal/loki/backend_prebuilt.go +++ b/internal/loki/backend_prebuilt.go @@ -29,7 +29,8 @@ import ( "time" ) -const llamaReleasesAPI = "https://api.github.com/repos/ggml-org/llama.cpp/releases/latest" +// llamaReleasesAPI : variable pour que les tests la pointent sur un faux GitHub. +var llamaReleasesAPI = "https://api.github.com/repos/ggml-org/llama.cpp/releases/latest" type ghAsset struct { Name string `json:"name"` @@ -41,18 +42,25 @@ func prebuiltDir() string { return filepath.Join(backendsDir(), "llama.cpp-prebuilt") } +// prebuiltVersionFields : les champs du marqueur VERSION, « tag cudaVer +// format ». La version CUDA est vide hors CUDA Windows : « b300 fmt2 » n'a +// alors que DEUX champs, et le format est le second. +func prebuiltVersionFields() []string { + b, err := os.ReadFile(filepath.Join(prebuiltDir(), "VERSION")) + if err != nil { + return nil + } + return strings.Fields(strings.TrimSpace(string(b))) +} + // prebuiltVersion lit le marqueur VERSION du dossier prebuilt : "tag cudaVer" // (cudaVer vide hors CUDA Windows). func prebuiltVersion() (tag, cudaVer string) { - b, err := os.ReadFile(filepath.Join(prebuiltDir(), "VERSION")) - if err != nil { - return "", "" - } - f := strings.Fields(strings.TrimSpace(string(b))) + f := prebuiltVersionFields() if len(f) > 0 { tag = f[0] } - if len(f) > 1 { + if len(f) > 1 && !strings.HasPrefix(f[1], "fmt") { cudaVer = f[1] } return @@ -64,15 +72,13 @@ func prebuiltVersion() (tag, cudaVer string) { // sera refaite au lieu d'être déclarée « déjà à jour ». const prebuiltFormat = "fmt2" -// prebuiltVersionFormat lit le 3e champ du marqueur VERSION ("" si absent). +// prebuiltVersionFormat lit le format, dernier champ du marqueur VERSION ("" +// si absent). Lu jadis comme 3e champ, il manquait dès que la version CUDA +// était vide (Linux, macOS) : chaque vérification réinstallait la release. func prebuiltVersionFormat() string { - b, err := os.ReadFile(filepath.Join(prebuiltDir(), "VERSION")) - if err != nil { - return "" - } - f := strings.Fields(strings.TrimSpace(string(b))) - if len(f) > 2 { - return f[2] + f := prebuiltVersionFields() + if len(f) > 1 && strings.HasPrefix(f[len(f)-1], "fmt") { + return f[len(f)-1] } return "" } diff --git a/internal/loki/facade_backends.go b/internal/loki/facade_backends.go new file mode 100644 index 0000000..0e833cc --- /dev/null +++ b/internal/loki/facade_backends.go @@ -0,0 +1,154 @@ +package loki + +import ( + "bytes" + "encoding/json" + "errors" + "fmt" + "net/http" + "os" + "strconv" + "strings" + "time" +) + +// facade_backends.go — backends llama.cpp tenus par la façade Rust +// (LOKI_BACKENDS=rust, lot 22 de la migration ; expérimental, le Go reste le +// défaut) : statut, vérification git, binaires précompilés, bascule fast/opt, +// suivi du job, backends custom et GPU vus par un moteur +// (rust/loki-rs/src/backends). +// +// La façade détient alors LE job des moteurs. Ceux que le Go fait encore +// tourner — compilation depuis les sources (install, update, install-custom, +// que la façade lui relaie), moteur de l'image, Strata — lui rapportent chaque +// étape (/internal/facade/v1/build-job/*) : lcAppend, lcPhase, lcFail, lcDone, +// startLcJob et lcJobSnapshot passent par elle, et le Go ne restaure plus de +// job au démarrage (la façade s'en charge). + +const facadeBackendsEnv = "LOKI_BACKENDS" + +// facadeBackends : la façade tient-elle les backends et le job ? +func facadeBackends() bool { + return strings.EqualFold(strings.TrimSpace(os.Getenv(facadeBackendsEnv)), "rust") && + facadeAddr() != "" && internalToken() != "" +} + +// facadeBackendsRoutes : les routes que la façade sert alors (backends/mod.rs). +var facadeBackendsRoutes = map[string]bool{ + "/api/llamacpp": true, + "/api/llamacpp/check": true, + "/api/llamacpp/prebuilt": true, + "/api/llamacpp/prebuilt/check": true, + "/api/llamacpp/use": true, + "/api/llamacpp/job": true, + "/api/llamacpp/uninstall-custom": true, + "/api/backends": true, + "/api/backends/custom": true, + "/api/backends/devices": true, +} + +// facadeJobClient : appels courts (une ligne de journal, une phase). +var facadeJobClient = &http.Client{Timeout: 30 * time.Second} + +// facadeJobCall envoie une étape du job à la façade. out peut être nil. +// Une réponse 409 rend l'erreur que la façade explique (job déjà en cours). +func facadeJobCall(op string, body map[string]any, out any) error { + b, err := json.Marshal(body) + if err != nil { + return err + } + req, err := http.NewRequest(http.MethodPost, "http://"+facadeAddr()+"/internal/facade/v1/build-job/"+op, bytes.NewReader(b)) + if err != nil { + return err + } + req.Header.Set("Content-Type", "application/json") + req.Header.Set(internalTokenHeader, internalToken()) + resp, err := facadeJobClient.Do(req) + if err != nil { + return err + } + defer resp.Body.Close() + if resp.StatusCode == http.StatusConflict { + var e struct { + Error string `json:"error"` + } + _ = json.NewDecoder(resp.Body).Decode(&e) + if e.Error == "" { + e.Error = resp.Status + } + return errors.New(e.Error) + } + if resp.StatusCode >= 300 { + return fmt.Errorf("façade : %s", resp.Status) + } + if out != nil { + return json.NewDecoder(resp.Body).Decode(out) + } + return nil +} + +// facadeJobNote : une étape dont l'échec ne doit pas arrêter le job (le +// journal perd une ligne, rien de plus) — tracée sur la sortie d'erreur. +func facadeJobNote(op string, body map[string]any) { + if err := facadeJobCall(op, body, nil); err != nil { + fmt.Fprintf(os.Stderr, "[job] étape %s non transmise à la façade : %v\n", op, err) + } +} + +// facadeJobStart démarre le job côté façade, puis son corps ici. +func facadeJobStart(action string, run func()) error { + if err := facadeJobCall("start", map[string]any{"action": action}, nil); err != nil { + return err + } + setBuildSink(lcAppend) + go func() { + defer func() { + setBuildSink(nil) + facadeJobNote("end", map[string]any{}) + }() + run() + }() + return nil +} + +// facadeJobSnapshot : la vue du job tenue par la façade. +func facadeJobSnapshot(from int, withLines bool) map[string]any { + lines := "0" + if withLines { + lines = "1" + } + req, err := http.NewRequest(http.MethodGet, "http://"+facadeAddr()+ + "/internal/facade/v1/build-job/snapshot?from="+strconv.Itoa(from)+"&lines="+lines, nil) + if err != nil { + return map[string]any{"exists": false} + } + req.Header.Set(internalTokenHeader, internalToken()) + resp, err := facadeJobClient.Do(req) + if err != nil { + return map[string]any{"exists": false} + } + defer resp.Body.Close() + var out map[string]any + if resp.StatusCode != http.StatusOK || json.NewDecoder(resp.Body).Decode(&out) != nil { + return map[string]any{"exists": false} + } + return out +} + +// lcSetCommit note le commit d'avant ("old") ou d'après ("new") une mise à +// jour. +func lcSetCommit(which, commit string) { + if facadeBackends() { + facadeJobNote("commits", map[string]any{which: commit}) + return + } + lcMu.Lock() + if lcCur != nil { + if which == "old" { + lcCur.OldCommit = commit + } else { + lcCur.NewCommit = commit + } + } + lcMu.Unlock() +} diff --git a/internal/loki/facade_chat.go b/internal/loki/facade_chat.go index 75bb605..96b665b 100644 --- a/internal/loki/facade_chat.go +++ b/internal/loki/facade_chat.go @@ -151,7 +151,7 @@ var facadeChatRoutes = map[string]bool{ // mémoire (facade_mem.go), les presets (facade_presets.go), le pilotage du // moteur (facade_engine.go), les interrupteurs système (facade_system.go), les // projets (facade_projects.go), les serveurs MCP (facade_mcp.go), la mise à -// jour (facade_update.go). +// jour (facade_update.go), les backends llama.cpp (facade_backends.go). // Posé APRÈS requireWebAuth : seule une requête authentifiée ici part, avec le // jeton interne qui en tient lieu là-bas. func withFacadeChat(path string, h http.HandlerFunc) http.HandlerFunc { @@ -182,6 +182,8 @@ func withFacadeChat(path string, h http.HandlerFunc) http.HandlerFunc { held, unavailable = facadeMCP, "serveurs MCP indisponibles : " case facadeUpdateRoutes[path]: held, unavailable = facadeUpdate, "mise à jour indisponible : " + case facadeBackendsRoutes[path]: + held, unavailable = facadeBackends, "moteurs indisponibles : " default: return h } diff --git a/internal/loki/facade_chat_test.go b/internal/loki/facade_chat_test.go index 7d8b627..1c1fdf2 100644 --- a/internal/loki/facade_chat_test.go +++ b/internal/loki/facade_chat_test.go @@ -2,6 +2,7 @@ package loki import ( "encoding/json" + "errors" "io" "net/http" "net/http/httptest" @@ -927,3 +928,139 @@ func TestFacadeMiseAJour(t *testing.T) { } } } + +// Lot 22 : les routes des backends partent à la façade, et les jobs du Go +// (compilation, moteur de l'image, Strata) lui rapportent chaque étape — le +// job vit chez elle. +func TestFacadeBackends(t *testing.T) { + testHome(t) + resetBackendsState() + t.Cleanup(resetBackendsState) + var mu sync.Mutex + var calls []string + busy := false + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Header.Get(internalTokenHeader) != jetonTest { + w.WriteHeader(403) + return + } + b, _ := io.ReadAll(r.Body) + mu.Lock() + calls = append(calls, r.Method+" "+r.URL.RequestURI()+" "+string(b)) + refuse := busy + mu.Unlock() + switch r.URL.Path { + case "/internal/facade/v1/build-job/start": + if refuse { + w.WriteHeader(409) + io.WriteString(w, `{"ok":false,"error":"un job engine est déjà en cours"}`) + return + } + io.WriteString(w, `{"ok":true}`) + case "/internal/facade/v1/build-job/snapshot": + io.WriteString(w, `{"exists":true,"action":"engine","running":true}`) + default: + io.WriteString(w, `{"ok":true,"de":"facade"}`) + } + })) + defer srv.Close() + t.Setenv(internalTokenEnv, jetonTest) + t.Setenv(facadeAddrEnv, strings.TrimPrefix(srv.URL, "http://")) + local := func(w http.ResponseWriter, r *http.Request) { io.WriteString(w, "go") } + + t.Setenv(facadeBackendsEnv, "") + w := httptest.NewRecorder() + withFacadeChat("/api/llamacpp", local)(w, httptest.NewRequest("GET", "/api/llamacpp", nil)) + if w.Body.String() != "go" || len(calls) != 0 { + t.Fatalf("sans LOKI_BACKENDS : %q", w.Body.String()) + } + t.Setenv(facadeBackendsEnv, "rust") + for p := range facadeBackendsRoutes { + w := httptest.NewRecorder() + withFacadeChat(p, local)(w, httptest.NewRequest("POST", p, strings.NewReader("{}"))) + if !strings.Contains(w.Body.String(), "facade") { + t.Fatalf("%s non relayé : %q", p, w.Body.String()) + } + } + // La compilation reste au Go. + for _, p := range []string{"/api/llamacpp/install", "/api/llamacpp/update", "/api/llamacpp/install-custom"} { + if facadeBackendsRoutes[p] { + t.Fatalf("%s ne doit pas partir à la façade", p) + } + } + mu.Lock() + calls = nil + mu.Unlock() + + // Un job du Go : chaque étape part à la façade, dans l'ordre. + done := make(chan struct{}) + if err := startLcJob("engine", func() { + lcPhase("recherche…") + emitBuildLine("[ 10%] Building CXX object a/b.cpp.o") + lcSetCommit("old", "") + lcSetCommit("new", "bbb") + lcFail(errors.New("raté")) + lcDone("fini") + close(done) + }); err != nil { + t.Fatal(err) + } + <-done + want := []string{ + `POST /internal/facade/v1/build-job/start {"action":"engine"}`, + `POST /internal/facade/v1/build-job/phase {"phase":"recherche…"}`, + `POST /internal/facade/v1/build-job/line {"line":"[ 10%] Building CXX object a/b.cpp.o"}`, + `POST /internal/facade/v1/build-job/commits {"old":""}`, + `POST /internal/facade/v1/build-job/commits {"new":"bbb"}`, + `POST /internal/facade/v1/build-job/fail {"error":"raté"}`, + `POST /internal/facade/v1/build-job/done {"msg":"fini"}`, + `POST /internal/facade/v1/build-job/end {}`, + } + for i := 0; i < 100; i++ { + mu.Lock() + n := len(calls) + mu.Unlock() + if n >= len(want) { + break + } + time.Sleep(10 * time.Millisecond) + } + mu.Lock() + got := append([]string(nil), calls...) + calls = nil + busy = true + mu.Unlock() + if strings.Join(got, "\n") != strings.Join(want, "\n") { + t.Fatalf("étapes transmises :\n%s", strings.Join(got, "\n")) + } + // Rien ne reste dans le job du Go, et le sink est retiré. + if lcCur != nil { + t.Fatalf("job tenu par le Go : %+v", lcCur) + } + // Un second job : la façade le refuse, le Go le dit. + if err := startLcJob("strata", func() { t.Error("ne doit pas tourner") }); err == nil || err.Error() != "un job engine est déjà en cours" { + t.Fatalf("refus : %v", err) + } + // La vue du job (statut du moteur de l'image) vient de la façade. + if v := lcJobSnapshot(0, false); v["action"] != "engine" || v["running"] != true { + t.Fatalf("vue : %v", v) + } + mu.Lock() + last := calls[len(calls)-1] + mu.Unlock() + if last != "GET /internal/facade/v1/build-job/snapshot?from=0&lines=0 " { + t.Fatalf("vue demandée : %q", last) + } + if v := lcJobSnapshot(3, true); v["exists"] != true { + t.Fatalf("vue avec lignes : %v", v) + } + // Façade injoignable : le job ne démarre pas, la vue dit « aucun job ». + t.Setenv(facadeAddrEnv, "127.0.0.1:1") + if err := startLcJob("engine", func() { t.Error("ne doit pas tourner") }); err == nil { + t.Fatal("job démarré sans façade") + } + if v := lcJobSnapshot(0, true); v["exists"] != false { + t.Fatalf("vue sans façade : %v", v) + } + lcAppend("perdue") // tracée, sans panique +} diff --git a/internal/loki/parity_backends_test.go b/internal/loki/parity_backends_test.go new file mode 100644 index 0000000..f77db05 --- /dev/null +++ b/internal/loki/parity_backends_test.go @@ -0,0 +1,922 @@ +//go:build linux + +package loki + +import ( + "archive/tar" + "bytes" + "compress/gzip" + "encoding/json" + "errors" + "io/fs" + "net/http" + "net/http/httptest" + "os" + "os/exec" + "path/filepath" + "regexp" + "runtime" + "sort" + "strings" + "sync" + "testing" + "time" +) + +// parity_backends_test.go — journal de référence des backends llama.cpp +// (lot 22 de la migration Rust) : statut (dépôt git, binaire, moteur de +// l'image, précompilé, moteur téléchargé, plan de build et conseil selon la +// machine), vérification git, backends custom et leur désinstallation, +// bascule fast/opt, vérification de la dernière release, job des binaires +// précompilés (téléchargement, extraction avec liens, ménage, marqueur +// VERSION, échecs), GPU vus par un moteur (caches mémoire et disque), puis le +// job lui-même pas à pas et sa reprise au démarrage. Les machines sont de faux +// outils dans le PATH (nvidia-smi, nvcc, hipcc, glslc, vulkaninfo, ldconfig) ; +// GitHub est un faux serveur. Rangé dans +// rust/loki-rs/tests/parity_backends/backends.json ; tests/backends.rs le +// rejoue par la façade. +// +// LOKI_PARITY_UPDATE=1 go test ./internal/loki -run TestPariteBackends + +const parityBackendsFile = "../../rust/loki-rs/tests/parity_backends/backends.json" + +// Une entrée d'archive servie par le faux GitHub. +type parityArchEntry struct { + Name string `json:"name"` + Type string `json:"type"` // file | dir | symlink | hardlink + Body string `json:"body,omitempty"` + Link string `json:"link,omitempty"` + Mode int64 `json:"mode,omitempty"` +} + +// Ce que sert le faux GitHub : la release (« {{GH}} » = son adresse) et les +// archives par nom. +type parityRelease struct { + Status int `json:"status"` + JSON string `json:"json"` + Assets map[string][]parityArchEntry `json:"assets,omitempty"` +} + +type parityBackendsStep struct { + Name string `json:"name"` + Machine string `json:"machine,omitempty"` // outils du PATH (machines) + Git []string `json:"git,omitempty"` // avant les fichiers : init | commit | clone | pull | detach | remote-bad + Files map[string]string `json:"files,omitempty"` // sous LOKI_HOME : contenu ; « #! » exécutable ; « -> x » lien ; « - » supprimé ; « / » dossier + Config map[string]string `json:"config,omitempty"` // "" = supprimé + Engine *string `json:"engine_env,omitempty"` + Release *parityRelease `json:"release,omitempty"` + Tune bool `json:"tune,omitempty"` + Method string `json:"method"` + Path string `json:"path"` + Body string `json:"body,omitempty"` + Wait bool `json:"wait,omitempty"` // job lancé : attendre sa fin, relire /api/llamacpp/job + Status int `json:"status"` + Out any `json:"out"` + Job any `json:"job,omitempty"` + After map[string]string `json:"after"` + Tree []string `json:"tree"` +} + +// Les machines : nom → outil → script. +var parityMachines = map[string]map[string]string{ + "nu": {}, + "nvidia": { + "nvidia-smi": "#!/bin/sh\ncase \"$1\" in\n-L) echo 'GPU 0: NVIDIA GeForce RTX 3090 (UUID: GPU-1)';;\n--query-gpu=compute_cap) printf '8.6\\n8.9\\n8.6\\n';;\n'') echo '| NVIDIA-SMI 570.00 Driver Version: 570.00 CUDA Version: 12.8 |';;\n--query-gpu=*) printf '0, NVIDIA GeForce RTX 3090, 24576, 100, 8.6, 00000000:01:00.0, 1, 4, 16, 16, 9751\\n1, Carte B, 8192, 5, 7.5, [N/A], [N/A], 3, 8, 16, 7001\\n';;\nesac\n", + "nvcc": "#!/bin/sh\nexit 0\n", + }, + "nvidia-sans-nvcc": { + "nvidia-smi": "#!/bin/sh\ncase \"$1\" in\n-L) echo 'GPU 0: NVIDIA (UUID: GPU-1)';;\n--query-gpu=compute_cap) echo '12.0';;\nesac\n", + }, + "nvidia-muet": { + "nvidia-smi": "#!/bin/sh\ncase \"$1\" in\n-L) echo 'rien';;\nesac\n", + "nvcc": "#!/bin/sh\nexit 0\n", + }, + "nvidia-en-panne": { + "nvidia-smi": "#!/bin/sh\nexit 9\n", + "nvcc": "#!/bin/sh\nexit 0\n", + }, + "rocm": { + "hipcc": "#!/bin/sh\nexit 0\n", + }, + "glslc-ldconfig": { + "glslc": "#!/bin/sh\nexit 0\n", + "ldconfig": "#!/bin/sh\necho '\tlibvulkan.so.1 (libc6,x86-64) => /usr/lib/libvulkan.so.1'\n", + }, + "vulkan": { + "glslc": "#!/bin/sh\nexit 0\n", + "vulkaninfo": "#!/bin/sh\nexit 0\n", + }, + "nvidia-jumelles": { + "nvidia-smi": "#!/bin/sh\ncase \"$1\" in\n-L) echo 'GPU 0: Carte B';;\n--query-gpu=*) printf '0, Carte B, 8192, 5, 7.5, [N/A], 1, 3, 8, 16, 7001\\n1, Carte B, 8192, 5, 7.5, [N/A], 1, 3, 8, 16, 7001\\n';;\nesac\n", + }, + "liaison-partielle": { + "nvidia-smi": "#!/bin/sh\ncase \"$1\" in\n--query-gpu=*) echo '0, Carte P, 4096, 1, 8.6, 00000000:02:00.0, [N/A], [N/A], 4, 8, 5001';;\nesac\n", + }, + "vulkaninfo-seul": { + "vulkaninfo": "#!/bin/sh\nexit 0\n", + }, + "arch-ancienne": { + "nvidia-smi": "#!/bin/sh\ncase \"$1\" in\n--query-gpu=index*pci.bus_id*) echo 'Field \"pci.bus_id\" is not a valid field to query.'; exit 2;;\n--query-gpu=*) echo '0, Vieille carte, 4096, 10, 6.1';;\n-L) echo 'GPU 0: Vieille carte';;\nesac\n", + }, +} + +var ( + parityTSRe = regexp.MustCompile(`"(started_at|ended_at)":[1-9][0-9]*`) + parityJobsRe = regexp.MustCompile(`"jobs":[0-9]+`) +) + +func TestPariteBackends(t *testing.T) { + for _, p := range []string{"/usr/local/cuda", "/opt/rocm"} { + if _, err := os.Stat(p); err == nil { + t.Skipf("%s présent : la machine fausserait le journal", p) + } + } + if m, _ := filepath.Glob("/usr/local/cuda-*"); len(m) > 0 { + t.Skip("CUDA installé : la machine fausserait le journal") + } + gitBin, err := exec.LookPath("git") + if err != nil { + t.Skip("git absent") + } + home := testHome(t) + t.Setenv("LOKI_ENGINE_BIN", "") + tools := t.TempDir() + t.Setenv("PATH", tools) + resetBackendsState() + t.Cleanup(resetBackendsState) + + // Le faux GitHub. + var mu sync.Mutex + var rel parityRelease + gh := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + mu.Lock() + cur := rel + mu.Unlock() + if r.URL.Path == "/latest" { + w.WriteHeader(cur.Status) + _, _ = w.Write([]byte(strings.ReplaceAll(cur.JSON, "{{GH}}", "http://"+r.Host))) + return + } + entries, ok := cur.Assets[strings.TrimPrefix(r.URL.Path, "/dl/")] + if !ok { + http.NotFound(w, r) + return + } + _, _ = w.Write(parityTarGz(t, entries)) + })) + defer gh.Close() + old := llamaReleasesAPI + llamaReleasesAPI = gh.URL + "/latest" + defer func() { llamaReleasesAPI = old }() + + mask := func(s string) string { + s = strings.ReplaceAll(s, gh.URL, "{{GH}}") + s = strings.ReplaceAll(s, home, "{{HOME}}") + s = parityJobsRe.ReplaceAllString(s, `"jobs":"{{JOBS}}"`) + return parityTSRe.ReplaceAllString(s, `"$1":"{{T}}"`) + } + unmask := func(s string) string { return strings.ReplaceAll(s, "{{HOME}}", home) } + handlers := map[string]http.HandlerFunc{ + "/api/llamacpp": handleLlamacpp, + "/api/llamacpp/check": handleLlamacppCheck, + "/api/llamacpp/prebuilt/check": handleLlamacppPrebuiltCheck, + "/api/llamacpp/prebuilt": handleLlamacppPrebuilt, + "/api/llamacpp/use": handleLlamacppUse, + "/api/llamacpp/job": handleLlamacppJob, + "/api/llamacpp/uninstall-custom": handleLlamacppUninstallCustom, + "/api/backends": handleBackends, + "/api/backends/custom": handleBackendsCustom, + "/api/backends/devices": handleBackendDevices, + } + call := func(method, path, body string) (int, any) { + req := httptest.NewRequest(method, path, strings.NewReader(body)) + rec := httptest.NewRecorder() + handlers[strings.SplitN(path, "?", 2)[0]](rec, req) + var out any + if err := json.Unmarshal([]byte(mask(rec.Body.String())), &out); err != nil { + t.Fatalf("%s : corps illisible %q", path, rec.Body.String()) + } + return rec.Code, out + } + machine := func(name string) { + entries, _ := os.ReadDir(tools) + for _, e := range entries { + _ = os.Remove(filepath.Join(tools, e.Name())) + } + if err := os.Symlink(gitBin, filepath.Join(tools, "git")); err != nil { + t.Fatal(err) + } + for tool, script := range parityMachines[name] { + if err := os.WriteFile(filepath.Join(tools, tool), []byte(script), 0o755); err != nil { + t.Fatal(err) + } + } + } + machine("nu") + + run := func(st parityBackendsStep) parityBackendsStep { + if st.Machine != "" { + machine(st.Machine) + } + for _, g := range st.Git { + parityGit(t, gitBin, home, g) + } + files := map[string]string{} + for k, v := range st.Files { + files[k] = unmask(v) + } + parityApplyFiles(t, home, files) + for k, v := range st.Config { + if err := SetConfigKey(k, unmask(v)); err != nil { + t.Fatal(err) + } + } + if st.Engine != nil { + t.Setenv("LOKI_ENGINE_BIN", unmask(*st.Engine)) + } + if st.Release != nil { + mu.Lock() + rel = *st.Release + mu.Unlock() + } + var lock *tuneLock + if st.Tune { + l, err := tuneLockAcquire("web", false) + if err != nil { + t.Fatal(err) + } + lock = l + } + st.Status, st.Out = call(st.Method, st.Path, unmask(st.Body)) + if lock != nil { + lock.release() + } + if st.Wait { + deadline := time.Now().Add(30 * time.Second) + for lcJobSnapshot(0, false)["running"] == true { + if time.Now().After(deadline) { + t.Fatalf("%s : le job ne finit pas", st.Name) + } + time.Sleep(20 * time.Millisecond) + } + _, st.Job = call("GET", "/api/llamacpp/job?from=0", "") + } + st.After = map[string]string{} + if v := ReadConfig()["BIN"]; v != "" { + st.After["config/BIN"] = mask(v) + } + if v := getStr(bkState, "build_job"); v != "" { + st.After["state/build_job"] = mask(v) + } + if b, err := os.ReadFile(filepath.Join(home, "loki-build.log")); err == nil { + st.After["loki-build.log"] = mask(string(b)) + } + if b, err := os.ReadFile(filepath.Join(home, "devices.json")); err == nil { + st.After["devices.json"] = mask(string(b)) + } + st.Tree = parityTree(t, home) + for i := range st.Tree { + st.Tree[i] = mask(st.Tree[i]) + } + return st + } + + str := func(s string) *string { return &s } + tarOK := []parityArchEntry{ + {Name: "llama-b300/", Type: "dir", Mode: 0o755}, + {Name: "llama-b300/llama-server", Type: "file", Body: "#!/bin/sh\necho serveur\n", Mode: 0o750}, + {Name: "llama-b300/libggml.so.1.0", Type: "file", Body: "lib", Mode: 0o644}, + {Name: "llama-b300/libggml.so.1", Type: "symlink", Link: "libggml.so.1.0"}, + {Name: "llama-b300/libggml.so", Type: "symlink", Link: "absente.so"}, + {Name: "llama-b300/llama-cli", Type: "hardlink", Link: "llama-b300/llama-server"}, + {Name: "llama-b300/sous/doc.txt", Type: "file", Body: "doc", Mode: 0o600}, + } + relJSON := func(tag string, names ...string) string { + var assets []string + for _, n := range names { + assets = append(assets, `{"name":"`+n+`","browser_download_url":"{{GH}}/dl/`+n+`","size":2500000}`) + } + return `{"tag_name":"` + tag + `","assets":[` + strings.Join(assets, ",") + `]}` + } + cpu := "llama-b300-bin-ubuntu-x64.tar.gz" + if runtime.GOARCH == "arm64" { + cpu = "llama-b300-bin-ubuntu-arm64.tar.gz" + } + allAssets := []string{"llama-b300-bin-macos-arm64.zip", "cudart-llama-bin-win-cuda-12.4-x64.zip", + "llama-b300-bin-win-cuda-12.4-x64.zip", "llama-b300-bin-ubuntu-vulkan-x64.tar.gz", + "llama-b300-bin-ubuntu-rocm-7.0-x64.tar.gz", "llama-b300-bin-ubuntu-vulkan-arm64.tar.gz", + "llama-b300-bin-ubuntu-arm64.tar.gz", "llama-b300-bin-ubuntu-x64.tar.gz"} + okRel := &parityRelease{Status: 200, JSON: relJSON("b300", allAssets...), + Assets: map[string][]parityArchEntry{cpu: tarOK}} + pb := "backends/llama.cpp-prebuilt/" + + var steps []parityBackendsStep + for _, st := range []parityBackendsStep{ + // --- statut, rien d'installé --- + {Name: "statut vide", Method: "GET", Path: "/api/llamacpp"}, + {Name: "backends vides", Method: "GET", Path: "/api/backends"}, + {Name: "custom vides", Method: "GET", Path: "/api/backends/custom"}, + {Name: "job absent", Method: "GET", Path: "/api/llamacpp/job"}, + + // --- précompilé déjà là --- + {Name: "deux versions précompilées", Files: map[string]string{ + pb + "llama-b100/llama-server": "#!/bin/sh\n", + pb + "llama-b200/build/bin/llama-server": "#!/bin/sh\n", + pb + "VERSION": "b100 12.4 fmt2\n", + }, Config: map[string]string{"BIN": "{{HOME}}/" + pb + "llama-b200/build/bin/llama-server"}, + Method: "GET", Path: "/api/llamacpp"}, + {Name: "VERSION absent : la plus récente", Files: map[string]string{pb + "VERSION": "-"}, + Method: "GET", Path: "/api/llamacpp"}, + {Name: "BIN précompilé disparu", Config: map[string]string{"BIN": "{{HOME}}/" + pb + "llama-b050/llama-server"}, + Method: "GET", Path: "/api/llamacpp"}, + + // --- backends custom --- + {Name: "backends custom", Files: map[string]string{ + "backends/fork-a/build/bin/llama-server": "#!/bin/sh\n", + "backends/fork-a/CMakeLists.txt": "", + "backends/fork-b/bin/llama-server": "#!/bin/sh\n", + "backends/fork-c/build/bin/llama-server": "/", + "backends/fork-c/llama-server.exe": "x", + "backends/.cache/llama-server": "x", + "backends/vide/": "/", + "backends/lien": "-> fork-b", + "backends/fichier": "x", + pb + "bin/llama-server": "#!/bin/sh\n", // vu par le balayage, mais pas un backend custom + }, Config: map[string]string{"BIN": "{{HOME}}/backends/fork-a/build/bin/llama-server"}, + Method: "GET", Path: "/api/backends"}, + {Name: "custom seulement", Method: "GET", Path: "/api/backends/custom"}, + {Name: "statut sur un fork", Method: "GET", Path: "/api/llamacpp"}, + {Name: "désinstaller le canonique", Method: "POST", Path: "/api/llamacpp/uninstall-custom", Body: `{"name":"llama.cpp"}`}, + {Name: "désinstaller le précompilé", Method: "POST", Path: "/api/llamacpp/uninstall-custom", Body: `{"name":" LLAMA.CPP-prebuilt "}`}, + {Name: "désinstaller sans nom", Method: "POST", Path: "/api/llamacpp/uninstall-custom", Body: `{"name":""}`}, + {Name: "désinstaller, corps illisible", Method: "POST", Path: "/api/llamacpp/uninstall-custom", Body: `{`}, + {Name: "désinstaller l'actif", Method: "POST", Path: "/api/llamacpp/uninstall-custom", Body: `{"name":"Fork-A"}`}, + {Name: "désinstaller par un chemin", Method: "POST", Path: "/api/llamacpp/uninstall-custom", Body: `{"name":"../fork-b"}`}, + {Name: "désinstaller un absent", Method: "POST", Path: "/api/llamacpp/uninstall-custom", Body: `{"name":"fork-z"}`}, + {Name: "désinstaller un lien", Method: "POST", Path: "/api/llamacpp/uninstall-custom", Body: `{"name":"lien"}`}, + {Name: "désinstaller un fichier", Method: "POST", Path: "/api/llamacpp/uninstall-custom", Body: `{"name":"fichier"}`}, + {Name: "désinstaller un dossier sans binaire", Method: "POST", Path: "/api/llamacpp/uninstall-custom", Body: `{"name":"vide"}`}, + + // --- dépôt git du build local --- + {Name: "dépôt cloné", Git: []string{ + "init src", "commit src 2024-01-02T03:04:05+00:00 premier état", + "clone src backends/llama.cpp", + }, Files: map[string]string{"backends/llama.cpp/build/bin/llama-server": "#!/bin/sh\n"}, + Config: map[string]string{"BIN": "{{HOME}}/backends/llama.cpp/build/bin/llama-server"}, + Method: "GET", Path: "/api/llamacpp"}, + {Name: "amont avancé, pas encore vu", Git: []string{"commit src 2024-02-03T04:05:06+00:00 & état"}, + Method: "GET", Path: "/api/llamacpp"}, + {Name: "vérification git", Method: "POST", Path: "/api/llamacpp/check"}, + {Name: "retard connu", Method: "GET", Path: "/api/llamacpp"}, + {Name: "binaire Release", Files: map[string]string{ + "backends/llama.cpp/build/bin/llama-server": "-", + "backends/llama.cpp/build/bin/Release/llama-server": "#!/bin/sh\n", + }, Method: "POST", Path: "/api/llamacpp/check"}, + {Name: "sans binaire", Files: map[string]string{"backends/llama.cpp/build/bin/Release/llama-server": "-"}, + Method: "POST", Path: "/api/llamacpp/check"}, + {Name: "à jour, sans binaire", Git: []string{"pull backends/llama.cpp"}, Method: "POST", Path: "/api/llamacpp/check"}, + {Name: "tête détachée", Git: []string{"detach backends/llama.cpp"}, Method: "GET", Path: "/api/llamacpp"}, + {Name: "vérification, tête détachée", Method: "POST", Path: "/api/llamacpp/check"}, + {Name: "origine perdue", Git: []string{"remote-bad backends/llama.cpp"}, Method: "POST", Path: "/api/llamacpp/check"}, + {Name: "pas de dépôt", Config: map[string]string{"BIN": "/nulle/part/llama-server"}, + Files: map[string]string{"backends/llama.cpp/.git": "-"}, Method: "POST", Path: "/api/llamacpp/check"}, + + // --- bascule fast / opt --- + {Name: "bascule fast", Method: "POST", Path: "/api/llamacpp/use", Body: `{"mode":"fast"}`}, + {Name: "bascule opt sans build", Method: "POST", Path: "/api/llamacpp/use", Body: `{"mode":"opt"}`}, + {Name: "bascule opt", Files: map[string]string{"backends/llama.cpp/build/llama-server": "#!/bin/sh\n"}, + Method: "POST", Path: "/api/llamacpp/use", Body: `{"mode":"opt"}`}, + {Name: "bascule opt depuis un fork", Config: map[string]string{"BIN": "{{HOME}}/backends/fork-a/build/bin/llama-server"}, + Files: map[string]string{"backends/fork-a/build/llama-server": "#!/bin/sh\n"}, + Method: "POST", Path: "/api/llamacpp/use", Body: `{"mode":"opt"}`}, + {Name: "mode inconnu", Method: "POST", Path: "/api/llamacpp/use", Body: `{"mode":"turbo"}`}, + {Name: "mode absent", Method: "POST", Path: "/api/llamacpp/use", Body: ``}, + {Name: "mode de travers", Method: "POST", Path: "/api/llamacpp/use", Body: `{"mode":1}`}, + {Name: "bascule pendant une optimisation", Tune: true, Method: "POST", Path: "/api/llamacpp/use", Body: `{"mode":"fast"}`}, + {Name: "fast sans précompilé", Files: map[string]string{pb: "-"}, + Method: "POST", Path: "/api/llamacpp/use", Body: `{"mode":"fast"}`}, + + // --- moteur téléchargé, moteur de l'image --- + {Name: "moteur téléchargé", Files: map[string]string{"engine/server-cuda-b10450/llama-server": "#!/bin/sh\n"}, + Config: map[string]string{"BIN": "{{HOME}}/engine/server-cuda-b10450/llama-server"}, + Method: "GET", Path: "/api/llamacpp"}, + {Name: "moteur de l'image", Engine: str(" {{HOME}}/engine/server-cuda-b10450/llama-server "), + Method: "GET", Path: "/api/llamacpp"}, + {Name: "moteur de l'image absent", Engine: str("{{HOME}}/image/llama-server"), + Method: "GET", Path: "/api/llamacpp"}, + {Name: "moteur de l'image : un dossier", Engine: str("{{HOME}}/engine"), + Method: "GET", Path: "/api/llamacpp"}, + + // --- dernière release --- + {Name: "release : CPU", Release: okRel, Engine: str(""), Method: "POST", Path: "/api/llamacpp/prebuilt/check"}, + {Name: "release refusée", Release: &parityRelease{Status: 403, JSON: `{"message":"rate limit"}`}, + Method: "POST", Path: "/api/llamacpp/prebuilt/check"}, + {Name: "release sans tag", Release: &parityRelease{Status: 200, JSON: `{"tag_name":"","assets":[]}`}, + Method: "POST", Path: "/api/llamacpp/prebuilt/check"}, + {Name: "release illisible", Release: &parityRelease{Status: 200, JSON: `{"tag_name":`}, + Method: "POST", Path: "/api/llamacpp/prebuilt/check"}, + {Name: "release vide", Release: &parityRelease{Status: 200, JSON: ``}, + Method: "POST", Path: "/api/llamacpp/prebuilt/check"}, + {Name: "release, tag de travers", Release: &parityRelease{Status: 200, JSON: `{"tag_name":5}`}, + Method: "POST", Path: "/api/llamacpp/prebuilt/check"}, + {Name: "release sans asset adapté", Release: &parityRelease{Status: 200, JSON: relJSON("b301", "llama-b301-bin-macos-arm64.zip")}, + Method: "POST", Path: "/api/llamacpp/prebuilt/check"}, + + // --- job des binaires précompilés --- + {Name: "installation", Release: okRel, Files: map[string]string{ + pb + "llama-b100/llama-server": "#!/bin/sh\n", + pb + "llama-b200/llama-server": "#!/bin/sh\n", + pb + "llama-b30/llama-server": "#!/bin/sh\n", // préfixe de llama-b300 : supprimé quand même + pb + "autre/llama-server": "#!/bin/sh\n", + pb + "VERSION": "b100 12.4 fmt2\n", + }, Method: "POST", Path: "/api/llamacpp/prebuilt", Wait: true}, + {Name: "statut après installation", Method: "GET", Path: "/api/llamacpp"}, + {Name: "release à jour", Method: "POST", Path: "/api/llamacpp/prebuilt/check"}, + {Name: "lignes à partir de 3", Method: "GET", Path: "/api/llamacpp/job?from=3"}, + {Name: "lignes au-delà", Method: "GET", Path: "/api/llamacpp/job?from=99"}, + {Name: "lignes, from négatif", Method: "GET", Path: "/api/llamacpp/job?from=-4"}, + {Name: "lignes, from illisible", Method: "GET", Path: "/api/llamacpp/job?from=abc"}, + {Name: "lignes, from démesuré", Method: "GET", Path: "/api/llamacpp/job?from=99999999999999999999"}, + {Name: "déjà à jour", Files: map[string]string{pb + "llama-b250/x": ""}, + Method: "POST", Path: "/api/llamacpp/prebuilt", Wait: true}, + {Name: "ancien format : réextrait", Files: map[string]string{pb + "VERSION": "b300 \n"}, + Method: "POST", Path: "/api/llamacpp/prebuilt", Wait: true}, + {Name: "pendant une optimisation", Tune: true, Method: "POST", Path: "/api/llamacpp/prebuilt"}, + {Name: "GitHub refuse", Release: &parityRelease{Status: 403, JSON: `{}`}, + Method: "POST", Path: "/api/llamacpp/prebuilt", Wait: true}, + {Name: "rien d'adapté", Release: &parityRelease{Status: 200, JSON: relJSON("b302", "llama-b302-bin-macos-arm64.zip")}, + Method: "POST", Path: "/api/llamacpp/prebuilt", Wait: true}, + {Name: "archive absente", Release: &parityRelease{Status: 200, JSON: relJSON("b303", allAssets...)}, + Method: "POST", Path: "/api/llamacpp/prebuilt", Wait: true}, + {Name: "archive piégée", Release: &parityRelease{Status: 200, JSON: relJSON("b304", allAssets...), + Assets: map[string][]parityArchEntry{cpu: { + {Name: "llama-b304/ok", Type: "file", Body: "x", Mode: 0o644}, + {Name: "../evasion", Type: "file", Body: "x", Mode: 0o644}, + }}}, Method: "POST", Path: "/api/llamacpp/prebuilt", Wait: true}, + {Name: "archive sans serveur", Release: &parityRelease{Status: 200, JSON: relJSON("b305", allAssets...), + Assets: map[string][]parityArchEntry{cpu: { + {Name: "llama-b305/llama-cli", Type: "file", Body: "x", Mode: 0o755}, + }}}, Files: map[string]string{pb: "-"}, Method: "POST", Path: "/api/llamacpp/prebuilt", Wait: true}, + {Name: "statut après échecs", Method: "GET", Path: "/api/llamacpp"}, + + // --- GPU vus par un moteur --- + {Name: "GPU sans moteur", Config: map[string]string{"BIN": ""}, Method: "POST", Path: "/api/backends/devices", Body: `{}`}, + {Name: "GPU d'un moteur absent", Method: "POST", Path: "/api/backends/devices", Body: `{"bin":"/nulle/part/llama-server"}`}, + {Name: "GPU : pas un llama-server", Files: map[string]string{"outils/autre": "#!/bin/sh\n"}, + Method: "POST", Path: "/api/backends/devices", Body: `{"bin":"{{HOME}}/outils/autre"}`}, + {Name: "GPU listés", Machine: "nvidia", Files: map[string]string{ + "gpu/LLAMA-SERVER": "#!/bin/sh\n[ \"$1\" = --list-devices ] || exit 7\necho 'Available devices:'\necho \" CUDA0: NVIDIA GeForce RTX 3090 (0 MiB, 0 MiB free)\"\necho \" CUDA1: Carte B (8192 MiB, 8000 MiB free)\"\necho \" Vulkan0: NVIDIA GeForce RTX 3090 (24576 MiB, 20000 MiB free)\"\necho \" CUDA2: ordre ${CUDA_DEVICE_ORDER:-aucun} cvd ${CUDA_VISIBLE_DEVICES:-aucun} (1 MiB, 1 MiB free)\" >&2\n", + }, Method: "POST", Path: "/api/backends/devices", Body: `{"bin":" {{HOME}}/gpu/LLAMA-SERVER "}`}, + {Name: "GPU en cache", Files: map[string]string{"gpu/LLAMA-SERVER": "#!/bin/sh\necho ' CUDA0: changé (1 MiB, 1 MiB free)'\n"}, + Method: "POST", Path: "/api/backends/devices", Body: `{"bin":"{{HOME}}/gpu/LLAMA-SERVER"}`}, + {Name: "GPU filtrés", Config: map[string]string{"CUDA_VISIBLE_DEVICES": "1,0", "BIN": "{{HOME}}/gpu/LLAMA-SERVER"}, + Files: map[string]string{"gpu/LLAMA-SERVER": "#!/bin/sh\necho \" CUDA0: ordre $CUDA_DEVICE_ORDER cvd $CUDA_VISIBLE_DEVICES (10 MiB, 5 MiB free)\"\n"}, + Method: "POST", Path: "/api/backends/devices", Body: `{"bin":""}`}, + {Name: "GPU : mémoire nulle non figée", Machine: "nu", Config: map[string]string{"CUDA_VISIBLE_DEVICES": "0"}, + Files: map[string]string{"gpu/LLAMA-SERVER": "#!/bin/sh\necho ' CUDA0: Saturée (0 MiB, 0 MiB free)'\n"}, + Method: "POST", Path: "/api/backends/devices", Body: `{}`}, + {Name: "GPU : relu, pas en cache", Files: map[string]string{"gpu/LLAMA-SERVER": "#!/bin/sh\necho ' CUDA0: Saturée (100 MiB, 0 MiB free)'\n"}, + Method: "POST", Path: "/api/backends/devices", Body: `{}`}, + {Name: "GPU : moteur planté, repli disque", Config: map[string]string{"CUDA_VISIBLE_DEVICES": "1,0"}, + Files: map[string]string{"gpu/llama-server": "#!/bin/sh\necho ' CUDA0: partielle (1 MiB, 1 MiB free)'\nexit 3\n"}, + Method: "POST", Path: "/api/backends/devices", Body: `{"bin":"{{HOME}}/gpu/llama-server"}`}, + {Name: "GPU : planté, partiel", Config: map[string]string{"CUDA_VISIBLE_DEVICES": "2"}, + Method: "POST", Path: "/api/backends/devices", Body: `{"bin":"{{HOME}}/gpu/llama-server"}`}, + {Name: "GPU : planté, rien", Config: map[string]string{"CUDA_VISIBLE_DEVICES": "3"}, + Files: map[string]string{"gpu/llama-server": "#!/bin/sh\necho 'CUDA error: out of memory'\nexit 4\n"}, + Method: "POST", Path: "/api/backends/devices", Body: `{"bin":"{{HOME}}/gpu/llama-server"}`}, + {Name: "GPU : tué", Config: map[string]string{"CUDA_VISIBLE_DEVICES": "4"}, + Files: map[string]string{"gpu/llama-server": "#!/bin/sh\nkill -9 $$\n"}, + Method: "POST", Path: "/api/backends/devices", Body: `{"bin":"{{HOME}}/gpu/llama-server"}`}, + {Name: "GPU : non exécutable", Config: map[string]string{"CUDA_VISIBLE_DEVICES": "5"}, + Files: map[string]string{"gpu/llama-server": "pas un script"}, + Method: "POST", Path: "/api/backends/devices", Body: `{"bin":"{{HOME}}/gpu/llama-server"}`}, + {Name: "GPU : corps illisible, BIN de la config", Config: map[string]string{"CUDA_VISIBLE_DEVICES": "6"}, + Files: map[string]string{"gpu/LLAMA-SERVER": "#!/bin/sh\necho ' CUDA0: Lue (2 MiB, 1 MiB free)'\n"}, + Method: "POST", Path: "/api/backends/devices", Body: `{"bin":`}, + {Name: "GPU : pilote ancien", Machine: "arch-ancienne", Config: map[string]string{"CUDA_VISIBLE_DEVICES": "7"}, + Files: map[string]string{"gpu/LLAMA-SERVER": "#!/bin/sh\necho ' CUDA0: Vieille carte (0 MiB, 0 MiB free)'\necho ' CUDA1: Vieille carte (0 MiB, 0 MiB free)'\n"}, + Method: "POST", Path: "/api/backends/devices", Body: `{}`}, + {Name: "GPU : deux cartes de même nom", Machine: "nvidia", Config: map[string]string{"CUDA_VISIBLE_DEVICES": "8"}, + Files: map[string]string{"gpu/LLAMA-SERVER": "#!/bin/sh\necho ' CUDA0: Carte B (0 MiB, 0 MiB free)'\n"}, + Method: "POST", Path: "/api/backends/devices", Body: `{}`}, + {Name: "GPU : lu à vide, gardé sur disque", Machine: "nu", Config: map[string]string{"CUDA_VISIBLE_DEVICES": "9"}, + Files: map[string]string{"gpu/LLAMA-SERVER": "#!/bin/sh\necho ' CUDA0: Pleine (0 MiB, 0 MiB free)'\n"}, + Method: "POST", Path: "/api/backends/devices", Body: `{}`}, + {Name: "GPU : cartes jumelles chez nvidia-smi", Machine: "nvidia-jumelles", Config: map[string]string{"CUDA_VISIBLE_DEVICES": "10"}, + Files: map[string]string{"gpu/LLAMA-SERVER": "#!/bin/sh\necho ' CUDA0: Carte B (0 MiB, 0 MiB free)'\n"}, + Method: "POST", Path: "/api/backends/devices", Body: `{}`}, + {Name: "GPU : liaison sans génération connue", Machine: "liaison-partielle", Config: map[string]string{"CUDA_VISIBLE_DEVICES": "11"}, + Files: map[string]string{"gpu/LLAMA-SERVER": "#!/bin/sh\necho ' CUDA0: Carte P (4096 MiB, 4000 MiB free)'\n"}, + Method: "POST", Path: "/api/backends/devices", Body: `{}`}, + {Name: "GPU : liste vide sur le disque, pas de repli", Machine: "nu", Config: map[string]string{"CUDA_VISIBLE_DEVICES": "vide"}, + Files: map[string]string{ + "devices.json": `{"{{HOME}}/gpu/LLAMA-SERVER\u0000vide": []}`, + "gpu/LLAMA-SERVER": "#!/bin/sh\nexit 3\n", + }, Method: "POST", Path: "/api/backends/devices", Body: `{}`}, + {Name: "GPU : planté, repli sur le disque", + Files: map[string]string{"gpu/LLAMA-SERVER": "#!/bin/sh\necho ' CUDA0: partielle (1 MiB, 1 MiB free)'\nexit 3\n"}, + Method: "POST", Path: "/api/backends/devices", Body: `{}`}, + } { + steps = append(steps, run(st)) + } + + // --- plan de build, conseil et release, machine par machine --- + type planCase struct { + Machine string `json:"machine"` + Plan any `json:"plan"` + Reco any `json:"reco"` + Check any `json:"check"` + } + var plans []planCase + machineNames := make([]string, 0, len(parityMachines)) + for n := range parityMachines { + machineNames = append(machineNames, n) + } + sort.Strings(machineNames) + planRel := parityRelease{Status: 200, JSON: relJSON("b400", allAssets...)} + mu.Lock() + rel = planRel + mu.Unlock() + for _, n := range machineNames { + machine(n) + _, out := call("GET", "/api/llamacpp", "") + m := out.(map[string]any) + _, check := call("POST", "/api/llamacpp/prebuilt/check", "") + plans = append(plans, planCase{Machine: n, Plan: m["plan"], Reco: m["reco"], Check: check}) + } + machine("vulkaninfo-seul") + sansVulkanRel := parityRelease{Status: 200, JSON: relJSON("b401", "llama-b401-bin-ubuntu-rocm-7.0-x64.tar.gz", "llama-b401-bin-ubuntu-x64.tar.gz", "llama-b401-bin-ubuntu-arm64.tar.gz")} + mu.Lock() + rel = sansVulkanRel + mu.Unlock() + _, sansVulkan := call("POST", "/api/llamacpp/prebuilt/check", "") + machine("rocm") + rocmRel := parityRelease{Status: 200, JSON: relJSON("b402", "llama-b402-bin-ubuntu-vulkan-x64.tar.gz", "llama-b402-bin-ubuntu-x64.tar.gz", "llama-b402-bin-ubuntu-vulkan-arm64.tar.gz", "llama-b402-bin-ubuntu-arm64.tar.gz")} + mu.Lock() + rel = rocmRel + mu.Unlock() + _, rocmSansAsset := call("POST", "/api/llamacpp/prebuilt/check", "") + + got, err := parityJSON(map[string]any{ + "goarch": runtime.GOARCH, + "machines": parityMachines, + "steps": steps, + "plans": plans, + "plan_release": planRel, + "sans_vulkan": map[string]any{"release": sansVulkanRel, "out": sansVulkan}, + "rocm_sans_asset": map[string]any{"release": rocmRel, "out": rocmSansAsset}, + "job": parityJobScript(t, home), + }) + if err != nil { + t.Fatal(err) + } + if os.Getenv("LOKI_PARITY_UPDATE") == "1" { + if err := os.MkdirAll(filepath.Dir(parityBackendsFile), 0o755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(parityBackendsFile, got, 0o644); err != nil { + t.Fatal(err) + } + return + } + want, err := os.ReadFile(parityBackendsFile) + 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 backends ne produisent plus le journal rangé dans %s.\n"+ + "Si c'est voulu : LOKI_PARITY_UPDATE=1 go test ./internal/loki -run TestPariteBackends,\n"+ + "puis cargo test --test backends dit si le port Rust suit.\n--- obtenu ---\n%s", parityBackendsFile, got) + } +} + +// resetBackendsState : pas de job ni de GPU en cache d'un test à l'autre. +func resetBackendsState() { + lcMu.Lock() + lcCur = nil + lcLastSave = time.Time{} + lcMu.Unlock() + setBuildSink(nil) + devCacheMu.Lock() + devCache = map[string]devCacheEntry{} + devCacheMu.Unlock() +} + +// parityApplyFiles pose les fichiers d'une étape, dans l'ordre des noms. +func parityApplyFiles(t *testing.T, home string, files map[string]string) { + t.Helper() + names := make([]string, 0, len(files)) + for n := range files { + names = append(names, n) + } + sort.Strings(names) + for _, n := range names { + v := files[n] + p := filepath.Join(home, n) + switch { + case v == "-": + if err := os.RemoveAll(p); err != nil { + t.Fatal(err) + } + continue + case v == "/": + 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 target, ok := strings.CutPrefix(v, "-> "); ok { + _ = os.Remove(p) + if err := os.Symlink(target, p); err != nil { + t.Fatal(err) + } + continue + } + mode := os.FileMode(0o644) + if strings.HasPrefix(v, "#!") { + mode = 0o755 + } + // Remplacé, pas réécrit en place : un script qui vient de tourner ne + // gêne pas l'écriture (ETXTBSY). + _ = os.Remove(p) + if err := os.WriteFile(p, []byte(v), mode); err != nil { + t.Fatal(err) + } + } +} + +// parityGit : les opérations git d'une étape, dates et identité fixées pour +// des commits reproductibles. +func parityGit(t *testing.T, gitBin, home, op string) { + t.Helper() + f := strings.SplitN(op, " ", 4) + gitIn := func(dir, date string, args ...string) { + cmd := exec.Command(gitBin, args...) + cmd.Dir = dir + cmd.Env = append(os.Environ(), "GIT_CONFIG_GLOBAL=/dev/null", "GIT_CONFIG_NOSYSTEM=1", + "GIT_AUTHOR_NAME=Loki", "GIT_AUTHOR_EMAIL=loki@exemple.fr", + "GIT_COMMITTER_NAME=Loki", "GIT_COMMITTER_EMAIL=loki@exemple.fr", + "GIT_AUTHOR_DATE="+date, "GIT_COMMITTER_DATE="+date) + if out, err := cmd.CombinedOutput(); err != nil { + t.Fatalf("git %v : %v\n%s", args, err, out) + } + } + switch f[0] { + case "init": + dir := filepath.Join(home, f[1]) + if err := os.MkdirAll(dir, 0o755); err != nil { + t.Fatal(err) + } + gitIn(dir, "", "init", "-q", "-b", "master") + case "commit": + dir := filepath.Join(home, f[1]) + if err := os.WriteFile(filepath.Join(dir, "README"), []byte(f[3]), 0o644); err != nil { + t.Fatal(err) + } + gitIn(dir, f[2], "add", "README") + gitIn(dir, f[2], "commit", "-q", "-m", f[3]) + case "clone": + gitIn(home, "", "clone", "-q", filepath.Join(home, f[1]), filepath.Join(home, f[2])) + case "pull": + gitIn(filepath.Join(home, f[1]), "", "pull", "-q", "--ff-only") + case "detach": + gitIn(filepath.Join(home, f[1]), "", "checkout", "-q", "--detach") + case "remote-bad": + gitIn(filepath.Join(home, f[1]), "", "remote", "set-url", "origin", filepath.Join(home, "nulle-part")) + default: + t.Fatalf("opération git inconnue : %s", op) + } +} + +// parityTarGz construit une archive .tar.gz. +func parityTarGz(t *testing.T, entries []parityArchEntry) []byte { + var buf bytes.Buffer + gz := gzip.NewWriter(&buf) + tw := tar.NewWriter(gz) + for _, e := range entries { + h := &tar.Header{Name: e.Name, Mode: e.Mode, ModTime: time.Unix(1700000000, 0)} + switch e.Type { + case "dir": + h.Typeflag = tar.TypeDir + case "symlink": + h.Typeflag, h.Linkname, h.Mode = tar.TypeSymlink, e.Link, 0o777 + case "hardlink": + h.Typeflag, h.Linkname = tar.TypeLink, e.Link + default: + h.Typeflag, h.Size = tar.TypeReg, int64(len(e.Body)) + } + if err := tw.WriteHeader(h); err != nil { + t.Error(err) + } + if h.Typeflag == tar.TypeReg { + _, _ = tw.Write([]byte(e.Body)) + } + } + _ = tw.Close() + _ = gz.Close() + return buf.Bytes() +} + +// parityTree : ce qu'il y a sous backends/ et engine/ (« d », « f mode », « l +// cible »), trié. +func parityTree(t *testing.T, home string) []string { + var out []string + for _, root := range []string{"backends", "engine"} { + _ = filepath.WalkDir(filepath.Join(home, root), func(p string, d fs.DirEntry, err error) error { + if err != nil { + return nil + } + rel, _ := filepath.Rel(home, p) + if strings.Contains(rel, "/.git/") || strings.HasSuffix(rel, "/.git") { + if d.IsDir() { + return filepath.SkipDir + } + return nil + } + info, err := os.Lstat(p) + if err != nil { + return nil + } + switch { + case info.Mode()&os.ModeSymlink != 0: + target, _ := os.Readlink(p) + out = append(out, "l "+rel+" -> "+target) + case info.IsDir(): + out = append(out, "d "+rel) + default: + b, _ := os.ReadFile(p) + out = append(out, "f "+rel+" "+info.Mode().Perm().String()+" "+string(b)) + } + return nil + }) + } + sort.Strings(out) + return out +} + +// parityJobScript rejoue le job pas à pas (sans la façade) : début, lignes +// de build (phases déduites, fichiers compilés), troncature à lcMaxLines, +// phases, commits, échec, fin, refus d'un second job, vues à partir d'un +// décalage, état écrit en base et journal ; puis la reprise au démarrage. +func parityJobScript(t *testing.T, home string) any { + resetBackendsState() + _ = os.Remove(lcJobLogPath()) + type op struct { + Op string `json:"op"` + Arg string `json:"arg,omitempty"` + From int `json:"from,omitempty"` + Out any `json:"out,omitempty"` + } + tsRe := regexp.MustCompile(`"(started_at|ended_at)":[1-9][0-9]*`) + norm := func(v any) any { + b, _ := json.Marshal(v) + var out any + _ = json.Unmarshal([]byte(tsRe.ReplaceAllString(string(b), `"$1":"{{T}}"`)), &out) + return out + } + var ops []op + done := make(chan struct{}) + release := make(chan struct{}) + do := func(o op) { + switch o.Op { + case "start": + err := startLcJob(o.Arg, func() { <-release; close(done) }) + if err != nil { + o.Out = err.Error() + } else { + o.Out = "ok" + } + case "line": + lcAppend(o.Arg) + case "lines": + for i := 0; i < 4002; i++ { + lcAppend("[" + o.Arg + "] bruit") + } + case "phase": + lcPhase(o.Arg) + case "fail": + lcFail(errors.New(o.Arg)) + case "done": + lcDone(o.Arg) + case "old", "new": + lcSetCommit(o.Op, o.Arg) + case "end": + close(release) + <-done + deadline := time.Now().Add(10 * time.Second) + for lcJobSnapshot(0, false)["running"] == true && time.Now().Before(deadline) { + time.Sleep(10 * time.Millisecond) + } + case "snapshot": + o.Out = norm(lcJobSnapshot(o.From, true)) + case "summary": // vue longue résumée : nombre de lignes, première, dernière + v := lcJobSnapshot(o.From, true) + lines, _ := v["lines"].([]string) + v["lines"] = map[string]any{"n": len(lines), "first": lines[0], "last": lines[len(lines)-1]} + o.Out = norm(v) + case "head": + o.Out = norm(lcJobSnapshot(0, false)) + case "state": + o.Out = tsRe.ReplaceAllString(getStr(bkState, "build_job"), `"$1":"{{T}}"`) + case "log": + b, _ := os.ReadFile(lcJobLogPath()) + o.Out = string(b) + } + ops = append(ops, o) + } + for _, o := range []op{ + {Op: "head"}, + {Op: "line", Arg: "avant tout job"}, + {Op: "start", Arg: "update"}, + {Op: "state"}, + {Op: "start", Arg: "engine"}, + {Op: "line", Arg: "-- The C compiler identification is GNU 13.2.0 avec un très long suffixe é"}, + {Op: "head"}, + {Op: "line", Arg: "[ 12%] Building CXX object ggml/src/CMakeFiles/ggml.dir/ggml.cpp.o"}, + {Op: "line", Arg: " Compiling CUDA source file ..\\ggml-cuda\\fattn.cu..."}, + {Op: "line", Arg: "softmax.cuh"}, + {Op: "line", Arg: "[100%] Linking CXX executable bin/llama-server"}, + {Op: "line", Arg: "état : é.cpp"}, + {Op: "head"}, + {Op: "old", Arg: "abc1234"}, + {Op: "phase", Arg: "git pull origin/master…"}, + {Op: "new", Arg: ""}, + {Op: "state"}, + {Op: "line", Arg: "-- Configuring done"}, // moins de 2 s après la dernière écriture : pas réécrit + {Op: "state"}, + {Op: "snapshot", From: 2}, + {Op: "lines", Arg: "bloc"}, + {Op: "snapshot", From: 4005}, + {Op: "summary", From: 0}, + {Op: "summary", From: 3}, + {Op: "line", Arg: "Build files have been written to: /src/build"}, + {Op: "summary", From: 0}, + {Op: "line", Arg: "une de plus"}, + {Op: "summary", From: 0}, + {Op: "fail", Arg: "compilation échouée: exit status 2"}, + {Op: "done", Arg: "tout de même"}, + {Op: "end"}, + {Op: "head"}, + {Op: "state"}, + } { + do(o) + } + logLines := strings.Count(func() string { b, _ := os.ReadFile(lcJobLogPath()); return string(b) }(), "\n") + + // Reprise au démarrage : un job noté « en cours » est mort avec le + // processus précédent ; un job fini est rendu tel quel. + type restoreCase struct { + State string `json:"state"` + Log *string `json:"log"` + Out any `json:"out"` + After string `json:"after"` + } + str := func(s string) *string { return &s } + var restores []restoreCase + for _, rc := range []restoreCase{ + {State: ``}, + {State: `{"action":""}`}, + {State: `{"action":"prebuilt","phase":"fini","running":false,"started_at":5,"ended_at":6}`, Log: str("a\nb\n")}, + {State: `{"action":"update","phase":"compilation… 3 fichiers","compiled":3,"running":true,"started_at":5,"old":"abc"}`, Log: str("x\n")}, + {State: `{"action":"strata","running":true}`}, + {State: `{"action":"engine","running":false}`, Log: str("")}, + {State: `{"action":"engine","running":false}`, Log: str("\n\n")}, + {State: `pas du json`}, + {State: `{"action":"engine","compiled":"3"}`}, + } { + resetBackendsState() + if rc.State == "" { + _ = putStr(bkState, "build_job", "") + } else if err := putStr(bkState, "build_job", rc.State); err != nil { + t.Fatal(err) + } + _ = os.Remove(lcJobLogPath()) + if rc.Log != nil { + if err := os.WriteFile(lcJobLogPath(), []byte(*rc.Log), 0o644); err != nil { + t.Fatal(err) + } + } + lcRestore() + rc.Out = norm(lcJobSnapshot(0, true)) + rc.After = tsRe.ReplaceAllString(getStr(bkState, "build_job"), `"$1":"{{T}}"`) + restores = append(restores, rc) + } + // Journal de plus de lcMaxLines lignes. + resetBackendsState() + var long strings.Builder + for i := 0; i < lcMaxLines+3; i++ { + long.WriteString("ligne\n") + } + _ = putStr(bkState, "build_job", `{"action":"update","running":false}`) + _ = os.WriteFile(lcJobLogPath(), []byte(long.String()), 0o644) + lcRestore() + snap := lcJobSnapshot(lcMaxLines, true) + resetBackendsState() + _ = putStr(bkState, "build_job", "") + _ = os.Remove(lcJobLogPath()) + return map[string]any{"ops": ops, "log_lines": logLines, "restores": restores, "long_restore": norm(snap)} +} diff --git a/internal/loki/web_llamacpp.go b/internal/loki/web_llamacpp.go index 3ab3141..7b2085c 100644 --- a/internal/loki/web_llamacpp.go +++ b/internal/loki/web_llamacpp.go @@ -50,6 +50,10 @@ const lcMaxLines = 4000 // compteur de fichiers compilés à partir du contenu (mêmes heuristiques que le // spinner du CLI). Appelée par le sink de build (emitBuildLine). func lcAppend(line string) { + if facadeBackends() { + facadeJobNote("line", map[string]any{"line": line}) + return + } lcMu.Lock() defer lcMu.Unlock() if lcCur == nil { @@ -74,6 +78,10 @@ func lcAppend(line string) { // lcPhase pose une phase explicite (étapes hors build : clone, fetch, service…) // et la trace aussi dans le log. func lcPhase(phase string) { + if facadeBackends() { + facadeJobNote("phase", map[string]any{"phase": phase}) + return + } lcMu.Lock() if lcCur != nil { lcCur.Phase = phase @@ -91,6 +99,9 @@ func startLcJob(action string, run func()) error { if err := tuneGuard(); err != nil { return err } + if facadeBackends() { // le job est tenu par la façade (facade_backends.go) + return facadeJobStart(action, run) + } lcMu.Lock() defer lcMu.Unlock() if lcCur != nil && lcCur.Running { @@ -115,6 +126,10 @@ func startLcJob(action string, run func()) error { } func lcFail(err error) { + if facadeBackends() { + facadeJobNote("fail", map[string]any{"error": err.Error()}) + return + } lcMu.Lock() if lcCur != nil { lcCur.Err = err.Error() @@ -127,6 +142,10 @@ func lcFail(err error) { } func lcDone(msg string) { + if facadeBackends() { + facadeJobNote("done", map[string]any{"msg": msg}) + return + } lcMu.Lock() if lcCur != nil { lcCur.Phase = msg @@ -476,6 +495,9 @@ func handleLlamacppJob(w http.ResponseWriter, r *http.Request) { // lcJobSnapshot construit la vue JSON du job courant. withLines=false renvoie // juste l'entête (imbriquée dans /api/llamacpp). func lcJobSnapshot(from int, withLines bool) map[string]any { + if facadeBackends() { + return facadeJobSnapshot(from, withLines) + } lcMu.Lock() defer lcMu.Unlock() if lcCur == nil { @@ -576,9 +598,7 @@ func lcRunUpdate(clean bool) { } oldCommit := gitOutput(repo, "rev-parse", "--short", "HEAD") - lcMu.Lock() - lcCur.OldCommit = oldCommit - lcMu.Unlock() + lcSetCommit("old", oldCommit) branch := gitOutput(repo, "rev-parse", "--abbrev-ref", "HEAD") if branch == "" || branch == "HEAD" { @@ -604,9 +624,7 @@ func lcRunUpdate(clean bool) { } } newCommit := gitOutput(repo, "rev-parse", "--short", "HEAD") - lcMu.Lock() - lcCur.NewCommit = newCommit - lcMu.Unlock() + lcSetCommit("new", newCommit) // Le binaire en cours d'exécution ne peut pas être réécrit → stop du service // pendant le build, redémarrage après (même en échec). diff --git a/internal/loki/web_server.go b/internal/loki/web_server.go index aaef0c7..ac2297b 100644 --- a/internal/loki/web_server.go +++ b/internal/loki/web_server.go @@ -143,7 +143,9 @@ func newWebMux() *http.ServeMux { // Idem pour une installation de moteur : elle meurt avec le process. On // recharge son état pour l'annoncer « interrompue » au lieu de n'afficher // plus rien du tout. - lcRestoreOnce.Do(lcRestore) + if !facadeBackends() { // sinon la façade a repris le job (facade_backends.go) + lcRestoreOnce.Do(lcRestore) + } // Chiffrement de la mémoire : une migration interrompue (coupure de courant // en plein chiffrement) reprend ici, si la clé est disponible. Sans clé, elle // attend le déverrouillage (voir handleMemUnlock). Idempotent. diff --git a/rust/loki-rs/src/api.rs b/rust/loki-rs/src/api.rs index d3a5567..b887caa 100644 --- a/rust/loki-rs/src/api.rs +++ b/rust/loki-rs/src/api.rs @@ -18,6 +18,7 @@ use crate::{engstatus, gguf, paths, presets, sysinfo}; use std::collections::BTreeMap; /// Ce dont les routes ont besoin, partagé par toutes les connexions. +#[derive(Clone)] pub struct Ctx { pub store: Store, pub internal: InternalClient, @@ -52,6 +53,9 @@ pub struct Ctx { pub mcp: Option>, /// Mise à jour de Loki tenue par la façade (`LOKI_UPDATE=rust`). pub update: bool, + /// Backends llama.cpp et job des moteurs tenus par la façade + /// (`LOKI_BACKENDS=rust`). + pub backends: Option>, /// Jeton de l'API interne : une requête qui le porte vient du Go. pub internal_token: String, } @@ -73,6 +77,7 @@ impl Ctx { || self.projects || self.mcp.is_some() || self.update + || self.backends.is_some() } /// La requête vient du Go (jeton interne valide) : déjà authentifiée par diff --git a/rust/loki-rs/src/auth.rs b/rust/loki-rs/src/auth.rs index 1beee73..284dc91 100644 --- a/rust/loki-rs/src/auth.rs +++ b/rust/loki-rs/src/auth.rs @@ -32,6 +32,7 @@ pub struct AuthInfo { } /// Client de l'API interne du Go. +#[derive(Clone)] pub struct InternalClient { client: Client>, backend: SocketAddr, diff --git a/rust/loki-rs/src/backends/devices.rs b/rust/loki-rs/src/backends/devices.rs new file mode 100644 index 0000000..96541df --- /dev/null +++ b/rust/loki-rs/src/backends/devices.rs @@ -0,0 +1,327 @@ +//! `/api/backends/devices` : les GPU tels que les voit UN moteur +//! (`llama-server --list-devices`), port de `web_devices.go` — caches +//! mémoire (10 min) et disque (`devices.json`, dernière énumération réussie). + +use std::collections::{BTreeMap, HashMap}; +use std::path::{Path, PathBuf}; +use std::process::Stdio; +use std::sync::{LazyLock, Mutex}; +use std::time::{Duration, Instant}; + +use regex::Regex; +use serde_json::{Map, Value, json}; + +use super::probe::{GpuInfo, Probe}; + +/// `devCacheTTL`. +const CACHE_TTL: Duration = Duration::from_secs(600); + +/// Délai de l'énumération. +const LIST_TIMEOUT: Duration = Duration::from_secs(20); + +// `\s` du Go : blancs ASCII seulement. +static DEVICE_LINE: LazyLock = LazyLock::new(|| { + Regex::new( + &r"^\s*([A-Za-z]+[0-9]+):\s*(.+?)\s*\(([0-9]+)\s*MiB,\s*([0-9]+)\s*MiB free\)\s*$" + .replace(r"\s", r"[\t\n\f\r ]"), + ) + .expect("motif") +}); + +/// `parseListDevices`. +pub fn parse_list_devices(out: &str) -> Vec> { + let mut devs = Vec::new(); + for line in out.split('\n') { + let Some(m) = DEVICE_LINE.captures(line.trim_end_matches('\r')) else { + continue; + }; + let mut d = Map::new(); + d.insert("id".into(), json!(&m[1])); + d.insert("name".into(), json!(&m[2])); + d.insert("total_mib".into(), json!(atoi(&m[3]))); + d.insert("free_mib".into(), json!(atoi(&m[4]))); + devs.push(d); + } + devs +} + +/// `strconv.Atoi` d'une suite de chiffres : 0 si elle déborde. +fn atoi(s: &str) -> i64 { + s.parse().unwrap_or(0) +} + +/// Mémoire totale annoncée (`d["total_mib"].(int)`) : seuls les entiers +/// issus de l'énumération comptent ; 0 sinon. +fn total_mib(d: &Map) -> i64 { + d.get("total_mib").and_then(Value::as_i64).unwrap_or(0) +} + +/// `annotateDevices` : mémoire manquante et liaison PCIe, par nom de carte. +pub fn annotate(devs: &mut [Map], gpus: &[GpuInfo]) { + let mut by_name: HashMap = HashMap::new(); + let mut dup: HashMap = HashMap::new(); + for g in gpus { + let name = g.name.trim().to_string(); + if by_name.contains_key(&name) { + dup.insert(name, true); + continue; + } + by_name.insert(name, g); + } + for d in devs.iter_mut() { + let name = d + .get("name") + .and_then(Value::as_str) + .unwrap_or("") + .trim() + .to_string(); + let Some(g) = by_name.get(&name) else { + continue; + }; + if dup.contains_key(&name) { + continue; + } + if total_mib(d) <= 0 + && let Ok(mb) = g.mem_total.trim().parse::() + { + d.insert("total_mib".into(), json!(mb)); + } + if !d + .get("id") + .and_then(Value::as_str) + .unwrap_or("") + .starts_with("CUDA") + { + continue; + } + if g.link_gen_max == 0 && g.link_width_max == 0 && g.mem_clock_max == 0 { + continue; + } + d.insert( + "link".into(), + json!({ + "gen": g.link_gen, "gen_max": g.link_gen_max, + "width": g.link_width, "width_max": g.link_width_max, + "mem_clock_mhz": g.mem_clock_max, + }), + ); + } +} + +/// `hasZeroMemory`. +pub fn has_zero_memory(devs: &[Map]) -> bool { + devs.iter().any(|d| total_mib(d) <= 0) +} + +/// Une énumération en cache, et quand. +type Cached = (Vec>, Instant); + +/// Les deux caches de l'énumération. +pub struct Devices { + home: PathBuf, + mem: Mutex>, + persist: Mutex<()>, +} + +impl Devices { + pub fn new(home: PathBuf) -> Self { + Devices { + home, + mem: Mutex::new(HashMap::new()), + persist: Mutex::new(()), + } + } + + fn cache_get(&self, key: &str) -> Option>> { + let m = self.mem.lock().unwrap_or_else(|e| e.into_inner()); + m.get(key) + .filter(|(_, at)| at.elapsed() <= CACHE_TTL) + .map(|(d, _)| d.clone()) + } + + fn cache_put(&self, key: &str, devs: &[Map]) { + self.mem + .lock() + .unwrap_or_else(|e| e.into_inner()) + .insert(key.into(), (devs.to_vec(), Instant::now())); + } + + /// `devPersistPath`. + pub fn persist_path(&self) -> PathBuf { + self.home.join("devices.json") + } + + fn persist_load(&self) -> BTreeMap { + std::fs::read(self.persist_path()) + .ok() + .and_then(|b| serde_json::from_slice::>(&b).ok()) + .unwrap_or_default() + } + + fn persist_get(&self, key: &str) -> Option { + let _g = self.persist.lock().unwrap_or_else(|e| e.into_inner()); + self.persist_load() + .remove(key) + .filter(|d| d.as_array().is_some_and(|a| !a.is_empty())) + } + + fn persist_put(&self, key: &str, devs: &[Map]) { + let _g = self.persist.lock().unwrap_or_else(|e| e.into_inner()); + let mut m = self.persist_load(); + m.insert(key.into(), json!(devs)); + let _ = std::fs::write(self.persist_path(), crate::chatx::marshal_indent(&m)); + } + + /// `handleBackendDevices`, une fois `bin` résolu et vérifié : la réponse. + pub async fn list(&self, probe: &Probe, bin: &str, cvd: &str) -> Value { + let key = format!("{bin}\x00{cvd}"); + if let Some(devs) = self.cache_get(&key) { + return json!({ "ok": true, "devices": devs }); + } + let (out, err) = list_devices(bin, cvd).await; + let mut devs = parse_list_devices(&out); + if let Some(err) = err { + if let Some(good) = self.persist_get(&key) { + return json!({ "ok": true, "devices": good, "stale": true }); + } + if devs.is_empty() { + return json!({ "ok": false, "error": format!("le moteur n'a pas répondu : {err}") }); + } + return json!({ "ok": true, "devices": devs, "stale": true }); + } + if let Ok(gpus) = probe.detect_gpus().await { + annotate(&mut devs, &gpus); + } + if !has_zero_memory(&devs) { + self.cache_put(&key, &devs); + } + if !devs.is_empty() { + self.persist_put(&key, &devs); + } + json!({ "ok": true, "devices": devs }) + } +} + +/// `bin --list-devices`, sortie standard et d'erreur mêlées ; l'erreur au +/// format d'`exec` (`exit status N`, `signal: killed`…). +async fn list_devices(bin: &str, cvd: &str) -> (String, Option) { + let dir = crate::paths::go_dir(bin); + let env = crate::serve::run::Env::default(); + let (k, v) = crate::serve::run::library_path_value(&env, &dir); + let mut c = tokio::process::Command::new(bin); + c.arg("--list-devices").env(k, v); + if !cvd.is_empty() { + c.env("CUDA_VISIBLE_DEVICES", cvd) + .env("CUDA_DEVICE_ORDER", "PCI_BUS_ID"); + } + // Sortie standard et d'erreur dans le même tube, comme CombinedOutput. + let (reader, writer) = match std::io::pipe() { + Ok(p) => p, + Err(e) => return (String::new(), Some(e.to_string())), + }; + let writer2 = match writer.try_clone() { + Ok(w) => w, + Err(e) => return (String::new(), Some(e.to_string())), + }; + c.stdin(Stdio::null()) + .stdout(Stdio::from(writer)) + .stderr(Stdio::from(writer2)) + .kill_on_drop(true); + let mut child = match c.spawn() { + Ok(ch) => ch, + Err(e) => { + return ( + String::new(), + Some(crate::paths::os_err("fork/exec", bin, &e)), + ); + } + }; + drop(c); + let read = tokio::task::spawn_blocking(move || { + let mut r = reader; + let mut buf = Vec::new(); + let _ = std::io::Read::read_to_end(&mut r, &mut buf); + buf + }); + let status = tokio::time::timeout(LIST_TIMEOUT, child.wait()).await; + let status = match status { + Ok(s) => s, + Err(_) => { + let _ = child.start_kill(); + let _ = child.wait().await; + Err(std::io::Error::other("signal: killed")) + } + }; + let out = read.await.unwrap_or_default(); + let out = crate::system::go_lossy(&out); + let err = match status { + Ok(s) if s.success() => None, + Ok(s) => Some(exit_text(s)), + Err(e) => Some(e.to_string()), + }; + (out, err) +} + +/// Texte d'un `*exec.ExitError`. +fn exit_text(s: std::process::ExitStatus) -> String { + if let Some(code) = s.code() { + return format!("exit status {code}"); + } + #[cfg(unix)] + { + use std::os::unix::process::ExitStatusExt; + if let Some(sig) = s.signal() { + return format!("signal: {}", signal_name(sig)); + } + } + s.to_string() +} + +/// Nom d'un signal comme `syscall.Signal.String()`. +#[cfg(unix)] +fn signal_name(sig: i32) -> String { + match sig { + 1 => "hangup".into(), + 2 => "interrupt".into(), + 3 => "quit".into(), + 4 => "illegal instruction".into(), + 6 => "aborted".into(), + 7 => "bus error".into(), + 8 => "floating point exception".into(), + 9 => "killed".into(), + 11 => "segmentation fault".into(), + 13 => "broken pipe".into(), + 15 => "terminated".into(), + n => format!("signal {n}"), + } +} + +/// Le binaire est-il un serveur llama.cpp (garde-fou de la route) ? +pub fn is_llama_server(bin: &str) -> bool { + let base = Path::new(bin) + .file_name() + .map(|b| b.to_string_lossy().to_lowercase()) + .unwrap_or_default(); + base == "llama-server" || base == "llama-server.exe" +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn parse() { + let d = parse_list_devices( + "Available devices:\n CUDA0: NVIDIA GeForce RTX 5060 Ti (15849 MiB, 15579 MiB free)\r\n bruit\n", + ); + assert_eq!(d.len(), 1); + assert_eq!(d[0]["id"], "CUDA0"); + assert_eq!(d[0]["total_mib"], 15849); + } + + #[test] + fn serveur() { + assert!(is_llama_server("/x/LLAMA-SERVER")); + assert!(!is_llama_server("/bin/sh")); + } +} diff --git a/rust/loki-rs/src/backends/job.rs b/rust/loki-rs/src/backends/job.rs new file mode 100644 index 0000000..3928e13 --- /dev/null +++ b/rust/loki-rs/src/backends/job.rs @@ -0,0 +1,414 @@ +//! LE job des moteurs (un seul à la fois) : port de `lcJob` et de sa +//! persistance (`web_llamacpp.go`, `web_llamacpp_job.go`). +//! +//! Quand la façade tient les backends, c'est elle qui détient le job : le +//! sien (binaires précompilés) comme ceux que le Go fait encore tourner +//! (compilation, moteur de l'image, Strata), qui lui rapportent chaque étape +//! par l'API interne (`/internal/facade/v1/build-job/*`). L'entête vit en base +//! (`state/build_job`), les lignes dans `$LOKI_HOME/loki-build.log`. + +use std::io::Write; +use std::path::PathBuf; +use std::sync::LazyLock; +use std::time::{Duration, Instant, SystemTime, UNIX_EPOCH}; + +use regex::Regex; +use serde::{Deserialize, Serialize}; +use serde_json::{Value, json}; +use tokio::sync::Mutex; + +use crate::gojson; +use crate::store::{BK_STATE, Store}; + +/// `lcMaxLines`. +pub const MAX_LINES: usize = 4000; + +/// Clé de l'entête en base. +pub const STATE_KEY: &str = "build_job"; + +/// Progression throttlée : une écriture au plus toutes les 2 s. +const SAVE_EVERY: Duration = Duration::from_secs(2); + +/// Le job courant (`lcJob`). +#[derive(Clone, Debug, Default, PartialEq, Eq)] +pub struct Job { + pub action: String, + pub phase: String, + pub compiled: i64, + pub running: bool, + pub err: String, + pub started_at: i64, + pub ended_at: i64, + pub old_commit: String, + pub new_commit: String, + pub lines: Vec, + pub base: usize, +} + +/// `lcPersisted` : l'entête sur disque, dans l'ordre des champs du Go. +#[derive(Serialize, Deserialize, Default)] +struct Persisted { + #[serde(default)] + action: String, + #[serde(default)] + phase: String, + #[serde(default)] + compiled: i64, + #[serde(default)] + running: bool, + #[serde(default, rename = "error")] + err: String, + #[serde(default)] + started_at: i64, + #[serde(default)] + ended_at: i64, + #[serde(default, rename = "old")] + old_commit: String, + #[serde(default, rename = "new")] + new_commit: String, +} + +struct State { + cur: Option, + last_save: Option, +} + +/// Le détenteur du job. +pub struct Jobs { + home: PathBuf, + state: Mutex, +} + +fn now() -> i64 { + SystemTime::now() + .duration_since(UNIX_EPOCH) + .map_or(0, |d| d.as_secs() as i64) +} + +impl Jobs { + pub fn new(home: PathBuf) -> Self { + Jobs { + home, + state: Mutex::new(State { + cur: None, + last_save: None, + }), + } + } + + /// `lcJobLogPath`. + pub fn log_path(&self) -> PathBuf { + self.home.join("loki-build.log") + } + + /// `lcSaveLine` : une ligne de plus au journal (append). + fn save_line(&self, line: &str) { + if let Ok(mut f) = std::fs::OpenOptions::new() + .create(true) + .append(true) + .open(self.log_path()) + { + let _ = f.write_all(format!("{line}\n").as_bytes()); + } + } + + /// `lcSave` (appelée sous le verrou, comme au Go). + async fn save(&self, st: &mut State, store: &Store, force: bool) { + let Some(j) = &st.cur else { return }; + if !force && st.last_save.is_some_and(|t| t.elapsed() < SAVE_EVERY) { + return; + } + st.last_save = Some(Instant::now()); + let p = Persisted { + action: j.action.clone(), + phase: j.phase.clone(), + compiled: j.compiled, + running: j.running, + err: j.err.clone(), + started_at: j.started_at, + ended_at: j.ended_at, + old_commit: j.old_commit.clone(), + new_commit: j.new_commit.clone(), + }; + if let Err(e) = store + .put(BK_STATE, STATE_KEY, gojson::marshal(&p).into_bytes()) + .await + { + eprintln!("[moteurs] état du job non enregistré : {e}"); + } + } + + /// `startLcJob` (sans le garde de l'optimiseur, posé par l'appelant). + pub async fn start(&self, store: &Store, action: &str) -> Result<(), String> { + let mut st = self.state.lock().await; + if let Some(j) = &st.cur + && j.running + { + return Err(format!("un job {} est déjà en cours", j.action)); + } + st.cur = Some(Job { + action: action.into(), + running: true, + phase: "démarrage…".into(), + started_at: now(), + ..Job::default() + }); + let _ = std::fs::remove_file(self.log_path()); + self.save(&mut st, store, true).await; + Ok(()) + } + + /// Fin du corps du job (le `defer` de `startLcJob`). + pub async fn end(&self, store: &Store) { + let mut st = self.state.lock().await; + if let Some(j) = &mut st.cur { + j.running = false; + j.ended_at = now(); + } + self.save(&mut st, store, true).await; + } + + /// `lcAppend` : une ligne, et la phase qu'elle annonce. + pub async fn append(&self, store: &Store, line: &str) { + let mut st = self.state.lock().await; + let Some(j) = &mut st.cur else { return }; + j.lines.push(line.into()); + if j.lines.len() > MAX_LINES { + let drop = j.lines.len() - MAX_LINES; + j.lines.drain(..drop); + j.base += drop; + } + if !compiled_file(line).is_empty() { + j.compiled += 1; + j.phase = format!("compilation… {} fichiers", j.compiled); + } else { + let p = phase_label(line); + if !p.is_empty() { + j.phase = p; + } + } + self.save_line(line); + self.save(&mut st, store, false).await; + } + + /// Une ligne marquée (phase, échec, fin) : posée sans troncature, comme + /// au Go, et écrite tout de suite. + async fn mark(&self, store: &Store, line: String, f: impl FnOnce(&mut Job)) { + let mut st = self.state.lock().await; + let Some(j) = &mut st.cur else { return }; + f(j); + j.lines.push(line.clone()); + self.save_line(&line); + self.save(&mut st, store, true).await; + } + + /// `lcPhase`. + pub async fn phase(&self, store: &Store, phase: &str) { + let p = phase.to_string(); + self.mark(store, format!("▶ {phase}"), |j| j.phase = p) + .await; + } + + /// `lcFail`. + pub async fn fail(&self, store: &Store, err: &str) { + let e = err.to_string(); + self.mark(store, format!("✗ {err}"), |j| { + j.err = e; + j.phase = "échec".into(); + }) + .await; + } + + /// `lcDone`. + pub async fn done(&self, store: &Store, msg: &str) { + let m = msg.to_string(); + self.mark(store, format!("✓ {msg}"), |j| j.phase = m).await; + } + + /// `lcCur.OldCommit = …` / `lcCur.NewCommit = …` (sans écriture : la + /// prochaine les emporte). + pub async fn set_commits(&self, old: Option<&str>, new: Option<&str>) { + let mut st = self.state.lock().await; + if let Some(j) = &mut st.cur { + if let Some(o) = old { + j.old_commit = o.into(); + } + if let Some(n) = new { + j.new_commit = n.into(); + } + } + } + + /// `lcJobSnapshot`. + pub async fn snapshot(&self, from: i64, with_lines: bool) -> Value { + let st = self.state.lock().await; + let Some(j) = &st.cur else { + return json!({ "exists": false }); + }; + let mut out = json!({ + "exists": true, + "action": j.action, + "running": j.running, + "phase": j.phase, + "compiled": j.compiled, + "error": j.err, + "started_at": j.started_at, + "ended_at": j.ended_at, + "old": j.old_commit, + "new": j.new_commit, + }); + if with_lines { + let start = (from - j.base as i64).clamp(0, j.lines.len() as i64) as usize; + let rest = &j.lines[start..]; + // `append([]string(nil), …)` d'une tranche vide : nil → null. + out["lines"] = if rest.is_empty() { + Value::Null + } else { + json!(rest) + }; + out["next"] = json!(j.base + j.lines.len()); + } + out + } + + /// Le job court-il ? + pub async fn running(&self) -> bool { + self.state + .lock() + .await + .cur + .as_ref() + .is_some_and(|j| j.running) + } + + /// `lcRestore` : le dernier job connu au démarrage ; un job noté « en + /// cours » est mort avec le processus précédent. + pub async fn restore(&self, store: &Store) { + let Ok(Some(raw)) = store.get(BK_STATE, STATE_KEY).await else { + return; + }; + let Ok(p) = serde_json::from_slice::(&raw) else { + return; + }; + if p.action.is_empty() { + return; + } + let mut j = Job { + action: p.action, + phase: p.phase, + compiled: p.compiled, + running: false, + err: p.err, + started_at: p.started_at, + ended_at: p.ended_at, + old_commit: p.old_commit, + new_commit: p.new_commit, + ..Job::default() + }; + if p.running { + j.err = "interrompu : le service a redémarré pendant l'opération — relancez-la".into(); + j.phase = "interrompu".into(); + j.ended_at = now(); + } + if let Ok(b) = std::fs::read(self.log_path()) { + let s = String::from_utf8_lossy(&b); + let mut lines: Vec = s + .trim_end_matches('\n') + .split('\n') + .map(str::to_string) + .collect(); + if lines.len() == 1 && lines[0].is_empty() { + lines.clear(); + } + if lines.len() > MAX_LINES { + j.base = lines.len() - MAX_LINES; + lines.drain(..j.base); + } + j.lines = lines; + } + if p.running { + j.lines.push( + "✗ interrompu : le service a redémarré (la compilation ne survit pas au redémarrage)" + .into(), + ); + } + let mut st = self.state.lock().await; + if st.cur.is_none() { + st.cur = Some(j); + } + if p.running { + self.save(&mut st, store, true).await; + } + } +} + +/// `truncLabel` : coupé à l'octet, comme le Go ; un caractère coupé devient +/// U+FFFD par octet, comme l'encodeur JSON du Go le rendra. +fn trunc_label(s: &str, n: usize) -> String { + let s = s.trim(); + if s.len() > n { + return format!("{}…", crate::system::go_lossy(&s.as_bytes()[..n - 1])); + } + s.to_string() +} + +/// `phaseLabel`. +pub fn phase_label(line: &str) -> String { + let t = line.trim(); + if let Some(rest) = t.strip_prefix("-- ") { + return format!("configuration… {}", trunc_label(rest, 50)); + } + if t.contains("Linking") || t.contains("Build files have been written") { + return "édition de liens…".into(); + } + String::new() +} + +static RE_COMPILING_CUDA: LazyLock = + LazyLock::new(|| Regex::new(r"Compiling .*?([0-9A-Za-z_.\-]+\.cu)(?-u:\b)").expect("motif")); +static RE_BARE_SOURCE: LazyLock = + LazyLock::new(|| Regex::new(r"^[0-9A-Za-z_.\-]+\.(c|cc|cpp|cxx|cu|cuh)$").expect("motif")); +static RE_BUILDING_OBJ: LazyLock = LazyLock::new(|| { + Regex::new(r"Building (?:C|CXX|CUDA|ASM)[0-9A-Za-z_]* object .*?/([^/]+?)\.o(?:bj)?(?-u:\b)") + .expect("motif") +}); + +/// `compiledFile` : le source qu'une ligne de build annonce, "" sinon. Les +/// classes `\w` et `\b` du Go sont ASCII : on les garde ASCII ici aussi. +pub fn compiled_file(line: &str) -> String { + let t = line.trim(); + if let Some(m) = RE_COMPILING_CUDA.captures(t) { + return m[1].to_string(); + } + if let Some(m) = RE_BUILDING_OBJ.captures(t) { + return m[1].to_string(); + } + if RE_BARE_SOURCE.is_match(t) { + return t.to_string(); + } + String::new() +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn labels() { + assert_eq!( + phase_label("-- The C compiler identification is GNU"), + "configuration… The C compiler identification is GNU" + ); + assert_eq!( + phase_label("[100%] Linking CXX executable"), + "édition de liens…" + ); + assert_eq!(phase_label("rien"), ""); + assert_eq!( + compiled_file("[ 45%] Building CXX object ggml/src/CMakeFiles/ggml.dir/ggml.cpp.o"), + "ggml.cpp" + ); + assert_eq!(compiled_file("foo.cu"), "foo.cu"); + assert_eq!(compiled_file("rien"), ""); + } +} diff --git a/rust/loki-rs/src/backends/mod.rs b/rust/loki-rs/src/backends/mod.rs new file mode 100644 index 0000000..8cdfb4f --- /dev/null +++ b/rust/loki-rs/src/backends/mod.rs @@ -0,0 +1,936 @@ +//! Backends llama.cpp tenus par la façade (lot 22 de la migration, +//! `LOKI_BACKENDS=rust` ; expérimental, le Go reste le défaut) — port de +//! `web_llamacpp.go`, `web_llamacpp_job.go`, `backend_prebuilt.go`, de +//! `handleBackends`, `handleBackendsCustom`, `handleLlamacppUninstallCustom` +//! (`web_api.go`) et `handleBackendDevices` (`web_devices.go`). +//! +//! La façade détient LE job des moteurs ([`job::Jobs`]) : le sien (binaires +//! précompilés) et ceux que le Go fait encore tourner — compilation depuis les +//! sources (install, update, install-custom, relayées au Go), moteur de +//! l'image, Strata — qui lui rapportent chaque étape par l'API interne +//! (`/internal/facade/v1/build-job/*`). + +pub mod devices; +pub mod job; +pub mod prebuilt; +pub mod probe; + +use std::path::{Path, PathBuf}; +use std::process::Stdio; +use std::sync::Arc; + +use http_body_util::{BodyExt, Limited}; +use hyper::body::Incoming; +use hyper::{Method, Request, Response, StatusCode}; +use serde_json::{Value, json}; + +use crate::api::Ctx; +use crate::auth::json as send_json; +use crate::gojson::{self, Kind}; +use crate::paths::{go_clean, go_dir}; +use crate::proxy::Body; +use crate::serve::run::{EnginePaths, same_path}; +use crate::store::{BK_CONFIG, Store}; + +use probe::{Probe, is_dir, is_file}; + +/// Préfixe de l'API interne du job (pour le Go seulement). +pub const INTERNAL_PREFIX: &str = "/internal/facade/v1/build-job/"; + +/// `refuseIfProvided` n'est plus utile ici : les routes de compilation +/// restent au Go, qui répond lui-même. +const USE_REQ_TYPE: &str = r#"struct { Mode string "json:\"mode\"" }"#; +const NAME_REQ_TYPE: &str = r#"struct { Name string "json:\"name\"" }"#; +const BIN_REQ_TYPE: &str = r#"struct { Bin string "json:\"bin\"" }"#; + +/// Les backends tenus par la façade. +pub struct Backends { + pub home: PathBuf, + pub probe: Probe, + pub target: prebuilt::Target, + /// `LOKI_ENGINE_BIN` (moteur livré par l'image), lu au démarrage. + engine_bin_env: std::sync::RwLock, + /// `llamaReleasesAPI`. + pub releases_api: String, + pub jobs: job::Jobs, + pub devices: devices::Devices, + client: reqwest::Client, +} + +impl Backends { + pub fn new(home: PathBuf, engine_bin_env: String) -> Self { + Self::with( + home, + engine_bin_env, + Probe::system(), + prebuilt::RELEASES_API.into(), + ) + } + + /// Pour les tests : machine et API des releases choisies. + pub fn with(home: PathBuf, engine_bin_env: String, probe: Probe, releases_api: String) -> Self { + crate::chat::engine::install_crypto(); + Backends { + jobs: job::Jobs::new(home.clone()), + devices: devices::Devices::new(home.clone()), + home, + probe, + target: prebuilt::Target::current(), + engine_bin_env: std::sync::RwLock::new(engine_bin_env), + releases_api, + client: reqwest::Client::builder().build().unwrap_or_default(), + } + } + + /// Change le moteur livré par l'image (tests). + pub fn set_engine_bin_env(&self, v: &str) { + *self + .engine_bin_env + .write() + .unwrap_or_else(|e| e.into_inner()) = v.to_string(); + } + + fn engine_paths(&self) -> EnginePaths { + EnginePaths { + home: self.home.clone(), + } + } + + fn home_s(&self) -> String { + self.home.to_string_lossy().into_owned() + } + + /// `backendsDir`. + fn backends_dir(&self) -> PathBuf { + self.home.join("backends") + } + + /// `defaultRepoDir`. + fn default_repo_dir(&self) -> PathBuf { + self.backends_dir().join("llama.cpp") + } + + /// `llamacppRepoDir` : la racine du dépôt du BIN, sinon le dépôt canonique. + fn repo_dir(&self, cfg_bin: &str) -> PathBuf { + if !cfg_bin.is_empty() { + let real = crate::paths::eval_symlinks(Path::new(cfg_bin)) + .unwrap_or_else(|| cfg_bin.to_string()); + if let Some(root) = find_repo_root(&real) { + return PathBuf::from(root); + } + } + self.default_repo_dir() + } + + /// `providedEngineBin`. + fn provided_engine_bin(&self) -> String { + let env = self + .engine_bin_env + .read() + .unwrap_or_else(|e| e.into_inner()) + .clone(); + let p = env.trim(); + if p.is_empty() { + return String::new(); + } + if is_file(Path::new(&self.engine_paths().prebuilt_resolve_bin(p))) { + p.to_string() + } else { + String::new() + } + } + + /// `gitOutput` : sortie nettoyée, "" en cas d'échec. + async fn git(&self, repo: &Path, args: &[&str]) -> String { + self.probe + .output_in(Some(repo), "git", args) + .await + .map(|o| String::from_utf8_lossy(&o).trim().to_string()) + .unwrap_or_default() + } + + /// `listBackendBins` : un binaire par dossier de `backends/`. + pub fn list_backend_bins(&self) -> Vec<(String, String)> { + let root = format!("{}/backends", self.home_s()); + let Ok(rd) = std::fs::read_dir(&root) else { + return Vec::new(); + }; + let mut names: Vec = rd + .flatten() + .map(|e| e.file_name().to_string_lossy().into_owned()) + .collect(); + names.sort(); + const SUBPATHS: [&str; 9] = [ + "build/bin/llama-server", + "build-sm120/bin/llama-server", + "build/llama-server", + "bin/llama-server", + "llama-server", + "build/bin/Release/llama-server.exe", + "build/bin/llama-server.exe", + "build/bin/Release/llama-server", + "llama-server.exe", + ]; + let mut out = Vec::new(); + for name in names { + if name.starts_with('.') { + continue; + } + for sp in SUBPATHS { + let p = format!("{root}/{name}/{sp}"); + if is_file(Path::new(&p)) { + out.push((name.clone(), p)); + break; + } + } + } + out + } + + /// `backendDir` : `backends/` sans évasion possible. + fn backend_dir(&self, name: &str) -> Result { + let name = sanitize_backend_name(name); + let root = crate::paths::go_abs(&self.backends_dir().to_string_lossy()) + .ok_or_else(|| "répertoire courant illisible".to_string())?; + let p = crate::paths::go_join(&[&root, &name]); + if !p.starts_with(&format!("{root}/")) { + return Err("nom de backend invalide".into()); + } + Ok(p) + } +} + +/// `findRepoRoot` : le dossier du dépôt llama.cpp au-dessus d'un binaire. +fn find_repo_root(bin: &str) -> Option { + let mut d = go_dir(bin); + for _ in 0..6 { + if is_dir(&Path::new(&d).join(".git")) || is_file(&Path::new(&d).join("CMakeLists.txt")) { + return Some(d); + } + let parent = go_dir(&d); + if parent == d { + break; + } + d = parent; + } + None +} + +/// `llamaServerBin` : le binaire compilé sous `repo`, "" sinon. +fn llama_server_bin(repo: &Path) -> String { + let ext = if cfg!(windows) { ".exe" } else { "" }; + for rel in [ + format!("build/bin/Release/llama-server{ext}"), + format!("build/bin/llama-server{ext}"), + format!("build/Release/llama-server{ext}"), + format!("build/llama-server{ext}"), + ] { + let p = go_clean(&repo.join(&rel).to_string_lossy()); + if is_file(Path::new(&p)) { + return p; + } + } + String::new() +} + +/// `sanitizeBackendName`. +pub fn sanitize_backend_name(name: &str) -> String { + let name = name.trim().to_lowercase(); + let mut b = String::new(); + for r in name.chars() { + match r { + 'a'..='z' | '0'..='9' | '.' | '_' | '-' => b.push(r), + ' ' | '/' | '\\' => b.push('-'), + _ => {} + } + } + let out = b.trim_matches(['.', '-']).to_string(); + if out.is_empty() { + "backend".into() + } else { + out + } +} + +/// `engineOwns` / `engineTagOf`. +fn engine_tag_of(ep: &EnginePaths, bin: &str) -> String { + if !ep.engine_owns(bin) { + return String::new(); + } + crate::paths::base_name(&go_dir(bin)) +} + +/// `strconv.Atoi` : 0 sur erreur de syntaxe, borné sur débordement. +pub fn go_atoi(s: &str) -> i64 { + let digits = s.strip_prefix(['+', '-']).unwrap_or(s); + if digits.is_empty() || !digits.bytes().all(|b| b.is_ascii_digit()) { + return 0; + } + s.parse().unwrap_or(if s.starts_with('-') { + i64::MIN + } else { + i64::MAX + }) +} + +/// Journal d'un job tenu ici : chaque ligne va au job, dans l'ordre. +pub struct Log<'a> { + pub jobs: &'a job::Jobs, + pub store: &'a Store, +} + +impl Log<'_> { + pub async fn line(&self, s: &str) { + self.jobs.append(self.store, s).await; + } + pub async fn phase(&self, s: &str) { + self.jobs.phase(self.store, s).await; + } +} + +/// Le verrou de l'optimiseur (au Go) : le message s'il tient le moteur. +async fn tune_busy(store: &Store) -> Option { + let v = store + .internal_json(Method::GET, "/internal/v1/tune/guard", None) + .await + .ok()?; + (v.get("busy").and_then(Value::as_bool) == Some(true)).then(|| { + v.get("error") + .and_then(Value::as_str) + .unwrap_or("") + .to_string() + }) +} + +/// Le service moteur : celui de la façade si elle le pilote, sinon celui du +/// Go. `if_active` : seulement s'il tourne. Rend (lancé, erreur). +async fn service(ctx: &Ctx, action: &str, if_active: bool) -> (bool, String) { + if let Some(ctl) = &ctx.engine_ctl { + if if_active && !crate::enginectl::service_is_active(ctx) { + return (false, String::new()); + } + let err = crate::enginectl::service_action(ctx, ctl, action) + .await + .err() + .unwrap_or_default(); + return (true, err); + } + match ctx + .store + .internal_json( + Method::POST, + "/internal/v1/service/action", + Some(&json!({ "action": action, "if_active": if_active })), + ) + .await + { + Ok(v) => ( + v.get("ran").and_then(Value::as_bool).unwrap_or(false), + v.get("error") + .and_then(Value::as_str) + .unwrap_or("") + .to_string(), + ), + Err(e) => (true, e), + } +} + +/// Les routes servies par ce module. +pub fn handles(path: &str) -> bool { + matches!( + path, + "/api/llamacpp" + | "/api/llamacpp/check" + | "/api/llamacpp/prebuilt/check" + | "/api/llamacpp/prebuilt" + | "/api/llamacpp/use" + | "/api/llamacpp/job" + | "/api/llamacpp/uninstall-custom" + | "/api/backends" + | "/api/backends/custom" + | "/api/backends/devices" + ) +} + +/// L'API interne du job ? +pub fn is_internal(path: &str) -> bool { + path.starts_with(INTERNAL_PREFIX) +} + +fn fail(code: StatusCode, e: impl Into) -> Response { + send_json(code, &json!({ "ok": false, "error": e.into() })) +} + +/// `from_go` : remise par le Go (tunnel), déjà authentifiée par lui. +pub async fn serve( + ctx: &Ctx, + b: &Arc, + req: Request, + from_go: bool, +) -> Response { + let (parts, body) = req.into_parts(); + if !from_go && let Err(refus) = ctx.guard(&parts.headers).await { + return *refus; + } + let raw = Limited::new(body, 1 << 20) + .collect() + .await + .map(|b| b.to_bytes().to_vec()) + .unwrap_or_default(); + let store = &ctx.store; + let query = parts.uri.query().unwrap_or(""); + match parts.uri.path() { + "/api/llamacpp" => send_json(StatusCode::OK, &status(b, store).await), + "/api/llamacpp/check" => send_json(StatusCode::OK, &check(b, store).await), + "/api/llamacpp/prebuilt/check" => send_json(StatusCode::OK, &prebuilt_check(b).await), + "/api/llamacpp/prebuilt" => { + if let Some(e) = tune_busy(store).await { + return fail(StatusCode::CONFLICT, e); + } + if let Err(e) = b.jobs.start(store, "prebuilt").await { + return fail(StatusCode::CONFLICT, e); + } + let (b2, ctx2) = (b.clone(), ctx.clone()); + tokio::spawn(async move { + run_prebuilt(&ctx2, &b2).await; + b2.jobs.end(&ctx2.store).await; + }); + send_json(StatusCode::OK, &json!({ "ok": true })) + } + "/api/llamacpp/use" => { + if let Some(e) = tune_busy(store).await { + return send_json( + StatusCode::CONFLICT, + &json!({ "ok": false, "tuning": true, "error": e }), + ); + } + let d = gojson::decode_struct(&raw, USE_REQ_TYPE, &[("mode", Kind::Str)]); + let cfg = store.config().await; + let cfg_bin = cfg.get("BIN").cloned().unwrap_or_default(); + let bin = match d.str("mode").as_str() { + "fast" => prebuilt::server_bin(&b.home), + "opt" => llama_server_bin(&b.repo_dir(&cfg_bin)), + _ => return fail(StatusCode::BAD_REQUEST, "mode inconnu"), + }; + if bin.is_empty() { + return fail(StatusCode::BAD_REQUEST, "cette version n'est pas installée"); + } + if let Err(e) = store.put(BK_CONFIG, "BIN", bin.clone().into_bytes()).await { + return fail(StatusCode::INTERNAL_SERVER_ERROR, e); + } + let _ = service(ctx, "restart", true).await; + send_json(StatusCode::OK, &json!({ "ok": true, "bin": bin })) + } + "/api/llamacpp/job" => { + let from = query_param(query, "from"); + send_json(StatusCode::OK, &b.jobs.snapshot(go_atoi(&from), true).await) + } + "/api/llamacpp/uninstall-custom" => uninstall_custom(b, store, &raw).await, + "/api/backends" => { + let list: Vec = b + .list_backend_bins() + .into_iter() + .map(|(name, path)| json!({ "name": name, "path": path })) + .collect(); + send_json(StatusCode::OK, &json!(list)) + } + "/api/backends/custom" => { + let cfg_bin = store.config().await.get("BIN").cloned().unwrap_or_default(); + let list: Vec = b + .list_backend_bins() + .into_iter() + .filter(|(name, _)| name != "llama.cpp" && name != "llama.cpp-prebuilt") + .map(|(name, path)| { + let in_use = same_path(&path, &cfg_bin); + json!({ "name": name, "path": path, "in_use": in_use }) + }) + .collect(); + send_json(StatusCode::OK, &json!(list)) + } + "/api/backends/devices" => { + let d = gojson::decode_struct(&raw, BIN_REQ_TYPE, &[("bin", Kind::Str)]); + let cfg = store.config().await; + let mut bin = d.str("bin").trim().to_string(); + if bin.is_empty() { + bin = cfg.get("BIN").cloned().unwrap_or_default(); + } + let bin = b.engine_paths().prebuilt_resolve_bin(&bin); + if bin.is_empty() || !is_file(Path::new(&bin)) { + return send_json( + StatusCode::OK, + &json!({ "ok": false, "error": "moteur introuvable — choisissez d'abord un moteur" }), + ); + } + if !devices::is_llama_server(&bin) { + return fail( + StatusCode::BAD_REQUEST, + "ce chemin n'est pas un llama-server", + ); + } + let cvd = cfg.get("CUDA_VISIBLE_DEVICES").cloned().unwrap_or_default(); + send_json(StatusCode::OK, &b.devices.list(&b.probe, &bin, &cvd).await) + } + _ => fail(StatusCode::NOT_FOUND, "route inconnue"), + } +} + +/// Valeur d'un paramètre de requête (dernière occurrence ignorée : la +/// première, comme `Query().Get`). +fn query_param(query: &str, key: &str) -> String { + url::form_urlencoded::parse(query.as_bytes()) + .find(|(k, _)| k == key) + .map(|(_, v)| v.into_owned()) + .unwrap_or_default() +} + +/// `handleLlamacpp` : le statut complet. +pub async fn status(b: &Backends, store: &Store) -> Value { + let cfg = store.config().await; + let cfg_bin = cfg.get("BIN").cloned().unwrap_or_default(); + let repo = b.repo_dir(&cfg_bin); + let installed = is_dir(&repo.join(".git")); + let mut out = json!({ + "repo": repo.to_string_lossy(), + "installed": installed, + }); + if installed { + let branch = b.git(&repo, &["rev-parse", "--abbrev-ref", "HEAD"]).await; + out["commit"] = json!(b.git(&repo, &["rev-parse", "--short", "HEAD"]).await); + out["commit_date"] = json!(b.git(&repo, &["log", "-1", "--format=%ci"]).await); + out["commit_msg"] = json!(b.git(&repo, &["log", "-1", "--format=%s"]).await); + if !branch.is_empty() && branch != "HEAD" { + let behind = b + .git( + &repo, + &["rev-list", "--count", &format!("HEAD..origin/{branch}")], + ) + .await; + if !behind.is_empty() { + out["behind"] = json!(behind.parse::().unwrap_or(0)); + } + } + out["branch"] = json!(branch); + } + let bin = llama_server_bin(&repo); + let ep = b.engine_paths(); + out["bin"] = json!(bin); + out["config_bin"] = json!(cfg_bin); + out["in_use"] = json!(!bin.is_empty() && same_path(&bin, &cfg_bin)); + let provided = b.provided_engine_bin(); + out["provided"] = json!(!provided.is_empty()); + out["provided_bin"] = json!(provided); + out["engine"] = json!({ + "dir": b.home.join("engine").to_string_lossy(), + "in_use": ep.engine_owns(&cfg_bin), + "tag": engine_tag_of(&ep, &cfg_bin), + }); + let plan = b.probe.detect_build_plan().await; + out["plan"] = json!({ "backend": plan.backend, "arch": plan.cuda_arch, "jobs": plan.jobs }); + let (pb_tag, _) = prebuilt::version(&b.home); + let pb_bin = prebuilt::server_bin(&b.home); + out["prebuilt"] = json!({ + "tag": pb_tag, + "bin": pb_bin, + "dir": prebuilt::dir(&b.home).to_string_lossy(), + "in_use": !pb_bin.is_empty() && ep.prebuilt_owns(&cfg_bin), + }); + out["backends_dir"] = json!(b.backends_dir().to_string_lossy()); + out["reco"] = prebuilt::recommended_mode(plan.backend, b.target.os); + out["job"] = b.jobs.snapshot(0, false).await; + out +} + +/// `runStep` (sans terminal interactif) : la commande, sorties sur celles de +/// la façade ; l'erreur au format d'`exec`. +async fn run_step(probe: &Probe, dir: &Path, name: &str, args: &[&str]) -> Result<(), String> { + let Some(bin) = probe.look_path(name) else { + return Err(format!( + "exec: \"{name}\": executable file not found in $PATH" + )); + }; + let status = tokio::process::Command::new(&bin) + .args(args) + .current_dir(dir) + .stdin(Stdio::null()) + .stdout(Stdio::inherit()) + .stderr(Stdio::inherit()) + .status() + .await + .map_err(|e| crate::paths::os_err("fork/exec", &bin.to_string_lossy(), &e))?; + match status.code() { + Some(0) => Ok(()), + Some(c) => Err(format!("exit status {c}")), + None => Err(status.to_string()), + } +} + +/// `handleLlamacppCheck` : git fetch, puis le retard sur origin. +async fn check(b: &Backends, store: &Store) -> Value { + let cfg_bin = store.config().await.get("BIN").cloned().unwrap_or_default(); + let repo = b.repo_dir(&cfg_bin); + if !is_dir(&repo.join(".git")) { + return json!({ "ok": false, "error": "llama.cpp n'est pas installé" }); + } + let mut branch = b.git(&repo, &["rev-parse", "--abbrev-ref", "HEAD"]).await; + if branch.is_empty() || branch == "HEAD" { + branch = "master".into(); + } + if let Err(e) = run_step(&b.probe, &repo, "git", &["fetch", "origin", "--quiet"]).await { + return json!({ "ok": false, "error": format!("git fetch a échoué : {e}") }); + } + let origin = format!("origin/{branch}"); + let behind_s = b + .git(&repo, &["rev-list", "--count", &format!("HEAD..{origin}")]) + .await; + let behind = if behind_s.is_empty() { + 0 + } else { + behind_s.parse::().unwrap_or(0) + }; + let has_binary = !llama_server_bin(&repo).is_empty(); + json!({ + "ok": true, + "behind": behind, + "local": b.git(&repo, &["rev-parse", "--short", "HEAD"]).await, + "remote": b.git(&repo, &["rev-parse", "--short", &origin]).await, + "remote_date": b.git(&repo, &["log", "-1", "--format=%ci", &origin]).await, + "remote_msg": b.git(&repo, &["log", "-1", "--format=%s", &origin]).await, + "branch": branch, + "has_binary": has_binary, + "needs_rebuild": behind > 0 || !has_binary, + }) +} + +/// La dernière release officielle (`fetchLlamaLatest`). +async fn fetch_latest(b: &Backends) -> Result<(String, Vec), String> { + prebuilt::fetch_latest(&b.client, &b.releases_api).await +} + +/// `handleLlamacppPrebuiltCheck`. +async fn prebuilt_check(b: &Backends) -> Value { + let (tag, assets) = match fetch_latest(b).await { + Ok(r) => r, + Err(e) => return json!({ "ok": false, "error": e }), + }; + let pick = match prebuilt::pick(&assets, &b.probe, b.target).await { + Ok(p) => p, + Err(e) => return json!({ "ok": false, "error": e }), + }; + let (cur, _) = prebuilt::version(&b.home); + let update = cur != tag || prebuilt::server_bin(&b.home).is_empty(); + json!({ + "ok": true, + "latest": tag, + "current": cur, + "variant": pick.label, + "size_mb": pick.main.size / 1_000_000, + "update": update, + }) +} + +/// `lcRunPrebuilt` : installe, pointe BIN, service arrêté pendant le +/// remplacement puis relancé. +async fn run_prebuilt(ctx: &Ctx, b: &Backends) { + let log = Log { + jobs: &b.jobs, + store: &ctx.store, + }; + let (svc_was_up, err) = if ctx.engine_ctl.is_some() { + if crate::enginectl::service_is_active(ctx) { + log.phase("arrêt du service le temps de l'installation…") + .await; + service(ctx, "stop", false).await + } else { + (false, String::new()) + } + } else { + // Côté Go, « arrête-le s'il tourne » dit aussi s'il tournait. + let r = service(ctx, "stop", true).await; + if r.0 { + log.phase("arrêt du service le temps de l'installation…") + .await; + } + r + }; + if svc_was_up && !err.is_empty() { + log.line(&format!("[warn] impossible d'arrêter le service : {err}")) + .await; + } + let mut res = install(b, &log).await; + if let Ok(bin) = &res { + match ctx + .store + .put(BK_CONFIG, "BIN", bin.clone().into_bytes()) + .await + { + Err(e) => { + res = Err(format!( + "binaires installés mais échec d'écriture de BIN : {e}" + )) + } + Ok(()) => log.line("BIN mis à jour").await, + } + } + if svc_was_up { + log.phase("redémarrage du service…").await; + let (_, e) = service(ctx, "start", false).await; + if !e.is_empty() { + log.line(&format!("[warn] redémarrage du service échoué : {e}")) + .await; + } + } + match res { + Err(e) => b.jobs.fail(&ctx.store, &e).await, + Ok(_) => { + let (tag, _) = prebuilt::version(&b.home); + b.jobs + .done( + &ctx.store, + &format!("binaires précompilés installés ({tag})"), + ) + .await; + } + } +} + +/// `prebuiltInstall` : télécharge et installe la dernière release ; rend le +/// chemin du binaire. +pub async fn install(b: &Backends, log: &Log<'_>) -> Result { + log.phase("récupération de la dernière release llama.cpp…") + .await; + let (tag, assets) = fetch_latest(b) + .await + .map_err(|e| format!("impossible d'interroger les releases llama.cpp : {e}"))?; + let (cur_tag, cur_cuda) = prebuilt::version(&b.home); + let pick = prebuilt::pick(&assets, &b.probe, b.target).await?; + log.line(&format!("release {tag} — variant retenu : {}", pick.label)) + .await; + let cur = prebuilt::server_bin(&b.home); + if cur_tag == tag && prebuilt::version_format(&b.home) == prebuilt::FORMAT && !cur.is_empty() { + log.line(&format!("déjà à jour ({tag})")).await; + prune(b, &cur, log).await; + return Ok(cur); + } + let dir = prebuilt::dir(&b.home); + std::fs::create_dir_all(&dir) + .map_err(|e| crate::paths::os_err("mkdir", &dir.to_string_lossy(), &e))?; + let mut cudart = pick.cudart.clone(); + if cudart.is_some() && !cudart_present(&dir).is_empty() && cur_cuda == pick.cuda_ver { + log.line(&format!( + "cudart {} déjà présent — téléchargement évité", + pick.cuda_ver + )) + .await; + cudart = None; + } + for a in [Some(pick.main.clone()), cudart].into_iter().flatten() { + log.phase(&format!( + "téléchargement de {} ({} Mo)…", + a.name, + a.size / 1_000_000 + )) + .await; + let tmp = dir.join(format!("{}.part", a.name)); + if let Err(e) = download(b, &a, &tmp, log).await { + let _ = std::fs::remove_file(&tmp); + return Err(format!("téléchargement de {} : {e}", a.name)); + } + log.phase(&format!("extraction de {}…", a.name)).await; + let (t2, d2) = (tmp.clone(), dir.clone()); + let ex = tokio::task::spawn_blocking(move || prebuilt::extract(&t2, &d2)) + .await + .unwrap_or_else(|e| Err(e.to_string())); + if let Err(e) = ex { + let _ = std::fs::remove_file(&tmp); + return Err(format!("extraction de {} : {e}", a.name)); + } + let _ = std::fs::remove_file(&tmp); + } + let vpath = dir.join("VERSION"); + std::fs::write( + &vpath, + format!("{tag} {} {}\n", pick.cuda_ver, prebuilt::FORMAT), + ) + .map_err(|e| crate::paths::os_err("open", &vpath.to_string_lossy(), &e))?; + let bin = prebuilt::server_bin(&b.home); + if bin.is_empty() { + return Err(format!( + "archives extraites mais llama-server introuvable sous {}", + dir.to_string_lossy() + )); + } + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + let _ = std::fs::set_permissions(&bin, std::fs::Permissions::from_mode(0o755)); + } + prune(b, &bin, log).await; + log.line(&format!("binaire installé : {bin} (release {tag})")) + .await; + Ok(bin) +} + +/// Le téléchargement d'un asset, sa progression au journal du job au fil de +/// l'eau et dans l'ordre. +async fn download( + b: &Backends, + a: &prebuilt::Asset, + dest: &Path, + log: &Log<'_>, +) -> Result<(), String> { + let (tx, mut rx) = tokio::sync::mpsc::unbounded_channel::(); + let dl = async move { + let mut logf = |s: String| { + let _ = tx.send(s); + }; + prebuilt::download(&b.client, &a.url, dest, a.size, &mut logf).await + }; + let pump = async { + while let Some(s) = rx.recv().await { + log.line(&s).await; + } + }; + let (res, ()) = tokio::join!(dl, pump); + res +} + +/// cudart déjà extrait (`filepath.Glob(dir/**/cudart64*.dll)` puis +/// `dir/cudart64*.dll` : `**` vaut `*` pour Glob). +fn cudart_present(dir: &Path) -> Vec { + let is_dll = |n: &str| n.starts_with("cudart64") && n.ends_with(".dll"); + let mut found = Vec::new(); + if let Ok(rd) = std::fs::read_dir(dir) { + for e in rd.flatten() { + if let Ok(sub) = std::fs::read_dir(e.path()) { + for s in sub.flatten() { + if is_dll(&s.file_name().to_string_lossy()) { + found.push(s.path()); + } + } + } + } + } + if found.is_empty() + && let Ok(rd) = std::fs::read_dir(dir) + { + for e in rd.flatten() { + if is_dll(&e.file_name().to_string_lossy()) { + found.push(e.path()); + } + } + } + found +} + +/// `prebuiltPrune`, ses lignes au job. +async fn prune(b: &Backends, keep: &str, log: &Log<'_>) { + let mut lines = Vec::new(); + prebuilt::prune(&b.home, keep, &mut |s| lines.push(s)); + for l in lines { + log.line(&l).await; + } +} + +/// `handleLlamacppUninstallCustom`. +async fn uninstall_custom(b: &Backends, store: &Store, raw: &[u8]) -> Response { + let d = gojson::decode_struct(raw, NAME_REQ_TYPE, &[("name", Kind::Str)]); + let name = sanitize_backend_name(&d.str("name")); + if name.is_empty() { + return fail(StatusCode::BAD_REQUEST, "nom requis"); + } + if name == "llama.cpp" || name == "llama.cpp-prebuilt" { + return fail( + StatusCode::BAD_REQUEST, + "ce moteur se gère depuis les cartes ⚡ / 🔧", + ); + } + let dir = match b.backend_dir(&name) { + Ok(d) => d, + Err(e) => return fail(StatusCode::BAD_REQUEST, e), + }; + if !is_dir(Path::new(&dir)) { + return fail(StatusCode::NOT_FOUND, "backend introuvable"); + } + let cfg_bin = store.config().await.get("BIN").cloned().unwrap_or_default(); + for (n, p) in b.list_backend_bins() { + if n == name && same_path(&p, &cfg_bin) { + return fail( + StatusCode::CONFLICT, + "backend utilisé par le modèle actif — bascule d'abord ce modèle sur un autre moteur", + ); + } + } + if let Err(e) = std::fs::remove_dir_all(&dir) { + return fail( + StatusCode::INTERNAL_SERVER_ERROR, + crate::paths::os_err("unlinkat", &dir, &e), + ); + } + send_json(StatusCode::OK, &json!({ "ok": true })) +} + +/// L'API interne du job : le Go y rapporte les étapes de ses jobs. +pub async fn serve_internal(ctx: &Ctx, b: &Backends, req: Request) -> Response { + let (parts, body) = req.into_parts(); + let raw = Limited::new(body, 1 << 20) + .collect() + .await + .map(|b| b.to_bytes().to_vec()) + .unwrap_or_default(); + let v: Value = serde_json::from_slice(&raw).unwrap_or(Value::Null); + let s = |k: &str| v.get(k).and_then(Value::as_str).unwrap_or("").to_string(); + let store = &ctx.store; + let op = parts.uri.path().trim_start_matches(INTERNAL_PREFIX); + if op == "snapshot" { + let q = parts.uri.query().unwrap_or(""); + let from = go_atoi(&query_param(q, "from")); + let lines = query_param(q, "lines") == "1"; + return send_json(StatusCode::OK, &b.jobs.snapshot(from, lines).await); + } + if parts.method != Method::POST { + return fail(StatusCode::METHOD_NOT_ALLOWED, "POST attendu"); + } + match op { + "start" => { + if let Err(e) = b.jobs.start(store, &s("action")).await { + return fail(StatusCode::CONFLICT, e); + } + return send_json(StatusCode::OK, &json!({ "ok": true })); + } + "line" => b.jobs.append(store, &s("line")).await, + "phase" => b.jobs.phase(store, &s("phase")).await, + "fail" => b.jobs.fail(store, &s("error")).await, + "done" => b.jobs.done(store, &s("msg")).await, + "end" => b.jobs.end(store).await, + "commits" => { + let old = v.get("old").and_then(Value::as_str); + let new = v.get("new").and_then(Value::as_str); + b.jobs.set_commits(old, new).await; + } + _ => return fail(StatusCode::NOT_FOUND, "route inconnue"), + } + send_json(StatusCode::OK, &json!({ "ok": true })) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn sanitize() { + assert_eq!(sanitize_backend_name(" Mon Fork/Test "), "mon-fork-test"); + assert_eq!(sanitize_backend_name("../.."), "backend"); + assert_eq!(sanitize_backend_name(""), "backend"); + assert_eq!(sanitize_backend_name("é-x."), "x"); + } + + #[test] + fn atoi() { + assert_eq!(go_atoi("12"), 12); + assert_eq!(go_atoi("-3"), -3); + assert_eq!(go_atoi("+4"), 4); + assert_eq!(go_atoi("x"), 0); + assert_eq!(go_atoi(""), 0); + assert_eq!(go_atoi("99999999999999999999"), i64::MAX); + } +} diff --git a/rust/loki-rs/src/backends/prebuilt.rs b/rust/loki-rs/src/backends/prebuilt.rs new file mode 100644 index 0000000..996cdbd --- /dev/null +++ b/rust/loki-rs/src/backends/prebuilt.rs @@ -0,0 +1,559 @@ +//! Binaires précompilés officiels de llama.cpp : port de `backend_prebuilt.go` +//! (dernière release, choix de l'asset, téléchargement, extraction avec les +//! liens, marqueur VERSION, ménage des anciennes versions). + +use std::io::Read; +use std::path::{Path, PathBuf}; +use std::sync::LazyLock; + +use futures_util::StreamExt; +use regex::Regex; +use serde::Deserialize; +use serde_json::Value; + +use crate::gojson::Kind; +use tokio::io::AsyncWriteExt; + +use super::probe::Probe; + +/// `llamaReleasesAPI`. +pub const RELEASES_API: &str = "https://api.github.com/repos/ggml-org/llama.cpp/releases/latest"; + +/// La struct anonyme que `fetchLlamaLatest` décode. +const RELEASE_TYPE: &str = + r#"struct { TagName string "json:\"tag_name\""; Assets []loki.ghAsset "json:\"assets\"" }"#; + +/// `prebuiltFormat` : extraction qui recrée les liens des archives. +pub const FORMAT: &str = "fmt2"; + +/// `ghAsset`. +#[derive(Clone, Debug, Deserialize, PartialEq, Eq)] +pub struct Asset { + #[serde(default)] + pub name: String, + #[serde(default, rename = "browser_download_url")] + pub url: String, + #[serde(default)] + pub size: i64, +} + +/// `prebuiltDir`. +pub fn dir(home: &Path) -> PathBuf { + home.join("backends").join("llama.cpp-prebuilt") +} + +fn version_fields(home: &Path) -> Vec { + std::fs::read_to_string(dir(home).join("VERSION")) + .map(|s| s.split_whitespace().map(str::to_string).collect()) + .unwrap_or_default() +} + +/// `prebuiltVersion` : (tag, version CUDA). La version CUDA est vide hors +/// CUDA Windows : « b300 fmt2 » n'a alors que deux champs, le format en +/// second. +pub fn version(home: &Path) -> (String, String) { + let f = version_fields(home); + let cuda = f + .get(1) + .filter(|c| !c.starts_with("fmt")) + .cloned() + .unwrap_or_default(); + (f.first().cloned().unwrap_or_default(), cuda) +} + +/// `prebuiltVersionFormat` : le dernier champ du marqueur, s'il en est un. +pub fn version_format(home: &Path) -> String { + let f = version_fields(home); + match f.last() { + Some(last) if f.len() > 1 && last.starts_with("fmt") => last.clone(), + _ => String::new(), + } +} + +/// `prebuiltServerBin`. +pub fn server_bin(home: &Path) -> String { + crate::serve::run::EnginePaths { + home: home.to_path_buf(), + } + .prebuilt_server_bin() +} + +/// `prebuiltPrune` : les extractions d'autres releases supprimées. +pub fn prune(home: &Path, keep: &str, logf: &mut dyn FnMut(String)) { + let keep_dir = if keep.is_empty() { + String::new() + } else { + crate::paths::go_clean(keep) + }; + let d = dir(home); + let Ok(rd) = std::fs::read_dir(&d) else { + return; + }; + let mut names: Vec = rd + .flatten() + .filter(|e| e.file_type().is_ok_and(|t| t.is_dir())) + .map(|e| e.file_name().to_string_lossy().into_owned()) + .filter(|n| n.starts_with("llama-b")) + .collect(); + names.sort(); + for name in names { + let p = d.join(&name); + let ps = crate::paths::go_clean(&p.to_string_lossy()); + if !keep_dir.is_empty() && keep_dir.starts_with(&format!("{ps}/")) { + continue; + } + if std::fs::remove_dir_all(&p).is_ok() { + logf(format!("ancienne version supprimée : {name}")); + } + } +} + +/// `fetchLlamaLatest` : (tag, assets) de la dernière release. +pub async fn fetch_latest( + client: &reqwest::Client, + url: &str, +) -> Result<(String, Vec), String> { + let resp = client + .get(url) + .header("Accept", "application/vnd.github+json") + .header("User-Agent", "Go-http-client/1.1") + .timeout(std::time::Duration::from_secs(30)) + .send() + .await + .map_err(|e| e.to_string())?; + if resp.status().as_u16() != 200 { + return Err(format!("GitHub API : HTTP {}", resp.status().as_u16())); + } + let body = resp.bytes().await.map_err(|e| e.to_string())?; + // Erreurs de décodage au mot près du Go (`json.NewDecoder(…).Decode`). + let d = crate::gojson::decode_struct( + &body, + RELEASE_TYPE, + &[("tag_name", Kind::Str), ("assets", Kind::Any)], + ); + if let Some(e) = d.err { + return Err(e); + } + let assets: Vec = match d.fields.get("assets") { + Some(Value::Null) | None => Vec::new(), + Some(v) => serde_json::from_value(v.clone()).map_err(|e| e.to_string())?, + }; + let tag = d.str("tag_name"); + if tag.is_empty() { + return Err("release invalide (tag vide)".into()); + } + Ok((tag, assets)) +} + +/// `assetMatch` : les assets dont le nom contient TOUS les fragments. +pub fn asset_match(assets: &[Asset], frags: &[&str]) -> Vec { + assets + .iter() + .filter(|a| frags.iter().all(|f| a.name.contains(f))) + .cloned() + .collect() +} + +static RE_CUDA_ASSET_VER: LazyLock = + LazyLock::new(|| Regex::new(r"cuda-([0-9]+\.[0-9]+)").expect("motif")); + +/// Le choix de `pickPrebuilt`. +#[derive(Clone, Debug, PartialEq)] +pub struct Pick { + pub main: Asset, + pub cudart: Option, + pub label: String, + pub cuda_ver: String, +} + +/// Système et architecture visés (`runtime.GOOS`, `runtime.GOARCH`). +#[derive(Clone, Copy, Debug)] +pub struct Target { + pub os: &'static str, + pub arm64: bool, +} + +impl Target { + pub fn current() -> Self { + Target { + os: if cfg!(windows) { + "windows" + } else if cfg!(target_os = "macos") { + "darwin" + } else { + "linux" + }, + arm64: cfg!(target_arch = "aarch64"), + } + } +} + +/// `pickPrebuilt` : l'asset principal (+ cudart pour CUDA Windows) adapté à +/// la machine, et un libellé lisible du variant. +pub async fn pick(assets: &[Asset], probe: &Probe, t: Target) -> Result { + let first = |l: Vec| l.into_iter().next(); + let mut cudart = None; + let mut cuda_ver = String::new(); + let (main, label): (Option, String) = match t.os { + "windows" => 'w: { + if t.arm64 { + break 'w ( + first(asset_match(assets, &["llama-", "bin-win-cpu-arm64"])), + "CPU (Windows arm64)".into(), + ); + } + if probe.has_nvidia_gpu().await { + let cand: Vec = + asset_match(assets, &["llama-", "bin-win-cuda-", "-x64.zip"]) + .into_iter() + .filter(|a| !a.name.starts_with("cudart")) + .collect(); + let max_v = probe.driver_cuda_version().await; + let mut best: Option = None; + let mut best_v = 0.0; + for a in cand { + let Some(m) = RE_CUDA_ASSET_VER.captures(&a.name) else { + continue; + }; + let v: f64 = m[1].parse().unwrap_or(0.0); + let ok = (max_v == 0.0 && (best.is_none() || v < best_v)) + || (max_v > 0.0 && v <= max_v && v > best_v); + if ok { + cuda_ver = m[1].to_string(); + best = Some(a); + best_v = v; + } + } + if let Some(b) = best { + cudart = first(asset_match( + assets, + &["cudart-", &format!("win-cuda-{cuda_ver}")], + )); + break 'w (Some(b), format!("CUDA {cuda_ver} (Windows x64)")); + } + } + if let Some(a) = first(asset_match(assets, &["llama-", "bin-win-vulkan-x64"])) { + break 'w (Some(a), "Vulkan (Windows x64)".into()); + } + ( + first(asset_match(assets, &["llama-", "bin-win-cpu-x64"])), + "CPU (Windows x64)".into(), + ) + } + "darwin" => { + let arch = if t.arm64 { "arm64" } else { "x64" }; + ( + first(asset_match( + assets, + &["llama-", &format!("bin-macos-{arch}")], + )), + format!("Metal (macOS {arch})"), + ) + } + _ => 'l: { + let arch = if t.arm64 { "arm64" } else { "x64" }; + if (probe.has_tool("hipcc") || super::probe::is_dir(&probe.sys("/opt/rocm"))) + && let Some(a) = first(asset_match(assets, &["llama-", "bin-ubuntu-rocm-", arch])) + { + break 'l (Some(a), format!("ROCm (Linux {arch})")); + } + let nvidia = probe.has_nvidia_gpu().await; + if (nvidia || probe.has_tool("vulkaninfo")) + && let Some(a) = first(asset_match( + assets, + &["llama-", &format!("bin-ubuntu-vulkan-{arch}")], + )) + { + let mut label = format!("Vulkan (Linux {arch})"); + // Le Go relance nvidia-smi pour ce suffixe : même résultat. + if probe.has_nvidia_gpu().await { + label.push_str(" — pas de build CUDA officiel Linux ; compile localement pour du CUDA natif"); + } + break 'l (Some(a), label); + } + ( + first(asset_match( + assets, + &["llama-", &format!("bin-ubuntu-{arch}.tar.gz")], + )), + format!("CPU (Linux {arch})"), + ) + } + }; + match main { + Some(main) => Ok(Pick { + main, + cudart, + label, + cuda_ver, + }), + None => { + Err("aucun binaire précompilé adapté à cette machine dans la release officielle".into()) + } + } +} + +/// `recommendedMode` : {mode, why}. +pub fn recommended_mode(backend: &str, os: &str) -> serde_json::Value { + if os == "linux" && backend == "cuda" { + return serde_json::json!({ + "mode": "opt", + "why": "seule voie vers du CUDA natif : llama.cpp ne publie aucun binaire CUDA pour Linux, le précompilé ne donnerait que du Vulkan. Compte plusieurs minutes de compilation.", + }); + } + serde_json::json!({ "mode": "fast", "why": "" }) +} + +/// `downloadWithProgress` : progression journalisée par tranches de ~25 Mo. +pub async fn download( + client: &reqwest::Client, + url: &str, + dest: &Path, + total: i64, + logf: &mut (dyn FnMut(String) + Send), +) -> Result<(), String> { + let resp = client + .get(url) + .header("User-Agent", "Go-http-client/1.1") + .send() + .await + .map_err(|e| e.to_string())?; + if resp.status().as_u16() != 200 { + return Err(format!("HTTP {}", resp.status().as_u16())); + } + let total = if total <= 0 { + resp.content_length().map_or(-1, |n| n as i64) + } else { + total + }; + let mut f = tokio::fs::File::create(dest) + .await + .map_err(|e| crate::paths::os_err("open", &dest.to_string_lossy(), &e))?; + let (mut done, mut last_log) = (0i64, 0i64); + let mut stream = resp.bytes_stream(); + while let Some(chunk) = stream.next().await { + let chunk = chunk.map_err(|e| e.to_string())?; + f.write_all(&chunk) + .await + .map_err(|e| crate::paths::os_err("write", &dest.to_string_lossy(), &e))?; + done += chunk.len() as i64; + if done - last_log >= 25 << 20 { + last_log = done; + if total > 0 { + logf(format!( + "⬇ {} / {} Mo ({}%)", + done / 1_000_000, + total / 1_000_000, + done * 100 / total + )); + } else { + logf(format!("⬇ {} Mo", done / 1_000_000)); + } + } + } + f.flush() + .await + .map_err(|e| crate::paths::os_err("write", &dest.to_string_lossy(), &e))?; + Ok(()) +} + +/// `extractArchive` : .zip ou .tar.gz dans `dir`, sans entrée qui +/// s'échapperait du dossier ; les liens des tar posés après coup. +pub fn extract(path: &Path, dir: &Path) -> Result<(), String> { + let dir_s = dir.to_string_lossy().into_owned(); + let safe = |name: &str| -> Result { + let p = crate::paths::go_join(&[&dir_s, name]); + let rel = go_rel(&crate::paths::go_clean(&dir_s), &p); + match rel { + Some(r) if !r.starts_with("..") => Ok(PathBuf::from(p)), + _ => Err(format!("entrée d'archive suspecte : {name}")), + } + }; + let ps = path.to_string_lossy(); + if ps.ends_with(".zip") || ps.ends_with(".zip.part") { + let f = std::fs::File::open(path).map_err(|e| crate::paths::os_err("open", &ps, &e))?; + let mut zr = zip::ZipArchive::new(f).map_err(|e| e.to_string())?; + for i in 0..zr.len() { + let mut e = zr.by_index(i).map_err(|e| e.to_string())?; + let name = e.name().to_string(); + let p = safe(&name)?; + if e.is_dir() { + mkdir_all(&p)?; + continue; + } + if let Some(parent) = p.parent() { + mkdir_all(parent)?; + } + let mut w = std::fs::File::create(&p) + .map_err(|err| crate::paths::os_err("open", &p.to_string_lossy(), &err))?; + std::io::copy(&mut e, &mut w).map_err(|err| err.to_string())?; + } + return Ok(()); + } + let f = std::fs::File::open(path).map_err(|e| crate::paths::os_err("open", &ps, &e))?; + let gz = flate2::read::GzDecoder::new(f); + let mut tr = tar::Archive::new(gz); + let mut links: Vec<(PathBuf, String)> = Vec::new(); + let entries = tr.entries().map_err(|e| e.to_string())?; + for entry in entries { + let mut entry = entry.map_err(|e| e.to_string())?; + let name = String::from_utf8_lossy(&entry.path_bytes()).into_owned(); + let p = safe(&name)?; + let kind = entry.header().entry_type(); + if kind.is_dir() { + mkdir_all(&p)?; + } else if kind.is_file() { + if let Some(parent) = p.parent() { + mkdir_all(parent)?; + } + let mut w = std::fs::File::create(&p) + .map_err(|err| crate::paths::os_err("open", &p.to_string_lossy(), &err))?; + let mut buf = Vec::new(); + entry.read_to_end(&mut buf).map_err(|e| e.to_string())?; + std::io::Write::write_all(&mut w, &buf).map_err(|e| e.to_string())?; + drop(w); + #[cfg(unix)] + if let Ok(mode) = entry.header().mode() { + use std::os::unix::fs::PermissionsExt; + let _ = std::fs::set_permissions(&p, std::fs::Permissions::from_mode(mode & 0o777)); + } + } else if kind.is_symlink() || kind.is_hard_link() { + let target = entry + .link_name_bytes() + .map(|b| String::from_utf8_lossy(&b).into_owned()) + .unwrap_or_default(); + let target = if kind.is_hard_link() { + safe(&target)?.to_string_lossy().into_owned() + } else { + target + }; + links.push((p, target)); + } + } + apply_links(&links) +} + +fn mkdir_all(p: &Path) -> Result<(), String> { + std::fs::create_dir_all(p).map_err(|e| crate::paths::os_err("mkdir", &p.to_string_lossy(), &e)) +} + +/// `applyLinks` : liens symboliques, ou copie de la cible quand le système +/// les refuse. +fn apply_links(links: &[(PathBuf, String)]) -> Result<(), String> { + for (path, target) in links { + let _ = std::fs::remove_file(path); + if let Some(parent) = path.parent() { + mkdir_all(parent)?; + } + #[cfg(unix)] + if std::os::unix::fs::symlink(target, path).is_ok() { + continue; + } + let src = if Path::new(target).is_absolute() { + PathBuf::from(target) + } else { + path.parent().unwrap_or(Path::new(".")).join(target) + }; + let Ok(data) = std::fs::read(&src) else { + continue; + }; + std::fs::write(path, data) + .map_err(|e| crate::paths::os_err("open", &path.to_string_lossy(), &e))?; + } + Ok(()) +} + +/// `filepath.Rel` pour deux chemins nettoyés, `base` absolu : `None` si +/// `targ` n'est pas exprimable relativement (l'un absolu, l'autre non). +fn go_rel(base: &str, targ: &str) -> Option { + if base.starts_with('/') != targ.starts_with('/') { + return None; + } + if base == targ { + return Some(".".into()); + } + let b: Vec<&str> = base.split('/').filter(|s| !s.is_empty()).collect(); + let t: Vec<&str> = targ.split('/').filter(|s| !s.is_empty()).collect(); + let common = b.iter().zip(&t).take_while(|(x, y)| x == y).count(); + let mut parts: Vec<&str> = vec![".."; b.len() - common]; + parts.extend(&t[common..]); + Some(parts.join("/")) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn rel() { + assert_eq!(go_rel("/a/b", "/a/b/c").as_deref(), Some("c")); + assert_eq!(go_rel("/a/b", "/a/c").as_deref(), Some("../c")); + assert_eq!(go_rel("/a/b", "/a/b").as_deref(), Some(".")); + assert_eq!(go_rel("/a/b", "/a/b..x").as_deref(), Some("../b..x")); + } + + #[test] + fn marqueur_version() { + let tmp = tempfile::tempdir().unwrap(); + let d = dir(tmp.path()); + std::fs::create_dir_all(&d).unwrap(); + for (content, tag, cuda, fmt) in [ + ("b300 fmt2\n", "b300", "", "fmt2"), + ("b300 12.4 fmt2\n", "b300", "12.4", "fmt2"), + ("b300 12.4\n", "b300", "12.4", ""), + ("b300 \n", "b300", "", ""), + ("fmt2\n", "fmt2", "", ""), + ("", "", "", ""), + ] { + std::fs::write(d.join("VERSION"), content).unwrap(); + assert_eq!( + version(tmp.path()), + (tag.into(), cuda.into()), + "{content:?}" + ); + assert_eq!(version_format(tmp.path()), fmt, "{content:?}"); + } + } + + /// ROCm reconnu à son dossier (/opt/rocm), sans hipcc dans le PATH. + #[tokio::test] + async fn rocm_par_son_dossier() { + let root = tempfile::tempdir().unwrap(); + let tools = tempfile::tempdir().unwrap(); + let probe = Probe { + path: Some(vec![tools.path().to_path_buf()]), + root: root.path().to_path_buf(), + }; + let asset = |n: &str| Asset { + name: n.into(), + url: String::new(), + size: 1, + }; + let assets = [ + asset("llama-b1-bin-ubuntu-rocm-7.0-x64.tar.gz"), + asset("llama-b1-bin-ubuntu-x64.tar.gz"), + ]; + let linux = Target { + os: "linux", + arm64: false, + }; + assert_eq!( + pick(&assets, &probe, linux).await.unwrap().label, + "CPU (Linux x64)" + ); + assert_eq!(probe.detect_build_plan().await.backend, "cpu"); + std::fs::create_dir_all(root.path().join("opt/rocm")).unwrap(); + assert_eq!( + pick(&assets, &probe, linux).await.unwrap().label, + "ROCm (Linux x64)" + ); + assert_eq!(probe.detect_build_plan().await.backend, "hip"); + } + + #[test] + fn reco() { + assert_eq!(recommended_mode("cuda", "linux")["mode"], "opt"); + assert_eq!(recommended_mode("cuda", "windows")["mode"], "fast"); + assert_eq!(recommended_mode("vulkan", "linux")["why"], ""); + } +} diff --git a/rust/loki-rs/src/backends/probe.rs b/rust/loki-rs/src/backends/probe.rs new file mode 100644 index 0000000..ccef7f4 --- /dev/null +++ b/rust/loki-rs/src/backends/probe.rs @@ -0,0 +1,428 @@ +//! Sondes de la machine : port de `detectBuildPlan` (le seul plan exposé : +//! backend, architectures CUDA, parallélisme), `findNvcc`, `hasNvidiaGPU`, +//! `detectCudaArch`, `hasTool`, `hasVulkanLib`, `driverCudaVersion` et +//! `detectGPUs` (`backend_build.go`, `backend_prebuilt.go`, `backend_gpu.go`). +//! +//! Les outils sont cherchés dans un PATH et les chemins système sous une +//! racine, tous deux injectables : les tests rejouent une machine sans toucher +//! à l'environnement du processus. + +use std::path::{Path, PathBuf}; +use std::process::Stdio; +use std::sync::LazyLock; +use std::time::Duration; + +use regex::Regex; +use tokio::process::Command; + +/// Délai de `nvidiaSmiGPUQuery` (`nvidiaSmiTimeout`). +const NVIDIA_SMI_TIMEOUT: Duration = Duration::from_secs(10); + +/// `gpuBaseFields` / `gpuLinkFields`. +const GPU_BASE_FIELDS: &str = "index,name,memory.total,memory.used,compute_cap"; +const GPU_LINK_FIELDS: &str = "pci.bus_id,pcie.link.gen.current,pcie.link.gen.max,pcie.link.width.current,pcie.link.width.max,clocks.max.memory"; + +/// La machine telle que les sondes la voient. +#[derive(Clone, Debug, Default)] +pub struct Probe { + /// Dossiers où chercher les outils ; `None` : le PATH du processus, relu + /// à chaque recherche (comme `exec.LookPath`). + pub path: Option>, + /// Racine des chemins système (`/usr/local/cuda`, `/opt/rocm`…) ; vide : + /// la vraie racine. + pub root: PathBuf, +} + +/// Le plan de compilation tel que le statut l'expose (`buildPlan`). +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct BuildPlan { + pub backend: &'static str, + pub cuda_arch: String, + pub jobs: usize, +} + +/// `gpuInfo` : une carte vue par nvidia-smi. +#[derive(Clone, Debug, Default, PartialEq, Eq)] +pub struct GpuInfo { + pub index: i64, + pub name: String, + pub mem_total: String, + pub mem_used: String, + pub cap: String, + pub bus_id: String, + pub link_gen: i64, + pub link_gen_max: i64, + pub link_width: i64, + pub link_width_max: i64, + pub mem_clock_max: i64, +} + +impl Probe { + /// La vraie machine. + pub fn system() -> Self { + Probe::default() + } + + /// Un chemin système sous la racine des sondes. + pub fn sys(&self, p: &str) -> PathBuf { + if self.root.as_os_str().is_empty() { + PathBuf::from(p) + } else { + self.root.join(p.trim_start_matches('/')) + } + } + + fn dirs(&self) -> Vec { + match &self.path { + Some(p) => p.clone(), + None => std::env::var_os("PATH") + .map(|p| std::env::split_paths(&p).collect()) + .unwrap_or_default(), + } + } + + /// `exec.LookPath` dans le PATH des sondes. + pub fn look_path(&self, name: &str) -> Option { + if name.contains('/') { + let p = PathBuf::from(name); + return is_exec(&p).then_some(p); + } + self.dirs() + .into_iter() + .map(|d| { + if d.as_os_str().is_empty() { + PathBuf::from(".").join(name) + } else { + d.join(name) + } + }) + .find(|p| is_exec(p)) + } + + /// `hasTool`. + pub fn has_tool(&self, name: &str) -> bool { + self.look_path(name).is_some() + } + + /// `exec.Command(name, args…).Output()` : la sortie standard si le + /// programme est trouvé et sort en succès. + pub async fn output(&self, name: &str, args: &[&str]) -> Option> { + self.output_in(None, name, args).await + } + + /// [`Probe::output`] lancé dans `dir`. + pub async fn output_in( + &self, + dir: Option<&Path>, + name: &str, + args: &[&str], + ) -> Option> { + let bin = self.look_path(name)?; + let mut c = Command::new(bin); + if let Some(d) = dir { + c.current_dir(d); + } + let out = c + .args(args) + .stdin(Stdio::null()) + .stderr(Stdio::null()) + .kill_on_drop(true) + .output() + .await + .ok()?; + out.status.success().then_some(out.stdout) + } + + /// `findNvcc` (Linux, macOS) : un vrai toolkit avant le PATH. + pub fn find_nvcc(&self) -> Option { + let p = self.sys("/usr/local/cuda/bin/nvcc"); + if is_file(&p) { + return Some(p); + } + let local = self.sys("/usr/local"); + let mut versioned: Vec = std::fs::read_dir(&local) + .map(|rd| { + rd.flatten() + .filter(|e| e.file_name().to_string_lossy().starts_with("cuda-")) + .map(|e| e.path().join("bin").join("nvcc")) + .filter(|p| std::fs::symlink_metadata(p).is_ok()) + .collect() + }) + .unwrap_or_default(); + versioned.sort(); + if let Some(p) = versioned.pop() { + return Some(p); + } + self.look_path("nvcc") + } + + /// `hasNvidiaGPU`. + pub async fn has_nvidia_gpu(&self) -> bool { + if !self.has_tool("nvidia-smi") { + return false; + } + self.output("nvidia-smi", &["-L"]) + .await + .is_some_and(|o| String::from_utf8_lossy(&o).contains("GPU")) + } + + /// `detectCudaArch` : « 8.6 » → « 86 », dédoublonnés, joints par « ; ». + pub async fn detect_cuda_arch(&self) -> String { + let Some(out) = self + .output( + "nvidia-smi", + &["--query-gpu=compute_cap", "--format=csv,noheader"], + ) + .await + else { + return String::new(); + }; + cuda_archs(&String::from_utf8_lossy(&out)) + } + + /// `hasVulkanLib` : le cache de ldconfig, puis des chemins connus. + pub async fn has_vulkan_lib(&self) -> bool { + if let Some(out) = self.output("ldconfig", &["-p"]).await + && String::from_utf8_lossy(&out).contains("libvulkan.so") + { + return true; + } + [ + "/usr/lib/x86_64-linux-gnu/libvulkan.so.1", + "/usr/lib64/libvulkan.so.1", + "/usr/lib/libvulkan.so.1", + "/lib64/libvulkan.so.1", + ] + .iter() + .any(|p| is_file(&self.sys(p))) + } + + /// `detectBuildPlan`, réduit à ce que le statut expose. + pub async fn detect_build_plan(&self) -> BuildPlan { + let mut p = BuildPlan { + backend: "cpu", + cuda_arch: String::new(), + jobs: num_jobs(), + }; + if cfg!(target_os = "macos") { + p.backend = "metal"; + return p; + } + if self.find_nvcc().is_some() && self.has_nvidia_gpu().await { + p.backend = "cuda"; + p.cuda_arch = self.detect_cuda_arch().await; + return p; + } + if self.has_tool("hipcc") || is_dir(&self.sys("/opt/rocm")) { + p.backend = "hip"; + return p; + } + if self.has_tool("glslc") && (self.has_vulkan_lib().await || self.has_tool("vulkaninfo")) { + p.backend = "vulkan"; + } + p + } + + /// `driverCudaVersion` : la version CUDA du bandeau de nvidia-smi, 0 si + /// inconnue. + pub async fn driver_cuda_version(&self) -> f64 { + let Some(out) = self.output("nvidia-smi", &[]).await else { + return 0.0; + }; + static RE: LazyLock = + LazyLock::new(|| Regex::new(r"CUDA Version:\s*([0-9]+\.[0-9]+)").expect("motif")); + RE.captures(&String::from_utf8_lossy(&out)) + .and_then(|m| m[1].parse().ok()) + .unwrap_or(0.0) + } + + /// `nvidiaSmiGPUQuery` : (sortie, code de sortie) ; `None` = pas lancé ou + /// délai dépassé. + async fn smi_query(&self, fields: &str) -> Option<(Vec, i32)> { + let bin = self.look_path("nvidia-smi")?; + let child = Command::new(bin) + .arg(format!("--query-gpu={fields}")) + .arg("--format=csv,noheader,nounits") + .stdin(Stdio::null()) + .stdout(Stdio::piped()) + .stderr(Stdio::null()) + .kill_on_drop(true) + .spawn() + .ok()?; + let out = tokio::time::timeout(NVIDIA_SMI_TIMEOUT, child.wait_with_output()) + .await + .ok()? + .ok()?; + Some((out.stdout, out.status.code().unwrap_or(-1))) + } + + /// `detectGPUs` : les cartes NVIDIA, champs de liaison compris quand le + /// pilote les connaît. + pub async fn detect_gpus(&self) -> Result, String> { + if !self.has_tool("nvidia-smi") { + return Err( + "nvidia-smi introuvable — sélection GPU disponible uniquement sur NVIDIA".into(), + ); + } + let mut res = self + .smi_query(&format!("{GPU_BASE_FIELDS},{GPU_LINK_FIELDS}")) + .await; + if let Some((out, code)) = &res + && *code != 0 + && (String::from_utf8_lossy(out).contains("valid field") || *code == 2) + { + res = self.smi_query(GPU_BASE_FIELDS).await; + } + let out = match res { + Some((out, 0)) => out, + Some((_, code)) => return Err(format!("nvidia-smi a échoué: exit status {code}")), + None => return Err("nvidia-smi a échoué".into()), + }; + let gpus = parse_gpu_query(&String::from_utf8_lossy(&out)); + if gpus.is_empty() { + return Err("aucun GPU NVIDIA détecté".into()); + } + Ok(gpus) + } +} + +/// Les architectures CUDA d'une sortie `compute_cap` (`detectCudaArch`). +pub fn cuda_archs(out: &str) -> String { + let mut archs: Vec = Vec::new(); + for line in out.trim().split('\n') { + let cap = line.trim(); + if cap.is_empty() || cap.to_lowercase().contains("not supported") { + continue; + } + let code = cap.replace('.', ""); + if !code.is_empty() && !archs.contains(&code) { + archs.push(code); + } + } + archs.join(";") +} + +/// `parseGPUQuery`. +pub fn parse_gpu_query(out: &str) -> Vec { + let mut gpus = Vec::new(); + for line in out.trim().split('\n') { + let parts: Vec<&str> = line.split(',').map(str::trim).collect(); + if parts.len() < 5 { + continue; + } + let mut g = GpuInfo { + index: parts[0].parse().unwrap_or(0), + name: parts[1].into(), + mem_total: parts[2].into(), + mem_used: parts[3].into(), + cap: parts[4].into(), + ..GpuInfo::default() + }; + if parts.len() >= 11 { + let b = parts[5]; + if !b.is_empty() && b != "N/A" && !b.starts_with('[') { + g.bus_id = b.into(); + } + g.link_gen = smi_num(parts[6]); + g.link_gen_max = smi_num(parts[7]); + g.link_width = smi_num(parts[8]); + g.link_width_max = smi_num(parts[9]); + g.mem_clock_max = smi_num(parts[10]); + } + gpus.push(g); + } + gpus +} + +/// `smiNum` : « [N/A] », vide ou négatif → 0. +pub fn smi_num(s: &str) -> i64 { + match s.trim().parse::() { + Ok(n) if n >= 0 => n, + _ => 0, + } +} + +/// `numJobs` : `runtime.NumCPU()`, c'est-à-dire les processeurs de +/// l'affinité du processus (sans les quotas de cgroup, que le Go ignore). +pub fn num_jobs() -> usize { + #[cfg(target_os = "linux")] + { + // SAFETY: cpu_set_t est une simple table de bits, initialisée à zéro + // avant que sched_getaffinity ne la remplisse. + unsafe { + let mut set: libc::cpu_set_t = std::mem::zeroed(); + if libc::sched_getaffinity(0, std::mem::size_of::(), &mut set) == 0 { + let n = libc::CPU_COUNT(&set); + if n > 0 { + return n as usize; + } + } + } + } + std::thread::available_parallelism().map_or(1, |n| n.get()) +} + +fn is_exec(p: &Path) -> bool { + std::fs::metadata(p).is_ok_and(|m| { + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + m.is_file() && m.permissions().mode() & 0o111 != 0 + } + #[cfg(not(unix))] + { + m.is_file() + } + }) +} + +/// `isFile`. +pub fn is_file(p: &Path) -> bool { + std::fs::metadata(p).is_ok_and(|m| !m.is_dir()) +} + +/// `isDir`. +pub fn is_dir(p: &Path) -> bool { + std::fs::metadata(p).is_ok_and(|m| m.is_dir()) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn archs() { + assert_eq!(cuda_archs("8.6\n8.9\n8.6\n"), "86;89"); + assert_eq!(cuda_archs("[Not Supported]\n12.0"), "120"); + assert_eq!(cuda_archs(""), ""); + } + + #[test] + fn requete_gpu() { + let g = parse_gpu_query( + "0, A, 100, 1, 8.6, 00000000:01:00.0, 1, 4, 16, 16, 9751\n1, B, 8, 0, 7.5, [N/A], 2, 3, 8, 16, 7001\n2, C, 8, 0, 7.5, N/A, x, 3, 8, 16, 1\n3, D, 1, 0\n4, E, 2, 1, 6.1", + ); + assert_eq!(g.len(), 4); + assert_eq!(g[0].bus_id, "00000000:01:00.0"); + assert_eq!((g[0].link_gen, g[0].mem_clock_max), (1, 9751)); + assert_eq!(g[1].bus_id, ""); + assert_eq!(g[2].bus_id, ""); + assert_eq!(g[2].link_gen, 0); + assert_eq!( + (g[3].index, g[3].name.as_str(), g[3].bus_id.as_str()), + (4, "E", "") + ); + } + + #[test] + fn smi() { + assert_eq!(smi_num(" 4 "), 4); + assert_eq!(smi_num("[N/A]"), 0); + assert_eq!(smi_num("-1"), 0); + } + + #[test] + fn jobs_positif() { + assert!(num_jobs() >= 1); + } +} diff --git a/rust/loki-rs/src/lib.rs b/rust/loki-rs/src/lib.rs index 56424dc..980da5d 100644 --- a/rust/loki-rs/src/lib.rs +++ b/rust/loki-rs/src/lib.rs @@ -12,6 +12,7 @@ pub mod api; pub mod auth; +pub mod backends; pub mod chat; pub mod chatapi; pub mod chatx; diff --git a/rust/loki-rs/src/main.rs b/rust/loki-rs/src/main.rs index 099d51f..ee1c242 100644 --- a/rust/loki-rs/src/main.rs +++ b/rust/loki-rs/src/main.rs @@ -163,6 +163,10 @@ fn serve() -> ExitCode { // (l'image se met à jour par Docker). let update_rust = held_by_facade("LOKI_UPDATE") && loki_rs::sysupdate::usable(std::env::var("LOKI_CONTAINER").ok().as_deref()); + // LOKI_BACKENDS=rust : les backends llama.cpp (statut, précompilé, + // bascule, GPU) et LE job des moteurs, auquel les jobs du Go + // (compilation, moteur de l'image, Strata) rapportent leurs étapes. + let backends_rust = held_by_facade("LOKI_BACKENDS"); let engine_rust = held_by_facade("LOKI_ENGINE") && match loki_rs::enginectl::usable( std::env::var("LOKI_CONTAINER").ok().as_deref(), @@ -194,6 +198,7 @@ fn serve() -> ExitCode { ("LOKI_PROJECTS", projects_rust), ("LOKI_MCP", mcp_rust), ("LOKI_UPDATE", update_rust), + ("LOKI_BACKENDS", backends_rust), ]; for (var, on) in held { if on { @@ -245,6 +250,21 @@ fn serve() -> ExitCode { std::sync::Arc::new(loki_rs::dictate::Dictation::new(&engine_paths.home)) }); + // Le dernier job connu, repris de la base (un job « en cours » est mort + // avec le processus précédent). + let backends = if backends_rust { + let b = std::sync::Arc::new(loki_rs::backends::Backends::new( + engine_paths.home.clone(), + std::env::var("LOKI_ENGINE_BIN").unwrap_or_default(), + )); + b.jobs + .restore(&loki_rs::store::Store::new(backend, token.clone())) + .await; + Some(b) + } else { + None + }; + let files = if files_rust { Some(start_files(backend, &token).await) } else { @@ -300,6 +320,7 @@ fn serve() -> ExitCode { system: system_rust.then(|| std::sync::Arc::new(loki_rs::system::System::new())), projects: projects_rust, update: update_rust, + backends, mcp: mcp_rust.then(|| std::sync::Arc::new(loki_rs::mcp::Mcp::new(mem_home))), }; let res = proxy::serve(listener, cfg, shutdown_signal()).await; diff --git a/rust/loki-rs/src/proxy.rs b/rust/loki-rs/src/proxy.rs index d014718..8e26eaa 100644 --- a/rust/loki-rs/src/proxy.rs +++ b/rust/loki-rs/src/proxy.rs @@ -93,6 +93,9 @@ pub struct Config { /// Mise à jour de Loki tenue par la façade (`LOKI_UPDATE=rust`, en /// conteneur). pub update: bool, + /// Backends llama.cpp (statut, précompilé, bascule, GPU) et job des + /// moteurs tenus par la façade (`LOKI_BACKENDS=rust`) ; `None` : au Go. + pub backends: Option>, } struct Proxy { @@ -132,6 +135,7 @@ pub async fn serve( projects: cfg.projects, mcp: cfg.mcp, update: cfg.update, + backends: cfg.backends, internal_token: cfg.internal_token.clone(), }, }); @@ -206,6 +210,12 @@ impl Proxy { { return crate::mcp::serve_internal(&self.ctx, m, req).await; } + if from_go + && let Some(b) = &self.ctx.backends + && crate::backends::is_internal(path) + { + return crate::backends::serve_internal(&self.ctx, b, req).await; + } // L'API interne du Go n'est jamais joignable de l'extérieur. if path.starts_with("/internal/") { return plain(StatusCode::NOT_FOUND, "404 page not found"); @@ -230,6 +240,11 @@ impl Proxy { { return chatapi::serve(&self.ctx, chat, req, from_go).await; } + if let Some(b) = &self.ctx.backends + && crate::backends::handles(path) + { + return crate::backends::serve(&self.ctx, b, req, from_go).await; + } if self.ctx.update && crate::sysupdate::handles(path) { return crate::sysupdate::serve(&self.ctx, req, from_go).await; } diff --git a/rust/loki-rs/src/serve/run.rs b/rust/loki-rs/src/serve/run.rs index ed73ac3..d6d38d3 100644 --- a/rust/loki-rs/src/serve/run.rs +++ b/rust/loki-rs/src/serve/run.rs @@ -189,7 +189,7 @@ impl EnginePaths { !p.is_empty() && norm_path(p).starts_with(&(norm_path(&self.engine_dir()) + "/")) } /// `prebuiltServerBin` : le binaire du VERSION, sinon le dernier. - fn prebuilt_server_bin(&self) -> String { + pub fn prebuilt_server_bin(&self) -> String { let mut found = Vec::new(); fn walk(d: &Path, out: &mut Vec) { let Ok(rd) = std::fs::read_dir(d) else { return }; @@ -229,7 +229,7 @@ impl EnginePaths { if cur.is_empty() { bin.into() } else { cur } } /// `providedEngineBin`. - fn provided_engine_bin(&self) -> String { + pub fn provided_engine_bin(&self) -> String { let p = std::env::var("LOKI_ENGINE_BIN").unwrap_or_default(); let p = trim(&p); if p.is_empty() { diff --git a/rust/loki-rs/src/system.rs b/rust/loki-rs/src/system.rs index 018b459..59b1b1a 100644 --- a/rust/loki-rs/src/system.rs +++ b/rust/loki-rs/src/system.rs @@ -171,7 +171,7 @@ pub fn key_hint(key: &str) -> String { /// Octets → texte comme l'encodeur JSON du Go : chaque octet invalide devient /// U+FFFD. -fn go_lossy(mut b: &[u8]) -> String { +pub(crate) fn go_lossy(mut b: &[u8]) -> String { let mut out = String::new(); loop { match std::str::from_utf8(b) { diff --git a/rust/loki-rs/tests/backends.rs b/rust/loki-rs/tests/backends.rs new file mode 100644 index 0000000..f88c412 --- /dev/null +++ b/rust/loki-rs/tests/backends.rs @@ -0,0 +1,786 @@ +//! Lot 22 : backends llama.cpp tenus par la façade (`LOKI_BACKENDS=rust`). +//! +//! Le journal tiré du Go (`internal/loki/parity_backends_test.go` → +//! `tests/parity_backends/backends.json`) est rejoué par une seule façade : +//! statut (dépôt git, précompilé, moteur téléchargé ou de l'image, plan de +//! build et conseil), vérification git, backends custom et désinstallation, +//! bascule fast/opt, dernière release, job des binaires précompilés +//! (extraction avec liens, ménage, VERSION, échecs), GPU vus par un moteur ; +//! puis le plan et la release machine par machine (faux outils dans le PATH), +//! le job pas à pas et sa reprise au démarrage. Le reste — l'API interne par +//! laquelle les jobs du Go rapportent leurs étapes, le service arrêté puis +//! relancé autour de l'installation — est vérifié contre le faux Go. + +mod common; + +use std::collections::BTreeMap; +use std::convert::Infallible; +use std::net::SocketAddr; +use std::path::{Path, PathBuf}; +use std::sync::{Arc, Mutex}; +use std::time::Duration; + +use bytes::Bytes; +use common::fakego; +use http_body_util::Full; +use hyper::service::service_fn; +use hyper::{Request, Response}; +use hyper_util::rt::TokioIo; +use loki_rs::backends::job::Jobs; +use loki_rs::backends::probe::Probe; +use loki_rs::backends::{Backends, prebuilt}; +use regex::Regex; +use serde_json::{Value, json}; +use tokio::net::TcpListener; + +fn fixture() -> Value { + serde_json::from_str(include_str!("parity_backends/backends.json")).expect("journal") +} + +fn s(v: &Value) -> &str { + v.as_str().unwrap_or("") +} + +const TUNE_BUSY: &str = "optimisation en cours (loki tune) : le moteur est réservé aux essais — attends la fin ou annule-la"; + +/// Le faux GitHub : la release courante (`{status, json, assets}`). +async fn fake_github(rel: Arc>) -> String { + let l = TcpListener::bind("127.0.0.1:0").await.unwrap(); + let addr = l.local_addr().unwrap(); + tokio::spawn(async move { + while let Ok((st, _)) = l.accept().await { + let rel = rel.clone(); + tokio::spawn(async move { + let svc = service_fn(move |req: Request| { + let cur = rel.lock().unwrap().clone(); + async move { + let host = req + .headers() + .get("host") + .and_then(|h| h.to_str().ok()) + .unwrap_or("") + .to_string(); + let path = req.uri().path().to_string(); + let (code, body) = if path == "/latest" { + ( + cur["status"].as_u64().unwrap_or(200) as u16, + s(&cur["json"]) + .replace("{{GH}}", &format!("http://{host}")) + .into_bytes(), + ) + } else { + let name = path.trim_start_matches("/dl/"); + match cur["assets"].get(name).and_then(Value::as_array) { + Some(entries) => (200, tar_gz(entries)), + None => (404, b"404 page not found\n".to_vec()), + } + }; + let mut r = Response::new(Full::new(Bytes::from(body))); + *r.status_mut() = hyper::StatusCode::from_u16(code).unwrap(); + Ok::<_, Infallible>(r) + } + }); + let _ = hyper::server::conn::http1::Builder::new() + .serve_connection(TokioIo::new(st), svc) + .await; + }); + } + }); + format!("http://{addr}") +} + +/// Une archive .tar.gz ; les noms sont écrits tels quels (« ../x » compris). +fn tar_gz(entries: &[Value]) -> Vec { + let gz = flate2::write::GzEncoder::new(Vec::new(), flate2::Compression::default()); + let mut b = tar::Builder::new(gz); + for e in entries { + let mut h = tar::Header::new_ustar(); + let name = s(&e["name"]).as_bytes(); + h.as_old_mut().name[..name.len()].copy_from_slice(name); + let link = s(&e["link"]).as_bytes(); + h.as_old_mut().linkname[..link.len()].copy_from_slice(link); + h.set_mtime(1_700_000_000); + h.set_mode(e["mode"].as_u64().unwrap_or(0) as u32); + let body = s(&e["body"]).as_bytes().to_vec(); + match s(&e["type"]) { + "dir" => h.set_entry_type(tar::EntryType::Directory), + "symlink" => { + h.set_entry_type(tar::EntryType::Symlink); + h.set_mode(0o777); + } + "hardlink" => h.set_entry_type(tar::EntryType::Link), + _ => { + h.set_entry_type(tar::EntryType::Regular); + h.set_size(body.len() as u64); + } + } + if s(&e["type"]) != "file" { + h.set_size(0); + } + h.set_cksum(); + let data: &[u8] = if s(&e["type"]) == "file" { &body } else { &[] }; + b.append(&h, data).unwrap(); + } + b.into_inner().unwrap().finish().unwrap() +} + +fn write_file(p: &Path, content: &str, mode: u32) { + use std::os::unix::fs::OpenOptionsExt; + let _ = std::fs::remove_file(p); + let mut f = std::fs::OpenOptions::new() + .write(true) + .create_new(true) + .mode(mode) + .open(p) + .unwrap(); + std::io::Write::write_all(&mut f, content.as_bytes()).unwrap(); +} + +fn remove_all(p: &Path) { + match std::fs::symlink_metadata(p) { + Ok(m) if m.is_dir() => std::fs::remove_dir_all(p).unwrap(), + Ok(_) => std::fs::remove_file(p).unwrap(), + Err(_) => {} + } +} + +/// Les fichiers d'une étape (`parityApplyFiles`). +fn apply_files(home: &Path, files: &Value) { + for (n, v) in files.as_object().into_iter().flatten() { + let v = s(v); + let p = home.join(n.trim_end_matches('/')); + match v { + "-" => { + remove_all(&p); + continue; + } + "/" => { + std::fs::create_dir_all(&p).unwrap(); + continue; + } + _ => {} + } + std::fs::create_dir_all(p.parent().unwrap()).unwrap(); + if let Some(target) = v.strip_prefix("-> ") { + let _ = std::fs::remove_file(&p); + std::os::unix::fs::symlink(target, &p).unwrap(); + continue; + } + write_file(&p, v, if v.starts_with("#!") { 0o755 } else { 0o644 }); + } +} + +/// Les opérations git d'une étape (`parityGit`). +fn git_op(home: &Path, op: &str) { + let f: Vec<&str> = op.splitn(4, ' ').collect(); + let git = |dir: &Path, date: &str, args: &[&str]| { + let out = std::process::Command::new("git") + .args(args) + .current_dir(dir) + .env("GIT_CONFIG_GLOBAL", "/dev/null") + .env("GIT_CONFIG_NOSYSTEM", "1") + .env("GIT_AUTHOR_NAME", "Loki") + .env("GIT_AUTHOR_EMAIL", "loki@exemple.fr") + .env("GIT_COMMITTER_NAME", "Loki") + .env("GIT_COMMITTER_EMAIL", "loki@exemple.fr") + .env("GIT_AUTHOR_DATE", date) + .env("GIT_COMMITTER_DATE", date) + .output() + .unwrap(); + assert!(out.status.success(), "git {args:?} : {out:?}"); + }; + match f[0] { + "init" => { + let d = home.join(f[1]); + std::fs::create_dir_all(&d).unwrap(); + git(&d, "", &["init", "-q", "-b", "master"]); + } + "commit" => { + let d = home.join(f[1]); + std::fs::write(d.join("README"), f[3]).unwrap(); + git(&d, f[2], &["add", "README"]); + git(&d, f[2], &["commit", "-q", "-m", f[3]]); + } + "clone" => { + let (src, dst) = (home.join(f[1]), home.join(f[2])); + git( + home, + "", + &[ + "clone", + "-q", + &src.to_string_lossy(), + &dst.to_string_lossy(), + ], + ); + } + "pull" => git(&home.join(f[1]), "", &["pull", "-q", "--ff-only"]), + "detach" => git(&home.join(f[1]), "", &["checkout", "-q", "--detach"]), + "remote-bad" => { + let bad = home.join("nulle-part"); + git( + &home.join(f[1]), + "", + &["remote", "set-url", "origin", &bad.to_string_lossy()], + ); + } + other => panic!("opération git inconnue : {other}"), + } +} + +/// Les outils d'une machine dans le PATH des sondes (git compris). +fn set_machine(tools: &Path, machines: &Value, name: &str) { + for e in std::fs::read_dir(tools).unwrap().flatten() { + std::fs::remove_file(e.path()).unwrap(); + } + let git = loki_rs::paths::look_path("git").expect("git"); + std::os::unix::fs::symlink(git, tools.join("git")).unwrap(); + for (tool, script) in machines[name].as_object().into_iter().flatten() { + write_file(&tools.join(tool), s(script), 0o755); + } +} + +/// `info.Mode().Perm().String()`. +fn perm_string(mode: u32) -> String { + let mut out = String::from("-"); + for shift in [6, 3, 0] { + let b = (mode >> shift) & 7; + out.push(if b & 4 != 0 { 'r' } else { '-' }); + out.push(if b & 2 != 0 { 'w' } else { '-' }); + out.push(if b & 1 != 0 { 'x' } else { '-' }); + } + out +} + +/// `parityTree`. +fn tree(home: &Path) -> Vec { + use std::os::unix::fs::PermissionsExt; + fn walk(home: &Path, p: &Path, out: &mut Vec) { + let Ok(m) = std::fs::symlink_metadata(p) else { + return; + }; + let rel = p.strip_prefix(home).unwrap().to_string_lossy().into_owned(); + if rel.contains("/.git/") || rel.ends_with("/.git") { + return; + } + if m.file_type().is_symlink() { + let t = std::fs::read_link(p).unwrap(); + out.push(format!("l {rel} -> {}", t.to_string_lossy())); + } else if m.is_dir() { + out.push(format!("d {rel}")); + for e in std::fs::read_dir(p).unwrap().flatten() { + walk(home, &e.path(), out); + } + } else { + let b = std::fs::read(p).unwrap(); + out.push(format!( + "f {rel} {} {}", + perm_string(m.permissions().mode() & 0o777), + String::from_utf8_lossy(&b) + )); + } + } + let mut out = Vec::new(); + for root in ["backends", "engine"] { + walk(home, &home.join(root), &mut out); + } + out.sort(); + out +} + +struct Bench { + facade: SocketAddr, + go: Arc, + _stop: tokio::sync::oneshot::Sender<()>, + home: PathBuf, + tools: PathBuf, + b: Arc, + rel: Arc>, + gh: String, + _tmp: Vec, +} + +async fn bench() -> Bench { + let tmp = tempfile::tempdir().unwrap(); + let tools = tempfile::tempdir().unwrap(); + let root = tempfile::tempdir().unwrap(); + let home = tmp.path().to_path_buf(); + let rel = Arc::new(Mutex::new(json!({ "status": 200, "json": "{}" }))); + let gh = fake_github(rel.clone()).await; + let b = Arc::new(Backends::with( + home.clone(), + String::new(), + Probe { + path: Some(vec![tools.path().to_path_buf()]), + root: root.path().to_path_buf(), + }, + format!("{gh}/latest"), + )); + let (facade, go, stop, _) = fakego::start_with( + home.clone(), + fakego::Opts { + backends: Some(b.clone()), + ..Default::default() + }, + ) + .await; + Bench { + facade, + go, + _stop: stop, + home, + tools: tools.path().to_path_buf(), + b, + rel, + gh, + _tmp: vec![tmp, tools, root], + } +} + +impl Bench { + fn mask(&self, v: &str) -> String { + let ts = Regex::new(r#""(started_at|ended_at)":[1-9][0-9]*"#).unwrap(); + let jobs = Regex::new(r#""jobs":[0-9]+"#).unwrap(); + let v = v + .replace(&self.gh, "{{GH}}") + .replace(&self.home.to_string_lossy().into_owned(), "{{HOME}}"); + let v = jobs.replace_all(&v, r#""jobs":"{{JOBS}}""#); + ts.replace_all(&v, r#""$1":"{{T}}""#).into_owned() + } + + fn unmask(&self, v: &str) -> String { + v.replace("{{HOME}}", &self.home.to_string_lossy()) + } + + async fn call(&self, method: &str, path: &str, body: &str) -> (u16, Value) { + let (code, b) = fakego::send(self.facade, method, path, &[], body).await; + let out = serde_json::from_str(&self.mask(&String::from_utf8_lossy(&b))) + .unwrap_or_else(|_| panic!("{path} : corps illisible {b:?}")); + (code.as_u16(), out) + } + + async fn wait_job(&self) { + for _ in 0..1500 { + let (_, v) = self.call("GET", "/api/llamacpp/job", "").await; + if v["running"] != json!(true) { + return; + } + tokio::time::sleep(Duration::from_millis(20)).await; + } + panic!("le job ne finit pas"); + } +} + +#[tokio::test] +async fn parite_des_routes() { + let fx = fixture(); + if (fx["goarch"] == "arm64") != cfg!(target_arch = "aarch64") { + eprintln!("journal tiré sur une autre architecture : rejeu sauté"); + return; + } + let bn = bench().await; + let machines = &fx["machines"]; + set_machine(&bn.tools, machines, "nu"); + for st in fx["steps"].as_array().unwrap() { + let name = s(&st["name"]); + if let Some(m) = st["machine"].as_str() { + set_machine(&bn.tools, machines, m); + } + for g in st["git"].as_array().into_iter().flatten() { + git_op(&bn.home, s(g)); + } + let files: serde_json::Map = st["files"] + .as_object() + .into_iter() + .flatten() + .map(|(k, v)| (k.clone(), Value::String(bn.unmask(s(v))))) + .collect(); + apply_files(&bn.home, &Value::Object(files)); + for (k, v) in st["config"].as_object().into_iter().flatten() { + let v = bn.unmask(s(v)); + if v.is_empty() { + bn.go.del("config", k); + } else { + bn.go.set("config", k, &v); + } + } + if let Some(e) = st["engine_env"].as_str() { + bn.b.set_engine_bin_env(&bn.unmask(e)); + } + if !st["release"].is_null() { + *bn.rel.lock().unwrap() = st["release"].clone(); + } + *bn.go.tune_busy.lock().unwrap() = + (st["tune"].as_bool() == Some(true)).then(|| TUNE_BUSY.to_string()); + let (code, out) = bn + .call(s(&st["method"]), s(&st["path"]), &bn.unmask(s(&st["body"]))) + .await; + *bn.go.tune_busy.lock().unwrap() = None; + assert_eq!(code as i64, st["status"].as_i64().unwrap(), "{name}"); + assert_eq!(out, st["out"], "{name}"); + if st["wait"].as_bool() == Some(true) { + bn.wait_job().await; + let (_, job) = bn.call("GET", "/api/llamacpp/job?from=0", "").await; + assert_eq!(job, st["job"], "{name} : job"); + } + let mut after = BTreeMap::new(); + if let Some(v) = bn.go.get("config", "BIN").filter(|v| !v.is_empty()) { + after.insert("config/BIN".to_string(), Value::String(bn.mask(&v))); + } + if let Some(v) = bn.go.get("state", "build_job").filter(|v| !v.is_empty()) { + after.insert("state/build_job".to_string(), Value::String(bn.mask(&v))); + } + for f in ["loki-build.log", "devices.json"] { + if let Ok(b) = std::fs::read(bn.home.join(f)) { + after.insert( + f.to_string(), + Value::String(bn.mask(&String::from_utf8_lossy(&b))), + ); + } + } + assert_eq!( + Value::Object(after.into_iter().collect()), + st["after"], + "{name} : après" + ); + let t: Vec = tree(&bn.home) + .into_iter() + .map(|l| Value::String(bn.mask(&l))) + .collect(); + // Une liste vide est nil côté Go : null. + let t = if t.is_empty() { + Value::Null + } else { + Value::Array(t) + }; + assert_eq!(t, st["tree"], "{name} : arborescence"); + } + + // Plan, conseil et release, machine par machine. + *bn.rel.lock().unwrap() = fx["plan_release"].clone(); + for p in fx["plans"].as_array().unwrap() { + let m = s(&p["machine"]); + set_machine(&bn.tools, machines, m); + let (_, out) = bn.call("GET", "/api/llamacpp", "").await; + assert_eq!(out["plan"], p["plan"], "{m} : plan"); + assert_eq!(out["reco"], p["reco"], "{m} : conseil"); + let (_, check) = bn.call("POST", "/api/llamacpp/prebuilt/check", "").await; + assert_eq!(check, p["check"], "{m} : release"); + } + for (machine, key) in [ + ("vulkaninfo-seul", "sans_vulkan"), + ("rocm", "rocm_sans_asset"), + ] { + set_machine(&bn.tools, machines, machine); + *bn.rel.lock().unwrap() = fx[key]["release"].clone(); + let (_, check) = bn.call("POST", "/api/llamacpp/prebuilt/check", "").await; + assert_eq!(check, fx[key]["out"], "{key}"); + } +} + +/// `norm` du journal : les instants masqués. +fn norm(v: &Value) -> Value { + let ts = Regex::new(r#""(started_at|ended_at)":[1-9][0-9]*"#).unwrap(); + serde_json::from_str(&ts.replace_all(&v.to_string(), r#""$1":"{{T}}""#)).unwrap() +} + +#[tokio::test] +async fn parite_du_job_pas_a_pas() { + let fx = fixture(); + let bn = bench().await; + let backend = bn.go.backend.lock().unwrap().unwrap(); + let store = loki_rs::store::Store::new(backend, fakego::JETON.into()); + let ts = Regex::new(r#""(started_at|ended_at)":[1-9][0-9]*"#).unwrap(); + let state = || { + ts.replace_all( + &bn.go.get("state", "build_job").unwrap_or_default(), + r#""$1":"{{T}}""#, + ) + .into_owned() + }; + let jobs = Jobs::new(bn.home.clone()); + let job = &fx["job"]; + for o in job["ops"].as_array().unwrap() { + let (op, arg) = (s(&o["op"]), s(&o["arg"])); + let from = o["from"].as_i64().unwrap_or(0); + let got = match op { + "start" => Some(Value::String(match jobs.start(&store, arg).await { + Ok(()) => "ok".into(), + Err(e) => e, + })), + "line" => { + jobs.append(&store, arg).await; + None + } + "lines" => { + for _ in 0..4002 { + jobs.append(&store, &format!("[{arg}] bruit")).await; + } + None + } + "phase" => { + jobs.phase(&store, arg).await; + None + } + "fail" => { + jobs.fail(&store, arg).await; + None + } + "done" => { + jobs.done(&store, arg).await; + None + } + "old" => { + jobs.set_commits(Some(arg), None).await; + None + } + "new" => { + jobs.set_commits(None, Some(arg)).await; + None + } + "end" => { + jobs.end(&store).await; + None + } + "snapshot" => Some(norm(&jobs.snapshot(from, true).await)), + "summary" => { + let mut v = jobs.snapshot(from, true).await; + let lines = v["lines"].as_array().unwrap().clone(); + v["lines"] = json!({ + "n": lines.len(), + "first": lines[0], + "last": lines[lines.len() - 1], + }); + Some(norm(&v)) + } + "head" => Some(norm(&jobs.snapshot(0, false).await)), + "state" => Some(Value::String(state())), + other => panic!("op inconnue {other}"), + }; + assert_eq!(got.unwrap_or(Value::Null), o["out"], "{o}"); + } + let log = std::fs::read_to_string(jobs.log_path()).unwrap(); + assert_eq!( + log.matches('\n').count() as i64, + job["log_lines"].as_i64().unwrap() + ); + + for rc in job["restores"].as_array().unwrap() { + let st = s(&rc["state"]); + if st.is_empty() { + bn.go.del("state", "build_job"); + } else { + bn.go.set("state", "build_job", st); + } + let jobs = Jobs::new(bn.home.clone()); + let _ = std::fs::remove_file(jobs.log_path()); + if let Some(l) = rc["log"].as_str() { + std::fs::write(jobs.log_path(), l).unwrap(); + } + jobs.restore(&store).await; + assert_eq!(norm(&jobs.snapshot(0, true).await), rc["out"], "{rc}"); + assert_eq!(state(), s(&rc["after"]), "{rc} : base"); + } + let jobs = Jobs::new(bn.home.clone()); + bn.go.set( + "state", + "build_job", + r#"{"action":"update","running":false}"#, + ); + std::fs::write(jobs.log_path(), "ligne\n".repeat(4003)).unwrap(); + jobs.restore(&store).await; + assert_eq!(norm(&jobs.snapshot(4000, true).await), job["long_restore"]); +} + +/// Les étapes des jobs du Go, par l'API interne (jeton du Go seulement). +#[tokio::test] +async fn api_interne_du_job() { + let bn = bench().await; + let tok = [("X-Loki-Internal", fakego::JETON)]; + let post = |op: &'static str, body: &'static str| { + let f = bn.facade; + async move { + let (c, b) = fakego::send( + f, + "POST", + &format!("/internal/facade/v1/build-job/{op}"), + &tok, + body, + ) + .await; + (c.as_u16(), String::from_utf8_lossy(&b).into_owned()) + } + }; + // Sans le jeton : l'API interne n'existe pas. + let (c, _) = fakego::send( + bn.facade, + "POST", + "/internal/facade/v1/build-job/start", + &[], + r#"{"action":"engine"}"#, + ) + .await; + assert_eq!(c.as_u16(), 404); + assert_eq!(post("start", r#"{"action":"engine"}"#).await.0, 200); + let (c, b) = post("start", r#"{"action":"strata"}"#).await; + assert_eq!( + (c, b.contains("un job engine est déjà en cours")), + (409, true) + ); + // La route publique refuse aussi un second job (précompilé). + let (c, out) = bn.call("POST", "/api/llamacpp/prebuilt", "").await; + assert_eq!(c, 409); + assert_eq!(out["error"], "un job engine est déjà en cours"); + post("commits", r#"{"old":"aaa"}"#).await; + post( + "phase", + r#"{"phase":"recherche de la dernière version publiée…"}"#, + ) + .await; + post( + "line", + r#"{"line":"[ 50%] Building CXX object a/b/c.cpp.o"}"#, + ) + .await; + post("commits", r#"{"new":"bbb"}"#).await; + post("fail", r#"{"error":"raté"}"#).await; + post("done", r#"{"msg":"rattrapé"}"#).await; + let (_, v) = bn.call("GET", "/api/llamacpp/job?from=1", "").await; + assert_eq!(v["running"], true); + assert_eq!(v["compiled"], 1); + assert_eq!(v["old"], "aaa"); + assert_eq!(v["new"], "bbb"); + assert_eq!( + v["lines"], + json!([ + "[ 50%] Building CXX object a/b/c.cpp.o", + "✗ raté", + "✓ rattrapé" + ]) + ); + // La vue que le Go relaie (statut du moteur de l'image). + let (c, b) = fakego::send( + bn.facade, + "GET", + "/internal/facade/v1/build-job/snapshot?from=0&lines=0", + &tok, + "", + ) + .await; + assert_eq!(c.as_u16(), 200); + let head: Value = serde_json::from_slice(&b).unwrap(); + assert_eq!(head["phase"], "rattrapé"); + assert!(head.get("lines").is_none()); + assert_eq!(post("end", "{}").await.0, 200); + assert_eq!( + bn.call("GET", "/api/llamacpp", "").await.1["job"]["running"], + false + ); + // Méthode et opération. + let (c, _) = fakego::send( + bn.facade, + "GET", + "/internal/facade/v1/build-job/line", + &tok, + "", + ) + .await; + assert_eq!(c.as_u16(), 405); + assert_eq!(post("autre", "{}").await.0, 404); + // L'entête suit en base. + let st: Value = serde_json::from_str(&bn.go.get("state", "build_job").unwrap()).unwrap(); + assert_eq!(st["action"], "engine"); + assert_eq!(st["running"], false); + assert_eq!(st["error"], "raté"); +} + +/// Le moteur tourne : arrêté pendant l'installation, relancé après ; la +/// bascule le relance. +#[tokio::test] +async fn service_arrete_puis_relance() { + let fx = fixture(); + let bn = bench().await; + set_machine(&bn.tools, &fx["machines"], "nu"); + *bn.go.service_active.lock().unwrap() = true; + let ok = fx["steps"] + .as_array() + .unwrap() + .iter() + .find(|s| s["name"] == "installation") + .unwrap(); + *bn.rel.lock().unwrap() = ok["release"].clone(); + assert_eq!(bn.call("POST", "/api/llamacpp/prebuilt", "").await.0, 200); + bn.wait_job().await; + let (_, job) = bn.call("GET", "/api/llamacpp/job", "").await; + let lines: Vec<&str> = job["lines"] + .as_array() + .unwrap() + .iter() + .map(|v| v.as_str().unwrap()) + .collect(); + assert_eq!(lines[0], "▶ arrêt du service le temps de l'installation…"); + assert_eq!( + &lines[lines.len() - 3..], + &[ + "BIN mis à jour", + "▶ redémarrage du service…", + "✓ binaires précompilés installés (b300)" + ] + ); + assert_eq!( + *bn.go.service_actions.lock().unwrap(), + vec![("stop".to_string(), true), ("start".to_string(), false)] + ); + bn.go.service_actions.lock().unwrap().clear(); + let (c, out) = bn + .call("POST", "/api/llamacpp/use", r#"{"mode":"fast"}"#) + .await; + assert_eq!((c, out["ok"].clone()), (200, json!(true))); + assert_eq!( + *bn.go.service_actions.lock().unwrap(), + vec![("restart".to_string(), true)] + ); + // Arrêté : rien n'est relancé. + *bn.go.service_active.lock().unwrap() = false; + bn.go.service_actions.lock().unwrap().clear(); + assert_eq!(bn.call("POST", "/api/llamacpp/prebuilt", "").await.0, 200); + bn.wait_job().await; + assert_eq!( + *bn.go.service_actions.lock().unwrap(), + vec![("stop".to_string(), true)] + ); +} + +/// Une archive .zip (Windows) s'extrait aussi, sans évasion possible. +#[test] +fn extraction_zip() { + let tmp = tempfile::tempdir().unwrap(); + let zpath = tmp.path().join("a.zip"); + { + let f = std::fs::File::create(&zpath).unwrap(); + let mut z = zip::ZipWriter::new(f); + let o = zip::write::SimpleFileOptions::default(); + z.add_directory("llama-b1/", o).unwrap(); + z.start_file("llama-b1/llama-server.exe", o).unwrap(); + std::io::Write::write_all(&mut z, b"exe").unwrap(); + z.finish().unwrap(); + } + let out = tmp.path().join("out"); + prebuilt::extract(&zpath, &out).unwrap(); + assert_eq!( + std::fs::read(out.join("llama-b1/llama-server.exe")).unwrap(), + b"exe" + ); + let bad = tmp.path().join("b.zip"); + { + let f = std::fs::File::create(&bad).unwrap(); + let mut z = zip::ZipWriter::new(f); + z.start_file("../evasion", zip::write::SimpleFileOptions::default()) + .unwrap(); + z.finish().unwrap(); + } + assert_eq!( + prebuilt::extract(&bad, &out).unwrap_err(), + "entrée d'archive suspecte : ../evasion" + ); +} diff --git a/rust/loki-rs/tests/common/fakego.rs b/rust/loki-rs/tests/common/fakego.rs index 17f5603..16518e8 100644 --- a/rust/loki-rs/tests/common/fakego.rs +++ b/rust/loki-rs/tests/common/fakego.rs @@ -90,6 +90,8 @@ pub struct FakeGo { pub cdp_shutdowns: Mutex, /// /internal/v1/service/action reçus : (action, if_active). pub service_actions: Mutex>, + /// Le moteur tourne-t-il (`if_active` des actions de service) ? + pub service_active: Mutex, /// Comportement de /internal/v1/crypt/* (préfixe « SCELLE: » par défaut). pub crypt_mode: Mutex, /// Réponse de /internal/v1/conv/workspace (None : 404). @@ -426,10 +428,14 @@ async fn handle(st: Arc, req: Request) -> Response "/internal/v1/service/action" => { let body = req.into_body().collect().await.unwrap().to_bytes(); let v: serde_json::Value = serde_json::from_slice(&body).unwrap_or_default(); - st.service_actions.lock().unwrap().push(( - v["action"].as_str().unwrap_or("").to_string(), - v["if_active"].as_bool().unwrap_or(false), - )); + let if_active = v["if_active"].as_bool().unwrap_or(false); + st.service_actions + .lock() + .unwrap() + .push((v["action"].as_str().unwrap_or("").to_string(), if_active)); + if if_active && !*st.service_active.lock().unwrap() { + return resp(200, r#"{"ran":false,"error":""}"#); + } resp(200, r#"{"ran":true,"error":""}"#) } "/internal/v1/dictate/stop" => { @@ -635,6 +641,7 @@ pub struct Opts { pub projects: bool, pub mcp: Option>, pub update: bool, + pub backends: Option>, } /// Comme [`start`], avec la conversation tenue par la façade @@ -736,6 +743,7 @@ pub async fn start_with( projects: opts.projects, mcp: opts.mcp, update: opts.update, + backends: opts.backends, }; tokio::spawn(loki_rs::proxy::serve(fl, cfg, async move { let _ = rx.await; diff --git a/rust/loki-rs/tests/lot2.rs b/rust/loki-rs/tests/lot2.rs index 26d8c2e..1b4ba15 100644 --- a/rust/loki-rs/tests/lot2.rs +++ b/rust/loki-rs/tests/lot2.rs @@ -106,6 +106,7 @@ async fn banc(cle: Option<&str>) -> Banc { projects: false, mcp: None, update: false, + backends: None, }; tokio::spawn(loki_rs::proxy::serve(l, cfg, async move { let _ = rx.await; diff --git a/rust/loki-rs/tests/parity_backends/backends.json b/rust/loki-rs/tests/parity_backends/backends.json new file mode 100644 index 0000000..3df206f --- /dev/null +++ b/rust/loki-rs/tests/parity_backends/backends.json @@ -0,0 +1,6010 @@ +{ + "goarch": "amd64", + "job": { + "log_lines": 4014, + "long_restore": { + "action": "update", + "compiled": 0, + "ended_at": 0, + "error": "", + "exists": true, + "lines": [ + "ligne", + "ligne", + "ligne" + ], + "new": "", + "next": 4003, + "old": "", + "phase": "", + "running": false, + "started_at": 0 + }, + "ops": [ + { + "op": "head", + "out": { + "exists": false + } + }, + { + "op": "line", + "arg": "avant tout job" + }, + { + "op": "start", + "arg": "update", + "out": "ok" + }, + { + "op": "state", + "out": "{\"action\":\"update\",\"phase\":\"démarrage…\",\"compiled\":0,\"running\":true,\"error\":\"\",\"started_at\":\"{{T}}\",\"ended_at\":0,\"old\":\"\",\"new\":\"\"}" + }, + { + "op": "start", + "arg": "engine", + "out": "un job update est déjà en cours" + }, + { + "op": "line", + "arg": "-- The C compiler identification is GNU 13.2.0 avec un très long suffixe é" + }, + { + "op": "head", + "out": { + "action": "update", + "compiled": 0, + "ended_at": 0, + "error": "", + "exists": true, + "new": "", + "old": "", + "phase": "configuration… The C compiler identification is GNU 13.2.0 avec …", + "running": true, + "started_at": "{{T}}" + } + }, + { + "op": "line", + "arg": "[ 12%] Building CXX object ggml/src/CMakeFiles/ggml.dir/ggml.cpp.o" + }, + { + "op": "line", + "arg": " Compiling CUDA source file ..\\ggml-cuda\\fattn.cu..." + }, + { + "op": "line", + "arg": "softmax.cuh" + }, + { + "op": "line", + "arg": "[100%] Linking CXX executable bin/llama-server" + }, + { + "op": "line", + "arg": "état : é.cpp" + }, + { + "op": "head", + "out": { + "action": "update", + "compiled": 3, + "ended_at": 0, + "error": "", + "exists": true, + "new": "", + "old": "", + "phase": "édition de liens…", + "running": true, + "started_at": "{{T}}" + } + }, + { + "op": "old", + "arg": "abc1234" + }, + { + "op": "phase", + "arg": "git pull origin/master…" + }, + { + "op": "new" + }, + { + "op": "state", + "out": "{\"action\":\"update\",\"phase\":\"git pull origin/master…\",\"compiled\":3,\"running\":true,\"error\":\"\",\"started_at\":\"{{T}}\",\"ended_at\":0,\"old\":\"abc1234\",\"new\":\"\"}" + }, + { + "op": "line", + "arg": "-- Configuring done" + }, + { + "op": "state", + "out": "{\"action\":\"update\",\"phase\":\"git pull origin/master…\",\"compiled\":3,\"running\":true,\"error\":\"\",\"started_at\":\"{{T}}\",\"ended_at\":0,\"old\":\"abc1234\",\"new\":\"\"}" + }, + { + "op": "snapshot", + "from": 2, + "out": { + "action": "update", + "compiled": 3, + "ended_at": 0, + "error": "", + "exists": true, + "lines": [ + " Compiling CUDA source file ..\\ggml-cuda\\fattn.cu...", + "softmax.cuh", + "[100%] Linking CXX executable bin/llama-server", + "état : é.cpp", + "▶ git pull origin/master…", + "-- Configuring done" + ], + "new": "", + "next": 8, + "old": "abc1234", + "phase": "configuration… Configuring done", + "running": true, + "started_at": "{{T}}" + } + }, + { + "op": "lines", + "arg": "bloc" + }, + { + "op": "snapshot", + "from": 4005, + "out": { + "action": "update", + "compiled": 3, + "ended_at": 0, + "error": "", + "exists": true, + "lines": [ + "[bloc] bruit", + "[bloc] bruit", + "[bloc] bruit", + "[bloc] bruit", + "[bloc] bruit" + ], + "new": "", + "next": 4010, + "old": "abc1234", + "phase": "configuration… Configuring done", + "running": true, + "started_at": "{{T}}" + } + }, + { + "op": "summary", + "out": { + "action": "update", + "compiled": 3, + "ended_at": 0, + "error": "", + "exists": true, + "lines": { + "first": "[bloc] bruit", + "last": "[bloc] bruit", + "n": 4000 + }, + "new": "", + "next": 4010, + "old": "abc1234", + "phase": "configuration… Configuring done", + "running": true, + "started_at": "{{T}}" + } + }, + { + "op": "summary", + "from": 3, + "out": { + "action": "update", + "compiled": 3, + "ended_at": 0, + "error": "", + "exists": true, + "lines": { + "first": "[bloc] bruit", + "last": "[bloc] bruit", + "n": 4000 + }, + "new": "", + "next": 4010, + "old": "abc1234", + "phase": "configuration… Configuring done", + "running": true, + "started_at": "{{T}}" + } + }, + { + "op": "line", + "arg": "Build files have been written to: /src/build" + }, + { + "op": "summary", + "out": { + "action": "update", + "compiled": 3, + "ended_at": 0, + "error": "", + "exists": true, + "lines": { + "first": "[bloc] bruit", + "last": "Build files have been written to: /src/build", + "n": 4000 + }, + "new": "", + "next": 4011, + "old": "abc1234", + "phase": "édition de liens…", + "running": true, + "started_at": "{{T}}" + } + }, + { + "op": "line", + "arg": "une de plus" + }, + { + "op": "summary", + "out": { + "action": "update", + "compiled": 3, + "ended_at": 0, + "error": "", + "exists": true, + "lines": { + "first": "[bloc] bruit", + "last": "une de plus", + "n": 4000 + }, + "new": "", + "next": 4012, + "old": "abc1234", + "phase": "édition de liens…", + "running": true, + "started_at": "{{T}}" + } + }, + { + "op": "fail", + "arg": "compilation échouée: exit status 2" + }, + { + "op": "done", + "arg": "tout de même" + }, + { + "op": "end" + }, + { + "op": "head", + "out": { + "action": "update", + "compiled": 3, + "ended_at": "{{T}}", + "error": "compilation échouée: exit status 2", + "exists": true, + "new": "", + "old": "abc1234", + "phase": "tout de même", + "running": false, + "started_at": "{{T}}" + } + }, + { + "op": "state", + "out": "{\"action\":\"update\",\"phase\":\"tout de même\",\"compiled\":3,\"running\":false,\"error\":\"compilation échouée: exit status 2\",\"started_at\":\"{{T}}\",\"ended_at\":\"{{T}}\",\"old\":\"abc1234\",\"new\":\"\"}" + } + ], + "restores": [ + { + "state": "", + "log": null, + "out": { + "exists": false + }, + "after": "" + }, + { + "state": "{\"action\":\"\"}", + "log": null, + "out": { + "exists": false + }, + "after": "{\"action\":\"\"}" + }, + { + "state": "{\"action\":\"prebuilt\",\"phase\":\"fini\",\"running\":false,\"started_at\":5,\"ended_at\":6}", + "log": "a\nb\n", + "out": { + "action": "prebuilt", + "compiled": 0, + "ended_at": "{{T}}", + "error": "", + "exists": true, + "lines": [ + "a", + "b" + ], + "new": "", + "next": 2, + "old": "", + "phase": "fini", + "running": false, + "started_at": "{{T}}" + }, + "after": "{\"action\":\"prebuilt\",\"phase\":\"fini\",\"running\":false,\"started_at\":\"{{T}}\",\"ended_at\":\"{{T}}\"}" + }, + { + "state": "{\"action\":\"update\",\"phase\":\"compilation… 3 fichiers\",\"compiled\":3,\"running\":true,\"started_at\":5,\"old\":\"abc\"}", + "log": "x\n", + "out": { + "action": "update", + "compiled": 3, + "ended_at": "{{T}}", + "error": "interrompu : le service a redémarré pendant l'opération — relancez-la", + "exists": true, + "lines": [ + "x", + "✗ interrompu : le service a redémarré (la compilation ne survit pas au redémarrage)" + ], + "new": "", + "next": 2, + "old": "abc", + "phase": "interrompu", + "running": false, + "started_at": "{{T}}" + }, + "after": "{\"action\":\"update\",\"phase\":\"interrompu\",\"compiled\":3,\"running\":false,\"error\":\"interrompu : le service a redémarré pendant l'opération — relancez-la\",\"started_at\":\"{{T}}\",\"ended_at\":\"{{T}}\",\"old\":\"abc\",\"new\":\"\"}" + }, + { + "state": "{\"action\":\"strata\",\"running\":true}", + "log": null, + "out": { + "action": "strata", + "compiled": 0, + "ended_at": "{{T}}", + "error": "interrompu : le service a redémarré pendant l'opération — relancez-la", + "exists": true, + "lines": [ + "✗ interrompu : le service a redémarré (la compilation ne survit pas au redémarrage)" + ], + "new": "", + "next": 1, + "old": "", + "phase": "interrompu", + "running": false, + "started_at": 0 + }, + "after": "{\"action\":\"strata\",\"phase\":\"interrompu\",\"compiled\":0,\"running\":false,\"error\":\"interrompu : le service a redémarré pendant l'opération — relancez-la\",\"started_at\":0,\"ended_at\":\"{{T}}\",\"old\":\"\",\"new\":\"\"}" + }, + { + "state": "{\"action\":\"engine\",\"running\":false}", + "log": "", + "out": { + "action": "engine", + "compiled": 0, + "ended_at": 0, + "error": "", + "exists": true, + "lines": null, + "new": "", + "next": 0, + "old": "", + "phase": "", + "running": false, + "started_at": 0 + }, + "after": "{\"action\":\"engine\",\"running\":false}" + }, + { + "state": "{\"action\":\"engine\",\"running\":false}", + "log": "\n\n", + "out": { + "action": "engine", + "compiled": 0, + "ended_at": 0, + "error": "", + "exists": true, + "lines": null, + "new": "", + "next": 0, + "old": "", + "phase": "", + "running": false, + "started_at": 0 + }, + "after": "{\"action\":\"engine\",\"running\":false}" + }, + { + "state": "pas du json", + "log": null, + "out": { + "exists": false + }, + "after": "pas du json" + }, + { + "state": "{\"action\":\"engine\",\"compiled\":\"3\"}", + "log": null, + "out": { + "exists": false + }, + "after": "{\"action\":\"engine\",\"compiled\":\"3\"}" + } + ] + }, + "machines": { + "arch-ancienne": { + "nvidia-smi": "#!/bin/sh\ncase \"$1\" in\n--query-gpu=index*pci.bus_id*) echo 'Field \"pci.bus_id\" is not a valid field to query.'; exit 2;;\n--query-gpu=*) echo '0, Vieille carte, 4096, 10, 6.1';;\n-L) echo 'GPU 0: Vieille carte';;\nesac\n" + }, + "glslc-ldconfig": { + "glslc": "#!/bin/sh\nexit 0\n", + "ldconfig": "#!/bin/sh\necho '\tlibvulkan.so.1 (libc6,x86-64) => /usr/lib/libvulkan.so.1'\n" + }, + "liaison-partielle": { + "nvidia-smi": "#!/bin/sh\ncase \"$1\" in\n--query-gpu=*) echo '0, Carte P, 4096, 1, 8.6, 00000000:02:00.0, [N/A], [N/A], 4, 8, 5001';;\nesac\n" + }, + "nu": {}, + "nvidia": { + "nvcc": "#!/bin/sh\nexit 0\n", + "nvidia-smi": "#!/bin/sh\ncase \"$1\" in\n-L) echo 'GPU 0: NVIDIA GeForce RTX 3090 (UUID: GPU-1)';;\n--query-gpu=compute_cap) printf '8.6\\n8.9\\n8.6\\n';;\n'') echo '| NVIDIA-SMI 570.00 Driver Version: 570.00 CUDA Version: 12.8 |';;\n--query-gpu=*) printf '0, NVIDIA GeForce RTX 3090, 24576, 100, 8.6, 00000000:01:00.0, 1, 4, 16, 16, 9751\\n1, Carte B, 8192, 5, 7.5, [N/A], [N/A], 3, 8, 16, 7001\\n';;\nesac\n" + }, + "nvidia-en-panne": { + "nvcc": "#!/bin/sh\nexit 0\n", + "nvidia-smi": "#!/bin/sh\nexit 9\n" + }, + "nvidia-jumelles": { + "nvidia-smi": "#!/bin/sh\ncase \"$1\" in\n-L) echo 'GPU 0: Carte B';;\n--query-gpu=*) printf '0, Carte B, 8192, 5, 7.5, [N/A], 1, 3, 8, 16, 7001\\n1, Carte B, 8192, 5, 7.5, [N/A], 1, 3, 8, 16, 7001\\n';;\nesac\n" + }, + "nvidia-muet": { + "nvcc": "#!/bin/sh\nexit 0\n", + "nvidia-smi": "#!/bin/sh\ncase \"$1\" in\n-L) echo 'rien';;\nesac\n" + }, + "nvidia-sans-nvcc": { + "nvidia-smi": "#!/bin/sh\ncase \"$1\" in\n-L) echo 'GPU 0: NVIDIA (UUID: GPU-1)';;\n--query-gpu=compute_cap) echo '12.0';;\nesac\n" + }, + "rocm": { + "hipcc": "#!/bin/sh\nexit 0\n" + }, + "vulkan": { + "glslc": "#!/bin/sh\nexit 0\n", + "vulkaninfo": "#!/bin/sh\nexit 0\n" + }, + "vulkaninfo-seul": { + "vulkaninfo": "#!/bin/sh\nexit 0\n" + } + }, + "plan_release": { + "status": 200, + "json": "{\"tag_name\":\"b400\",\"assets\":[{\"name\":\"llama-b300-bin-macos-arm64.zip\",\"browser_download_url\":\"{{GH}}/dl/llama-b300-bin-macos-arm64.zip\",\"size\":2500000},{\"name\":\"cudart-llama-bin-win-cuda-12.4-x64.zip\",\"browser_download_url\":\"{{GH}}/dl/cudart-llama-bin-win-cuda-12.4-x64.zip\",\"size\":2500000},{\"name\":\"llama-b300-bin-win-cuda-12.4-x64.zip\",\"browser_download_url\":\"{{GH}}/dl/llama-b300-bin-win-cuda-12.4-x64.zip\",\"size\":2500000},{\"name\":\"llama-b300-bin-ubuntu-vulkan-x64.tar.gz\",\"browser_download_url\":\"{{GH}}/dl/llama-b300-bin-ubuntu-vulkan-x64.tar.gz\",\"size\":2500000},{\"name\":\"llama-b300-bin-ubuntu-rocm-7.0-x64.tar.gz\",\"browser_download_url\":\"{{GH}}/dl/llama-b300-bin-ubuntu-rocm-7.0-x64.tar.gz\",\"size\":2500000},{\"name\":\"llama-b300-bin-ubuntu-vulkan-arm64.tar.gz\",\"browser_download_url\":\"{{GH}}/dl/llama-b300-bin-ubuntu-vulkan-arm64.tar.gz\",\"size\":2500000},{\"name\":\"llama-b300-bin-ubuntu-arm64.tar.gz\",\"browser_download_url\":\"{{GH}}/dl/llama-b300-bin-ubuntu-arm64.tar.gz\",\"size\":2500000},{\"name\":\"llama-b300-bin-ubuntu-x64.tar.gz\",\"browser_download_url\":\"{{GH}}/dl/llama-b300-bin-ubuntu-x64.tar.gz\",\"size\":2500000}]}" + }, + "plans": [ + { + "machine": "arch-ancienne", + "plan": { + "arch": "", + "backend": "cpu", + "jobs": "{{JOBS}}" + }, + "reco": { + "mode": "fast", + "why": "" + }, + "check": { + "current": "b305", + "latest": "b400", + "ok": true, + "size_mb": 2, + "update": true, + "variant": "Vulkan (Linux x64) — pas de build CUDA officiel Linux ; compile localement pour du CUDA natif" + } + }, + { + "machine": "glslc-ldconfig", + "plan": { + "arch": "", + "backend": "vulkan", + "jobs": "{{JOBS}}" + }, + "reco": { + "mode": "fast", + "why": "" + }, + "check": { + "current": "b305", + "latest": "b400", + "ok": true, + "size_mb": 2, + "update": true, + "variant": "CPU (Linux x64)" + } + }, + { + "machine": "liaison-partielle", + "plan": { + "arch": "", + "backend": "cpu", + "jobs": "{{JOBS}}" + }, + "reco": { + "mode": "fast", + "why": "" + }, + "check": { + "current": "b305", + "latest": "b400", + "ok": true, + "size_mb": 2, + "update": true, + "variant": "CPU (Linux x64)" + } + }, + { + "machine": "nu", + "plan": { + "arch": "", + "backend": "cpu", + "jobs": "{{JOBS}}" + }, + "reco": { + "mode": "fast", + "why": "" + }, + "check": { + "current": "b305", + "latest": "b400", + "ok": true, + "size_mb": 2, + "update": true, + "variant": "CPU (Linux x64)" + } + }, + { + "machine": "nvidia", + "plan": { + "arch": "86;89", + "backend": "cuda", + "jobs": "{{JOBS}}" + }, + "reco": { + "mode": "opt", + "why": "seule voie vers du CUDA natif : llama.cpp ne publie aucun binaire CUDA pour Linux, le précompilé ne donnerait que du Vulkan. Compte plusieurs minutes de compilation." + }, + "check": { + "current": "b305", + "latest": "b400", + "ok": true, + "size_mb": 2, + "update": true, + "variant": "Vulkan (Linux x64) — pas de build CUDA officiel Linux ; compile localement pour du CUDA natif" + } + }, + { + "machine": "nvidia-en-panne", + "plan": { + "arch": "", + "backend": "cpu", + "jobs": "{{JOBS}}" + }, + "reco": { + "mode": "fast", + "why": "" + }, + "check": { + "current": "b305", + "latest": "b400", + "ok": true, + "size_mb": 2, + "update": true, + "variant": "CPU (Linux x64)" + } + }, + { + "machine": "nvidia-jumelles", + "plan": { + "arch": "", + "backend": "cpu", + "jobs": "{{JOBS}}" + }, + "reco": { + "mode": "fast", + "why": "" + }, + "check": { + "current": "b305", + "latest": "b400", + "ok": true, + "size_mb": 2, + "update": true, + "variant": "Vulkan (Linux x64) — pas de build CUDA officiel Linux ; compile localement pour du CUDA natif" + } + }, + { + "machine": "nvidia-muet", + "plan": { + "arch": "", + "backend": "cpu", + "jobs": "{{JOBS}}" + }, + "reco": { + "mode": "fast", + "why": "" + }, + "check": { + "current": "b305", + "latest": "b400", + "ok": true, + "size_mb": 2, + "update": true, + "variant": "CPU (Linux x64)" + } + }, + { + "machine": "nvidia-sans-nvcc", + "plan": { + "arch": "", + "backend": "cpu", + "jobs": "{{JOBS}}" + }, + "reco": { + "mode": "fast", + "why": "" + }, + "check": { + "current": "b305", + "latest": "b400", + "ok": true, + "size_mb": 2, + "update": true, + "variant": "Vulkan (Linux x64) — pas de build CUDA officiel Linux ; compile localement pour du CUDA natif" + } + }, + { + "machine": "rocm", + "plan": { + "arch": "", + "backend": "hip", + "jobs": "{{JOBS}}" + }, + "reco": { + "mode": "fast", + "why": "" + }, + "check": { + "current": "b305", + "latest": "b400", + "ok": true, + "size_mb": 2, + "update": true, + "variant": "ROCm (Linux x64)" + } + }, + { + "machine": "vulkan", + "plan": { + "arch": "", + "backend": "vulkan", + "jobs": "{{JOBS}}" + }, + "reco": { + "mode": "fast", + "why": "" + }, + "check": { + "current": "b305", + "latest": "b400", + "ok": true, + "size_mb": 2, + "update": true, + "variant": "Vulkan (Linux x64)" + } + }, + { + "machine": "vulkaninfo-seul", + "plan": { + "arch": "", + "backend": "cpu", + "jobs": "{{JOBS}}" + }, + "reco": { + "mode": "fast", + "why": "" + }, + "check": { + "current": "b305", + "latest": "b400", + "ok": true, + "size_mb": 2, + "update": true, + "variant": "Vulkan (Linux x64)" + } + } + ], + "rocm_sans_asset": { + "out": { + "current": "b305", + "latest": "b402", + "ok": true, + "size_mb": 2, + "update": true, + "variant": "CPU (Linux x64)" + }, + "release": { + "status": 200, + "json": "{\"tag_name\":\"b402\",\"assets\":[{\"name\":\"llama-b402-bin-ubuntu-vulkan-x64.tar.gz\",\"browser_download_url\":\"{{GH}}/dl/llama-b402-bin-ubuntu-vulkan-x64.tar.gz\",\"size\":2500000},{\"name\":\"llama-b402-bin-ubuntu-x64.tar.gz\",\"browser_download_url\":\"{{GH}}/dl/llama-b402-bin-ubuntu-x64.tar.gz\",\"size\":2500000},{\"name\":\"llama-b402-bin-ubuntu-vulkan-arm64.tar.gz\",\"browser_download_url\":\"{{GH}}/dl/llama-b402-bin-ubuntu-vulkan-arm64.tar.gz\",\"size\":2500000},{\"name\":\"llama-b402-bin-ubuntu-arm64.tar.gz\",\"browser_download_url\":\"{{GH}}/dl/llama-b402-bin-ubuntu-arm64.tar.gz\",\"size\":2500000}]}" + } + }, + "sans_vulkan": { + "out": { + "current": "b305", + "latest": "b401", + "ok": true, + "size_mb": 2, + "update": true, + "variant": "CPU (Linux x64)" + }, + "release": { + "status": 200, + "json": "{\"tag_name\":\"b401\",\"assets\":[{\"name\":\"llama-b401-bin-ubuntu-rocm-7.0-x64.tar.gz\",\"browser_download_url\":\"{{GH}}/dl/llama-b401-bin-ubuntu-rocm-7.0-x64.tar.gz\",\"size\":2500000},{\"name\":\"llama-b401-bin-ubuntu-x64.tar.gz\",\"browser_download_url\":\"{{GH}}/dl/llama-b401-bin-ubuntu-x64.tar.gz\",\"size\":2500000},{\"name\":\"llama-b401-bin-ubuntu-arm64.tar.gz\",\"browser_download_url\":\"{{GH}}/dl/llama-b401-bin-ubuntu-arm64.tar.gz\",\"size\":2500000}]}" + } + }, + "steps": [ + { + "name": "statut vide", + "method": "GET", + "path": "/api/llamacpp", + "status": 200, + "out": { + "backends_dir": "{{HOME}}/backends", + "bin": "", + "config_bin": "", + "engine": { + "dir": "{{HOME}}/engine", + "in_use": false, + "tag": "" + }, + "in_use": false, + "installed": false, + "job": { + "exists": false + }, + "plan": { + "arch": "", + "backend": "cpu", + "jobs": "{{JOBS}}" + }, + "prebuilt": { + "bin": "", + "dir": "{{HOME}}/backends/llama.cpp-prebuilt", + "in_use": false, + "tag": "" + }, + "provided": false, + "provided_bin": "", + "reco": { + "mode": "fast", + "why": "" + }, + "repo": "{{HOME}}/backends/llama.cpp" + }, + "after": {}, + "tree": null + }, + { + "name": "backends vides", + "method": "GET", + "path": "/api/backends", + "status": 200, + "out": [], + "after": {}, + "tree": null + }, + { + "name": "custom vides", + "method": "GET", + "path": "/api/backends/custom", + "status": 200, + "out": [], + "after": {}, + "tree": null + }, + { + "name": "job absent", + "method": "GET", + "path": "/api/llamacpp/job", + "status": 200, + "out": { + "exists": false + }, + "after": {}, + "tree": null + }, + { + "name": "deux versions précompilées", + "files": { + "backends/llama.cpp-prebuilt/VERSION": "b100 12.4 fmt2\n", + "backends/llama.cpp-prebuilt/llama-b100/llama-server": "#!/bin/sh\n", + "backends/llama.cpp-prebuilt/llama-b200/build/bin/llama-server": "#!/bin/sh\n" + }, + "config": { + "BIN": "{{HOME}}/backends/llama.cpp-prebuilt/llama-b200/build/bin/llama-server" + }, + "method": "GET", + "path": "/api/llamacpp", + "status": 200, + "out": { + "backends_dir": "{{HOME}}/backends", + "bin": "", + "config_bin": "{{HOME}}/backends/llama.cpp-prebuilt/llama-b200/build/bin/llama-server", + "engine": { + "dir": "{{HOME}}/engine", + "in_use": false, + "tag": "" + }, + "in_use": false, + "installed": false, + "job": { + "exists": false + }, + "plan": { + "arch": "", + "backend": "cpu", + "jobs": "{{JOBS}}" + }, + "prebuilt": { + "bin": "{{HOME}}/backends/llama.cpp-prebuilt/llama-b100/llama-server", + "dir": "{{HOME}}/backends/llama.cpp-prebuilt", + "in_use": true, + "tag": "b100" + }, + "provided": false, + "provided_bin": "", + "reco": { + "mode": "fast", + "why": "" + }, + "repo": "{{HOME}}/backends/llama.cpp" + }, + "after": { + "config/BIN": "{{HOME}}/backends/llama.cpp-prebuilt/llama-b200/build/bin/llama-server" + }, + "tree": [ + "d backends", + "d backends/llama.cpp-prebuilt", + "d backends/llama.cpp-prebuilt/llama-b100", + "d backends/llama.cpp-prebuilt/llama-b200", + "d backends/llama.cpp-prebuilt/llama-b200/build", + "d backends/llama.cpp-prebuilt/llama-b200/build/bin", + "f backends/llama.cpp-prebuilt/VERSION -rw-r--r-- b100 12.4 fmt2\n", + "f backends/llama.cpp-prebuilt/llama-b100/llama-server -rwxr-xr-x #!/bin/sh\n", + "f backends/llama.cpp-prebuilt/llama-b200/build/bin/llama-server -rwxr-xr-x #!/bin/sh\n" + ] + }, + { + "name": "VERSION absent : la plus récente", + "files": { + "backends/llama.cpp-prebuilt/VERSION": "-" + }, + "method": "GET", + "path": "/api/llamacpp", + "status": 200, + "out": { + "backends_dir": "{{HOME}}/backends", + "bin": "", + "config_bin": "{{HOME}}/backends/llama.cpp-prebuilt/llama-b200/build/bin/llama-server", + "engine": { + "dir": "{{HOME}}/engine", + "in_use": false, + "tag": "" + }, + "in_use": false, + "installed": false, + "job": { + "exists": false + }, + "plan": { + "arch": "", + "backend": "cpu", + "jobs": "{{JOBS}}" + }, + "prebuilt": { + "bin": "{{HOME}}/backends/llama.cpp-prebuilt/llama-b200/build/bin/llama-server", + "dir": "{{HOME}}/backends/llama.cpp-prebuilt", + "in_use": true, + "tag": "" + }, + "provided": false, + "provided_bin": "", + "reco": { + "mode": "fast", + "why": "" + }, + "repo": "{{HOME}}/backends/llama.cpp" + }, + "after": { + "config/BIN": "{{HOME}}/backends/llama.cpp-prebuilt/llama-b200/build/bin/llama-server" + }, + "tree": [ + "d backends", + "d backends/llama.cpp-prebuilt", + "d backends/llama.cpp-prebuilt/llama-b100", + "d backends/llama.cpp-prebuilt/llama-b200", + "d backends/llama.cpp-prebuilt/llama-b200/build", + "d backends/llama.cpp-prebuilt/llama-b200/build/bin", + "f backends/llama.cpp-prebuilt/llama-b100/llama-server -rwxr-xr-x #!/bin/sh\n", + "f backends/llama.cpp-prebuilt/llama-b200/build/bin/llama-server -rwxr-xr-x #!/bin/sh\n" + ] + }, + { + "name": "BIN précompilé disparu", + "config": { + "BIN": "{{HOME}}/backends/llama.cpp-prebuilt/llama-b050/llama-server" + }, + "method": "GET", + "path": "/api/llamacpp", + "status": 200, + "out": { + "backends_dir": "{{HOME}}/backends", + "bin": "", + "config_bin": "{{HOME}}/backends/llama.cpp-prebuilt/llama-b050/llama-server", + "engine": { + "dir": "{{HOME}}/engine", + "in_use": false, + "tag": "" + }, + "in_use": false, + "installed": false, + "job": { + "exists": false + }, + "plan": { + "arch": "", + "backend": "cpu", + "jobs": "{{JOBS}}" + }, + "prebuilt": { + "bin": "{{HOME}}/backends/llama.cpp-prebuilt/llama-b200/build/bin/llama-server", + "dir": "{{HOME}}/backends/llama.cpp-prebuilt", + "in_use": true, + "tag": "" + }, + "provided": false, + "provided_bin": "", + "reco": { + "mode": "fast", + "why": "" + }, + "repo": "{{HOME}}/backends/llama.cpp" + }, + "after": { + "config/BIN": "{{HOME}}/backends/llama.cpp-prebuilt/llama-b050/llama-server" + }, + "tree": [ + "d backends", + "d backends/llama.cpp-prebuilt", + "d backends/llama.cpp-prebuilt/llama-b100", + "d backends/llama.cpp-prebuilt/llama-b200", + "d backends/llama.cpp-prebuilt/llama-b200/build", + "d backends/llama.cpp-prebuilt/llama-b200/build/bin", + "f backends/llama.cpp-prebuilt/llama-b100/llama-server -rwxr-xr-x #!/bin/sh\n", + "f backends/llama.cpp-prebuilt/llama-b200/build/bin/llama-server -rwxr-xr-x #!/bin/sh\n" + ] + }, + { + "name": "backends custom", + "files": { + "backends/.cache/llama-server": "x", + "backends/fichier": "x", + "backends/fork-a/CMakeLists.txt": "", + "backends/fork-a/build/bin/llama-server": "#!/bin/sh\n", + "backends/fork-b/bin/llama-server": "#!/bin/sh\n", + "backends/fork-c/build/bin/llama-server": "/", + "backends/fork-c/llama-server.exe": "x", + "backends/lien": "-> fork-b", + "backends/llama.cpp-prebuilt/bin/llama-server": "#!/bin/sh\n", + "backends/vide/": "/" + }, + "config": { + "BIN": "{{HOME}}/backends/fork-a/build/bin/llama-server" + }, + "method": "GET", + "path": "/api/backends", + "status": 200, + "out": [ + { + "name": "fork-a", + "path": "{{HOME}}/backends/fork-a/build/bin/llama-server" + }, + { + "name": "fork-b", + "path": "{{HOME}}/backends/fork-b/bin/llama-server" + }, + { + "name": "fork-c", + "path": "{{HOME}}/backends/fork-c/llama-server.exe" + }, + { + "name": "lien", + "path": "{{HOME}}/backends/lien/bin/llama-server" + }, + { + "name": "llama.cpp-prebuilt", + "path": "{{HOME}}/backends/llama.cpp-prebuilt/bin/llama-server" + } + ], + "after": { + "config/BIN": "{{HOME}}/backends/fork-a/build/bin/llama-server" + }, + "tree": [ + "d backends", + "d backends/.cache", + "d backends/fork-a", + "d backends/fork-a/build", + "d backends/fork-a/build/bin", + "d backends/fork-b", + "d backends/fork-b/bin", + "d backends/fork-c", + "d backends/fork-c/build", + "d backends/fork-c/build/bin", + "d backends/fork-c/build/bin/llama-server", + "d backends/llama.cpp-prebuilt", + "d backends/llama.cpp-prebuilt/bin", + "d backends/llama.cpp-prebuilt/llama-b100", + "d backends/llama.cpp-prebuilt/llama-b200", + "d backends/llama.cpp-prebuilt/llama-b200/build", + "d backends/llama.cpp-prebuilt/llama-b200/build/bin", + "d backends/vide", + "f backends/.cache/llama-server -rw-r--r-- x", + "f backends/fichier -rw-r--r-- x", + "f backends/fork-a/CMakeLists.txt -rw-r--r-- ", + "f backends/fork-a/build/bin/llama-server -rwxr-xr-x #!/bin/sh\n", + "f backends/fork-b/bin/llama-server -rwxr-xr-x #!/bin/sh\n", + "f backends/fork-c/llama-server.exe -rw-r--r-- x", + "f backends/llama.cpp-prebuilt/bin/llama-server -rwxr-xr-x #!/bin/sh\n", + "f backends/llama.cpp-prebuilt/llama-b100/llama-server -rwxr-xr-x #!/bin/sh\n", + "f backends/llama.cpp-prebuilt/llama-b200/build/bin/llama-server -rwxr-xr-x #!/bin/sh\n", + "l backends/lien -> fork-b" + ] + }, + { + "name": "custom seulement", + "method": "GET", + "path": "/api/backends/custom", + "status": 200, + "out": [ + { + "in_use": true, + "name": "fork-a", + "path": "{{HOME}}/backends/fork-a/build/bin/llama-server" + }, + { + "in_use": false, + "name": "fork-b", + "path": "{{HOME}}/backends/fork-b/bin/llama-server" + }, + { + "in_use": false, + "name": "fork-c", + "path": "{{HOME}}/backends/fork-c/llama-server.exe" + }, + { + "in_use": false, + "name": "lien", + "path": "{{HOME}}/backends/lien/bin/llama-server" + } + ], + "after": { + "config/BIN": "{{HOME}}/backends/fork-a/build/bin/llama-server" + }, + "tree": [ + "d backends", + "d backends/.cache", + "d backends/fork-a", + "d backends/fork-a/build", + "d backends/fork-a/build/bin", + "d backends/fork-b", + "d backends/fork-b/bin", + "d backends/fork-c", + "d backends/fork-c/build", + "d backends/fork-c/build/bin", + "d backends/fork-c/build/bin/llama-server", + "d backends/llama.cpp-prebuilt", + "d backends/llama.cpp-prebuilt/bin", + "d backends/llama.cpp-prebuilt/llama-b100", + "d backends/llama.cpp-prebuilt/llama-b200", + "d backends/llama.cpp-prebuilt/llama-b200/build", + "d backends/llama.cpp-prebuilt/llama-b200/build/bin", + "d backends/vide", + "f backends/.cache/llama-server -rw-r--r-- x", + "f backends/fichier -rw-r--r-- x", + "f backends/fork-a/CMakeLists.txt -rw-r--r-- ", + "f backends/fork-a/build/bin/llama-server -rwxr-xr-x #!/bin/sh\n", + "f backends/fork-b/bin/llama-server -rwxr-xr-x #!/bin/sh\n", + "f backends/fork-c/llama-server.exe -rw-r--r-- x", + "f backends/llama.cpp-prebuilt/bin/llama-server -rwxr-xr-x #!/bin/sh\n", + "f backends/llama.cpp-prebuilt/llama-b100/llama-server -rwxr-xr-x #!/bin/sh\n", + "f backends/llama.cpp-prebuilt/llama-b200/build/bin/llama-server -rwxr-xr-x #!/bin/sh\n", + "l backends/lien -> fork-b" + ] + }, + { + "name": "statut sur un fork", + "method": "GET", + "path": "/api/llamacpp", + "status": 200, + "out": { + "backends_dir": "{{HOME}}/backends", + "bin": "{{HOME}}/backends/fork-a/build/bin/llama-server", + "config_bin": "{{HOME}}/backends/fork-a/build/bin/llama-server", + "engine": { + "dir": "{{HOME}}/engine", + "in_use": false, + "tag": "" + }, + "in_use": true, + "installed": false, + "job": { + "exists": false + }, + "plan": { + "arch": "", + "backend": "cpu", + "jobs": "{{JOBS}}" + }, + "prebuilt": { + "bin": "{{HOME}}/backends/llama.cpp-prebuilt/llama-b200/build/bin/llama-server", + "dir": "{{HOME}}/backends/llama.cpp-prebuilt", + "in_use": false, + "tag": "" + }, + "provided": false, + "provided_bin": "", + "reco": { + "mode": "fast", + "why": "" + }, + "repo": "{{HOME}}/backends/fork-a" + }, + "after": { + "config/BIN": "{{HOME}}/backends/fork-a/build/bin/llama-server" + }, + "tree": [ + "d backends", + "d backends/.cache", + "d backends/fork-a", + "d backends/fork-a/build", + "d backends/fork-a/build/bin", + "d backends/fork-b", + "d backends/fork-b/bin", + "d backends/fork-c", + "d backends/fork-c/build", + "d backends/fork-c/build/bin", + "d backends/fork-c/build/bin/llama-server", + "d backends/llama.cpp-prebuilt", + "d backends/llama.cpp-prebuilt/bin", + "d backends/llama.cpp-prebuilt/llama-b100", + "d backends/llama.cpp-prebuilt/llama-b200", + "d backends/llama.cpp-prebuilt/llama-b200/build", + "d backends/llama.cpp-prebuilt/llama-b200/build/bin", + "d backends/vide", + "f backends/.cache/llama-server -rw-r--r-- x", + "f backends/fichier -rw-r--r-- x", + "f backends/fork-a/CMakeLists.txt -rw-r--r-- ", + "f backends/fork-a/build/bin/llama-server -rwxr-xr-x #!/bin/sh\n", + "f backends/fork-b/bin/llama-server -rwxr-xr-x #!/bin/sh\n", + "f backends/fork-c/llama-server.exe -rw-r--r-- x", + "f backends/llama.cpp-prebuilt/bin/llama-server -rwxr-xr-x #!/bin/sh\n", + "f backends/llama.cpp-prebuilt/llama-b100/llama-server -rwxr-xr-x #!/bin/sh\n", + "f backends/llama.cpp-prebuilt/llama-b200/build/bin/llama-server -rwxr-xr-x #!/bin/sh\n", + "l backends/lien -> fork-b" + ] + }, + { + "name": "désinstaller le canonique", + "method": "POST", + "path": "/api/llamacpp/uninstall-custom", + "body": "{\"name\":\"llama.cpp\"}", + "status": 400, + "out": { + "error": "ce moteur se gère depuis les cartes ⚡ / 🔧", + "ok": false + }, + "after": { + "config/BIN": "{{HOME}}/backends/fork-a/build/bin/llama-server" + }, + "tree": [ + "d backends", + "d backends/.cache", + "d backends/fork-a", + "d backends/fork-a/build", + "d backends/fork-a/build/bin", + "d backends/fork-b", + "d backends/fork-b/bin", + "d backends/fork-c", + "d backends/fork-c/build", + "d backends/fork-c/build/bin", + "d backends/fork-c/build/bin/llama-server", + "d backends/llama.cpp-prebuilt", + "d backends/llama.cpp-prebuilt/bin", + "d backends/llama.cpp-prebuilt/llama-b100", + "d backends/llama.cpp-prebuilt/llama-b200", + "d backends/llama.cpp-prebuilt/llama-b200/build", + "d backends/llama.cpp-prebuilt/llama-b200/build/bin", + "d backends/vide", + "f backends/.cache/llama-server -rw-r--r-- x", + "f backends/fichier -rw-r--r-- x", + "f backends/fork-a/CMakeLists.txt -rw-r--r-- ", + "f backends/fork-a/build/bin/llama-server -rwxr-xr-x #!/bin/sh\n", + "f backends/fork-b/bin/llama-server -rwxr-xr-x #!/bin/sh\n", + "f backends/fork-c/llama-server.exe -rw-r--r-- x", + "f backends/llama.cpp-prebuilt/bin/llama-server -rwxr-xr-x #!/bin/sh\n", + "f backends/llama.cpp-prebuilt/llama-b100/llama-server -rwxr-xr-x #!/bin/sh\n", + "f backends/llama.cpp-prebuilt/llama-b200/build/bin/llama-server -rwxr-xr-x #!/bin/sh\n", + "l backends/lien -> fork-b" + ] + }, + { + "name": "désinstaller le précompilé", + "method": "POST", + "path": "/api/llamacpp/uninstall-custom", + "body": "{\"name\":\" LLAMA.CPP-prebuilt \"}", + "status": 400, + "out": { + "error": "ce moteur se gère depuis les cartes ⚡ / 🔧", + "ok": false + }, + "after": { + "config/BIN": "{{HOME}}/backends/fork-a/build/bin/llama-server" + }, + "tree": [ + "d backends", + "d backends/.cache", + "d backends/fork-a", + "d backends/fork-a/build", + "d backends/fork-a/build/bin", + "d backends/fork-b", + "d backends/fork-b/bin", + "d backends/fork-c", + "d backends/fork-c/build", + "d backends/fork-c/build/bin", + "d backends/fork-c/build/bin/llama-server", + "d backends/llama.cpp-prebuilt", + "d backends/llama.cpp-prebuilt/bin", + "d backends/llama.cpp-prebuilt/llama-b100", + "d backends/llama.cpp-prebuilt/llama-b200", + "d backends/llama.cpp-prebuilt/llama-b200/build", + "d backends/llama.cpp-prebuilt/llama-b200/build/bin", + "d backends/vide", + "f backends/.cache/llama-server -rw-r--r-- x", + "f backends/fichier -rw-r--r-- x", + "f backends/fork-a/CMakeLists.txt -rw-r--r-- ", + "f backends/fork-a/build/bin/llama-server -rwxr-xr-x #!/bin/sh\n", + "f backends/fork-b/bin/llama-server -rwxr-xr-x #!/bin/sh\n", + "f backends/fork-c/llama-server.exe -rw-r--r-- x", + "f backends/llama.cpp-prebuilt/bin/llama-server -rwxr-xr-x #!/bin/sh\n", + "f backends/llama.cpp-prebuilt/llama-b100/llama-server -rwxr-xr-x #!/bin/sh\n", + "f backends/llama.cpp-prebuilt/llama-b200/build/bin/llama-server -rwxr-xr-x #!/bin/sh\n", + "l backends/lien -> fork-b" + ] + }, + { + "name": "désinstaller sans nom", + "method": "POST", + "path": "/api/llamacpp/uninstall-custom", + "body": "{\"name\":\"\"}", + "status": 404, + "out": { + "error": "backend introuvable", + "ok": false + }, + "after": { + "config/BIN": "{{HOME}}/backends/fork-a/build/bin/llama-server" + }, + "tree": [ + "d backends", + "d backends/.cache", + "d backends/fork-a", + "d backends/fork-a/build", + "d backends/fork-a/build/bin", + "d backends/fork-b", + "d backends/fork-b/bin", + "d backends/fork-c", + "d backends/fork-c/build", + "d backends/fork-c/build/bin", + "d backends/fork-c/build/bin/llama-server", + "d backends/llama.cpp-prebuilt", + "d backends/llama.cpp-prebuilt/bin", + "d backends/llama.cpp-prebuilt/llama-b100", + "d backends/llama.cpp-prebuilt/llama-b200", + "d backends/llama.cpp-prebuilt/llama-b200/build", + "d backends/llama.cpp-prebuilt/llama-b200/build/bin", + "d backends/vide", + "f backends/.cache/llama-server -rw-r--r-- x", + "f backends/fichier -rw-r--r-- x", + "f backends/fork-a/CMakeLists.txt -rw-r--r-- ", + "f backends/fork-a/build/bin/llama-server -rwxr-xr-x #!/bin/sh\n", + "f backends/fork-b/bin/llama-server -rwxr-xr-x #!/bin/sh\n", + "f backends/fork-c/llama-server.exe -rw-r--r-- x", + "f backends/llama.cpp-prebuilt/bin/llama-server -rwxr-xr-x #!/bin/sh\n", + "f backends/llama.cpp-prebuilt/llama-b100/llama-server -rwxr-xr-x #!/bin/sh\n", + "f backends/llama.cpp-prebuilt/llama-b200/build/bin/llama-server -rwxr-xr-x #!/bin/sh\n", + "l backends/lien -> fork-b" + ] + }, + { + "name": "désinstaller, corps illisible", + "method": "POST", + "path": "/api/llamacpp/uninstall-custom", + "body": "{", + "status": 404, + "out": { + "error": "backend introuvable", + "ok": false + }, + "after": { + "config/BIN": "{{HOME}}/backends/fork-a/build/bin/llama-server" + }, + "tree": [ + "d backends", + "d backends/.cache", + "d backends/fork-a", + "d backends/fork-a/build", + "d backends/fork-a/build/bin", + "d backends/fork-b", + "d backends/fork-b/bin", + "d backends/fork-c", + "d backends/fork-c/build", + "d backends/fork-c/build/bin", + "d backends/fork-c/build/bin/llama-server", + "d backends/llama.cpp-prebuilt", + "d backends/llama.cpp-prebuilt/bin", + "d backends/llama.cpp-prebuilt/llama-b100", + "d backends/llama.cpp-prebuilt/llama-b200", + "d backends/llama.cpp-prebuilt/llama-b200/build", + "d backends/llama.cpp-prebuilt/llama-b200/build/bin", + "d backends/vide", + "f backends/.cache/llama-server -rw-r--r-- x", + "f backends/fichier -rw-r--r-- x", + "f backends/fork-a/CMakeLists.txt -rw-r--r-- ", + "f backends/fork-a/build/bin/llama-server -rwxr-xr-x #!/bin/sh\n", + "f backends/fork-b/bin/llama-server -rwxr-xr-x #!/bin/sh\n", + "f backends/fork-c/llama-server.exe -rw-r--r-- x", + "f backends/llama.cpp-prebuilt/bin/llama-server -rwxr-xr-x #!/bin/sh\n", + "f backends/llama.cpp-prebuilt/llama-b100/llama-server -rwxr-xr-x #!/bin/sh\n", + "f backends/llama.cpp-prebuilt/llama-b200/build/bin/llama-server -rwxr-xr-x #!/bin/sh\n", + "l backends/lien -> fork-b" + ] + }, + { + "name": "désinstaller l'actif", + "method": "POST", + "path": "/api/llamacpp/uninstall-custom", + "body": "{\"name\":\"Fork-A\"}", + "status": 409, + "out": { + "error": "backend utilisé par le modèle actif — bascule d'abord ce modèle sur un autre moteur", + "ok": false + }, + "after": { + "config/BIN": "{{HOME}}/backends/fork-a/build/bin/llama-server" + }, + "tree": [ + "d backends", + "d backends/.cache", + "d backends/fork-a", + "d backends/fork-a/build", + "d backends/fork-a/build/bin", + "d backends/fork-b", + "d backends/fork-b/bin", + "d backends/fork-c", + "d backends/fork-c/build", + "d backends/fork-c/build/bin", + "d backends/fork-c/build/bin/llama-server", + "d backends/llama.cpp-prebuilt", + "d backends/llama.cpp-prebuilt/bin", + "d backends/llama.cpp-prebuilt/llama-b100", + "d backends/llama.cpp-prebuilt/llama-b200", + "d backends/llama.cpp-prebuilt/llama-b200/build", + "d backends/llama.cpp-prebuilt/llama-b200/build/bin", + "d backends/vide", + "f backends/.cache/llama-server -rw-r--r-- x", + "f backends/fichier -rw-r--r-- x", + "f backends/fork-a/CMakeLists.txt -rw-r--r-- ", + "f backends/fork-a/build/bin/llama-server -rwxr-xr-x #!/bin/sh\n", + "f backends/fork-b/bin/llama-server -rwxr-xr-x #!/bin/sh\n", + "f backends/fork-c/llama-server.exe -rw-r--r-- x", + "f backends/llama.cpp-prebuilt/bin/llama-server -rwxr-xr-x #!/bin/sh\n", + "f backends/llama.cpp-prebuilt/llama-b100/llama-server -rwxr-xr-x #!/bin/sh\n", + "f backends/llama.cpp-prebuilt/llama-b200/build/bin/llama-server -rwxr-xr-x #!/bin/sh\n", + "l backends/lien -> fork-b" + ] + }, + { + "name": "désinstaller par un chemin", + "method": "POST", + "path": "/api/llamacpp/uninstall-custom", + "body": "{\"name\":\"../fork-b\"}", + "status": 200, + "out": { + "ok": true + }, + "after": { + "config/BIN": "{{HOME}}/backends/fork-a/build/bin/llama-server" + }, + "tree": [ + "d backends", + "d backends/.cache", + "d backends/fork-a", + "d backends/fork-a/build", + "d backends/fork-a/build/bin", + "d backends/fork-c", + "d backends/fork-c/build", + "d backends/fork-c/build/bin", + "d backends/fork-c/build/bin/llama-server", + "d backends/llama.cpp-prebuilt", + "d backends/llama.cpp-prebuilt/bin", + "d backends/llama.cpp-prebuilt/llama-b100", + "d backends/llama.cpp-prebuilt/llama-b200", + "d backends/llama.cpp-prebuilt/llama-b200/build", + "d backends/llama.cpp-prebuilt/llama-b200/build/bin", + "d backends/vide", + "f backends/.cache/llama-server -rw-r--r-- x", + "f backends/fichier -rw-r--r-- x", + "f backends/fork-a/CMakeLists.txt -rw-r--r-- ", + "f backends/fork-a/build/bin/llama-server -rwxr-xr-x #!/bin/sh\n", + "f backends/fork-c/llama-server.exe -rw-r--r-- x", + "f backends/llama.cpp-prebuilt/bin/llama-server -rwxr-xr-x #!/bin/sh\n", + "f backends/llama.cpp-prebuilt/llama-b100/llama-server -rwxr-xr-x #!/bin/sh\n", + "f backends/llama.cpp-prebuilt/llama-b200/build/bin/llama-server -rwxr-xr-x #!/bin/sh\n", + "l backends/lien -> fork-b" + ] + }, + { + "name": "désinstaller un absent", + "method": "POST", + "path": "/api/llamacpp/uninstall-custom", + "body": "{\"name\":\"fork-z\"}", + "status": 404, + "out": { + "error": "backend introuvable", + "ok": false + }, + "after": { + "config/BIN": "{{HOME}}/backends/fork-a/build/bin/llama-server" + }, + "tree": [ + "d backends", + "d backends/.cache", + "d backends/fork-a", + "d backends/fork-a/build", + "d backends/fork-a/build/bin", + "d backends/fork-c", + "d backends/fork-c/build", + "d backends/fork-c/build/bin", + "d backends/fork-c/build/bin/llama-server", + "d backends/llama.cpp-prebuilt", + "d backends/llama.cpp-prebuilt/bin", + "d backends/llama.cpp-prebuilt/llama-b100", + "d backends/llama.cpp-prebuilt/llama-b200", + "d backends/llama.cpp-prebuilt/llama-b200/build", + "d backends/llama.cpp-prebuilt/llama-b200/build/bin", + "d backends/vide", + "f backends/.cache/llama-server -rw-r--r-- x", + "f backends/fichier -rw-r--r-- x", + "f backends/fork-a/CMakeLists.txt -rw-r--r-- ", + "f backends/fork-a/build/bin/llama-server -rwxr-xr-x #!/bin/sh\n", + "f backends/fork-c/llama-server.exe -rw-r--r-- x", + "f backends/llama.cpp-prebuilt/bin/llama-server -rwxr-xr-x #!/bin/sh\n", + "f backends/llama.cpp-prebuilt/llama-b100/llama-server -rwxr-xr-x #!/bin/sh\n", + "f backends/llama.cpp-prebuilt/llama-b200/build/bin/llama-server -rwxr-xr-x #!/bin/sh\n", + "l backends/lien -> fork-b" + ] + }, + { + "name": "désinstaller un lien", + "method": "POST", + "path": "/api/llamacpp/uninstall-custom", + "body": "{\"name\":\"lien\"}", + "status": 404, + "out": { + "error": "backend introuvable", + "ok": false + }, + "after": { + "config/BIN": "{{HOME}}/backends/fork-a/build/bin/llama-server" + }, + "tree": [ + "d backends", + "d backends/.cache", + "d backends/fork-a", + "d backends/fork-a/build", + "d backends/fork-a/build/bin", + "d backends/fork-c", + "d backends/fork-c/build", + "d backends/fork-c/build/bin", + "d backends/fork-c/build/bin/llama-server", + "d backends/llama.cpp-prebuilt", + "d backends/llama.cpp-prebuilt/bin", + "d backends/llama.cpp-prebuilt/llama-b100", + "d backends/llama.cpp-prebuilt/llama-b200", + "d backends/llama.cpp-prebuilt/llama-b200/build", + "d backends/llama.cpp-prebuilt/llama-b200/build/bin", + "d backends/vide", + "f backends/.cache/llama-server -rw-r--r-- x", + "f backends/fichier -rw-r--r-- x", + "f backends/fork-a/CMakeLists.txt -rw-r--r-- ", + "f backends/fork-a/build/bin/llama-server -rwxr-xr-x #!/bin/sh\n", + "f backends/fork-c/llama-server.exe -rw-r--r-- x", + "f backends/llama.cpp-prebuilt/bin/llama-server -rwxr-xr-x #!/bin/sh\n", + "f backends/llama.cpp-prebuilt/llama-b100/llama-server -rwxr-xr-x #!/bin/sh\n", + "f backends/llama.cpp-prebuilt/llama-b200/build/bin/llama-server -rwxr-xr-x #!/bin/sh\n", + "l backends/lien -> fork-b" + ] + }, + { + "name": "désinstaller un fichier", + "method": "POST", + "path": "/api/llamacpp/uninstall-custom", + "body": "{\"name\":\"fichier\"}", + "status": 404, + "out": { + "error": "backend introuvable", + "ok": false + }, + "after": { + "config/BIN": "{{HOME}}/backends/fork-a/build/bin/llama-server" + }, + "tree": [ + "d backends", + "d backends/.cache", + "d backends/fork-a", + "d backends/fork-a/build", + "d backends/fork-a/build/bin", + "d backends/fork-c", + "d backends/fork-c/build", + "d backends/fork-c/build/bin", + "d backends/fork-c/build/bin/llama-server", + "d backends/llama.cpp-prebuilt", + "d backends/llama.cpp-prebuilt/bin", + "d backends/llama.cpp-prebuilt/llama-b100", + "d backends/llama.cpp-prebuilt/llama-b200", + "d backends/llama.cpp-prebuilt/llama-b200/build", + "d backends/llama.cpp-prebuilt/llama-b200/build/bin", + "d backends/vide", + "f backends/.cache/llama-server -rw-r--r-- x", + "f backends/fichier -rw-r--r-- x", + "f backends/fork-a/CMakeLists.txt -rw-r--r-- ", + "f backends/fork-a/build/bin/llama-server -rwxr-xr-x #!/bin/sh\n", + "f backends/fork-c/llama-server.exe -rw-r--r-- x", + "f backends/llama.cpp-prebuilt/bin/llama-server -rwxr-xr-x #!/bin/sh\n", + "f backends/llama.cpp-prebuilt/llama-b100/llama-server -rwxr-xr-x #!/bin/sh\n", + "f backends/llama.cpp-prebuilt/llama-b200/build/bin/llama-server -rwxr-xr-x #!/bin/sh\n", + "l backends/lien -> fork-b" + ] + }, + { + "name": "désinstaller un dossier sans binaire", + "method": "POST", + "path": "/api/llamacpp/uninstall-custom", + "body": "{\"name\":\"vide\"}", + "status": 200, + "out": { + "ok": true + }, + "after": { + "config/BIN": "{{HOME}}/backends/fork-a/build/bin/llama-server" + }, + "tree": [ + "d backends", + "d backends/.cache", + "d backends/fork-a", + "d backends/fork-a/build", + "d backends/fork-a/build/bin", + "d backends/fork-c", + "d backends/fork-c/build", + "d backends/fork-c/build/bin", + "d backends/fork-c/build/bin/llama-server", + "d backends/llama.cpp-prebuilt", + "d backends/llama.cpp-prebuilt/bin", + "d backends/llama.cpp-prebuilt/llama-b100", + "d backends/llama.cpp-prebuilt/llama-b200", + "d backends/llama.cpp-prebuilt/llama-b200/build", + "d backends/llama.cpp-prebuilt/llama-b200/build/bin", + "f backends/.cache/llama-server -rw-r--r-- x", + "f backends/fichier -rw-r--r-- x", + "f backends/fork-a/CMakeLists.txt -rw-r--r-- ", + "f backends/fork-a/build/bin/llama-server -rwxr-xr-x #!/bin/sh\n", + "f backends/fork-c/llama-server.exe -rw-r--r-- x", + "f backends/llama.cpp-prebuilt/bin/llama-server -rwxr-xr-x #!/bin/sh\n", + "f backends/llama.cpp-prebuilt/llama-b100/llama-server -rwxr-xr-x #!/bin/sh\n", + "f backends/llama.cpp-prebuilt/llama-b200/build/bin/llama-server -rwxr-xr-x #!/bin/sh\n", + "l backends/lien -> fork-b" + ] + }, + { + "name": "dépôt cloné", + "git": [ + "init src", + "commit src 2024-01-02T03:04:05+00:00 premier état", + "clone src backends/llama.cpp" + ], + "files": { + "backends/llama.cpp/build/bin/llama-server": "#!/bin/sh\n" + }, + "config": { + "BIN": "{{HOME}}/backends/llama.cpp/build/bin/llama-server" + }, + "method": "GET", + "path": "/api/llamacpp", + "status": 200, + "out": { + "backends_dir": "{{HOME}}/backends", + "behind": 0, + "bin": "{{HOME}}/backends/llama.cpp/build/bin/llama-server", + "branch": "master", + "commit": "53ac21a", + "commit_date": "2024-01-02 03:04:05 +0000", + "commit_msg": "premier état", + "config_bin": "{{HOME}}/backends/llama.cpp/build/bin/llama-server", + "engine": { + "dir": "{{HOME}}/engine", + "in_use": false, + "tag": "" + }, + "in_use": true, + "installed": true, + "job": { + "exists": false + }, + "plan": { + "arch": "", + "backend": "cpu", + "jobs": "{{JOBS}}" + }, + "prebuilt": { + "bin": "{{HOME}}/backends/llama.cpp-prebuilt/llama-b200/build/bin/llama-server", + "dir": "{{HOME}}/backends/llama.cpp-prebuilt", + "in_use": false, + "tag": "" + }, + "provided": false, + "provided_bin": "", + "reco": { + "mode": "fast", + "why": "" + }, + "repo": "{{HOME}}/backends/llama.cpp" + }, + "after": { + "config/BIN": "{{HOME}}/backends/llama.cpp/build/bin/llama-server" + }, + "tree": [ + "d backends", + "d backends/.cache", + "d backends/fork-a", + "d backends/fork-a/build", + "d backends/fork-a/build/bin", + "d backends/fork-c", + "d backends/fork-c/build", + "d backends/fork-c/build/bin", + "d backends/fork-c/build/bin/llama-server", + "d backends/llama.cpp", + "d backends/llama.cpp-prebuilt", + "d backends/llama.cpp-prebuilt/bin", + "d backends/llama.cpp-prebuilt/llama-b100", + "d backends/llama.cpp-prebuilt/llama-b200", + "d backends/llama.cpp-prebuilt/llama-b200/build", + "d backends/llama.cpp-prebuilt/llama-b200/build/bin", + "d backends/llama.cpp/build", + "d backends/llama.cpp/build/bin", + "f backends/.cache/llama-server -rw-r--r-- x", + "f backends/fichier -rw-r--r-- x", + "f backends/fork-a/CMakeLists.txt -rw-r--r-- ", + "f backends/fork-a/build/bin/llama-server -rwxr-xr-x #!/bin/sh\n", + "f backends/fork-c/llama-server.exe -rw-r--r-- x", + "f backends/llama.cpp-prebuilt/bin/llama-server -rwxr-xr-x #!/bin/sh\n", + "f backends/llama.cpp-prebuilt/llama-b100/llama-server -rwxr-xr-x #!/bin/sh\n", + "f backends/llama.cpp-prebuilt/llama-b200/build/bin/llama-server -rwxr-xr-x #!/bin/sh\n", + "f backends/llama.cpp/README -rw-r--r-- premier état", + "f backends/llama.cpp/build/bin/llama-server -rwxr-xr-x #!/bin/sh\n", + "l backends/lien -> fork-b" + ] + }, + { + "name": "amont avancé, pas encore vu", + "git": [ + "commit src 2024-02-03T04:05:06+00:00 & état" + ], + "method": "GET", + "path": "/api/llamacpp", + "status": 200, + "out": { + "backends_dir": "{{HOME}}/backends", + "behind": 0, + "bin": "{{HOME}}/backends/llama.cpp/build/bin/llama-server", + "branch": "master", + "commit": "53ac21a", + "commit_date": "2024-01-02 03:04:05 +0000", + "commit_msg": "premier état", + "config_bin": "{{HOME}}/backends/llama.cpp/build/bin/llama-server", + "engine": { + "dir": "{{HOME}}/engine", + "in_use": false, + "tag": "" + }, + "in_use": true, + "installed": true, + "job": { + "exists": false + }, + "plan": { + "arch": "", + "backend": "cpu", + "jobs": "{{JOBS}}" + }, + "prebuilt": { + "bin": "{{HOME}}/backends/llama.cpp-prebuilt/llama-b200/build/bin/llama-server", + "dir": "{{HOME}}/backends/llama.cpp-prebuilt", + "in_use": false, + "tag": "" + }, + "provided": false, + "provided_bin": "", + "reco": { + "mode": "fast", + "why": "" + }, + "repo": "{{HOME}}/backends/llama.cpp" + }, + "after": { + "config/BIN": "{{HOME}}/backends/llama.cpp/build/bin/llama-server" + }, + "tree": [ + "d backends", + "d backends/.cache", + "d backends/fork-a", + "d backends/fork-a/build", + "d backends/fork-a/build/bin", + "d backends/fork-c", + "d backends/fork-c/build", + "d backends/fork-c/build/bin", + "d backends/fork-c/build/bin/llama-server", + "d backends/llama.cpp", + "d backends/llama.cpp-prebuilt", + "d backends/llama.cpp-prebuilt/bin", + "d backends/llama.cpp-prebuilt/llama-b100", + "d backends/llama.cpp-prebuilt/llama-b200", + "d backends/llama.cpp-prebuilt/llama-b200/build", + "d backends/llama.cpp-prebuilt/llama-b200/build/bin", + "d backends/llama.cpp/build", + "d backends/llama.cpp/build/bin", + "f backends/.cache/llama-server -rw-r--r-- x", + "f backends/fichier -rw-r--r-- x", + "f backends/fork-a/CMakeLists.txt -rw-r--r-- ", + "f backends/fork-a/build/bin/llama-server -rwxr-xr-x #!/bin/sh\n", + "f backends/fork-c/llama-server.exe -rw-r--r-- x", + "f backends/llama.cpp-prebuilt/bin/llama-server -rwxr-xr-x #!/bin/sh\n", + "f backends/llama.cpp-prebuilt/llama-b100/llama-server -rwxr-xr-x #!/bin/sh\n", + "f backends/llama.cpp-prebuilt/llama-b200/build/bin/llama-server -rwxr-xr-x #!/bin/sh\n", + "f backends/llama.cpp/README -rw-r--r-- premier état", + "f backends/llama.cpp/build/bin/llama-server -rwxr-xr-x #!/bin/sh\n", + "l backends/lien -> fork-b" + ] + }, + { + "name": "vérification git", + "method": "POST", + "path": "/api/llamacpp/check", + "status": 200, + "out": { + "behind": 1, + "branch": "master", + "has_binary": true, + "local": "53ac21a", + "needs_rebuild": true, + "ok": true, + "remote": "2f78f13", + "remote_date": "2024-02-03 04:05:06 +0000", + "remote_msg": " & état" + }, + "after": { + "config/BIN": "{{HOME}}/backends/llama.cpp/build/bin/llama-server" + }, + "tree": [ + "d backends", + "d backends/.cache", + "d backends/fork-a", + "d backends/fork-a/build", + "d backends/fork-a/build/bin", + "d backends/fork-c", + "d backends/fork-c/build", + "d backends/fork-c/build/bin", + "d backends/fork-c/build/bin/llama-server", + "d backends/llama.cpp", + "d backends/llama.cpp-prebuilt", + "d backends/llama.cpp-prebuilt/bin", + "d backends/llama.cpp-prebuilt/llama-b100", + "d backends/llama.cpp-prebuilt/llama-b200", + "d backends/llama.cpp-prebuilt/llama-b200/build", + "d backends/llama.cpp-prebuilt/llama-b200/build/bin", + "d backends/llama.cpp/build", + "d backends/llama.cpp/build/bin", + "f backends/.cache/llama-server -rw-r--r-- x", + "f backends/fichier -rw-r--r-- x", + "f backends/fork-a/CMakeLists.txt -rw-r--r-- ", + "f backends/fork-a/build/bin/llama-server -rwxr-xr-x #!/bin/sh\n", + "f backends/fork-c/llama-server.exe -rw-r--r-- x", + "f backends/llama.cpp-prebuilt/bin/llama-server -rwxr-xr-x #!/bin/sh\n", + "f backends/llama.cpp-prebuilt/llama-b100/llama-server -rwxr-xr-x #!/bin/sh\n", + "f backends/llama.cpp-prebuilt/llama-b200/build/bin/llama-server -rwxr-xr-x #!/bin/sh\n", + "f backends/llama.cpp/README -rw-r--r-- premier état", + "f backends/llama.cpp/build/bin/llama-server -rwxr-xr-x #!/bin/sh\n", + "l backends/lien -> fork-b" + ] + }, + { + "name": "retard connu", + "method": "GET", + "path": "/api/llamacpp", + "status": 200, + "out": { + "backends_dir": "{{HOME}}/backends", + "behind": 1, + "bin": "{{HOME}}/backends/llama.cpp/build/bin/llama-server", + "branch": "master", + "commit": "53ac21a", + "commit_date": "2024-01-02 03:04:05 +0000", + "commit_msg": "premier état", + "config_bin": "{{HOME}}/backends/llama.cpp/build/bin/llama-server", + "engine": { + "dir": "{{HOME}}/engine", + "in_use": false, + "tag": "" + }, + "in_use": true, + "installed": true, + "job": { + "exists": false + }, + "plan": { + "arch": "", + "backend": "cpu", + "jobs": "{{JOBS}}" + }, + "prebuilt": { + "bin": "{{HOME}}/backends/llama.cpp-prebuilt/llama-b200/build/bin/llama-server", + "dir": "{{HOME}}/backends/llama.cpp-prebuilt", + "in_use": false, + "tag": "" + }, + "provided": false, + "provided_bin": "", + "reco": { + "mode": "fast", + "why": "" + }, + "repo": "{{HOME}}/backends/llama.cpp" + }, + "after": { + "config/BIN": "{{HOME}}/backends/llama.cpp/build/bin/llama-server" + }, + "tree": [ + "d backends", + "d backends/.cache", + "d backends/fork-a", + "d backends/fork-a/build", + "d backends/fork-a/build/bin", + "d backends/fork-c", + "d backends/fork-c/build", + "d backends/fork-c/build/bin", + "d backends/fork-c/build/bin/llama-server", + "d backends/llama.cpp", + "d backends/llama.cpp-prebuilt", + "d backends/llama.cpp-prebuilt/bin", + "d backends/llama.cpp-prebuilt/llama-b100", + "d backends/llama.cpp-prebuilt/llama-b200", + "d backends/llama.cpp-prebuilt/llama-b200/build", + "d backends/llama.cpp-prebuilt/llama-b200/build/bin", + "d backends/llama.cpp/build", + "d backends/llama.cpp/build/bin", + "f backends/.cache/llama-server -rw-r--r-- x", + "f backends/fichier -rw-r--r-- x", + "f backends/fork-a/CMakeLists.txt -rw-r--r-- ", + "f backends/fork-a/build/bin/llama-server -rwxr-xr-x #!/bin/sh\n", + "f backends/fork-c/llama-server.exe -rw-r--r-- x", + "f backends/llama.cpp-prebuilt/bin/llama-server -rwxr-xr-x #!/bin/sh\n", + "f backends/llama.cpp-prebuilt/llama-b100/llama-server -rwxr-xr-x #!/bin/sh\n", + "f backends/llama.cpp-prebuilt/llama-b200/build/bin/llama-server -rwxr-xr-x #!/bin/sh\n", + "f backends/llama.cpp/README -rw-r--r-- premier état", + "f backends/llama.cpp/build/bin/llama-server -rwxr-xr-x #!/bin/sh\n", + "l backends/lien -> fork-b" + ] + }, + { + "name": "binaire Release", + "files": { + "backends/llama.cpp/build/bin/Release/llama-server": "#!/bin/sh\n", + "backends/llama.cpp/build/bin/llama-server": "-" + }, + "method": "POST", + "path": "/api/llamacpp/check", + "status": 200, + "out": { + "behind": 1, + "branch": "master", + "has_binary": true, + "local": "53ac21a", + "needs_rebuild": true, + "ok": true, + "remote": "2f78f13", + "remote_date": "2024-02-03 04:05:06 +0000", + "remote_msg": " & état" + }, + "after": { + "config/BIN": "{{HOME}}/backends/llama.cpp/build/bin/llama-server" + }, + "tree": [ + "d backends", + "d backends/.cache", + "d backends/fork-a", + "d backends/fork-a/build", + "d backends/fork-a/build/bin", + "d backends/fork-c", + "d backends/fork-c/build", + "d backends/fork-c/build/bin", + "d backends/fork-c/build/bin/llama-server", + "d backends/llama.cpp", + "d backends/llama.cpp-prebuilt", + "d backends/llama.cpp-prebuilt/bin", + "d backends/llama.cpp-prebuilt/llama-b100", + "d backends/llama.cpp-prebuilt/llama-b200", + "d backends/llama.cpp-prebuilt/llama-b200/build", + "d backends/llama.cpp-prebuilt/llama-b200/build/bin", + "d backends/llama.cpp/build", + "d backends/llama.cpp/build/bin", + "d backends/llama.cpp/build/bin/Release", + "f backends/.cache/llama-server -rw-r--r-- x", + "f backends/fichier -rw-r--r-- x", + "f backends/fork-a/CMakeLists.txt -rw-r--r-- ", + "f backends/fork-a/build/bin/llama-server -rwxr-xr-x #!/bin/sh\n", + "f backends/fork-c/llama-server.exe -rw-r--r-- x", + "f backends/llama.cpp-prebuilt/bin/llama-server -rwxr-xr-x #!/bin/sh\n", + "f backends/llama.cpp-prebuilt/llama-b100/llama-server -rwxr-xr-x #!/bin/sh\n", + "f backends/llama.cpp-prebuilt/llama-b200/build/bin/llama-server -rwxr-xr-x #!/bin/sh\n", + "f backends/llama.cpp/README -rw-r--r-- premier état", + "f backends/llama.cpp/build/bin/Release/llama-server -rwxr-xr-x #!/bin/sh\n", + "l backends/lien -> fork-b" + ] + }, + { + "name": "sans binaire", + "files": { + "backends/llama.cpp/build/bin/Release/llama-server": "-" + }, + "method": "POST", + "path": "/api/llamacpp/check", + "status": 200, + "out": { + "behind": 1, + "branch": "master", + "has_binary": false, + "local": "53ac21a", + "needs_rebuild": true, + "ok": true, + "remote": "2f78f13", + "remote_date": "2024-02-03 04:05:06 +0000", + "remote_msg": " & état" + }, + "after": { + "config/BIN": "{{HOME}}/backends/llama.cpp/build/bin/llama-server" + }, + "tree": [ + "d backends", + "d backends/.cache", + "d backends/fork-a", + "d backends/fork-a/build", + "d backends/fork-a/build/bin", + "d backends/fork-c", + "d backends/fork-c/build", + "d backends/fork-c/build/bin", + "d backends/fork-c/build/bin/llama-server", + "d backends/llama.cpp", + "d backends/llama.cpp-prebuilt", + "d backends/llama.cpp-prebuilt/bin", + "d backends/llama.cpp-prebuilt/llama-b100", + "d backends/llama.cpp-prebuilt/llama-b200", + "d backends/llama.cpp-prebuilt/llama-b200/build", + "d backends/llama.cpp-prebuilt/llama-b200/build/bin", + "d backends/llama.cpp/build", + "d backends/llama.cpp/build/bin", + "d backends/llama.cpp/build/bin/Release", + "f backends/.cache/llama-server -rw-r--r-- x", + "f backends/fichier -rw-r--r-- x", + "f backends/fork-a/CMakeLists.txt -rw-r--r-- ", + "f backends/fork-a/build/bin/llama-server -rwxr-xr-x #!/bin/sh\n", + "f backends/fork-c/llama-server.exe -rw-r--r-- x", + "f backends/llama.cpp-prebuilt/bin/llama-server -rwxr-xr-x #!/bin/sh\n", + "f backends/llama.cpp-prebuilt/llama-b100/llama-server -rwxr-xr-x #!/bin/sh\n", + "f backends/llama.cpp-prebuilt/llama-b200/build/bin/llama-server -rwxr-xr-x #!/bin/sh\n", + "f backends/llama.cpp/README -rw-r--r-- premier état", + "l backends/lien -> fork-b" + ] + }, + { + "name": "à jour, sans binaire", + "git": [ + "pull backends/llama.cpp" + ], + "method": "POST", + "path": "/api/llamacpp/check", + "status": 200, + "out": { + "behind": 0, + "branch": "master", + "has_binary": false, + "local": "2f78f13", + "needs_rebuild": true, + "ok": true, + "remote": "2f78f13", + "remote_date": "2024-02-03 04:05:06 +0000", + "remote_msg": " & état" + }, + "after": { + "config/BIN": "{{HOME}}/backends/llama.cpp/build/bin/llama-server" + }, + "tree": [ + "d backends", + "d backends/.cache", + "d backends/fork-a", + "d backends/fork-a/build", + "d backends/fork-a/build/bin", + "d backends/fork-c", + "d backends/fork-c/build", + "d backends/fork-c/build/bin", + "d backends/fork-c/build/bin/llama-server", + "d backends/llama.cpp", + "d backends/llama.cpp-prebuilt", + "d backends/llama.cpp-prebuilt/bin", + "d backends/llama.cpp-prebuilt/llama-b100", + "d backends/llama.cpp-prebuilt/llama-b200", + "d backends/llama.cpp-prebuilt/llama-b200/build", + "d backends/llama.cpp-prebuilt/llama-b200/build/bin", + "d backends/llama.cpp/build", + "d backends/llama.cpp/build/bin", + "d backends/llama.cpp/build/bin/Release", + "f backends/.cache/llama-server -rw-r--r-- x", + "f backends/fichier -rw-r--r-- x", + "f backends/fork-a/CMakeLists.txt -rw-r--r-- ", + "f backends/fork-a/build/bin/llama-server -rwxr-xr-x #!/bin/sh\n", + "f backends/fork-c/llama-server.exe -rw-r--r-- x", + "f backends/llama.cpp-prebuilt/bin/llama-server -rwxr-xr-x #!/bin/sh\n", + "f backends/llama.cpp-prebuilt/llama-b100/llama-server -rwxr-xr-x #!/bin/sh\n", + "f backends/llama.cpp-prebuilt/llama-b200/build/bin/llama-server -rwxr-xr-x #!/bin/sh\n", + "f backends/llama.cpp/README -rw-r--r-- & état", + "l backends/lien -> fork-b" + ] + }, + { + "name": "tête détachée", + "git": [ + "detach backends/llama.cpp" + ], + "method": "GET", + "path": "/api/llamacpp", + "status": 200, + "out": { + "backends_dir": "{{HOME}}/backends", + "bin": "", + "branch": "HEAD", + "commit": "2f78f13", + "commit_date": "2024-02-03 04:05:06 +0000", + "commit_msg": " & état", + "config_bin": "{{HOME}}/backends/llama.cpp/build/bin/llama-server", + "engine": { + "dir": "{{HOME}}/engine", + "in_use": false, + "tag": "" + }, + "in_use": false, + "installed": true, + "job": { + "exists": false + }, + "plan": { + "arch": "", + "backend": "cpu", + "jobs": "{{JOBS}}" + }, + "prebuilt": { + "bin": "{{HOME}}/backends/llama.cpp-prebuilt/llama-b200/build/bin/llama-server", + "dir": "{{HOME}}/backends/llama.cpp-prebuilt", + "in_use": false, + "tag": "" + }, + "provided": false, + "provided_bin": "", + "reco": { + "mode": "fast", + "why": "" + }, + "repo": "{{HOME}}/backends/llama.cpp" + }, + "after": { + "config/BIN": "{{HOME}}/backends/llama.cpp/build/bin/llama-server" + }, + "tree": [ + "d backends", + "d backends/.cache", + "d backends/fork-a", + "d backends/fork-a/build", + "d backends/fork-a/build/bin", + "d backends/fork-c", + "d backends/fork-c/build", + "d backends/fork-c/build/bin", + "d backends/fork-c/build/bin/llama-server", + "d backends/llama.cpp", + "d backends/llama.cpp-prebuilt", + "d backends/llama.cpp-prebuilt/bin", + "d backends/llama.cpp-prebuilt/llama-b100", + "d backends/llama.cpp-prebuilt/llama-b200", + "d backends/llama.cpp-prebuilt/llama-b200/build", + "d backends/llama.cpp-prebuilt/llama-b200/build/bin", + "d backends/llama.cpp/build", + "d backends/llama.cpp/build/bin", + "d backends/llama.cpp/build/bin/Release", + "f backends/.cache/llama-server -rw-r--r-- x", + "f backends/fichier -rw-r--r-- x", + "f backends/fork-a/CMakeLists.txt -rw-r--r-- ", + "f backends/fork-a/build/bin/llama-server -rwxr-xr-x #!/bin/sh\n", + "f backends/fork-c/llama-server.exe -rw-r--r-- x", + "f backends/llama.cpp-prebuilt/bin/llama-server -rwxr-xr-x #!/bin/sh\n", + "f backends/llama.cpp-prebuilt/llama-b100/llama-server -rwxr-xr-x #!/bin/sh\n", + "f backends/llama.cpp-prebuilt/llama-b200/build/bin/llama-server -rwxr-xr-x #!/bin/sh\n", + "f backends/llama.cpp/README -rw-r--r-- & état", + "l backends/lien -> fork-b" + ] + }, + { + "name": "vérification, tête détachée", + "method": "POST", + "path": "/api/llamacpp/check", + "status": 200, + "out": { + "behind": 0, + "branch": "master", + "has_binary": false, + "local": "2f78f13", + "needs_rebuild": true, + "ok": true, + "remote": "2f78f13", + "remote_date": "2024-02-03 04:05:06 +0000", + "remote_msg": " & état" + }, + "after": { + "config/BIN": "{{HOME}}/backends/llama.cpp/build/bin/llama-server" + }, + "tree": [ + "d backends", + "d backends/.cache", + "d backends/fork-a", + "d backends/fork-a/build", + "d backends/fork-a/build/bin", + "d backends/fork-c", + "d backends/fork-c/build", + "d backends/fork-c/build/bin", + "d backends/fork-c/build/bin/llama-server", + "d backends/llama.cpp", + "d backends/llama.cpp-prebuilt", + "d backends/llama.cpp-prebuilt/bin", + "d backends/llama.cpp-prebuilt/llama-b100", + "d backends/llama.cpp-prebuilt/llama-b200", + "d backends/llama.cpp-prebuilt/llama-b200/build", + "d backends/llama.cpp-prebuilt/llama-b200/build/bin", + "d backends/llama.cpp/build", + "d backends/llama.cpp/build/bin", + "d backends/llama.cpp/build/bin/Release", + "f backends/.cache/llama-server -rw-r--r-- x", + "f backends/fichier -rw-r--r-- x", + "f backends/fork-a/CMakeLists.txt -rw-r--r-- ", + "f backends/fork-a/build/bin/llama-server -rwxr-xr-x #!/bin/sh\n", + "f backends/fork-c/llama-server.exe -rw-r--r-- x", + "f backends/llama.cpp-prebuilt/bin/llama-server -rwxr-xr-x #!/bin/sh\n", + "f backends/llama.cpp-prebuilt/llama-b100/llama-server -rwxr-xr-x #!/bin/sh\n", + "f backends/llama.cpp-prebuilt/llama-b200/build/bin/llama-server -rwxr-xr-x #!/bin/sh\n", + "f backends/llama.cpp/README -rw-r--r-- & état", + "l backends/lien -> fork-b" + ] + }, + { + "name": "origine perdue", + "git": [ + "remote-bad backends/llama.cpp" + ], + "method": "POST", + "path": "/api/llamacpp/check", + "status": 200, + "out": { + "error": "git fetch a échoué : exit status 128", + "ok": false + }, + "after": { + "config/BIN": "{{HOME}}/backends/llama.cpp/build/bin/llama-server" + }, + "tree": [ + "d backends", + "d backends/.cache", + "d backends/fork-a", + "d backends/fork-a/build", + "d backends/fork-a/build/bin", + "d backends/fork-c", + "d backends/fork-c/build", + "d backends/fork-c/build/bin", + "d backends/fork-c/build/bin/llama-server", + "d backends/llama.cpp", + "d backends/llama.cpp-prebuilt", + "d backends/llama.cpp-prebuilt/bin", + "d backends/llama.cpp-prebuilt/llama-b100", + "d backends/llama.cpp-prebuilt/llama-b200", + "d backends/llama.cpp-prebuilt/llama-b200/build", + "d backends/llama.cpp-prebuilt/llama-b200/build/bin", + "d backends/llama.cpp/build", + "d backends/llama.cpp/build/bin", + "d backends/llama.cpp/build/bin/Release", + "f backends/.cache/llama-server -rw-r--r-- x", + "f backends/fichier -rw-r--r-- x", + "f backends/fork-a/CMakeLists.txt -rw-r--r-- ", + "f backends/fork-a/build/bin/llama-server -rwxr-xr-x #!/bin/sh\n", + "f backends/fork-c/llama-server.exe -rw-r--r-- x", + "f backends/llama.cpp-prebuilt/bin/llama-server -rwxr-xr-x #!/bin/sh\n", + "f backends/llama.cpp-prebuilt/llama-b100/llama-server -rwxr-xr-x #!/bin/sh\n", + "f backends/llama.cpp-prebuilt/llama-b200/build/bin/llama-server -rwxr-xr-x #!/bin/sh\n", + "f backends/llama.cpp/README -rw-r--r-- & état", + "l backends/lien -> fork-b" + ] + }, + { + "name": "pas de dépôt", + "files": { + "backends/llama.cpp/.git": "-" + }, + "config": { + "BIN": "/nulle/part/llama-server" + }, + "method": "POST", + "path": "/api/llamacpp/check", + "status": 200, + "out": { + "error": "llama.cpp n'est pas installé", + "ok": false + }, + "after": { + "config/BIN": "/nulle/part/llama-server" + }, + "tree": [ + "d backends", + "d backends/.cache", + "d backends/fork-a", + "d backends/fork-a/build", + "d backends/fork-a/build/bin", + "d backends/fork-c", + "d backends/fork-c/build", + "d backends/fork-c/build/bin", + "d backends/fork-c/build/bin/llama-server", + "d backends/llama.cpp", + "d backends/llama.cpp-prebuilt", + "d backends/llama.cpp-prebuilt/bin", + "d backends/llama.cpp-prebuilt/llama-b100", + "d backends/llama.cpp-prebuilt/llama-b200", + "d backends/llama.cpp-prebuilt/llama-b200/build", + "d backends/llama.cpp-prebuilt/llama-b200/build/bin", + "d backends/llama.cpp/build", + "d backends/llama.cpp/build/bin", + "d backends/llama.cpp/build/bin/Release", + "f backends/.cache/llama-server -rw-r--r-- x", + "f backends/fichier -rw-r--r-- x", + "f backends/fork-a/CMakeLists.txt -rw-r--r-- ", + "f backends/fork-a/build/bin/llama-server -rwxr-xr-x #!/bin/sh\n", + "f backends/fork-c/llama-server.exe -rw-r--r-- x", + "f backends/llama.cpp-prebuilt/bin/llama-server -rwxr-xr-x #!/bin/sh\n", + "f backends/llama.cpp-prebuilt/llama-b100/llama-server -rwxr-xr-x #!/bin/sh\n", + "f backends/llama.cpp-prebuilt/llama-b200/build/bin/llama-server -rwxr-xr-x #!/bin/sh\n", + "f backends/llama.cpp/README -rw-r--r-- & état", + "l backends/lien -> fork-b" + ] + }, + { + "name": "bascule fast", + "method": "POST", + "path": "/api/llamacpp/use", + "body": "{\"mode\":\"fast\"}", + "status": 200, + "out": { + "bin": "{{HOME}}/backends/llama.cpp-prebuilt/llama-b200/build/bin/llama-server", + "ok": true + }, + "after": { + "config/BIN": "{{HOME}}/backends/llama.cpp-prebuilt/llama-b200/build/bin/llama-server" + }, + "tree": [ + "d backends", + "d backends/.cache", + "d backends/fork-a", + "d backends/fork-a/build", + "d backends/fork-a/build/bin", + "d backends/fork-c", + "d backends/fork-c/build", + "d backends/fork-c/build/bin", + "d backends/fork-c/build/bin/llama-server", + "d backends/llama.cpp", + "d backends/llama.cpp-prebuilt", + "d backends/llama.cpp-prebuilt/bin", + "d backends/llama.cpp-prebuilt/llama-b100", + "d backends/llama.cpp-prebuilt/llama-b200", + "d backends/llama.cpp-prebuilt/llama-b200/build", + "d backends/llama.cpp-prebuilt/llama-b200/build/bin", + "d backends/llama.cpp/build", + "d backends/llama.cpp/build/bin", + "d backends/llama.cpp/build/bin/Release", + "f backends/.cache/llama-server -rw-r--r-- x", + "f backends/fichier -rw-r--r-- x", + "f backends/fork-a/CMakeLists.txt -rw-r--r-- ", + "f backends/fork-a/build/bin/llama-server -rwxr-xr-x #!/bin/sh\n", + "f backends/fork-c/llama-server.exe -rw-r--r-- x", + "f backends/llama.cpp-prebuilt/bin/llama-server -rwxr-xr-x #!/bin/sh\n", + "f backends/llama.cpp-prebuilt/llama-b100/llama-server -rwxr-xr-x #!/bin/sh\n", + "f backends/llama.cpp-prebuilt/llama-b200/build/bin/llama-server -rwxr-xr-x #!/bin/sh\n", + "f backends/llama.cpp/README -rw-r--r-- & état", + "l backends/lien -> fork-b" + ] + }, + { + "name": "bascule opt sans build", + "method": "POST", + "path": "/api/llamacpp/use", + "body": "{\"mode\":\"opt\"}", + "status": 400, + "out": { + "error": "cette version n'est pas installée", + "ok": false + }, + "after": { + "config/BIN": "{{HOME}}/backends/llama.cpp-prebuilt/llama-b200/build/bin/llama-server" + }, + "tree": [ + "d backends", + "d backends/.cache", + "d backends/fork-a", + "d backends/fork-a/build", + "d backends/fork-a/build/bin", + "d backends/fork-c", + "d backends/fork-c/build", + "d backends/fork-c/build/bin", + "d backends/fork-c/build/bin/llama-server", + "d backends/llama.cpp", + "d backends/llama.cpp-prebuilt", + "d backends/llama.cpp-prebuilt/bin", + "d backends/llama.cpp-prebuilt/llama-b100", + "d backends/llama.cpp-prebuilt/llama-b200", + "d backends/llama.cpp-prebuilt/llama-b200/build", + "d backends/llama.cpp-prebuilt/llama-b200/build/bin", + "d backends/llama.cpp/build", + "d backends/llama.cpp/build/bin", + "d backends/llama.cpp/build/bin/Release", + "f backends/.cache/llama-server -rw-r--r-- x", + "f backends/fichier -rw-r--r-- x", + "f backends/fork-a/CMakeLists.txt -rw-r--r-- ", + "f backends/fork-a/build/bin/llama-server -rwxr-xr-x #!/bin/sh\n", + "f backends/fork-c/llama-server.exe -rw-r--r-- x", + "f backends/llama.cpp-prebuilt/bin/llama-server -rwxr-xr-x #!/bin/sh\n", + "f backends/llama.cpp-prebuilt/llama-b100/llama-server -rwxr-xr-x #!/bin/sh\n", + "f backends/llama.cpp-prebuilt/llama-b200/build/bin/llama-server -rwxr-xr-x #!/bin/sh\n", + "f backends/llama.cpp/README -rw-r--r-- & état", + "l backends/lien -> fork-b" + ] + }, + { + "name": "bascule opt", + "files": { + "backends/llama.cpp/build/llama-server": "#!/bin/sh\n" + }, + "method": "POST", + "path": "/api/llamacpp/use", + "body": "{\"mode\":\"opt\"}", + "status": 200, + "out": { + "bin": "{{HOME}}/backends/llama.cpp/build/llama-server", + "ok": true + }, + "after": { + "config/BIN": "{{HOME}}/backends/llama.cpp/build/llama-server" + }, + "tree": [ + "d backends", + "d backends/.cache", + "d backends/fork-a", + "d backends/fork-a/build", + "d backends/fork-a/build/bin", + "d backends/fork-c", + "d backends/fork-c/build", + "d backends/fork-c/build/bin", + "d backends/fork-c/build/bin/llama-server", + "d backends/llama.cpp", + "d backends/llama.cpp-prebuilt", + "d backends/llama.cpp-prebuilt/bin", + "d backends/llama.cpp-prebuilt/llama-b100", + "d backends/llama.cpp-prebuilt/llama-b200", + "d backends/llama.cpp-prebuilt/llama-b200/build", + "d backends/llama.cpp-prebuilt/llama-b200/build/bin", + "d backends/llama.cpp/build", + "d backends/llama.cpp/build/bin", + "d backends/llama.cpp/build/bin/Release", + "f backends/.cache/llama-server -rw-r--r-- x", + "f backends/fichier -rw-r--r-- x", + "f backends/fork-a/CMakeLists.txt -rw-r--r-- ", + "f backends/fork-a/build/bin/llama-server -rwxr-xr-x #!/bin/sh\n", + "f backends/fork-c/llama-server.exe -rw-r--r-- x", + "f backends/llama.cpp-prebuilt/bin/llama-server -rwxr-xr-x #!/bin/sh\n", + "f backends/llama.cpp-prebuilt/llama-b100/llama-server -rwxr-xr-x #!/bin/sh\n", + "f backends/llama.cpp-prebuilt/llama-b200/build/bin/llama-server -rwxr-xr-x #!/bin/sh\n", + "f backends/llama.cpp/README -rw-r--r-- & état", + "f backends/llama.cpp/build/llama-server -rwxr-xr-x #!/bin/sh\n", + "l backends/lien -> fork-b" + ] + }, + { + "name": "bascule opt depuis un fork", + "files": { + "backends/fork-a/build/llama-server": "#!/bin/sh\n" + }, + "config": { + "BIN": "{{HOME}}/backends/fork-a/build/bin/llama-server" + }, + "method": "POST", + "path": "/api/llamacpp/use", + "body": "{\"mode\":\"opt\"}", + "status": 200, + "out": { + "bin": "{{HOME}}/backends/fork-a/build/bin/llama-server", + "ok": true + }, + "after": { + "config/BIN": "{{HOME}}/backends/fork-a/build/bin/llama-server" + }, + "tree": [ + "d backends", + "d backends/.cache", + "d backends/fork-a", + "d backends/fork-a/build", + "d backends/fork-a/build/bin", + "d backends/fork-c", + "d backends/fork-c/build", + "d backends/fork-c/build/bin", + "d backends/fork-c/build/bin/llama-server", + "d backends/llama.cpp", + "d backends/llama.cpp-prebuilt", + "d backends/llama.cpp-prebuilt/bin", + "d backends/llama.cpp-prebuilt/llama-b100", + "d backends/llama.cpp-prebuilt/llama-b200", + "d backends/llama.cpp-prebuilt/llama-b200/build", + "d backends/llama.cpp-prebuilt/llama-b200/build/bin", + "d backends/llama.cpp/build", + "d backends/llama.cpp/build/bin", + "d backends/llama.cpp/build/bin/Release", + "f backends/.cache/llama-server -rw-r--r-- x", + "f backends/fichier -rw-r--r-- x", + "f backends/fork-a/CMakeLists.txt -rw-r--r-- ", + "f backends/fork-a/build/bin/llama-server -rwxr-xr-x #!/bin/sh\n", + "f backends/fork-a/build/llama-server -rwxr-xr-x #!/bin/sh\n", + "f backends/fork-c/llama-server.exe -rw-r--r-- x", + "f backends/llama.cpp-prebuilt/bin/llama-server -rwxr-xr-x #!/bin/sh\n", + "f backends/llama.cpp-prebuilt/llama-b100/llama-server -rwxr-xr-x #!/bin/sh\n", + "f backends/llama.cpp-prebuilt/llama-b200/build/bin/llama-server -rwxr-xr-x #!/bin/sh\n", + "f backends/llama.cpp/README -rw-r--r-- & état", + "f backends/llama.cpp/build/llama-server -rwxr-xr-x #!/bin/sh\n", + "l backends/lien -> fork-b" + ] + }, + { + "name": "mode inconnu", + "method": "POST", + "path": "/api/llamacpp/use", + "body": "{\"mode\":\"turbo\"}", + "status": 400, + "out": { + "error": "mode inconnu", + "ok": false + }, + "after": { + "config/BIN": "{{HOME}}/backends/fork-a/build/bin/llama-server" + }, + "tree": [ + "d backends", + "d backends/.cache", + "d backends/fork-a", + "d backends/fork-a/build", + "d backends/fork-a/build/bin", + "d backends/fork-c", + "d backends/fork-c/build", + "d backends/fork-c/build/bin", + "d backends/fork-c/build/bin/llama-server", + "d backends/llama.cpp", + "d backends/llama.cpp-prebuilt", + "d backends/llama.cpp-prebuilt/bin", + "d backends/llama.cpp-prebuilt/llama-b100", + "d backends/llama.cpp-prebuilt/llama-b200", + "d backends/llama.cpp-prebuilt/llama-b200/build", + "d backends/llama.cpp-prebuilt/llama-b200/build/bin", + "d backends/llama.cpp/build", + "d backends/llama.cpp/build/bin", + "d backends/llama.cpp/build/bin/Release", + "f backends/.cache/llama-server -rw-r--r-- x", + "f backends/fichier -rw-r--r-- x", + "f backends/fork-a/CMakeLists.txt -rw-r--r-- ", + "f backends/fork-a/build/bin/llama-server -rwxr-xr-x #!/bin/sh\n", + "f backends/fork-a/build/llama-server -rwxr-xr-x #!/bin/sh\n", + "f backends/fork-c/llama-server.exe -rw-r--r-- x", + "f backends/llama.cpp-prebuilt/bin/llama-server -rwxr-xr-x #!/bin/sh\n", + "f backends/llama.cpp-prebuilt/llama-b100/llama-server -rwxr-xr-x #!/bin/sh\n", + "f backends/llama.cpp-prebuilt/llama-b200/build/bin/llama-server -rwxr-xr-x #!/bin/sh\n", + "f backends/llama.cpp/README -rw-r--r-- & état", + "f backends/llama.cpp/build/llama-server -rwxr-xr-x #!/bin/sh\n", + "l backends/lien -> fork-b" + ] + }, + { + "name": "mode absent", + "method": "POST", + "path": "/api/llamacpp/use", + "status": 400, + "out": { + "error": "mode inconnu", + "ok": false + }, + "after": { + "config/BIN": "{{HOME}}/backends/fork-a/build/bin/llama-server" + }, + "tree": [ + "d backends", + "d backends/.cache", + "d backends/fork-a", + "d backends/fork-a/build", + "d backends/fork-a/build/bin", + "d backends/fork-c", + "d backends/fork-c/build", + "d backends/fork-c/build/bin", + "d backends/fork-c/build/bin/llama-server", + "d backends/llama.cpp", + "d backends/llama.cpp-prebuilt", + "d backends/llama.cpp-prebuilt/bin", + "d backends/llama.cpp-prebuilt/llama-b100", + "d backends/llama.cpp-prebuilt/llama-b200", + "d backends/llama.cpp-prebuilt/llama-b200/build", + "d backends/llama.cpp-prebuilt/llama-b200/build/bin", + "d backends/llama.cpp/build", + "d backends/llama.cpp/build/bin", + "d backends/llama.cpp/build/bin/Release", + "f backends/.cache/llama-server -rw-r--r-- x", + "f backends/fichier -rw-r--r-- x", + "f backends/fork-a/CMakeLists.txt -rw-r--r-- ", + "f backends/fork-a/build/bin/llama-server -rwxr-xr-x #!/bin/sh\n", + "f backends/fork-a/build/llama-server -rwxr-xr-x #!/bin/sh\n", + "f backends/fork-c/llama-server.exe -rw-r--r-- x", + "f backends/llama.cpp-prebuilt/bin/llama-server -rwxr-xr-x #!/bin/sh\n", + "f backends/llama.cpp-prebuilt/llama-b100/llama-server -rwxr-xr-x #!/bin/sh\n", + "f backends/llama.cpp-prebuilt/llama-b200/build/bin/llama-server -rwxr-xr-x #!/bin/sh\n", + "f backends/llama.cpp/README -rw-r--r-- & état", + "f backends/llama.cpp/build/llama-server -rwxr-xr-x #!/bin/sh\n", + "l backends/lien -> fork-b" + ] + }, + { + "name": "mode de travers", + "method": "POST", + "path": "/api/llamacpp/use", + "body": "{\"mode\":1}", + "status": 400, + "out": { + "error": "mode inconnu", + "ok": false + }, + "after": { + "config/BIN": "{{HOME}}/backends/fork-a/build/bin/llama-server" + }, + "tree": [ + "d backends", + "d backends/.cache", + "d backends/fork-a", + "d backends/fork-a/build", + "d backends/fork-a/build/bin", + "d backends/fork-c", + "d backends/fork-c/build", + "d backends/fork-c/build/bin", + "d backends/fork-c/build/bin/llama-server", + "d backends/llama.cpp", + "d backends/llama.cpp-prebuilt", + "d backends/llama.cpp-prebuilt/bin", + "d backends/llama.cpp-prebuilt/llama-b100", + "d backends/llama.cpp-prebuilt/llama-b200", + "d backends/llama.cpp-prebuilt/llama-b200/build", + "d backends/llama.cpp-prebuilt/llama-b200/build/bin", + "d backends/llama.cpp/build", + "d backends/llama.cpp/build/bin", + "d backends/llama.cpp/build/bin/Release", + "f backends/.cache/llama-server -rw-r--r-- x", + "f backends/fichier -rw-r--r-- x", + "f backends/fork-a/CMakeLists.txt -rw-r--r-- ", + "f backends/fork-a/build/bin/llama-server -rwxr-xr-x #!/bin/sh\n", + "f backends/fork-a/build/llama-server -rwxr-xr-x #!/bin/sh\n", + "f backends/fork-c/llama-server.exe -rw-r--r-- x", + "f backends/llama.cpp-prebuilt/bin/llama-server -rwxr-xr-x #!/bin/sh\n", + "f backends/llama.cpp-prebuilt/llama-b100/llama-server -rwxr-xr-x #!/bin/sh\n", + "f backends/llama.cpp-prebuilt/llama-b200/build/bin/llama-server -rwxr-xr-x #!/bin/sh\n", + "f backends/llama.cpp/README -rw-r--r-- & état", + "f backends/llama.cpp/build/llama-server -rwxr-xr-x #!/bin/sh\n", + "l backends/lien -> fork-b" + ] + }, + { + "name": "bascule pendant une optimisation", + "tune": true, + "method": "POST", + "path": "/api/llamacpp/use", + "body": "{\"mode\":\"fast\"}", + "status": 409, + "out": { + "error": "optimisation en cours (loki tune) : le moteur est réservé aux essais — attends la fin ou annule-la", + "ok": false, + "tuning": true + }, + "after": { + "config/BIN": "{{HOME}}/backends/fork-a/build/bin/llama-server" + }, + "tree": [ + "d backends", + "d backends/.cache", + "d backends/fork-a", + "d backends/fork-a/build", + "d backends/fork-a/build/bin", + "d backends/fork-c", + "d backends/fork-c/build", + "d backends/fork-c/build/bin", + "d backends/fork-c/build/bin/llama-server", + "d backends/llama.cpp", + "d backends/llama.cpp-prebuilt", + "d backends/llama.cpp-prebuilt/bin", + "d backends/llama.cpp-prebuilt/llama-b100", + "d backends/llama.cpp-prebuilt/llama-b200", + "d backends/llama.cpp-prebuilt/llama-b200/build", + "d backends/llama.cpp-prebuilt/llama-b200/build/bin", + "d backends/llama.cpp/build", + "d backends/llama.cpp/build/bin", + "d backends/llama.cpp/build/bin/Release", + "f backends/.cache/llama-server -rw-r--r-- x", + "f backends/fichier -rw-r--r-- x", + "f backends/fork-a/CMakeLists.txt -rw-r--r-- ", + "f backends/fork-a/build/bin/llama-server -rwxr-xr-x #!/bin/sh\n", + "f backends/fork-a/build/llama-server -rwxr-xr-x #!/bin/sh\n", + "f backends/fork-c/llama-server.exe -rw-r--r-- x", + "f backends/llama.cpp-prebuilt/bin/llama-server -rwxr-xr-x #!/bin/sh\n", + "f backends/llama.cpp-prebuilt/llama-b100/llama-server -rwxr-xr-x #!/bin/sh\n", + "f backends/llama.cpp-prebuilt/llama-b200/build/bin/llama-server -rwxr-xr-x #!/bin/sh\n", + "f backends/llama.cpp/README -rw-r--r-- & état", + "f backends/llama.cpp/build/llama-server -rwxr-xr-x #!/bin/sh\n", + "l backends/lien -> fork-b" + ] + }, + { + "name": "fast sans précompilé", + "files": { + "backends/llama.cpp-prebuilt/": "-" + }, + "method": "POST", + "path": "/api/llamacpp/use", + "body": "{\"mode\":\"fast\"}", + "status": 400, + "out": { + "error": "cette version n'est pas installée", + "ok": false + }, + "after": { + "config/BIN": "{{HOME}}/backends/fork-a/build/bin/llama-server" + }, + "tree": [ + "d backends", + "d backends/.cache", + "d backends/fork-a", + "d backends/fork-a/build", + "d backends/fork-a/build/bin", + "d backends/fork-c", + "d backends/fork-c/build", + "d backends/fork-c/build/bin", + "d backends/fork-c/build/bin/llama-server", + "d backends/llama.cpp", + "d backends/llama.cpp/build", + "d backends/llama.cpp/build/bin", + "d backends/llama.cpp/build/bin/Release", + "f backends/.cache/llama-server -rw-r--r-- x", + "f backends/fichier -rw-r--r-- x", + "f backends/fork-a/CMakeLists.txt -rw-r--r-- ", + "f backends/fork-a/build/bin/llama-server -rwxr-xr-x #!/bin/sh\n", + "f backends/fork-a/build/llama-server -rwxr-xr-x #!/bin/sh\n", + "f backends/fork-c/llama-server.exe -rw-r--r-- x", + "f backends/llama.cpp/README -rw-r--r-- & état", + "f backends/llama.cpp/build/llama-server -rwxr-xr-x #!/bin/sh\n", + "l backends/lien -> fork-b" + ] + }, + { + "name": "moteur téléchargé", + "files": { + "engine/server-cuda-b10450/llama-server": "#!/bin/sh\n" + }, + "config": { + "BIN": "{{HOME}}/engine/server-cuda-b10450/llama-server" + }, + "method": "GET", + "path": "/api/llamacpp", + "status": 200, + "out": { + "backends_dir": "{{HOME}}/backends", + "bin": "{{HOME}}/backends/llama.cpp/build/llama-server", + "config_bin": "{{HOME}}/engine/server-cuda-b10450/llama-server", + "engine": { + "dir": "{{HOME}}/engine", + "in_use": true, + "tag": "server-cuda-b10450" + }, + "in_use": false, + "installed": false, + "job": { + "exists": false + }, + "plan": { + "arch": "", + "backend": "cpu", + "jobs": "{{JOBS}}" + }, + "prebuilt": { + "bin": "", + "dir": "{{HOME}}/backends/llama.cpp-prebuilt", + "in_use": false, + "tag": "" + }, + "provided": false, + "provided_bin": "", + "reco": { + "mode": "fast", + "why": "" + }, + "repo": "{{HOME}}/backends/llama.cpp" + }, + "after": { + "config/BIN": "{{HOME}}/engine/server-cuda-b10450/llama-server" + }, + "tree": [ + "d backends", + "d backends/.cache", + "d backends/fork-a", + "d backends/fork-a/build", + "d backends/fork-a/build/bin", + "d backends/fork-c", + "d backends/fork-c/build", + "d backends/fork-c/build/bin", + "d backends/fork-c/build/bin/llama-server", + "d backends/llama.cpp", + "d backends/llama.cpp/build", + "d backends/llama.cpp/build/bin", + "d backends/llama.cpp/build/bin/Release", + "d engine", + "d engine/server-cuda-b10450", + "f backends/.cache/llama-server -rw-r--r-- x", + "f backends/fichier -rw-r--r-- x", + "f backends/fork-a/CMakeLists.txt -rw-r--r-- ", + "f backends/fork-a/build/bin/llama-server -rwxr-xr-x #!/bin/sh\n", + "f backends/fork-a/build/llama-server -rwxr-xr-x #!/bin/sh\n", + "f backends/fork-c/llama-server.exe -rw-r--r-- x", + "f backends/llama.cpp/README -rw-r--r-- & état", + "f backends/llama.cpp/build/llama-server -rwxr-xr-x #!/bin/sh\n", + "f engine/server-cuda-b10450/llama-server -rwxr-xr-x #!/bin/sh\n", + "l backends/lien -> fork-b" + ] + }, + { + "name": "moteur de l'image", + "engine_env": " {{HOME}}/engine/server-cuda-b10450/llama-server ", + "method": "GET", + "path": "/api/llamacpp", + "status": 200, + "out": { + "backends_dir": "{{HOME}}/backends", + "bin": "{{HOME}}/backends/llama.cpp/build/llama-server", + "config_bin": "{{HOME}}/engine/server-cuda-b10450/llama-server", + "engine": { + "dir": "{{HOME}}/engine", + "in_use": true, + "tag": "server-cuda-b10450" + }, + "in_use": false, + "installed": false, + "job": { + "exists": false + }, + "plan": { + "arch": "", + "backend": "cpu", + "jobs": "{{JOBS}}" + }, + "prebuilt": { + "bin": "", + "dir": "{{HOME}}/backends/llama.cpp-prebuilt", + "in_use": false, + "tag": "" + }, + "provided": true, + "provided_bin": "{{HOME}}/engine/server-cuda-b10450/llama-server", + "reco": { + "mode": "fast", + "why": "" + }, + "repo": "{{HOME}}/backends/llama.cpp" + }, + "after": { + "config/BIN": "{{HOME}}/engine/server-cuda-b10450/llama-server" + }, + "tree": [ + "d backends", + "d backends/.cache", + "d backends/fork-a", + "d backends/fork-a/build", + "d backends/fork-a/build/bin", + "d backends/fork-c", + "d backends/fork-c/build", + "d backends/fork-c/build/bin", + "d backends/fork-c/build/bin/llama-server", + "d backends/llama.cpp", + "d backends/llama.cpp/build", + "d backends/llama.cpp/build/bin", + "d backends/llama.cpp/build/bin/Release", + "d engine", + "d engine/server-cuda-b10450", + "f backends/.cache/llama-server -rw-r--r-- x", + "f backends/fichier -rw-r--r-- x", + "f backends/fork-a/CMakeLists.txt -rw-r--r-- ", + "f backends/fork-a/build/bin/llama-server -rwxr-xr-x #!/bin/sh\n", + "f backends/fork-a/build/llama-server -rwxr-xr-x #!/bin/sh\n", + "f backends/fork-c/llama-server.exe -rw-r--r-- x", + "f backends/llama.cpp/README -rw-r--r-- & état", + "f backends/llama.cpp/build/llama-server -rwxr-xr-x #!/bin/sh\n", + "f engine/server-cuda-b10450/llama-server -rwxr-xr-x #!/bin/sh\n", + "l backends/lien -> fork-b" + ] + }, + { + "name": "moteur de l'image absent", + "engine_env": "{{HOME}}/image/llama-server", + "method": "GET", + "path": "/api/llamacpp", + "status": 200, + "out": { + "backends_dir": "{{HOME}}/backends", + "bin": "{{HOME}}/backends/llama.cpp/build/llama-server", + "config_bin": "{{HOME}}/engine/server-cuda-b10450/llama-server", + "engine": { + "dir": "{{HOME}}/engine", + "in_use": true, + "tag": "server-cuda-b10450" + }, + "in_use": false, + "installed": false, + "job": { + "exists": false + }, + "plan": { + "arch": "", + "backend": "cpu", + "jobs": "{{JOBS}}" + }, + "prebuilt": { + "bin": "", + "dir": "{{HOME}}/backends/llama.cpp-prebuilt", + "in_use": false, + "tag": "" + }, + "provided": false, + "provided_bin": "", + "reco": { + "mode": "fast", + "why": "" + }, + "repo": "{{HOME}}/backends/llama.cpp" + }, + "after": { + "config/BIN": "{{HOME}}/engine/server-cuda-b10450/llama-server" + }, + "tree": [ + "d backends", + "d backends/.cache", + "d backends/fork-a", + "d backends/fork-a/build", + "d backends/fork-a/build/bin", + "d backends/fork-c", + "d backends/fork-c/build", + "d backends/fork-c/build/bin", + "d backends/fork-c/build/bin/llama-server", + "d backends/llama.cpp", + "d backends/llama.cpp/build", + "d backends/llama.cpp/build/bin", + "d backends/llama.cpp/build/bin/Release", + "d engine", + "d engine/server-cuda-b10450", + "f backends/.cache/llama-server -rw-r--r-- x", + "f backends/fichier -rw-r--r-- x", + "f backends/fork-a/CMakeLists.txt -rw-r--r-- ", + "f backends/fork-a/build/bin/llama-server -rwxr-xr-x #!/bin/sh\n", + "f backends/fork-a/build/llama-server -rwxr-xr-x #!/bin/sh\n", + "f backends/fork-c/llama-server.exe -rw-r--r-- x", + "f backends/llama.cpp/README -rw-r--r-- & état", + "f backends/llama.cpp/build/llama-server -rwxr-xr-x #!/bin/sh\n", + "f engine/server-cuda-b10450/llama-server -rwxr-xr-x #!/bin/sh\n", + "l backends/lien -> fork-b" + ] + }, + { + "name": "moteur de l'image : un dossier", + "engine_env": "{{HOME}}/engine", + "method": "GET", + "path": "/api/llamacpp", + "status": 200, + "out": { + "backends_dir": "{{HOME}}/backends", + "bin": "{{HOME}}/backends/llama.cpp/build/llama-server", + "config_bin": "{{HOME}}/engine/server-cuda-b10450/llama-server", + "engine": { + "dir": "{{HOME}}/engine", + "in_use": true, + "tag": "server-cuda-b10450" + }, + "in_use": false, + "installed": false, + "job": { + "exists": false + }, + "plan": { + "arch": "", + "backend": "cpu", + "jobs": "{{JOBS}}" + }, + "prebuilt": { + "bin": "", + "dir": "{{HOME}}/backends/llama.cpp-prebuilt", + "in_use": false, + "tag": "" + }, + "provided": false, + "provided_bin": "", + "reco": { + "mode": "fast", + "why": "" + }, + "repo": "{{HOME}}/backends/llama.cpp" + }, + "after": { + "config/BIN": "{{HOME}}/engine/server-cuda-b10450/llama-server" + }, + "tree": [ + "d backends", + "d backends/.cache", + "d backends/fork-a", + "d backends/fork-a/build", + "d backends/fork-a/build/bin", + "d backends/fork-c", + "d backends/fork-c/build", + "d backends/fork-c/build/bin", + "d backends/fork-c/build/bin/llama-server", + "d backends/llama.cpp", + "d backends/llama.cpp/build", + "d backends/llama.cpp/build/bin", + "d backends/llama.cpp/build/bin/Release", + "d engine", + "d engine/server-cuda-b10450", + "f backends/.cache/llama-server -rw-r--r-- x", + "f backends/fichier -rw-r--r-- x", + "f backends/fork-a/CMakeLists.txt -rw-r--r-- ", + "f backends/fork-a/build/bin/llama-server -rwxr-xr-x #!/bin/sh\n", + "f backends/fork-a/build/llama-server -rwxr-xr-x #!/bin/sh\n", + "f backends/fork-c/llama-server.exe -rw-r--r-- x", + "f backends/llama.cpp/README -rw-r--r-- & état", + "f backends/llama.cpp/build/llama-server -rwxr-xr-x #!/bin/sh\n", + "f engine/server-cuda-b10450/llama-server -rwxr-xr-x #!/bin/sh\n", + "l backends/lien -> fork-b" + ] + }, + { + "name": "release : CPU", + "engine_env": "", + "release": { + "status": 200, + "json": "{\"tag_name\":\"b300\",\"assets\":[{\"name\":\"llama-b300-bin-macos-arm64.zip\",\"browser_download_url\":\"{{GH}}/dl/llama-b300-bin-macos-arm64.zip\",\"size\":2500000},{\"name\":\"cudart-llama-bin-win-cuda-12.4-x64.zip\",\"browser_download_url\":\"{{GH}}/dl/cudart-llama-bin-win-cuda-12.4-x64.zip\",\"size\":2500000},{\"name\":\"llama-b300-bin-win-cuda-12.4-x64.zip\",\"browser_download_url\":\"{{GH}}/dl/llama-b300-bin-win-cuda-12.4-x64.zip\",\"size\":2500000},{\"name\":\"llama-b300-bin-ubuntu-vulkan-x64.tar.gz\",\"browser_download_url\":\"{{GH}}/dl/llama-b300-bin-ubuntu-vulkan-x64.tar.gz\",\"size\":2500000},{\"name\":\"llama-b300-bin-ubuntu-rocm-7.0-x64.tar.gz\",\"browser_download_url\":\"{{GH}}/dl/llama-b300-bin-ubuntu-rocm-7.0-x64.tar.gz\",\"size\":2500000},{\"name\":\"llama-b300-bin-ubuntu-vulkan-arm64.tar.gz\",\"browser_download_url\":\"{{GH}}/dl/llama-b300-bin-ubuntu-vulkan-arm64.tar.gz\",\"size\":2500000},{\"name\":\"llama-b300-bin-ubuntu-arm64.tar.gz\",\"browser_download_url\":\"{{GH}}/dl/llama-b300-bin-ubuntu-arm64.tar.gz\",\"size\":2500000},{\"name\":\"llama-b300-bin-ubuntu-x64.tar.gz\",\"browser_download_url\":\"{{GH}}/dl/llama-b300-bin-ubuntu-x64.tar.gz\",\"size\":2500000}]}", + "assets": { + "llama-b300-bin-ubuntu-x64.tar.gz": [ + { + "name": "llama-b300/", + "type": "dir", + "mode": 493 + }, + { + "name": "llama-b300/llama-server", + "type": "file", + "body": "#!/bin/sh\necho serveur\n", + "mode": 488 + }, + { + "name": "llama-b300/libggml.so.1.0", + "type": "file", + "body": "lib", + "mode": 420 + }, + { + "name": "llama-b300/libggml.so.1", + "type": "symlink", + "link": "libggml.so.1.0" + }, + { + "name": "llama-b300/libggml.so", + "type": "symlink", + "link": "absente.so" + }, + { + "name": "llama-b300/llama-cli", + "type": "hardlink", + "link": "llama-b300/llama-server" + }, + { + "name": "llama-b300/sous/doc.txt", + "type": "file", + "body": "doc", + "mode": 384 + } + ] + } + }, + "method": "POST", + "path": "/api/llamacpp/prebuilt/check", + "status": 200, + "out": { + "current": "", + "latest": "b300", + "ok": true, + "size_mb": 2, + "update": true, + "variant": "CPU (Linux x64)" + }, + "after": { + "config/BIN": "{{HOME}}/engine/server-cuda-b10450/llama-server" + }, + "tree": [ + "d backends", + "d backends/.cache", + "d backends/fork-a", + "d backends/fork-a/build", + "d backends/fork-a/build/bin", + "d backends/fork-c", + "d backends/fork-c/build", + "d backends/fork-c/build/bin", + "d backends/fork-c/build/bin/llama-server", + "d backends/llama.cpp", + "d backends/llama.cpp/build", + "d backends/llama.cpp/build/bin", + "d backends/llama.cpp/build/bin/Release", + "d engine", + "d engine/server-cuda-b10450", + "f backends/.cache/llama-server -rw-r--r-- x", + "f backends/fichier -rw-r--r-- x", + "f backends/fork-a/CMakeLists.txt -rw-r--r-- ", + "f backends/fork-a/build/bin/llama-server -rwxr-xr-x #!/bin/sh\n", + "f backends/fork-a/build/llama-server -rwxr-xr-x #!/bin/sh\n", + "f backends/fork-c/llama-server.exe -rw-r--r-- x", + "f backends/llama.cpp/README -rw-r--r-- & état", + "f backends/llama.cpp/build/llama-server -rwxr-xr-x #!/bin/sh\n", + "f engine/server-cuda-b10450/llama-server -rwxr-xr-x #!/bin/sh\n", + "l backends/lien -> fork-b" + ] + }, + { + "name": "release refusée", + "release": { + "status": 403, + "json": "{\"message\":\"rate limit\"}" + }, + "method": "POST", + "path": "/api/llamacpp/prebuilt/check", + "status": 200, + "out": { + "error": "GitHub API : HTTP 403", + "ok": false + }, + "after": { + "config/BIN": "{{HOME}}/engine/server-cuda-b10450/llama-server" + }, + "tree": [ + "d backends", + "d backends/.cache", + "d backends/fork-a", + "d backends/fork-a/build", + "d backends/fork-a/build/bin", + "d backends/fork-c", + "d backends/fork-c/build", + "d backends/fork-c/build/bin", + "d backends/fork-c/build/bin/llama-server", + "d backends/llama.cpp", + "d backends/llama.cpp/build", + "d backends/llama.cpp/build/bin", + "d backends/llama.cpp/build/bin/Release", + "d engine", + "d engine/server-cuda-b10450", + "f backends/.cache/llama-server -rw-r--r-- x", + "f backends/fichier -rw-r--r-- x", + "f backends/fork-a/CMakeLists.txt -rw-r--r-- ", + "f backends/fork-a/build/bin/llama-server -rwxr-xr-x #!/bin/sh\n", + "f backends/fork-a/build/llama-server -rwxr-xr-x #!/bin/sh\n", + "f backends/fork-c/llama-server.exe -rw-r--r-- x", + "f backends/llama.cpp/README -rw-r--r-- & état", + "f backends/llama.cpp/build/llama-server -rwxr-xr-x #!/bin/sh\n", + "f engine/server-cuda-b10450/llama-server -rwxr-xr-x #!/bin/sh\n", + "l backends/lien -> fork-b" + ] + }, + { + "name": "release sans tag", + "release": { + "status": 200, + "json": "{\"tag_name\":\"\",\"assets\":[]}" + }, + "method": "POST", + "path": "/api/llamacpp/prebuilt/check", + "status": 200, + "out": { + "error": "release invalide (tag vide)", + "ok": false + }, + "after": { + "config/BIN": "{{HOME}}/engine/server-cuda-b10450/llama-server" + }, + "tree": [ + "d backends", + "d backends/.cache", + "d backends/fork-a", + "d backends/fork-a/build", + "d backends/fork-a/build/bin", + "d backends/fork-c", + "d backends/fork-c/build", + "d backends/fork-c/build/bin", + "d backends/fork-c/build/bin/llama-server", + "d backends/llama.cpp", + "d backends/llama.cpp/build", + "d backends/llama.cpp/build/bin", + "d backends/llama.cpp/build/bin/Release", + "d engine", + "d engine/server-cuda-b10450", + "f backends/.cache/llama-server -rw-r--r-- x", + "f backends/fichier -rw-r--r-- x", + "f backends/fork-a/CMakeLists.txt -rw-r--r-- ", + "f backends/fork-a/build/bin/llama-server -rwxr-xr-x #!/bin/sh\n", + "f backends/fork-a/build/llama-server -rwxr-xr-x #!/bin/sh\n", + "f backends/fork-c/llama-server.exe -rw-r--r-- x", + "f backends/llama.cpp/README -rw-r--r-- & état", + "f backends/llama.cpp/build/llama-server -rwxr-xr-x #!/bin/sh\n", + "f engine/server-cuda-b10450/llama-server -rwxr-xr-x #!/bin/sh\n", + "l backends/lien -> fork-b" + ] + }, + { + "name": "release illisible", + "release": { + "status": 200, + "json": "{\"tag_name\":" + }, + "method": "POST", + "path": "/api/llamacpp/prebuilt/check", + "status": 200, + "out": { + "error": "unexpected EOF", + "ok": false + }, + "after": { + "config/BIN": "{{HOME}}/engine/server-cuda-b10450/llama-server" + }, + "tree": [ + "d backends", + "d backends/.cache", + "d backends/fork-a", + "d backends/fork-a/build", + "d backends/fork-a/build/bin", + "d backends/fork-c", + "d backends/fork-c/build", + "d backends/fork-c/build/bin", + "d backends/fork-c/build/bin/llama-server", + "d backends/llama.cpp", + "d backends/llama.cpp/build", + "d backends/llama.cpp/build/bin", + "d backends/llama.cpp/build/bin/Release", + "d engine", + "d engine/server-cuda-b10450", + "f backends/.cache/llama-server -rw-r--r-- x", + "f backends/fichier -rw-r--r-- x", + "f backends/fork-a/CMakeLists.txt -rw-r--r-- ", + "f backends/fork-a/build/bin/llama-server -rwxr-xr-x #!/bin/sh\n", + "f backends/fork-a/build/llama-server -rwxr-xr-x #!/bin/sh\n", + "f backends/fork-c/llama-server.exe -rw-r--r-- x", + "f backends/llama.cpp/README -rw-r--r-- & état", + "f backends/llama.cpp/build/llama-server -rwxr-xr-x #!/bin/sh\n", + "f engine/server-cuda-b10450/llama-server -rwxr-xr-x #!/bin/sh\n", + "l backends/lien -> fork-b" + ] + }, + { + "name": "release vide", + "release": { + "status": 200, + "json": "" + }, + "method": "POST", + "path": "/api/llamacpp/prebuilt/check", + "status": 200, + "out": { + "error": "EOF", + "ok": false + }, + "after": { + "config/BIN": "{{HOME}}/engine/server-cuda-b10450/llama-server" + }, + "tree": [ + "d backends", + "d backends/.cache", + "d backends/fork-a", + "d backends/fork-a/build", + "d backends/fork-a/build/bin", + "d backends/fork-c", + "d backends/fork-c/build", + "d backends/fork-c/build/bin", + "d backends/fork-c/build/bin/llama-server", + "d backends/llama.cpp", + "d backends/llama.cpp/build", + "d backends/llama.cpp/build/bin", + "d backends/llama.cpp/build/bin/Release", + "d engine", + "d engine/server-cuda-b10450", + "f backends/.cache/llama-server -rw-r--r-- x", + "f backends/fichier -rw-r--r-- x", + "f backends/fork-a/CMakeLists.txt -rw-r--r-- ", + "f backends/fork-a/build/bin/llama-server -rwxr-xr-x #!/bin/sh\n", + "f backends/fork-a/build/llama-server -rwxr-xr-x #!/bin/sh\n", + "f backends/fork-c/llama-server.exe -rw-r--r-- x", + "f backends/llama.cpp/README -rw-r--r-- & état", + "f backends/llama.cpp/build/llama-server -rwxr-xr-x #!/bin/sh\n", + "f engine/server-cuda-b10450/llama-server -rwxr-xr-x #!/bin/sh\n", + "l backends/lien -> fork-b" + ] + }, + { + "name": "release, tag de travers", + "release": { + "status": 200, + "json": "{\"tag_name\":5}" + }, + "method": "POST", + "path": "/api/llamacpp/prebuilt/check", + "status": 200, + "out": { + "error": "json: cannot unmarshal number into Go struct field .tag_name of type string", + "ok": false + }, + "after": { + "config/BIN": "{{HOME}}/engine/server-cuda-b10450/llama-server" + }, + "tree": [ + "d backends", + "d backends/.cache", + "d backends/fork-a", + "d backends/fork-a/build", + "d backends/fork-a/build/bin", + "d backends/fork-c", + "d backends/fork-c/build", + "d backends/fork-c/build/bin", + "d backends/fork-c/build/bin/llama-server", + "d backends/llama.cpp", + "d backends/llama.cpp/build", + "d backends/llama.cpp/build/bin", + "d backends/llama.cpp/build/bin/Release", + "d engine", + "d engine/server-cuda-b10450", + "f backends/.cache/llama-server -rw-r--r-- x", + "f backends/fichier -rw-r--r-- x", + "f backends/fork-a/CMakeLists.txt -rw-r--r-- ", + "f backends/fork-a/build/bin/llama-server -rwxr-xr-x #!/bin/sh\n", + "f backends/fork-a/build/llama-server -rwxr-xr-x #!/bin/sh\n", + "f backends/fork-c/llama-server.exe -rw-r--r-- x", + "f backends/llama.cpp/README -rw-r--r-- & état", + "f backends/llama.cpp/build/llama-server -rwxr-xr-x #!/bin/sh\n", + "f engine/server-cuda-b10450/llama-server -rwxr-xr-x #!/bin/sh\n", + "l backends/lien -> fork-b" + ] + }, + { + "name": "release sans asset adapté", + "release": { + "status": 200, + "json": "{\"tag_name\":\"b301\",\"assets\":[{\"name\":\"llama-b301-bin-macos-arm64.zip\",\"browser_download_url\":\"{{GH}}/dl/llama-b301-bin-macos-arm64.zip\",\"size\":2500000}]}" + }, + "method": "POST", + "path": "/api/llamacpp/prebuilt/check", + "status": 200, + "out": { + "error": "aucun binaire précompilé adapté à cette machine dans la release officielle", + "ok": false + }, + "after": { + "config/BIN": "{{HOME}}/engine/server-cuda-b10450/llama-server" + }, + "tree": [ + "d backends", + "d backends/.cache", + "d backends/fork-a", + "d backends/fork-a/build", + "d backends/fork-a/build/bin", + "d backends/fork-c", + "d backends/fork-c/build", + "d backends/fork-c/build/bin", + "d backends/fork-c/build/bin/llama-server", + "d backends/llama.cpp", + "d backends/llama.cpp/build", + "d backends/llama.cpp/build/bin", + "d backends/llama.cpp/build/bin/Release", + "d engine", + "d engine/server-cuda-b10450", + "f backends/.cache/llama-server -rw-r--r-- x", + "f backends/fichier -rw-r--r-- x", + "f backends/fork-a/CMakeLists.txt -rw-r--r-- ", + "f backends/fork-a/build/bin/llama-server -rwxr-xr-x #!/bin/sh\n", + "f backends/fork-a/build/llama-server -rwxr-xr-x #!/bin/sh\n", + "f backends/fork-c/llama-server.exe -rw-r--r-- x", + "f backends/llama.cpp/README -rw-r--r-- & état", + "f backends/llama.cpp/build/llama-server -rwxr-xr-x #!/bin/sh\n", + "f engine/server-cuda-b10450/llama-server -rwxr-xr-x #!/bin/sh\n", + "l backends/lien -> fork-b" + ] + }, + { + "name": "installation", + "files": { + "backends/llama.cpp-prebuilt/VERSION": "b100 12.4 fmt2\n", + "backends/llama.cpp-prebuilt/autre/llama-server": "#!/bin/sh\n", + "backends/llama.cpp-prebuilt/llama-b100/llama-server": "#!/bin/sh\n", + "backends/llama.cpp-prebuilt/llama-b200/llama-server": "#!/bin/sh\n", + "backends/llama.cpp-prebuilt/llama-b30/llama-server": "#!/bin/sh\n" + }, + "release": { + "status": 200, + "json": "{\"tag_name\":\"b300\",\"assets\":[{\"name\":\"llama-b300-bin-macos-arm64.zip\",\"browser_download_url\":\"{{GH}}/dl/llama-b300-bin-macos-arm64.zip\",\"size\":2500000},{\"name\":\"cudart-llama-bin-win-cuda-12.4-x64.zip\",\"browser_download_url\":\"{{GH}}/dl/cudart-llama-bin-win-cuda-12.4-x64.zip\",\"size\":2500000},{\"name\":\"llama-b300-bin-win-cuda-12.4-x64.zip\",\"browser_download_url\":\"{{GH}}/dl/llama-b300-bin-win-cuda-12.4-x64.zip\",\"size\":2500000},{\"name\":\"llama-b300-bin-ubuntu-vulkan-x64.tar.gz\",\"browser_download_url\":\"{{GH}}/dl/llama-b300-bin-ubuntu-vulkan-x64.tar.gz\",\"size\":2500000},{\"name\":\"llama-b300-bin-ubuntu-rocm-7.0-x64.tar.gz\",\"browser_download_url\":\"{{GH}}/dl/llama-b300-bin-ubuntu-rocm-7.0-x64.tar.gz\",\"size\":2500000},{\"name\":\"llama-b300-bin-ubuntu-vulkan-arm64.tar.gz\",\"browser_download_url\":\"{{GH}}/dl/llama-b300-bin-ubuntu-vulkan-arm64.tar.gz\",\"size\":2500000},{\"name\":\"llama-b300-bin-ubuntu-arm64.tar.gz\",\"browser_download_url\":\"{{GH}}/dl/llama-b300-bin-ubuntu-arm64.tar.gz\",\"size\":2500000},{\"name\":\"llama-b300-bin-ubuntu-x64.tar.gz\",\"browser_download_url\":\"{{GH}}/dl/llama-b300-bin-ubuntu-x64.tar.gz\",\"size\":2500000}]}", + "assets": { + "llama-b300-bin-ubuntu-x64.tar.gz": [ + { + "name": "llama-b300/", + "type": "dir", + "mode": 493 + }, + { + "name": "llama-b300/llama-server", + "type": "file", + "body": "#!/bin/sh\necho serveur\n", + "mode": 488 + }, + { + "name": "llama-b300/libggml.so.1.0", + "type": "file", + "body": "lib", + "mode": 420 + }, + { + "name": "llama-b300/libggml.so.1", + "type": "symlink", + "link": "libggml.so.1.0" + }, + { + "name": "llama-b300/libggml.so", + "type": "symlink", + "link": "absente.so" + }, + { + "name": "llama-b300/llama-cli", + "type": "hardlink", + "link": "llama-b300/llama-server" + }, + { + "name": "llama-b300/sous/doc.txt", + "type": "file", + "body": "doc", + "mode": 384 + } + ] + } + }, + "method": "POST", + "path": "/api/llamacpp/prebuilt", + "wait": true, + "status": 200, + "out": { + "ok": true + }, + "job": { + "action": "prebuilt", + "compiled": 0, + "ended_at": "{{T}}", + "error": "", + "exists": true, + "lines": [ + "▶ récupération de la dernière release llama.cpp…", + "release b300 — variant retenu : CPU (Linux x64)", + "▶ téléchargement de llama-b300-bin-ubuntu-x64.tar.gz (2 Mo)…", + "▶ extraction de llama-b300-bin-ubuntu-x64.tar.gz…", + "ancienne version supprimée : llama-b100", + "ancienne version supprimée : llama-b200", + "ancienne version supprimée : llama-b30", + "binaire installé : {{HOME}}/backends/llama.cpp-prebuilt/llama-b300/llama-server (release b300)", + "BIN mis à jour", + "✓ binaires précompilés installés (b300)" + ], + "new": "", + "next": 10, + "old": "", + "phase": "binaires précompilés installés (b300)", + "running": false, + "started_at": "{{T}}" + }, + "after": { + "config/BIN": "{{HOME}}/backends/llama.cpp-prebuilt/llama-b300/llama-server", + "loki-build.log": "▶ récupération de la dernière release llama.cpp…\nrelease b300 — variant retenu : CPU (Linux x64)\n▶ téléchargement de llama-b300-bin-ubuntu-x64.tar.gz (2 Mo)…\n▶ extraction de llama-b300-bin-ubuntu-x64.tar.gz…\nancienne version supprimée : llama-b100\nancienne version supprimée : llama-b200\nancienne version supprimée : llama-b30\nbinaire installé : {{HOME}}/backends/llama.cpp-prebuilt/llama-b300/llama-server (release b300)\nBIN mis à jour\n✓ binaires précompilés installés (b300)\n", + "state/build_job": "{\"action\":\"prebuilt\",\"phase\":\"binaires précompilés installés (b300)\",\"compiled\":0,\"running\":false,\"error\":\"\",\"started_at\":\"{{T}}\",\"ended_at\":\"{{T}}\",\"old\":\"\",\"new\":\"\"}" + }, + "tree": [ + "d backends", + "d backends/.cache", + "d backends/fork-a", + "d backends/fork-a/build", + "d backends/fork-a/build/bin", + "d backends/fork-c", + "d backends/fork-c/build", + "d backends/fork-c/build/bin", + "d backends/fork-c/build/bin/llama-server", + "d backends/llama.cpp", + "d backends/llama.cpp-prebuilt", + "d backends/llama.cpp-prebuilt/autre", + "d backends/llama.cpp-prebuilt/llama-b300", + "d backends/llama.cpp-prebuilt/llama-b300/sous", + "d backends/llama.cpp/build", + "d backends/llama.cpp/build/bin", + "d backends/llama.cpp/build/bin/Release", + "d engine", + "d engine/server-cuda-b10450", + "f backends/.cache/llama-server -rw-r--r-- x", + "f backends/fichier -rw-r--r-- x", + "f backends/fork-a/CMakeLists.txt -rw-r--r-- ", + "f backends/fork-a/build/bin/llama-server -rwxr-xr-x #!/bin/sh\n", + "f backends/fork-a/build/llama-server -rwxr-xr-x #!/bin/sh\n", + "f backends/fork-c/llama-server.exe -rw-r--r-- x", + "f backends/llama.cpp-prebuilt/VERSION -rw-r--r-- b300 fmt2\n", + "f backends/llama.cpp-prebuilt/autre/llama-server -rwxr-xr-x #!/bin/sh\n", + "f backends/llama.cpp-prebuilt/llama-b300/libggml.so.1.0 -rw-r--r-- lib", + "f backends/llama.cpp-prebuilt/llama-b300/llama-server -rwxr-xr-x #!/bin/sh\necho serveur\n", + "f backends/llama.cpp-prebuilt/llama-b300/sous/doc.txt -rw------- doc", + "f backends/llama.cpp/README -rw-r--r-- & état", + "f backends/llama.cpp/build/llama-server -rwxr-xr-x #!/bin/sh\n", + "f engine/server-cuda-b10450/llama-server -rwxr-xr-x #!/bin/sh\n", + "l backends/lien -> fork-b", + "l backends/llama.cpp-prebuilt/llama-b300/libggml.so -> absente.so", + "l backends/llama.cpp-prebuilt/llama-b300/libggml.so.1 -> libggml.so.1.0", + "l backends/llama.cpp-prebuilt/llama-b300/llama-cli -> {{HOME}}/backends/llama.cpp-prebuilt/llama-b300/llama-server" + ] + }, + { + "name": "statut après installation", + "method": "GET", + "path": "/api/llamacpp", + "status": 200, + "out": { + "backends_dir": "{{HOME}}/backends", + "bin": "{{HOME}}/backends/llama.cpp/build/llama-server", + "config_bin": "{{HOME}}/backends/llama.cpp-prebuilt/llama-b300/llama-server", + "engine": { + "dir": "{{HOME}}/engine", + "in_use": false, + "tag": "" + }, + "in_use": false, + "installed": false, + "job": { + "action": "prebuilt", + "compiled": 0, + "ended_at": "{{T}}", + "error": "", + "exists": true, + "new": "", + "old": "", + "phase": "binaires précompilés installés (b300)", + "running": false, + "started_at": "{{T}}" + }, + "plan": { + "arch": "", + "backend": "cpu", + "jobs": "{{JOBS}}" + }, + "prebuilt": { + "bin": "{{HOME}}/backends/llama.cpp-prebuilt/llama-b300/llama-server", + "dir": "{{HOME}}/backends/llama.cpp-prebuilt", + "in_use": true, + "tag": "b300" + }, + "provided": false, + "provided_bin": "", + "reco": { + "mode": "fast", + "why": "" + }, + "repo": "{{HOME}}/backends/llama.cpp" + }, + "after": { + "config/BIN": "{{HOME}}/backends/llama.cpp-prebuilt/llama-b300/llama-server", + "loki-build.log": "▶ récupération de la dernière release llama.cpp…\nrelease b300 — variant retenu : CPU (Linux x64)\n▶ téléchargement de llama-b300-bin-ubuntu-x64.tar.gz (2 Mo)…\n▶ extraction de llama-b300-bin-ubuntu-x64.tar.gz…\nancienne version supprimée : llama-b100\nancienne version supprimée : llama-b200\nancienne version supprimée : llama-b30\nbinaire installé : {{HOME}}/backends/llama.cpp-prebuilt/llama-b300/llama-server (release b300)\nBIN mis à jour\n✓ binaires précompilés installés (b300)\n", + "state/build_job": "{\"action\":\"prebuilt\",\"phase\":\"binaires précompilés installés (b300)\",\"compiled\":0,\"running\":false,\"error\":\"\",\"started_at\":\"{{T}}\",\"ended_at\":\"{{T}}\",\"old\":\"\",\"new\":\"\"}" + }, + "tree": [ + "d backends", + "d backends/.cache", + "d backends/fork-a", + "d backends/fork-a/build", + "d backends/fork-a/build/bin", + "d backends/fork-c", + "d backends/fork-c/build", + "d backends/fork-c/build/bin", + "d backends/fork-c/build/bin/llama-server", + "d backends/llama.cpp", + "d backends/llama.cpp-prebuilt", + "d backends/llama.cpp-prebuilt/autre", + "d backends/llama.cpp-prebuilt/llama-b300", + "d backends/llama.cpp-prebuilt/llama-b300/sous", + "d backends/llama.cpp/build", + "d backends/llama.cpp/build/bin", + "d backends/llama.cpp/build/bin/Release", + "d engine", + "d engine/server-cuda-b10450", + "f backends/.cache/llama-server -rw-r--r-- x", + "f backends/fichier -rw-r--r-- x", + "f backends/fork-a/CMakeLists.txt -rw-r--r-- ", + "f backends/fork-a/build/bin/llama-server -rwxr-xr-x #!/bin/sh\n", + "f backends/fork-a/build/llama-server -rwxr-xr-x #!/bin/sh\n", + "f backends/fork-c/llama-server.exe -rw-r--r-- x", + "f backends/llama.cpp-prebuilt/VERSION -rw-r--r-- b300 fmt2\n", + "f backends/llama.cpp-prebuilt/autre/llama-server -rwxr-xr-x #!/bin/sh\n", + "f backends/llama.cpp-prebuilt/llama-b300/libggml.so.1.0 -rw-r--r-- lib", + "f backends/llama.cpp-prebuilt/llama-b300/llama-server -rwxr-xr-x #!/bin/sh\necho serveur\n", + "f backends/llama.cpp-prebuilt/llama-b300/sous/doc.txt -rw------- doc", + "f backends/llama.cpp/README -rw-r--r-- & état", + "f backends/llama.cpp/build/llama-server -rwxr-xr-x #!/bin/sh\n", + "f engine/server-cuda-b10450/llama-server -rwxr-xr-x #!/bin/sh\n", + "l backends/lien -> fork-b", + "l backends/llama.cpp-prebuilt/llama-b300/libggml.so -> absente.so", + "l backends/llama.cpp-prebuilt/llama-b300/libggml.so.1 -> libggml.so.1.0", + "l backends/llama.cpp-prebuilt/llama-b300/llama-cli -> {{HOME}}/backends/llama.cpp-prebuilt/llama-b300/llama-server" + ] + }, + { + "name": "release à jour", + "method": "POST", + "path": "/api/llamacpp/prebuilt/check", + "status": 200, + "out": { + "current": "b300", + "latest": "b300", + "ok": true, + "size_mb": 2, + "update": false, + "variant": "CPU (Linux x64)" + }, + "after": { + "config/BIN": "{{HOME}}/backends/llama.cpp-prebuilt/llama-b300/llama-server", + "loki-build.log": "▶ récupération de la dernière release llama.cpp…\nrelease b300 — variant retenu : CPU (Linux x64)\n▶ téléchargement de llama-b300-bin-ubuntu-x64.tar.gz (2 Mo)…\n▶ extraction de llama-b300-bin-ubuntu-x64.tar.gz…\nancienne version supprimée : llama-b100\nancienne version supprimée : llama-b200\nancienne version supprimée : llama-b30\nbinaire installé : {{HOME}}/backends/llama.cpp-prebuilt/llama-b300/llama-server (release b300)\nBIN mis à jour\n✓ binaires précompilés installés (b300)\n", + "state/build_job": "{\"action\":\"prebuilt\",\"phase\":\"binaires précompilés installés (b300)\",\"compiled\":0,\"running\":false,\"error\":\"\",\"started_at\":\"{{T}}\",\"ended_at\":\"{{T}}\",\"old\":\"\",\"new\":\"\"}" + }, + "tree": [ + "d backends", + "d backends/.cache", + "d backends/fork-a", + "d backends/fork-a/build", + "d backends/fork-a/build/bin", + "d backends/fork-c", + "d backends/fork-c/build", + "d backends/fork-c/build/bin", + "d backends/fork-c/build/bin/llama-server", + "d backends/llama.cpp", + "d backends/llama.cpp-prebuilt", + "d backends/llama.cpp-prebuilt/autre", + "d backends/llama.cpp-prebuilt/llama-b300", + "d backends/llama.cpp-prebuilt/llama-b300/sous", + "d backends/llama.cpp/build", + "d backends/llama.cpp/build/bin", + "d backends/llama.cpp/build/bin/Release", + "d engine", + "d engine/server-cuda-b10450", + "f backends/.cache/llama-server -rw-r--r-- x", + "f backends/fichier -rw-r--r-- x", + "f backends/fork-a/CMakeLists.txt -rw-r--r-- ", + "f backends/fork-a/build/bin/llama-server -rwxr-xr-x #!/bin/sh\n", + "f backends/fork-a/build/llama-server -rwxr-xr-x #!/bin/sh\n", + "f backends/fork-c/llama-server.exe -rw-r--r-- x", + "f backends/llama.cpp-prebuilt/VERSION -rw-r--r-- b300 fmt2\n", + "f backends/llama.cpp-prebuilt/autre/llama-server -rwxr-xr-x #!/bin/sh\n", + "f backends/llama.cpp-prebuilt/llama-b300/libggml.so.1.0 -rw-r--r-- lib", + "f backends/llama.cpp-prebuilt/llama-b300/llama-server -rwxr-xr-x #!/bin/sh\necho serveur\n", + "f backends/llama.cpp-prebuilt/llama-b300/sous/doc.txt -rw------- doc", + "f backends/llama.cpp/README -rw-r--r-- & état", + "f backends/llama.cpp/build/llama-server -rwxr-xr-x #!/bin/sh\n", + "f engine/server-cuda-b10450/llama-server -rwxr-xr-x #!/bin/sh\n", + "l backends/lien -> fork-b", + "l backends/llama.cpp-prebuilt/llama-b300/libggml.so -> absente.so", + "l backends/llama.cpp-prebuilt/llama-b300/libggml.so.1 -> libggml.so.1.0", + "l backends/llama.cpp-prebuilt/llama-b300/llama-cli -> {{HOME}}/backends/llama.cpp-prebuilt/llama-b300/llama-server" + ] + }, + { + "name": "lignes à partir de 3", + "method": "GET", + "path": "/api/llamacpp/job?from=3", + "status": 200, + "out": { + "action": "prebuilt", + "compiled": 0, + "ended_at": "{{T}}", + "error": "", + "exists": true, + "lines": [ + "▶ extraction de llama-b300-bin-ubuntu-x64.tar.gz…", + "ancienne version supprimée : llama-b100", + "ancienne version supprimée : llama-b200", + "ancienne version supprimée : llama-b30", + "binaire installé : {{HOME}}/backends/llama.cpp-prebuilt/llama-b300/llama-server (release b300)", + "BIN mis à jour", + "✓ binaires précompilés installés (b300)" + ], + "new": "", + "next": 10, + "old": "", + "phase": "binaires précompilés installés (b300)", + "running": false, + "started_at": "{{T}}" + }, + "after": { + "config/BIN": "{{HOME}}/backends/llama.cpp-prebuilt/llama-b300/llama-server", + "loki-build.log": "▶ récupération de la dernière release llama.cpp…\nrelease b300 — variant retenu : CPU (Linux x64)\n▶ téléchargement de llama-b300-bin-ubuntu-x64.tar.gz (2 Mo)…\n▶ extraction de llama-b300-bin-ubuntu-x64.tar.gz…\nancienne version supprimée : llama-b100\nancienne version supprimée : llama-b200\nancienne version supprimée : llama-b30\nbinaire installé : {{HOME}}/backends/llama.cpp-prebuilt/llama-b300/llama-server (release b300)\nBIN mis à jour\n✓ binaires précompilés installés (b300)\n", + "state/build_job": "{\"action\":\"prebuilt\",\"phase\":\"binaires précompilés installés (b300)\",\"compiled\":0,\"running\":false,\"error\":\"\",\"started_at\":\"{{T}}\",\"ended_at\":\"{{T}}\",\"old\":\"\",\"new\":\"\"}" + }, + "tree": [ + "d backends", + "d backends/.cache", + "d backends/fork-a", + "d backends/fork-a/build", + "d backends/fork-a/build/bin", + "d backends/fork-c", + "d backends/fork-c/build", + "d backends/fork-c/build/bin", + "d backends/fork-c/build/bin/llama-server", + "d backends/llama.cpp", + "d backends/llama.cpp-prebuilt", + "d backends/llama.cpp-prebuilt/autre", + "d backends/llama.cpp-prebuilt/llama-b300", + "d backends/llama.cpp-prebuilt/llama-b300/sous", + "d backends/llama.cpp/build", + "d backends/llama.cpp/build/bin", + "d backends/llama.cpp/build/bin/Release", + "d engine", + "d engine/server-cuda-b10450", + "f backends/.cache/llama-server -rw-r--r-- x", + "f backends/fichier -rw-r--r-- x", + "f backends/fork-a/CMakeLists.txt -rw-r--r-- ", + "f backends/fork-a/build/bin/llama-server -rwxr-xr-x #!/bin/sh\n", + "f backends/fork-a/build/llama-server -rwxr-xr-x #!/bin/sh\n", + "f backends/fork-c/llama-server.exe -rw-r--r-- x", + "f backends/llama.cpp-prebuilt/VERSION -rw-r--r-- b300 fmt2\n", + "f backends/llama.cpp-prebuilt/autre/llama-server -rwxr-xr-x #!/bin/sh\n", + "f backends/llama.cpp-prebuilt/llama-b300/libggml.so.1.0 -rw-r--r-- lib", + "f backends/llama.cpp-prebuilt/llama-b300/llama-server -rwxr-xr-x #!/bin/sh\necho serveur\n", + "f backends/llama.cpp-prebuilt/llama-b300/sous/doc.txt -rw------- doc", + "f backends/llama.cpp/README -rw-r--r-- & état", + "f backends/llama.cpp/build/llama-server -rwxr-xr-x #!/bin/sh\n", + "f engine/server-cuda-b10450/llama-server -rwxr-xr-x #!/bin/sh\n", + "l backends/lien -> fork-b", + "l backends/llama.cpp-prebuilt/llama-b300/libggml.so -> absente.so", + "l backends/llama.cpp-prebuilt/llama-b300/libggml.so.1 -> libggml.so.1.0", + "l backends/llama.cpp-prebuilt/llama-b300/llama-cli -> {{HOME}}/backends/llama.cpp-prebuilt/llama-b300/llama-server" + ] + }, + { + "name": "lignes au-delà", + "method": "GET", + "path": "/api/llamacpp/job?from=99", + "status": 200, + "out": { + "action": "prebuilt", + "compiled": 0, + "ended_at": "{{T}}", + "error": "", + "exists": true, + "lines": null, + "new": "", + "next": 10, + "old": "", + "phase": "binaires précompilés installés (b300)", + "running": false, + "started_at": "{{T}}" + }, + "after": { + "config/BIN": "{{HOME}}/backends/llama.cpp-prebuilt/llama-b300/llama-server", + "loki-build.log": "▶ récupération de la dernière release llama.cpp…\nrelease b300 — variant retenu : CPU (Linux x64)\n▶ téléchargement de llama-b300-bin-ubuntu-x64.tar.gz (2 Mo)…\n▶ extraction de llama-b300-bin-ubuntu-x64.tar.gz…\nancienne version supprimée : llama-b100\nancienne version supprimée : llama-b200\nancienne version supprimée : llama-b30\nbinaire installé : {{HOME}}/backends/llama.cpp-prebuilt/llama-b300/llama-server (release b300)\nBIN mis à jour\n✓ binaires précompilés installés (b300)\n", + "state/build_job": "{\"action\":\"prebuilt\",\"phase\":\"binaires précompilés installés (b300)\",\"compiled\":0,\"running\":false,\"error\":\"\",\"started_at\":\"{{T}}\",\"ended_at\":\"{{T}}\",\"old\":\"\",\"new\":\"\"}" + }, + "tree": [ + "d backends", + "d backends/.cache", + "d backends/fork-a", + "d backends/fork-a/build", + "d backends/fork-a/build/bin", + "d backends/fork-c", + "d backends/fork-c/build", + "d backends/fork-c/build/bin", + "d backends/fork-c/build/bin/llama-server", + "d backends/llama.cpp", + "d backends/llama.cpp-prebuilt", + "d backends/llama.cpp-prebuilt/autre", + "d backends/llama.cpp-prebuilt/llama-b300", + "d backends/llama.cpp-prebuilt/llama-b300/sous", + "d backends/llama.cpp/build", + "d backends/llama.cpp/build/bin", + "d backends/llama.cpp/build/bin/Release", + "d engine", + "d engine/server-cuda-b10450", + "f backends/.cache/llama-server -rw-r--r-- x", + "f backends/fichier -rw-r--r-- x", + "f backends/fork-a/CMakeLists.txt -rw-r--r-- ", + "f backends/fork-a/build/bin/llama-server -rwxr-xr-x #!/bin/sh\n", + "f backends/fork-a/build/llama-server -rwxr-xr-x #!/bin/sh\n", + "f backends/fork-c/llama-server.exe -rw-r--r-- x", + "f backends/llama.cpp-prebuilt/VERSION -rw-r--r-- b300 fmt2\n", + "f backends/llama.cpp-prebuilt/autre/llama-server -rwxr-xr-x #!/bin/sh\n", + "f backends/llama.cpp-prebuilt/llama-b300/libggml.so.1.0 -rw-r--r-- lib", + "f backends/llama.cpp-prebuilt/llama-b300/llama-server -rwxr-xr-x #!/bin/sh\necho serveur\n", + "f backends/llama.cpp-prebuilt/llama-b300/sous/doc.txt -rw------- doc", + "f backends/llama.cpp/README -rw-r--r-- & état", + "f backends/llama.cpp/build/llama-server -rwxr-xr-x #!/bin/sh\n", + "f engine/server-cuda-b10450/llama-server -rwxr-xr-x #!/bin/sh\n", + "l backends/lien -> fork-b", + "l backends/llama.cpp-prebuilt/llama-b300/libggml.so -> absente.so", + "l backends/llama.cpp-prebuilt/llama-b300/libggml.so.1 -> libggml.so.1.0", + "l backends/llama.cpp-prebuilt/llama-b300/llama-cli -> {{HOME}}/backends/llama.cpp-prebuilt/llama-b300/llama-server" + ] + }, + { + "name": "lignes, from négatif", + "method": "GET", + "path": "/api/llamacpp/job?from=-4", + "status": 200, + "out": { + "action": "prebuilt", + "compiled": 0, + "ended_at": "{{T}}", + "error": "", + "exists": true, + "lines": [ + "▶ récupération de la dernière release llama.cpp…", + "release b300 — variant retenu : CPU (Linux x64)", + "▶ téléchargement de llama-b300-bin-ubuntu-x64.tar.gz (2 Mo)…", + "▶ extraction de llama-b300-bin-ubuntu-x64.tar.gz…", + "ancienne version supprimée : llama-b100", + "ancienne version supprimée : llama-b200", + "ancienne version supprimée : llama-b30", + "binaire installé : {{HOME}}/backends/llama.cpp-prebuilt/llama-b300/llama-server (release b300)", + "BIN mis à jour", + "✓ binaires précompilés installés (b300)" + ], + "new": "", + "next": 10, + "old": "", + "phase": "binaires précompilés installés (b300)", + "running": false, + "started_at": "{{T}}" + }, + "after": { + "config/BIN": "{{HOME}}/backends/llama.cpp-prebuilt/llama-b300/llama-server", + "loki-build.log": "▶ récupération de la dernière release llama.cpp…\nrelease b300 — variant retenu : CPU (Linux x64)\n▶ téléchargement de llama-b300-bin-ubuntu-x64.tar.gz (2 Mo)…\n▶ extraction de llama-b300-bin-ubuntu-x64.tar.gz…\nancienne version supprimée : llama-b100\nancienne version supprimée : llama-b200\nancienne version supprimée : llama-b30\nbinaire installé : {{HOME}}/backends/llama.cpp-prebuilt/llama-b300/llama-server (release b300)\nBIN mis à jour\n✓ binaires précompilés installés (b300)\n", + "state/build_job": "{\"action\":\"prebuilt\",\"phase\":\"binaires précompilés installés (b300)\",\"compiled\":0,\"running\":false,\"error\":\"\",\"started_at\":\"{{T}}\",\"ended_at\":\"{{T}}\",\"old\":\"\",\"new\":\"\"}" + }, + "tree": [ + "d backends", + "d backends/.cache", + "d backends/fork-a", + "d backends/fork-a/build", + "d backends/fork-a/build/bin", + "d backends/fork-c", + "d backends/fork-c/build", + "d backends/fork-c/build/bin", + "d backends/fork-c/build/bin/llama-server", + "d backends/llama.cpp", + "d backends/llama.cpp-prebuilt", + "d backends/llama.cpp-prebuilt/autre", + "d backends/llama.cpp-prebuilt/llama-b300", + "d backends/llama.cpp-prebuilt/llama-b300/sous", + "d backends/llama.cpp/build", + "d backends/llama.cpp/build/bin", + "d backends/llama.cpp/build/bin/Release", + "d engine", + "d engine/server-cuda-b10450", + "f backends/.cache/llama-server -rw-r--r-- x", + "f backends/fichier -rw-r--r-- x", + "f backends/fork-a/CMakeLists.txt -rw-r--r-- ", + "f backends/fork-a/build/bin/llama-server -rwxr-xr-x #!/bin/sh\n", + "f backends/fork-a/build/llama-server -rwxr-xr-x #!/bin/sh\n", + "f backends/fork-c/llama-server.exe -rw-r--r-- x", + "f backends/llama.cpp-prebuilt/VERSION -rw-r--r-- b300 fmt2\n", + "f backends/llama.cpp-prebuilt/autre/llama-server -rwxr-xr-x #!/bin/sh\n", + "f backends/llama.cpp-prebuilt/llama-b300/libggml.so.1.0 -rw-r--r-- lib", + "f backends/llama.cpp-prebuilt/llama-b300/llama-server -rwxr-xr-x #!/bin/sh\necho serveur\n", + "f backends/llama.cpp-prebuilt/llama-b300/sous/doc.txt -rw------- doc", + "f backends/llama.cpp/README -rw-r--r-- & état", + "f backends/llama.cpp/build/llama-server -rwxr-xr-x #!/bin/sh\n", + "f engine/server-cuda-b10450/llama-server -rwxr-xr-x #!/bin/sh\n", + "l backends/lien -> fork-b", + "l backends/llama.cpp-prebuilt/llama-b300/libggml.so -> absente.so", + "l backends/llama.cpp-prebuilt/llama-b300/libggml.so.1 -> libggml.so.1.0", + "l backends/llama.cpp-prebuilt/llama-b300/llama-cli -> {{HOME}}/backends/llama.cpp-prebuilt/llama-b300/llama-server" + ] + }, + { + "name": "lignes, from illisible", + "method": "GET", + "path": "/api/llamacpp/job?from=abc", + "status": 200, + "out": { + "action": "prebuilt", + "compiled": 0, + "ended_at": "{{T}}", + "error": "", + "exists": true, + "lines": [ + "▶ récupération de la dernière release llama.cpp…", + "release b300 — variant retenu : CPU (Linux x64)", + "▶ téléchargement de llama-b300-bin-ubuntu-x64.tar.gz (2 Mo)…", + "▶ extraction de llama-b300-bin-ubuntu-x64.tar.gz…", + "ancienne version supprimée : llama-b100", + "ancienne version supprimée : llama-b200", + "ancienne version supprimée : llama-b30", + "binaire installé : {{HOME}}/backends/llama.cpp-prebuilt/llama-b300/llama-server (release b300)", + "BIN mis à jour", + "✓ binaires précompilés installés (b300)" + ], + "new": "", + "next": 10, + "old": "", + "phase": "binaires précompilés installés (b300)", + "running": false, + "started_at": "{{T}}" + }, + "after": { + "config/BIN": "{{HOME}}/backends/llama.cpp-prebuilt/llama-b300/llama-server", + "loki-build.log": "▶ récupération de la dernière release llama.cpp…\nrelease b300 — variant retenu : CPU (Linux x64)\n▶ téléchargement de llama-b300-bin-ubuntu-x64.tar.gz (2 Mo)…\n▶ extraction de llama-b300-bin-ubuntu-x64.tar.gz…\nancienne version supprimée : llama-b100\nancienne version supprimée : llama-b200\nancienne version supprimée : llama-b30\nbinaire installé : {{HOME}}/backends/llama.cpp-prebuilt/llama-b300/llama-server (release b300)\nBIN mis à jour\n✓ binaires précompilés installés (b300)\n", + "state/build_job": "{\"action\":\"prebuilt\",\"phase\":\"binaires précompilés installés (b300)\",\"compiled\":0,\"running\":false,\"error\":\"\",\"started_at\":\"{{T}}\",\"ended_at\":\"{{T}}\",\"old\":\"\",\"new\":\"\"}" + }, + "tree": [ + "d backends", + "d backends/.cache", + "d backends/fork-a", + "d backends/fork-a/build", + "d backends/fork-a/build/bin", + "d backends/fork-c", + "d backends/fork-c/build", + "d backends/fork-c/build/bin", + "d backends/fork-c/build/bin/llama-server", + "d backends/llama.cpp", + "d backends/llama.cpp-prebuilt", + "d backends/llama.cpp-prebuilt/autre", + "d backends/llama.cpp-prebuilt/llama-b300", + "d backends/llama.cpp-prebuilt/llama-b300/sous", + "d backends/llama.cpp/build", + "d backends/llama.cpp/build/bin", + "d backends/llama.cpp/build/bin/Release", + "d engine", + "d engine/server-cuda-b10450", + "f backends/.cache/llama-server -rw-r--r-- x", + "f backends/fichier -rw-r--r-- x", + "f backends/fork-a/CMakeLists.txt -rw-r--r-- ", + "f backends/fork-a/build/bin/llama-server -rwxr-xr-x #!/bin/sh\n", + "f backends/fork-a/build/llama-server -rwxr-xr-x #!/bin/sh\n", + "f backends/fork-c/llama-server.exe -rw-r--r-- x", + "f backends/llama.cpp-prebuilt/VERSION -rw-r--r-- b300 fmt2\n", + "f backends/llama.cpp-prebuilt/autre/llama-server -rwxr-xr-x #!/bin/sh\n", + "f backends/llama.cpp-prebuilt/llama-b300/libggml.so.1.0 -rw-r--r-- lib", + "f backends/llama.cpp-prebuilt/llama-b300/llama-server -rwxr-xr-x #!/bin/sh\necho serveur\n", + "f backends/llama.cpp-prebuilt/llama-b300/sous/doc.txt -rw------- doc", + "f backends/llama.cpp/README -rw-r--r-- & état", + "f backends/llama.cpp/build/llama-server -rwxr-xr-x #!/bin/sh\n", + "f engine/server-cuda-b10450/llama-server -rwxr-xr-x #!/bin/sh\n", + "l backends/lien -> fork-b", + "l backends/llama.cpp-prebuilt/llama-b300/libggml.so -> absente.so", + "l backends/llama.cpp-prebuilt/llama-b300/libggml.so.1 -> libggml.so.1.0", + "l backends/llama.cpp-prebuilt/llama-b300/llama-cli -> {{HOME}}/backends/llama.cpp-prebuilt/llama-b300/llama-server" + ] + }, + { + "name": "lignes, from démesuré", + "method": "GET", + "path": "/api/llamacpp/job?from=99999999999999999999", + "status": 200, + "out": { + "action": "prebuilt", + "compiled": 0, + "ended_at": "{{T}}", + "error": "", + "exists": true, + "lines": null, + "new": "", + "next": 10, + "old": "", + "phase": "binaires précompilés installés (b300)", + "running": false, + "started_at": "{{T}}" + }, + "after": { + "config/BIN": "{{HOME}}/backends/llama.cpp-prebuilt/llama-b300/llama-server", + "loki-build.log": "▶ récupération de la dernière release llama.cpp…\nrelease b300 — variant retenu : CPU (Linux x64)\n▶ téléchargement de llama-b300-bin-ubuntu-x64.tar.gz (2 Mo)…\n▶ extraction de llama-b300-bin-ubuntu-x64.tar.gz…\nancienne version supprimée : llama-b100\nancienne version supprimée : llama-b200\nancienne version supprimée : llama-b30\nbinaire installé : {{HOME}}/backends/llama.cpp-prebuilt/llama-b300/llama-server (release b300)\nBIN mis à jour\n✓ binaires précompilés installés (b300)\n", + "state/build_job": "{\"action\":\"prebuilt\",\"phase\":\"binaires précompilés installés (b300)\",\"compiled\":0,\"running\":false,\"error\":\"\",\"started_at\":\"{{T}}\",\"ended_at\":\"{{T}}\",\"old\":\"\",\"new\":\"\"}" + }, + "tree": [ + "d backends", + "d backends/.cache", + "d backends/fork-a", + "d backends/fork-a/build", + "d backends/fork-a/build/bin", + "d backends/fork-c", + "d backends/fork-c/build", + "d backends/fork-c/build/bin", + "d backends/fork-c/build/bin/llama-server", + "d backends/llama.cpp", + "d backends/llama.cpp-prebuilt", + "d backends/llama.cpp-prebuilt/autre", + "d backends/llama.cpp-prebuilt/llama-b300", + "d backends/llama.cpp-prebuilt/llama-b300/sous", + "d backends/llama.cpp/build", + "d backends/llama.cpp/build/bin", + "d backends/llama.cpp/build/bin/Release", + "d engine", + "d engine/server-cuda-b10450", + "f backends/.cache/llama-server -rw-r--r-- x", + "f backends/fichier -rw-r--r-- x", + "f backends/fork-a/CMakeLists.txt -rw-r--r-- ", + "f backends/fork-a/build/bin/llama-server -rwxr-xr-x #!/bin/sh\n", + "f backends/fork-a/build/llama-server -rwxr-xr-x #!/bin/sh\n", + "f backends/fork-c/llama-server.exe -rw-r--r-- x", + "f backends/llama.cpp-prebuilt/VERSION -rw-r--r-- b300 fmt2\n", + "f backends/llama.cpp-prebuilt/autre/llama-server -rwxr-xr-x #!/bin/sh\n", + "f backends/llama.cpp-prebuilt/llama-b300/libggml.so.1.0 -rw-r--r-- lib", + "f backends/llama.cpp-prebuilt/llama-b300/llama-server -rwxr-xr-x #!/bin/sh\necho serveur\n", + "f backends/llama.cpp-prebuilt/llama-b300/sous/doc.txt -rw------- doc", + "f backends/llama.cpp/README -rw-r--r-- & état", + "f backends/llama.cpp/build/llama-server -rwxr-xr-x #!/bin/sh\n", + "f engine/server-cuda-b10450/llama-server -rwxr-xr-x #!/bin/sh\n", + "l backends/lien -> fork-b", + "l backends/llama.cpp-prebuilt/llama-b300/libggml.so -> absente.so", + "l backends/llama.cpp-prebuilt/llama-b300/libggml.so.1 -> libggml.so.1.0", + "l backends/llama.cpp-prebuilt/llama-b300/llama-cli -> {{HOME}}/backends/llama.cpp-prebuilt/llama-b300/llama-server" + ] + }, + { + "name": "déjà à jour", + "files": { + "backends/llama.cpp-prebuilt/llama-b250/x": "" + }, + "method": "POST", + "path": "/api/llamacpp/prebuilt", + "wait": true, + "status": 200, + "out": { + "ok": true + }, + "job": { + "action": "prebuilt", + "compiled": 0, + "ended_at": "{{T}}", + "error": "", + "exists": true, + "lines": [ + "▶ récupération de la dernière release llama.cpp…", + "release b300 — variant retenu : CPU (Linux x64)", + "déjà à jour (b300)", + "ancienne version supprimée : llama-b250", + "BIN mis à jour", + "✓ binaires précompilés installés (b300)" + ], + "new": "", + "next": 6, + "old": "", + "phase": "binaires précompilés installés (b300)", + "running": false, + "started_at": "{{T}}" + }, + "after": { + "config/BIN": "{{HOME}}/backends/llama.cpp-prebuilt/llama-b300/llama-server", + "loki-build.log": "▶ récupération de la dernière release llama.cpp…\nrelease b300 — variant retenu : CPU (Linux x64)\ndéjà à jour (b300)\nancienne version supprimée : llama-b250\nBIN mis à jour\n✓ binaires précompilés installés (b300)\n", + "state/build_job": "{\"action\":\"prebuilt\",\"phase\":\"binaires précompilés installés (b300)\",\"compiled\":0,\"running\":false,\"error\":\"\",\"started_at\":\"{{T}}\",\"ended_at\":\"{{T}}\",\"old\":\"\",\"new\":\"\"}" + }, + "tree": [ + "d backends", + "d backends/.cache", + "d backends/fork-a", + "d backends/fork-a/build", + "d backends/fork-a/build/bin", + "d backends/fork-c", + "d backends/fork-c/build", + "d backends/fork-c/build/bin", + "d backends/fork-c/build/bin/llama-server", + "d backends/llama.cpp", + "d backends/llama.cpp-prebuilt", + "d backends/llama.cpp-prebuilt/autre", + "d backends/llama.cpp-prebuilt/llama-b300", + "d backends/llama.cpp-prebuilt/llama-b300/sous", + "d backends/llama.cpp/build", + "d backends/llama.cpp/build/bin", + "d backends/llama.cpp/build/bin/Release", + "d engine", + "d engine/server-cuda-b10450", + "f backends/.cache/llama-server -rw-r--r-- x", + "f backends/fichier -rw-r--r-- x", + "f backends/fork-a/CMakeLists.txt -rw-r--r-- ", + "f backends/fork-a/build/bin/llama-server -rwxr-xr-x #!/bin/sh\n", + "f backends/fork-a/build/llama-server -rwxr-xr-x #!/bin/sh\n", + "f backends/fork-c/llama-server.exe -rw-r--r-- x", + "f backends/llama.cpp-prebuilt/VERSION -rw-r--r-- b300 fmt2\n", + "f backends/llama.cpp-prebuilt/autre/llama-server -rwxr-xr-x #!/bin/sh\n", + "f backends/llama.cpp-prebuilt/llama-b300/libggml.so.1.0 -rw-r--r-- lib", + "f backends/llama.cpp-prebuilt/llama-b300/llama-server -rwxr-xr-x #!/bin/sh\necho serveur\n", + "f backends/llama.cpp-prebuilt/llama-b300/sous/doc.txt -rw------- doc", + "f backends/llama.cpp/README -rw-r--r-- & état", + "f backends/llama.cpp/build/llama-server -rwxr-xr-x #!/bin/sh\n", + "f engine/server-cuda-b10450/llama-server -rwxr-xr-x #!/bin/sh\n", + "l backends/lien -> fork-b", + "l backends/llama.cpp-prebuilt/llama-b300/libggml.so -> absente.so", + "l backends/llama.cpp-prebuilt/llama-b300/libggml.so.1 -> libggml.so.1.0", + "l backends/llama.cpp-prebuilt/llama-b300/llama-cli -> {{HOME}}/backends/llama.cpp-prebuilt/llama-b300/llama-server" + ] + }, + { + "name": "ancien format : réextrait", + "files": { + "backends/llama.cpp-prebuilt/VERSION": "b300 \n" + }, + "method": "POST", + "path": "/api/llamacpp/prebuilt", + "wait": true, + "status": 200, + "out": { + "ok": true + }, + "job": { + "action": "prebuilt", + "compiled": 0, + "ended_at": "{{T}}", + "error": "", + "exists": true, + "lines": [ + "▶ récupération de la dernière release llama.cpp…", + "release b300 — variant retenu : CPU (Linux x64)", + "▶ téléchargement de llama-b300-bin-ubuntu-x64.tar.gz (2 Mo)…", + "▶ extraction de llama-b300-bin-ubuntu-x64.tar.gz…", + "binaire installé : {{HOME}}/backends/llama.cpp-prebuilt/llama-b300/llama-server (release b300)", + "BIN mis à jour", + "✓ binaires précompilés installés (b300)" + ], + "new": "", + "next": 7, + "old": "", + "phase": "binaires précompilés installés (b300)", + "running": false, + "started_at": "{{T}}" + }, + "after": { + "config/BIN": "{{HOME}}/backends/llama.cpp-prebuilt/llama-b300/llama-server", + "loki-build.log": "▶ récupération de la dernière release llama.cpp…\nrelease b300 — variant retenu : CPU (Linux x64)\n▶ téléchargement de llama-b300-bin-ubuntu-x64.tar.gz (2 Mo)…\n▶ extraction de llama-b300-bin-ubuntu-x64.tar.gz…\nbinaire installé : {{HOME}}/backends/llama.cpp-prebuilt/llama-b300/llama-server (release b300)\nBIN mis à jour\n✓ binaires précompilés installés (b300)\n", + "state/build_job": "{\"action\":\"prebuilt\",\"phase\":\"binaires précompilés installés (b300)\",\"compiled\":0,\"running\":false,\"error\":\"\",\"started_at\":\"{{T}}\",\"ended_at\":\"{{T}}\",\"old\":\"\",\"new\":\"\"}" + }, + "tree": [ + "d backends", + "d backends/.cache", + "d backends/fork-a", + "d backends/fork-a/build", + "d backends/fork-a/build/bin", + "d backends/fork-c", + "d backends/fork-c/build", + "d backends/fork-c/build/bin", + "d backends/fork-c/build/bin/llama-server", + "d backends/llama.cpp", + "d backends/llama.cpp-prebuilt", + "d backends/llama.cpp-prebuilt/autre", + "d backends/llama.cpp-prebuilt/llama-b300", + "d backends/llama.cpp-prebuilt/llama-b300/sous", + "d backends/llama.cpp/build", + "d backends/llama.cpp/build/bin", + "d backends/llama.cpp/build/bin/Release", + "d engine", + "d engine/server-cuda-b10450", + "f backends/.cache/llama-server -rw-r--r-- x", + "f backends/fichier -rw-r--r-- x", + "f backends/fork-a/CMakeLists.txt -rw-r--r-- ", + "f backends/fork-a/build/bin/llama-server -rwxr-xr-x #!/bin/sh\n", + "f backends/fork-a/build/llama-server -rwxr-xr-x #!/bin/sh\n", + "f backends/fork-c/llama-server.exe -rw-r--r-- x", + "f backends/llama.cpp-prebuilt/VERSION -rw-r--r-- b300 fmt2\n", + "f backends/llama.cpp-prebuilt/autre/llama-server -rwxr-xr-x #!/bin/sh\n", + "f backends/llama.cpp-prebuilt/llama-b300/libggml.so.1.0 -rw-r--r-- lib", + "f backends/llama.cpp-prebuilt/llama-b300/llama-server -rwxr-xr-x #!/bin/sh\necho serveur\n", + "f backends/llama.cpp-prebuilt/llama-b300/sous/doc.txt -rw------- doc", + "f backends/llama.cpp/README -rw-r--r-- & état", + "f backends/llama.cpp/build/llama-server -rwxr-xr-x #!/bin/sh\n", + "f engine/server-cuda-b10450/llama-server -rwxr-xr-x #!/bin/sh\n", + "l backends/lien -> fork-b", + "l backends/llama.cpp-prebuilt/llama-b300/libggml.so -> absente.so", + "l backends/llama.cpp-prebuilt/llama-b300/libggml.so.1 -> libggml.so.1.0", + "l backends/llama.cpp-prebuilt/llama-b300/llama-cli -> {{HOME}}/backends/llama.cpp-prebuilt/llama-b300/llama-server" + ] + }, + { + "name": "pendant une optimisation", + "tune": true, + "method": "POST", + "path": "/api/llamacpp/prebuilt", + "status": 409, + "out": { + "error": "optimisation en cours (loki tune) : le moteur est réservé aux essais — attends la fin ou annule-la", + "ok": false + }, + "after": { + "config/BIN": "{{HOME}}/backends/llama.cpp-prebuilt/llama-b300/llama-server", + "loki-build.log": "▶ récupération de la dernière release llama.cpp…\nrelease b300 — variant retenu : CPU (Linux x64)\n▶ téléchargement de llama-b300-bin-ubuntu-x64.tar.gz (2 Mo)…\n▶ extraction de llama-b300-bin-ubuntu-x64.tar.gz…\nbinaire installé : {{HOME}}/backends/llama.cpp-prebuilt/llama-b300/llama-server (release b300)\nBIN mis à jour\n✓ binaires précompilés installés (b300)\n", + "state/build_job": "{\"action\":\"prebuilt\",\"phase\":\"binaires précompilés installés (b300)\",\"compiled\":0,\"running\":false,\"error\":\"\",\"started_at\":\"{{T}}\",\"ended_at\":\"{{T}}\",\"old\":\"\",\"new\":\"\"}" + }, + "tree": [ + "d backends", + "d backends/.cache", + "d backends/fork-a", + "d backends/fork-a/build", + "d backends/fork-a/build/bin", + "d backends/fork-c", + "d backends/fork-c/build", + "d backends/fork-c/build/bin", + "d backends/fork-c/build/bin/llama-server", + "d backends/llama.cpp", + "d backends/llama.cpp-prebuilt", + "d backends/llama.cpp-prebuilt/autre", + "d backends/llama.cpp-prebuilt/llama-b300", + "d backends/llama.cpp-prebuilt/llama-b300/sous", + "d backends/llama.cpp/build", + "d backends/llama.cpp/build/bin", + "d backends/llama.cpp/build/bin/Release", + "d engine", + "d engine/server-cuda-b10450", + "f backends/.cache/llama-server -rw-r--r-- x", + "f backends/fichier -rw-r--r-- x", + "f backends/fork-a/CMakeLists.txt -rw-r--r-- ", + "f backends/fork-a/build/bin/llama-server -rwxr-xr-x #!/bin/sh\n", + "f backends/fork-a/build/llama-server -rwxr-xr-x #!/bin/sh\n", + "f backends/fork-c/llama-server.exe -rw-r--r-- x", + "f backends/llama.cpp-prebuilt/VERSION -rw-r--r-- b300 fmt2\n", + "f backends/llama.cpp-prebuilt/autre/llama-server -rwxr-xr-x #!/bin/sh\n", + "f backends/llama.cpp-prebuilt/llama-b300/libggml.so.1.0 -rw-r--r-- lib", + "f backends/llama.cpp-prebuilt/llama-b300/llama-server -rwxr-xr-x #!/bin/sh\necho serveur\n", + "f backends/llama.cpp-prebuilt/llama-b300/sous/doc.txt -rw------- doc", + "f backends/llama.cpp/README -rw-r--r-- & état", + "f backends/llama.cpp/build/llama-server -rwxr-xr-x #!/bin/sh\n", + "f engine/server-cuda-b10450/llama-server -rwxr-xr-x #!/bin/sh\n", + "l backends/lien -> fork-b", + "l backends/llama.cpp-prebuilt/llama-b300/libggml.so -> absente.so", + "l backends/llama.cpp-prebuilt/llama-b300/libggml.so.1 -> libggml.so.1.0", + "l backends/llama.cpp-prebuilt/llama-b300/llama-cli -> {{HOME}}/backends/llama.cpp-prebuilt/llama-b300/llama-server" + ] + }, + { + "name": "GitHub refuse", + "release": { + "status": 403, + "json": "{}" + }, + "method": "POST", + "path": "/api/llamacpp/prebuilt", + "wait": true, + "status": 200, + "out": { + "ok": true + }, + "job": { + "action": "prebuilt", + "compiled": 0, + "ended_at": "{{T}}", + "error": "impossible d'interroger les releases llama.cpp : GitHub API : HTTP 403", + "exists": true, + "lines": [ + "▶ récupération de la dernière release llama.cpp…", + "✗ impossible d'interroger les releases llama.cpp : GitHub API : HTTP 403" + ], + "new": "", + "next": 2, + "old": "", + "phase": "échec", + "running": false, + "started_at": "{{T}}" + }, + "after": { + "config/BIN": "{{HOME}}/backends/llama.cpp-prebuilt/llama-b300/llama-server", + "loki-build.log": "▶ récupération de la dernière release llama.cpp…\n✗ impossible d'interroger les releases llama.cpp : GitHub API : HTTP 403\n", + "state/build_job": "{\"action\":\"prebuilt\",\"phase\":\"échec\",\"compiled\":0,\"running\":false,\"error\":\"impossible d'interroger les releases llama.cpp : GitHub API : HTTP 403\",\"started_at\":\"{{T}}\",\"ended_at\":\"{{T}}\",\"old\":\"\",\"new\":\"\"}" + }, + "tree": [ + "d backends", + "d backends/.cache", + "d backends/fork-a", + "d backends/fork-a/build", + "d backends/fork-a/build/bin", + "d backends/fork-c", + "d backends/fork-c/build", + "d backends/fork-c/build/bin", + "d backends/fork-c/build/bin/llama-server", + "d backends/llama.cpp", + "d backends/llama.cpp-prebuilt", + "d backends/llama.cpp-prebuilt/autre", + "d backends/llama.cpp-prebuilt/llama-b300", + "d backends/llama.cpp-prebuilt/llama-b300/sous", + "d backends/llama.cpp/build", + "d backends/llama.cpp/build/bin", + "d backends/llama.cpp/build/bin/Release", + "d engine", + "d engine/server-cuda-b10450", + "f backends/.cache/llama-server -rw-r--r-- x", + "f backends/fichier -rw-r--r-- x", + "f backends/fork-a/CMakeLists.txt -rw-r--r-- ", + "f backends/fork-a/build/bin/llama-server -rwxr-xr-x #!/bin/sh\n", + "f backends/fork-a/build/llama-server -rwxr-xr-x #!/bin/sh\n", + "f backends/fork-c/llama-server.exe -rw-r--r-- x", + "f backends/llama.cpp-prebuilt/VERSION -rw-r--r-- b300 fmt2\n", + "f backends/llama.cpp-prebuilt/autre/llama-server -rwxr-xr-x #!/bin/sh\n", + "f backends/llama.cpp-prebuilt/llama-b300/libggml.so.1.0 -rw-r--r-- lib", + "f backends/llama.cpp-prebuilt/llama-b300/llama-server -rwxr-xr-x #!/bin/sh\necho serveur\n", + "f backends/llama.cpp-prebuilt/llama-b300/sous/doc.txt -rw------- doc", + "f backends/llama.cpp/README -rw-r--r-- & état", + "f backends/llama.cpp/build/llama-server -rwxr-xr-x #!/bin/sh\n", + "f engine/server-cuda-b10450/llama-server -rwxr-xr-x #!/bin/sh\n", + "l backends/lien -> fork-b", + "l backends/llama.cpp-prebuilt/llama-b300/libggml.so -> absente.so", + "l backends/llama.cpp-prebuilt/llama-b300/libggml.so.1 -> libggml.so.1.0", + "l backends/llama.cpp-prebuilt/llama-b300/llama-cli -> {{HOME}}/backends/llama.cpp-prebuilt/llama-b300/llama-server" + ] + }, + { + "name": "rien d'adapté", + "release": { + "status": 200, + "json": "{\"tag_name\":\"b302\",\"assets\":[{\"name\":\"llama-b302-bin-macos-arm64.zip\",\"browser_download_url\":\"{{GH}}/dl/llama-b302-bin-macos-arm64.zip\",\"size\":2500000}]}" + }, + "method": "POST", + "path": "/api/llamacpp/prebuilt", + "wait": true, + "status": 200, + "out": { + "ok": true + }, + "job": { + "action": "prebuilt", + "compiled": 0, + "ended_at": "{{T}}", + "error": "aucun binaire précompilé adapté à cette machine dans la release officielle", + "exists": true, + "lines": [ + "▶ récupération de la dernière release llama.cpp…", + "✗ aucun binaire précompilé adapté à cette machine dans la release officielle" + ], + "new": "", + "next": 2, + "old": "", + "phase": "échec", + "running": false, + "started_at": "{{T}}" + }, + "after": { + "config/BIN": "{{HOME}}/backends/llama.cpp-prebuilt/llama-b300/llama-server", + "loki-build.log": "▶ récupération de la dernière release llama.cpp…\n✗ aucun binaire précompilé adapté à cette machine dans la release officielle\n", + "state/build_job": "{\"action\":\"prebuilt\",\"phase\":\"échec\",\"compiled\":0,\"running\":false,\"error\":\"aucun binaire précompilé adapté à cette machine dans la release officielle\",\"started_at\":\"{{T}}\",\"ended_at\":\"{{T}}\",\"old\":\"\",\"new\":\"\"}" + }, + "tree": [ + "d backends", + "d backends/.cache", + "d backends/fork-a", + "d backends/fork-a/build", + "d backends/fork-a/build/bin", + "d backends/fork-c", + "d backends/fork-c/build", + "d backends/fork-c/build/bin", + "d backends/fork-c/build/bin/llama-server", + "d backends/llama.cpp", + "d backends/llama.cpp-prebuilt", + "d backends/llama.cpp-prebuilt/autre", + "d backends/llama.cpp-prebuilt/llama-b300", + "d backends/llama.cpp-prebuilt/llama-b300/sous", + "d backends/llama.cpp/build", + "d backends/llama.cpp/build/bin", + "d backends/llama.cpp/build/bin/Release", + "d engine", + "d engine/server-cuda-b10450", + "f backends/.cache/llama-server -rw-r--r-- x", + "f backends/fichier -rw-r--r-- x", + "f backends/fork-a/CMakeLists.txt -rw-r--r-- ", + "f backends/fork-a/build/bin/llama-server -rwxr-xr-x #!/bin/sh\n", + "f backends/fork-a/build/llama-server -rwxr-xr-x #!/bin/sh\n", + "f backends/fork-c/llama-server.exe -rw-r--r-- x", + "f backends/llama.cpp-prebuilt/VERSION -rw-r--r-- b300 fmt2\n", + "f backends/llama.cpp-prebuilt/autre/llama-server -rwxr-xr-x #!/bin/sh\n", + "f backends/llama.cpp-prebuilt/llama-b300/libggml.so.1.0 -rw-r--r-- lib", + "f backends/llama.cpp-prebuilt/llama-b300/llama-server -rwxr-xr-x #!/bin/sh\necho serveur\n", + "f backends/llama.cpp-prebuilt/llama-b300/sous/doc.txt -rw------- doc", + "f backends/llama.cpp/README -rw-r--r-- & état", + "f backends/llama.cpp/build/llama-server -rwxr-xr-x #!/bin/sh\n", + "f engine/server-cuda-b10450/llama-server -rwxr-xr-x #!/bin/sh\n", + "l backends/lien -> fork-b", + "l backends/llama.cpp-prebuilt/llama-b300/libggml.so -> absente.so", + "l backends/llama.cpp-prebuilt/llama-b300/libggml.so.1 -> libggml.so.1.0", + "l backends/llama.cpp-prebuilt/llama-b300/llama-cli -> {{HOME}}/backends/llama.cpp-prebuilt/llama-b300/llama-server" + ] + }, + { + "name": "archive absente", + "release": { + "status": 200, + "json": "{\"tag_name\":\"b303\",\"assets\":[{\"name\":\"llama-b300-bin-macos-arm64.zip\",\"browser_download_url\":\"{{GH}}/dl/llama-b300-bin-macos-arm64.zip\",\"size\":2500000},{\"name\":\"cudart-llama-bin-win-cuda-12.4-x64.zip\",\"browser_download_url\":\"{{GH}}/dl/cudart-llama-bin-win-cuda-12.4-x64.zip\",\"size\":2500000},{\"name\":\"llama-b300-bin-win-cuda-12.4-x64.zip\",\"browser_download_url\":\"{{GH}}/dl/llama-b300-bin-win-cuda-12.4-x64.zip\",\"size\":2500000},{\"name\":\"llama-b300-bin-ubuntu-vulkan-x64.tar.gz\",\"browser_download_url\":\"{{GH}}/dl/llama-b300-bin-ubuntu-vulkan-x64.tar.gz\",\"size\":2500000},{\"name\":\"llama-b300-bin-ubuntu-rocm-7.0-x64.tar.gz\",\"browser_download_url\":\"{{GH}}/dl/llama-b300-bin-ubuntu-rocm-7.0-x64.tar.gz\",\"size\":2500000},{\"name\":\"llama-b300-bin-ubuntu-vulkan-arm64.tar.gz\",\"browser_download_url\":\"{{GH}}/dl/llama-b300-bin-ubuntu-vulkan-arm64.tar.gz\",\"size\":2500000},{\"name\":\"llama-b300-bin-ubuntu-arm64.tar.gz\",\"browser_download_url\":\"{{GH}}/dl/llama-b300-bin-ubuntu-arm64.tar.gz\",\"size\":2500000},{\"name\":\"llama-b300-bin-ubuntu-x64.tar.gz\",\"browser_download_url\":\"{{GH}}/dl/llama-b300-bin-ubuntu-x64.tar.gz\",\"size\":2500000}]}" + }, + "method": "POST", + "path": "/api/llamacpp/prebuilt", + "wait": true, + "status": 200, + "out": { + "ok": true + }, + "job": { + "action": "prebuilt", + "compiled": 0, + "ended_at": "{{T}}", + "error": "téléchargement de llama-b300-bin-ubuntu-x64.tar.gz : HTTP 404", + "exists": true, + "lines": [ + "▶ récupération de la dernière release llama.cpp…", + "release b303 — variant retenu : CPU (Linux x64)", + "▶ téléchargement de llama-b300-bin-ubuntu-x64.tar.gz (2 Mo)…", + "✗ téléchargement de llama-b300-bin-ubuntu-x64.tar.gz : HTTP 404" + ], + "new": "", + "next": 4, + "old": "", + "phase": "échec", + "running": false, + "started_at": "{{T}}" + }, + "after": { + "config/BIN": "{{HOME}}/backends/llama.cpp-prebuilt/llama-b300/llama-server", + "loki-build.log": "▶ récupération de la dernière release llama.cpp…\nrelease b303 — variant retenu : CPU (Linux x64)\n▶ téléchargement de llama-b300-bin-ubuntu-x64.tar.gz (2 Mo)…\n✗ téléchargement de llama-b300-bin-ubuntu-x64.tar.gz : HTTP 404\n", + "state/build_job": "{\"action\":\"prebuilt\",\"phase\":\"échec\",\"compiled\":0,\"running\":false,\"error\":\"téléchargement de llama-b300-bin-ubuntu-x64.tar.gz : HTTP 404\",\"started_at\":\"{{T}}\",\"ended_at\":\"{{T}}\",\"old\":\"\",\"new\":\"\"}" + }, + "tree": [ + "d backends", + "d backends/.cache", + "d backends/fork-a", + "d backends/fork-a/build", + "d backends/fork-a/build/bin", + "d backends/fork-c", + "d backends/fork-c/build", + "d backends/fork-c/build/bin", + "d backends/fork-c/build/bin/llama-server", + "d backends/llama.cpp", + "d backends/llama.cpp-prebuilt", + "d backends/llama.cpp-prebuilt/autre", + "d backends/llama.cpp-prebuilt/llama-b300", + "d backends/llama.cpp-prebuilt/llama-b300/sous", + "d backends/llama.cpp/build", + "d backends/llama.cpp/build/bin", + "d backends/llama.cpp/build/bin/Release", + "d engine", + "d engine/server-cuda-b10450", + "f backends/.cache/llama-server -rw-r--r-- x", + "f backends/fichier -rw-r--r-- x", + "f backends/fork-a/CMakeLists.txt -rw-r--r-- ", + "f backends/fork-a/build/bin/llama-server -rwxr-xr-x #!/bin/sh\n", + "f backends/fork-a/build/llama-server -rwxr-xr-x #!/bin/sh\n", + "f backends/fork-c/llama-server.exe -rw-r--r-- x", + "f backends/llama.cpp-prebuilt/VERSION -rw-r--r-- b300 fmt2\n", + "f backends/llama.cpp-prebuilt/autre/llama-server -rwxr-xr-x #!/bin/sh\n", + "f backends/llama.cpp-prebuilt/llama-b300/libggml.so.1.0 -rw-r--r-- lib", + "f backends/llama.cpp-prebuilt/llama-b300/llama-server -rwxr-xr-x #!/bin/sh\necho serveur\n", + "f backends/llama.cpp-prebuilt/llama-b300/sous/doc.txt -rw------- doc", + "f backends/llama.cpp/README -rw-r--r-- & état", + "f backends/llama.cpp/build/llama-server -rwxr-xr-x #!/bin/sh\n", + "f engine/server-cuda-b10450/llama-server -rwxr-xr-x #!/bin/sh\n", + "l backends/lien -> fork-b", + "l backends/llama.cpp-prebuilt/llama-b300/libggml.so -> absente.so", + "l backends/llama.cpp-prebuilt/llama-b300/libggml.so.1 -> libggml.so.1.0", + "l backends/llama.cpp-prebuilt/llama-b300/llama-cli -> {{HOME}}/backends/llama.cpp-prebuilt/llama-b300/llama-server" + ] + }, + { + "name": "archive piégée", + "release": { + "status": 200, + "json": "{\"tag_name\":\"b304\",\"assets\":[{\"name\":\"llama-b300-bin-macos-arm64.zip\",\"browser_download_url\":\"{{GH}}/dl/llama-b300-bin-macos-arm64.zip\",\"size\":2500000},{\"name\":\"cudart-llama-bin-win-cuda-12.4-x64.zip\",\"browser_download_url\":\"{{GH}}/dl/cudart-llama-bin-win-cuda-12.4-x64.zip\",\"size\":2500000},{\"name\":\"llama-b300-bin-win-cuda-12.4-x64.zip\",\"browser_download_url\":\"{{GH}}/dl/llama-b300-bin-win-cuda-12.4-x64.zip\",\"size\":2500000},{\"name\":\"llama-b300-bin-ubuntu-vulkan-x64.tar.gz\",\"browser_download_url\":\"{{GH}}/dl/llama-b300-bin-ubuntu-vulkan-x64.tar.gz\",\"size\":2500000},{\"name\":\"llama-b300-bin-ubuntu-rocm-7.0-x64.tar.gz\",\"browser_download_url\":\"{{GH}}/dl/llama-b300-bin-ubuntu-rocm-7.0-x64.tar.gz\",\"size\":2500000},{\"name\":\"llama-b300-bin-ubuntu-vulkan-arm64.tar.gz\",\"browser_download_url\":\"{{GH}}/dl/llama-b300-bin-ubuntu-vulkan-arm64.tar.gz\",\"size\":2500000},{\"name\":\"llama-b300-bin-ubuntu-arm64.tar.gz\",\"browser_download_url\":\"{{GH}}/dl/llama-b300-bin-ubuntu-arm64.tar.gz\",\"size\":2500000},{\"name\":\"llama-b300-bin-ubuntu-x64.tar.gz\",\"browser_download_url\":\"{{GH}}/dl/llama-b300-bin-ubuntu-x64.tar.gz\",\"size\":2500000}]}", + "assets": { + "llama-b300-bin-ubuntu-x64.tar.gz": [ + { + "name": "llama-b304/ok", + "type": "file", + "body": "x", + "mode": 420 + }, + { + "name": "../evasion", + "type": "file", + "body": "x", + "mode": 420 + } + ] + } + }, + "method": "POST", + "path": "/api/llamacpp/prebuilt", + "wait": true, + "status": 200, + "out": { + "ok": true + }, + "job": { + "action": "prebuilt", + "compiled": 0, + "ended_at": "{{T}}", + "error": "extraction de llama-b300-bin-ubuntu-x64.tar.gz : entrée d'archive suspecte : ../evasion", + "exists": true, + "lines": [ + "▶ récupération de la dernière release llama.cpp…", + "release b304 — variant retenu : CPU (Linux x64)", + "▶ téléchargement de llama-b300-bin-ubuntu-x64.tar.gz (2 Mo)…", + "▶ extraction de llama-b300-bin-ubuntu-x64.tar.gz…", + "✗ extraction de llama-b300-bin-ubuntu-x64.tar.gz : entrée d'archive suspecte : ../evasion" + ], + "new": "", + "next": 5, + "old": "", + "phase": "échec", + "running": false, + "started_at": "{{T}}" + }, + "after": { + "config/BIN": "{{HOME}}/backends/llama.cpp-prebuilt/llama-b300/llama-server", + "loki-build.log": "▶ récupération de la dernière release llama.cpp…\nrelease b304 — variant retenu : CPU (Linux x64)\n▶ téléchargement de llama-b300-bin-ubuntu-x64.tar.gz (2 Mo)…\n▶ extraction de llama-b300-bin-ubuntu-x64.tar.gz…\n✗ extraction de llama-b300-bin-ubuntu-x64.tar.gz : entrée d'archive suspecte : ../evasion\n", + "state/build_job": "{\"action\":\"prebuilt\",\"phase\":\"échec\",\"compiled\":0,\"running\":false,\"error\":\"extraction de llama-b300-bin-ubuntu-x64.tar.gz : entrée d'archive suspecte : ../evasion\",\"started_at\":\"{{T}}\",\"ended_at\":\"{{T}}\",\"old\":\"\",\"new\":\"\"}" + }, + "tree": [ + "d backends", + "d backends/.cache", + "d backends/fork-a", + "d backends/fork-a/build", + "d backends/fork-a/build/bin", + "d backends/fork-c", + "d backends/fork-c/build", + "d backends/fork-c/build/bin", + "d backends/fork-c/build/bin/llama-server", + "d backends/llama.cpp", + "d backends/llama.cpp-prebuilt", + "d backends/llama.cpp-prebuilt/autre", + "d backends/llama.cpp-prebuilt/llama-b300", + "d backends/llama.cpp-prebuilt/llama-b300/sous", + "d backends/llama.cpp-prebuilt/llama-b304", + "d backends/llama.cpp/build", + "d backends/llama.cpp/build/bin", + "d backends/llama.cpp/build/bin/Release", + "d engine", + "d engine/server-cuda-b10450", + "f backends/.cache/llama-server -rw-r--r-- x", + "f backends/fichier -rw-r--r-- x", + "f backends/fork-a/CMakeLists.txt -rw-r--r-- ", + "f backends/fork-a/build/bin/llama-server -rwxr-xr-x #!/bin/sh\n", + "f backends/fork-a/build/llama-server -rwxr-xr-x #!/bin/sh\n", + "f backends/fork-c/llama-server.exe -rw-r--r-- x", + "f backends/llama.cpp-prebuilt/VERSION -rw-r--r-- b300 fmt2\n", + "f backends/llama.cpp-prebuilt/autre/llama-server -rwxr-xr-x #!/bin/sh\n", + "f backends/llama.cpp-prebuilt/llama-b300/libggml.so.1.0 -rw-r--r-- lib", + "f backends/llama.cpp-prebuilt/llama-b300/llama-server -rwxr-xr-x #!/bin/sh\necho serveur\n", + "f backends/llama.cpp-prebuilt/llama-b300/sous/doc.txt -rw------- doc", + "f backends/llama.cpp-prebuilt/llama-b304/ok -rw-r--r-- x", + "f backends/llama.cpp/README -rw-r--r-- & état", + "f backends/llama.cpp/build/llama-server -rwxr-xr-x #!/bin/sh\n", + "f engine/server-cuda-b10450/llama-server -rwxr-xr-x #!/bin/sh\n", + "l backends/lien -> fork-b", + "l backends/llama.cpp-prebuilt/llama-b300/libggml.so -> absente.so", + "l backends/llama.cpp-prebuilt/llama-b300/libggml.so.1 -> libggml.so.1.0", + "l backends/llama.cpp-prebuilt/llama-b300/llama-cli -> {{HOME}}/backends/llama.cpp-prebuilt/llama-b300/llama-server" + ] + }, + { + "name": "archive sans serveur", + "files": { + "backends/llama.cpp-prebuilt/": "-" + }, + "release": { + "status": 200, + "json": "{\"tag_name\":\"b305\",\"assets\":[{\"name\":\"llama-b300-bin-macos-arm64.zip\",\"browser_download_url\":\"{{GH}}/dl/llama-b300-bin-macos-arm64.zip\",\"size\":2500000},{\"name\":\"cudart-llama-bin-win-cuda-12.4-x64.zip\",\"browser_download_url\":\"{{GH}}/dl/cudart-llama-bin-win-cuda-12.4-x64.zip\",\"size\":2500000},{\"name\":\"llama-b300-bin-win-cuda-12.4-x64.zip\",\"browser_download_url\":\"{{GH}}/dl/llama-b300-bin-win-cuda-12.4-x64.zip\",\"size\":2500000},{\"name\":\"llama-b300-bin-ubuntu-vulkan-x64.tar.gz\",\"browser_download_url\":\"{{GH}}/dl/llama-b300-bin-ubuntu-vulkan-x64.tar.gz\",\"size\":2500000},{\"name\":\"llama-b300-bin-ubuntu-rocm-7.0-x64.tar.gz\",\"browser_download_url\":\"{{GH}}/dl/llama-b300-bin-ubuntu-rocm-7.0-x64.tar.gz\",\"size\":2500000},{\"name\":\"llama-b300-bin-ubuntu-vulkan-arm64.tar.gz\",\"browser_download_url\":\"{{GH}}/dl/llama-b300-bin-ubuntu-vulkan-arm64.tar.gz\",\"size\":2500000},{\"name\":\"llama-b300-bin-ubuntu-arm64.tar.gz\",\"browser_download_url\":\"{{GH}}/dl/llama-b300-bin-ubuntu-arm64.tar.gz\",\"size\":2500000},{\"name\":\"llama-b300-bin-ubuntu-x64.tar.gz\",\"browser_download_url\":\"{{GH}}/dl/llama-b300-bin-ubuntu-x64.tar.gz\",\"size\":2500000}]}", + "assets": { + "llama-b300-bin-ubuntu-x64.tar.gz": [ + { + "name": "llama-b305/llama-cli", + "type": "file", + "body": "x", + "mode": 493 + } + ] + } + }, + "method": "POST", + "path": "/api/llamacpp/prebuilt", + "wait": true, + "status": 200, + "out": { + "ok": true + }, + "job": { + "action": "prebuilt", + "compiled": 0, + "ended_at": "{{T}}", + "error": "archives extraites mais llama-server introuvable sous {{HOME}}/backends/llama.cpp-prebuilt", + "exists": true, + "lines": [ + "▶ récupération de la dernière release llama.cpp…", + "release b305 — variant retenu : CPU (Linux x64)", + "▶ téléchargement de llama-b300-bin-ubuntu-x64.tar.gz (2 Mo)…", + "▶ extraction de llama-b300-bin-ubuntu-x64.tar.gz…", + "✗ archives extraites mais llama-server introuvable sous {{HOME}}/backends/llama.cpp-prebuilt" + ], + "new": "", + "next": 5, + "old": "", + "phase": "échec", + "running": false, + "started_at": "{{T}}" + }, + "after": { + "config/BIN": "{{HOME}}/backends/llama.cpp-prebuilt/llama-b300/llama-server", + "loki-build.log": "▶ récupération de la dernière release llama.cpp…\nrelease b305 — variant retenu : CPU (Linux x64)\n▶ téléchargement de llama-b300-bin-ubuntu-x64.tar.gz (2 Mo)…\n▶ extraction de llama-b300-bin-ubuntu-x64.tar.gz…\n✗ archives extraites mais llama-server introuvable sous {{HOME}}/backends/llama.cpp-prebuilt\n", + "state/build_job": "{\"action\":\"prebuilt\",\"phase\":\"échec\",\"compiled\":0,\"running\":false,\"error\":\"archives extraites mais llama-server introuvable sous {{HOME}}/backends/llama.cpp-prebuilt\",\"started_at\":\"{{T}}\",\"ended_at\":\"{{T}}\",\"old\":\"\",\"new\":\"\"}" + }, + "tree": [ + "d backends", + "d backends/.cache", + "d backends/fork-a", + "d backends/fork-a/build", + "d backends/fork-a/build/bin", + "d backends/fork-c", + "d backends/fork-c/build", + "d backends/fork-c/build/bin", + "d backends/fork-c/build/bin/llama-server", + "d backends/llama.cpp", + "d backends/llama.cpp-prebuilt", + "d backends/llama.cpp-prebuilt/llama-b305", + "d backends/llama.cpp/build", + "d backends/llama.cpp/build/bin", + "d backends/llama.cpp/build/bin/Release", + "d engine", + "d engine/server-cuda-b10450", + "f backends/.cache/llama-server -rw-r--r-- x", + "f backends/fichier -rw-r--r-- x", + "f backends/fork-a/CMakeLists.txt -rw-r--r-- ", + "f backends/fork-a/build/bin/llama-server -rwxr-xr-x #!/bin/sh\n", + "f backends/fork-a/build/llama-server -rwxr-xr-x #!/bin/sh\n", + "f backends/fork-c/llama-server.exe -rw-r--r-- x", + "f backends/llama.cpp-prebuilt/VERSION -rw-r--r-- b305 fmt2\n", + "f backends/llama.cpp-prebuilt/llama-b305/llama-cli -rwxr-xr-x x", + "f backends/llama.cpp/README -rw-r--r-- & état", + "f backends/llama.cpp/build/llama-server -rwxr-xr-x #!/bin/sh\n", + "f engine/server-cuda-b10450/llama-server -rwxr-xr-x #!/bin/sh\n", + "l backends/lien -> fork-b" + ] + }, + { + "name": "statut après échecs", + "method": "GET", + "path": "/api/llamacpp", + "status": 200, + "out": { + "backends_dir": "{{HOME}}/backends", + "bin": "{{HOME}}/backends/llama.cpp/build/llama-server", + "config_bin": "{{HOME}}/backends/llama.cpp-prebuilt/llama-b300/llama-server", + "engine": { + "dir": "{{HOME}}/engine", + "in_use": false, + "tag": "" + }, + "in_use": false, + "installed": false, + "job": { + "action": "prebuilt", + "compiled": 0, + "ended_at": "{{T}}", + "error": "archives extraites mais llama-server introuvable sous {{HOME}}/backends/llama.cpp-prebuilt", + "exists": true, + "new": "", + "old": "", + "phase": "échec", + "running": false, + "started_at": "{{T}}" + }, + "plan": { + "arch": "", + "backend": "cpu", + "jobs": "{{JOBS}}" + }, + "prebuilt": { + "bin": "", + "dir": "{{HOME}}/backends/llama.cpp-prebuilt", + "in_use": false, + "tag": "b305" + }, + "provided": false, + "provided_bin": "", + "reco": { + "mode": "fast", + "why": "" + }, + "repo": "{{HOME}}/backends/llama.cpp" + }, + "after": { + "config/BIN": "{{HOME}}/backends/llama.cpp-prebuilt/llama-b300/llama-server", + "loki-build.log": "▶ récupération de la dernière release llama.cpp…\nrelease b305 — variant retenu : CPU (Linux x64)\n▶ téléchargement de llama-b300-bin-ubuntu-x64.tar.gz (2 Mo)…\n▶ extraction de llama-b300-bin-ubuntu-x64.tar.gz…\n✗ archives extraites mais llama-server introuvable sous {{HOME}}/backends/llama.cpp-prebuilt\n", + "state/build_job": "{\"action\":\"prebuilt\",\"phase\":\"échec\",\"compiled\":0,\"running\":false,\"error\":\"archives extraites mais llama-server introuvable sous {{HOME}}/backends/llama.cpp-prebuilt\",\"started_at\":\"{{T}}\",\"ended_at\":\"{{T}}\",\"old\":\"\",\"new\":\"\"}" + }, + "tree": [ + "d backends", + "d backends/.cache", + "d backends/fork-a", + "d backends/fork-a/build", + "d backends/fork-a/build/bin", + "d backends/fork-c", + "d backends/fork-c/build", + "d backends/fork-c/build/bin", + "d backends/fork-c/build/bin/llama-server", + "d backends/llama.cpp", + "d backends/llama.cpp-prebuilt", + "d backends/llama.cpp-prebuilt/llama-b305", + "d backends/llama.cpp/build", + "d backends/llama.cpp/build/bin", + "d backends/llama.cpp/build/bin/Release", + "d engine", + "d engine/server-cuda-b10450", + "f backends/.cache/llama-server -rw-r--r-- x", + "f backends/fichier -rw-r--r-- x", + "f backends/fork-a/CMakeLists.txt -rw-r--r-- ", + "f backends/fork-a/build/bin/llama-server -rwxr-xr-x #!/bin/sh\n", + "f backends/fork-a/build/llama-server -rwxr-xr-x #!/bin/sh\n", + "f backends/fork-c/llama-server.exe -rw-r--r-- x", + "f backends/llama.cpp-prebuilt/VERSION -rw-r--r-- b305 fmt2\n", + "f backends/llama.cpp-prebuilt/llama-b305/llama-cli -rwxr-xr-x x", + "f backends/llama.cpp/README -rw-r--r-- & état", + "f backends/llama.cpp/build/llama-server -rwxr-xr-x #!/bin/sh\n", + "f engine/server-cuda-b10450/llama-server -rwxr-xr-x #!/bin/sh\n", + "l backends/lien -> fork-b" + ] + }, + { + "name": "GPU sans moteur", + "config": { + "BIN": "" + }, + "method": "POST", + "path": "/api/backends/devices", + "body": "{}", + "status": 200, + "out": { + "error": "moteur introuvable — choisissez d'abord un moteur", + "ok": false + }, + "after": { + "loki-build.log": "▶ récupération de la dernière release llama.cpp…\nrelease b305 — variant retenu : CPU (Linux x64)\n▶ téléchargement de llama-b300-bin-ubuntu-x64.tar.gz (2 Mo)…\n▶ extraction de llama-b300-bin-ubuntu-x64.tar.gz…\n✗ archives extraites mais llama-server introuvable sous {{HOME}}/backends/llama.cpp-prebuilt\n", + "state/build_job": "{\"action\":\"prebuilt\",\"phase\":\"échec\",\"compiled\":0,\"running\":false,\"error\":\"archives extraites mais llama-server introuvable sous {{HOME}}/backends/llama.cpp-prebuilt\",\"started_at\":\"{{T}}\",\"ended_at\":\"{{T}}\",\"old\":\"\",\"new\":\"\"}" + }, + "tree": [ + "d backends", + "d backends/.cache", + "d backends/fork-a", + "d backends/fork-a/build", + "d backends/fork-a/build/bin", + "d backends/fork-c", + "d backends/fork-c/build", + "d backends/fork-c/build/bin", + "d backends/fork-c/build/bin/llama-server", + "d backends/llama.cpp", + "d backends/llama.cpp-prebuilt", + "d backends/llama.cpp-prebuilt/llama-b305", + "d backends/llama.cpp/build", + "d backends/llama.cpp/build/bin", + "d backends/llama.cpp/build/bin/Release", + "d engine", + "d engine/server-cuda-b10450", + "f backends/.cache/llama-server -rw-r--r-- x", + "f backends/fichier -rw-r--r-- x", + "f backends/fork-a/CMakeLists.txt -rw-r--r-- ", + "f backends/fork-a/build/bin/llama-server -rwxr-xr-x #!/bin/sh\n", + "f backends/fork-a/build/llama-server -rwxr-xr-x #!/bin/sh\n", + "f backends/fork-c/llama-server.exe -rw-r--r-- x", + "f backends/llama.cpp-prebuilt/VERSION -rw-r--r-- b305 fmt2\n", + "f backends/llama.cpp-prebuilt/llama-b305/llama-cli -rwxr-xr-x x", + "f backends/llama.cpp/README -rw-r--r-- & état", + "f backends/llama.cpp/build/llama-server -rwxr-xr-x #!/bin/sh\n", + "f engine/server-cuda-b10450/llama-server -rwxr-xr-x #!/bin/sh\n", + "l backends/lien -> fork-b" + ] + }, + { + "name": "GPU d'un moteur absent", + "method": "POST", + "path": "/api/backends/devices", + "body": "{\"bin\":\"/nulle/part/llama-server\"}", + "status": 200, + "out": { + "error": "moteur introuvable — choisissez d'abord un moteur", + "ok": false + }, + "after": { + "loki-build.log": "▶ récupération de la dernière release llama.cpp…\nrelease b305 — variant retenu : CPU (Linux x64)\n▶ téléchargement de llama-b300-bin-ubuntu-x64.tar.gz (2 Mo)…\n▶ extraction de llama-b300-bin-ubuntu-x64.tar.gz…\n✗ archives extraites mais llama-server introuvable sous {{HOME}}/backends/llama.cpp-prebuilt\n", + "state/build_job": "{\"action\":\"prebuilt\",\"phase\":\"échec\",\"compiled\":0,\"running\":false,\"error\":\"archives extraites mais llama-server introuvable sous {{HOME}}/backends/llama.cpp-prebuilt\",\"started_at\":\"{{T}}\",\"ended_at\":\"{{T}}\",\"old\":\"\",\"new\":\"\"}" + }, + "tree": [ + "d backends", + "d backends/.cache", + "d backends/fork-a", + "d backends/fork-a/build", + "d backends/fork-a/build/bin", + "d backends/fork-c", + "d backends/fork-c/build", + "d backends/fork-c/build/bin", + "d backends/fork-c/build/bin/llama-server", + "d backends/llama.cpp", + "d backends/llama.cpp-prebuilt", + "d backends/llama.cpp-prebuilt/llama-b305", + "d backends/llama.cpp/build", + "d backends/llama.cpp/build/bin", + "d backends/llama.cpp/build/bin/Release", + "d engine", + "d engine/server-cuda-b10450", + "f backends/.cache/llama-server -rw-r--r-- x", + "f backends/fichier -rw-r--r-- x", + "f backends/fork-a/CMakeLists.txt -rw-r--r-- ", + "f backends/fork-a/build/bin/llama-server -rwxr-xr-x #!/bin/sh\n", + "f backends/fork-a/build/llama-server -rwxr-xr-x #!/bin/sh\n", + "f backends/fork-c/llama-server.exe -rw-r--r-- x", + "f backends/llama.cpp-prebuilt/VERSION -rw-r--r-- b305 fmt2\n", + "f backends/llama.cpp-prebuilt/llama-b305/llama-cli -rwxr-xr-x x", + "f backends/llama.cpp/README -rw-r--r-- & état", + "f backends/llama.cpp/build/llama-server -rwxr-xr-x #!/bin/sh\n", + "f engine/server-cuda-b10450/llama-server -rwxr-xr-x #!/bin/sh\n", + "l backends/lien -> fork-b" + ] + }, + { + "name": "GPU : pas un llama-server", + "files": { + "outils/autre": "#!/bin/sh\n" + }, + "method": "POST", + "path": "/api/backends/devices", + "body": "{\"bin\":\"{{HOME}}/outils/autre\"}", + "status": 400, + "out": { + "error": "ce chemin n'est pas un llama-server", + "ok": false + }, + "after": { + "loki-build.log": "▶ récupération de la dernière release llama.cpp…\nrelease b305 — variant retenu : CPU (Linux x64)\n▶ téléchargement de llama-b300-bin-ubuntu-x64.tar.gz (2 Mo)…\n▶ extraction de llama-b300-bin-ubuntu-x64.tar.gz…\n✗ archives extraites mais llama-server introuvable sous {{HOME}}/backends/llama.cpp-prebuilt\n", + "state/build_job": "{\"action\":\"prebuilt\",\"phase\":\"échec\",\"compiled\":0,\"running\":false,\"error\":\"archives extraites mais llama-server introuvable sous {{HOME}}/backends/llama.cpp-prebuilt\",\"started_at\":\"{{T}}\",\"ended_at\":\"{{T}}\",\"old\":\"\",\"new\":\"\"}" + }, + "tree": [ + "d backends", + "d backends/.cache", + "d backends/fork-a", + "d backends/fork-a/build", + "d backends/fork-a/build/bin", + "d backends/fork-c", + "d backends/fork-c/build", + "d backends/fork-c/build/bin", + "d backends/fork-c/build/bin/llama-server", + "d backends/llama.cpp", + "d backends/llama.cpp-prebuilt", + "d backends/llama.cpp-prebuilt/llama-b305", + "d backends/llama.cpp/build", + "d backends/llama.cpp/build/bin", + "d backends/llama.cpp/build/bin/Release", + "d engine", + "d engine/server-cuda-b10450", + "f backends/.cache/llama-server -rw-r--r-- x", + "f backends/fichier -rw-r--r-- x", + "f backends/fork-a/CMakeLists.txt -rw-r--r-- ", + "f backends/fork-a/build/bin/llama-server -rwxr-xr-x #!/bin/sh\n", + "f backends/fork-a/build/llama-server -rwxr-xr-x #!/bin/sh\n", + "f backends/fork-c/llama-server.exe -rw-r--r-- x", + "f backends/llama.cpp-prebuilt/VERSION -rw-r--r-- b305 fmt2\n", + "f backends/llama.cpp-prebuilt/llama-b305/llama-cli -rwxr-xr-x x", + "f backends/llama.cpp/README -rw-r--r-- & état", + "f backends/llama.cpp/build/llama-server -rwxr-xr-x #!/bin/sh\n", + "f engine/server-cuda-b10450/llama-server -rwxr-xr-x #!/bin/sh\n", + "l backends/lien -> fork-b" + ] + }, + { + "name": "GPU listés", + "machine": "nvidia", + "files": { + "gpu/LLAMA-SERVER": "#!/bin/sh\n[ \"$1\" = --list-devices ] || exit 7\necho 'Available devices:'\necho \" CUDA0: NVIDIA GeForce RTX 3090 (0 MiB, 0 MiB free)\"\necho \" CUDA1: Carte B (8192 MiB, 8000 MiB free)\"\necho \" Vulkan0: NVIDIA GeForce RTX 3090 (24576 MiB, 20000 MiB free)\"\necho \" CUDA2: ordre ${CUDA_DEVICE_ORDER:-aucun} cvd ${CUDA_VISIBLE_DEVICES:-aucun} (1 MiB, 1 MiB free)\" >&2\n" + }, + "method": "POST", + "path": "/api/backends/devices", + "body": "{\"bin\":\" {{HOME}}/gpu/LLAMA-SERVER \"}", + "status": 200, + "out": { + "devices": [ + { + "free_mib": 0, + "id": "CUDA0", + "link": { + "gen": 1, + "gen_max": 4, + "mem_clock_mhz": 9751, + "width": 16, + "width_max": 16 + }, + "name": "NVIDIA GeForce RTX 3090", + "total_mib": 24576 + }, + { + "free_mib": 8000, + "id": "CUDA1", + "link": { + "gen": 0, + "gen_max": 3, + "mem_clock_mhz": 7001, + "width": 8, + "width_max": 16 + }, + "name": "Carte B", + "total_mib": 8192 + }, + { + "free_mib": 20000, + "id": "Vulkan0", + "name": "NVIDIA GeForce RTX 3090", + "total_mib": 24576 + }, + { + "free_mib": 1, + "id": "CUDA2", + "name": "ordre aucun cvd aucun", + "total_mib": 1 + } + ], + "ok": true + }, + "after": { + "devices.json": "{\n \"{{HOME}}/gpu/LLAMA-SERVER\\u0000\": [\n {\n \"free_mib\": 0,\n \"id\": \"CUDA0\",\n \"link\": {\n \"gen\": 1,\n \"gen_max\": 4,\n \"mem_clock_mhz\": 9751,\n \"width\": 16,\n \"width_max\": 16\n },\n \"name\": \"NVIDIA GeForce RTX 3090\",\n \"total_mib\": 24576\n },\n {\n \"free_mib\": 8000,\n \"id\": \"CUDA1\",\n \"link\": {\n \"gen\": 0,\n \"gen_max\": 3,\n \"mem_clock_mhz\": 7001,\n \"width\": 8,\n \"width_max\": 16\n },\n \"name\": \"Carte B\",\n \"total_mib\": 8192\n },\n {\n \"free_mib\": 20000,\n \"id\": \"Vulkan0\",\n \"name\": \"NVIDIA GeForce RTX 3090\",\n \"total_mib\": 24576\n },\n {\n \"free_mib\": 1,\n \"id\": \"CUDA2\",\n \"name\": \"ordre aucun cvd aucun\",\n \"total_mib\": 1\n }\n ]\n}", + "loki-build.log": "▶ récupération de la dernière release llama.cpp…\nrelease b305 — variant retenu : CPU (Linux x64)\n▶ téléchargement de llama-b300-bin-ubuntu-x64.tar.gz (2 Mo)…\n▶ extraction de llama-b300-bin-ubuntu-x64.tar.gz…\n✗ archives extraites mais llama-server introuvable sous {{HOME}}/backends/llama.cpp-prebuilt\n", + "state/build_job": "{\"action\":\"prebuilt\",\"phase\":\"échec\",\"compiled\":0,\"running\":false,\"error\":\"archives extraites mais llama-server introuvable sous {{HOME}}/backends/llama.cpp-prebuilt\",\"started_at\":\"{{T}}\",\"ended_at\":\"{{T}}\",\"old\":\"\",\"new\":\"\"}" + }, + "tree": [ + "d backends", + "d backends/.cache", + "d backends/fork-a", + "d backends/fork-a/build", + "d backends/fork-a/build/bin", + "d backends/fork-c", + "d backends/fork-c/build", + "d backends/fork-c/build/bin", + "d backends/fork-c/build/bin/llama-server", + "d backends/llama.cpp", + "d backends/llama.cpp-prebuilt", + "d backends/llama.cpp-prebuilt/llama-b305", + "d backends/llama.cpp/build", + "d backends/llama.cpp/build/bin", + "d backends/llama.cpp/build/bin/Release", + "d engine", + "d engine/server-cuda-b10450", + "f backends/.cache/llama-server -rw-r--r-- x", + "f backends/fichier -rw-r--r-- x", + "f backends/fork-a/CMakeLists.txt -rw-r--r-- ", + "f backends/fork-a/build/bin/llama-server -rwxr-xr-x #!/bin/sh\n", + "f backends/fork-a/build/llama-server -rwxr-xr-x #!/bin/sh\n", + "f backends/fork-c/llama-server.exe -rw-r--r-- x", + "f backends/llama.cpp-prebuilt/VERSION -rw-r--r-- b305 fmt2\n", + "f backends/llama.cpp-prebuilt/llama-b305/llama-cli -rwxr-xr-x x", + "f backends/llama.cpp/README -rw-r--r-- & état", + "f backends/llama.cpp/build/llama-server -rwxr-xr-x #!/bin/sh\n", + "f engine/server-cuda-b10450/llama-server -rwxr-xr-x #!/bin/sh\n", + "l backends/lien -> fork-b" + ] + }, + { + "name": "GPU en cache", + "files": { + "gpu/LLAMA-SERVER": "#!/bin/sh\necho ' CUDA0: changé (1 MiB, 1 MiB free)'\n" + }, + "method": "POST", + "path": "/api/backends/devices", + "body": "{\"bin\":\"{{HOME}}/gpu/LLAMA-SERVER\"}", + "status": 200, + "out": { + "devices": [ + { + "free_mib": 0, + "id": "CUDA0", + "link": { + "gen": 1, + "gen_max": 4, + "mem_clock_mhz": 9751, + "width": 16, + "width_max": 16 + }, + "name": "NVIDIA GeForce RTX 3090", + "total_mib": 24576 + }, + { + "free_mib": 8000, + "id": "CUDA1", + "link": { + "gen": 0, + "gen_max": 3, + "mem_clock_mhz": 7001, + "width": 8, + "width_max": 16 + }, + "name": "Carte B", + "total_mib": 8192 + }, + { + "free_mib": 20000, + "id": "Vulkan0", + "name": "NVIDIA GeForce RTX 3090", + "total_mib": 24576 + }, + { + "free_mib": 1, + "id": "CUDA2", + "name": "ordre aucun cvd aucun", + "total_mib": 1 + } + ], + "ok": true + }, + "after": { + "devices.json": "{\n \"{{HOME}}/gpu/LLAMA-SERVER\\u0000\": [\n {\n \"free_mib\": 0,\n \"id\": \"CUDA0\",\n \"link\": {\n \"gen\": 1,\n \"gen_max\": 4,\n \"mem_clock_mhz\": 9751,\n \"width\": 16,\n \"width_max\": 16\n },\n \"name\": \"NVIDIA GeForce RTX 3090\",\n \"total_mib\": 24576\n },\n {\n \"free_mib\": 8000,\n \"id\": \"CUDA1\",\n \"link\": {\n \"gen\": 0,\n \"gen_max\": 3,\n \"mem_clock_mhz\": 7001,\n \"width\": 8,\n \"width_max\": 16\n },\n \"name\": \"Carte B\",\n \"total_mib\": 8192\n },\n {\n \"free_mib\": 20000,\n \"id\": \"Vulkan0\",\n \"name\": \"NVIDIA GeForce RTX 3090\",\n \"total_mib\": 24576\n },\n {\n \"free_mib\": 1,\n \"id\": \"CUDA2\",\n \"name\": \"ordre aucun cvd aucun\",\n \"total_mib\": 1\n }\n ]\n}", + "loki-build.log": "▶ récupération de la dernière release llama.cpp…\nrelease b305 — variant retenu : CPU (Linux x64)\n▶ téléchargement de llama-b300-bin-ubuntu-x64.tar.gz (2 Mo)…\n▶ extraction de llama-b300-bin-ubuntu-x64.tar.gz…\n✗ archives extraites mais llama-server introuvable sous {{HOME}}/backends/llama.cpp-prebuilt\n", + "state/build_job": "{\"action\":\"prebuilt\",\"phase\":\"échec\",\"compiled\":0,\"running\":false,\"error\":\"archives extraites mais llama-server introuvable sous {{HOME}}/backends/llama.cpp-prebuilt\",\"started_at\":\"{{T}}\",\"ended_at\":\"{{T}}\",\"old\":\"\",\"new\":\"\"}" + }, + "tree": [ + "d backends", + "d backends/.cache", + "d backends/fork-a", + "d backends/fork-a/build", + "d backends/fork-a/build/bin", + "d backends/fork-c", + "d backends/fork-c/build", + "d backends/fork-c/build/bin", + "d backends/fork-c/build/bin/llama-server", + "d backends/llama.cpp", + "d backends/llama.cpp-prebuilt", + "d backends/llama.cpp-prebuilt/llama-b305", + "d backends/llama.cpp/build", + "d backends/llama.cpp/build/bin", + "d backends/llama.cpp/build/bin/Release", + "d engine", + "d engine/server-cuda-b10450", + "f backends/.cache/llama-server -rw-r--r-- x", + "f backends/fichier -rw-r--r-- x", + "f backends/fork-a/CMakeLists.txt -rw-r--r-- ", + "f backends/fork-a/build/bin/llama-server -rwxr-xr-x #!/bin/sh\n", + "f backends/fork-a/build/llama-server -rwxr-xr-x #!/bin/sh\n", + "f backends/fork-c/llama-server.exe -rw-r--r-- x", + "f backends/llama.cpp-prebuilt/VERSION -rw-r--r-- b305 fmt2\n", + "f backends/llama.cpp-prebuilt/llama-b305/llama-cli -rwxr-xr-x x", + "f backends/llama.cpp/README -rw-r--r-- & état", + "f backends/llama.cpp/build/llama-server -rwxr-xr-x #!/bin/sh\n", + "f engine/server-cuda-b10450/llama-server -rwxr-xr-x #!/bin/sh\n", + "l backends/lien -> fork-b" + ] + }, + { + "name": "GPU filtrés", + "files": { + "gpu/LLAMA-SERVER": "#!/bin/sh\necho \" CUDA0: ordre $CUDA_DEVICE_ORDER cvd $CUDA_VISIBLE_DEVICES (10 MiB, 5 MiB free)\"\n" + }, + "config": { + "BIN": "{{HOME}}/gpu/LLAMA-SERVER", + "CUDA_VISIBLE_DEVICES": "1,0" + }, + "method": "POST", + "path": "/api/backends/devices", + "body": "{\"bin\":\"\"}", + "status": 200, + "out": { + "devices": [ + { + "free_mib": 5, + "id": "CUDA0", + "name": "ordre PCI_BUS_ID cvd 1,0", + "total_mib": 10 + } + ], + "ok": true + }, + "after": { + "config/BIN": "{{HOME}}/gpu/LLAMA-SERVER", + "devices.json": "{\n \"{{HOME}}/gpu/LLAMA-SERVER\\u0000\": [\n {\n \"free_mib\": 0,\n \"id\": \"CUDA0\",\n \"link\": {\n \"gen\": 1,\n \"gen_max\": 4,\n \"mem_clock_mhz\": 9751,\n \"width\": 16,\n \"width_max\": 16\n },\n \"name\": \"NVIDIA GeForce RTX 3090\",\n \"total_mib\": 24576\n },\n {\n \"free_mib\": 8000,\n \"id\": \"CUDA1\",\n \"link\": {\n \"gen\": 0,\n \"gen_max\": 3,\n \"mem_clock_mhz\": 7001,\n \"width\": 8,\n \"width_max\": 16\n },\n \"name\": \"Carte B\",\n \"total_mib\": 8192\n },\n {\n \"free_mib\": 20000,\n \"id\": \"Vulkan0\",\n \"name\": \"NVIDIA GeForce RTX 3090\",\n \"total_mib\": 24576\n },\n {\n \"free_mib\": 1,\n \"id\": \"CUDA2\",\n \"name\": \"ordre aucun cvd aucun\",\n \"total_mib\": 1\n }\n ],\n \"{{HOME}}/gpu/LLAMA-SERVER\\u00001,0\": [\n {\n \"free_mib\": 5,\n \"id\": \"CUDA0\",\n \"name\": \"ordre PCI_BUS_ID cvd 1,0\",\n \"total_mib\": 10\n }\n ]\n}", + "loki-build.log": "▶ récupération de la dernière release llama.cpp…\nrelease b305 — variant retenu : CPU (Linux x64)\n▶ téléchargement de llama-b300-bin-ubuntu-x64.tar.gz (2 Mo)…\n▶ extraction de llama-b300-bin-ubuntu-x64.tar.gz…\n✗ archives extraites mais llama-server introuvable sous {{HOME}}/backends/llama.cpp-prebuilt\n", + "state/build_job": "{\"action\":\"prebuilt\",\"phase\":\"échec\",\"compiled\":0,\"running\":false,\"error\":\"archives extraites mais llama-server introuvable sous {{HOME}}/backends/llama.cpp-prebuilt\",\"started_at\":\"{{T}}\",\"ended_at\":\"{{T}}\",\"old\":\"\",\"new\":\"\"}" + }, + "tree": [ + "d backends", + "d backends/.cache", + "d backends/fork-a", + "d backends/fork-a/build", + "d backends/fork-a/build/bin", + "d backends/fork-c", + "d backends/fork-c/build", + "d backends/fork-c/build/bin", + "d backends/fork-c/build/bin/llama-server", + "d backends/llama.cpp", + "d backends/llama.cpp-prebuilt", + "d backends/llama.cpp-prebuilt/llama-b305", + "d backends/llama.cpp/build", + "d backends/llama.cpp/build/bin", + "d backends/llama.cpp/build/bin/Release", + "d engine", + "d engine/server-cuda-b10450", + "f backends/.cache/llama-server -rw-r--r-- x", + "f backends/fichier -rw-r--r-- x", + "f backends/fork-a/CMakeLists.txt -rw-r--r-- ", + "f backends/fork-a/build/bin/llama-server -rwxr-xr-x #!/bin/sh\n", + "f backends/fork-a/build/llama-server -rwxr-xr-x #!/bin/sh\n", + "f backends/fork-c/llama-server.exe -rw-r--r-- x", + "f backends/llama.cpp-prebuilt/VERSION -rw-r--r-- b305 fmt2\n", + "f backends/llama.cpp-prebuilt/llama-b305/llama-cli -rwxr-xr-x x", + "f backends/llama.cpp/README -rw-r--r-- & état", + "f backends/llama.cpp/build/llama-server -rwxr-xr-x #!/bin/sh\n", + "f engine/server-cuda-b10450/llama-server -rwxr-xr-x #!/bin/sh\n", + "l backends/lien -> fork-b" + ] + }, + { + "name": "GPU : mémoire nulle non figée", + "machine": "nu", + "files": { + "gpu/LLAMA-SERVER": "#!/bin/sh\necho ' CUDA0: Saturée (0 MiB, 0 MiB free)'\n" + }, + "config": { + "CUDA_VISIBLE_DEVICES": "0" + }, + "method": "POST", + "path": "/api/backends/devices", + "body": "{}", + "status": 200, + "out": { + "devices": [ + { + "free_mib": 0, + "id": "CUDA0", + "name": "Saturée", + "total_mib": 0 + } + ], + "ok": true + }, + "after": { + "config/BIN": "{{HOME}}/gpu/LLAMA-SERVER", + "devices.json": "{\n \"{{HOME}}/gpu/LLAMA-SERVER\\u0000\": [\n {\n \"free_mib\": 0,\n \"id\": \"CUDA0\",\n \"link\": {\n \"gen\": 1,\n \"gen_max\": 4,\n \"mem_clock_mhz\": 9751,\n \"width\": 16,\n \"width_max\": 16\n },\n \"name\": \"NVIDIA GeForce RTX 3090\",\n \"total_mib\": 24576\n },\n {\n \"free_mib\": 8000,\n \"id\": \"CUDA1\",\n \"link\": {\n \"gen\": 0,\n \"gen_max\": 3,\n \"mem_clock_mhz\": 7001,\n \"width\": 8,\n \"width_max\": 16\n },\n \"name\": \"Carte B\",\n \"total_mib\": 8192\n },\n {\n \"free_mib\": 20000,\n \"id\": \"Vulkan0\",\n \"name\": \"NVIDIA GeForce RTX 3090\",\n \"total_mib\": 24576\n },\n {\n \"free_mib\": 1,\n \"id\": \"CUDA2\",\n \"name\": \"ordre aucun cvd aucun\",\n \"total_mib\": 1\n }\n ],\n \"{{HOME}}/gpu/LLAMA-SERVER\\u00000\": [\n {\n \"free_mib\": 0,\n \"id\": \"CUDA0\",\n \"name\": \"Saturée\",\n \"total_mib\": 0\n }\n ],\n \"{{HOME}}/gpu/LLAMA-SERVER\\u00001,0\": [\n {\n \"free_mib\": 5,\n \"id\": \"CUDA0\",\n \"name\": \"ordre PCI_BUS_ID cvd 1,0\",\n \"total_mib\": 10\n }\n ]\n}", + "loki-build.log": "▶ récupération de la dernière release llama.cpp…\nrelease b305 — variant retenu : CPU (Linux x64)\n▶ téléchargement de llama-b300-bin-ubuntu-x64.tar.gz (2 Mo)…\n▶ extraction de llama-b300-bin-ubuntu-x64.tar.gz…\n✗ archives extraites mais llama-server introuvable sous {{HOME}}/backends/llama.cpp-prebuilt\n", + "state/build_job": "{\"action\":\"prebuilt\",\"phase\":\"échec\",\"compiled\":0,\"running\":false,\"error\":\"archives extraites mais llama-server introuvable sous {{HOME}}/backends/llama.cpp-prebuilt\",\"started_at\":\"{{T}}\",\"ended_at\":\"{{T}}\",\"old\":\"\",\"new\":\"\"}" + }, + "tree": [ + "d backends", + "d backends/.cache", + "d backends/fork-a", + "d backends/fork-a/build", + "d backends/fork-a/build/bin", + "d backends/fork-c", + "d backends/fork-c/build", + "d backends/fork-c/build/bin", + "d backends/fork-c/build/bin/llama-server", + "d backends/llama.cpp", + "d backends/llama.cpp-prebuilt", + "d backends/llama.cpp-prebuilt/llama-b305", + "d backends/llama.cpp/build", + "d backends/llama.cpp/build/bin", + "d backends/llama.cpp/build/bin/Release", + "d engine", + "d engine/server-cuda-b10450", + "f backends/.cache/llama-server -rw-r--r-- x", + "f backends/fichier -rw-r--r-- x", + "f backends/fork-a/CMakeLists.txt -rw-r--r-- ", + "f backends/fork-a/build/bin/llama-server -rwxr-xr-x #!/bin/sh\n", + "f backends/fork-a/build/llama-server -rwxr-xr-x #!/bin/sh\n", + "f backends/fork-c/llama-server.exe -rw-r--r-- x", + "f backends/llama.cpp-prebuilt/VERSION -rw-r--r-- b305 fmt2\n", + "f backends/llama.cpp-prebuilt/llama-b305/llama-cli -rwxr-xr-x x", + "f backends/llama.cpp/README -rw-r--r-- & état", + "f backends/llama.cpp/build/llama-server -rwxr-xr-x #!/bin/sh\n", + "f engine/server-cuda-b10450/llama-server -rwxr-xr-x #!/bin/sh\n", + "l backends/lien -> fork-b" + ] + }, + { + "name": "GPU : relu, pas en cache", + "files": { + "gpu/LLAMA-SERVER": "#!/bin/sh\necho ' CUDA0: Saturée (100 MiB, 0 MiB free)'\n" + }, + "method": "POST", + "path": "/api/backends/devices", + "body": "{}", + "status": 200, + "out": { + "devices": [ + { + "free_mib": 0, + "id": "CUDA0", + "name": "Saturée", + "total_mib": 100 + } + ], + "ok": true + }, + "after": { + "config/BIN": "{{HOME}}/gpu/LLAMA-SERVER", + "devices.json": "{\n \"{{HOME}}/gpu/LLAMA-SERVER\\u0000\": [\n {\n \"free_mib\": 0,\n \"id\": \"CUDA0\",\n \"link\": {\n \"gen\": 1,\n \"gen_max\": 4,\n \"mem_clock_mhz\": 9751,\n \"width\": 16,\n \"width_max\": 16\n },\n \"name\": \"NVIDIA GeForce RTX 3090\",\n \"total_mib\": 24576\n },\n {\n \"free_mib\": 8000,\n \"id\": \"CUDA1\",\n \"link\": {\n \"gen\": 0,\n \"gen_max\": 3,\n \"mem_clock_mhz\": 7001,\n \"width\": 8,\n \"width_max\": 16\n },\n \"name\": \"Carte B\",\n \"total_mib\": 8192\n },\n {\n \"free_mib\": 20000,\n \"id\": \"Vulkan0\",\n \"name\": \"NVIDIA GeForce RTX 3090\",\n \"total_mib\": 24576\n },\n {\n \"free_mib\": 1,\n \"id\": \"CUDA2\",\n \"name\": \"ordre aucun cvd aucun\",\n \"total_mib\": 1\n }\n ],\n \"{{HOME}}/gpu/LLAMA-SERVER\\u00000\": [\n {\n \"free_mib\": 0,\n \"id\": \"CUDA0\",\n \"name\": \"Saturée\",\n \"total_mib\": 100\n }\n ],\n \"{{HOME}}/gpu/LLAMA-SERVER\\u00001,0\": [\n {\n \"free_mib\": 5,\n \"id\": \"CUDA0\",\n \"name\": \"ordre PCI_BUS_ID cvd 1,0\",\n \"total_mib\": 10\n }\n ]\n}", + "loki-build.log": "▶ récupération de la dernière release llama.cpp…\nrelease b305 — variant retenu : CPU (Linux x64)\n▶ téléchargement de llama-b300-bin-ubuntu-x64.tar.gz (2 Mo)…\n▶ extraction de llama-b300-bin-ubuntu-x64.tar.gz…\n✗ archives extraites mais llama-server introuvable sous {{HOME}}/backends/llama.cpp-prebuilt\n", + "state/build_job": "{\"action\":\"prebuilt\",\"phase\":\"échec\",\"compiled\":0,\"running\":false,\"error\":\"archives extraites mais llama-server introuvable sous {{HOME}}/backends/llama.cpp-prebuilt\",\"started_at\":\"{{T}}\",\"ended_at\":\"{{T}}\",\"old\":\"\",\"new\":\"\"}" + }, + "tree": [ + "d backends", + "d backends/.cache", + "d backends/fork-a", + "d backends/fork-a/build", + "d backends/fork-a/build/bin", + "d backends/fork-c", + "d backends/fork-c/build", + "d backends/fork-c/build/bin", + "d backends/fork-c/build/bin/llama-server", + "d backends/llama.cpp", + "d backends/llama.cpp-prebuilt", + "d backends/llama.cpp-prebuilt/llama-b305", + "d backends/llama.cpp/build", + "d backends/llama.cpp/build/bin", + "d backends/llama.cpp/build/bin/Release", + "d engine", + "d engine/server-cuda-b10450", + "f backends/.cache/llama-server -rw-r--r-- x", + "f backends/fichier -rw-r--r-- x", + "f backends/fork-a/CMakeLists.txt -rw-r--r-- ", + "f backends/fork-a/build/bin/llama-server -rwxr-xr-x #!/bin/sh\n", + "f backends/fork-a/build/llama-server -rwxr-xr-x #!/bin/sh\n", + "f backends/fork-c/llama-server.exe -rw-r--r-- x", + "f backends/llama.cpp-prebuilt/VERSION -rw-r--r-- b305 fmt2\n", + "f backends/llama.cpp-prebuilt/llama-b305/llama-cli -rwxr-xr-x x", + "f backends/llama.cpp/README -rw-r--r-- & état", + "f backends/llama.cpp/build/llama-server -rwxr-xr-x #!/bin/sh\n", + "f engine/server-cuda-b10450/llama-server -rwxr-xr-x #!/bin/sh\n", + "l backends/lien -> fork-b" + ] + }, + { + "name": "GPU : moteur planté, repli disque", + "files": { + "gpu/llama-server": "#!/bin/sh\necho ' CUDA0: partielle (1 MiB, 1 MiB free)'\nexit 3\n" + }, + "config": { + "CUDA_VISIBLE_DEVICES": "1,0" + }, + "method": "POST", + "path": "/api/backends/devices", + "body": "{\"bin\":\"{{HOME}}/gpu/llama-server\"}", + "status": 200, + "out": { + "devices": [ + { + "free_mib": 1, + "id": "CUDA0", + "name": "partielle", + "total_mib": 1 + } + ], + "ok": true, + "stale": true + }, + "after": { + "config/BIN": "{{HOME}}/gpu/LLAMA-SERVER", + "devices.json": "{\n \"{{HOME}}/gpu/LLAMA-SERVER\\u0000\": [\n {\n \"free_mib\": 0,\n \"id\": \"CUDA0\",\n \"link\": {\n \"gen\": 1,\n \"gen_max\": 4,\n \"mem_clock_mhz\": 9751,\n \"width\": 16,\n \"width_max\": 16\n },\n \"name\": \"NVIDIA GeForce RTX 3090\",\n \"total_mib\": 24576\n },\n {\n \"free_mib\": 8000,\n \"id\": \"CUDA1\",\n \"link\": {\n \"gen\": 0,\n \"gen_max\": 3,\n \"mem_clock_mhz\": 7001,\n \"width\": 8,\n \"width_max\": 16\n },\n \"name\": \"Carte B\",\n \"total_mib\": 8192\n },\n {\n \"free_mib\": 20000,\n \"id\": \"Vulkan0\",\n \"name\": \"NVIDIA GeForce RTX 3090\",\n \"total_mib\": 24576\n },\n {\n \"free_mib\": 1,\n \"id\": \"CUDA2\",\n \"name\": \"ordre aucun cvd aucun\",\n \"total_mib\": 1\n }\n ],\n \"{{HOME}}/gpu/LLAMA-SERVER\\u00000\": [\n {\n \"free_mib\": 0,\n \"id\": \"CUDA0\",\n \"name\": \"Saturée\",\n \"total_mib\": 100\n }\n ],\n \"{{HOME}}/gpu/LLAMA-SERVER\\u00001,0\": [\n {\n \"free_mib\": 5,\n \"id\": \"CUDA0\",\n \"name\": \"ordre PCI_BUS_ID cvd 1,0\",\n \"total_mib\": 10\n }\n ]\n}", + "loki-build.log": "▶ récupération de la dernière release llama.cpp…\nrelease b305 — variant retenu : CPU (Linux x64)\n▶ téléchargement de llama-b300-bin-ubuntu-x64.tar.gz (2 Mo)…\n▶ extraction de llama-b300-bin-ubuntu-x64.tar.gz…\n✗ archives extraites mais llama-server introuvable sous {{HOME}}/backends/llama.cpp-prebuilt\n", + "state/build_job": "{\"action\":\"prebuilt\",\"phase\":\"échec\",\"compiled\":0,\"running\":false,\"error\":\"archives extraites mais llama-server introuvable sous {{HOME}}/backends/llama.cpp-prebuilt\",\"started_at\":\"{{T}}\",\"ended_at\":\"{{T}}\",\"old\":\"\",\"new\":\"\"}" + }, + "tree": [ + "d backends", + "d backends/.cache", + "d backends/fork-a", + "d backends/fork-a/build", + "d backends/fork-a/build/bin", + "d backends/fork-c", + "d backends/fork-c/build", + "d backends/fork-c/build/bin", + "d backends/fork-c/build/bin/llama-server", + "d backends/llama.cpp", + "d backends/llama.cpp-prebuilt", + "d backends/llama.cpp-prebuilt/llama-b305", + "d backends/llama.cpp/build", + "d backends/llama.cpp/build/bin", + "d backends/llama.cpp/build/bin/Release", + "d engine", + "d engine/server-cuda-b10450", + "f backends/.cache/llama-server -rw-r--r-- x", + "f backends/fichier -rw-r--r-- x", + "f backends/fork-a/CMakeLists.txt -rw-r--r-- ", + "f backends/fork-a/build/bin/llama-server -rwxr-xr-x #!/bin/sh\n", + "f backends/fork-a/build/llama-server -rwxr-xr-x #!/bin/sh\n", + "f backends/fork-c/llama-server.exe -rw-r--r-- x", + "f backends/llama.cpp-prebuilt/VERSION -rw-r--r-- b305 fmt2\n", + "f backends/llama.cpp-prebuilt/llama-b305/llama-cli -rwxr-xr-x x", + "f backends/llama.cpp/README -rw-r--r-- & état", + "f backends/llama.cpp/build/llama-server -rwxr-xr-x #!/bin/sh\n", + "f engine/server-cuda-b10450/llama-server -rwxr-xr-x #!/bin/sh\n", + "l backends/lien -> fork-b" + ] + }, + { + "name": "GPU : planté, partiel", + "config": { + "CUDA_VISIBLE_DEVICES": "2" + }, + "method": "POST", + "path": "/api/backends/devices", + "body": "{\"bin\":\"{{HOME}}/gpu/llama-server\"}", + "status": 200, + "out": { + "devices": [ + { + "free_mib": 1, + "id": "CUDA0", + "name": "partielle", + "total_mib": 1 + } + ], + "ok": true, + "stale": true + }, + "after": { + "config/BIN": "{{HOME}}/gpu/LLAMA-SERVER", + "devices.json": "{\n \"{{HOME}}/gpu/LLAMA-SERVER\\u0000\": [\n {\n \"free_mib\": 0,\n \"id\": \"CUDA0\",\n \"link\": {\n \"gen\": 1,\n \"gen_max\": 4,\n \"mem_clock_mhz\": 9751,\n \"width\": 16,\n \"width_max\": 16\n },\n \"name\": \"NVIDIA GeForce RTX 3090\",\n \"total_mib\": 24576\n },\n {\n \"free_mib\": 8000,\n \"id\": \"CUDA1\",\n \"link\": {\n \"gen\": 0,\n \"gen_max\": 3,\n \"mem_clock_mhz\": 7001,\n \"width\": 8,\n \"width_max\": 16\n },\n \"name\": \"Carte B\",\n \"total_mib\": 8192\n },\n {\n \"free_mib\": 20000,\n \"id\": \"Vulkan0\",\n \"name\": \"NVIDIA GeForce RTX 3090\",\n \"total_mib\": 24576\n },\n {\n \"free_mib\": 1,\n \"id\": \"CUDA2\",\n \"name\": \"ordre aucun cvd aucun\",\n \"total_mib\": 1\n }\n ],\n \"{{HOME}}/gpu/LLAMA-SERVER\\u00000\": [\n {\n \"free_mib\": 0,\n \"id\": \"CUDA0\",\n \"name\": \"Saturée\",\n \"total_mib\": 100\n }\n ],\n \"{{HOME}}/gpu/LLAMA-SERVER\\u00001,0\": [\n {\n \"free_mib\": 5,\n \"id\": \"CUDA0\",\n \"name\": \"ordre PCI_BUS_ID cvd 1,0\",\n \"total_mib\": 10\n }\n ]\n}", + "loki-build.log": "▶ récupération de la dernière release llama.cpp…\nrelease b305 — variant retenu : CPU (Linux x64)\n▶ téléchargement de llama-b300-bin-ubuntu-x64.tar.gz (2 Mo)…\n▶ extraction de llama-b300-bin-ubuntu-x64.tar.gz…\n✗ archives extraites mais llama-server introuvable sous {{HOME}}/backends/llama.cpp-prebuilt\n", + "state/build_job": "{\"action\":\"prebuilt\",\"phase\":\"échec\",\"compiled\":0,\"running\":false,\"error\":\"archives extraites mais llama-server introuvable sous {{HOME}}/backends/llama.cpp-prebuilt\",\"started_at\":\"{{T}}\",\"ended_at\":\"{{T}}\",\"old\":\"\",\"new\":\"\"}" + }, + "tree": [ + "d backends", + "d backends/.cache", + "d backends/fork-a", + "d backends/fork-a/build", + "d backends/fork-a/build/bin", + "d backends/fork-c", + "d backends/fork-c/build", + "d backends/fork-c/build/bin", + "d backends/fork-c/build/bin/llama-server", + "d backends/llama.cpp", + "d backends/llama.cpp-prebuilt", + "d backends/llama.cpp-prebuilt/llama-b305", + "d backends/llama.cpp/build", + "d backends/llama.cpp/build/bin", + "d backends/llama.cpp/build/bin/Release", + "d engine", + "d engine/server-cuda-b10450", + "f backends/.cache/llama-server -rw-r--r-- x", + "f backends/fichier -rw-r--r-- x", + "f backends/fork-a/CMakeLists.txt -rw-r--r-- ", + "f backends/fork-a/build/bin/llama-server -rwxr-xr-x #!/bin/sh\n", + "f backends/fork-a/build/llama-server -rwxr-xr-x #!/bin/sh\n", + "f backends/fork-c/llama-server.exe -rw-r--r-- x", + "f backends/llama.cpp-prebuilt/VERSION -rw-r--r-- b305 fmt2\n", + "f backends/llama.cpp-prebuilt/llama-b305/llama-cli -rwxr-xr-x x", + "f backends/llama.cpp/README -rw-r--r-- & état", + "f backends/llama.cpp/build/llama-server -rwxr-xr-x #!/bin/sh\n", + "f engine/server-cuda-b10450/llama-server -rwxr-xr-x #!/bin/sh\n", + "l backends/lien -> fork-b" + ] + }, + { + "name": "GPU : planté, rien", + "files": { + "gpu/llama-server": "#!/bin/sh\necho 'CUDA error: out of memory'\nexit 4\n" + }, + "config": { + "CUDA_VISIBLE_DEVICES": "3" + }, + "method": "POST", + "path": "/api/backends/devices", + "body": "{\"bin\":\"{{HOME}}/gpu/llama-server\"}", + "status": 200, + "out": { + "error": "le moteur n'a pas répondu : exit status 4", + "ok": false + }, + "after": { + "config/BIN": "{{HOME}}/gpu/LLAMA-SERVER", + "devices.json": "{\n \"{{HOME}}/gpu/LLAMA-SERVER\\u0000\": [\n {\n \"free_mib\": 0,\n \"id\": \"CUDA0\",\n \"link\": {\n \"gen\": 1,\n \"gen_max\": 4,\n \"mem_clock_mhz\": 9751,\n \"width\": 16,\n \"width_max\": 16\n },\n \"name\": \"NVIDIA GeForce RTX 3090\",\n \"total_mib\": 24576\n },\n {\n \"free_mib\": 8000,\n \"id\": \"CUDA1\",\n \"link\": {\n \"gen\": 0,\n \"gen_max\": 3,\n \"mem_clock_mhz\": 7001,\n \"width\": 8,\n \"width_max\": 16\n },\n \"name\": \"Carte B\",\n \"total_mib\": 8192\n },\n {\n \"free_mib\": 20000,\n \"id\": \"Vulkan0\",\n \"name\": \"NVIDIA GeForce RTX 3090\",\n \"total_mib\": 24576\n },\n {\n \"free_mib\": 1,\n \"id\": \"CUDA2\",\n \"name\": \"ordre aucun cvd aucun\",\n \"total_mib\": 1\n }\n ],\n \"{{HOME}}/gpu/LLAMA-SERVER\\u00000\": [\n {\n \"free_mib\": 0,\n \"id\": \"CUDA0\",\n \"name\": \"Saturée\",\n \"total_mib\": 100\n }\n ],\n \"{{HOME}}/gpu/LLAMA-SERVER\\u00001,0\": [\n {\n \"free_mib\": 5,\n \"id\": \"CUDA0\",\n \"name\": \"ordre PCI_BUS_ID cvd 1,0\",\n \"total_mib\": 10\n }\n ]\n}", + "loki-build.log": "▶ récupération de la dernière release llama.cpp…\nrelease b305 — variant retenu : CPU (Linux x64)\n▶ téléchargement de llama-b300-bin-ubuntu-x64.tar.gz (2 Mo)…\n▶ extraction de llama-b300-bin-ubuntu-x64.tar.gz…\n✗ archives extraites mais llama-server introuvable sous {{HOME}}/backends/llama.cpp-prebuilt\n", + "state/build_job": "{\"action\":\"prebuilt\",\"phase\":\"échec\",\"compiled\":0,\"running\":false,\"error\":\"archives extraites mais llama-server introuvable sous {{HOME}}/backends/llama.cpp-prebuilt\",\"started_at\":\"{{T}}\",\"ended_at\":\"{{T}}\",\"old\":\"\",\"new\":\"\"}" + }, + "tree": [ + "d backends", + "d backends/.cache", + "d backends/fork-a", + "d backends/fork-a/build", + "d backends/fork-a/build/bin", + "d backends/fork-c", + "d backends/fork-c/build", + "d backends/fork-c/build/bin", + "d backends/fork-c/build/bin/llama-server", + "d backends/llama.cpp", + "d backends/llama.cpp-prebuilt", + "d backends/llama.cpp-prebuilt/llama-b305", + "d backends/llama.cpp/build", + "d backends/llama.cpp/build/bin", + "d backends/llama.cpp/build/bin/Release", + "d engine", + "d engine/server-cuda-b10450", + "f backends/.cache/llama-server -rw-r--r-- x", + "f backends/fichier -rw-r--r-- x", + "f backends/fork-a/CMakeLists.txt -rw-r--r-- ", + "f backends/fork-a/build/bin/llama-server -rwxr-xr-x #!/bin/sh\n", + "f backends/fork-a/build/llama-server -rwxr-xr-x #!/bin/sh\n", + "f backends/fork-c/llama-server.exe -rw-r--r-- x", + "f backends/llama.cpp-prebuilt/VERSION -rw-r--r-- b305 fmt2\n", + "f backends/llama.cpp-prebuilt/llama-b305/llama-cli -rwxr-xr-x x", + "f backends/llama.cpp/README -rw-r--r-- & état", + "f backends/llama.cpp/build/llama-server -rwxr-xr-x #!/bin/sh\n", + "f engine/server-cuda-b10450/llama-server -rwxr-xr-x #!/bin/sh\n", + "l backends/lien -> fork-b" + ] + }, + { + "name": "GPU : tué", + "files": { + "gpu/llama-server": "#!/bin/sh\nkill -9 $$\n" + }, + "config": { + "CUDA_VISIBLE_DEVICES": "4" + }, + "method": "POST", + "path": "/api/backends/devices", + "body": "{\"bin\":\"{{HOME}}/gpu/llama-server\"}", + "status": 200, + "out": { + "error": "le moteur n'a pas répondu : signal: killed", + "ok": false + }, + "after": { + "config/BIN": "{{HOME}}/gpu/LLAMA-SERVER", + "devices.json": "{\n \"{{HOME}}/gpu/LLAMA-SERVER\\u0000\": [\n {\n \"free_mib\": 0,\n \"id\": \"CUDA0\",\n \"link\": {\n \"gen\": 1,\n \"gen_max\": 4,\n \"mem_clock_mhz\": 9751,\n \"width\": 16,\n \"width_max\": 16\n },\n \"name\": \"NVIDIA GeForce RTX 3090\",\n \"total_mib\": 24576\n },\n {\n \"free_mib\": 8000,\n \"id\": \"CUDA1\",\n \"link\": {\n \"gen\": 0,\n \"gen_max\": 3,\n \"mem_clock_mhz\": 7001,\n \"width\": 8,\n \"width_max\": 16\n },\n \"name\": \"Carte B\",\n \"total_mib\": 8192\n },\n {\n \"free_mib\": 20000,\n \"id\": \"Vulkan0\",\n \"name\": \"NVIDIA GeForce RTX 3090\",\n \"total_mib\": 24576\n },\n {\n \"free_mib\": 1,\n \"id\": \"CUDA2\",\n \"name\": \"ordre aucun cvd aucun\",\n \"total_mib\": 1\n }\n ],\n \"{{HOME}}/gpu/LLAMA-SERVER\\u00000\": [\n {\n \"free_mib\": 0,\n \"id\": \"CUDA0\",\n \"name\": \"Saturée\",\n \"total_mib\": 100\n }\n ],\n \"{{HOME}}/gpu/LLAMA-SERVER\\u00001,0\": [\n {\n \"free_mib\": 5,\n \"id\": \"CUDA0\",\n \"name\": \"ordre PCI_BUS_ID cvd 1,0\",\n \"total_mib\": 10\n }\n ]\n}", + "loki-build.log": "▶ récupération de la dernière release llama.cpp…\nrelease b305 — variant retenu : CPU (Linux x64)\n▶ téléchargement de llama-b300-bin-ubuntu-x64.tar.gz (2 Mo)…\n▶ extraction de llama-b300-bin-ubuntu-x64.tar.gz…\n✗ archives extraites mais llama-server introuvable sous {{HOME}}/backends/llama.cpp-prebuilt\n", + "state/build_job": "{\"action\":\"prebuilt\",\"phase\":\"échec\",\"compiled\":0,\"running\":false,\"error\":\"archives extraites mais llama-server introuvable sous {{HOME}}/backends/llama.cpp-prebuilt\",\"started_at\":\"{{T}}\",\"ended_at\":\"{{T}}\",\"old\":\"\",\"new\":\"\"}" + }, + "tree": [ + "d backends", + "d backends/.cache", + "d backends/fork-a", + "d backends/fork-a/build", + "d backends/fork-a/build/bin", + "d backends/fork-c", + "d backends/fork-c/build", + "d backends/fork-c/build/bin", + "d backends/fork-c/build/bin/llama-server", + "d backends/llama.cpp", + "d backends/llama.cpp-prebuilt", + "d backends/llama.cpp-prebuilt/llama-b305", + "d backends/llama.cpp/build", + "d backends/llama.cpp/build/bin", + "d backends/llama.cpp/build/bin/Release", + "d engine", + "d engine/server-cuda-b10450", + "f backends/.cache/llama-server -rw-r--r-- x", + "f backends/fichier -rw-r--r-- x", + "f backends/fork-a/CMakeLists.txt -rw-r--r-- ", + "f backends/fork-a/build/bin/llama-server -rwxr-xr-x #!/bin/sh\n", + "f backends/fork-a/build/llama-server -rwxr-xr-x #!/bin/sh\n", + "f backends/fork-c/llama-server.exe -rw-r--r-- x", + "f backends/llama.cpp-prebuilt/VERSION -rw-r--r-- b305 fmt2\n", + "f backends/llama.cpp-prebuilt/llama-b305/llama-cli -rwxr-xr-x x", + "f backends/llama.cpp/README -rw-r--r-- & état", + "f backends/llama.cpp/build/llama-server -rwxr-xr-x #!/bin/sh\n", + "f engine/server-cuda-b10450/llama-server -rwxr-xr-x #!/bin/sh\n", + "l backends/lien -> fork-b" + ] + }, + { + "name": "GPU : non exécutable", + "files": { + "gpu/llama-server": "pas un script" + }, + "config": { + "CUDA_VISIBLE_DEVICES": "5" + }, + "method": "POST", + "path": "/api/backends/devices", + "body": "{\"bin\":\"{{HOME}}/gpu/llama-server\"}", + "status": 200, + "out": { + "error": "le moteur n'a pas répondu : fork/exec {{HOME}}/gpu/llama-server: permission denied", + "ok": false + }, + "after": { + "config/BIN": "{{HOME}}/gpu/LLAMA-SERVER", + "devices.json": "{\n \"{{HOME}}/gpu/LLAMA-SERVER\\u0000\": [\n {\n \"free_mib\": 0,\n \"id\": \"CUDA0\",\n \"link\": {\n \"gen\": 1,\n \"gen_max\": 4,\n \"mem_clock_mhz\": 9751,\n \"width\": 16,\n \"width_max\": 16\n },\n \"name\": \"NVIDIA GeForce RTX 3090\",\n \"total_mib\": 24576\n },\n {\n \"free_mib\": 8000,\n \"id\": \"CUDA1\",\n \"link\": {\n \"gen\": 0,\n \"gen_max\": 3,\n \"mem_clock_mhz\": 7001,\n \"width\": 8,\n \"width_max\": 16\n },\n \"name\": \"Carte B\",\n \"total_mib\": 8192\n },\n {\n \"free_mib\": 20000,\n \"id\": \"Vulkan0\",\n \"name\": \"NVIDIA GeForce RTX 3090\",\n \"total_mib\": 24576\n },\n {\n \"free_mib\": 1,\n \"id\": \"CUDA2\",\n \"name\": \"ordre aucun cvd aucun\",\n \"total_mib\": 1\n }\n ],\n \"{{HOME}}/gpu/LLAMA-SERVER\\u00000\": [\n {\n \"free_mib\": 0,\n \"id\": \"CUDA0\",\n \"name\": \"Saturée\",\n \"total_mib\": 100\n }\n ],\n \"{{HOME}}/gpu/LLAMA-SERVER\\u00001,0\": [\n {\n \"free_mib\": 5,\n \"id\": \"CUDA0\",\n \"name\": \"ordre PCI_BUS_ID cvd 1,0\",\n \"total_mib\": 10\n }\n ]\n}", + "loki-build.log": "▶ récupération de la dernière release llama.cpp…\nrelease b305 — variant retenu : CPU (Linux x64)\n▶ téléchargement de llama-b300-bin-ubuntu-x64.tar.gz (2 Mo)…\n▶ extraction de llama-b300-bin-ubuntu-x64.tar.gz…\n✗ archives extraites mais llama-server introuvable sous {{HOME}}/backends/llama.cpp-prebuilt\n", + "state/build_job": "{\"action\":\"prebuilt\",\"phase\":\"échec\",\"compiled\":0,\"running\":false,\"error\":\"archives extraites mais llama-server introuvable sous {{HOME}}/backends/llama.cpp-prebuilt\",\"started_at\":\"{{T}}\",\"ended_at\":\"{{T}}\",\"old\":\"\",\"new\":\"\"}" + }, + "tree": [ + "d backends", + "d backends/.cache", + "d backends/fork-a", + "d backends/fork-a/build", + "d backends/fork-a/build/bin", + "d backends/fork-c", + "d backends/fork-c/build", + "d backends/fork-c/build/bin", + "d backends/fork-c/build/bin/llama-server", + "d backends/llama.cpp", + "d backends/llama.cpp-prebuilt", + "d backends/llama.cpp-prebuilt/llama-b305", + "d backends/llama.cpp/build", + "d backends/llama.cpp/build/bin", + "d backends/llama.cpp/build/bin/Release", + "d engine", + "d engine/server-cuda-b10450", + "f backends/.cache/llama-server -rw-r--r-- x", + "f backends/fichier -rw-r--r-- x", + "f backends/fork-a/CMakeLists.txt -rw-r--r-- ", + "f backends/fork-a/build/bin/llama-server -rwxr-xr-x #!/bin/sh\n", + "f backends/fork-a/build/llama-server -rwxr-xr-x #!/bin/sh\n", + "f backends/fork-c/llama-server.exe -rw-r--r-- x", + "f backends/llama.cpp-prebuilt/VERSION -rw-r--r-- b305 fmt2\n", + "f backends/llama.cpp-prebuilt/llama-b305/llama-cli -rwxr-xr-x x", + "f backends/llama.cpp/README -rw-r--r-- & état", + "f backends/llama.cpp/build/llama-server -rwxr-xr-x #!/bin/sh\n", + "f engine/server-cuda-b10450/llama-server -rwxr-xr-x #!/bin/sh\n", + "l backends/lien -> fork-b" + ] + }, + { + "name": "GPU : corps illisible, BIN de la config", + "files": { + "gpu/LLAMA-SERVER": "#!/bin/sh\necho ' CUDA0: Lue (2 MiB, 1 MiB free)'\n" + }, + "config": { + "CUDA_VISIBLE_DEVICES": "6" + }, + "method": "POST", + "path": "/api/backends/devices", + "body": "{\"bin\":", + "status": 200, + "out": { + "devices": [ + { + "free_mib": 1, + "id": "CUDA0", + "name": "Lue", + "total_mib": 2 + } + ], + "ok": true + }, + "after": { + "config/BIN": "{{HOME}}/gpu/LLAMA-SERVER", + "devices.json": "{\n \"{{HOME}}/gpu/LLAMA-SERVER\\u0000\": [\n {\n \"free_mib\": 0,\n \"id\": \"CUDA0\",\n \"link\": {\n \"gen\": 1,\n \"gen_max\": 4,\n \"mem_clock_mhz\": 9751,\n \"width\": 16,\n \"width_max\": 16\n },\n \"name\": \"NVIDIA GeForce RTX 3090\",\n \"total_mib\": 24576\n },\n {\n \"free_mib\": 8000,\n \"id\": \"CUDA1\",\n \"link\": {\n \"gen\": 0,\n \"gen_max\": 3,\n \"mem_clock_mhz\": 7001,\n \"width\": 8,\n \"width_max\": 16\n },\n \"name\": \"Carte B\",\n \"total_mib\": 8192\n },\n {\n \"free_mib\": 20000,\n \"id\": \"Vulkan0\",\n \"name\": \"NVIDIA GeForce RTX 3090\",\n \"total_mib\": 24576\n },\n {\n \"free_mib\": 1,\n \"id\": \"CUDA2\",\n \"name\": \"ordre aucun cvd aucun\",\n \"total_mib\": 1\n }\n ],\n \"{{HOME}}/gpu/LLAMA-SERVER\\u00000\": [\n {\n \"free_mib\": 0,\n \"id\": \"CUDA0\",\n \"name\": \"Saturée\",\n \"total_mib\": 100\n }\n ],\n \"{{HOME}}/gpu/LLAMA-SERVER\\u00001,0\": [\n {\n \"free_mib\": 5,\n \"id\": \"CUDA0\",\n \"name\": \"ordre PCI_BUS_ID cvd 1,0\",\n \"total_mib\": 10\n }\n ],\n \"{{HOME}}/gpu/LLAMA-SERVER\\u00006\": [\n {\n \"free_mib\": 1,\n \"id\": \"CUDA0\",\n \"name\": \"Lue\",\n \"total_mib\": 2\n }\n ]\n}", + "loki-build.log": "▶ récupération de la dernière release llama.cpp…\nrelease b305 — variant retenu : CPU (Linux x64)\n▶ téléchargement de llama-b300-bin-ubuntu-x64.tar.gz (2 Mo)…\n▶ extraction de llama-b300-bin-ubuntu-x64.tar.gz…\n✗ archives extraites mais llama-server introuvable sous {{HOME}}/backends/llama.cpp-prebuilt\n", + "state/build_job": "{\"action\":\"prebuilt\",\"phase\":\"échec\",\"compiled\":0,\"running\":false,\"error\":\"archives extraites mais llama-server introuvable sous {{HOME}}/backends/llama.cpp-prebuilt\",\"started_at\":\"{{T}}\",\"ended_at\":\"{{T}}\",\"old\":\"\",\"new\":\"\"}" + }, + "tree": [ + "d backends", + "d backends/.cache", + "d backends/fork-a", + "d backends/fork-a/build", + "d backends/fork-a/build/bin", + "d backends/fork-c", + "d backends/fork-c/build", + "d backends/fork-c/build/bin", + "d backends/fork-c/build/bin/llama-server", + "d backends/llama.cpp", + "d backends/llama.cpp-prebuilt", + "d backends/llama.cpp-prebuilt/llama-b305", + "d backends/llama.cpp/build", + "d backends/llama.cpp/build/bin", + "d backends/llama.cpp/build/bin/Release", + "d engine", + "d engine/server-cuda-b10450", + "f backends/.cache/llama-server -rw-r--r-- x", + "f backends/fichier -rw-r--r-- x", + "f backends/fork-a/CMakeLists.txt -rw-r--r-- ", + "f backends/fork-a/build/bin/llama-server -rwxr-xr-x #!/bin/sh\n", + "f backends/fork-a/build/llama-server -rwxr-xr-x #!/bin/sh\n", + "f backends/fork-c/llama-server.exe -rw-r--r-- x", + "f backends/llama.cpp-prebuilt/VERSION -rw-r--r-- b305 fmt2\n", + "f backends/llama.cpp-prebuilt/llama-b305/llama-cli -rwxr-xr-x x", + "f backends/llama.cpp/README -rw-r--r-- & état", + "f backends/llama.cpp/build/llama-server -rwxr-xr-x #!/bin/sh\n", + "f engine/server-cuda-b10450/llama-server -rwxr-xr-x #!/bin/sh\n", + "l backends/lien -> fork-b" + ] + }, + { + "name": "GPU : pilote ancien", + "machine": "arch-ancienne", + "files": { + "gpu/LLAMA-SERVER": "#!/bin/sh\necho ' CUDA0: Vieille carte (0 MiB, 0 MiB free)'\necho ' CUDA1: Vieille carte (0 MiB, 0 MiB free)'\n" + }, + "config": { + "CUDA_VISIBLE_DEVICES": "7" + }, + "method": "POST", + "path": "/api/backends/devices", + "body": "{}", + "status": 200, + "out": { + "devices": [ + { + "free_mib": 0, + "id": "CUDA0", + "name": "Vieille carte", + "total_mib": 4096 + }, + { + "free_mib": 0, + "id": "CUDA1", + "name": "Vieille carte", + "total_mib": 4096 + } + ], + "ok": true + }, + "after": { + "config/BIN": "{{HOME}}/gpu/LLAMA-SERVER", + "devices.json": "{\n \"{{HOME}}/gpu/LLAMA-SERVER\\u0000\": [\n {\n \"free_mib\": 0,\n \"id\": \"CUDA0\",\n \"link\": {\n \"gen\": 1,\n \"gen_max\": 4,\n \"mem_clock_mhz\": 9751,\n \"width\": 16,\n \"width_max\": 16\n },\n \"name\": \"NVIDIA GeForce RTX 3090\",\n \"total_mib\": 24576\n },\n {\n \"free_mib\": 8000,\n \"id\": \"CUDA1\",\n \"link\": {\n \"gen\": 0,\n \"gen_max\": 3,\n \"mem_clock_mhz\": 7001,\n \"width\": 8,\n \"width_max\": 16\n },\n \"name\": \"Carte B\",\n \"total_mib\": 8192\n },\n {\n \"free_mib\": 20000,\n \"id\": \"Vulkan0\",\n \"name\": \"NVIDIA GeForce RTX 3090\",\n \"total_mib\": 24576\n },\n {\n \"free_mib\": 1,\n \"id\": \"CUDA2\",\n \"name\": \"ordre aucun cvd aucun\",\n \"total_mib\": 1\n }\n ],\n \"{{HOME}}/gpu/LLAMA-SERVER\\u00000\": [\n {\n \"free_mib\": 0,\n \"id\": \"CUDA0\",\n \"name\": \"Saturée\",\n \"total_mib\": 100\n }\n ],\n \"{{HOME}}/gpu/LLAMA-SERVER\\u00001,0\": [\n {\n \"free_mib\": 5,\n \"id\": \"CUDA0\",\n \"name\": \"ordre PCI_BUS_ID cvd 1,0\",\n \"total_mib\": 10\n }\n ],\n \"{{HOME}}/gpu/LLAMA-SERVER\\u00006\": [\n {\n \"free_mib\": 1,\n \"id\": \"CUDA0\",\n \"name\": \"Lue\",\n \"total_mib\": 2\n }\n ],\n \"{{HOME}}/gpu/LLAMA-SERVER\\u00007\": [\n {\n \"free_mib\": 0,\n \"id\": \"CUDA0\",\n \"name\": \"Vieille carte\",\n \"total_mib\": 4096\n },\n {\n \"free_mib\": 0,\n \"id\": \"CUDA1\",\n \"name\": \"Vieille carte\",\n \"total_mib\": 4096\n }\n ]\n}", + "loki-build.log": "▶ récupération de la dernière release llama.cpp…\nrelease b305 — variant retenu : CPU (Linux x64)\n▶ téléchargement de llama-b300-bin-ubuntu-x64.tar.gz (2 Mo)…\n▶ extraction de llama-b300-bin-ubuntu-x64.tar.gz…\n✗ archives extraites mais llama-server introuvable sous {{HOME}}/backends/llama.cpp-prebuilt\n", + "state/build_job": "{\"action\":\"prebuilt\",\"phase\":\"échec\",\"compiled\":0,\"running\":false,\"error\":\"archives extraites mais llama-server introuvable sous {{HOME}}/backends/llama.cpp-prebuilt\",\"started_at\":\"{{T}}\",\"ended_at\":\"{{T}}\",\"old\":\"\",\"new\":\"\"}" + }, + "tree": [ + "d backends", + "d backends/.cache", + "d backends/fork-a", + "d backends/fork-a/build", + "d backends/fork-a/build/bin", + "d backends/fork-c", + "d backends/fork-c/build", + "d backends/fork-c/build/bin", + "d backends/fork-c/build/bin/llama-server", + "d backends/llama.cpp", + "d backends/llama.cpp-prebuilt", + "d backends/llama.cpp-prebuilt/llama-b305", + "d backends/llama.cpp/build", + "d backends/llama.cpp/build/bin", + "d backends/llama.cpp/build/bin/Release", + "d engine", + "d engine/server-cuda-b10450", + "f backends/.cache/llama-server -rw-r--r-- x", + "f backends/fichier -rw-r--r-- x", + "f backends/fork-a/CMakeLists.txt -rw-r--r-- ", + "f backends/fork-a/build/bin/llama-server -rwxr-xr-x #!/bin/sh\n", + "f backends/fork-a/build/llama-server -rwxr-xr-x #!/bin/sh\n", + "f backends/fork-c/llama-server.exe -rw-r--r-- x", + "f backends/llama.cpp-prebuilt/VERSION -rw-r--r-- b305 fmt2\n", + "f backends/llama.cpp-prebuilt/llama-b305/llama-cli -rwxr-xr-x x", + "f backends/llama.cpp/README -rw-r--r-- & état", + "f backends/llama.cpp/build/llama-server -rwxr-xr-x #!/bin/sh\n", + "f engine/server-cuda-b10450/llama-server -rwxr-xr-x #!/bin/sh\n", + "l backends/lien -> fork-b" + ] + }, + { + "name": "GPU : deux cartes de même nom", + "machine": "nvidia", + "files": { + "gpu/LLAMA-SERVER": "#!/bin/sh\necho ' CUDA0: Carte B (0 MiB, 0 MiB free)'\n" + }, + "config": { + "CUDA_VISIBLE_DEVICES": "8" + }, + "method": "POST", + "path": "/api/backends/devices", + "body": "{}", + "status": 200, + "out": { + "devices": [ + { + "free_mib": 0, + "id": "CUDA0", + "link": { + "gen": 0, + "gen_max": 3, + "mem_clock_mhz": 7001, + "width": 8, + "width_max": 16 + }, + "name": "Carte B", + "total_mib": 8192 + } + ], + "ok": true + }, + "after": { + "config/BIN": "{{HOME}}/gpu/LLAMA-SERVER", + "devices.json": "{\n \"{{HOME}}/gpu/LLAMA-SERVER\\u0000\": [\n {\n \"free_mib\": 0,\n \"id\": \"CUDA0\",\n \"link\": {\n \"gen\": 1,\n \"gen_max\": 4,\n \"mem_clock_mhz\": 9751,\n \"width\": 16,\n \"width_max\": 16\n },\n \"name\": \"NVIDIA GeForce RTX 3090\",\n \"total_mib\": 24576\n },\n {\n \"free_mib\": 8000,\n \"id\": \"CUDA1\",\n \"link\": {\n \"gen\": 0,\n \"gen_max\": 3,\n \"mem_clock_mhz\": 7001,\n \"width\": 8,\n \"width_max\": 16\n },\n \"name\": \"Carte B\",\n \"total_mib\": 8192\n },\n {\n \"free_mib\": 20000,\n \"id\": \"Vulkan0\",\n \"name\": \"NVIDIA GeForce RTX 3090\",\n \"total_mib\": 24576\n },\n {\n \"free_mib\": 1,\n \"id\": \"CUDA2\",\n \"name\": \"ordre aucun cvd aucun\",\n \"total_mib\": 1\n }\n ],\n \"{{HOME}}/gpu/LLAMA-SERVER\\u00000\": [\n {\n \"free_mib\": 0,\n \"id\": \"CUDA0\",\n \"name\": \"Saturée\",\n \"total_mib\": 100\n }\n ],\n \"{{HOME}}/gpu/LLAMA-SERVER\\u00001,0\": [\n {\n \"free_mib\": 5,\n \"id\": \"CUDA0\",\n \"name\": \"ordre PCI_BUS_ID cvd 1,0\",\n \"total_mib\": 10\n }\n ],\n \"{{HOME}}/gpu/LLAMA-SERVER\\u00006\": [\n {\n \"free_mib\": 1,\n \"id\": \"CUDA0\",\n \"name\": \"Lue\",\n \"total_mib\": 2\n }\n ],\n \"{{HOME}}/gpu/LLAMA-SERVER\\u00007\": [\n {\n \"free_mib\": 0,\n \"id\": \"CUDA0\",\n \"name\": \"Vieille carte\",\n \"total_mib\": 4096\n },\n {\n \"free_mib\": 0,\n \"id\": \"CUDA1\",\n \"name\": \"Vieille carte\",\n \"total_mib\": 4096\n }\n ],\n \"{{HOME}}/gpu/LLAMA-SERVER\\u00008\": [\n {\n \"free_mib\": 0,\n \"id\": \"CUDA0\",\n \"link\": {\n \"gen\": 0,\n \"gen_max\": 3,\n \"mem_clock_mhz\": 7001,\n \"width\": 8,\n \"width_max\": 16\n },\n \"name\": \"Carte B\",\n \"total_mib\": 8192\n }\n ]\n}", + "loki-build.log": "▶ récupération de la dernière release llama.cpp…\nrelease b305 — variant retenu : CPU (Linux x64)\n▶ téléchargement de llama-b300-bin-ubuntu-x64.tar.gz (2 Mo)…\n▶ extraction de llama-b300-bin-ubuntu-x64.tar.gz…\n✗ archives extraites mais llama-server introuvable sous {{HOME}}/backends/llama.cpp-prebuilt\n", + "state/build_job": "{\"action\":\"prebuilt\",\"phase\":\"échec\",\"compiled\":0,\"running\":false,\"error\":\"archives extraites mais llama-server introuvable sous {{HOME}}/backends/llama.cpp-prebuilt\",\"started_at\":\"{{T}}\",\"ended_at\":\"{{T}}\",\"old\":\"\",\"new\":\"\"}" + }, + "tree": [ + "d backends", + "d backends/.cache", + "d backends/fork-a", + "d backends/fork-a/build", + "d backends/fork-a/build/bin", + "d backends/fork-c", + "d backends/fork-c/build", + "d backends/fork-c/build/bin", + "d backends/fork-c/build/bin/llama-server", + "d backends/llama.cpp", + "d backends/llama.cpp-prebuilt", + "d backends/llama.cpp-prebuilt/llama-b305", + "d backends/llama.cpp/build", + "d backends/llama.cpp/build/bin", + "d backends/llama.cpp/build/bin/Release", + "d engine", + "d engine/server-cuda-b10450", + "f backends/.cache/llama-server -rw-r--r-- x", + "f backends/fichier -rw-r--r-- x", + "f backends/fork-a/CMakeLists.txt -rw-r--r-- ", + "f backends/fork-a/build/bin/llama-server -rwxr-xr-x #!/bin/sh\n", + "f backends/fork-a/build/llama-server -rwxr-xr-x #!/bin/sh\n", + "f backends/fork-c/llama-server.exe -rw-r--r-- x", + "f backends/llama.cpp-prebuilt/VERSION -rw-r--r-- b305 fmt2\n", + "f backends/llama.cpp-prebuilt/llama-b305/llama-cli -rwxr-xr-x x", + "f backends/llama.cpp/README -rw-r--r-- & état", + "f backends/llama.cpp/build/llama-server -rwxr-xr-x #!/bin/sh\n", + "f engine/server-cuda-b10450/llama-server -rwxr-xr-x #!/bin/sh\n", + "l backends/lien -> fork-b" + ] + }, + { + "name": "GPU : lu à vide, gardé sur disque", + "machine": "nu", + "files": { + "gpu/LLAMA-SERVER": "#!/bin/sh\necho ' CUDA0: Pleine (0 MiB, 0 MiB free)'\n" + }, + "config": { + "CUDA_VISIBLE_DEVICES": "9" + }, + "method": "POST", + "path": "/api/backends/devices", + "body": "{}", + "status": 200, + "out": { + "devices": [ + { + "free_mib": 0, + "id": "CUDA0", + "name": "Pleine", + "total_mib": 0 + } + ], + "ok": true + }, + "after": { + "config/BIN": "{{HOME}}/gpu/LLAMA-SERVER", + "devices.json": "{\n \"{{HOME}}/gpu/LLAMA-SERVER\\u0000\": [\n {\n \"free_mib\": 0,\n \"id\": \"CUDA0\",\n \"link\": {\n \"gen\": 1,\n \"gen_max\": 4,\n \"mem_clock_mhz\": 9751,\n \"width\": 16,\n \"width_max\": 16\n },\n \"name\": \"NVIDIA GeForce RTX 3090\",\n \"total_mib\": 24576\n },\n {\n \"free_mib\": 8000,\n \"id\": \"CUDA1\",\n \"link\": {\n \"gen\": 0,\n \"gen_max\": 3,\n \"mem_clock_mhz\": 7001,\n \"width\": 8,\n \"width_max\": 16\n },\n \"name\": \"Carte B\",\n \"total_mib\": 8192\n },\n {\n \"free_mib\": 20000,\n \"id\": \"Vulkan0\",\n \"name\": \"NVIDIA GeForce RTX 3090\",\n \"total_mib\": 24576\n },\n {\n \"free_mib\": 1,\n \"id\": \"CUDA2\",\n \"name\": \"ordre aucun cvd aucun\",\n \"total_mib\": 1\n }\n ],\n \"{{HOME}}/gpu/LLAMA-SERVER\\u00000\": [\n {\n \"free_mib\": 0,\n \"id\": \"CUDA0\",\n \"name\": \"Saturée\",\n \"total_mib\": 100\n }\n ],\n \"{{HOME}}/gpu/LLAMA-SERVER\\u00001,0\": [\n {\n \"free_mib\": 5,\n \"id\": \"CUDA0\",\n \"name\": \"ordre PCI_BUS_ID cvd 1,0\",\n \"total_mib\": 10\n }\n ],\n \"{{HOME}}/gpu/LLAMA-SERVER\\u00006\": [\n {\n \"free_mib\": 1,\n \"id\": \"CUDA0\",\n \"name\": \"Lue\",\n \"total_mib\": 2\n }\n ],\n \"{{HOME}}/gpu/LLAMA-SERVER\\u00007\": [\n {\n \"free_mib\": 0,\n \"id\": \"CUDA0\",\n \"name\": \"Vieille carte\",\n \"total_mib\": 4096\n },\n {\n \"free_mib\": 0,\n \"id\": \"CUDA1\",\n \"name\": \"Vieille carte\",\n \"total_mib\": 4096\n }\n ],\n \"{{HOME}}/gpu/LLAMA-SERVER\\u00008\": [\n {\n \"free_mib\": 0,\n \"id\": \"CUDA0\",\n \"link\": {\n \"gen\": 0,\n \"gen_max\": 3,\n \"mem_clock_mhz\": 7001,\n \"width\": 8,\n \"width_max\": 16\n },\n \"name\": \"Carte B\",\n \"total_mib\": 8192\n }\n ],\n \"{{HOME}}/gpu/LLAMA-SERVER\\u00009\": [\n {\n \"free_mib\": 0,\n \"id\": \"CUDA0\",\n \"name\": \"Pleine\",\n \"total_mib\": 0\n }\n ]\n}", + "loki-build.log": "▶ récupération de la dernière release llama.cpp…\nrelease b305 — variant retenu : CPU (Linux x64)\n▶ téléchargement de llama-b300-bin-ubuntu-x64.tar.gz (2 Mo)…\n▶ extraction de llama-b300-bin-ubuntu-x64.tar.gz…\n✗ archives extraites mais llama-server introuvable sous {{HOME}}/backends/llama.cpp-prebuilt\n", + "state/build_job": "{\"action\":\"prebuilt\",\"phase\":\"échec\",\"compiled\":0,\"running\":false,\"error\":\"archives extraites mais llama-server introuvable sous {{HOME}}/backends/llama.cpp-prebuilt\",\"started_at\":\"{{T}}\",\"ended_at\":\"{{T}}\",\"old\":\"\",\"new\":\"\"}" + }, + "tree": [ + "d backends", + "d backends/.cache", + "d backends/fork-a", + "d backends/fork-a/build", + "d backends/fork-a/build/bin", + "d backends/fork-c", + "d backends/fork-c/build", + "d backends/fork-c/build/bin", + "d backends/fork-c/build/bin/llama-server", + "d backends/llama.cpp", + "d backends/llama.cpp-prebuilt", + "d backends/llama.cpp-prebuilt/llama-b305", + "d backends/llama.cpp/build", + "d backends/llama.cpp/build/bin", + "d backends/llama.cpp/build/bin/Release", + "d engine", + "d engine/server-cuda-b10450", + "f backends/.cache/llama-server -rw-r--r-- x", + "f backends/fichier -rw-r--r-- x", + "f backends/fork-a/CMakeLists.txt -rw-r--r-- ", + "f backends/fork-a/build/bin/llama-server -rwxr-xr-x #!/bin/sh\n", + "f backends/fork-a/build/llama-server -rwxr-xr-x #!/bin/sh\n", + "f backends/fork-c/llama-server.exe -rw-r--r-- x", + "f backends/llama.cpp-prebuilt/VERSION -rw-r--r-- b305 fmt2\n", + "f backends/llama.cpp-prebuilt/llama-b305/llama-cli -rwxr-xr-x x", + "f backends/llama.cpp/README -rw-r--r-- & état", + "f backends/llama.cpp/build/llama-server -rwxr-xr-x #!/bin/sh\n", + "f engine/server-cuda-b10450/llama-server -rwxr-xr-x #!/bin/sh\n", + "l backends/lien -> fork-b" + ] + }, + { + "name": "GPU : cartes jumelles chez nvidia-smi", + "machine": "nvidia-jumelles", + "files": { + "gpu/LLAMA-SERVER": "#!/bin/sh\necho ' CUDA0: Carte B (0 MiB, 0 MiB free)'\n" + }, + "config": { + "CUDA_VISIBLE_DEVICES": "10" + }, + "method": "POST", + "path": "/api/backends/devices", + "body": "{}", + "status": 200, + "out": { + "devices": [ + { + "free_mib": 0, + "id": "CUDA0", + "name": "Carte B", + "total_mib": 0 + } + ], + "ok": true + }, + "after": { + "config/BIN": "{{HOME}}/gpu/LLAMA-SERVER", + "devices.json": "{\n \"{{HOME}}/gpu/LLAMA-SERVER\\u0000\": [\n {\n \"free_mib\": 0,\n \"id\": \"CUDA0\",\n \"link\": {\n \"gen\": 1,\n \"gen_max\": 4,\n \"mem_clock_mhz\": 9751,\n \"width\": 16,\n \"width_max\": 16\n },\n \"name\": \"NVIDIA GeForce RTX 3090\",\n \"total_mib\": 24576\n },\n {\n \"free_mib\": 8000,\n \"id\": \"CUDA1\",\n \"link\": {\n \"gen\": 0,\n \"gen_max\": 3,\n \"mem_clock_mhz\": 7001,\n \"width\": 8,\n \"width_max\": 16\n },\n \"name\": \"Carte B\",\n \"total_mib\": 8192\n },\n {\n \"free_mib\": 20000,\n \"id\": \"Vulkan0\",\n \"name\": \"NVIDIA GeForce RTX 3090\",\n \"total_mib\": 24576\n },\n {\n \"free_mib\": 1,\n \"id\": \"CUDA2\",\n \"name\": \"ordre aucun cvd aucun\",\n \"total_mib\": 1\n }\n ],\n \"{{HOME}}/gpu/LLAMA-SERVER\\u00000\": [\n {\n \"free_mib\": 0,\n \"id\": \"CUDA0\",\n \"name\": \"Saturée\",\n \"total_mib\": 100\n }\n ],\n \"{{HOME}}/gpu/LLAMA-SERVER\\u00001,0\": [\n {\n \"free_mib\": 5,\n \"id\": \"CUDA0\",\n \"name\": \"ordre PCI_BUS_ID cvd 1,0\",\n \"total_mib\": 10\n }\n ],\n \"{{HOME}}/gpu/LLAMA-SERVER\\u000010\": [\n {\n \"free_mib\": 0,\n \"id\": \"CUDA0\",\n \"name\": \"Carte B\",\n \"total_mib\": 0\n }\n ],\n \"{{HOME}}/gpu/LLAMA-SERVER\\u00006\": [\n {\n \"free_mib\": 1,\n \"id\": \"CUDA0\",\n \"name\": \"Lue\",\n \"total_mib\": 2\n }\n ],\n \"{{HOME}}/gpu/LLAMA-SERVER\\u00007\": [\n {\n \"free_mib\": 0,\n \"id\": \"CUDA0\",\n \"name\": \"Vieille carte\",\n \"total_mib\": 4096\n },\n {\n \"free_mib\": 0,\n \"id\": \"CUDA1\",\n \"name\": \"Vieille carte\",\n \"total_mib\": 4096\n }\n ],\n \"{{HOME}}/gpu/LLAMA-SERVER\\u00008\": [\n {\n \"free_mib\": 0,\n \"id\": \"CUDA0\",\n \"link\": {\n \"gen\": 0,\n \"gen_max\": 3,\n \"mem_clock_mhz\": 7001,\n \"width\": 8,\n \"width_max\": 16\n },\n \"name\": \"Carte B\",\n \"total_mib\": 8192\n }\n ],\n \"{{HOME}}/gpu/LLAMA-SERVER\\u00009\": [\n {\n \"free_mib\": 0,\n \"id\": \"CUDA0\",\n \"name\": \"Pleine\",\n \"total_mib\": 0\n }\n ]\n}", + "loki-build.log": "▶ récupération de la dernière release llama.cpp…\nrelease b305 — variant retenu : CPU (Linux x64)\n▶ téléchargement de llama-b300-bin-ubuntu-x64.tar.gz (2 Mo)…\n▶ extraction de llama-b300-bin-ubuntu-x64.tar.gz…\n✗ archives extraites mais llama-server introuvable sous {{HOME}}/backends/llama.cpp-prebuilt\n", + "state/build_job": "{\"action\":\"prebuilt\",\"phase\":\"échec\",\"compiled\":0,\"running\":false,\"error\":\"archives extraites mais llama-server introuvable sous {{HOME}}/backends/llama.cpp-prebuilt\",\"started_at\":\"{{T}}\",\"ended_at\":\"{{T}}\",\"old\":\"\",\"new\":\"\"}" + }, + "tree": [ + "d backends", + "d backends/.cache", + "d backends/fork-a", + "d backends/fork-a/build", + "d backends/fork-a/build/bin", + "d backends/fork-c", + "d backends/fork-c/build", + "d backends/fork-c/build/bin", + "d backends/fork-c/build/bin/llama-server", + "d backends/llama.cpp", + "d backends/llama.cpp-prebuilt", + "d backends/llama.cpp-prebuilt/llama-b305", + "d backends/llama.cpp/build", + "d backends/llama.cpp/build/bin", + "d backends/llama.cpp/build/bin/Release", + "d engine", + "d engine/server-cuda-b10450", + "f backends/.cache/llama-server -rw-r--r-- x", + "f backends/fichier -rw-r--r-- x", + "f backends/fork-a/CMakeLists.txt -rw-r--r-- ", + "f backends/fork-a/build/bin/llama-server -rwxr-xr-x #!/bin/sh\n", + "f backends/fork-a/build/llama-server -rwxr-xr-x #!/bin/sh\n", + "f backends/fork-c/llama-server.exe -rw-r--r-- x", + "f backends/llama.cpp-prebuilt/VERSION -rw-r--r-- b305 fmt2\n", + "f backends/llama.cpp-prebuilt/llama-b305/llama-cli -rwxr-xr-x x", + "f backends/llama.cpp/README -rw-r--r-- & état", + "f backends/llama.cpp/build/llama-server -rwxr-xr-x #!/bin/sh\n", + "f engine/server-cuda-b10450/llama-server -rwxr-xr-x #!/bin/sh\n", + "l backends/lien -> fork-b" + ] + }, + { + "name": "GPU : liaison sans génération connue", + "machine": "liaison-partielle", + "files": { + "gpu/LLAMA-SERVER": "#!/bin/sh\necho ' CUDA0: Carte P (4096 MiB, 4000 MiB free)'\n" + }, + "config": { + "CUDA_VISIBLE_DEVICES": "11" + }, + "method": "POST", + "path": "/api/backends/devices", + "body": "{}", + "status": 200, + "out": { + "devices": [ + { + "free_mib": 4000, + "id": "CUDA0", + "link": { + "gen": 0, + "gen_max": 0, + "mem_clock_mhz": 5001, + "width": 4, + "width_max": 8 + }, + "name": "Carte P", + "total_mib": 4096 + } + ], + "ok": true + }, + "after": { + "config/BIN": "{{HOME}}/gpu/LLAMA-SERVER", + "devices.json": "{\n \"{{HOME}}/gpu/LLAMA-SERVER\\u0000\": [\n {\n \"free_mib\": 0,\n \"id\": \"CUDA0\",\n \"link\": {\n \"gen\": 1,\n \"gen_max\": 4,\n \"mem_clock_mhz\": 9751,\n \"width\": 16,\n \"width_max\": 16\n },\n \"name\": \"NVIDIA GeForce RTX 3090\",\n \"total_mib\": 24576\n },\n {\n \"free_mib\": 8000,\n \"id\": \"CUDA1\",\n \"link\": {\n \"gen\": 0,\n \"gen_max\": 3,\n \"mem_clock_mhz\": 7001,\n \"width\": 8,\n \"width_max\": 16\n },\n \"name\": \"Carte B\",\n \"total_mib\": 8192\n },\n {\n \"free_mib\": 20000,\n \"id\": \"Vulkan0\",\n \"name\": \"NVIDIA GeForce RTX 3090\",\n \"total_mib\": 24576\n },\n {\n \"free_mib\": 1,\n \"id\": \"CUDA2\",\n \"name\": \"ordre aucun cvd aucun\",\n \"total_mib\": 1\n }\n ],\n \"{{HOME}}/gpu/LLAMA-SERVER\\u00000\": [\n {\n \"free_mib\": 0,\n \"id\": \"CUDA0\",\n \"name\": \"Saturée\",\n \"total_mib\": 100\n }\n ],\n \"{{HOME}}/gpu/LLAMA-SERVER\\u00001,0\": [\n {\n \"free_mib\": 5,\n \"id\": \"CUDA0\",\n \"name\": \"ordre PCI_BUS_ID cvd 1,0\",\n \"total_mib\": 10\n }\n ],\n \"{{HOME}}/gpu/LLAMA-SERVER\\u000010\": [\n {\n \"free_mib\": 0,\n \"id\": \"CUDA0\",\n \"name\": \"Carte B\",\n \"total_mib\": 0\n }\n ],\n \"{{HOME}}/gpu/LLAMA-SERVER\\u000011\": [\n {\n \"free_mib\": 4000,\n \"id\": \"CUDA0\",\n \"link\": {\n \"gen\": 0,\n \"gen_max\": 0,\n \"mem_clock_mhz\": 5001,\n \"width\": 4,\n \"width_max\": 8\n },\n \"name\": \"Carte P\",\n \"total_mib\": 4096\n }\n ],\n \"{{HOME}}/gpu/LLAMA-SERVER\\u00006\": [\n {\n \"free_mib\": 1,\n \"id\": \"CUDA0\",\n \"name\": \"Lue\",\n \"total_mib\": 2\n }\n ],\n \"{{HOME}}/gpu/LLAMA-SERVER\\u00007\": [\n {\n \"free_mib\": 0,\n \"id\": \"CUDA0\",\n \"name\": \"Vieille carte\",\n \"total_mib\": 4096\n },\n {\n \"free_mib\": 0,\n \"id\": \"CUDA1\",\n \"name\": \"Vieille carte\",\n \"total_mib\": 4096\n }\n ],\n \"{{HOME}}/gpu/LLAMA-SERVER\\u00008\": [\n {\n \"free_mib\": 0,\n \"id\": \"CUDA0\",\n \"link\": {\n \"gen\": 0,\n \"gen_max\": 3,\n \"mem_clock_mhz\": 7001,\n \"width\": 8,\n \"width_max\": 16\n },\n \"name\": \"Carte B\",\n \"total_mib\": 8192\n }\n ],\n \"{{HOME}}/gpu/LLAMA-SERVER\\u00009\": [\n {\n \"free_mib\": 0,\n \"id\": \"CUDA0\",\n \"name\": \"Pleine\",\n \"total_mib\": 0\n }\n ]\n}", + "loki-build.log": "▶ récupération de la dernière release llama.cpp…\nrelease b305 — variant retenu : CPU (Linux x64)\n▶ téléchargement de llama-b300-bin-ubuntu-x64.tar.gz (2 Mo)…\n▶ extraction de llama-b300-bin-ubuntu-x64.tar.gz…\n✗ archives extraites mais llama-server introuvable sous {{HOME}}/backends/llama.cpp-prebuilt\n", + "state/build_job": "{\"action\":\"prebuilt\",\"phase\":\"échec\",\"compiled\":0,\"running\":false,\"error\":\"archives extraites mais llama-server introuvable sous {{HOME}}/backends/llama.cpp-prebuilt\",\"started_at\":\"{{T}}\",\"ended_at\":\"{{T}}\",\"old\":\"\",\"new\":\"\"}" + }, + "tree": [ + "d backends", + "d backends/.cache", + "d backends/fork-a", + "d backends/fork-a/build", + "d backends/fork-a/build/bin", + "d backends/fork-c", + "d backends/fork-c/build", + "d backends/fork-c/build/bin", + "d backends/fork-c/build/bin/llama-server", + "d backends/llama.cpp", + "d backends/llama.cpp-prebuilt", + "d backends/llama.cpp-prebuilt/llama-b305", + "d backends/llama.cpp/build", + "d backends/llama.cpp/build/bin", + "d backends/llama.cpp/build/bin/Release", + "d engine", + "d engine/server-cuda-b10450", + "f backends/.cache/llama-server -rw-r--r-- x", + "f backends/fichier -rw-r--r-- x", + "f backends/fork-a/CMakeLists.txt -rw-r--r-- ", + "f backends/fork-a/build/bin/llama-server -rwxr-xr-x #!/bin/sh\n", + "f backends/fork-a/build/llama-server -rwxr-xr-x #!/bin/sh\n", + "f backends/fork-c/llama-server.exe -rw-r--r-- x", + "f backends/llama.cpp-prebuilt/VERSION -rw-r--r-- b305 fmt2\n", + "f backends/llama.cpp-prebuilt/llama-b305/llama-cli -rwxr-xr-x x", + "f backends/llama.cpp/README -rw-r--r-- & état", + "f backends/llama.cpp/build/llama-server -rwxr-xr-x #!/bin/sh\n", + "f engine/server-cuda-b10450/llama-server -rwxr-xr-x #!/bin/sh\n", + "l backends/lien -> fork-b" + ] + }, + { + "name": "GPU : liste vide sur le disque, pas de repli", + "machine": "nu", + "files": { + "devices.json": "{\"{{HOME}}/gpu/LLAMA-SERVER\\u0000vide\": []}", + "gpu/LLAMA-SERVER": "#!/bin/sh\nexit 3\n" + }, + "config": { + "CUDA_VISIBLE_DEVICES": "vide" + }, + "method": "POST", + "path": "/api/backends/devices", + "body": "{}", + "status": 200, + "out": { + "error": "le moteur n'a pas répondu : exit status 3", + "ok": false + }, + "after": { + "config/BIN": "{{HOME}}/gpu/LLAMA-SERVER", + "devices.json": "{\"{{HOME}}/gpu/LLAMA-SERVER\\u0000vide\": []}", + "loki-build.log": "▶ récupération de la dernière release llama.cpp…\nrelease b305 — variant retenu : CPU (Linux x64)\n▶ téléchargement de llama-b300-bin-ubuntu-x64.tar.gz (2 Mo)…\n▶ extraction de llama-b300-bin-ubuntu-x64.tar.gz…\n✗ archives extraites mais llama-server introuvable sous {{HOME}}/backends/llama.cpp-prebuilt\n", + "state/build_job": "{\"action\":\"prebuilt\",\"phase\":\"échec\",\"compiled\":0,\"running\":false,\"error\":\"archives extraites mais llama-server introuvable sous {{HOME}}/backends/llama.cpp-prebuilt\",\"started_at\":\"{{T}}\",\"ended_at\":\"{{T}}\",\"old\":\"\",\"new\":\"\"}" + }, + "tree": [ + "d backends", + "d backends/.cache", + "d backends/fork-a", + "d backends/fork-a/build", + "d backends/fork-a/build/bin", + "d backends/fork-c", + "d backends/fork-c/build", + "d backends/fork-c/build/bin", + "d backends/fork-c/build/bin/llama-server", + "d backends/llama.cpp", + "d backends/llama.cpp-prebuilt", + "d backends/llama.cpp-prebuilt/llama-b305", + "d backends/llama.cpp/build", + "d backends/llama.cpp/build/bin", + "d backends/llama.cpp/build/bin/Release", + "d engine", + "d engine/server-cuda-b10450", + "f backends/.cache/llama-server -rw-r--r-- x", + "f backends/fichier -rw-r--r-- x", + "f backends/fork-a/CMakeLists.txt -rw-r--r-- ", + "f backends/fork-a/build/bin/llama-server -rwxr-xr-x #!/bin/sh\n", + "f backends/fork-a/build/llama-server -rwxr-xr-x #!/bin/sh\n", + "f backends/fork-c/llama-server.exe -rw-r--r-- x", + "f backends/llama.cpp-prebuilt/VERSION -rw-r--r-- b305 fmt2\n", + "f backends/llama.cpp-prebuilt/llama-b305/llama-cli -rwxr-xr-x x", + "f backends/llama.cpp/README -rw-r--r-- & état", + "f backends/llama.cpp/build/llama-server -rwxr-xr-x #!/bin/sh\n", + "f engine/server-cuda-b10450/llama-server -rwxr-xr-x #!/bin/sh\n", + "l backends/lien -> fork-b" + ] + }, + { + "name": "GPU : planté, repli sur le disque", + "files": { + "gpu/LLAMA-SERVER": "#!/bin/sh\necho ' CUDA0: partielle (1 MiB, 1 MiB free)'\nexit 3\n" + }, + "method": "POST", + "path": "/api/backends/devices", + "body": "{}", + "status": 200, + "out": { + "devices": [ + { + "free_mib": 1, + "id": "CUDA0", + "name": "partielle", + "total_mib": 1 + } + ], + "ok": true, + "stale": true + }, + "after": { + "config/BIN": "{{HOME}}/gpu/LLAMA-SERVER", + "devices.json": "{\"{{HOME}}/gpu/LLAMA-SERVER\\u0000vide\": []}", + "loki-build.log": "▶ récupération de la dernière release llama.cpp…\nrelease b305 — variant retenu : CPU (Linux x64)\n▶ téléchargement de llama-b300-bin-ubuntu-x64.tar.gz (2 Mo)…\n▶ extraction de llama-b300-bin-ubuntu-x64.tar.gz…\n✗ archives extraites mais llama-server introuvable sous {{HOME}}/backends/llama.cpp-prebuilt\n", + "state/build_job": "{\"action\":\"prebuilt\",\"phase\":\"échec\",\"compiled\":0,\"running\":false,\"error\":\"archives extraites mais llama-server introuvable sous {{HOME}}/backends/llama.cpp-prebuilt\",\"started_at\":\"{{T}}\",\"ended_at\":\"{{T}}\",\"old\":\"\",\"new\":\"\"}" + }, + "tree": [ + "d backends", + "d backends/.cache", + "d backends/fork-a", + "d backends/fork-a/build", + "d backends/fork-a/build/bin", + "d backends/fork-c", + "d backends/fork-c/build", + "d backends/fork-c/build/bin", + "d backends/fork-c/build/bin/llama-server", + "d backends/llama.cpp", + "d backends/llama.cpp-prebuilt", + "d backends/llama.cpp-prebuilt/llama-b305", + "d backends/llama.cpp/build", + "d backends/llama.cpp/build/bin", + "d backends/llama.cpp/build/bin/Release", + "d engine", + "d engine/server-cuda-b10450", + "f backends/.cache/llama-server -rw-r--r-- x", + "f backends/fichier -rw-r--r-- x", + "f backends/fork-a/CMakeLists.txt -rw-r--r-- ", + "f backends/fork-a/build/bin/llama-server -rwxr-xr-x #!/bin/sh\n", + "f backends/fork-a/build/llama-server -rwxr-xr-x #!/bin/sh\n", + "f backends/fork-c/llama-server.exe -rw-r--r-- x", + "f backends/llama.cpp-prebuilt/VERSION -rw-r--r-- b305 fmt2\n", + "f backends/llama.cpp-prebuilt/llama-b305/llama-cli -rwxr-xr-x x", + "f backends/llama.cpp/README -rw-r--r-- & état", + "f backends/llama.cpp/build/llama-server -rwxr-xr-x #!/bin/sh\n", + "f engine/server-cuda-b10450/llama-server -rwxr-xr-x #!/bin/sh\n", + "l backends/lien -> fork-b" + ] + } + ] +} diff --git a/rust/loki-rs/tests/proxy.rs b/rust/loki-rs/tests/proxy.rs index 7ce058c..8fdda9d 100644 --- a/rust/loki-rs/tests/proxy.rs +++ b/rust/loki-rs/tests/proxy.rs @@ -112,6 +112,7 @@ async fn facade(backend: SocketAddr) -> (SocketAddr, oneshot::Sender<()>) { projects: false, mcp: None, update: false, + backends: None, }; tokio::spawn(loki_rs::proxy::serve(l, cfg, async move { let _ = rx.await; From 78766a28d93d58415de406807091d1c35213fe82 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 15 Oct 2026 21:08:37 +0000 Subject: [PATCH 21/52] =?UTF-8?q?Tests=20des=20backends=20:=20resetBackend?= =?UTF-8?q?sState=20hors=20du=20fichier=20r=C3=A9serv=C3=A9=20=C3=A0=20Lin?= =?UTF-8?q?ux?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit TestFacadeBackends tourne sur toutes les plateformes (job macOS de la CI). Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_016tcGfJWefW5kUGoC2wL8cv --- internal/loki/facade_backends_test.go | 15 +++++++++++++++ internal/loki/parity_backends_test.go | 12 ------------ 2 files changed, 15 insertions(+), 12 deletions(-) create mode 100644 internal/loki/facade_backends_test.go diff --git a/internal/loki/facade_backends_test.go b/internal/loki/facade_backends_test.go new file mode 100644 index 0000000..abae0d6 --- /dev/null +++ b/internal/loki/facade_backends_test.go @@ -0,0 +1,15 @@ +package loki + +import "time" + +// resetBackendsState : pas de job ni de GPU en cache d'un test à l'autre. +func resetBackendsState() { + lcMu.Lock() + lcCur = nil + lcLastSave = time.Time{} + lcMu.Unlock() + setBuildSink(nil) + devCacheMu.Lock() + devCache = map[string]devCacheEntry{} + devCacheMu.Unlock() +} diff --git a/internal/loki/parity_backends_test.go b/internal/loki/parity_backends_test.go index f77db05..645fcf7 100644 --- a/internal/loki/parity_backends_test.go +++ b/internal/loki/parity_backends_test.go @@ -591,18 +591,6 @@ func TestPariteBackends(t *testing.T) { } } -// resetBackendsState : pas de job ni de GPU en cache d'un test à l'autre. -func resetBackendsState() { - lcMu.Lock() - lcCur = nil - lcLastSave = time.Time{} - lcMu.Unlock() - setBuildSink(nil) - devCacheMu.Lock() - devCache = map[string]devCacheEntry{} - devCacheMu.Unlock() -} - // parityApplyFiles pose les fichiers d'une étape, dans l'ordre des noms. func parityApplyFiles(t *testing.T, home string, files map[string]string) { t.Helper() From 0f3a3d52c1ea26534aa5a8edf5fff7dcdafa559f Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 15 Oct 2026 21:34:57 +0000 Subject: [PATCH 22/52] Lot 23 : sonde de gabarit en Rust (avec LOKI_CHAT=rust) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Port de chat_tplprobe.go : la façade sonde le gabarit du moteur après ses complétions (/health, /props, /apply-template), avec les verdicts preserves_history, prefix_stable et renders_reasoning, le cache par build/modèle/gabarit/forme et la relance bornée. - l'écho du raisonnement suit le verdict de la sonde (echo_policy_for) ; - NUDGE_IN_TOOL ne garde plus la conversation au Go : le rappel de budget va dans le dernier résultat d'outil quand le préfixe n'est pas stable ; - le Go lit le verdict de la façade (/internal/facade/v1/chat/tpl) quand elle tient la conversation. Parité : parity_tplprobe_test.go tire du Go un journal d'échanges rejoué octet pour octet par tests/tplprobe.rs. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_016tcGfJWefW5kUGoC2wL8cv --- README.md | 2 +- ...st-migration-lot23-sonde-gabarit-design.md | 85 + internal/loki/chat_tplprobe.go | 3 + internal/loki/facade_chat.go | 39 +- internal/loki/facade_chat_test.go | 40 +- internal/loki/parity_tplprobe_test.go | 358 +++ rust/loki-rs/src/chat/generate.rs | 8 +- rust/loki-rs/src/chat/host.rs | 37 +- rust/loki-rs/src/chat/mod.rs | 4 +- rust/loki-rs/src/chat/nudges.rs | 89 + rust/loki-rs/src/chat/tplprobe.rs | 915 ++++++ rust/loki-rs/src/chat/turn.rs | 39 +- rust/loki-rs/src/chat/turn/tests.rs | 125 + rust/loki-rs/src/chatapi.rs | 17 + rust/loki-rs/tests/lot6.rs | 196 +- .../tests/parity_tplprobe/tplprobe.json | 2807 +++++++++++++++++ rust/loki-rs/tests/tplprobe.rs | 264 ++ 17 files changed, 5006 insertions(+), 22 deletions(-) create mode 100644 docs/superpowers/specs/2026-10-15-rust-migration-lot23-sonde-gabarit-design.md create mode 100644 internal/loki/parity_tplprobe_test.go create mode 100644 rust/loki-rs/src/chat/tplprobe.rs create mode 100644 rust/loki-rs/tests/parity_tplprobe/tplprobe.json create mode 100644 rust/loki-rs/tests/tplprobe.rs diff --git a/README.md b/README.md index 29fd6fb..326aaeb 100644 --- a/README.md +++ b/README.md @@ -227,7 +227,7 @@ redémarrages (volume `/data`). Variables d'environnement du conteneur : | `LOKI_AUTO_UPDATE` | `0` : pas de mise à jour des binaires au démarrage | `1` | | `LOKI_FACADE` | `0` : sans façade Rust, le Go sert directement le port public (porte de sortie) | `1` | | `LOKI_GO_PORT` | port local (127.0.0.1) du serveur Go derrière la façade | `8091` | -| `LOKI_CHAT` | `rust` : la conversation est tenue par la façade Rust (expérimental, migration en cours), outils fichiers et shell compris, avec l'export, le mode Code et les critères de la discussion. Le Go garde les autres outils et le reste du mode Code. Avec `PROJ_SNAPSHOT`, `KEEP_TURN_IMAGES`, `NUDGE_IN_TOOL`, `SIDE_SLOT`, `SLOT_PERSIST`, `PREWARM` ou des motifs `retry_patterns`, le Go garde la conversation (décidé au démarrage) | Go | +| `LOKI_CHAT` | `rust` : la conversation est tenue par la façade Rust (expérimental, migration en cours), outils fichiers et shell compris, avec l'export, le mode Code et les critères de la discussion. Le Go garde les autres outils et le reste du mode Code. La façade sonde aussi le gabarit du moteur après ses complétions (sonde de gabarit, `NUDGE_IN_TOOL`). Avec `PROJ_SNAPSHOT`, `KEEP_TURN_IMAGES`, `SIDE_SLOT`, `SLOT_PERSIST`, `PREWARM` ou des motifs `retry_patterns`, le Go garde la conversation (décidé au démarrage) | Go | | `LOKI_MODELS` | `rust` : les routes des modèles — liste, dossiers, suppression, téléchargements, catalogue Hugging Face — sont servies par la façade Rust (expérimental, migration en cours) | Go | | `LOKI_DICTATE` | `rust` : la dictée vocale (`/api/transcribe`, `/api/dictate/*`) et son serveur de transcription sont tenus par la façade Rust (expérimental, migration en cours) | Go | | `LOKI_SETTINGS` | `rust` : les réglages partagés — préférences d'affichage, prompt système, intensité du raisonnement, adresse publique — sont servis par la façade Rust (expérimental, migration en cours) | Go | diff --git a/docs/superpowers/specs/2026-10-15-rust-migration-lot23-sonde-gabarit-design.md b/docs/superpowers/specs/2026-10-15-rust-migration-lot23-sonde-gabarit-design.md new file mode 100644 index 0000000..81c5144 --- /dev/null +++ b/docs/superpowers/specs/2026-10-15-rust-migration-lot23-sonde-gabarit-design.md @@ -0,0 +1,85 @@ +# Lot 23 — sonde de gabarit portée en Rust + +## Contexte + +La sonde de gabarit (`chat_tplprobe.go`) dit ce que le gabarit de chat du +modèle chargé fait de ce qu'on lui envoie : + +- `preserves_history` : le raisonnement d'un tour passé est-il encore rendu ? +- `prefix_stable` : après un tour d'outil, le rendu précédent reste-t-il un + préfixe exact du suivant ? +- `renders_reasoning` : `reasoning_content` est-il rendu quelque part ? + +Le Go la lance après chaque complétion du fil principal, avec les outils et +les `chat_template_kwargs` de la requête. Trois choses lisent son verdict : + +- la politique d'écho du raisonnement (`REASONING_ECHO`) ; +- le rappel de budget glissé dans le résultat d'outil (`NUDGE_IN_TOOL`) ; +- le résumé des performances (`/api/perf/summary`). + +Le relevé du rendu (`tplRenderPrint`) et sa comparaison (`tplRenderDiff`) +servent le panneau Moteur, autour d'une mise à jour. + +Quand la façade tenait la conversation (`LOKI_CHAT=rust`), plus aucune sonde +ne tournait : + +- l'écho décidait sans verdict ; +- `NUDGE_IN_TOOL` gardait la conversation au Go (`facadeChatBlocker`). + +## Le lot + +`rust/loki-rs/src/chat/tplprobe.rs` porte la sonde entière : + +- les requêtes au moteur local (`/health`, `/props`, `/apply-template`, avec la + clé du moteur et les relances sur 503) ; +- les rendus synthétiques et leurs verdicts ; +- le cache par build, modèle, gabarit et forme ; +- le dernier verdict, global et par forme ; +- la ligne de journal ; +- le déclenchement borné (une sonde en vol au plus, une par minute) ; +- le relevé du rendu et sa comparaison. + +Avec `LOKI_CHAT=rust` : + +- **la boucle du tour** signale chaque complétion lue en entier sur le moteur + local, outils ni coupés ni neutralisés (`TurnHost::completed`). L'hôte lance + la sonde pour le fil principal seulement : ni tâche ni sous-agent ; +- **la politique d'écho** lit le dernier verdict ; +- **`NUDGE_IN_TOOL`** est porté (`nudge_in_tool_key`, `nudge_into_tool`, + `TurnHost::prefix_unstable`). Il ne garde plus la conversation au Go ; +- **le résumé des performances**, que sert encore le Go, relit le verdict de la + façade (`/internal/facade/v1/chat/tpl`, `facadeTplCaps`). + +## Parité + +`internal/loki/parity_tplprobe_test.go` enregistre chaque échange de la sonde +du Go avec un faux moteur : méthode, chemin, clé, corps envoyé, réponse. + +Les comportements de moteur couverts : + +- gabarit Qwen3, gabarit qui garde tout, raisonnement jamais rendu, amorce + ignorée ; +- 404, exception du gabarit, clé exigée (inconnue ou connue) ; +- 503 relancés ou persistants, 429 ; +- `/props` illisible, moteur pas prêt, réponse sans prompt ; +- rendus incohérents, preset externe. + +Le journal contient aussi : + +- le verdict, sa ligne de journal, `tplCapsCurrent` et `tplCapsFor` ; +- le cache : même moteur, gabarit changé, build changé ; +- le relevé du rendu sur trois gabarits, et une erreur ; +- neuf comparaisons de relevés ; +- l'empreinte de six formes. + +`tests/tplprobe.rs` rejoue chaque échange contre le port. Le faux moteur rend +les réponses enregistrées et exige des requêtes identiques, octet pour octet. +Il vérifie aussi le déclenchement borné. + +Autres tests : + +- `tests/lot6.rs` vérifie la sonde lancée après un vrai tour de la façade ; +- les tests de la boucle vérifient le rappel dans le résultat d'outil et les + complétions signalées ; +- `TestFacadeSondeDeGabarit` vérifie, côté Go, que le résumé des performances + relit le verdict de la façade. diff --git a/internal/loki/chat_tplprobe.go b/internal/loki/chat_tplprobe.go index 3a89001..108dad3 100644 --- a/internal/loki/chat_tplprobe.go +++ b/internal/loki/chat_tplprobe.go @@ -238,6 +238,9 @@ func tplCapsCurrent() (tplProbeResult, bool) { if externalActive() { return tplProbeResult{}, false } + if facadeChat() { // la façade sonde après SES complétions (facade_chat.go) + return facadeTplCaps() + } tplProbeMu.Lock() defer tplProbeMu.Unlock() if tplProbeLast == nil { diff --git a/internal/loki/facade_chat.go b/internal/loki/facade_chat.go index 96b665b..e4996b8 100644 --- a/internal/loki/facade_chat.go +++ b/internal/loki/facade_chat.go @@ -49,15 +49,14 @@ func facadeChat() bool { // facadeChatBlocker : le réglage qui garde la conversation ici, "" s'il n'y // en a pas. Ces clés opt-in demandent une machinerie restée au Go (bloc projet // figé, images gardées, slots du moteur, préchauffage, motifs de relance -// personnalisés). +// personnalisés). NUDGE_IN_TOOL n'en est plus (lot 23) : la façade a sa sonde +// de gabarit. func facadeChatBlocker(cfg map[string]string) string { switch { case projSnapEnabled(cfg): return "PROJ_SNAPSHOT" case keepTurnImagesOn(cfg): return "KEEP_TURN_IMAGES" - case cfgOn(cfg, "NUDGE_IN_TOOL"): - return "NUDGE_IN_TOOL" case sideSlotOn(cfg): return "SIDE_SLOT" case slotPersistOn(cfg): @@ -70,15 +69,6 @@ func facadeChatBlocker(cfg map[string]string) string { return "" } -// cfgOn : une clé booléenne posée (mêmes valeurs que les autres clés opt-in). -func cfgOn(cfg map[string]string, k string) bool { - switch strings.ToLower(strings.TrimSpace(cfg[k])) { - case "on", "1", "true", "yes", "oui": - return true - } - return false -} - // Décision prise UNE fois par process : passer la conversation d'un détenteur // à l'autre en cours de route laisserait chacun avec sa copie — un réglage // changé vaut au redémarrage suivant. La façade la lit au démarrage @@ -244,6 +234,31 @@ func facadePost(path string, body any) error { return nil } +// facadeTplCaps : le dernier verdict de la sonde de gabarit de la façade, +// quand elle tient la conversation (c'est elle qui sonde après ses +// complétions). +func facadeTplCaps() (tplProbeResult, bool) { + req, err := http.NewRequest(http.MethodPost, "http://"+facadeAddr()+"/internal/facade/v1/chat/tpl", strings.NewReader("{}")) + if err != nil { + return tplProbeResult{}, false + } + req.Header.Set("Content-Type", "application/json") + req.Header.Set(internalTokenHeader, internalToken()) + resp, err := facadeClient.Do(req) + if err != nil { + return tplProbeResult{}, false + } + defer resp.Body.Close() + var out struct { + OK bool `json:"ok"` + Result tplProbeResult `json:"result"` + } + if resp.StatusCode != http.StatusOK || json.NewDecoder(resp.Body).Decode(&out) != nil || !out.OK { + return tplProbeResult{}, false + } + return out.Result, true +} + // facadeError : refus de la façade, avec son statut (409 : un tour tourne). type facadeError struct { status int diff --git a/internal/loki/facade_chat_test.go b/internal/loki/facade_chat_test.go index 1c1fdf2..cd0c1d6 100644 --- a/internal/loki/facade_chat_test.go +++ b/internal/loki/facade_chat_test.go @@ -298,7 +298,7 @@ func TestFacadeReglageQuiGardeLaConversation(t *testing.T) { t.Fatalf("remis à la façade malgré le réglage : %q %v", rec.Body.String(), f.recu()) } for k, want := range map[string]string{ - "PROJ_SNAPSHOT": "PROJ_SNAPSHOT", "NUDGE_IN_TOOL": "NUDGE_IN_TOOL", "SIDE_SLOT": "SIDE_SLOT", + "PROJ_SNAPSHOT": "PROJ_SNAPSHOT", "NUDGE_IN_TOOL": "", "SIDE_SLOT": "SIDE_SLOT", "SLOT_PERSIST": "SLOT_PERSIST", "PREWARM": "PREWARM", } { if got := facadeChatBlocker(map[string]string{k: "on"}); got != want { @@ -1064,3 +1064,41 @@ func TestFacadeBackends(t *testing.T) { } lcAppend("perdue") // tracée, sans panique } + +// Lot 23 : quand la façade tient la conversation, c'est elle qui sonde le +// gabarit ; le résumé des performances relit son verdict. +func TestFacadeSondeDeGabarit(t *testing.T) { + testHome(t) + freshTplProbe(t) + oublierDecisionFacade(t) + verdict := `{"ok":true,"result":{"preserves_history":"no","prefix_stable":"no","renders_reasoning":"yes","model":"m1.gguf","tools":2,"at":"2026-10-15T20:00:00.5Z"}}` + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Header.Get(internalTokenHeader) != jetonTest || r.URL.Path != "/internal/facade/v1/chat/tpl" { + w.WriteHeader(404) + return + } + io.WriteString(w, verdict) + })) + defer srv.Close() + t.Setenv(internalTokenEnv, jetonTest) + t.Setenv(facadeAddrEnv, strings.TrimPrefix(srv.URL, "http://")) + t.Setenv(facadeChatEnv, "rust") + r, ok := tplCapsCurrent() + if !ok || r.PrefixStable != tplNo || r.Model != "m1.gguf" || r.Tools != 2 || r.At.IsZero() { + t.Fatalf("verdict de la façade : %+v %v", r, ok) + } + rec := httptest.NewRecorder() + handlePerfSummary(rec, httptest.NewRequest(http.MethodGet, "/api/perf/summary", nil)) + if !strings.Contains(rec.Body.String(), `"prefix_stable":"no"`) { + t.Fatalf("résumé : %s", rec.Body.String()) + } + // Aucun verdict encore, ou façade injoignable : rien. + verdict = `{"ok":false}` + if _, ok := tplCapsCurrent(); ok { + t.Fatal("verdict inventé") + } + t.Setenv(facadeAddrEnv, "127.0.0.1:1") + if _, ok := tplCapsCurrent(); ok { + t.Fatal("verdict sans façade") + } +} diff --git a/internal/loki/parity_tplprobe_test.go b/internal/loki/parity_tplprobe_test.go new file mode 100644 index 0000000..a06735d --- /dev/null +++ b/internal/loki/parity_tplprobe_test.go @@ -0,0 +1,358 @@ +package loki + +import ( + "context" + "encoding/json" + "io" + "net/http" + "net/http/httptest" + "net/url" + "os" + "path/filepath" + "strings" + "sync" + "testing" +) + +// parity_tplprobe_test.go — journal de référence de la sonde de gabarit +// (lot 23 de la migration Rust) : pour chaque comportement de moteur (gabarit +// Qwen3, gabarit qui garde tout, raisonnement jamais rendu, amorce ignorée, +// 404, exception du gabarit, clé exigée, 503 relancés ou persistants, /props +// illisible, moteur pas prêt, réponse sans prompt, rendu incohérent, preset +// externe), chaque échange avec le moteur — méthode, chemin, clé, corps envoyé, +// réponse — puis le verdict, sa ligne de journal, ce qu'en disent +// tplCapsCurrent et tplCapsFor, et le cache (même moteur : pas de nouveau +// rendu ; gabarit changé : nouvelle sonde). Puis le relevé du rendu +// (tplRenderPrint), sa comparaison (tplRenderDiff) et l'empreinte des formes. +// Rangé dans rust/loki-rs/tests/parity_tplprobe/tplprobe.json ; +// tests/tplprobe.rs rejoue chaque échange contre le port Rust. +// +// LOKI_PARITY_UPDATE=1 go test ./internal/loki -run TestPariteSondeGabarit + +const parityTplFile = "../../rust/loki-rs/tests/parity_tplprobe/tplprobe.json" + +// Un échange avec le moteur, tel que le moteur l'a vu. +type parityTplExchange struct { + Method string `json:"method"` + Path string `json:"path"` + Auth string `json:"auth,omitempty"` + Body string `json:"body,omitempty"` + Status int `json:"status"` + Reply string `json:"reply"` +} + +// parityTplEngine : le faux moteur des tests de la sonde (fakeTpl), avec des +// réponses imposées par chemin et le journal des échanges. +type parityTplEngine struct { + fake *fakeTpl + mu sync.Mutex + override map[string][]parityTplExchange // chemin → réponses imposées, consommées dans l'ordre + exchanges []parityTplExchange +} + +func (e *parityTplEngine) ServeHTTP(w http.ResponseWriter, r *http.Request) { + body, _ := io.ReadAll(r.Body) + e.mu.Lock() + var forced *parityTplExchange + if l := e.override[r.URL.Path]; len(l) > 0 { + forced = &l[0] + e.override[r.URL.Path] = l[1:] + } + e.mu.Unlock() + rec := httptest.NewRecorder() + if forced != nil { + rec.WriteHeader(forced.Status) + _, _ = rec.WriteString(forced.Reply) + } else { + r2 := r.Clone(r.Context()) + r2.Body = io.NopCloser(strings.NewReader(string(body))) + e.fake.handler(rec, r2) + } + ex := parityTplExchange{Method: r.Method, Path: r.URL.Path, Auth: r.Header.Get("Authorization"), + Body: string(body), Status: rec.Code, Reply: rec.Body.String()} + e.mu.Lock() + e.exchanges = append(e.exchanges, ex) + e.mu.Unlock() + w.WriteHeader(rec.Code) + _, _ = w.Write(rec.Body.Bytes()) +} + +// parityTplResult : le verdict sans l'instant, plus ce qui n'est pas exporté. +func parityTplResult(r tplProbeResult) map[string]any { + b, _ := json.Marshal(r) + var m map[string]any + _ = json.Unmarshal(b, &m) + delete(m, "at") + m["cacheable"] = r.cacheable + return m +} + +func TestPariteSondeGabarit(t *testing.T) { + type step struct { + Shape string `json:"shape"` // forme rejouée (shapes) + Mode string `json:"mode,omitempty"` + Template string `json:"template,omitempty"` + Build string `json:"build,omitempty"` + Override map[string][]parityTplExchange `json:"override,omitempty"` + Exchanges []parityTplExchange `json:"exchanges"` + Result any `json:"result"` + Line string `json:"line"` + Current any `json:"current"` + For any `json:"for"` + } + type scenario struct { + Name string `json:"name"` + Key string `json:"key,omitempty"` // clé exigée par le moteur + APIKey string `json:"api_key,omitempty"` // clé connue de Loki (state/api_key) + External bool `json:"external,omitempty"` // preset externe actif + Config map[string]string `json:"config,omitempty"` + Steps []step `json:"steps"` + } + shapes := map[string]struct { + Tools string `json:"tools"` + Kwargs map[string]any `json:"kwargs"` + Effort string `json:"effort"` + }{ + "nue": {}, + "outils": {Tools: `[{"type":"function","function":{"name":"bash","description":"d & é","parameters":{"type":"object"}}}]`, Kwargs: map[string]any{"enable_thinking": false}}, + "kwargs": {Kwargs: map[string]any{"enable_thinking": true, "reasoning_effort": "high"}}, + "effort": {Effort: "low"}, + "deux": {Tools: `[{"type":"function","function":{"name":"lire","description":"","parameters":{"type":"object","properties":{"p":{"type":"string"}}}}},{"type":"function","function":{"name":"écrire","description":"x","parameters":null}}]`}, + "kw-vides": {Kwargs: map[string]any{}}, + } + shapeOf := func(name string) tplShape { + s := shapes[name] + var tools []Tool + if s.Tools != "" { + if err := json.Unmarshal([]byte(s.Tools), &tools); err != nil { + t.Fatal(err) + } + } + return newTplShape(tools, s.Kwargs, s.Effort) + } + jsonErr := `{"error":{"code":500,"message":"Jinja Exception: ` + strings.Repeat("é", 200) + `"}}` + scenarios := []scenario{ + {Name: "Qwen3, outils et kwargs", Steps: []step{ + {Shape: "outils", Mode: "qwen3"}, + {Shape: "outils", Mode: "qwen3"}, // en cache : /health et /props seulement + {Shape: "nue", Mode: "qwen3"}, // autre forme : nouvelle sonde + {Shape: "outils", Mode: "qwen3", Template: "qwen3 v2"}, + {Shape: "outils", Mode: "qwen3", Template: "qwen3 v2", Build: "b8000-def"}, + }}, + {Name: "garde tout", Steps: []step{{Shape: "nue", Mode: "keep"}, {Shape: "deux", Mode: "keep"}}}, + {Name: "raisonnement jamais rendu", Steps: []step{{Shape: "kwargs", Mode: "drop"}}}, + {Name: "amorce ignorée", Steps: []step{{Shape: "effort", Mode: "noagp"}}}, + {Name: "route absente", Steps: []step{ + {Shape: "nue", Mode: "keep", Override: map[string][]parityTplExchange{"/apply-template": { + {Status: 404, Reply: "Not Found"}, {Status: 404, Reply: ""}, {Status: 404, Reply: ""}, {Status: 404, Reply: ""}, + }}}, + {Shape: "nue", Mode: "keep"}, // 404 définitif : en cache + }}, + {Name: "exception du gabarit", Steps: []step{{Shape: "nue", Mode: "keep", + Override: map[string][]parityTplExchange{"/apply-template": {{Status: 500, Reply: jsonErr}}}}}}, + {Name: "clé exigée, inconnue", Key: "sk-loki-k", Steps: []step{{Shape: "nue", Mode: "keep"}, {Shape: "nue", Mode: "keep"}}}, + {Name: "clé exigée, connue", Key: "sk-loki-k", APIKey: "sk-loki-k", Steps: []step{{Shape: "nue", Mode: "keep"}}}, + {Name: "503 puis rendu", Steps: []step{{Shape: "nue", Mode: "qwen3", Override: map[string][]parityTplExchange{"/apply-template": { + {Status: 503, Reply: `{"error":{"code":503,"message":"Loading model"}}`}, {Status: 503, Reply: `{"error":{"code":503,"message":"Loading model"}}`}, + }}}}}, + {Name: "503 persistant", Steps: []step{{Shape: "nue", Mode: "qwen3", Override: map[string][]parityTplExchange{"/apply-template": { + {Status: 503, Reply: "x"}, {Status: 503, Reply: "x"}, {Status: 503, Reply: "x"}, + {Status: 503, Reply: "y"}, {Status: 503, Reply: "y"}, {Status: 503, Reply: "y"}, + }}}, {Shape: "nue", Mode: "qwen3"}}}, // non rangé : resondé + {Name: "messages aux bornes", Steps: []step{{Shape: "nue", Mode: "keep", Override: map[string][]parityTplExchange{"/apply-template": { + {Status: 500, Reply: " " + strings.Repeat("à", 160) + " "}, {Status: 500, Reply: strings.Repeat("ù", 161)}, + }}}}}, + {Name: "props de travers", Steps: []step{{Shape: "nue", Mode: "keep", Override: map[string][]parityTplExchange{"/props": {{Status: 200, Reply: `{"build_info":5}`}}}}}}, + {Name: "historique sans la question B", Steps: []step{{Shape: "nue", Mode: "keep", Override: map[string][]parityTplExchange{"/apply-template": { + {Status: 200, Reply: `{"prompt":"` + tplMarkC1 + `"}`}, + {Status: 200, Reply: `{"prompt":"` + tplMarkT1 + `"}`}, + {Status: 200, Reply: `{"prompt":"` + tplMarkT1 + ` amorce"}`}, + {Status: 200, Reply: `{"prompt":"` + tplMarkT1 + ` ` + tplMarkB + `"}`}, + }}}}}, + {Name: "préfixe sans la question B", Steps: []step{{Shape: "nue", Mode: "keep", Override: map[string][]parityTplExchange{"/apply-template": { + {Status: 200, Reply: `{"prompt":"` + tplMarkC1 + ` ` + tplMarkB + `"}`}, + {Status: 200, Reply: `{"prompt":"` + tplMarkT1 + `"}`}, + {Status: 200, Reply: `{"prompt":"` + tplMarkT1 + ` amorce"}`}, + {Status: 200, Reply: `{"prompt":"` + tplMarkT1 + ` suite"}`}, + }}}}}, + {Name: "historique en erreur, outil rendu", Steps: []step{{Shape: "nue", Mode: "keep", Override: map[string][]parityTplExchange{"/apply-template": { + {Status: 500, Reply: "boum"}, + {Status: 200, Reply: `{"prompt":"` + tplMarkT1 + `"}`}, + {Status: 200, Reply: `{"prompt":"` + tplMarkT1 + ` amorce"}`}, + {Status: 200, Reply: `{"prompt":"` + tplMarkT1 + ` ` + tplMarkB + `"}`}, + }}}}}, + {Name: "trop de requêtes", Steps: []step{{Shape: "nue", Mode: "keep", Override: map[string][]parityTplExchange{"/apply-template": { + {Status: 429, Reply: " lent "}, + }}}}}, + {Name: "props illisible", Steps: []step{ + {Shape: "nue", Mode: "keep", Override: map[string][]parityTplExchange{"/props": {{Status: 200, Reply: ""}}}}, + {Shape: "nue", Mode: "keep", Override: map[string][]parityTplExchange{"/props": {{Status: 500, Reply: "panne"}}}}, + }}, + {Name: "moteur pas prêt", Steps: []step{{Shape: "outils", Mode: "keep", Override: map[string][]parityTplExchange{"/health": {{Status: 503, Reply: `{"status":"loading"}`}}}}}}, + {Name: "réponse sans prompt", Steps: []step{{Shape: "nue", Mode: "keep", Override: map[string][]parityTplExchange{"/apply-template": { + {Status: 200, Reply: `{}`}, {Status: 200, Reply: `{"prompt":null}`}, {Status: 200, Reply: `pas du json`}, + }}}}}, + {Name: "rendu incohérent", Steps: []step{{Shape: "nue", Mode: "keep", Override: map[string][]parityTplExchange{"/apply-template": { + {Status: 200, Reply: `{"prompt":"x"}`}, {Status: 200, Reply: `{"prompt":"y"}`}, {Status: 200, Reply: `{"prompt":"z"}`}, {Status: 200, Reply: `{"prompt":"yz"}`}, + }}}}}, + {Name: "préfixe incohérent", Steps: []step{{Shape: "nue", Mode: "keep", Override: map[string][]parityTplExchange{"/apply-template": { + {Status: 200, Reply: `{"prompt":"x"}`}, {Status: 200, Reply: `{"prompt":"y"}`}, {Status: 200, Reply: `{"prompt":"z"}`}, {Status: 200, Reply: `{"prompt":"w"}`}, + }}}}}, + {Name: "preset externe", External: true, Steps: []step{{Shape: "nue", Mode: "keep"}}}, + } + + for i := range scenarios { + sc := &scenarios[i] + t.Run(sc.Name, func(t *testing.T) { + testHome(t) + freshTplProbe(t) + eng := &parityTplEngine{fake: &fakeTpl{key: sc.Key}, override: map[string][]parityTplExchange{}} + srv := httptest.NewServer(eng) + defer srv.Close() + u, _ := url.Parse(srv.URL) + if sc.External { + if err := WriteConfig(parseEnv(externalPresetContent("http://127.0.0.1:1", "gpt-4o-mini", "", "", false))); err != nil { + t.Fatal(err) + } + sc.Config = ReadConfig() + } + if err := SetConfigKey("PORT", u.Port()); err != nil { + t.Fatal(err) + } + if sc.APIKey != "" { + if err := writeAPIKey(sc.APIKey); err != nil { + t.Fatal(err) + } + } + for j := range sc.Steps { + st := &sc.Steps[j] + eng.mu.Lock() + eng.fake.mode, eng.fake.tpl, eng.fake.build = st.Mode, st.Template, st.Build + for p, l := range st.Override { + eng.override[p] = append([]parityTplExchange(nil), l...) + } + eng.exchanges = nil + eng.mu.Unlock() + r := tplProbeEnsure(context.Background(), shapeOf(st.Shape)) + eng.mu.Lock() + st.Exchanges = append([]parityTplExchange(nil), eng.exchanges...) + eng.override = map[string][]parityTplExchange{} + eng.mu.Unlock() + st.Result = parityTplResult(r) + st.Line = tplProbeLine(r) + if c, ok := tplCapsCurrent(); ok { + st.Current = parityTplResult(c) + } + if c, ok := tplCapsFor(shapeOf(st.Shape)); ok { + st.For = parityTplResult(c) + } + } + if sc.External { + delete(sc.Config, "PORT") + } + }) + } + + // --- relevé du rendu et comparaison --- + type printCase struct { + Shape string `json:"shape"` + Mode string `json:"mode"` + Override map[string][]parityTplExchange `json:"override,omitempty"` + Exchanges []parityTplExchange `json:"exchanges"` + Out string `json:"out"` + Err string `json:"err"` + } + prints := []printCase{ + {Shape: "nue", Mode: "qwen3"}, + {Shape: "outils", Mode: "keep"}, + {Shape: "kwargs", Mode: "drop"}, + {Shape: "nue", Mode: "keep", Override: map[string][]parityTplExchange{"/apply-template": {{Status: 200, Reply: `{"prompt":"a"}`}, {Status: 500, Reply: "boum"}}}}, + } + func() { + testHome(t) + freshTplProbe(t) + eng := &parityTplEngine{fake: &fakeTpl{}, override: map[string][]parityTplExchange{}} + srv := httptest.NewServer(eng) + defer srv.Close() + u, _ := url.Parse(srv.URL) + if err := SetConfigKey("PORT", u.Port()); err != nil { + t.Fatal(err) + } + for i := range prints { + pc := &prints[i] + eng.mu.Lock() + eng.fake.mode = pc.Mode + for p, l := range pc.Override { + eng.override[p] = append([]parityTplExchange(nil), l...) + } + eng.exchanges = nil + eng.mu.Unlock() + out, err := tplRenderPrint(context.Background(), tplLocalProber(), shapeOf(pc.Shape)) + pc.Out = out + if err != nil { + pc.Err = err.Error() + } + eng.mu.Lock() + pc.Exchanges = append([]parityTplExchange(nil), eng.exchanges...) + eng.override = map[string][]parityTplExchange{} + eng.mu.Unlock() + } + }() + type diffCase struct { + Before string `json:"before"` + After string `json:"after"` + Same bool `json:"same"` + Detail string `json:"detail"` + } + long := strings.Repeat("abcdéfgh\n", 30) + var diffs []diffCase + for _, c := range [][2]string{ + {prints[0].Out, prints[0].Out}, + {prints[0].Out, prints[1].Out}, + {prints[1].Out, prints[2].Out}, + {"", "x"}, + {"x", ""}, + {"abc", "abd"}, + {long + "X" + long, long + "Y" + long}, + {tplRenderSep + "section ---\n" + "ééé" + long, tplRenderSep + "section ---\n" + "éèé" + long}, + {"sans\x00section", "sans\x00sectioN"}, + } { + same, d := tplRenderDiff(c[0], c[1]) + diffs = append(diffs, diffCase{Before: c[0], After: c[1], Same: same, Detail: d}) + } + + // --- empreinte des formes --- + hashes := map[string]string{} + for name := range shapes { + hashes[name] = shapeOf(name).hash() + } + + got, err := parityJSON(map[string]any{ + "shapes": shapes, + "scenarios": scenarios, + "prints": prints, + "diffs": diffs, + "hashes": hashes, + }) + if err != nil { + t.Fatal(err) + } + if os.Getenv("LOKI_PARITY_UPDATE") == "1" { + if err := os.MkdirAll(filepath.Dir(parityTplFile), 0o755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(parityTplFile, got, 0o644); err != nil { + t.Fatal(err) + } + return + } + want, err := os.ReadFile(parityTplFile) + 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("la sonde de gabarit ne produit plus le journal rangé dans %s.\n"+ + "Si c'est voulu : LOKI_PARITY_UPDATE=1 go test ./internal/loki -run TestPariteSondeGabarit,\n"+ + "puis cargo test --test tplprobe dit si le port Rust suit.\n--- obtenu ---\n%s", parityTplFile, got) + } +} diff --git a/rust/loki-rs/src/chat/generate.rs b/rust/loki-rs/src/chat/generate.rs index a81882f..5a48246 100644 --- a/rust/loki-rs/src/chat/generate.rs +++ b/rust/loki-rs/src/chat/generate.rs @@ -494,7 +494,12 @@ impl ChatService { let cfg = store.config().await; let local_key = store.get_str(BK_STATE, "api_key").await; let ep = resolve_chat_endpoint(&cfg, &local_key); - let echo = echo_policy_for(&cfg, &self.shared.echo_refusals, None); + let verdict = self + .shared + .tpl + .caps_current(ep.external) + .map(|r| r.verdict()); + let echo = echo_policy_for(&cfg, &self.shared.echo_refusals, verdict.as_ref()); let ccfg = CompactConfig::from_config(&cfg, echo.clone()); let port = llm_port(&cfg); let summarizer = EngineSummarizer { @@ -1062,6 +1067,7 @@ impl ChatShared { compact_memory: super::compact::CompactMemory::new(), images: super::host::ImageCache::new(img_dir), patterns: super::stream::RetryPatterns::builtin(), + tpl: std::sync::Arc::new(super::tplprobe::TplProbe::new()), } } } diff --git a/rust/loki-rs/src/chat/host.rs b/rust/loki-rs/src/chat/host.rs index 0a78b28..337ed94 100644 --- a/rust/loki-rs/src/chat/host.rs +++ b/rust/loki-rs/src/chat/host.rs @@ -39,7 +39,7 @@ use super::wire::{ ChatEndpoint, ChatPayloadOpts, EchoRefusals, EffortFallbacks, IMG_REF_SCHEME, build_chat_payload, content_parts, part_image_url, wire_messages, }; -use crate::store::Store; +use crate::store::{BK_STATE, Store}; /// Taille au-delà de laquelle le cache d'images oublie les plus anciennes /// (`imgCacheMax`). @@ -284,6 +284,8 @@ pub struct ChatShared { pub compact_memory: CompactMemory, pub images: ImageCache, pub patterns: Arc, + /// Sonde de gabarit (`chat_tplprobe.go`) : derniers verdicts et cache. + pub tpl: Arc, } /// Ce que la compaction sait du tour. @@ -490,6 +492,39 @@ impl TurnHost for GoTurnHost { (self.queued)() } + fn prefix_unstable( + &self, + tools: &[Tool], + kwargs: Option<&Map>, + effort: &str, + ) -> bool { + let shape = super::tplprobe::Shape::new(tools, kwargs, effort); + self.shared.tpl.caps_for(false, &shape).is_some_and(|r| { + r.prefix_stable == super::wire::TplTri::No + && !r.model.is_empty() + && r.model == super::wire::reasoning_echo_model(&self.cfg) + }) + } + + /// Fil principal seulement (`perfMain`) : une tâche ou un sous-agent ne + /// déclenche pas la sonde. + fn completed(&self, tools: &[Tool], kwargs: Option<&Map>, effort: &str) { + if self.compact.main_conv.is_empty() { + return; + } + let shape = super::tplprobe::Shape::new(tools, kwargs, effort); + let (shared, port) = (self.shared.clone(), self.port); + tokio::spawn(async move { + let key = shared.store.get_str(BK_STATE, "api_key").await; + let env = super::tplprobe::Env { + external: false, + port: port.into(), + key, + }; + shared.tpl.kick(env, shape); + }); + } + async fn prepare_images(&self, msgs: &[Message]) { self.shared.images.prefetch(&self.shared.store, msgs).await; } diff --git a/rust/loki-rs/src/chat/mod.rs b/rust/loki-rs/src/chat/mod.rs index 3a6725c..e2edcdc 100644 --- a/rust/loki-rs/src/chat/mod.rs +++ b/rust/loki-rs/src/chat/mod.rs @@ -17,7 +17,8 @@ //! - [`turn`] : la boucle d'un tour (complétions, outils, filets) ; //! - [`host`] : le tour branché sur le moteur, les outils du Go et la base ; //! - [`history`] : début et fin d'un tour côté historique ; -//! - [`generate`] : un tour de bout en bout (envoi, file, fin de tour). +//! - [`generate`] : un tour de bout en bout (envoi, file, fin de tour) ; +//! - [`tplprobe`] : sonde de gabarit (rendus de `/apply-template`). pub mod compact; pub mod conv; @@ -31,6 +32,7 @@ pub mod nudges; pub mod persist; pub mod sessions; pub mod stream; +pub mod tplprobe; pub mod turn; pub mod types; pub mod verify; diff --git a/rust/loki-rs/src/chat/nudges.rs b/rust/loki-rs/src/chat/nudges.rs index 21248d7..9f59135 100644 --- a/rust/loki-rs/src/chat/nudges.rs +++ b/rust/loki-rs/src/chat/nudges.rs @@ -75,6 +75,45 @@ pub fn append_nudge(messages: &mut Vec, extra: &mut Vec, text: } } +/// La clé `NUDGE_IN_TOOL` est-elle posée (`nudgeInToolOn`, sans la sonde) ? +pub fn nudge_in_tool_key(cfg: &std::collections::HashMap) -> bool { + matches!( + cfg.get("NUDGE_IN_TOOL") + .map(|v| v.trim().to_lowercase()) + .as_deref(), + Some("on" | "1" | "true" | "yes" | "oui") + ) +} + +/// `nudgeIntoTool` : le rappel au bout du dernier résultat d'outil, dans la +/// vue du modèle ET dans l'historique, à l'identique. `false` : dernier +/// message d'une autre forme, ou historique réécrit entre-temps — l'appelant +/// passe alors par [`append_nudge`]. +pub fn nudge_into_tool(messages: &mut [Message], extra: &mut [Message], text: &str) -> bool { + let (Some(last), Some(kept)) = (messages.last(), extra.last()) else { + return false; + }; + let (Some(Value::String(s)), Some(Value::String(ks))) = (&last.content, &kept.content) else { + return false; + }; + if s != ks + || last.role != "tool" + || kept.role != "tool" + || last.tool_call_id != kept.tool_call_id + { + return false; + } + let mut m = last.clone(); + m.content = Some(Value::String(format!("{s}\n\n{text}"))); + if let Some(l) = extra.last_mut() { + *l = m.clone(); + } + if let Some(l) = messages.last_mut() { + *l = m; + } + true +} + /// `steerSystem` : la consigne s'ajoute au système de tête (texte), ou /// devient un système en tête — jamais un système en fin de fil, que les /// gabarits stricts refusent. @@ -224,4 +263,54 @@ mod tests { (TOOL_PREVIEW_CHARS, TOOL_PREVIEW_CHARS + 5, true) ); } + + #[test] + fn rappel_dans_le_resultat_d_outil() { + let tm = |c: &str| Message { + role: "tool".into(), + content: Some(Value::String(c.into())), + tool_call_id: "t1".into(), + ..Default::default() + }; + let um = |c: &str| Message { + role: "user".into(), + content: Some(Value::String(c.into())), + ..Default::default() + }; + let mut msgs = vec![um("q"), tm("res")]; + let mut extra = vec![tm("res")]; + assert!(nudge_into_tool(&mut msgs, &mut extra, "[system] stop")); + assert_eq!( + msgs[1].content, + Some(Value::String("res\n\n[system] stop".into())) + ); + assert_eq!(msgs[1], extra[0]); + // Dernier message d'une autre forme, ou historique réécrit. + let mut m2 = vec![tm("res"), um("img")]; + assert!(!nudge_into_tool(&mut m2, &mut [tm("res")], "x")); + assert!(!nudge_into_tool(&mut [tm("res")], &mut [], "x")); + assert!(!nudge_into_tool(&mut [tm("res")], &mut [tm("autre")], "x")); + let mut other_id = tm("res"); + other_id.tool_call_id = "t2".into(); + assert!(!nudge_into_tool(&mut [tm("res")], &mut [other_id], "x")); + let mut empty = tm(""); + empty.content = None; + assert!(!nudge_into_tool(&mut [empty.clone()], &mut [empty], "x")); + } + + #[test] + fn cle_nudge_in_tool() { + for (v, on) in [ + ("on", true), + (" TRUE ", true), + ("oui", true), + ("off", false), + ("", false), + ] { + let cfg = + std::collections::HashMap::from([("NUDGE_IN_TOOL".to_string(), v.to_string())]); + assert_eq!(nudge_in_tool_key(&cfg), on, "{v}"); + } + assert!(!nudge_in_tool_key(&std::collections::HashMap::new())); + } } diff --git a/rust/loki-rs/src/chat/tplprobe.rs b/rust/loki-rs/src/chat/tplprobe.rs new file mode 100644 index 0000000..86f5cd5 --- /dev/null +++ b/rust/loki-rs/src/chat/tplprobe.rs @@ -0,0 +1,915 @@ +//! Sonde de gabarit (lot 23 de la migration) — port de `chat_tplprobe.go`. +//! +//! Que fait le gabarit de chat du modèle chargé de ce qu'on lui envoie ? +//! - `preserves_history` : le raisonnement d'un tour PASSÉ est-il rendu ? +//! - `prefix_stable` : après un tour d'outil, le rendu précédent reste-t-il un +//! préfixe exact du suivant ? +//! - `renders_reasoning` : `reasoning_content` est-il rendu quelque part ? +//! +//! Mêmes règles de conduite que le Go : moteur local seulement, rien d'autre +//! que `POST /apply-template` (sans état, sans slot), lancée après une +//! complétion terminée, avec les outils et `chat_template_kwargs` de cette +//! complétion ; tout échec donne « unknown ». +//! +//! Le relevé du rendu (`tplRenderPrint`) et sa comparaison (`tplRenderDiff`) +//! servent la vérification autour d'une mise à jour du moteur. + +use std::collections::HashMap; +use std::sync::Mutex; +use std::time::{Duration, Instant, SystemTime, UNIX_EPOCH}; + +use serde::Serialize; +use serde_json::value::RawValue; +use serde_json::{Map, Value, json}; + +use super::types::{Tool, ToolCall, ToolCallFunc}; +use super::wire::TplTri; +use crate::gojson; + +/// Délai de chaque requête (`tplProbeTimeout`). +pub const PROBE_TIMEOUT: Duration = Duration::from_secs(3); +/// Au plus une vérification par minute (`tplProbeEvery`). +pub const PROBE_EVERY: Duration = Duration::from_secs(60); +const CACHE_MAX: usize = 16; +const TRIES_503: u32 = 3; +const BODY_MAX: usize = 8 << 20; + +const MARK_SYS: &str = "loki-sonde-systeme"; +const MARK_A: &str = "loki-sonde-question-a"; +const MARK_R1: &str = "loki-sonde-raisonnement-r1"; +const MARK_C1: &str = "loki-sonde-reponse-c1"; +const MARK_T1: &str = "loki-sonde-resultat-t1"; +const MARK_B: &str = "loki-sonde-question-b"; +const CALL_ID: &str = "a1B2c3D4e"; + +/// `tplRenderSep`. +pub const RENDER_SEP: &str = "\n\x00--- "; + +impl TplTri { + pub fn as_str(self) -> &'static str { + match self { + TplTri::Yes => "yes", + TplTri::No => "no", + TplTri::Unknown => "unknown", + } + } +} + +/// `tplProbeResult`. +#[derive(Debug, Clone, PartialEq)] +pub struct ProbeResult { + pub preserves_history: TplTri, + pub prefix_stable: TplTri, + pub renders_reasoning: TplTri, + pub build: String, + pub model: String, + pub template_hash: String, + pub tools: usize, + pub kwargs: Option>, + pub effort: String, + pub note: String, + pub at: SystemTime, + /// La réponse tient tant que moteur, modèle et gabarit restent les mêmes. + pub cacheable: bool, +} + +impl ProbeResult { + /// `tplUnknownResult`. + pub fn unknown(note: &str) -> Self { + ProbeResult { + preserves_history: TplTri::Unknown, + prefix_stable: TplTri::Unknown, + renders_reasoning: TplTri::Unknown, + build: String::new(), + model: String::new(), + template_hash: String::new(), + tools: 0, + kwargs: None, + effort: String::new(), + note: note.into(), + at: SystemTime::now(), + cacheable: false, + } + } + + /// Le JSON du Go (champs dans l'ordre de la struct, `omitempty` compris). + pub fn to_json(&self) -> Value { + let mut m = Map::new(); + m.insert( + "preserves_history".into(), + json!(self.preserves_history.as_str()), + ); + m.insert("prefix_stable".into(), json!(self.prefix_stable.as_str())); + m.insert( + "renders_reasoning".into(), + json!(self.renders_reasoning.as_str()), + ); + for (k, v) in [ + ("build", &self.build), + ("model", &self.model), + ("template_hash", &self.template_hash), + ] { + if !v.is_empty() { + m.insert(k.into(), json!(v)); + } + } + m.insert("tools".into(), json!(self.tools)); + if let Some(k) = self.kwargs.as_ref().filter(|k| !k.is_empty()) { + m.insert("chat_template_kwargs".into(), Value::Object(k.clone())); + } + if !self.effort.is_empty() { + m.insert("reasoning_effort".into(), json!(self.effort)); + } + if !self.note.is_empty() { + m.insert("note".into(), json!(self.note)); + } + m.insert("at".into(), json!(rfc3339(self.at))); + Value::Object(m) + } + + /// Le sous-ensemble que lit la politique d'écho. + pub fn verdict(&self) -> super::wire::TplVerdict { + super::wire::TplVerdict { + model: self.model.clone(), + renders_reasoning: self.renders_reasoning, + preserves_history: self.preserves_history, + } + } +} + +/// Instant au format RFC 3339 (UTC, nanosecondes), comme `time.Time`. +pub fn rfc3339(t: SystemTime) -> String { + let d = t.duration_since(UNIX_EPOCH).unwrap_or_default(); + let secs = d.as_secs() as i64; + let (days, rem) = (secs.div_euclid(86_400), secs.rem_euclid(86_400)); + // Jours depuis 1970 → date civile (algorithme de Howard Hinnant). + let z = days + 719_468; + let era = z.div_euclid(146_097); + let doe = z - era * 146_097; + let yoe = (doe - doe / 1460 + doe / 36_524 - doe / 146_096) / 365; + let doy = doe - (365 * yoe + yoe / 4 - yoe / 100); + let mp = (5 * doy + 2) / 153; + let day = doy - (153 * mp + 2) / 5 + 1; + let month = if mp < 10 { mp + 3 } else { mp - 9 }; + let year = yoe + era * 400 + i64::from(month <= 2); + let mut s = format!( + "{year:04}-{month:02}-{day:02}T{:02}:{:02}:{:02}", + rem / 3600, + rem % 3600 / 60, + rem % 60 + ); + let ns = d.subsec_nanos(); + if ns > 0 { + let frac = format!("{ns:09}"); + s.push('.'); + s.push_str(frac.trim_end_matches('0')); + } + s.push('Z'); + s +} + +/// `tplShape` : la forme de la requête à reproduire. +#[derive(Debug, Clone, PartialEq, Default)] +pub struct Shape { + /// Outils figés en JSON ; `None` = aucun outil envoyé. + pub tools: Option, + pub n_tools: usize, + pub first: String, + pub kwargs: Option>, + pub effort: String, +} + +impl Shape { + /// `newTplShape`. + pub fn new(tools: &[Tool], kwargs: Option<&Map>, effort: &str) -> Self { + let mut s = Shape { + n_tools: tools.len(), + effort: effort.into(), + ..Shape::default() + }; + if !tools.is_empty() { + s.tools = Some(gojson::marshal(tools)); + s.first = tools[0].function.name.clone(); + } + s.kwargs = kwargs.cloned(); + s + } + + /// `tplShape.hash` : FNV-64a des outils, des kwargs et de l'effort. + pub fn hash(&self) -> String { + let mut h = Fnv::new(); + if let Some(t) = &self.tools { + h.write(t.as_bytes()); + } + h.write(&[0]); + let kw = match &self.kwargs { + Some(k) => gojson::marshal(&Value::Object(k.clone())), + None => "null".into(), + }; + h.write(kw.as_bytes()); + h.write(&[0]); + h.write(self.effort.as_bytes()); + format!("{:016x}", h.0) + } +} + +/// FNV-1a 64 bits (`hash/fnv.New64a`). +struct Fnv(u64); + +impl Fnv { + fn new() -> Self { + Fnv(0xcbf2_9ce4_8422_2325) + } + fn write(&mut self, b: &[u8]) { + for &c in b { + self.0 ^= u64::from(c); + self.0 = self.0.wrapping_mul(0x0000_0100_0000_01b3); + } + } +} + +fn fnv_hex(b: &[u8]) -> String { + let mut h = Fnv::new(); + h.write(b); + format!("{:016x}", h.0) +} + +/// `tplMsg`. +#[derive(Serialize, Clone)] +struct Msg { + role: &'static str, + content: &'static str, + #[serde(skip_serializing_if = "str::is_empty")] + reasoning_content: &'static str, + #[serde(skip_serializing_if = "Vec::is_empty")] + tool_calls: Vec, + #[serde(skip_serializing_if = "str::is_empty")] + tool_call_id: &'static str, +} + +fn msg(role: &'static str, content: &'static str) -> Msg { + Msg { + role, + content, + reasoning_content: "", + tool_calls: Vec::new(), + tool_call_id: "", + } +} + +fn call(name: &str) -> ToolCall { + ToolCall { + index: None, + id: CALL_ID.into(), + kind: "function".into(), + function: ToolCallFunc { + name: name.into(), + arguments: "{}".into(), + }, + } +} + +/// Réponse du moteur : non-200 (`tplHTTPError`) ou autre échec. +#[derive(Debug, Clone, PartialEq)] +pub enum ProbeError { + Http { status: u16, body: String }, + Other(String), +} + +impl std::fmt::Display for ProbeError { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + ProbeError::Http { status, body } if !body.is_empty() => { + write!(f, "HTTP {status} : {body}") + } + ProbeError::Http { status, .. } => write!(f, "HTTP {status}"), + ProbeError::Other(e) => f.write_str(e), + } + } +} + +impl ProbeError { + /// `tplDefinitive` : l'échec se reproduira à l'identique. + fn definitive(&self) -> bool { + match self { + ProbeError::Http { status, .. } => { + !matches!(status, 401 | 403 | 503 | 429) && (400..600).contains(status) + } + ProbeError::Other(_) => false, + } + } +} + +/// `/props` : ce que la sonde en retient. +#[derive(Debug, Clone, Default, PartialEq)] +pub struct Props { + pub build_info: String, + pub model_path: String, + pub chat_template: String, +} + +/// `tplProber` : le moteur local. +#[derive(Clone)] +pub struct Prober { + pub base: String, + /// Clé Bearer du moteur ("" = aucune). + pub key: String, + pub client: reqwest::Client, + /// Attente entre deux essais sur un 503 (`tplProbeBackoff`). + pub backoff: Duration, +} + +impl Prober { + /// `tplLocalProber`. + pub fn local(port: i64, key: &str, backoff: Duration) -> Self { + crate::chat::engine::install_crypto(); + Prober { + base: format!("http://localhost:{port}"), + key: key.into(), + client: reqwest::Client::builder() + .timeout(PROBE_TIMEOUT) + .build() + .unwrap_or_default(), + backoff, + } + } + + async fn call( + &self, + method: reqwest::Method, + path: &str, + body: Option>, + ) -> Result, ProbeError> { + let mut rq = self + .client + .request(method, format!("{}{path}", self.base)) + .timeout(PROBE_TIMEOUT); + if let Some(b) = body { + rq = rq.header("Content-Type", "application/json").body(b); + } + if !self.key.is_empty() { + rq = rq.header("Authorization", format!("Bearer {}", self.key)); + } + let mut resp = rq + .send() + .await + .map_err(|e| ProbeError::Other(e.to_string()))?; + let status = resp.status().as_u16(); + let mut data = Vec::new(); + while data.len() < BODY_MAX { + match resp.chunk().await { + Ok(Some(c)) => data.extend_from_slice(&c), + Ok(None) => break, + Err(e) => return Err(ProbeError::Other(e.to_string())), + } + } + data.truncate(BODY_MAX); + if status != 200 { + let msg = String::from_utf8_lossy(&data).trim().to_string(); + let msg = if msg.chars().count() > 160 { + format!("{}…", msg.chars().take(160).collect::()) + } else { + msg + }; + return Err(ProbeError::Http { status, body: msg }); + } + Ok(data) + } + + /// `/health` à 200 ? + pub async fn healthy(&self) -> bool { + self.call(reqwest::Method::GET, "/health", None) + .await + .is_ok() + } + + /// `/props`. + pub async fn props(&self) -> Result { + let data = self.call(reqwest::Method::GET, "/props", None).await?; + let bad = || ProbeError::Other("réponse non JSON".into()); + let v: Value = serde_json::from_slice(&data).map_err(|_| bad())?; + let mut p = Props::default(); + match v { + Value::Null => return Ok(p), + Value::Object(m) => { + for (k, dst) in [ + ("build_info", &mut p.build_info), + ("model_path", &mut p.model_path), + ("chat_template", &mut p.chat_template), + ] { + match m.get(k) { + None | Some(Value::Null) => {} + Some(Value::String(s)) => *dst = s.clone(), + Some(_) => return Err(bad()), + } + } + } + _ => return Err(bad()), + } + Ok(p) + } + + /// `render` : POST /apply-template, relancé sur 503. + async fn render( + &self, + shape: &Shape, + msgs: &[Msg], + gen_prompt: bool, + ) -> Result { + #[derive(Serialize)] + struct Body<'a> { + add_generation_prompt: bool, + #[serde(skip_serializing_if = "Option::is_none")] + chat_template_kwargs: Option<&'a Map>, + messages: &'a [Msg], + #[serde(skip_serializing_if = "Option::is_none")] + parallel_tool_calls: Option, + #[serde(skip_serializing_if = "Option::is_none")] + reasoning_effort: Option<&'a str>, + #[serde(skip_serializing_if = "Option::is_none")] + tools: Option<&'a RawValue>, + } + let raw_tools = shape + .tools + .as_deref() + .and_then(|t| serde_json::from_str::<&RawValue>(t).ok()); + let raw = gojson::marshal(&Body { + add_generation_prompt: gen_prompt, + chat_template_kwargs: shape.kwargs.as_ref(), + messages: msgs, + parallel_tool_calls: raw_tools.map(|_| false), + reasoning_effort: (!shape.effort.is_empty()).then_some(shape.effort.as_str()), + tools: raw_tools, + }); + let mut tries = 1; + loop { + match self + .call( + reqwest::Method::POST, + "/apply-template", + Some(raw.clone().into_bytes()), + ) + .await + { + Err(ProbeError::Http { status: 503, .. }) if tries < TRIES_503 => { + tokio::time::sleep(self.backoff * tries).await; + tries += 1; + } + Err(e) => return Err(e), + Ok(data) => { + let prompt = serde_json::from_slice::(&data) + .ok() + .and_then(|v| v.get("prompt").cloned()); + return match prompt { + Some(Value::String(p)) => Ok(p), + _ => Err(ProbeError::Http { + status: 200, + body: "réponse sans prompt".into(), + }), + }; + } + } + } + } + + /// `probe` : les rendus et leurs verdicts. + pub async fn probe(&self, shape: &Shape) -> ProbeResult { + let mut r = ProbeResult::unknown(""); + let mut definitive = true; + let mut notes: Vec = Vec::new(); + let mut fail = |what: &str, e: &ProbeError, notes: &mut Vec| { + if !e.definitive() { + definitive = false; + } + notes.push(format!("{what} : {e}")); + }; + let sys = msg("system", MARK_SYS); + let user_a = msg("user", MARK_A); + let user_b = msg("user", MARK_B); + let mut past = msg("assistant", MARK_C1); + past.reasoning_content = MARK_R1; + + // 1) Raisonnement d'un tour passé. + match self + .render( + shape, + &[sys.clone(), user_a.clone(), past, user_b.clone()], + false, + ) + .await + { + Err(e) => fail("historique", &e, &mut notes), + Ok(h) if !h.contains(MARK_C1) || !h.contains(MARK_B) => { + notes.push("historique : rendu incohérent".into()); + } + Ok(h) if h.contains(MARK_R1) => r.preserves_history = TplTri::Yes, + Ok(_) => r.preserves_history = TplTri::No, + } + + // 2) Stabilité du préfixe à travers un tour d'outil. + let name = if shape.first.is_empty() { + "lookup" + } else { + &shape.first + }; + let mut c = msg("assistant", MARK_C1); + c.reasoning_content = MARK_R1; + c.tool_calls = vec![call(name)]; + let mut result = msg("tool", MARK_T1); + result.tool_call_id = CALL_ID; + let before = vec![sys, user_a, c, result]; + let mut after = before.clone(); + after.push(user_b); + + let r1 = self.render(shape, &before, false).await; + let mut r1g: Result = Err(ProbeError::Other(String::new())); + let mut r2: Result = Err(ProbeError::Other(String::new())); + if r1.is_ok() { + r1g = self.render(shape, &before, true).await; + } + if r1.is_ok() && r1g.is_ok() { + r2 = self.render(shape, &after, false).await; + } + match (&r1, &r1g, &r2) { + (Err(e), _, _) | (Ok(_), Err(e), _) | (Ok(_), Ok(_), Err(e)) => { + fail("préfixe", e, &mut notes) + } + (Ok(a), Ok(g), _) if a == g => { + notes.push("add_generation_prompt ignoré par le moteur".into()) + } + (Ok(a), Ok(_), Ok(b)) if !a.contains(MARK_T1) || !b.contains(MARK_B) => { + notes.push("préfixe : rendu incohérent".into()) + } + (Ok(a), Ok(_), Ok(b)) if b.starts_with(a.as_str()) => r.prefix_stable = TplTri::Yes, + _ => r.prefix_stable = TplTri::No, + } + let r1_has = |m: &str| r1.as_ref().is_ok_and(|s| s.contains(m)); + if r.preserves_history == TplTri::Yes || r1_has(MARK_R1) { + r.renders_reasoning = TplTri::Yes; + } else if r1_has(MARK_T1) && r.preserves_history == TplTri::No { + r.renders_reasoning = TplTri::No; + notes.push("reasoning_content jamais rendu".into()); + } + r.note = notes.join(" ; "); + r.cacheable = definitive; + r + } + + /// `tplRenderPrint` : les rendus concaténés de trois conversations + /// synthétiques sur la forme donnée. + pub async fn render_print(&self, shape: &Shape) -> Result { + let sys = msg("system", MARK_SYS); + let user_a = msg("user", MARK_A); + let user_b = msg("user", MARK_B); + let name = if shape.first.is_empty() { + "lookup" + } else { + &shape.first + }; + let mut with_r = msg("assistant", MARK_C1); + with_r.reasoning_content = MARK_R1; + let mut c = msg("assistant", MARK_C1); + c.tool_calls = vec![call(name)]; + let mut result = msg("tool", MARK_T1); + result.tool_call_id = CALL_ID; + let convs: [(&str, Vec); 3] = [ + ( + "historique sans raisonnement", + vec![ + sys.clone(), + user_a.clone(), + msg("assistant", MARK_C1), + user_b.clone(), + ], + ), + ( + "historique avec raisonnement", + vec![sys.clone(), user_a.clone(), with_r, user_b.clone()], + ), + ("tour d'outil", vec![sys, user_a, c, result, user_b]), + ]; + let mut b = String::new(); + for (name, msgs) in convs { + let out = self + .render(shape, &msgs, true) + .await + .map_err(|e| format!("{name} : {e}"))?; + b.push_str(&format!("{RENDER_SEP}{name} ---\n")); + b.push_str(&out); + } + Ok(b) + } +} + +/// `tplRenderDiff` : identiques → (true, "") ; sinon la conversation où les +/// relevés divergent et un extrait de part et d'autre du premier écart. +pub fn render_diff(before: &str, after: &str) -> (bool, String) { + if before == after { + return (true, String::new()); + } + let (bb, ab) = (before.as_bytes(), after.as_bytes()); + let mut i = 0; + while i < bb.len() && i < ab.len() && bb[i] == ab[i] { + i += 1; + } + let mut section = String::new(); + if let Some(k) = find_last(&bb[..i], RENDER_SEP.as_bytes()) { + let rest = &bb[k + RENDER_SEP.len()..]; + if let Some(e) = find(rest, b" ---") { + section = String::from_utf8_lossy(&rest[..e]).into_owned(); + } + } + let excerpt = |s: &[u8]| -> String { + let mut from = i.saturating_sub(40); + let mut to = (i + 60).min(s.len()); + while from > 0 && !rune_start(s[from]) { + from -= 1; + } + while to < s.len() && !rune_start(s[to]) { + to += 1; + } + let mut x = String::from_utf8_lossy(&s[from..to]) + .replace('\n', "⏎") + .replace('\x00', ""); + if from > 0 { + x = format!("…{x}"); + } + if to < s.len() { + x.push('…'); + } + x + }; + let mut d = format!("avant « {} » / après « {} »", excerpt(bb), excerpt(ab)); + if !section.is_empty() { + d = format!("{section} : {d}"); + } + (false, d) +} + +fn rune_start(b: u8) -> bool { + b & 0xC0 != 0x80 +} + +fn find(h: &[u8], n: &[u8]) -> Option { + h.windows(n.len()).position(|w| w == n) +} + +fn find_last(h: &[u8], n: &[u8]) -> Option { + if h.len() < n.len() { + return None; + } + (0..=h.len() - n.len()) + .rev() + .find(|&i| &h[i..i + n.len()] == n) +} + +/// `tplProbeLine`. +pub fn probe_line(r: &ProbeResult) -> String { + let dash = |s: &str| { + if s.is_empty() { + "-".to_string() + } else { + s.to_string() + } + }; + let mut s = format!( + "[tplprobe] build={} modèle={} gabarit={} outils={} historique_raisonnement={} préfixe_stable={} raisonnement_rendu={}", + dash(&r.build), + dash(&r.model), + dash(&r.template_hash), + r.tools, + r.preserves_history.as_str(), + r.prefix_stable.as_str(), + r.renders_reasoning.as_str() + ); + if !r.note.is_empty() { + s.push_str(&format!(" ({})", r.note)); + } + s +} + +/// Où sonder : le moteur local, sa clé, et si un preset externe est actif. +#[derive(Debug, Clone, Default)] +pub struct Env { + pub external: bool, + pub port: i64, + pub key: String, +} + +#[derive(Default)] +struct State { + cache: HashMap, + last: Option, + by_shape: HashMap, + running: bool, + tried: Option, + logged: String, + shape_last: Option, +} + +/// La sonde : son cache et ses derniers verdicts (l'état global du Go). +pub struct TplProbe { + state: Mutex, + /// Déclenchement automatique après les complétions (`tplProbeAuto`). + pub auto: bool, + /// Attente entre deux essais sur un 503. + pub backoff: Duration, + /// Intervalle minimal entre deux vérifications automatiques. + pub every: Duration, +} + +impl Default for TplProbe { + fn default() -> Self { + TplProbe::new() + } +} + +/// Le plus ancien verdict d'une table (pour lui faire de la place). +fn oldest(m: &HashMap) -> Option { + m.iter().min_by_key(|(_, v)| v.at).map(|(k, _)| k.clone()) +} + +impl TplProbe { + pub fn new() -> Self { + TplProbe { + state: Mutex::new(State::default()), + auto: true, + backoff: Duration::from_secs(1), + every: PROBE_EVERY, + } + } + + fn st(&self) -> std::sync::MutexGuard<'_, State> { + self.state.lock().unwrap_or_else(|e| e.into_inner()) + } + + /// `tplLastShape` : la forme de la dernière requête sondée, ou une + /// requête nue. + pub fn last_shape(&self) -> Shape { + self.st().shape_last.clone().unwrap_or_default() + } + + /// `tplCapsCurrent` : le dernier verdict, rien pour un preset externe. + pub fn caps_current(&self, external: bool) -> Option { + if external { + return None; + } + self.st().last.clone() + } + + /// `tplCapsFor` : le dernier verdict pour CETTE forme. + pub fn caps_for(&self, external: bool, shape: &Shape) -> Option { + if external { + return None; + } + self.st().by_shape.get(&shape.hash()).cloned() + } + + /// Pose le verdict d'une forme comme si la sonde venait de conclure + /// (tests, et reprise d'un verdict connu). + pub fn remember(&self, shape: &Shape, r: ProbeResult) { + let mut st = self.st(); + st.last = Some(r.clone()); + Self::keep_shape(&mut st, &shape.hash(), &r); + } + + /// `tplProbeKeepShape` (verrou tenu). + fn keep_shape(st: &mut State, shape: &str, r: &ProbeResult) { + if !st.by_shape.contains_key(shape) + && st.by_shape.len() >= CACHE_MAX + && let Some(k) = oldest(&st.by_shape) + { + st.by_shape.remove(&k); + } + st.by_shape.insert(shape.into(), r.clone()); + } + + /// `tplProbeStore`. + fn store(&self, key: &str, shape: &str, r: &ProbeResult) { + let mut st = self.st(); + st.last = Some(r.clone()); + Self::keep_shape(&mut st, shape, r); + if key.is_empty() || !r.cacheable { + return; + } + if st.cache.len() >= CACHE_MAX + && let Some(k) = oldest(&st.cache) + { + st.cache.remove(&k); + } + st.cache.insert(key.into(), r.clone()); + } + + /// Le prober du moteur local. + pub fn prober(&self, env: &Env) -> Prober { + Prober::local(env.port, &env.key, self.backoff) + } + + /// `tplProbeEnsure` : verdict pour la forme, depuis le cache ou une sonde. + pub async fn ensure(&self, env: &Env, shape: &Shape) -> ProbeResult { + if env.external { + return ProbeResult::unknown("preset externe : pas de sonde"); + } + self.st().shape_last = Some(shape.clone()); + let p = self.prober(env); + if !p.healthy().await { + return ProbeResult::unknown("moteur pas prêt (/health)"); + } + let props = match p.props().await { + Ok(pr) => pr, + Err(e) => { + let r = ProbeResult::unknown(&format!("/props illisible : {e}")); + self.store("", &shape.hash(), &r); + return r; + } + }; + let tmpl_hash = fnv_hex(props.chat_template.as_bytes()); + let key = format!( + "{}\x00{}\x00{tmpl_hash}\x00{}", + props.build_info, + props.model_path, + shape.hash() + ); + { + let mut st = self.st(); + if let Some(r) = st.cache.get(&key).cloned() { + st.last = Some(r.clone()); + Self::keep_shape(&mut st, &shape.hash(), &r); + return r; + } + } + let mut r = p.probe(shape).await; + r.build = props.build_info.clone(); + r.template_hash = tmpl_hash; + r.tools = shape.n_tools; + r.kwargs = shape.kwargs.clone(); + r.effort = shape.effort.clone(); + if !props.model_path.is_empty() { + r.model = crate::paths::base_name(&props.model_path); + } + let line = probe_line(&r); + let fresh = { + let mut st = self.st(); + let fresh = line != st.logged; + st.logged = line.clone(); + fresh + }; + if fresh { + eprintln!("{line}"); + } + self.store(&key, &shape.hash(), &r); + r + } + + /// `tplProbeKick` : après une complétion complète du fil principal, en + /// tâche de fond ; au plus une sonde en vol, au plus une par `every`. + pub fn kick(self: &std::sync::Arc, env: Env, shape: Shape) { + if !self.auto { + return; + } + { + let mut st = self.st(); + if st.running || st.tried.is_some_and(|t| t.elapsed() < self.every) { + return; + } + st.running = true; + st.tried = Some(Instant::now()); + } + let me = self.clone(); + tokio::spawn(async move { + let _ = tokio::time::timeout(Duration::from_secs(30), me.ensure(&env, &shape)).await; + me.st().running = false; + }); + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn fnv64a() { + // Vecteurs de référence FNV-1a 64 bits. + assert_eq!(fnv_hex(b""), "cbf29ce484222325"); + assert_eq!(fnv_hex(b"a"), "af63dc4c8601ec8c"); + } + + #[test] + fn instant_rfc3339() { + assert_eq!(rfc3339(UNIX_EPOCH), "1970-01-01T00:00:00Z"); + assert_eq!( + rfc3339(UNIX_EPOCH + Duration::new(1_709_251_200, 500_000_000)), + "2024-03-01T00:00:00.5Z" + ); + } + + #[test] + fn json_omitempty() { + let mut r = ProbeResult::unknown(""); + r.kwargs = Some(Map::new()); + let v = r.to_json(); + assert!(v.get("chat_template_kwargs").is_none()); + assert!(v.get("note").is_none()); + assert_eq!(v["tools"], 0); + } +} diff --git a/rust/loki-rs/src/chat/turn.rs b/rust/loki-rs/src/chat/turn.rs index 7eb782f..b7638d7 100644 --- a/rust/loki-rs/src/chat/turn.rs +++ b/rust/loki-rs/src/chat/turn.rs @@ -24,9 +24,9 @@ //! l'historique ([`TurnResult::extra`]). //! //! Non portés (clés opt-in, ou machinerie du moteur local restée au Go) : -//! KEEP_TURN_IMAGES, PROJ_SNAPSHOT, NUDGE_IN_TOOL, SIDE_SLOT (slot et -//! rejeux « cache KV plein »), SLOT_PERSIST, préchauffage, sonde de gabarit, -//! télémétrie `perfRecord`, comptage des requêtes en vol du moteur. Avec l'une +//! KEEP_TURN_IMAGES, PROJ_SNAPSHOT, SIDE_SLOT (slot et rejeux « cache KV +//! plein »), SLOT_PERSIST, préchauffage, télémétrie `perfRecord`, comptage +//! des requêtes en vol du moteur. Avec l'une //! de ces clés, le Go garde la conversation (`facadeChatBlocker`, décidé au //! démarrage). @@ -152,6 +152,20 @@ pub trait TurnHost: Send + Sync { } /// Messages mis en file par l'utilisateur, à injecter maintenant. fn drain_queued(&self) -> Vec; + /// Complétion lue en entier sur le moteur local, outils ni coupés ni + /// neutralisés : la sonde de gabarit peut rejouer sa forme + /// (`tplProbeKick`). + fn completed(&self, _tools: &[Tool], _kwargs: Option<&Map>, _effort: &str) {} + /// La sonde a-t-elle conclu « préfixe instable » pour CE modèle et pour + /// cette forme de requête (`nudgeInToolOn`, hors clé et preset externe) ? + fn prefix_unstable( + &self, + _tools: &[Tool], + _kwargs: Option<&Map>, + _effort: &str, + ) -> bool { + false + } /// Prépare ce que [`Self::image_data_url`] devra rendre pour ces messages /// (lecture et déchiffrement des images rangées), avant chaque requête. fn prepare_images(&self, _msgs: &[Message]) -> impl Future + Send { @@ -384,7 +398,19 @@ pub async fn run_turn( if !nudge.is_empty() { s.budget_nudges += 1; log_budget(s.tool_runs, budget, s.budget_nudges); - append_nudge(&mut messages, &mut extra, &nudge); + // NUDGE_IN_TOOL (opt-in, gabarits dont le rendu bouge quand un + // message user s'ajoute) : au bout du dernier résultat d'outil. + // La sonde ne connaît que la forme normale : une relance d'une + // autre forme se replie sur le message à part. + let placed = !s.disable_tools + && !s.tool_choice_none + && super::nudges::nudge_in_tool_key(&tc.cfg) + && !ep.external + && host.prefix_unstable(&tc.tools, kwargs.as_ref(), &effort) + && super::nudges::nudge_into_tool(&mut messages, &mut extra, &nudge); + if !placed { + append_nudge(&mut messages, &mut extra, &nudge); + } } let calls_on = !tc.tools.is_empty() && !s.disable_tools && !s.tool_choice_none; let pol = turn_echo_policy(ep.external || s.echo_off_turn, || tc.echo.clone()); @@ -585,6 +611,11 @@ pub async fn run_turn( if out.is_complete(read_err.is_some()) { // Dernier événement de la complétion : copie complète des stats. cb(StreamEvent::Stats(out.stats.clone())); + // Sonde de gabarit, en tâche de fond et après coup : rien n'en + // revient dans la requête suivante. + if !ep.external && !s.disable_tools && !s.tool_choice_none { + host.completed(&tc.tools, kwargs.as_ref(), &effort); + } if !ep.external { s.peak_gen = s.peak_gen.max(out.stats.gen_tokens); if s.last_est > 0 && out.stats.prompt_tokens_total > 0 { diff --git a/rust/loki-rs/src/chat/turn/tests.rs b/rust/loki-rs/src/chat/turn/tests.rs index 50d6505..e9040c5 100644 --- a/rust/loki-rs/src/chat/turn/tests.rs +++ b/rust/loki-rs/src/chat/turn/tests.rs @@ -17,6 +17,9 @@ enum Script { Cut(Vec), } +/// Une complétion signalée à la sonde : (outils, kwargs, effort). +type Completion = (usize, Option>, String); + #[derive(Default)] struct Fake { script: Mutex>, @@ -29,6 +32,10 @@ struct Fake { efforts: Mutex>, /// L'arrêt à déclencher pendant l'exécution d'un outil. stop_on_exec: Mutex>>, + /// Verdict « préfixe instable » de la sonde (NUDGE_IN_TOOL). + unstable: Mutex, + /// Complétions signalées à la sonde : (outils, kwargs, effort). + completions: Mutex>, } impl Fake { @@ -127,6 +134,15 @@ impl TurnHost for Fake { self.efforts.lock().unwrap().push((want.into(), got.into())); } fn echo_refuse(&self, _model: &str) {} + fn completed(&self, tools: &[Tool], kwargs: Option<&Map>, effort: &str) { + self.completions + .lock() + .unwrap() + .push((tools.len(), kwargs.cloned(), effort.into())); + } + fn prefix_unstable(&self, _t: &[Tool], _k: Option<&Map>, _e: &str) -> bool { + *self.unstable.lock().unwrap() + } } fn content(s: &str) -> String { @@ -339,6 +355,17 @@ async fn erreur_500_tool_choice_none_puis_outils_retires() { ); assert!(host.body(2).get("tools").is_none(), "puis retirés"); assert_eq!(host.body(2)["messages"][0]["role"], "system"); + // Une relance outils coupés n'est pas la forme normale : pas de sonde. + assert!(host.completions.lock().unwrap().is_empty()); + // Pas davantage outils neutralisés (tool_choice « none »). + let host = Fake::new(vec![ + Script::Status(500, "parse error".into()), + answer("texte"), + ]); + let (r, _) = run(&host, &config(&["glob"]), &Cancel::never()).await; + assert_eq!(r.err, None); + assert_eq!(host.body(1)["tool_choice"], "none"); + assert!(host.completions.lock().unwrap().is_empty()); } #[tokio::test] @@ -471,6 +498,104 @@ async fn budget_d_outils_rappel() { })); } +/// NUDGE_IN_TOOL : avec la clé et un gabarit « préfixe instable », le rappel +/// part au bout du dernier résultat d'outil (envoyé et gardé à l'identique), +/// sans message user à part ; sans verdict, ou sans la clé, à part. +#[tokio::test] +async fn rappel_de_budget_dans_le_resultat() { + for (key, unstable, inside) in [ + ("on", true, true), + ("on", false, false), + ("", true, false), + ("Oui", true, true), + ("external", true, false), + ] { + let mut script: Vec` + } + res := func(items ...map[string]any) string { + b, _ := json.Marshal(map[string]any{"results": items}) + return string(b) + } + it := func(img, title, u string, w, h int) map[string]any { + return map[string]any{"image": img, "title": title, "url": u, "width": w, "height": h, "thumbnail": "t"} + } + return map[string]parityImgResp{ + "chats": {200, page("4-123-456"), "dcm=7; Path=/; HttpOnly", 200, res( + it("https://img.example/1.jpg", "Chat roux", "https://page.example/1", 800, 600), + it("https://img.example/1.jpg", "Doublon", "https://page.example/1b", 1, 1), + it("", "Sans image", "https://page.example/2", 1, 1), + it("data:image/png;base64,xx", "Pas http", "https://page.example/3", 1, 1), + it("http://img.example/4.png", "Chat & chien <3", "https://page.example/4", 1024, 768), + it("https://img.example/5.jpg", "Cinquième", "https://page.example/5", 10, 20), + )}, + "sans jeton": {200, "rien", "", 200, "{}"}, + "api en panne": {200, page("1"), "", 500, "oops"}, + "page en panne": {403, "interdit", "", 200, "{}"}, + "illisible": {200, page("2"), "", 200, "pas du json"}, + "apres": {200, page("3"), "", 200, `{"results":[]} x`}, + "largeur texte": {200, page("4"), "", 200, `{"results":[{"image":"https://i/1","width":"800"}]}`}, + "vide": {200, page("5"), "", 200, `{"results":[]}`}, + "null": {200, page("6"), "", 200, `{"results":null,"next":"i.js?x"}`}, + "resultats faux": {200, page("7"), "", 200, `{"results":"non"}`}, + "apostrophe": {200, `vqd='77-88'`, "", 200, `{"results":[{"image":"https://i/a","title":"A","url":"https://p/a","width":1,"height":2}]}`}, + } +} + +// Le faux site du moteur intégré. +var parityWebPages = map[string]string{ + "/article.html": `Guide de Loki + +

Guide de Loki

+

Loki fait tourner un modèle de langage sur votre machine, sans rien envoyer ailleurs. Ce guide explique l'installation, la configuration et les premiers pas avec l'agent.

+

Installation

+

Téléchargez le binaire depuis la page des versions, puis lancez-le. Le moteur llama.cpp est installé au premier démarrage, précompilé pour votre carte graphique.

+

Configuration

+

Les réglages se font dans l'interface : modèle, contexte, outils. Erreur fréquente : un port déjà pris par un autre service. Changez alors le port dans les réglages avancés.

+

Erreur moins fréquente : un disque plein empêche le téléchargement du modèle.

+
© Loki
`, + "/texte.txt": "Ligne brute 1\nLigne brute 2\n", +} + +// Les faux résultats DuckDuckGo (page HTML) du moteur intégré. +var parityWebDDGHTML = map[string]string{ + "loki": ``, + "bot": ``, +} + +func TestPariteOutilsWeb(t *testing.T) { + testHome(t) + crawlFx := parityCrawlFixtures() + imgFx := parityImageFixtures() + + var mu sync.Mutex + var crawls, images []parityWebReq + served := map[string]int{} + crawlSrv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + body, _ := io.ReadAll(r.Body) + var in struct { + URLs []string `json:"urls"` + } + _ = json.Unmarshal(body, &in) + mu.Lock() + crawls = append(crawls, parityWebReq{Path: r.Method + " " + r.URL.Path, Auth: r.Header.Get("Authorization"), + CType: r.Header.Get("Content-Type"), Body: string(body)}) + target := "" + if len(in.URLs) > 0 { + target = in.URLs[0] + } + list := crawlFx[target] + n := served[target] + served[target]++ + mu.Unlock() + if len(list) == 0 { + w.WriteHeader(404) + _, _ = io.WriteString(w, "inconnu : "+target) + return + } + if n >= len(list) { + n = len(list) - 1 + } + w.WriteHeader(list[n].Status) + _, _ = io.WriteString(w, list[n].Body) + })) + defer crawlSrv.Close() + imgSrv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + mu.Lock() + images = append(images, parityWebReq{Path: r.URL.RequestURI(), Referer: r.Header.Get("Referer"), + Accept: r.Header.Get("Accept"), Lang: r.Header.Get("Accept-Language"), + UA: r.Header.Get("User-Agent"), Cookie: r.Header.Get("Cookie")}) + mu.Unlock() + fx := imgFx[r.URL.Query().Get("q")] + if r.URL.Path == "/i.js" { + w.WriteHeader(fx.APIStatus) + _, _ = io.WriteString(w, fx.API) + return + } + if fx.Cookie != "" { + w.Header().Set("Set-Cookie", fx.Cookie) + } + w.WriteHeader(fx.PageStatus) + _, _ = io.WriteString(w, fx.Page) + })) + defer imgSrv.Close() + var pagesHit, searchHit []string + pagesSrv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + mu.Lock() + pagesHit = append(pagesHit, r.URL.Path) + mu.Unlock() + p, ok := parityWebPages[r.URL.Path] + if !ok { + http.NotFound(w, r) + return + } + if strings.HasSuffix(r.URL.Path, ".txt") { + w.Header().Set("Content-Type", "text/plain; charset=utf-8") + } else { + w.Header().Set("Content-Type", "text/html; charset=utf-8") + } + _, _ = io.WriteString(w, p) + })) + defer pagesSrv.Close() + ddgHTMLSrv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + q := r.URL.Query().Get("q") + mu.Lock() + searchHit = append(searchHit, q) + mu.Unlock() + w.Header().Set("Content-Type", "text/html; charset=utf-8") + _, _ = io.WriteString(w, parityWebDDGHTML[q]) + })) + defer ddgHTMLSrv.Close() + dead, err := net.Listen("tcp", "127.0.0.1:0") + if err != nil { + t.Fatal(err) + } + deadAddr := dead.Addr().String() + dead.Close() + + oldImg, oldHTML, oldNow, oldJar := ddgImagesBase, ddgHTMLBase, webNow, goHTTPClient.Jar + ddgImagesBase, ddgHTMLBase = imgSrv.URL, ddgHTMLSrv.URL + goHTTPClient.Jar = newCookieJar() // les cookies d'un passage précédent ne comptent pas + base := time.Now() + var skew time.Duration + webNow = func() time.Time { return base.Add(skew) } + pageCacheMu.Lock() + pageCache = map[string]*cacheEntry{} + pageCacheMu.Unlock() + defer func() { + ddgImagesBase, ddgHTMLBase, webNow, goHTTPClient.Jar = oldImg, oldHTML, oldNow, oldJar + pageCacheMu.Lock() + pageCache = map[string]*cacheEntry{} + pageCacheMu.Unlock() + }() + + mask := func(s string) string { + s = strings.ReplaceAll(s, crawlSrv.URL, "{{CRAWL}}") + s = strings.ReplaceAll(s, imgSrv.URL, "{{DDG}}") + s = strings.ReplaceAll(s, pagesSrv.URL, "{{PAGES}}") + s = strings.ReplaceAll(s, "http://"+deadAddr, "{{DEAD}}") + return strings.ReplaceAll(s, deadAddr, "{{DEADADDR}}") + } + unmask := func(s string) string { + s = strings.ReplaceAll(s, "{{CRAWL}}", crawlSrv.URL) + s = strings.ReplaceAll(s, "{{PAGES}}", pagesSrv.URL) + return strings.ReplaceAll(s, "{{DEAD}}", "http://"+deadAddr) + } + maskReqs := func(rs []parityWebReq) []parityWebReq { + out := []parityWebReq{} + for _, r := range rs { + r.Path, r.Body, r.Referer = mask(r.Path), mask(r.Body), mask(r.Referer) + out = append(out, r) + } + return out + } + + // Ce que le moteur intégré tire des pages et des résultats, rangé pour la + // façade (qui le demande au Go). + goFetch := map[string]parityGoOut{} + for _, p := range []string{"/article.html", "/texte.txt", "/absente"} { + md, err := goFetchMarkdown(pagesSrv.URL + p) + o := parityGoOut{Markdown: mask(md)} + if err != nil { + o = parityGoOut{Error: mask(err.Error())} + } + goFetch["{{PAGES}}"+p] = o + } + goSearchOut := map[string]parityGoOut{} + for _, q := range []struct { + q string + n int + }{{"loki", 8}, {"loki", 1}, {"bot", 8}} { + res, err := goSearch(q.q, q.n) + o := parityGoOut{Results: []map[string]string{}} + for _, r := range res { + o.Results = append(o.Results, map[string]string{"title": r.Title, "url": r.URL, "snippet": r.Snippet}) + } + if err != nil { + o = parityGoOut{Error: err.Error()} + } + goSearchOut[fmt.Sprintf("%s|%d", q.q, q.n)] = o + } + mu.Lock() + pagesHit, searchHit = nil, nil + mu.Unlock() + + crawlSetup := map[string]string{"WEB_ENGINE": "crawl4ai", "CRAWL4AI_URL": "{{CRAWL}}", "crawl_key": "sesame"} + var calls []parityWebCall + run := func(c parityWebCall) { + for k, v := range c.Setup { + switch k { + case "crawl_key": + if v == "-" { + v = "" + } + if err := putStr(bkState, "crawl_key", v); err != nil { + t.Fatal(err) + } + default: + if v == "-" { + v = "" + } + if err := SetConfigKey(k, unmask(v)); err != nil { + t.Fatal(err) + } + } + } + skew += time.Duration(c.Advance) * time.Second + args := map[string]any{} + raw, _ := json.Marshal(c.Args) + _ = json.Unmarshal([]byte(unmask(string(raw))), &args) + res := execTool(t.Context(), c.Tool, args, toolCallLabel(c.Tool, args), Caps{Agent: true}) + mu.Lock() + c.Result = mask(res.Result) + c.Crawls, c.Images = maskReqs(crawls), maskReqs(images) + c.GoFetch, c.GoSearch = []string{}, []string{} + for _, p := range pagesHit { + c.GoFetch = append(c.GoFetch, "{{PAGES}}"+p) + } + c.GoSearch = append(c.GoSearch, searchHit...) + crawls, images, pagesHit, searchHit = nil, nil, nil, nil + mu.Unlock() + if c.Args == nil { + c.Args = map[string]any{} + } + calls = append(calls, c) + } + a := func(kv ...any) map[string]any { + m := map[string]any{} + for i := 0; i+1 < len(kv); i += 2 { + m[kv[i].(string)] = kv[i+1] + } + return m + } + + // Crawl4AI. + run(parityWebCall{Name: "recherche", Setup: crawlSetup, Tool: "web_search", Args: a("query", "loki rust")}) + run(parityWebCall{Name: "recherche limitée", Tool: "web_search", Args: a("query", "loki rust", "limit", 1.0)}) + run(parityWebCall{Name: "recherche, limite hors bornes", Tool: "web_search", Args: a("query", "loki rust", "limit", 99.0)}) + run(parityWebCall{Name: "recherche, limite nulle", Tool: "web_search", Args: a("query", "loki rust", "limit", -3.0)}) + run(parityWebCall{Name: "recherche, limite texte", Tool: "web_search", Args: a("query", "loki rust", "limit", "2")}) + run(parityWebCall{Name: "défi anti-bot", Tool: "web_search", Args: a("query", "anti-bot")}) + run(parityWebCall{Name: "aucun résultat", Tool: "web_search", Args: a("query", "rien")}) + run(parityWebCall{Name: "Crawl4AI en panne", Tool: "web_search", Args: a("query", "panne")}) + run(parityWebCall{Name: "ouverture", Tool: "web_open", Args: a("url", "https://exemple.test/doc")}) + run(parityWebCall{Name: "lecture", Tool: "web_read", Args: a("url", "https://exemple.test/doc")}) + run(parityWebCall{Name: "lecture d'une plage", Tool: "web_read", Args: a("url", "https://exemple.test/doc", "offset", 5.0, "limit", 3.0)}) + run(parityWebCall{Name: "lecture au-delà", Tool: "web_read", Args: a("url", "https://exemple.test/doc", "offset", 400.0)}) + run(parityWebCall{Name: "lecture, bornes", Tool: "web_read", Args: a("url", "https://exemple.test/doc", "offset", -2.0, "limit", 0.0)}) + run(parityWebCall{Name: "grep", Tool: "web_grep", Args: a("url", "https://exemple.test/doc", "pattern", "erreur")}) + run(parityWebCall{Name: "grep sans contexte", Tool: "web_grep", Args: a("url", "https://exemple.test/doc", "pattern", "^ligne [ae]$", "context", 0.0)}) + run(parityWebCall{Name: "grep plafonné", Tool: "web_grep", Args: a("url", "https://exemple.test/doc", "pattern", "ligne", "max_matches", 2.0, "context", 1.0)}) + run(parityWebCall{Name: "grep, contexte large", Tool: "web_grep", Args: a("url", "https://exemple.test/doc", "pattern", "Détails", "context", 50.0)}) + run(parityWebCall{Name: "grep sans résultat", Tool: "web_grep", Args: a("url", "https://exemple.test/doc", "pattern", "introuvable")}) + run(parityWebCall{Name: "grep, ascii", Tool: "web_grep", Args: a("url", "https://exemple.test/doc", "pattern", `\bD\w+ils`)}) + for i, p := range []string{"(", "a)", "[abc", "*x", "a**", `\q`, `a\`} { + run(parityWebCall{Name: fmt.Sprintf("regex invalide %d", i+1), Tool: "web_grep", Args: a("url", "https://exemple.test/doc", "pattern", p)}) + } + run(parityWebCall{Name: "page jamais ouverte", Tool: "web_read", Args: a("url", "https://exemple.test/autre")}) + run(parityWebCall{Name: "grep, page jamais ouverte", Tool: "web_grep", Args: a("url", "https://exemple.test/autre", "pattern", "x")}) + run(parityWebCall{Name: "réouverture : cache", Tool: "web_open", Args: a("url", "https://exemple.test/doc")}) + run(parityWebCall{Name: "réouverture forcée", Tool: "web_open", Args: a("url", "https://exemple.test/doc", "refresh", true)}) + run(parityWebCall{Name: "ouverture avec actions", Advance: 5, Tool: "web_open", Args: a("url", "https://exemple.test/doc", + "actions", []any{"document.querySelector('#plus').click();", 3.0, "x && y < z"}, "wait_for", "css:#fin", "dismiss_popups", false)}) + run(parityWebCall{Name: "lecture : la plus récente", Tool: "web_read", Args: a("url", "https://exemple.test/doc")}) + run(parityWebCall{Name: "attente seule", Tool: "web_open", Args: a("url", "https://exemple.test/seul", "wait_for", "js:() => true")}) + run(parityWebCall{Name: "sans fermer les bandeaux", Tool: "web_open", Args: a("url", "https://exemple.test/brut-absent", "dismiss_popups", false)}) + run(parityWebCall{Name: "cache périmé", Advance: 601, Tool: "web_read", Args: a("url", "https://exemple.test/doc")}) + run(parityWebCall{Name: "ouverture après péremption", Tool: "web_open", Args: a("url", "https://exemple.test/doc")}) + run(parityWebCall{Name: "dépôt GitHub", Tool: "web_open", Args: a("url", "https://github.com/r0m1k3/loki/")}) + run(parityWebCall{Name: "lecture du dépôt", Tool: "web_read", Args: a("url", "https://github.com/r0m1k3/loki")}) + run(parityWebCall{Name: "GitHub, pas un dépôt", Tool: "web_open", Args: a("url", "https://github.com/r0m1k3")}) + run(parityWebCall{Name: "crawl en échec", Tool: "web_open", Args: a("url", "https://exemple.test/echec")}) + run(parityWebCall{Name: "crawl en échec, sans message", Tool: "web_open", Args: a("url", "https://exemple.test/echec-muet")}) + run(parityWebCall{Name: "réponse vide", Tool: "web_open", Args: a("url", "https://exemple.test/vide")}) + run(parityWebCall{Name: "réponse tableau", Tool: "web_open", Args: a("url", "https://exemple.test/tableau")}) + run(parityWebCall{Name: "réponse illisible", Tool: "web_open", Args: a("url", "https://exemple.test/illisible")}) + run(parityWebCall{Name: "résultat mal typé", Tool: "web_open", Args: a("url", "https://exemple.test/mal-type")}) + run(parityWebCall{Name: "page blanche", Tool: "web_open", Args: a("url", "https://exemple.test/blanc")}) + run(parityWebCall{Name: "markdown objet faux", Tool: "web_open", Args: a("url", "https://exemple.test/objet-faux")}) + run(parityWebCall{Name: "page longue", Tool: "web_open", Args: a("url", "https://exemple.test/longue")}) + run(parityWebCall{Name: "lecture tronquée", Tool: "web_read", Args: a("url", "https://exemple.test/longue", "limit", 500.0)}) + run(parityWebCall{Name: "URL inconnue du faux", Tool: "web_open", Args: a("url", "https://exemple.test/inconnue")}) + run(parityWebCall{Name: "sans clé", Setup: map[string]string{"crawl_key": "-"}, Tool: "web_open", Args: a("url", "https://exemple.test/seul", "refresh", true)}) + run(parityWebCall{Name: "Crawl4AI injoignable", Setup: map[string]string{"CRAWL4AI_URL": "{{DEAD}}/"}, Tool: "web_open", Args: a("url", "https://exemple.test/x")}) + run(parityWebCall{Name: "sans serveur Crawl4AI", Setup: map[string]string{"CRAWL4AI_URL": "-"}, Tool: "web_open", Args: a("url", "https://exemple.test/x")}) + run(parityWebCall{Name: "lecture d'une page vue par Crawl4AI", Tool: "web_read", Args: a("url", "https://exemple.test/seul", "limit", 1.0)}) + + // Moteur intégré. + goSetup := map[string]string{"WEB_ENGINE": "go"} + run(parityWebCall{Name: "intégré : ouverture", Setup: goSetup, Tool: "web_open", Args: a("url", "{{PAGES}}/article.html")}) + run(parityWebCall{Name: "intégré : grep", Tool: "web_grep", Args: a("url", "{{PAGES}}/article.html", "pattern", "erreur", "context", 0.0)}) + run(parityWebCall{Name: "intégré : texte brut", Tool: "web_open", Args: a("url", "{{PAGES}}/texte.txt", "actions", []any{"x()"})}) + run(parityWebCall{Name: "intégré : lecture", Tool: "web_read", Args: a("url", "{{PAGES}}/texte.txt")}) + run(parityWebCall{Name: "intégré : page absente", Tool: "web_open", Args: a("url", "{{PAGES}}/absente")}) + run(parityWebCall{Name: "intégré : recherche", Tool: "web_search", Args: a("query", "loki")}) + run(parityWebCall{Name: "intégré : recherche limitée", Tool: "web_search", Args: a("query", "loki", "limit", 1.0)}) + run(parityWebCall{Name: "intégré : anti-bot", Tool: "web_search", Args: a("query", "bot")}) + + // Images (même client quel que soit le moteur). + run(parityWebCall{Name: "images", Tool: "web_images", Args: a("query", "chats")}) + run(parityWebCall{Name: "images, cookie gardé", Tool: "web_images", Args: a("query", "chats", "limit", 1.0)}) + for _, q := range []string{"sans jeton", "api en panne", "page en panne", "illisible", "apres", "largeur texte", "vide", "null", "resultats faux", "apostrophe"} { + run(parityWebCall{Name: "images : " + q, Tool: "web_images", Args: a("query", q)}) + } + + j := parityWebJournal{Crawl: crawlFx, Images: imgFx, GoFetch: goFetch, GoSearch: goSearchOut, Calls: calls} + var buf bytes.Buffer + enc := json.NewEncoder(&buf) + enc.SetEscapeHTML(false) + enc.SetIndent("", " ") + if err := enc.Encode(j); err != nil { + t.Fatal(err) + } + got := buf.Bytes() + if os.Getenv("LOKI_PARITY_UPDATE") == "1" { + if err := os.MkdirAll(filepath.Dir(parityWebFile), 0o755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(parityWebFile, got, 0o644); err != nil { + t.Fatal(err) + } + return + } + want, err := os.ReadFile(parityWebFile) + if err != nil { + t.Fatalf("journal de référence absent (%v) : LOKI_PARITY_UPDATE=1 pour le créer", err) + } + if !bytes.Equal(got, want) { + t.Errorf("les outils web ne produisent plus le journal rangé dans %s.\n"+ + "LOKI_PARITY_UPDATE=1 pour le régénérer si le changement est voulu.", parityWebFile) + } +} diff --git a/internal/loki/web_fetch_go.go b/internal/loki/web_fetch_go.go index 1d96af0..6f5d6de 100644 --- a/internal/loki/web_fetch_go.go +++ b/internal/loki/web_fetch_go.go @@ -309,7 +309,7 @@ func goSearch(query string, limit int) ([]searchResult, error) { ctx, cancel := context.WithTimeout(context.Background(), goFetchTimeout) defer cancel() - target := "https://html.duckduckgo.com/html/?q=" + url.QueryEscape(query) + target := ddgHTMLBase + "/html/?q=" + url.QueryEscape(query) body, contentType, _, err := goFetch(ctx, target) if err != nil { return nil, err diff --git a/internal/loki/web_internal.go b/internal/loki/web_internal.go index ce5a857..3a28ccb 100644 --- a/internal/loki/web_internal.go +++ b/internal/loki/web_internal.go @@ -137,6 +137,10 @@ func withInternalAPI(next http.Handler) http.Handler { handleInternalDictateStop(w, r) case "/internal/v1/web/reach/reset": handleInternalReachReset(w, r) + case "/internal/v1/web/fetch": + handleInternalWebFetch(w, r) + case "/internal/v1/web/search": + handleInternalWebSearch(w, r) case "/internal/v1/cdp/shutdown": handleInternalCDPShutdown(w, r) case "/internal/v1/service/action": diff --git a/internal/loki/web_internal_web.go b/internal/loki/web_internal_web.go new file mode 100644 index 0000000..3e26917 --- /dev/null +++ b/internal/loki/web_internal_web.go @@ -0,0 +1,52 @@ +package loki + +import "net/http" + +// API interne du moteur web intégré (lot 42 de la migration Rust). +// +// La façade exécute les outils web de ses tours (web_search, web_images, +// web_open, web_read, web_grep) : cache des pages, client Crawl4AI, lecture +// des résultats, mise en forme. Le moteur intégré lit le HTML avec +// go-readability, html-to-markdown et le parseur HTML5 de x/net : rien en +// Rust ne les reproduit au caractère près, ils restent donc ici, derrière +// deux appels sans état. +// +// POST /internal/v1/web/fetch {"url":"…"} +// → {"markdown":"…"} | {"error":"…"} +// POST /internal/v1/web/search {"query":"…","limit":8} +// → {"results":[{"title":"…","url":"…","snippet":"…"}]} | {"error":"…"} + +func handleInternalWebFetch(w http.ResponseWriter, r *http.Request) { + var in struct { + URL string `json:"url"` + } + if !readInternalJSON(w, r, &in) { + return + } + md, err := goFetchMarkdown(in.URL) + if err != nil { + sendJSON(w, http.StatusOK, map[string]any{"error": err.Error()}) + return + } + sendJSON(w, http.StatusOK, map[string]any{"markdown": md}) +} + +func handleInternalWebSearch(w http.ResponseWriter, r *http.Request) { + var in struct { + Query string `json:"query"` + Limit int `json:"limit"` + } + if !readInternalJSON(w, r, &in) { + return + } + results, err := goSearch(in.Query, in.Limit) + if err != nil { + sendJSON(w, http.StatusOK, map[string]any{"error": err.Error()}) + return + } + out := make([]map[string]string, 0, len(results)) + for _, r := range results { + out = append(out, map[string]string{"title": r.Title, "url": r.URL, "snippet": r.Snippet}) + } + sendJSON(w, http.StatusOK, map[string]any{"results": out}) +} diff --git a/internal/loki/web_internal_web_test.go b/internal/loki/web_internal_web_test.go new file mode 100644 index 0000000..5cbd403 --- /dev/null +++ b/internal/loki/web_internal_web_test.go @@ -0,0 +1,58 @@ +package loki + +import ( + "encoding/json" + "io" + "net/http" + "net/http/httptest" + "strings" + "testing" +) + +// L'API interne du moteur web intégré (lot 42) : la conversion d'une page et +// la recherche DuckDuckGo, telles que la façade les demande. +func TestInternalMoteurWebIntegre(t *testing.T) { + site := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + switch r.URL.Path { + case "/page.txt": + w.Header().Set("Content-Type", "text/plain") + _, _ = io.WriteString(w, "bonjour\n") + case "/html/": + w.Header().Set("Content-Type", "text/html") + _, _ = io.WriteString(w, `
R`+ + `résumé
`) + default: + http.NotFound(w, r) + } + })) + defer site.Close() + old := ddgHTMLBase + ddgHTMLBase = site.URL + defer func() { ddgHTMLBase = old }() + + call := func(h http.HandlerFunc, method, body string) (int, map[string]any) { + w := httptest.NewRecorder() + h(w, httptest.NewRequest(method, "/x", strings.NewReader(body))) + var out map[string]any + _ = json.Unmarshal(w.Body.Bytes(), &out) + return w.Code, out + } + if code, out := call(handleInternalWebFetch, "POST", `{"url":"`+site.URL+`/page.txt"}`); code != 200 || out["markdown"] != "bonjour\n" { + t.Fatalf("fetch : %d %v", code, out) + } + if code, out := call(handleInternalWebFetch, "POST", `{"url":"`+site.URL+`/absente"}`); code != 200 || + out["error"] != "HTTP 404 sur "+site.URL+"/absente" { + t.Fatalf("fetch absente : %d %v", code, out) + } + code, out := call(handleInternalWebSearch, "POST", `{"query":"q","limit":5}`) + res, _ := json.Marshal(out["results"]) + if code != 200 || string(res) != `[{"snippet":"résumé","title":"R","url":"https://r.example/"}]` { + t.Fatalf("search : %d %s", code, res) + } + if code, _ := call(handleInternalWebSearch, "GET", ""); code != http.StatusMethodNotAllowed { + t.Fatalf("GET : %d", code) + } + if code, _ := call(handleInternalWebFetch, "POST", "pas du json"); code != http.StatusBadRequest { + t.Fatalf("corps illisible : %d", code) + } +} diff --git a/rust/loki-rs/src/chat/generate.rs b/rust/loki-rs/src/chat/generate.rs index aa589da..0d4ef4c 100644 --- a/rust/loki-rs/src/chat/generate.rs +++ b/rust/loki-rs/src/chat/generate.rs @@ -1322,6 +1322,7 @@ impl ChatShared { mcp: None, tasks: None, memory: None, + web: Default::default(), go_version: Default::default(), } } diff --git a/rust/loki-rs/src/chat/host.rs b/rust/loki-rs/src/chat/host.rs index 1c99111..ebb4d7d 100644 --- a/rust/loki-rs/src/chat/host.rs +++ b/rust/loki-rs/src/chat/host.rs @@ -347,6 +347,8 @@ pub struct ChatShared { /// La mémoire, quand la façade la tient (LOKI_MEM=rust) : les outils /// `mem_*` et `tracker` du tour s'exécutent ici (lot 40). pub memory: Option>, + /// Les outils web (lot 42) : cache des pages, Crawl4AI, DuckDuckGo. + pub web: Arc, /// La version du Go, annoncée aux serveurs MCP à une reconnexion ; lue /// une fois. pub go_version: tokio::sync::OnceCell, @@ -698,6 +700,13 @@ impl TurnHost for GoTurnHost { }; } } + // Outils web (lot 42) : le cache des pages est ici. + if let Some(result) = self.shared.web.exec(&self.shared.store, name, args).await { + return ToolOutcome { + result, + ..Default::default() + }; + } // Outils task_*, quand la façade tient les tâches (lot 37). if crate::tasks::tools::is_tool(name) && let Some(tasks) = &self.shared.tasks diff --git a/rust/loki-rs/src/chat/stream.rs b/rust/loki-rs/src/chat/stream.rs index 1b0d83f..69614fb 100644 --- a/rust/loki-rs/src/chat/stream.rs +++ b/rust/loki-rs/src/chat/stream.rs @@ -968,8 +968,67 @@ pub fn go_regex(p: &str) -> Option { let cs: Vec = p.chars().collect(); let mut out = String::with_capacity(p.len() + 16); let (mut i, mut class, mut class_start) = (0, false, false); + // Répétition qui précède : 0 aucune, 1 une répétition (un « ? » la + // rend paresseuse), 2 déjà paresseuse. Le Go refuse d'en répéter une. + let mut rep = 0u8; while i < cs.len() { let c = cs[i]; + if !class { + match c { + '*' | '+' | '?' => { + if rep == 1 && c == '?' { + rep = 2; + } else if rep != 0 { + return None; + } else { + rep = 1; + } + out.push(c); + i += 1; + continue; + } + '{' => { + // `{n}`, `{n,}`, `{n,m}` (1000 au plus) ; sinon un « { » + // littéral pour le Go. + let rest: String = cs[i + 1..].iter().collect(); + let body = rest.split_once('}').map(|(b, _)| b); + let parsed = body.and_then(|b| { + let (lo, hi) = match b.split_once(',') { + Some((lo, hi)) => (lo, Some(hi)), + None => (b, None), + }; + let num = |t: &str| { + (!t.is_empty() && t.bytes().all(|d| d.is_ascii_digit())) + .then(|| t.parse::().unwrap_or(u64::MAX)) + }; + let lo = num(lo)?; + let hi = match hi { + None => Some(lo), + Some("") => None, + Some(h) => Some(num(h)?), + }; + Some((b.chars().count(), lo, hi)) + }); + match parsed { + Some((len, lo, hi)) => { + if lo > 1000 || hi.is_some_and(|h| h > 1000 || h < lo) || rep != 0 { + return None; + } + rep = 1; + out.extend(&cs[i..i + len + 2]); + i += len + 2; + } + None => { + rep = 0; + out.push_str(r"\{"); + i += 1; + } + } + continue; + } + _ => rep = 0, + } + } if c == '\\' && i + 1 < cs.len() { let n = cs[i + 1]; i += 2; @@ -990,6 +1049,18 @@ pub fn go_regex(p: &str) -> Option { class_start = false; continue; } + // `\p{…}`, `\P{…}`, `\x{…}` : les accolades sont à l'échappement. + if matches!(n, 'p' | 'P' | 'x') + && cs.get(i) == Some(&'{') + && let Some(k) = cs[i..].iter().position(|&c| c == '}') + { + out.push('\\'); + out.push(n); + out.extend(&cs[i..=i + k]); + i += k + 1; + class_start = false; + continue; + } // Un chiffre seul est une référence arrière pour le Go, qui la // refuse ; avec l'octal permis, `regex` l'accepterait. if ('1'..='7').contains(&n) && !cs.get(i).is_some_and(|d| ('0'..='7').contains(d)) { @@ -2872,6 +2943,19 @@ mod tests { assert_eq!(m(r"\Q(x", "a(x"), Some("(x".into())); // Le Go refuse \Q dans une classe. assert_eq!(go_regex(r"[\Q]-\E]+"), None); + // Répétition d'une répétition : refusée par le Go ; « ? » paresseux + // permis une fois ; « { » hors répétition littéral ; bornes ≤ 1000. + for p in ["a**", "a+*", "a*??", "a{2}*", "a*{2}", "a{1001}", "a{3,2}"] { + assert_eq!(go_regex(p), None, "{p}"); + } + assert_eq!(go_regex("a*?b+?c??").as_deref(), Some("a*?b+?c??")); + assert_eq!(go_regex("a{2,}{").as_deref(), Some(r"a{2,}\{")); + assert_eq!(go_regex("x{a}").as_deref(), Some(r"x\{a}")); + assert!(compile_go_regex("x{,3}").unwrap().is_match("x{,3}")); + assert_eq!( + go_regex(r"\p{Greek}+\x{41}{2}").as_deref(), + Some(r"\p{Greek}+\x{41}{2}") + ); // Dans une classe, « [ », « && », « -- » et « ~~ » sont littéraux. assert_eq!(m(r"[a[]+", "x[a["), Some("[a[".into())); assert_eq!(m(r"[a&&b]+", "x&ab"), Some("&ab".into())); diff --git a/rust/loki-rs/src/gojson.rs b/rust/loki-rs/src/gojson.rs index 415b322..ac4d9b4 100644 --- a/rust/loki-rs/src/gojson.rs +++ b/rust/loki-rs/src/gojson.rs @@ -304,7 +304,7 @@ fn kind_of(v: &Value) -> &'static str { } /// `quoteChar` du scanner Go. -fn quote_char(c: u8) -> String { +pub(crate) fn quote_char(c: u8) -> String { match c { b'\'' => r"'\''".into(), b'"' => "'\"'".into(), diff --git a/rust/loki-rs/src/lib.rs b/rust/loki-rs/src/lib.rs index 4d14062..3faa175 100644 --- a/rust/loki-rs/src/lib.rs +++ b/rust/loki-rs/src/lib.rs @@ -52,3 +52,4 @@ pub mod tune; pub mod tz; pub mod ui; pub mod update; +pub mod webtools; diff --git a/rust/loki-rs/src/webtools.rs b/rust/loki-rs/src/webtools.rs new file mode 100644 index 0000000..6a2b297 --- /dev/null +++ b/rust/loki-rs/src/webtools.rs @@ -0,0 +1,1578 @@ +//! Outils web de l'agent (lot 42 de la migration) — port de +//! `chat_internet_tools.go` (`web_search`, `web_open`, `web_read`, +//! `web_grep`), de `chat_internet.go` (client Crawl4AI, cache des pages, +//! lecture des résultats DuckDuckGo) et de `chat_web_images.go` +//! (`web_images`). +//! +//! Le cache des pages (10 min) est ici : `web_open` puis `web_read` ou +//! `web_grep` dans un tour de la façade ne passent plus par le Go. Le +//! moteur intégré lit le HTML avec go-readability, html-to-markdown et le +//! parseur HTML5 de x/net, que rien en Rust ne reproduit au caractère près : +//! la conversion d'une page et la lecture des résultats DuckDuckGo restent +//! au Go (`/internal/v1/web/fetch`, `/internal/v1/web/search`), sans état. +//! `web_screenshot` reste au Go (navigateur piloté). + +use std::collections::HashMap; +use std::sync::Arc; +use std::time::{Duration, Instant}; + +use hyper::Method; +use serde_json::{Map, Value, json}; + +use crate::hf::go_query_escape; +use crate::store::{BK_STATE, Store}; +use crate::system::{ENGINE_GO, crawl4ai_url, go_lossy, web_engine}; + +/// `webMaxOutput` : plafond d'une réponse d'outil web (en caractères). +pub const MAX_OUTPUT: usize = 8000; +/// `pageCacheTTL`. +pub const PAGE_TTL: Duration = Duration::from_secs(600); +/// `goFetchUA`. +pub const UA: &str = "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36"; +/// `goFetchMaxBytes`. +const MAX_BYTES: usize = 8 << 20; +/// `goFetchTimeout`. +const FETCH_TIMEOUT: Duration = Duration::from_secs(30); +/// Délai d'un appel à Crawl4AI. +const CRAWL_TIMEOUT: Duration = Duration::from_secs(60); + +/// `autoDismissJS` : bandeaux cookies et consentement fermés dans la page. +pub const AUTO_DISMISS_JS: &str = r#"(() => { try { + const TEXT = /^(accept all|accept|i accept|agree|i agree|got it|i understand|j'accepte|tout accepter|accepter|d'accord|allow all|allow|consent|continue|ok)$/i; + const isOverlayish = (el) => { + try { + let cur = el; + for (let i = 0; i < 6 && cur; i++) { + const s = getComputedStyle(cur); + if (s.position === 'fixed' || s.position === 'sticky') return true; + const idcls = ((cur.id || '') + ' ' + (cur.className || '')).toLowerCase(); + if (/cookie|consent|gdpr|cmp|privacy/.test(idcls)) return true; + cur = cur.parentElement; + } + } catch {} + return false; + }; + const candidates = document.querySelectorAll('button, [role="button"], input[type="button"], input[type="submit"]'); + let clicked = 0; + for (const b of candidates) { + if (clicked >= 2) break; + const t = (b.innerText || b.value || b.getAttribute('aria-label') || '').trim(); + if (!t || !TEXT.test(t)) continue; + if (b.offsetParent === null) continue; + if (!isOverlayish(b)) continue; + try { b.click(); clicked++; } catch {} + } + document.querySelectorAll('[id*="cookie" i],[id*="consent" i],[class*="cookie" i],[class*="consent" i],[id*="gdpr" i],[class*="gdpr" i],[id*="cmp" i],[class*="cmp" i]') + .forEach(el => { try { + const s = getComputedStyle(el); + if (s.position === 'fixed' || s.position === 'sticky') el.remove(); + } catch {} }); + document.documentElement.style.overflow = 'auto'; + if (document.body) document.body.style.overflow = 'auto'; +} catch {} })();"#; + +/// Les outils servis ici. +pub fn is_tool(name: &str) -> bool { + matches!( + name, + "web_search" | "web_images" | "web_open" | "web_read" | "web_grep" + ) +} + +/// Une page ouverte (`cacheEntry`). +#[derive(Debug)] +pub struct Entry { + pub url: String, + pub lines: Vec, + at: Instant, +} + +/// `fetchOptions`. +#[derive(Clone, Debug, Default)] +pub struct FetchOpts { + pub force: bool, + pub actions: Vec, + pub dismiss_popups: bool, + pub wait_for: String, +} + +/// `crwlOptions`. +#[derive(Default)] +struct CrwlOpts { + js_code: Vec, + wait_for: String, + page_timeout_ms: i64, + raw_markdown: bool, +} + +/// `searchResult`. +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct SearchResult { + pub title: String, + pub url: String, + pub snippet: String, +} + +/// `imageResult`. +#[derive(Clone, Debug, Default, PartialEq, Eq)] +pub struct ImageResult { + pub image: String, + pub title: String, + pub url: String, + pub width: i64, + pub height: i64, +} + +/// Les outils web d'une façade : cache des pages, clients HTTP. +pub struct Web { + cache: std::sync::Mutex>>, + crawl: reqwest::Client, + http: reqwest::Client, + /// Cookies posés par les sites joints par `http` (le jar en mémoire du + /// client du moteur intégré) : (hôte, nom, valeur), dans l'ordre. + jar: std::sync::Mutex>, + /// `ddgImagesBase`. + pub ddg_base: String, + /// Avance de l'horloge du cache (tests). + skew: std::sync::Mutex, +} + +impl Default for Web { + fn default() -> Self { + Self::new() + } +} + +impl Web { + pub fn new() -> Self { + Self::with_ddg_base("https://duckduckgo.com") + } + + /// Pour les tests : un faux DuckDuckGo. + pub fn with_ddg_base(base: &str) -> Self { + crate::chat::engine::install_crypto(); + Web { + cache: Default::default(), + crawl: reqwest::Client::builder().build().unwrap_or_default(), + http: reqwest::Client::builder() + .redirect(reqwest::redirect::Policy::limited(10)) + .timeout(FETCH_TIMEOUT) + .build() + .unwrap_or_default(), + jar: Default::default(), + ddg_base: base.trim_end_matches('/').to_string(), + skew: Default::default(), + } + } + + /// Avance l'horloge du cache (tests). + pub fn advance(&self, d: Duration) { + *self.skew.lock().unwrap_or_else(|e| e.into_inner()) += d; + } + + fn now(&self) -> Instant { + Instant::now() + *self.skew.lock().unwrap_or_else(|e| e.into_inner()) + } + + /// Un outil web : le résultat que le modèle reçoit, `None` pour un autre + /// outil. + pub async fn exec( + &self, + store: &Store, + name: &str, + args: &Map, + ) -> Option { + let out = match name { + "web_search" => self.tool_search(store, args).await, + "web_images" => self.tool_images(args).await, + "web_open" => self.tool_open(store, args).await, + "web_read" => self.tool_read(args), + "web_grep" => self.tool_grep(args), + _ => return None, + }; + Some(cap_output(&out)) + } + + // --- Outils ------------------------------------------------------------- + + /// `toolWebSearch`. + async fn tool_search(&self, store: &Store, args: &Map) -> String { + let query = arg_str(args, "query"); + let limit = arg_int(args, "limit").unwrap_or(8).clamp(1, 20) as usize; + let results = match self.search(store, &query, limit).await { + Ok(r) => r, + Err(e) => return format!("❌ Recherche échouée : {e}"), + }; + if results.is_empty() { + return format!("Aucun résultat pour « {query} »"); + } + let mut b = format!( + "# Recherche : {query}\n{} résultat(s) DuckDuckGo\n\n", + results.len() + ); + for (i, r) in results.iter().enumerate() { + b.push_str(&format!( + "{}. {}\n {}\n {}\n\n", + i + 1, + r.title, + r.url, + r.snippet + )); + } + b.trim_end_matches('\n').to_string() + } + + /// `toolWebImages`. + async fn tool_images(&self, args: &Map) -> String { + let query = arg_str(args, "query"); + let limit = arg_int(args, "limit").unwrap_or(6).clamp(1, 20) as usize; + let results = match self.images(&query, limit).await { + Ok(r) => r, + Err(e) => return format!("❌ Recherche d'images échouée : {e}"), + }; + if results.is_empty() { + return format!("Aucune image pour « {query} »"); + } + let mut b = format!( + "# Images : {query}\n{} résultat(s) DuckDuckGo\n\n", + results.len() + ); + for (i, r) in results.iter().enumerate() { + b.push_str(&format!( + "{}. {} ({}x{})\n image : {}\n page : {}\n\n", + i + 1, + r.title, + r.width, + r.height, + r.image, + r.url + )); + } + b.push_str("To show an image to the user, write ![title](image URL) in your answer."); + b + } + + /// `toolWebOpen`. + async fn tool_open(&self, store: &Store, args: &Map) -> String { + let u = arg_str(args, "url"); + let mut opts = FetchOpts { + dismiss_popups: true, + ..Default::default() + }; + if let Some(Value::Bool(b)) = args.get("refresh") { + opts.force = *b; + } + if let Some(Value::Bool(b)) = args.get("dismiss_popups") { + opts.dismiss_popups = *b; + } + if let Some(Value::String(s)) = args.get("wait_for") { + opts.wait_for = s.clone(); + } + if let Some(Value::Array(a)) = args.get("actions") { + opts.actions = a + .iter() + .filter_map(|v| v.as_str().map(String::from)) + .collect(); + } + let entry = match self.get_page(store, &u, &opts).await { + Ok(e) => e, + Err(e) => return format!("❌ {e}"), + }; + let total = entry.lines.len(); + let chars = total + entry.lines.iter().map(String::len).sum::(); + format!( + "# Ouvert : {}\nTotal : {total} lignes, {} ({chars} caractères)\nEn cache 10 min. Utilise web_read ou web_grep pour lire.\n\n## Plan (n° de ligne des titres)\n```\n{}\n```", + entry.url, + format_bytes(chars), + extract_outline(&entry.lines) + ) + } + + /// `toolWebRead`. + fn tool_read(&self, args: &Map) -> String { + let u = arg_str(args, "url"); + let Some(entry) = self.find_cached(&u) else { + return format!("❌ Page absente du cache. Appelle d'abord web_open(\"{u}\")."); + }; + let total = entry.lines.len() as i64; + let offset = arg_int(args, "offset").unwrap_or(1).max(1); + let limit = arg_int(args, "limit").unwrap_or(80).clamp(1, 500); + let start = (offset - 1).min(total); + let end = start.saturating_add(limit).min(total); + let slice = &entry.lines[start as usize..end as usize]; + let remaining = total - end; + let tail = if remaining > 0 { + format!(" ({remaining} de plus en dessous)") + } else { + " (fin de page)".to_string() + }; + format!( + "# {}\nLignes {offset}–{end} sur {total}{tail}\n\n```\n{}\n```", + entry.url, + format_lines(slice, offset) + ) + } + + /// `toolWebGrep`. + fn tool_grep(&self, args: &Map) -> String { + let u = arg_str(args, "url"); + let pattern = arg_str(args, "pattern"); + let Some(entry) = self.find_cached(&u) else { + return format!("❌ Page absente du cache. Appelle d'abord web_open(\"{u}\")."); + }; + let expr = format!("(?i){pattern}"); + let Some(re) = crate::chat::stream::compile_go_regex(&expr) else { + return format!("❌ Regex invalide : {}", go_regexp_error(&expr)); + }; + let ctx = arg_int(args, "context").unwrap_or(2).max(0); + let max_matches = arg_int(args, "max_matches").unwrap_or(30).max(1); + let lines = &entry.lines; + let mut idx: Vec = Vec::new(); + for (i, l) in lines.iter().enumerate() { + if idx.len() as i64 >= max_matches { + break; + } + if re.is_match(l) { + idx.push(i); + } + } + if idx.is_empty() { + return format!("# {}\nAucun match pour /{pattern}/i", entry.url); + } + // Fenêtres de contexte fusionnées quand elles se chevauchent. + let last = lines.len() as i64 - 1; + let mut ranges: Vec<(i64, i64)> = Vec::new(); + for &i in &idx { + let i = i as i64; + let s = i.saturating_sub(ctx).max(0); + let e = i.saturating_add(ctx).min(last); + match ranges.last_mut() { + Some(r) if s <= r.1 + 1 => r.1 = r.1.max(e), + _ => ranges.push((s, e)), + } + } + let blocks: Vec = ranges + .iter() + .map(|&(s, e)| { + format!( + "```\n{}\n```", + format_lines(&lines[s as usize..=e as usize], s + 1) + ) + }) + .collect(); + let capped = if idx.len() as i64 == max_matches { + format!(" (plafonné à {max_matches})") + } else { + String::new() + }; + format!( + "# {}\n{} match(es) pour /{pattern}/i{capped}\n\n{}", + entry.url, + idx.len(), + blocks.join("\n\n---\n\n") + ) + } + + // --- Cache -------------------------------------------------------------- + + /// `findCached` : l'ouverture la plus récente d'une URL, toutes options. + pub fn find_cached(&self, raw: &str) -> Option> { + let u = normalize_crawl_url(raw); + let now = self.now(); + let cache = self.cache.lock().unwrap_or_else(|e| e.into_inner()); + let mut best: Option<&Arc> = None; + for (k, v) in cache.iter() { + if k == &u || k.starts_with(&format!("{u}#")) { + if now.saturating_duration_since(v.at) > PAGE_TTL { + continue; + } + if best.is_none_or(|b| v.at > b.at) { + best = Some(v); + } + } + } + best.cloned() + } + + /// `getPage` : la page, du cache ou récupérée. + pub async fn get_page( + &self, + store: &Store, + raw: &str, + opts: &FetchOpts, + ) -> Result, String> { + let u = normalize_crawl_url(raw); + let key = cache_key_for(&u, opts); + if !opts.force { + let cache = self.cache.lock().unwrap_or_else(|e| e.into_inner()); + if let Some(c) = cache.get(&key) + && self.now().saturating_duration_since(c.at) < PAGE_TTL + { + return Ok(c.clone()); + } + } + let mut js_code = Vec::new(); + if opts.dismiss_popups { + js_code.push(AUTO_DISMISS_JS.to_string()); + } + js_code.extend(opts.actions.iter().cloned()); + let page_timeout_ms = if !opts.wait_for.is_empty() || !opts.actions.is_empty() { + 45000 + } else { + 0 + }; + let cfg = store.config().await; + let md = if web_engine(&cfg) == ENGINE_GO { + go_fetch(store, &u).await? + } else { + self.run_crwl( + store, + &cfg, + &u, + &CrwlOpts { + js_code, + wait_for: opts.wait_for.clone(), + page_timeout_ms, + raw_markdown: false, + }, + ) + .await? + }; + if md.trim().is_empty() { + return Err("page vide".into()); + } + let entry = Arc::new(Entry { + url: u, + lines: normalize_lines(&md), + at: self.now(), + }); + self.cache + .lock() + .unwrap_or_else(|e| e.into_inner()) + .insert(key, entry.clone()); + Ok(entry) + } + + // --- Crawl4AI ----------------------------------------------------------- + + /// `runCrwl` : POST {base}/crawl, le markdown du premier résultat. + async fn run_crwl( + &self, + store: &Store, + cfg: &HashMap, + target: &str, + opts: &CrwlOpts, + ) -> Result { + let base = crawl4ai_url(cfg); + if base.is_empty() { + return Err("aucun serveur Crawl4AI configuré (CRAWL4AI_URL)".into()); + } + // Clés dans l'ordre où `json.Marshal` écrit une map : triées. + let mut params = Map::new(); + if !opts.js_code.is_empty() { + params.insert("js_code".into(), json!(opts.js_code)); + } + if opts.page_timeout_ms > 0 { + params.insert("page_timeout".into(), json!(opts.page_timeout_ms)); + } + if !opts.wait_for.is_empty() { + params.insert("wait_for".into(), json!(opts.wait_for)); + } + let body = crate::gojson::marshal(&json!({ + "browser_config": { "params": { "headless": true }, "type": "BrowserConfig" }, + "crawler_config": { "params": params, "type": "CrawlerRunConfig" }, + "urls": [target], + })); + let url = format!("{base}/crawl"); + let key = store.get_str(BK_STATE, "crawl_key").await; + let mut rq = self + .crawl + .post(&url) + .header("Content-Type", "application/json") + .body(body); + if !key.is_empty() { + rq = rq.header("Authorization", format!("Bearer {key}")); + } + let call = async { + let resp = rq.send().await.map_err(|e| { + format!( + "Crawl4AI injoignable ({base}): {}", + go_net_err("Post", &url, &e) + ) + })?; + let status = resp.status().as_u16(); + let raw = resp.bytes().await.map(|b| b.to_vec()).unwrap_or_default(); + Ok::<_, String>((status, raw)) + }; + let (status, raw) = tokio::time::timeout(CRAWL_TIMEOUT, call) + .await + .map_err(|_| { + format!("Crawl4AI injoignable ({base}): Post \"{url}\": context deadline exceeded") + })??; + if status != 200 { + return Err(format!( + "Crawl4AI HTTP {status}: {}", + tail_runes(&go_lossy(&raw), 300) + )); + } + crawl_markdown(&raw, opts.raw_markdown) + } + + // --- Recherche ---------------------------------------------------------- + + /// `duckduckgoSearch`. + async fn search( + &self, + store: &Store, + query: &str, + limit: usize, + ) -> Result, String> { + let cfg = store.config().await; + if web_engine(&cfg) == ENGINE_GO { + return go_search(store, query, limit).await; + } + let url = format!( + "https://html.duckduckgo.com/html/?q={}", + go_query_escape(query.as_bytes()) + ); + let md = self + .run_crwl( + store, + &cfg, + &url, + &CrwlOpts { + raw_markdown: true, + page_timeout_ms: 30000, + ..Default::default() + }, + ) + .await?; + parse_ddg_markdown(&md, limit) + } + + /// `duckduckgoImages` : le jeton vqd de la page de recherche, puis i.js. + async fn images(&self, query: &str, limit: usize) -> Result, String> { + let deadline = tokio::time::Instant::now() + FETCH_TIMEOUT; + let q = go_query_escape(query.as_bytes()); + let page = format!("{}/?q={q}&iax=images&ia=images", self.ddg_base); + let body = self.ddg_get(&page, "", deadline).await?; + let vqd = regex::bytes::Regex::new(r#"vqd=["']?([0-9-]+)"#).expect("regex"); + let Some(m) = vqd.captures(&body) else { + return Err("jeton DuckDuckGo introuvable (défi anti-bot ?)".into()); + }; + let token = String::from_utf8_lossy(&m[1]).into_owned(); + let api = format!( + "{}/i.js?l=fr-fr&o=json&f=,,,,,&p=1&q={q}&vqd={token}", + self.ddg_base + ); + let body = self + .ddg_get(&api, "https://duckduckgo.com/", deadline) + .await?; + let all = decode_images(&body).map_err(|e| format!("réponse illisible : {e}"))?; + let mut out: Vec = Vec::new(); + let mut seen = std::collections::HashSet::new(); + for it in all { + if it.image.is_empty() || seen.contains(&it.image) || !it.image.starts_with("http") { + continue; + } + seen.insert(it.image.clone()); + out.push(it); + if out.len() >= limit { + break; + } + } + Ok(out) + } + + /// `ddgGet`. + async fn ddg_get( + &self, + target: &str, + referer: &str, + deadline: tokio::time::Instant, + ) -> Result, String> { + let host = url::Url::parse(target) + .ok() + .and_then(|u| u.host_str().map(String::from)) + .unwrap_or_default(); + let mut rq = self + .http + .get(target) + .header("User-Agent", UA) + .header("Accept-Language", "fr-FR,fr;q=0.9,en;q=0.8"); + if !referer.is_empty() { + rq = rq + .header("Referer", referer) + .header("Accept", "application/json, text/javascript, */*; q=0.01"); + } + let cookies: Vec = self + .jar + .lock() + .unwrap_or_else(|e| e.into_inner()) + .iter() + .filter(|(h, _, _)| *h == host) + .map(|(_, n, v)| format!("{n}={v}")) + .collect(); + if !cookies.is_empty() { + rq = rq.header("Cookie", cookies.join("; ")); + } + let call = async { + let resp = rq + .send() + .await + .map_err(|e| format!("échec de la requête : {}", go_net_err("Get", target, &e)))?; + self.keep_cookies(&host, resp.headers()); + let status = resp.status().as_u16(); + let mut body = resp.bytes().await.map_err(|e| e.to_string())?.to_vec(); + body.truncate(MAX_BYTES); + Ok::<_, String>((status, body)) + }; + let (status, body) = tokio::time::timeout_at(deadline, call) + .await + .map_err(|_| { + format!("échec de la requête : Get \"{target}\": context deadline exceeded") + })??; + if status >= 400 { + return Err(format!("HTTP {status}")); + } + Ok(body) + } + + /// Les `Set-Cookie` d'une réponse, gardés pour l'hôte (nom=valeur ; le + /// suivant du même nom remplace, à sa place). + fn keep_cookies(&self, host: &str, headers: &reqwest::header::HeaderMap) { + let mut jar = self.jar.lock().unwrap_or_else(|e| e.into_inner()); + for v in headers.get_all("set-cookie") { + let Ok(v) = v.to_str() else { continue }; + let pair = v.split(';').next().unwrap_or("").trim(); + let Some((name, value)) = pair.split_once('=') else { + continue; + }; + let (name, value) = (name.trim().to_string(), value.trim().to_string()); + if name.is_empty() { + continue; + } + match jar.iter_mut().find(|(h, n, _)| h == host && *n == name) { + Some(c) => c.2 = value, + None => jar.push((host.to_string(), name, value)), + } + } + } +} + +// --- Moteur intégré, au Go --------------------------------------------------- + +/// `goFetchMarkdown`, par l'API interne du Go. +async fn go_fetch(store: &Store, u: &str) -> Result { + let v = store + .internal_json( + Method::POST, + "/internal/v1/web/fetch", + Some(&json!({ "url": u })), + ) + .await + .map_err(|e| format!("moteur web intégré injoignable : {e}"))?; + if let Some(e) = v + .get("error") + .and_then(Value::as_str) + .filter(|e| !e.is_empty()) + { + return Err(e.to_string()); + } + Ok(v.get("markdown") + .and_then(Value::as_str) + .unwrap_or("") + .to_string()) +} + +/// `goSearch`, par l'API interne du Go. +async fn go_search(store: &Store, query: &str, limit: usize) -> Result, String> { + let v = store + .internal_json( + Method::POST, + "/internal/v1/web/search", + Some(&json!({ "query": query, "limit": limit })), + ) + .await + .map_err(|e| format!("moteur web intégré injoignable : {e}"))?; + if let Some(e) = v + .get("error") + .and_then(Value::as_str) + .filter(|e| !e.is_empty()) + { + return Err(e.to_string()); + } + let s = |r: &Value, k: &str| r.get(k).and_then(Value::as_str).unwrap_or("").to_string(); + Ok(v.get("results") + .and_then(Value::as_array) + .into_iter() + .flatten() + .map(|r| SearchResult { + title: s(r, "title"), + url: s(r, "url"), + snippet: s(r, "snippet"), + }) + .collect()) +} + +// --- Fonctions pures --------------------------------------------------------- + +/// Texte d'un argument (`args[k].(string)`). +fn arg_str(args: &Map, k: &str) -> String { + args.get(k) + .and_then(Value::as_str) + .unwrap_or("") + .to_string() +} + +/// Entier d'un argument nombre (`int(args[k].(float64))`). +fn arg_int(args: &Map, k: &str) -> Option { + args.get(k).and_then(Value::as_f64).map(go_int) +} + +/// `int(f)` du Go : tronqué ; hors bornes, l'entier indéfini d'amd64 (ou la +/// saturation d'arm64). +pub fn go_int(f: f64) -> i64 { + const TWO63: f64 = 9_223_372_036_854_775_808.0; + if f.is_nan() || !(-TWO63..TWO63).contains(&f) { + if cfg!(target_arch = "x86_64") { + return i64::MIN; + } + return f as i64; + } + f as i64 +} + +/// `capWebOutput` : le début gardé, la suite à relire par tranches. +pub fn cap_output(s: &str) -> String { + match s.char_indices().nth(MAX_OUTPUT) { + None => s.to_string(), + Some((i, _)) => format!( + "{}\n…[tronqué : réponse trop longue. Relis par tranches avec web_read(offset, limit) ou cible avec web_grep.]", + &s[..i] + ), + } +} + +/// `tailRunes`. +pub fn tail_runes(s: &str, n: usize) -> String { + let count = s.chars().count(); + if count <= n { + return s.to_string(); + } + s.chars().skip(count - n).collect() +} + +/// `formatBytes`. +pub fn format_bytes(n: usize) -> String { + if n < 1024 { + format!("{n} B") + } else if n < 1024 * 1024 { + format!("{:.1} KB", n as f64 / 1024.0) + } else { + format!("{:.2} MB", n as f64 / 1024.0 / 1024.0) + } +} + +/// `formatLines` : lignes numérotées à partir de `start`. +pub fn format_lines(lines: &[String], start: i64) -> String { + let mut b = String::new(); + for (i, l) in lines.iter().enumerate() { + b.push_str(&format!("{:5} | {l}\n", start + i as i64)); + } + b.trim_end_matches('\n').to_string() +} + +/// `extractOutline` : les titres markdown et leur n° de ligne. +pub fn extract_outline(lines: &[String]) -> String { + let re = go_re(r"^(#{1,6})\s+(.+?)\s*$"); + let mut out = Vec::new(); + for (i, l) in lines.iter().enumerate() { + if let Some(m) = re.captures(l) { + let indent = " ".repeat(m[1].len() - 1); + out.push(format!("{:5} | {indent}{}", i + 1, &m[2])); + } + } + if out.is_empty() { + return "(aucun titre markdown trouvé)".into(); + } + out.join("\n") +} + +/// Une regex écrite pour le Go, traduite. +fn go_re(p: &str) -> regex::Regex { + crate::chat::stream::compile_go_regex(p).expect("regex du Go") +} + +/// `normalizeLines` : \r\n → \n, fins de ligne nettoyées, vides et +/// doublons consécutifs réduits, vides du bord retirés. +pub fn normalize_lines(raw: &str) -> Vec { + let src = raw.replace("\r\n", "\n"); + let mut out: Vec = Vec::new(); + let mut prev = String::new(); + let mut blank_run = 0; + for l in src.split('\n') { + let l = l.trim_end_matches([' ', '\t']); + if l.trim().is_empty() { + blank_run += 1; + if blank_run > 1 { + continue; + } + out.push(String::new()); + prev.clear(); + continue; + } + blank_run = 0; + if l == prev { + continue; + } + out.push(l.to_string()); + prev = l.to_string(); + } + let start = out.iter().position(|l| !l.is_empty()).unwrap_or(out.len()); + let end = out + .iter() + .rposition(|l| !l.is_empty()) + .map_or(start, |e| e + 1); + out[start..end.max(start)].to_vec() +} + +/// `cacheKeyFor` : l'URL, suivie de l'empreinte des options qui changent la +/// page. +pub fn cache_key_for(u: &str, opts: &FetchOpts) -> String { + let a = if opts.actions.is_empty() { + Value::Null + } else { + json!(opts.actions) + }; + let fp = + crate::gojson::marshal(&json!({ "a": a, "d": opts.dismiss_popups, "w": opts.wait_for })); + if fp == r#"{"a":null,"d":true,"w":""}"# { + return u.to_string(); + } + format!("{u}#{}", &hex::encode(md5(fp.as_bytes()))[..8]) +} + +/// Les morceaux d'une URL lue comme `url.Parse` du Go : (hôte, chemin +/// décodé, requête brute) ; `None` si le Go la refuse. +fn go_url_parts(raw: &str) -> Option<(String, String, String)> { + if raw.bytes().any(|b| b < 0x20 || b == 0x7f) { + return None; + } + let s = raw.split('#').next().unwrap_or(""); + // Schéma : une lettre, puis lettres, chiffres, « + - . », avant « : ». + let mut rest = s; + for (i, c) in s.char_indices() { + match c { + 'a'..='z' | 'A'..='Z' => {} + '0'..='9' | '+' | '-' | '.' if i > 0 => {} + ':' => { + if i == 0 { + return None; + } + rest = &s[i + 1..]; + break; + } + _ => break, + } + } + let (rest, query) = match rest.split_once('?') { + Some((r, q)) => (r, q.to_string()), + None => (rest, String::new()), + }; + let (host, path) = match rest.strip_prefix("//") { + Some(a) if !rest.starts_with("///") || s.len() != rest.len() => { + let end = a.find('/').unwrap_or(a.len()); + let auth = &a[..end]; + let host = auth.rsplit_once('@').map_or(auth, |(_, h)| h); + (host.to_string(), &a[end..]) + } + _ => (String::new(), rest), + }; + let path = percent_decode(path, false)?; + Some((host, go_lossy(&path), query)) +} + +/// `%XX` décodés (`+` en espace pour une requête) ; `None` sur un `%` mal +/// formé, comme le Go. +fn percent_decode(s: &str, plus: bool) -> Option> { + let b = s.as_bytes(); + let mut out = Vec::with_capacity(b.len()); + let mut i = 0; + while i < b.len() { + match b[i] { + b'%' => { + let h = |c: u8| (c as char).to_digit(16); + let (Some(hi), Some(lo)) = ( + b.get(i + 1).and_then(|&c| h(c)), + b.get(i + 2).and_then(|&c| h(c)), + ) else { + return None; + }; + out.push((hi * 16 + lo) as u8); + i += 3; + } + b'+' if plus => { + out.push(b' '); + i += 1; + } + c => { + out.push(c); + i += 1; + } + } + } + Some(out) +} + +/// `normalizeCrawlURL` : github.com/owner/repo → son README brut. +pub fn normalize_crawl_url(raw: &str) -> String { + let Some((host, path, _)) = go_url_parts(raw) else { + return raw.to_string(); + }; + if host == "github.com" { + let parts: Vec<&str> = path.trim_matches('/').split('/').collect(); + if parts.len() == 2 { + return format!( + "https://raw.githubusercontent.com/{}/{}/HEAD/README.md", + parts[0], parts[1] + ); + } + } + raw.to_string() +} + +/// `decodeHTMLEntities`. +pub fn decode_html_entities(s: &str) -> String { + const REPL: [(&str, &str); 7] = [ + ("&", "&"), + ("<", "<"), + (">", ">"), + (""", "\""), + ("'", "'"), + ("/", "/"), + (" ", " "), + ]; + let mut out = String::with_capacity(s.len()); + let mut i = 0; + 'outer: while i < s.len() { + for (old, new) in REPL { + if s[i..].starts_with(old) { + out.push_str(new); + i += old.len(); + continue 'outer; + } + } + let c = s[i..].chars().next().expect("caractère"); + out.push(c); + i += c.len_utf8(); + } + let re = go_re(r"&#(\d+);"); + re.replace_all(&out, |m: ®ex::Captures| { + let n: i64 = m[1].parse().unwrap_or(0); + if n > 0 { + char::from_u32(n as i32 as u32) + .filter(|_| (n as i32) >= 0) + .unwrap_or('\u{fffd}') + .to_string() + } else { + m[0].to_string() + } + }) + .into_owned() +} + +/// `decodeUddg` : DuckDuckGo enrobe ses résultats en +/// `//duckduckgo.com/l/?uddg=URL_ENCODÉE`. +pub fn decode_uddg(raw: &str) -> String { + let s = if raw.starts_with("//") { + format!("https:{raw}") + } else { + raw.to_string() + }; + let Some((_, _, query)) = go_url_parts(&s) else { + return s; + }; + // `u.Query().Get("uddg")` : la première paire lisible. + let mut q: Option = None; + for pair in query.split('&') { + if pair.is_empty() || pair.contains(';') { + continue; + } + let (k, v) = pair.split_once('=').unwrap_or((pair, "")); + let (Some(k), Some(v)) = (percent_decode(k, true), percent_decode(v, true)) else { + continue; + }; + if k == b"uddg" { + q = Some(go_lossy(&v)); + break; + } + } + match q.filter(|q| !q.is_empty()) { + Some(q) => match percent_decode(&q, true) { + Some(dec) => go_lossy(&dec), + None => s, + }, + None => s, + } +} + +/// La lecture des résultats DuckDuckGo dans le markdown de Crawl4AI +/// (`duckduckgoSearch`). +pub fn parse_ddg_markdown(md: &str, limit: usize) -> Result, String> { + if md.contains("anomaly-modal") || md.contains("anomaly.js") { + return Err("DuckDuckGo a renvoyé un défi anti-bot".into()); + } + let head = go_re(r"^##\s+\[([^\]]+)\]\(([^)]+)\)\s*$"); + let link = go_re(r"\[([^\]]+)\]\([^)]+\)"); + let urlish = go_re(r"^[\w.-]+\.[a-z]{2,}"); + let lines: Vec<&str> = md.split('\n').collect(); + let mut results: Vec = Vec::new(); + let mut i = 0; + while i < lines.len() && results.len() < limit { + let Some(h) = head.captures(lines[i]) else { + i += 1; + continue; + }; + let title = decode_html_entities(&h[1]) + .replace("**", "") + .trim() + .to_string(); + let u = decode_uddg(&h[2]); + if title.is_empty() || u.is_empty() || u.contains("duckduckgo.com") { + i += 1; + continue; + } + let mut snippet = String::new(); + let mut j = i + 1; + while j < i + 6 && j < lines.len() { + let ln = lines[j].trim(); + j += 1; + if ln.is_empty() { + continue; + } + for lm in link.captures_iter(ln) { + let text = lm[1].trim(); + if text.is_empty() || text.starts_with('!') || urlish.is_match(text) { + continue; + } + snippet = decode_html_entities(text) + .replace("**", "") + .trim() + .to_string(); + break; + } + if !snippet.is_empty() { + break; + } + } + if !results.iter().any(|r| r.url == u) { + results.push(SearchResult { + title, + url: u, + snippet, + }); + } + i += 1; + } + Ok(results) +} + +/// Le markdown du premier résultat d'une réponse de Crawl4AI : `{results: +/// [...]}` ou un résultat seul, lus comme `json.Unmarshal` du Go. +pub fn crawl_markdown(raw: &[u8], prefer_raw: bool) -> Result { + let v = go_unmarshal_value(raw).ok(); + // `{results:[…]}` d'abord. + let mut results: Option> = None; + let mut results_ok = false; + if let Some(v) = &v { + match v { + Value::Object(o) => match go_field(o, "results") { + None | Some(Value::Null) => results_ok = true, + Some(Value::Array(a)) => { + let mut ok = true; + let list: Vec = a + .iter() + .map(|e| { + let (r, good) = CrwlResult::from(e); + ok &= good; + r + }) + .collect(); + results_ok = ok; + results = Some(list); + } + Some(_) => {} + }, + Value::Null => results_ok = true, + _ => {} + } + } + let mut list = results.unwrap_or_default(); + if !results_ok || list.is_empty() { + // Un résultat seul. + let single = match &v { + Some(Value::Null) => Some(CrwlResult::default()), + Some(o @ Value::Object(_)) => { + let (r, good) = CrwlResult::from(o); + good.then_some(r) + } + _ => None, + }; + if let Some(s) = single { + list = vec![s]; + } + } + let Some(r) = list.into_iter().next() else { + return Err("Crawl4AI : réponse vide".into()); + }; + if !r.success { + let msg = if r.error_message.is_empty() { + "échec du crawl".to_string() + } else { + r.error_message + }; + return Err(format!("Crawl4AI : {}", tail_runes(&msg, 300))); + } + Ok(r.markdown(prefer_raw)) +} + +/// `crwlResult`. +#[derive(Default)] +struct CrwlResult { + success: bool, + error_message: String, + markdown: Option, +} + +impl CrwlResult { + /// Le résultat, et faux si un champ a le mauvais type (erreur du Go). + fn from(v: &Value) -> (Self, bool) { + let mut r = CrwlResult::default(); + let Value::Object(o) = v else { + return (r, v.is_null()); + }; + let mut ok = true; + match go_field(o, "success") { + None | Some(Value::Null) => {} + Some(Value::Bool(b)) => r.success = *b, + Some(_) => ok = false, + } + match go_field(o, "error_message") { + None | Some(Value::Null) => {} + Some(Value::String(s)) => r.error_message = s.clone(), + Some(_) => ok = false, + } + r.markdown = go_field(o, "markdown").cloned(); + (r, ok) + } + + /// `crwlResult.markdown` : une chaîne, ou `{raw_markdown, fit_markdown}`. + fn markdown(&self, prefer_raw: bool) -> String { + match &self.markdown { + None | Some(Value::Null) => String::new(), + Some(Value::String(s)) => s.clone(), + Some(Value::Object(o)) => { + let field = |k: &str| match go_field(o, k) { + None | Some(Value::Null) => Some(String::new()), + Some(Value::String(s)) => Some(s.clone()), + Some(_) => None, + }; + let (Some(raw), Some(fit)) = (field("raw_markdown"), field("fit_markdown")) else { + return String::new(); + }; + if prefer_raw { + if !raw.is_empty() { raw } else { fit } + } else if !fit.is_empty() { + fit + } else { + raw + } + } + Some(_) => String::new(), + } + } +} + +/// Un champ d'objet comme le cherche `encoding/json` : le nom exact, sinon +/// sans casse ; la dernière occurrence l'emporte (déjà vrai d'un objet lu +/// par serde_json). +fn go_field<'a>(o: &'a Map, name: &str) -> Option<&'a Value> { + o.get(name).or_else(|| { + o.iter() + .filter(|(k, _)| k.eq_ignore_ascii_case(name)) + .map(|(_, v)| v) + .next_back() + }) +} + +/// `json.Unmarshal` dans une valeur : erreurs de syntaxe au texte du Go. +pub fn go_unmarshal_value(raw: &[u8]) -> Result { + let v = match crate::gojson::first_value(raw) { + Ok(v) => v, + Err(e) if e == "EOF" || e == "unexpected EOF" => { + return Err("unexpected end of JSON input".into()); + } + Err(e) => return Err(e), + }; + let start = v.as_ptr() as usize - raw.as_ptr() as usize; + if let Some(&c) = raw[start + v.len()..] + .iter() + .find(|c| !matches!(c, b' ' | b'\t' | b'\r' | b'\n')) + { + return Err(format!( + "invalid character {} after top-level value", + crate::gojson::quote_char(c) + )); + } + serde_json::from_slice(v).map_err(|e| e.to_string()) +} + +/// La réponse de `i.js`, lue comme `json.Unmarshal` dans +/// `struct{ Results []imageResult }` : les erreurs de type au texte du Go +/// (la première), après avoir tout lu. +pub fn decode_images(raw: &[u8]) -> Result, String> { + let v = go_unmarshal_value(raw)?; + let mut err: Option = None; + let mut fail = |e: String| { + if err.is_none() { + err = Some(e); + } + }; + let mut out = Vec::new(); + match &v { + Value::Object(o) => match go_field(o, "results") { + None | Some(Value::Null) => {} + Some(Value::Array(a)) => { + for e in a { + let mut r = ImageResult::default(); + match e { + Value::Null => {} + Value::Object(f) => { + for (name, slot) in [("image", 0), ("title", 1), ("url", 2)] { + match go_field(f, name) { + None | Some(Value::Null) => {} + Some(Value::String(s)) => match slot { + 0 => r.image = s.clone(), + 1 => r.title = s.clone(), + _ => r.url = s.clone(), + }, + Some(other) => fail(format!( + "json: cannot unmarshal {} into Go struct field imageResult.results.{name} of type string", + go_kind(other) + )), + } + } + for (name, wide) in [("width", true), ("height", false)] { + match go_field(f, name) { + None | Some(Value::Null) => {} + Some(Value::Number(n)) => match n.as_i64() { + Some(i) if !n.to_string().contains(['.', 'e', 'E']) => { + if wide { + r.width = i + } else { + r.height = i + } + } + _ => fail(format!( + "json: cannot unmarshal number {n} into Go struct field imageResult.results.{name} of type int" + )), + }, + Some(other) => fail(format!( + "json: cannot unmarshal {} into Go struct field imageResult.results.{name} of type int", + go_kind(other) + )), + } + } + } + other => fail(format!( + "json: cannot unmarshal {} into Go struct field .results of type loki.imageResult", + go_kind(other) + )), + } + out.push(r); + } + } + Some(other) => fail(format!( + "json: cannot unmarshal {} into Go struct field .results of type []loki.imageResult", + go_kind(other) + )), + }, + Value::Null => {} + other => fail(format!( + "json: cannot unmarshal {} into Go value of type struct {{ Results []loki.imageResult \"json:\\\"results\\\"\" }}", + go_kind(other) + )), + } + match err { + Some(e) => Err(e), + None => Ok(out), + } +} + +/// Le nom d'une valeur JSON dans les erreurs du Go. +fn go_kind(v: &Value) -> &'static str { + match v { + Value::Null => "null", + Value::Bool(_) => "bool", + Value::Number(_) => "number", + Value::String(_) => "string", + Value::Array(_) => "array", + Value::Object(_) => "object", + } +} + +/// L'erreur d'un client HTTP du Go (`*url.Error`), au mieux. +fn go_net_err(method: &str, url: &str, e: &reqwest::Error) -> String { + if e.is_timeout() { + return format!("{method} \"{url}\": context deadline exceeded"); + } + if e.is_redirect() { + return format!("{method} \"{url}\": trop de redirections"); + } + if e.is_connect() { + let mut src: Option<&(dyn std::error::Error + 'static)> = Some(e); + while let Some(s) = src { + if let Some(io) = s.downcast_ref::() + && io.kind() == std::io::ErrorKind::ConnectionRefused + { + let addr = url::Url::parse(url) + .ok() + .and_then(|u| { + let port = u.port_or_known_default()?; + Some(match u.host()? { + url::Host::Ipv6(a) => format!("[{a}]:{port}"), + h => format!("{h}:{port}"), + }) + }) + .unwrap_or_default(); + return format!("{method} \"{url}\": dial tcp {addr}: connect: connection refused"); + } + src = s.source(); + } + } + format!("{method} \"{url}\": {e}") +} + +/// L'erreur de `regexp.Compile` du Go pour les fautes courantes (parenthèse, +/// crochet, répétition sans argument ou imbriquée, échappement inconnu) ; +/// sinon « Perl non pris en charge ». +pub fn go_regexp_error(expr: &str) -> String { + let fmt = |code: &str, frag: &str| format!("error parsing regexp: {code}: `{frag}`"); + let cs: Vec = expr.chars().collect(); + let mut depth = 0i32; + // Peut-on répéter ce qui précède ? (début, après « ( » ou « | » : non) + let mut can_repeat = false; + // Le précédent était-il déjà une répétition (et son début) ? + let mut last_repeat: Option = None; + let mut i = 0; + while i < cs.len() { + let c = cs[i]; + match c { + '\\' => { + let Some(&n) = cs.get(i + 1) else { + return fmt("trailing backslash at end of expression", ""); + }; + let ok = n.is_ascii_punctuation() + || matches!( + n, + 'a' | 'f' + | 't' + | 'n' + | 'r' + | 'v' + | 'd' + | 'D' + | 's' + | 'S' + | 'w' + | 'W' + | 'b' + | 'B' + | 'A' + | 'z' + | 'p' + | 'P' + | 'Q' + | 'E' + | 'x' + | 'C' + | '0'..='7' + ); + if !ok || ('1'..='7').contains(&n) { + return fmt("invalid escape sequence", &format!("\\{n}")); + } + i += 2; + can_repeat = true; + last_repeat = None; + continue; + } + '[' => { + // Jusqu'au « ] » fermant (un « ] » en tête est littéral). + let start = i; + let mut j = i + 1; + if cs.get(j) == Some(&'^') { + j += 1; + } + if cs.get(j) == Some(&']') { + j += 1; + } + let mut closed = false; + while j < cs.len() { + match cs[j] { + '\\' => j += 2, + '[' if cs.get(j + 1) == Some(&':') => { + match (j + 2..cs.len().saturating_sub(1)) + .find(|&k| cs[k] == ':' && cs[k + 1] == ']') + { + Some(k) => j = k + 2, + None => j += 1, + } + } + ']' => { + closed = true; + break; + } + _ => j += 1, + } + } + if !closed { + return fmt("missing closing ]", &cs[start..].iter().collect::()); + } + i = j + 1; + can_repeat = true; + last_repeat = None; + continue; + } + '(' => { + depth += 1; + if cs.get(i + 1) == Some(&'?') { + // Drapeaux seuls « (?i) » : rien à répéter ensuite. + let k = cs[i..].iter().position(|&c| c == ')' || c == ':'); + if let Some(k) = k + && cs[i + k] == ')' + { + depth -= 1; + i += k + 1; + can_repeat = false; + last_repeat = None; + continue; + } + i += k.map_or(2, |k| k + 1); + } else { + i += 1; + } + can_repeat = false; + last_repeat = None; + continue; + } + ')' => { + if depth == 0 { + return fmt("unexpected )", expr); + } + depth -= 1; + can_repeat = true; + last_repeat = None; + } + '|' => { + can_repeat = false; + last_repeat = None; + } + '*' | '+' | '?' => { + if let Some(start) = last_repeat { + // « ? » après une répétition la rend paresseuse, une fois. + let prev = cs[i - 1]; + if !(c == '?' && prev != '?' && i - start == 1) { + return fmt( + "invalid nested repetition operator", + &cs[start..=i].iter().collect::(), + ); + } + i += 1; + continue; + } + if !can_repeat { + return fmt("missing argument to repetition operator", &c.to_string()); + } + last_repeat = Some(i); + i += 1; + continue; + } + _ => { + can_repeat = true; + last_repeat = None; + } + } + i += 1; + } + if depth > 0 { + return fmt("missing closing )", expr); + } + fmt("invalid or unsupported Perl syntax", expr) +} + +/// MD5 (RFC 1321), pour l'empreinte des options du cache. +pub fn md5(data: &[u8]) -> [u8; 16] { + const S: [u32; 64] = [ + 7, 12, 17, 22, 7, 12, 17, 22, 7, 12, 17, 22, 7, 12, 17, 22, 5, 9, 14, 20, 5, 9, 14, 20, 5, + 9, 14, 20, 5, 9, 14, 20, 4, 11, 16, 23, 4, 11, 16, 23, 4, 11, 16, 23, 4, 11, 16, 23, 6, 10, + 15, 21, 6, 10, 15, 21, 6, 10, 15, 21, 6, 10, 15, 21, + ]; + const K: [u32; 64] = [ + 0xd76aa478, 0xe8c7b756, 0x242070db, 0xc1bdceee, 0xf57c0faf, 0x4787c62a, 0xa8304613, + 0xfd469501, 0x698098d8, 0x8b44f7af, 0xffff5bb1, 0x895cd7be, 0x6b901122, 0xfd987193, + 0xa679438e, 0x49b40821, 0xf61e2562, 0xc040b340, 0x265e5a51, 0xe9b6c7aa, 0xd62f105d, + 0x02441453, 0xd8a1e681, 0xe7d3fbc8, 0x21e1cde6, 0xc33707d6, 0xf4d50d87, 0x455a14ed, + 0xa9e3e905, 0xfcefa3f8, 0x676f02d9, 0x8d2a4c8a, 0xfffa3942, 0x8771f681, 0x6d9d6122, + 0xfde5380c, 0xa4beea44, 0x4bdecfa9, 0xf6bb4b60, 0xbebfbc70, 0x289b7ec6, 0xeaa127fa, + 0xd4ef3085, 0x04881d05, 0xd9d4d039, 0xe6db99e5, 0x1fa27cf8, 0xc4ac5665, 0xf4292244, + 0x432aff97, 0xab9423a7, 0xfc93a039, 0x655b59c3, 0x8f0ccc92, 0xffeff47d, 0x85845dd1, + 0x6fa87e4f, 0xfe2ce6e0, 0xa3014314, 0x4e0811a1, 0xf7537e82, 0xbd3af235, 0x2ad7d2bb, + 0xeb86d391, + ]; + let (mut a0, mut b0, mut c0, mut d0) = ( + 0x6745_2301u32, + 0xefcd_ab89u32, + 0x98ba_dcfeu32, + 0x1032_5476u32, + ); + let mut msg = data.to_vec(); + let bits = (data.len() as u64).wrapping_mul(8); + msg.push(0x80); + while msg.len() % 64 != 56 { + msg.push(0); + } + msg.extend_from_slice(&bits.to_le_bytes()); + for chunk in msg.chunks(64) { + let m: Vec = chunk + .chunks(4) + .map(|w| u32::from_le_bytes([w[0], w[1], w[2], w[3]])) + .collect(); + let (mut a, mut b, mut c, mut d) = (a0, b0, c0, d0); + for i in 0..64 { + let (f, g) = match i / 16 { + 0 => ((b & c) | (!b & d), i), + 1 => ((d & b) | (!d & c), (5 * i + 1) % 16), + 2 => (b ^ c ^ d, (3 * i + 5) % 16), + _ => (c ^ (b | !d), (7 * i) % 16), + }; + let f = f.wrapping_add(a).wrapping_add(K[i]).wrapping_add(m[g]); + a = d; + d = c; + c = b; + b = b.wrapping_add(f.rotate_left(S[i])); + } + a0 = a0.wrapping_add(a); + b0 = b0.wrapping_add(b); + c0 = c0.wrapping_add(c); + d0 = d0.wrapping_add(d); + } + let mut out = [0u8; 16]; + for (i, w) in [a0, b0, c0, d0].iter().enumerate() { + out[i * 4..i * 4 + 4].copy_from_slice(&w.to_le_bytes()); + } + out +} + +#[cfg(test)] +mod tests; diff --git a/rust/loki-rs/src/webtools/tests.rs b/rust/loki-rs/src/webtools/tests.rs new file mode 100644 index 0000000..a3ab463 --- /dev/null +++ b/rust/loki-rs/src/webtools/tests.rs @@ -0,0 +1,192 @@ +use super::*; + +#[test] +fn md5_rfc1321() { + for (input, want) in [ + ("", "d41d8cd98f00b204e9800998ecf8427e"), + ("a", "0cc175b9c0f1b6a831c399e269772661"), + ("abc", "900150983cd24fb0d6963f7d28e17f72"), + ("message digest", "f96b697d7cb7938d525a2f31aaf161d0"), + ( + "12345678901234567890123456789012345678901234567890123456789012345678901234567890", + "57edf4a22be3c955ac49da2e2107b67a", + ), + ] { + assert_eq!(hex::encode(md5(input.as_bytes())), want, "{input}"); + } +} + +#[test] +fn plafond_de_sortie() { + let s = "é".repeat(MAX_OUTPUT); + assert_eq!(cap_output(&s), s); + let long = format!("{s}x"); + let out = cap_output(&long); + assert!(out.starts_with(&s)); + assert!(out.ends_with("ou cible avec web_grep.]")); + assert!(!out.contains('x')); +} + +#[test] +fn cles_du_cache() { + let base = FetchOpts { + dismiss_popups: true, + ..Default::default() + }; + assert_eq!(cache_key_for("u", &base), "u"); + let o = FetchOpts { + dismiss_popups: false, + ..Default::default() + }; + // Valeurs tirées de cacheKeyFor du Go. + assert_eq!(cache_key_for("u", &o), "u#77fc370a"); + let o = FetchOpts { + dismiss_popups: true, + actions: vec!["a < b && c".into()], + ..Default::default() + }; + assert_eq!(cache_key_for("u", &o), "u#d1533b44"); +} + +#[test] +fn urls() { + for (raw, want) in [ + ( + "https://github.com/a/b", + "https://raw.githubusercontent.com/a/b/HEAD/README.md", + ), + ( + "//github.com/a/b/", + "https://raw.githubusercontent.com/a/b/HEAD/README.md", + ), + ( + "https://github.com/a%20x/b?tab=1#f", + "https://raw.githubusercontent.com/a x/b/HEAD/README.md", + ), + ("https://GitHub.com/a/b", "https://GitHub.com/a/b"), + ("https://github.com:443/a/b", "https://github.com:443/a/b"), + ("https://github.com/a", "https://github.com/a"), + ("https://github.com/a/b/c", "https://github.com/a/b/c"), + ("https://github.com/%zz/b", "https://github.com/%zz/b"), + ("github.com/a/b", "github.com/a/b"), + (":github.com/a/b", ":github.com/a/b"), + ] { + assert_eq!(normalize_crawl_url(raw), want, "{raw}"); + } + for (raw, want) in [ + ( + "//duckduckgo.com/l/?uddg=https%3A%2F%2Fa.example%2F%3Fx%3D1%26y%3D2&rut=z", + "https://a.example/?x=1&y=2", + ), + ("https://b.example/", "https://b.example/"), + ( + "//duckduckgo.com/l/?uddg=%ZZ", + "https://duckduckgo.com/l/?uddg=%ZZ", + ), + ("//duckduckgo.com/l/?x;y=1&uddg=a%2Bb+c", "a b c"), + ("/l/?uddg=", "/l/?uddg="), + ] { + assert_eq!(decode_uddg(raw), want, "{raw}"); + } +} + +#[test] +fn entites() { + assert_eq!( + decode_html_entities( + "a &lt; été � � � "" + ), + "a < été � A � \"" + ); +} + +#[test] +fn nombres_du_go() { + assert_eq!(go_int(3.9), 3); + assert_eq!(go_int(-3.9), -3); + if cfg!(target_arch = "x86_64") { + assert_eq!(go_int(1e300), i64::MIN); + assert_eq!(go_int(f64::NAN), i64::MIN); + } + assert_eq!(format_bytes(1023), "1023 B"); + assert_eq!(format_bytes(1536), "1.5 KB"); + assert_eq!(format_bytes(3 * 1024 * 1024 + 1), "3.00 MB"); + assert_eq!(tail_runes("abcdé", 2), "dé"); + assert_eq!(tail_runes("ab", 5), "ab"); +} + +#[test] +fn erreurs_de_regex() { + for (expr, want) in [ + ("(?i)(", "missing closing ): `(?i)(`"), + ("(?i)a)", "unexpected ): `(?i)a)`"), + ("(?i)[abc", "missing closing ]: `[abc`"), + ("(?i)[[:alpha:]", "missing closing ]: `[[:alpha:]`"), + ("(?i)*x", "missing argument to repetition operator: `*`"), + ("(?i)a|+", "missing argument to repetition operator: `+`"), + ("(?i)a**", "invalid nested repetition operator: `**`"), + ("(?i)a*??", "invalid nested repetition operator: `*??`"), + ("(?i)\\q", "invalid escape sequence: `\\q`"), + ("(?i)\\1", "invalid escape sequence: `\\1`"), + ("(?i)a\\", "trailing backslash at end of expression: ``"), + ] { + assert_eq!( + go_regexp_error(expr), + format!("error parsing regexp: {want}"), + "{expr}" + ); + } +} + +#[test] +fn reponses_de_crawl4ai() { + let md = |raw: &str, pr: bool| crawl_markdown(raw.as_bytes(), pr); + assert_eq!( + md( + r#"{"results":[{"success":true,"markdown":{"raw_markdown":"r","fit_markdown":"f"}}]}"#, + true + ), + Ok("r".into()) + ); + assert_eq!( + md( + r#"{"results":[{"success":true,"markdown":{"raw_markdown":"","fit_markdown":"f"}}]}"#, + true + ), + Ok("f".into()) + ); + assert_eq!( + md( + r#"{"results":[{"success":true,"markdown":{"raw_markdown":"r"}}]}"#, + false + ), + Ok("r".into()) + ); + assert_eq!( + md(r#"{"Results":[{"Success":true,"Markdown":"x"}]}"#, false), + Ok("x".into()) + ); + assert_eq!(md("null", false), Err("Crawl4AI : échec du crawl".into())); + assert_eq!(md("5", false), Err("Crawl4AI : réponse vide".into())); + assert_eq!( + md(r#"{"results":[{"success":"x"}],"success":"y"}"#, false), + Err("Crawl4AI : échec du crawl".into()) + ); + assert_eq!( + md(r#"{"results":[null,{"success":true}]}"#, false), + Err("Crawl4AI : échec du crawl".into()) + ); + assert_eq!( + md(r#"{"results":[{"success":true,"markdown":null}]}"#, false), + Ok(String::new()) + ); +} + +#[test] +fn lignes_normalisees() { + assert_eq!( + normalize_lines("\n\n a \r\nb \n\n\n\nb\nb\nc\t\n\n"), + [" a", "b", "", "b", "c"] + ); + assert!(normalize_lines(" \n\t\n").is_empty()); +} diff --git a/rust/loki-rs/tests/common/fakego.rs b/rust/loki-rs/tests/common/fakego.rs index 4a37412..a97ab71 100644 --- a/rust/loki-rs/tests/common/fakego.rs +++ b/rust/loki-rs/tests/common/fakego.rs @@ -149,6 +149,12 @@ pub struct FakeGo { pub projsnaps: Mutex>, pub projsnap_refresh: Mutex>, pub projsnap_refreshes: Mutex>, + /// Moteur web intégré (lot 42) : réponses de /internal/v1/web/fetch par + /// URL et de /internal/v1/web/search par « requête|limite » ; appels + /// reçus (« fetch URL », « search requête|limite »). + pub web_fetch: Mutex>, + pub web_search: Mutex>, + pub web_log: Mutex>, } impl FakeGo { @@ -505,6 +511,28 @@ async fn handle(st: Arc, req: Request) -> Response 200, serde_json::json!({ "generating": *st.generating.lock().unwrap() }).to_string(), ), + "/internal/v1/web/fetch" | "/internal/v1/web/search" => { + let fetch = path == "/internal/v1/web/fetch"; + let body = req.into_body().collect().await.unwrap().to_bytes(); + let v: serde_json::Value = serde_json::from_slice(&body).unwrap_or_default(); + let key = if fetch { + v["url"].as_str().unwrap_or("").to_string() + } else { + format!("{}|{}", v["query"].as_str().unwrap_or(""), v["limit"]) + }; + st.web_log + .lock() + .unwrap() + .push(format!("{} {key}", if fetch { "fetch" } else { "search" })); + let map = if fetch { &st.web_fetch } else { &st.web_search }; + let out = map + .lock() + .unwrap() + .get(&key) + .cloned() + .unwrap_or_else(|| serde_json::json!({ "error": format!("inconnu : {key}") })); + resp(200, out.to_string()) + } "/internal/v1/web/reach/reset" => { *st.reach_resets.lock().unwrap() += 1; resp(204, "") diff --git a/rust/loki-rs/tests/lot6.rs b/rust/loki-rs/tests/lot6.rs index 68653c3..4fb1323 100644 --- a/rust/loki-rs/tests/lot6.rs +++ b/rust/loki-rs/tests/lot6.rs @@ -671,6 +671,7 @@ async fn tour_complet_par_le_go() { mcp: None, tasks: None, memory: None, + web: Default::default(), go_version: Default::default(), }); let cfg = std::collections::HashMap::new(); @@ -2746,3 +2747,44 @@ async fn outil_memoire_par_la_facade() { .clone(); assert_eq!(tool, serde_json::json!("[ok] page 'chat' créée")); } + +/// Lot 42 : un outil web d'un tour de la façade s'exécute chez elle (cache +/// des pages compris) ; seule la conversion du moteur intégré est demandée +/// au Go, jamais /tool/exec. +#[tokio::test] +async fn outil_web_par_la_facade() { + let call = sse(&[ + serde_json::json!({"choices":[{"delta":{"tool_calls":[{"index":0,"id":"w1","type":"function","function":{"name":"web_open","arguments":"{\"url\":\"https://exemple.test/doc\"}"}}]}}]}), + serde_json::json!({"choices":[{"delta":{},"finish_reason":"tool_calls"}]}), + ]); + let (url, seen) = fake_engine(vec![call, answer("lu")]).await; + let (svc, go, _stop, _t) = service_with(Some(&url), |_| {}).await; + go.set("config", "WEB_ENGINE", "go"); + go.web_fetch.lock().unwrap().insert( + "https://exemple.test/doc".into(), + serde_json::json!({"markdown": "# Doc\n\n## Partie\ntexte"}), + ); + svc.send(serde_json::json!({"message": "lis la doc", "cid": "w"})) + .await + .unwrap(); + until_done(&svc, 1).await; + assert!(go.tool_log.lock().unwrap().is_empty(), "{:?}", go.tool_log); + assert_eq!( + *go.web_log.lock().unwrap(), + vec!["fetch https://exemple.test/doc".to_string()] + ); + let seen = seen.lock().unwrap().clone(); + let tool = seen[1].1["messages"] + .as_array() + .unwrap() + .iter() + .rfind(|m| m["role"] == "tool") + .unwrap()["content"] + .clone(); + assert_eq!( + tool, + serde_json::json!( + "# Ouvert : https://exemple.test/doc\nTotal : 4 lignes, 23 B (23 caractères)\nEn cache 10 min. Utilise web_read ou web_grep pour lire.\n\n## Plan (n° de ligne des titres)\n```\n 1 | Doc\n 3 | Partie\n```" + ) + ); +} diff --git a/rust/loki-rs/tests/parity_web/outils.json b/rust/loki-rs/tests/parity_web/outils.json new file mode 100644 index 0000000..7a66aa7 --- /dev/null +++ b/rust/loki-rs/tests/parity_web/outils.json @@ -0,0 +1,1541 @@ +{ + "crawl": { + "https://exemple.test/blanc": [ + { + "status": 200, + "body": "{\"results\":[{\"success\":true,\"markdown\":\" \\n\\t\\n\"}]}" + } + ], + "https://exemple.test/brut-absent": [ + { + "status": 200, + "body": "{\"results\":[{\"success\":true,\"markdown\":{\"fit_markdown\":\"# Fit seul\\n\\nok\"}}]}" + } + ], + "https://exemple.test/doc": [ + { + "status": 200, + "body": "{\"results\":[{\"markdown\":{\"fit_markdown\":\"\\n# Documentation \\n\\nIntro ligne 1\\nIntro ligne 1\\n\\n\\n\\n## Installation\\nLancer `loki install`.\\t\\nErreur : moteur absent\\n### Détails\\nligne a\\nligne b\\nErreur : port pris\\nligne c\\nligne d\\nligne e\\nligne f\\nErreur : disque plein\\n####### pas un titre\\n## Fin\\r\\n\",\"raw_markdown\":\"brut\"},\"success\":true}]}" + }, + { + "status": 200, + "body": "{\"results\":[{\"markdown\":\"# Version 2\\n\\nnouveau contenu\",\"success\":true}]}" + }, + { + "status": 200, + "body": "{\"results\":[{\"markdown\":\"# Avec actions\\n\\nbouton cliqué\",\"success\":true}]}" + } + ], + "https://exemple.test/echec": [ + { + "status": 200, + "body": "{\"results\":[{\"error_message\":\"net::ERR_NAME_NOT_RESOLVED xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx\",\"success\":false}]}" + } + ], + "https://exemple.test/echec-muet": [ + { + "status": 200, + "body": "{\"results\":[{\"success\":false}]}" + } + ], + "https://exemple.test/illisible": [ + { + "status": 200, + "body": "pas du json" + } + ], + "https://exemple.test/longue": [ + { + "status": 200, + "body": "{\"results\":[{\"markdown\":\"0 une ligne assez longue pour remplir la fenêtre de lecture, encore et encore. une ligne assez longue pour remplir la fenêtre de lecture, encore et encore. une ligne assez longue pour remplir la fenêtre de lecture, encore et encore. \\n1 une ligne assez longue pour remplir la fenêtre de lecture, encore et encore. une ligne assez longue pour remplir la fenêtre de lecture, encore et encore. une ligne assez longue pour remplir la fenêtre de lecture, encore et encore. \\n2 une ligne assez longue pour remplir la fenêtre de lecture, encore et encore. une ligne assez longue pour remplir la fenêtre de lecture, encore et encore. une ligne assez longue pour remplir la fenêtre de lecture, encore et encore. \\n3 une ligne assez longue pour remplir la fenêtre de lecture, encore et encore. une ligne assez longue pour remplir la fenêtre de lecture, encore et encore. une ligne assez longue pour remplir la fenêtre de lecture, encore et encore. \\n4 une ligne assez longue pour remplir la fenêtre de lecture, encore et encore. une ligne assez longue pour remplir la fenêtre de lecture, encore et encore. une ligne assez longue pour remplir la fenêtre de lecture, encore et encore. \\n5 une ligne assez longue pour remplir la fenêtre de lecture, encore et encore. une ligne assez longue pour remplir la fenêtre de lecture, encore et encore. une ligne assez longue pour remplir la fenêtre de lecture, encore et encore. \\n6 une ligne assez longue pour remplir la fenêtre de lecture, encore et encore. une ligne assez longue pour remplir la fenêtre de lecture, encore et encore. une ligne assez longue pour remplir la fenêtre de lecture, encore et encore. \\n7 une ligne assez longue pour remplir la fenêtre de lecture, encore et encore. une ligne assez longue pour remplir la fenêtre de lecture, encore et encore. une ligne assez longue pour remplir la fenêtre de lecture, encore et encore. \\n8 une ligne assez longue pour remplir la fenêtre de lecture, encore et encore. une ligne assez longue pour remplir la fenêtre de lecture, encore et encore. une ligne assez longue pour remplir la fenêtre de lecture, encore et encore. \\n9 une ligne assez longue pour remplir la fenêtre de lecture, encore et encore. une ligne assez longue pour remplir la fenêtre de lecture, encore et encore. une ligne assez longue pour remplir la fenêtre de lecture, encore et encore. \\n10 une ligne assez longue pour remplir la fenêtre de lecture, encore et encore. une ligne assez longue pour remplir la fenêtre de lecture, encore et encore. une ligne assez longue pour remplir la fenêtre de lecture, encore et encore. \\n11 une ligne assez longue pour remplir la fenêtre de lecture, encore et encore. une ligne assez longue pour remplir la fenêtre de lecture, encore et encore. une ligne assez longue pour remplir la fenêtre de lecture, encore et encore. \\n12 une ligne assez longue pour remplir la fenêtre de lecture, encore et encore. une ligne assez longue pour remplir la fenêtre de lecture, encore et encore. une ligne assez longue pour remplir la fenêtre de lecture, encore et encore. \\n13 une ligne assez longue pour remplir la fenêtre de lecture, encore et encore. une ligne assez longue pour remplir la fenêtre de lecture, encore et encore. une ligne assez longue pour remplir la fenêtre de lecture, encore et encore. \\n14 une ligne assez longue pour remplir la fenêtre de lecture, encore et encore. une ligne assez longue pour remplir la fenêtre de lecture, encore et encore. une ligne assez longue pour remplir la fenêtre de lecture, encore et encore. \\n15 une ligne assez longue pour remplir la fenêtre de lecture, encore et encore. une ligne assez longue pour remplir la fenêtre de lecture, encore et encore. une ligne assez longue pour remplir la fenêtre de lecture, encore et encore. \\n16 une ligne assez longue pour remplir la fenêtre de lecture, encore et encore. une ligne assez longue pour remplir la fenêtre de lecture, encore et encore. une ligne assez longue pour remplir la fenêtre de lecture, encore et encore. \\n17 une ligne assez longue pour remplir la fenêtre de lecture, encore et encore. une ligne assez longue pour remplir la fenêtre de lecture, encore et encore. une ligne assez longue pour remplir la fenêtre de lecture, encore et encore. \\n18 une ligne assez longue pour remplir la fenêtre de lecture, encore et encore. une ligne assez longue pour remplir la fenêtre de lecture, encore et encore. une ligne assez longue pour remplir la fenêtre de lecture, encore et encore. \\n19 une ligne assez longue pour remplir la fenêtre de lecture, encore et encore. une ligne assez longue pour remplir la fenêtre de lecture, encore et encore. une ligne assez longue pour remplir la fenêtre de lecture, encore et encore. \\n20 une ligne assez longue pour remplir la fenêtre de lecture, encore et encore. une ligne assez longue pour remplir la fenêtre de lecture, encore et encore. une ligne assez longue pour remplir la fenêtre de lecture, encore et encore. \\n21 une ligne assez longue pour remplir la fenêtre de lecture, encore et encore. une ligne assez longue pour remplir la fenêtre de lecture, encore et encore. une ligne assez longue pour remplir la fenêtre de lecture, encore et encore. \\n22 une ligne assez longue pour remplir la fenêtre de lecture, encore et encore. une ligne assez longue pour remplir la fenêtre de lecture, encore et encore. une ligne assez longue pour remplir la fenêtre de lecture, encore et encore. \\n23 une ligne assez longue pour remplir la fenêtre de lecture, encore et encore. une ligne assez longue pour remplir la fenêtre de lecture, encore et encore. une ligne assez longue pour remplir la fenêtre de lecture, encore et encore. \\n24 une ligne assez longue pour remplir la fenêtre de lecture, encore et encore. une ligne assez longue pour remplir la fenêtre de lecture, encore et encore. une ligne assez longue pour remplir la fenêtre de lecture, encore et encore. \\n25 une ligne assez longue pour remplir la fenêtre de lecture, encore et encore. une ligne assez longue pour remplir la fenêtre de lecture, encore et encore. une ligne assez longue pour remplir la fenêtre de lecture, encore et encore. \\n26 une ligne assez longue pour remplir la fenêtre de lecture, encore et encore. une ligne assez longue pour remplir la fenêtre de lecture, encore et encore. une ligne assez longue pour remplir la fenêtre de lecture, encore et encore. \\n27 une ligne assez longue pour remplir la fenêtre de lecture, encore et encore. une ligne assez longue pour remplir la fenêtre de lecture, encore et encore. une ligne assez longue pour remplir la fenêtre de lecture, encore et encore. \\n28 une ligne assez longue pour remplir la fenêtre de lecture, encore et encore. une ligne assez longue pour remplir la fenêtre de lecture, encore et encore. une ligne assez longue pour remplir la fenêtre de lecture, encore et encore. \\n29 une ligne assez longue pour remplir la fenêtre de lecture, encore et encore. une ligne assez longue pour remplir la fenêtre de lecture, encore et encore. une ligne assez longue pour remplir la fenêtre de lecture, encore et encore. \\n30 une ligne assez longue pour remplir la fenêtre de lecture, encore et encore. une ligne assez longue pour remplir la fenêtre de lecture, encore et encore. une ligne assez longue pour remplir la fenêtre de lecture, encore et encore. \\n31 une ligne assez longue pour remplir la fenêtre de lecture, encore et encore. une ligne assez longue pour remplir la fenêtre de lecture, encore et encore. une ligne assez longue pour remplir la fenêtre de lecture, encore et encore. \\n32 une ligne assez longue pour remplir la fenêtre de lecture, encore et encore. une ligne assez longue pour remplir la fenêtre de lecture, encore et encore. une ligne assez longue pour remplir la fenêtre de lecture, encore et encore. \\n33 une ligne assez longue pour remplir la fenêtre de lecture, encore et encore. une ligne assez longue pour remplir la fenêtre de lecture, encore et encore. une ligne assez longue pour remplir la fenêtre de lecture, encore et encore. \\n34 une ligne assez longue pour remplir la fenêtre de lecture, encore et encore. une ligne assez longue pour remplir la fenêtre de lecture, encore et encore. une ligne assez longue pour remplir la fenêtre de lecture, encore et encore. \\n35 une ligne assez longue pour remplir la fenêtre de lecture, encore et encore. une ligne assez longue pour remplir la fenêtre de lecture, encore et encore. une ligne assez longue pour remplir la fenêtre de lecture, encore et encore. \\n36 une ligne assez longue pour remplir la fenêtre de lecture, encore et encore. une ligne assez longue pour remplir la fenêtre de lecture, encore et encore. une ligne assez longue pour remplir la fenêtre de lecture, encore et encore. \\n37 une ligne assez longue pour remplir la fenêtre de lecture, encore et encore. une ligne assez longue pour remplir la fenêtre de lecture, encore et encore. une ligne assez longue pour remplir la fenêtre de lecture, encore et encore. \\n38 une ligne assez longue pour remplir la fenêtre de lecture, encore et encore. une ligne assez longue pour remplir la fenêtre de lecture, encore et encore. une ligne assez longue pour remplir la fenêtre de lecture, encore et encore. \\n39 une ligne assez longue pour remplir la fenêtre de lecture, encore et encore. une ligne assez longue pour remplir la fenêtre de lecture, encore et encore. une ligne assez longue pour remplir la fenêtre de lecture, encore et encore. \\n40 une ligne assez longue pour remplir la fenêtre de lecture, encore et encore. une ligne assez longue pour remplir la fenêtre de lecture, encore et encore. une ligne assez longue pour remplir la fenêtre de lecture, encore et encore. \\n41 une ligne assez longue pour remplir la fenêtre de lecture, encore et encore. une ligne assez longue pour remplir la fenêtre de lecture, encore et encore. une ligne assez longue pour remplir la fenêtre de lecture, encore et encore. \\n42 une ligne assez longue pour remplir la fenêtre de lecture, encore et encore. une ligne assez longue pour remplir la fenêtre de lecture, encore et encore. une ligne assez longue pour remplir la fenêtre de lecture, encore et encore. \\n43 une ligne assez longue pour remplir la fenêtre de lecture, encore et encore. une ligne assez longue pour remplir la fenêtre de lecture, encore et encore. une ligne assez longue pour remplir la fenêtre de lecture, encore et encore. \\n44 une ligne assez longue pour remplir la fenêtre de lecture, encore et encore. une ligne assez longue pour remplir la fenêtre de lecture, encore et encore. une ligne assez longue pour remplir la fenêtre de lecture, encore et encore. \\n45 une ligne assez longue pour remplir la fenêtre de lecture, encore et encore. une ligne assez longue pour remplir la fenêtre de lecture, encore et encore. une ligne assez longue pour remplir la fenêtre de lecture, encore et encore. \\n46 une ligne assez longue pour remplir la fenêtre de lecture, encore et encore. une ligne assez longue pour remplir la fenêtre de lecture, encore et encore. une ligne assez longue pour remplir la fenêtre de lecture, encore et encore. \\n47 une ligne assez longue pour remplir la fenêtre de lecture, encore et encore. une ligne assez longue pour remplir la fenêtre de lecture, encore et encore. une ligne assez longue pour remplir la fenêtre de lecture, encore et encore. \\n48 une ligne assez longue pour remplir la fenêtre de lecture, encore et encore. une ligne assez longue pour remplir la fenêtre de lecture, encore et encore. une ligne assez longue pour remplir la fenêtre de lecture, encore et encore. \\n49 une ligne assez longue pour remplir la fenêtre de lecture, encore et encore. une ligne assez longue pour remplir la fenêtre de lecture, encore et encore. une ligne assez longue pour remplir la fenêtre de lecture, encore et encore. \\n50 une ligne assez longue pour remplir la fenêtre de lecture, encore et encore. une ligne assez longue pour remplir la fenêtre de lecture, encore et encore. une ligne assez longue pour remplir la fenêtre de lecture, encore et encore. \\n51 une ligne assez longue pour remplir la fenêtre de lecture, encore et encore. une ligne assez longue pour remplir la fenêtre de lecture, encore et encore. une ligne assez longue pour remplir la fenêtre de lecture, encore et encore. \\n52 une ligne assez longue pour remplir la fenêtre de lecture, encore et encore. une ligne assez longue pour remplir la fenêtre de lecture, encore et encore. une ligne assez longue pour remplir la fenêtre de lecture, encore et encore. \\n53 une ligne assez longue pour remplir la fenêtre de lecture, encore et encore. une ligne assez longue pour remplir la fenêtre de lecture, encore et encore. une ligne assez longue pour remplir la fenêtre de lecture, encore et encore. \\n54 une ligne assez longue pour remplir la fenêtre de lecture, encore et encore. une ligne assez longue pour remplir la fenêtre de lecture, encore et encore. une ligne assez longue pour remplir la fenêtre de lecture, encore et encore. \\n55 une ligne assez longue pour remplir la fenêtre de lecture, encore et encore. une ligne assez longue pour remplir la fenêtre de lecture, encore et encore. une ligne assez longue pour remplir la fenêtre de lecture, encore et encore. \\n56 une ligne assez longue pour remplir la fenêtre de lecture, encore et encore. une ligne assez longue pour remplir la fenêtre de lecture, encore et encore. une ligne assez longue pour remplir la fenêtre de lecture, encore et encore. \\n57 une ligne assez longue pour remplir la fenêtre de lecture, encore et encore. une ligne assez longue pour remplir la fenêtre de lecture, encore et encore. une ligne assez longue pour remplir la fenêtre de lecture, encore et encore. \\n58 une ligne assez longue pour remplir la fenêtre de lecture, encore et encore. une ligne assez longue pour remplir la fenêtre de lecture, encore et encore. une ligne assez longue pour remplir la fenêtre de lecture, encore et encore. \\n59 une ligne assez longue pour remplir la fenêtre de lecture, encore et encore. une ligne assez longue pour remplir la fenêtre de lecture, encore et encore. une ligne assez longue pour remplir la fenêtre de lecture, encore et encore. \",\"success\":true}]}" + } + ], + "https://exemple.test/mal-type": [ + { + "status": 200, + "body": "{\"results\":[{\"success\":\"oui\",\"markdown\":\"x\"}]}" + } + ], + "https://exemple.test/objet-faux": [ + { + "status": 200, + "body": "{\"results\":[{\"success\":true,\"markdown\":{\"raw_markdown\":5}}]}" + } + ], + "https://exemple.test/seul": [ + { + "status": 200, + "body": "{\"success\":true,\"markdown\":\"# Seul\\n\\nun résultat seul\"}" + } + ], + "https://exemple.test/tableau": [ + { + "status": 200, + "body": "[]" + } + ], + "https://exemple.test/vide": [ + { + "status": 200, + "body": "{\"results\":[]}" + } + ], + "https://html.duckduckgo.com/html/?q=anti-bot": [ + { + "status": 200, + "body": "{\"results\":[{\"markdown\":\"\\u003cdiv class=\\\"anomaly-modal\\\"\\u003e\",\"success\":true}]}" + } + ], + "https://html.duckduckgo.com/html/?q=loki+rust": [ + { + "status": 200, + "body": "{\"results\":[{\"markdown\":{\"fit_markdown\":\"ignoré\",\"raw_markdown\":\"# DuckDuckGo\\n\\n## [Loki \\u0026amp; **Rust**](//duckduckgo.com/l/?uddg=https%3A%2F%2Floki.example%2Frust%3Fa%3D1%26b%3D2\\u0026rut=abc)\\n\\n[loki.example/rust](//duckduckgo.com/l/?uddg=x) [![](icon.png)](x) [Un port en **Rust** \\u0026#233;tape par \\u0026#233;tape](//duckduckgo.com/l/?uddg=y)\\n## [Publicité](https://duckduckgo.com/y.js?ad=1)\\n## [Deuxième \\u0026quot;résultat\\u0026quot;](https://deux.example/page)\\n\\n\\n\\n\\n\\n[trop loin pour le résumé](x)\\n## [Loki encore](//duckduckgo.com/l/?uddg=https%3A%2F%2Floki.example%2Frust%3Fa%3D1%26b%3D2)\\n## [Troisième](https://trois.example/) \\n[!bang ignoré](x) [vraie description](y)\\n## [Quatrième](//duckduckgo.com/l/?uddg=%ZZ)\\n## [](https://vide.example/)\"},\"success\":true}]}" + } + ], + "https://html.duckduckgo.com/html/?q=panne": [ + { + "status": 500, + "body": "Internal Server Error: éééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééé" + } + ], + "https://html.duckduckgo.com/html/?q=rien": [ + { + "status": 200, + "body": "{\"results\":[{\"markdown\":\"# Rien\\n\\npas de résultat\",\"success\":true}]}" + } + ], + "https://raw.githubusercontent.com/r0m1k3/loki/HEAD/README.md": [ + { + "status": 200, + "body": "{\"results\":[{\"markdown\":\"# Loki\\n\\nLe README.\",\"success\":true}]}" + } + ] + }, + "images": { + "api en panne": { + "page_status": 200, + "page": "", + "api_status": 500, + "api": "oops" + }, + "apostrophe": { + "page_status": 200, + "page": "vqd='77-88'", + "api_status": 200, + "api": "{\"results\":[{\"image\":\"https://i/a\",\"title\":\"A\",\"url\":\"https://p/a\",\"width\":1,\"height\":2}]}" + }, + "apres": { + "page_status": 200, + "page": "", + "api_status": 200, + "api": "{\"results\":[]} x" + }, + "chats": { + "page_status": 200, + "page": "", + "cookie": "dcm=7; Path=/; HttpOnly", + "api_status": 200, + "api": "{\"results\":[{\"height\":600,\"image\":\"https://img.example/1.jpg\",\"thumbnail\":\"t\",\"title\":\"Chat roux\",\"url\":\"https://page.example/1\",\"width\":800},{\"height\":1,\"image\":\"https://img.example/1.jpg\",\"thumbnail\":\"t\",\"title\":\"Doublon\",\"url\":\"https://page.example/1b\",\"width\":1},{\"height\":1,\"image\":\"\",\"thumbnail\":\"t\",\"title\":\"Sans image\",\"url\":\"https://page.example/2\",\"width\":1},{\"height\":1,\"image\":\"data:image/png;base64,xx\",\"thumbnail\":\"t\",\"title\":\"Pas http\",\"url\":\"https://page.example/3\",\"width\":1},{\"height\":768,\"image\":\"http://img.example/4.png\",\"thumbnail\":\"t\",\"title\":\"Chat \\u0026 chien \\u003c3\",\"url\":\"https://page.example/4\",\"width\":1024},{\"height\":20,\"image\":\"https://img.example/5.jpg\",\"thumbnail\":\"t\",\"title\":\"Cinquième\",\"url\":\"https://page.example/5\",\"width\":10}]}" + }, + "illisible": { + "page_status": 200, + "page": "", + "api_status": 200, + "api": "pas du json" + }, + "largeur texte": { + "page_status": 200, + "page": "", + "api_status": 200, + "api": "{\"results\":[{\"image\":\"https://i/1\",\"width\":\"800\"}]}" + }, + "null": { + "page_status": 200, + "page": "", + "api_status": 200, + "api": "{\"results\":null,\"next\":\"i.js?x\"}" + }, + "page en panne": { + "page_status": 403, + "page": "interdit", + "api_status": 200, + "api": "{}" + }, + "resultats faux": { + "page_status": 200, + "page": "", + "api_status": 200, + "api": "{\"results\":\"non\"}" + }, + "sans jeton": { + "page_status": 200, + "page": "rien", + "api_status": 200, + "api": "{}" + }, + "vide": { + "page_status": 200, + "page": "", + "api_status": 200, + "api": "{\"results\":[]}" + } + }, + "go_fetch": { + "{{PAGES}}/absente": { + "error": "HTTP 404 sur {{PAGES}}/absente" + }, + "{{PAGES}}/article.html": { + "markdown": "# Guide de Loki\n\nLoki fait tourner un modèle de langage sur votre machine, sans rien envoyer ailleurs. Ce guide explique l'installation, la configuration et les premiers pas avec l'agent.\n\n## Installation\n\nTéléchargez le binaire depuis la [page des versions](https://loki.example/releases), puis lancez-le. Le moteur llama.cpp est installé au premier démarrage, précompilé pour votre carte graphique.\n\n## Configuration\n\nLes réglages se font dans l'interface : modèle, contexte, outils. Erreur fréquente : un port déjà pris par un autre service. Changez alors le port dans les réglages avancés.\n\nErreur moins fréquente : un disque plein empêche le téléchargement du modèle." + }, + "{{PAGES}}/texte.txt": { + "markdown": "Ligne brute 1\nLigne brute 2\n" + } + }, + "go_search": { + "bot|8": { + "error": "DuckDuckGo a renvoyé un défi anti-bot" + }, + "loki|1": { + "results": [ + { + "snippet": "Un assistant local et privé.", + "title": "Loki local", + "url": "https://loki.example/" + } + ] + }, + "loki|8": { + "results": [ + { + "snippet": "Un assistant local et privé.", + "title": "Loki local", + "url": "https://loki.example/" + }, + { + "snippet": "", + "title": "Deux", + "url": "https://deux.example/" + } + ] + } + }, + "calls": [ + { + "name": "recherche", + "setup": { + "CRAWL4AI_URL": "{{CRAWL}}", + "WEB_ENGINE": "crawl4ai", + "crawl_key": "sesame" + }, + "tool": "web_search", + "args": { + "query": "loki rust" + }, + "result": "# Recherche : loki rust\n3 résultat(s) DuckDuckGo\n\n1. Loki & Rust\n https://loki.example/rust?a=1&b=2\n Un port en Rust étape par étape\n\n2. Deuxième \"résultat\"\n https://deux.example/page\n \n\n3. Troisième\n https://trois.example/\n vraie description", + "crawls": [ + { + "path": "POST /crawl", + "auth": "Bearer sesame", + "ctype": "application/json", + "body": "{\"browser_config\":{\"params\":{\"headless\":true},\"type\":\"BrowserConfig\"},\"crawler_config\":{\"params\":{\"page_timeout\":30000},\"type\":\"CrawlerRunConfig\"},\"urls\":[\"https://html.duckduckgo.com/html/?q=loki+rust\"]}" + } + ], + "images": [], + "go_fetch": [], + "go_search": [] + }, + { + "name": "recherche limitée", + "tool": "web_search", + "args": { + "limit": 1, + "query": "loki rust" + }, + "result": "# Recherche : loki rust\n1 résultat(s) DuckDuckGo\n\n1. Loki & Rust\n https://loki.example/rust?a=1&b=2\n Un port en Rust étape par étape", + "crawls": [ + { + "path": "POST /crawl", + "auth": "Bearer sesame", + "ctype": "application/json", + "body": "{\"browser_config\":{\"params\":{\"headless\":true},\"type\":\"BrowserConfig\"},\"crawler_config\":{\"params\":{\"page_timeout\":30000},\"type\":\"CrawlerRunConfig\"},\"urls\":[\"https://html.duckduckgo.com/html/?q=loki+rust\"]}" + } + ], + "images": [], + "go_fetch": [], + "go_search": [] + }, + { + "name": "recherche, limite hors bornes", + "tool": "web_search", + "args": { + "limit": 99, + "query": "loki rust" + }, + "result": "# Recherche : loki rust\n3 résultat(s) DuckDuckGo\n\n1. Loki & Rust\n https://loki.example/rust?a=1&b=2\n Un port en Rust étape par étape\n\n2. Deuxième \"résultat\"\n https://deux.example/page\n \n\n3. Troisième\n https://trois.example/\n vraie description", + "crawls": [ + { + "path": "POST /crawl", + "auth": "Bearer sesame", + "ctype": "application/json", + "body": "{\"browser_config\":{\"params\":{\"headless\":true},\"type\":\"BrowserConfig\"},\"crawler_config\":{\"params\":{\"page_timeout\":30000},\"type\":\"CrawlerRunConfig\"},\"urls\":[\"https://html.duckduckgo.com/html/?q=loki+rust\"]}" + } + ], + "images": [], + "go_fetch": [], + "go_search": [] + }, + { + "name": "recherche, limite nulle", + "tool": "web_search", + "args": { + "limit": -3, + "query": "loki rust" + }, + "result": "# Recherche : loki rust\n1 résultat(s) DuckDuckGo\n\n1. Loki & Rust\n https://loki.example/rust?a=1&b=2\n Un port en Rust étape par étape", + "crawls": [ + { + "path": "POST /crawl", + "auth": "Bearer sesame", + "ctype": "application/json", + "body": "{\"browser_config\":{\"params\":{\"headless\":true},\"type\":\"BrowserConfig\"},\"crawler_config\":{\"params\":{\"page_timeout\":30000},\"type\":\"CrawlerRunConfig\"},\"urls\":[\"https://html.duckduckgo.com/html/?q=loki+rust\"]}" + } + ], + "images": [], + "go_fetch": [], + "go_search": [] + }, + { + "name": "recherche, limite texte", + "tool": "web_search", + "args": { + "limit": "2", + "query": "loki rust" + }, + "result": "# Recherche : loki rust\n3 résultat(s) DuckDuckGo\n\n1. Loki & Rust\n https://loki.example/rust?a=1&b=2\n Un port en Rust étape par étape\n\n2. Deuxième \"résultat\"\n https://deux.example/page\n \n\n3. Troisième\n https://trois.example/\n vraie description", + "crawls": [ + { + "path": "POST /crawl", + "auth": "Bearer sesame", + "ctype": "application/json", + "body": "{\"browser_config\":{\"params\":{\"headless\":true},\"type\":\"BrowserConfig\"},\"crawler_config\":{\"params\":{\"page_timeout\":30000},\"type\":\"CrawlerRunConfig\"},\"urls\":[\"https://html.duckduckgo.com/html/?q=loki+rust\"]}" + } + ], + "images": [], + "go_fetch": [], + "go_search": [] + }, + { + "name": "défi anti-bot", + "tool": "web_search", + "args": { + "query": "anti-bot" + }, + "result": "❌ Recherche échouée : DuckDuckGo a renvoyé un défi anti-bot", + "crawls": [ + { + "path": "POST /crawl", + "auth": "Bearer sesame", + "ctype": "application/json", + "body": "{\"browser_config\":{\"params\":{\"headless\":true},\"type\":\"BrowserConfig\"},\"crawler_config\":{\"params\":{\"page_timeout\":30000},\"type\":\"CrawlerRunConfig\"},\"urls\":[\"https://html.duckduckgo.com/html/?q=anti-bot\"]}" + } + ], + "images": [], + "go_fetch": [], + "go_search": [] + }, + { + "name": "aucun résultat", + "tool": "web_search", + "args": { + "query": "rien" + }, + "result": "Aucun résultat pour « rien »", + "crawls": [ + { + "path": "POST /crawl", + "auth": "Bearer sesame", + "ctype": "application/json", + "body": "{\"browser_config\":{\"params\":{\"headless\":true},\"type\":\"BrowserConfig\"},\"crawler_config\":{\"params\":{\"page_timeout\":30000},\"type\":\"CrawlerRunConfig\"},\"urls\":[\"https://html.duckduckgo.com/html/?q=rien\"]}" + } + ], + "images": [], + "go_fetch": [], + "go_search": [] + }, + { + "name": "Crawl4AI en panne", + "tool": "web_search", + "args": { + "query": "panne" + }, + "result": "❌ Recherche échouée : Crawl4AI HTTP 500: éééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééé", + "crawls": [ + { + "path": "POST /crawl", + "auth": "Bearer sesame", + "ctype": "application/json", + "body": "{\"browser_config\":{\"params\":{\"headless\":true},\"type\":\"BrowserConfig\"},\"crawler_config\":{\"params\":{\"page_timeout\":30000},\"type\":\"CrawlerRunConfig\"},\"urls\":[\"https://html.duckduckgo.com/html/?q=panne\"]}" + } + ], + "images": [], + "go_fetch": [], + "go_search": [] + }, + { + "name": "ouverture", + "tool": "web_open", + "args": { + "url": "https://exemple.test/doc" + }, + "result": "# Ouvert : https://exemple.test/doc\nTotal : 18 lignes, 224 B (224 caractères)\nEn cache 10 min. Utilise web_read ou web_grep pour lire.\n\n## Plan (n° de ligne des titres)\n```\n 1 | Documentation\n 5 | Installation\n 8 | Détails\n 18 | Fin\n```", + "crawls": [ + { + "path": "POST /crawl", + "auth": "Bearer sesame", + "ctype": "application/json", + "body": "{\"browser_config\":{\"params\":{\"headless\":true},\"type\":\"BrowserConfig\"},\"crawler_config\":{\"params\":{\"js_code\":[\"(() =\\u003e { try {\\n const TEXT = /^(accept all|accept|i accept|agree|i agree|got it|i understand|j'accepte|tout accepter|accepter|d'accord|allow all|allow|consent|continue|ok)$/i;\\n const isOverlayish = (el) =\\u003e {\\n try {\\n let cur = el;\\n for (let i = 0; i \\u003c 6 \\u0026\\u0026 cur; i++) {\\n const s = getComputedStyle(cur);\\n if (s.position === 'fixed' || s.position === 'sticky') return true;\\n const idcls = ((cur.id || '') + ' ' + (cur.className || '')).toLowerCase();\\n if (/cookie|consent|gdpr|cmp|privacy/.test(idcls)) return true;\\n cur = cur.parentElement;\\n }\\n } catch {}\\n return false;\\n };\\n const candidates = document.querySelectorAll('button, [role=\\\"button\\\"], input[type=\\\"button\\\"], input[type=\\\"submit\\\"]');\\n let clicked = 0;\\n for (const b of candidates) {\\n if (clicked \\u003e= 2) break;\\n const t = (b.innerText || b.value || b.getAttribute('aria-label') || '').trim();\\n if (!t || !TEXT.test(t)) continue;\\n if (b.offsetParent === null) continue;\\n if (!isOverlayish(b)) continue;\\n try { b.click(); clicked++; } catch {}\\n }\\n document.querySelectorAll('[id*=\\\"cookie\\\" i],[id*=\\\"consent\\\" i],[class*=\\\"cookie\\\" i],[class*=\\\"consent\\\" i],[id*=\\\"gdpr\\\" i],[class*=\\\"gdpr\\\" i],[id*=\\\"cmp\\\" i],[class*=\\\"cmp\\\" i]')\\n .forEach(el =\\u003e { try {\\n const s = getComputedStyle(el);\\n if (s.position === 'fixed' || s.position === 'sticky') el.remove();\\n } catch {} });\\n document.documentElement.style.overflow = 'auto';\\n if (document.body) document.body.style.overflow = 'auto';\\n} catch {} })();\"]},\"type\":\"CrawlerRunConfig\"},\"urls\":[\"https://exemple.test/doc\"]}" + } + ], + "images": [], + "go_fetch": [], + "go_search": [] + }, + { + "name": "lecture", + "tool": "web_read", + "args": { + "url": "https://exemple.test/doc" + }, + "result": "# https://exemple.test/doc\nLignes 1–18 sur 18 (fin de page)\n\n```\n 1 | # Documentation\n 2 | \n 3 | Intro ligne 1\n 4 | \n 5 | ## Installation\n 6 | Lancer `loki install`.\n 7 | Erreur : moteur absent\n 8 | ### Détails\n 9 | ligne a\n 10 | ligne b\n 11 | Erreur : port pris\n 12 | ligne c\n 13 | ligne d\n 14 | ligne e\n 15 | ligne f\n 16 | Erreur : disque plein\n 17 | ####### pas un titre\n 18 | ## Fin\n```", + "crawls": [], + "images": [], + "go_fetch": [], + "go_search": [] + }, + { + "name": "lecture d'une plage", + "tool": "web_read", + "args": { + "limit": 3, + "offset": 5, + "url": "https://exemple.test/doc" + }, + "result": "# https://exemple.test/doc\nLignes 5–7 sur 18 (11 de plus en dessous)\n\n```\n 5 | ## Installation\n 6 | Lancer `loki install`.\n 7 | Erreur : moteur absent\n```", + "crawls": [], + "images": [], + "go_fetch": [], + "go_search": [] + }, + { + "name": "lecture au-delà", + "tool": "web_read", + "args": { + "offset": 400, + "url": "https://exemple.test/doc" + }, + "result": "# https://exemple.test/doc\nLignes 400–18 sur 18 (fin de page)\n\n```\n\n```", + "crawls": [], + "images": [], + "go_fetch": [], + "go_search": [] + }, + { + "name": "lecture, bornes", + "tool": "web_read", + "args": { + "limit": 0, + "offset": -2, + "url": "https://exemple.test/doc" + }, + "result": "# https://exemple.test/doc\nLignes 1–1 sur 18 (17 de plus en dessous)\n\n```\n 1 | # Documentation\n```", + "crawls": [], + "images": [], + "go_fetch": [], + "go_search": [] + }, + { + "name": "grep", + "tool": "web_grep", + "args": { + "pattern": "erreur", + "url": "https://exemple.test/doc" + }, + "result": "# https://exemple.test/doc\n3 match(es) pour /erreur/i\n\n```\n 5 | ## Installation\n 6 | Lancer `loki install`.\n 7 | Erreur : moteur absent\n 8 | ### Détails\n 9 | ligne a\n 10 | ligne b\n 11 | Erreur : port pris\n 12 | ligne c\n 13 | ligne d\n 14 | ligne e\n 15 | ligne f\n 16 | Erreur : disque plein\n 17 | ####### pas un titre\n 18 | ## Fin\n```", + "crawls": [], + "images": [], + "go_fetch": [], + "go_search": [] + }, + { + "name": "grep sans contexte", + "tool": "web_grep", + "args": { + "context": 0, + "pattern": "^ligne [ae]$", + "url": "https://exemple.test/doc" + }, + "result": "# https://exemple.test/doc\n2 match(es) pour /^ligne [ae]$/i\n\n```\n 9 | ligne a\n```\n\n---\n\n```\n 14 | ligne e\n```", + "crawls": [], + "images": [], + "go_fetch": [], + "go_search": [] + }, + { + "name": "grep plafonné", + "tool": "web_grep", + "args": { + "context": 1, + "max_matches": 2, + "pattern": "ligne", + "url": "https://exemple.test/doc" + }, + "result": "# https://exemple.test/doc\n2 match(es) pour /ligne/i (plafonné à 2)\n\n```\n 2 | \n 3 | Intro ligne 1\n 4 | \n```\n\n---\n\n```\n 8 | ### Détails\n 9 | ligne a\n 10 | ligne b\n```", + "crawls": [], + "images": [], + "go_fetch": [], + "go_search": [] + }, + { + "name": "grep, contexte large", + "tool": "web_grep", + "args": { + "context": 50, + "pattern": "Détails", + "url": "https://exemple.test/doc" + }, + "result": "# https://exemple.test/doc\n1 match(es) pour /Détails/i\n\n```\n 1 | # Documentation\n 2 | \n 3 | Intro ligne 1\n 4 | \n 5 | ## Installation\n 6 | Lancer `loki install`.\n 7 | Erreur : moteur absent\n 8 | ### Détails\n 9 | ligne a\n 10 | ligne b\n 11 | Erreur : port pris\n 12 | ligne c\n 13 | ligne d\n 14 | ligne e\n 15 | ligne f\n 16 | Erreur : disque plein\n 17 | ####### pas un titre\n 18 | ## Fin\n```", + "crawls": [], + "images": [], + "go_fetch": [], + "go_search": [] + }, + { + "name": "grep sans résultat", + "tool": "web_grep", + "args": { + "pattern": "introuvable", + "url": "https://exemple.test/doc" + }, + "result": "# https://exemple.test/doc\nAucun match pour /introuvable/i", + "crawls": [], + "images": [], + "go_fetch": [], + "go_search": [] + }, + { + "name": "grep, ascii", + "tool": "web_grep", + "args": { + "pattern": "\\bD\\w+ils", + "url": "https://exemple.test/doc" + }, + "result": "# https://exemple.test/doc\nAucun match pour /\\bD\\w+ils/i", + "crawls": [], + "images": [], + "go_fetch": [], + "go_search": [] + }, + { + "name": "regex invalide 1", + "tool": "web_grep", + "args": { + "pattern": "(", + "url": "https://exemple.test/doc" + }, + "result": "❌ Regex invalide : error parsing regexp: missing closing ): `(?i)(`", + "crawls": [], + "images": [], + "go_fetch": [], + "go_search": [] + }, + { + "name": "regex invalide 2", + "tool": "web_grep", + "args": { + "pattern": "a)", + "url": "https://exemple.test/doc" + }, + "result": "❌ Regex invalide : error parsing regexp: unexpected ): `(?i)a)`", + "crawls": [], + "images": [], + "go_fetch": [], + "go_search": [] + }, + { + "name": "regex invalide 3", + "tool": "web_grep", + "args": { + "pattern": "[abc", + "url": "https://exemple.test/doc" + }, + "result": "❌ Regex invalide : error parsing regexp: missing closing ]: `[abc`", + "crawls": [], + "images": [], + "go_fetch": [], + "go_search": [] + }, + { + "name": "regex invalide 4", + "tool": "web_grep", + "args": { + "pattern": "*x", + "url": "https://exemple.test/doc" + }, + "result": "❌ Regex invalide : error parsing regexp: missing argument to repetition operator: `*`", + "crawls": [], + "images": [], + "go_fetch": [], + "go_search": [] + }, + { + "name": "regex invalide 5", + "tool": "web_grep", + "args": { + "pattern": "a**", + "url": "https://exemple.test/doc" + }, + "result": "❌ Regex invalide : error parsing regexp: invalid nested repetition operator: `**`", + "crawls": [], + "images": [], + "go_fetch": [], + "go_search": [] + }, + { + "name": "regex invalide 6", + "tool": "web_grep", + "args": { + "pattern": "\\q", + "url": "https://exemple.test/doc" + }, + "result": "❌ Regex invalide : error parsing regexp: invalid escape sequence: `\\q`", + "crawls": [], + "images": [], + "go_fetch": [], + "go_search": [] + }, + { + "name": "regex invalide 7", + "tool": "web_grep", + "args": { + "pattern": "a\\", + "url": "https://exemple.test/doc" + }, + "result": "❌ Regex invalide : error parsing regexp: trailing backslash at end of expression: ``", + "crawls": [], + "images": [], + "go_fetch": [], + "go_search": [] + }, + { + "name": "page jamais ouverte", + "tool": "web_read", + "args": { + "url": "https://exemple.test/autre" + }, + "result": "❌ Page absente du cache. Appelle d'abord web_open(\"https://exemple.test/autre\").", + "crawls": [], + "images": [], + "go_fetch": [], + "go_search": [] + }, + { + "name": "grep, page jamais ouverte", + "tool": "web_grep", + "args": { + "pattern": "x", + "url": "https://exemple.test/autre" + }, + "result": "❌ Page absente du cache. Appelle d'abord web_open(\"https://exemple.test/autre\").", + "crawls": [], + "images": [], + "go_fetch": [], + "go_search": [] + }, + { + "name": "réouverture : cache", + "tool": "web_open", + "args": { + "url": "https://exemple.test/doc" + }, + "result": "# Ouvert : https://exemple.test/doc\nTotal : 18 lignes, 224 B (224 caractères)\nEn cache 10 min. Utilise web_read ou web_grep pour lire.\n\n## Plan (n° de ligne des titres)\n```\n 1 | Documentation\n 5 | Installation\n 8 | Détails\n 18 | Fin\n```", + "crawls": [], + "images": [], + "go_fetch": [], + "go_search": [] + }, + { + "name": "réouverture forcée", + "tool": "web_open", + "args": { + "refresh": true, + "url": "https://exemple.test/doc" + }, + "result": "# Ouvert : https://exemple.test/doc\nTotal : 3 lignes, 29 B (29 caractères)\nEn cache 10 min. Utilise web_read ou web_grep pour lire.\n\n## Plan (n° de ligne des titres)\n```\n 1 | Version 2\n```", + "crawls": [ + { + "path": "POST /crawl", + "auth": "Bearer sesame", + "ctype": "application/json", + "body": "{\"browser_config\":{\"params\":{\"headless\":true},\"type\":\"BrowserConfig\"},\"crawler_config\":{\"params\":{\"js_code\":[\"(() =\\u003e { try {\\n const TEXT = /^(accept all|accept|i accept|agree|i agree|got it|i understand|j'accepte|tout accepter|accepter|d'accord|allow all|allow|consent|continue|ok)$/i;\\n const isOverlayish = (el) =\\u003e {\\n try {\\n let cur = el;\\n for (let i = 0; i \\u003c 6 \\u0026\\u0026 cur; i++) {\\n const s = getComputedStyle(cur);\\n if (s.position === 'fixed' || s.position === 'sticky') return true;\\n const idcls = ((cur.id || '') + ' ' + (cur.className || '')).toLowerCase();\\n if (/cookie|consent|gdpr|cmp|privacy/.test(idcls)) return true;\\n cur = cur.parentElement;\\n }\\n } catch {}\\n return false;\\n };\\n const candidates = document.querySelectorAll('button, [role=\\\"button\\\"], input[type=\\\"button\\\"], input[type=\\\"submit\\\"]');\\n let clicked = 0;\\n for (const b of candidates) {\\n if (clicked \\u003e= 2) break;\\n const t = (b.innerText || b.value || b.getAttribute('aria-label') || '').trim();\\n if (!t || !TEXT.test(t)) continue;\\n if (b.offsetParent === null) continue;\\n if (!isOverlayish(b)) continue;\\n try { b.click(); clicked++; } catch {}\\n }\\n document.querySelectorAll('[id*=\\\"cookie\\\" i],[id*=\\\"consent\\\" i],[class*=\\\"cookie\\\" i],[class*=\\\"consent\\\" i],[id*=\\\"gdpr\\\" i],[class*=\\\"gdpr\\\" i],[id*=\\\"cmp\\\" i],[class*=\\\"cmp\\\" i]')\\n .forEach(el =\\u003e { try {\\n const s = getComputedStyle(el);\\n if (s.position === 'fixed' || s.position === 'sticky') el.remove();\\n } catch {} });\\n document.documentElement.style.overflow = 'auto';\\n if (document.body) document.body.style.overflow = 'auto';\\n} catch {} })();\"]},\"type\":\"CrawlerRunConfig\"},\"urls\":[\"https://exemple.test/doc\"]}" + } + ], + "images": [], + "go_fetch": [], + "go_search": [] + }, + { + "name": "ouverture avec actions", + "advance_s": 5, + "tool": "web_open", + "args": { + "actions": [ + "document.querySelector('#plus').click();", + 3, + "x && y < z" + ], + "dismiss_popups": false, + "url": "https://exemple.test/doc", + "wait_for": "css:#fin" + }, + "result": "# Ouvert : https://exemple.test/doc\nTotal : 3 lignes, 31 B (31 caractères)\nEn cache 10 min. Utilise web_read ou web_grep pour lire.\n\n## Plan (n° de ligne des titres)\n```\n 1 | Avec actions\n```", + "crawls": [ + { + "path": "POST /crawl", + "auth": "Bearer sesame", + "ctype": "application/json", + "body": "{\"browser_config\":{\"params\":{\"headless\":true},\"type\":\"BrowserConfig\"},\"crawler_config\":{\"params\":{\"js_code\":[\"document.querySelector('#plus').click();\",\"x \\u0026\\u0026 y \\u003c z\"],\"page_timeout\":45000,\"wait_for\":\"css:#fin\"},\"type\":\"CrawlerRunConfig\"},\"urls\":[\"https://exemple.test/doc\"]}" + } + ], + "images": [], + "go_fetch": [], + "go_search": [] + }, + { + "name": "lecture : la plus récente", + "tool": "web_read", + "args": { + "url": "https://exemple.test/doc" + }, + "result": "# https://exemple.test/doc\nLignes 1–3 sur 3 (fin de page)\n\n```\n 1 | # Avec actions\n 2 | \n 3 | bouton cliqué\n```", + "crawls": [], + "images": [], + "go_fetch": [], + "go_search": [] + }, + { + "name": "attente seule", + "tool": "web_open", + "args": { + "url": "https://exemple.test/seul", + "wait_for": "js:() => true" + }, + "result": "# Ouvert : https://exemple.test/seul\nTotal : 3 lignes, 26 B (26 caractères)\nEn cache 10 min. Utilise web_read ou web_grep pour lire.\n\n## Plan (n° de ligne des titres)\n```\n 1 | Seul\n```", + "crawls": [ + { + "path": "POST /crawl", + "auth": "Bearer sesame", + "ctype": "application/json", + "body": "{\"browser_config\":{\"params\":{\"headless\":true},\"type\":\"BrowserConfig\"},\"crawler_config\":{\"params\":{\"js_code\":[\"(() =\\u003e { try {\\n const TEXT = /^(accept all|accept|i accept|agree|i agree|got it|i understand|j'accepte|tout accepter|accepter|d'accord|allow all|allow|consent|continue|ok)$/i;\\n const isOverlayish = (el) =\\u003e {\\n try {\\n let cur = el;\\n for (let i = 0; i \\u003c 6 \\u0026\\u0026 cur; i++) {\\n const s = getComputedStyle(cur);\\n if (s.position === 'fixed' || s.position === 'sticky') return true;\\n const idcls = ((cur.id || '') + ' ' + (cur.className || '')).toLowerCase();\\n if (/cookie|consent|gdpr|cmp|privacy/.test(idcls)) return true;\\n cur = cur.parentElement;\\n }\\n } catch {}\\n return false;\\n };\\n const candidates = document.querySelectorAll('button, [role=\\\"button\\\"], input[type=\\\"button\\\"], input[type=\\\"submit\\\"]');\\n let clicked = 0;\\n for (const b of candidates) {\\n if (clicked \\u003e= 2) break;\\n const t = (b.innerText || b.value || b.getAttribute('aria-label') || '').trim();\\n if (!t || !TEXT.test(t)) continue;\\n if (b.offsetParent === null) continue;\\n if (!isOverlayish(b)) continue;\\n try { b.click(); clicked++; } catch {}\\n }\\n document.querySelectorAll('[id*=\\\"cookie\\\" i],[id*=\\\"consent\\\" i],[class*=\\\"cookie\\\" i],[class*=\\\"consent\\\" i],[id*=\\\"gdpr\\\" i],[class*=\\\"gdpr\\\" i],[id*=\\\"cmp\\\" i],[class*=\\\"cmp\\\" i]')\\n .forEach(el =\\u003e { try {\\n const s = getComputedStyle(el);\\n if (s.position === 'fixed' || s.position === 'sticky') el.remove();\\n } catch {} });\\n document.documentElement.style.overflow = 'auto';\\n if (document.body) document.body.style.overflow = 'auto';\\n} catch {} })();\"],\"page_timeout\":45000,\"wait_for\":\"js:() =\\u003e true\"},\"type\":\"CrawlerRunConfig\"},\"urls\":[\"https://exemple.test/seul\"]}" + } + ], + "images": [], + "go_fetch": [], + "go_search": [] + }, + { + "name": "sans fermer les bandeaux", + "tool": "web_open", + "args": { + "dismiss_popups": false, + "url": "https://exemple.test/brut-absent" + }, + "result": "# Ouvert : https://exemple.test/brut-absent\nTotal : 3 lignes, 15 B (15 caractères)\nEn cache 10 min. Utilise web_read ou web_grep pour lire.\n\n## Plan (n° de ligne des titres)\n```\n 1 | Fit seul\n```", + "crawls": [ + { + "path": "POST /crawl", + "auth": "Bearer sesame", + "ctype": "application/json", + "body": "{\"browser_config\":{\"params\":{\"headless\":true},\"type\":\"BrowserConfig\"},\"crawler_config\":{\"params\":{},\"type\":\"CrawlerRunConfig\"},\"urls\":[\"https://exemple.test/brut-absent\"]}" + } + ], + "images": [], + "go_fetch": [], + "go_search": [] + }, + { + "name": "cache périmé", + "advance_s": 601, + "tool": "web_read", + "args": { + "url": "https://exemple.test/doc" + }, + "result": "❌ Page absente du cache. Appelle d'abord web_open(\"https://exemple.test/doc\").", + "crawls": [], + "images": [], + "go_fetch": [], + "go_search": [] + }, + { + "name": "ouverture après péremption", + "tool": "web_open", + "args": { + "url": "https://exemple.test/doc" + }, + "result": "# Ouvert : https://exemple.test/doc\nTotal : 3 lignes, 31 B (31 caractères)\nEn cache 10 min. Utilise web_read ou web_grep pour lire.\n\n## Plan (n° de ligne des titres)\n```\n 1 | Avec actions\n```", + "crawls": [ + { + "path": "POST /crawl", + "auth": "Bearer sesame", + "ctype": "application/json", + "body": "{\"browser_config\":{\"params\":{\"headless\":true},\"type\":\"BrowserConfig\"},\"crawler_config\":{\"params\":{\"js_code\":[\"(() =\\u003e { try {\\n const TEXT = /^(accept all|accept|i accept|agree|i agree|got it|i understand|j'accepte|tout accepter|accepter|d'accord|allow all|allow|consent|continue|ok)$/i;\\n const isOverlayish = (el) =\\u003e {\\n try {\\n let cur = el;\\n for (let i = 0; i \\u003c 6 \\u0026\\u0026 cur; i++) {\\n const s = getComputedStyle(cur);\\n if (s.position === 'fixed' || s.position === 'sticky') return true;\\n const idcls = ((cur.id || '') + ' ' + (cur.className || '')).toLowerCase();\\n if (/cookie|consent|gdpr|cmp|privacy/.test(idcls)) return true;\\n cur = cur.parentElement;\\n }\\n } catch {}\\n return false;\\n };\\n const candidates = document.querySelectorAll('button, [role=\\\"button\\\"], input[type=\\\"button\\\"], input[type=\\\"submit\\\"]');\\n let clicked = 0;\\n for (const b of candidates) {\\n if (clicked \\u003e= 2) break;\\n const t = (b.innerText || b.value || b.getAttribute('aria-label') || '').trim();\\n if (!t || !TEXT.test(t)) continue;\\n if (b.offsetParent === null) continue;\\n if (!isOverlayish(b)) continue;\\n try { b.click(); clicked++; } catch {}\\n }\\n document.querySelectorAll('[id*=\\\"cookie\\\" i],[id*=\\\"consent\\\" i],[class*=\\\"cookie\\\" i],[class*=\\\"consent\\\" i],[id*=\\\"gdpr\\\" i],[class*=\\\"gdpr\\\" i],[id*=\\\"cmp\\\" i],[class*=\\\"cmp\\\" i]')\\n .forEach(el =\\u003e { try {\\n const s = getComputedStyle(el);\\n if (s.position === 'fixed' || s.position === 'sticky') el.remove();\\n } catch {} });\\n document.documentElement.style.overflow = 'auto';\\n if (document.body) document.body.style.overflow = 'auto';\\n} catch {} })();\"]},\"type\":\"CrawlerRunConfig\"},\"urls\":[\"https://exemple.test/doc\"]}" + } + ], + "images": [], + "go_fetch": [], + "go_search": [] + }, + { + "name": "dépôt GitHub", + "tool": "web_open", + "args": { + "url": "https://github.com/r0m1k3/loki/" + }, + "result": "# Ouvert : https://raw.githubusercontent.com/r0m1k3/loki/HEAD/README.md\nTotal : 3 lignes, 19 B (19 caractères)\nEn cache 10 min. Utilise web_read ou web_grep pour lire.\n\n## Plan (n° de ligne des titres)\n```\n 1 | Loki\n```", + "crawls": [ + { + "path": "POST /crawl", + "auth": "Bearer sesame", + "ctype": "application/json", + "body": "{\"browser_config\":{\"params\":{\"headless\":true},\"type\":\"BrowserConfig\"},\"crawler_config\":{\"params\":{\"js_code\":[\"(() =\\u003e { try {\\n const TEXT = /^(accept all|accept|i accept|agree|i agree|got it|i understand|j'accepte|tout accepter|accepter|d'accord|allow all|allow|consent|continue|ok)$/i;\\n const isOverlayish = (el) =\\u003e {\\n try {\\n let cur = el;\\n for (let i = 0; i \\u003c 6 \\u0026\\u0026 cur; i++) {\\n const s = getComputedStyle(cur);\\n if (s.position === 'fixed' || s.position === 'sticky') return true;\\n const idcls = ((cur.id || '') + ' ' + (cur.className || '')).toLowerCase();\\n if (/cookie|consent|gdpr|cmp|privacy/.test(idcls)) return true;\\n cur = cur.parentElement;\\n }\\n } catch {}\\n return false;\\n };\\n const candidates = document.querySelectorAll('button, [role=\\\"button\\\"], input[type=\\\"button\\\"], input[type=\\\"submit\\\"]');\\n let clicked = 0;\\n for (const b of candidates) {\\n if (clicked \\u003e= 2) break;\\n const t = (b.innerText || b.value || b.getAttribute('aria-label') || '').trim();\\n if (!t || !TEXT.test(t)) continue;\\n if (b.offsetParent === null) continue;\\n if (!isOverlayish(b)) continue;\\n try { b.click(); clicked++; } catch {}\\n }\\n document.querySelectorAll('[id*=\\\"cookie\\\" i],[id*=\\\"consent\\\" i],[class*=\\\"cookie\\\" i],[class*=\\\"consent\\\" i],[id*=\\\"gdpr\\\" i],[class*=\\\"gdpr\\\" i],[id*=\\\"cmp\\\" i],[class*=\\\"cmp\\\" i]')\\n .forEach(el =\\u003e { try {\\n const s = getComputedStyle(el);\\n if (s.position === 'fixed' || s.position === 'sticky') el.remove();\\n } catch {} });\\n document.documentElement.style.overflow = 'auto';\\n if (document.body) document.body.style.overflow = 'auto';\\n} catch {} })();\"]},\"type\":\"CrawlerRunConfig\"},\"urls\":[\"https://raw.githubusercontent.com/r0m1k3/loki/HEAD/README.md\"]}" + } + ], + "images": [], + "go_fetch": [], + "go_search": [] + }, + { + "name": "lecture du dépôt", + "tool": "web_read", + "args": { + "url": "https://github.com/r0m1k3/loki" + }, + "result": "# https://raw.githubusercontent.com/r0m1k3/loki/HEAD/README.md\nLignes 1–3 sur 3 (fin de page)\n\n```\n 1 | # Loki\n 2 | \n 3 | Le README.\n```", + "crawls": [], + "images": [], + "go_fetch": [], + "go_search": [] + }, + { + "name": "GitHub, pas un dépôt", + "tool": "web_open", + "args": { + "url": "https://github.com/r0m1k3" + }, + "result": "❌ Crawl4AI HTTP 404: inconnu : https://github.com/r0m1k3", + "crawls": [ + { + "path": "POST /crawl", + "auth": "Bearer sesame", + "ctype": "application/json", + "body": "{\"browser_config\":{\"params\":{\"headless\":true},\"type\":\"BrowserConfig\"},\"crawler_config\":{\"params\":{\"js_code\":[\"(() =\\u003e { try {\\n const TEXT = /^(accept all|accept|i accept|agree|i agree|got it|i understand|j'accepte|tout accepter|accepter|d'accord|allow all|allow|consent|continue|ok)$/i;\\n const isOverlayish = (el) =\\u003e {\\n try {\\n let cur = el;\\n for (let i = 0; i \\u003c 6 \\u0026\\u0026 cur; i++) {\\n const s = getComputedStyle(cur);\\n if (s.position === 'fixed' || s.position === 'sticky') return true;\\n const idcls = ((cur.id || '') + ' ' + (cur.className || '')).toLowerCase();\\n if (/cookie|consent|gdpr|cmp|privacy/.test(idcls)) return true;\\n cur = cur.parentElement;\\n }\\n } catch {}\\n return false;\\n };\\n const candidates = document.querySelectorAll('button, [role=\\\"button\\\"], input[type=\\\"button\\\"], input[type=\\\"submit\\\"]');\\n let clicked = 0;\\n for (const b of candidates) {\\n if (clicked \\u003e= 2) break;\\n const t = (b.innerText || b.value || b.getAttribute('aria-label') || '').trim();\\n if (!t || !TEXT.test(t)) continue;\\n if (b.offsetParent === null) continue;\\n if (!isOverlayish(b)) continue;\\n try { b.click(); clicked++; } catch {}\\n }\\n document.querySelectorAll('[id*=\\\"cookie\\\" i],[id*=\\\"consent\\\" i],[class*=\\\"cookie\\\" i],[class*=\\\"consent\\\" i],[id*=\\\"gdpr\\\" i],[class*=\\\"gdpr\\\" i],[id*=\\\"cmp\\\" i],[class*=\\\"cmp\\\" i]')\\n .forEach(el =\\u003e { try {\\n const s = getComputedStyle(el);\\n if (s.position === 'fixed' || s.position === 'sticky') el.remove();\\n } catch {} });\\n document.documentElement.style.overflow = 'auto';\\n if (document.body) document.body.style.overflow = 'auto';\\n} catch {} })();\"]},\"type\":\"CrawlerRunConfig\"},\"urls\":[\"https://github.com/r0m1k3\"]}" + } + ], + "images": [], + "go_fetch": [], + "go_search": [] + }, + { + "name": "crawl en échec", + "tool": "web_open", + "args": { + "url": "https://exemple.test/echec" + }, + "result": "❌ Crawl4AI : xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx", + "crawls": [ + { + "path": "POST /crawl", + "auth": "Bearer sesame", + "ctype": "application/json", + "body": "{\"browser_config\":{\"params\":{\"headless\":true},\"type\":\"BrowserConfig\"},\"crawler_config\":{\"params\":{\"js_code\":[\"(() =\\u003e { try {\\n const TEXT = /^(accept all|accept|i accept|agree|i agree|got it|i understand|j'accepte|tout accepter|accepter|d'accord|allow all|allow|consent|continue|ok)$/i;\\n const isOverlayish = (el) =\\u003e {\\n try {\\n let cur = el;\\n for (let i = 0; i \\u003c 6 \\u0026\\u0026 cur; i++) {\\n const s = getComputedStyle(cur);\\n if (s.position === 'fixed' || s.position === 'sticky') return true;\\n const idcls = ((cur.id || '') + ' ' + (cur.className || '')).toLowerCase();\\n if (/cookie|consent|gdpr|cmp|privacy/.test(idcls)) return true;\\n cur = cur.parentElement;\\n }\\n } catch {}\\n return false;\\n };\\n const candidates = document.querySelectorAll('button, [role=\\\"button\\\"], input[type=\\\"button\\\"], input[type=\\\"submit\\\"]');\\n let clicked = 0;\\n for (const b of candidates) {\\n if (clicked \\u003e= 2) break;\\n const t = (b.innerText || b.value || b.getAttribute('aria-label') || '').trim();\\n if (!t || !TEXT.test(t)) continue;\\n if (b.offsetParent === null) continue;\\n if (!isOverlayish(b)) continue;\\n try { b.click(); clicked++; } catch {}\\n }\\n document.querySelectorAll('[id*=\\\"cookie\\\" i],[id*=\\\"consent\\\" i],[class*=\\\"cookie\\\" i],[class*=\\\"consent\\\" i],[id*=\\\"gdpr\\\" i],[class*=\\\"gdpr\\\" i],[id*=\\\"cmp\\\" i],[class*=\\\"cmp\\\" i]')\\n .forEach(el =\\u003e { try {\\n const s = getComputedStyle(el);\\n if (s.position === 'fixed' || s.position === 'sticky') el.remove();\\n } catch {} });\\n document.documentElement.style.overflow = 'auto';\\n if (document.body) document.body.style.overflow = 'auto';\\n} catch {} })();\"]},\"type\":\"CrawlerRunConfig\"},\"urls\":[\"https://exemple.test/echec\"]}" + } + ], + "images": [], + "go_fetch": [], + "go_search": [] + }, + { + "name": "crawl en échec, sans message", + "tool": "web_open", + "args": { + "url": "https://exemple.test/echec-muet" + }, + "result": "❌ Crawl4AI : échec du crawl", + "crawls": [ + { + "path": "POST /crawl", + "auth": "Bearer sesame", + "ctype": "application/json", + "body": "{\"browser_config\":{\"params\":{\"headless\":true},\"type\":\"BrowserConfig\"},\"crawler_config\":{\"params\":{\"js_code\":[\"(() =\\u003e { try {\\n const TEXT = /^(accept all|accept|i accept|agree|i agree|got it|i understand|j'accepte|tout accepter|accepter|d'accord|allow all|allow|consent|continue|ok)$/i;\\n const isOverlayish = (el) =\\u003e {\\n try {\\n let cur = el;\\n for (let i = 0; i \\u003c 6 \\u0026\\u0026 cur; i++) {\\n const s = getComputedStyle(cur);\\n if (s.position === 'fixed' || s.position === 'sticky') return true;\\n const idcls = ((cur.id || '') + ' ' + (cur.className || '')).toLowerCase();\\n if (/cookie|consent|gdpr|cmp|privacy/.test(idcls)) return true;\\n cur = cur.parentElement;\\n }\\n } catch {}\\n return false;\\n };\\n const candidates = document.querySelectorAll('button, [role=\\\"button\\\"], input[type=\\\"button\\\"], input[type=\\\"submit\\\"]');\\n let clicked = 0;\\n for (const b of candidates) {\\n if (clicked \\u003e= 2) break;\\n const t = (b.innerText || b.value || b.getAttribute('aria-label') || '').trim();\\n if (!t || !TEXT.test(t)) continue;\\n if (b.offsetParent === null) continue;\\n if (!isOverlayish(b)) continue;\\n try { b.click(); clicked++; } catch {}\\n }\\n document.querySelectorAll('[id*=\\\"cookie\\\" i],[id*=\\\"consent\\\" i],[class*=\\\"cookie\\\" i],[class*=\\\"consent\\\" i],[id*=\\\"gdpr\\\" i],[class*=\\\"gdpr\\\" i],[id*=\\\"cmp\\\" i],[class*=\\\"cmp\\\" i]')\\n .forEach(el =\\u003e { try {\\n const s = getComputedStyle(el);\\n if (s.position === 'fixed' || s.position === 'sticky') el.remove();\\n } catch {} });\\n document.documentElement.style.overflow = 'auto';\\n if (document.body) document.body.style.overflow = 'auto';\\n} catch {} })();\"]},\"type\":\"CrawlerRunConfig\"},\"urls\":[\"https://exemple.test/echec-muet\"]}" + } + ], + "images": [], + "go_fetch": [], + "go_search": [] + }, + { + "name": "réponse vide", + "tool": "web_open", + "args": { + "url": "https://exemple.test/vide" + }, + "result": "❌ Crawl4AI : échec du crawl", + "crawls": [ + { + "path": "POST /crawl", + "auth": "Bearer sesame", + "ctype": "application/json", + "body": "{\"browser_config\":{\"params\":{\"headless\":true},\"type\":\"BrowserConfig\"},\"crawler_config\":{\"params\":{\"js_code\":[\"(() =\\u003e { try {\\n const TEXT = /^(accept all|accept|i accept|agree|i agree|got it|i understand|j'accepte|tout accepter|accepter|d'accord|allow all|allow|consent|continue|ok)$/i;\\n const isOverlayish = (el) =\\u003e {\\n try {\\n let cur = el;\\n for (let i = 0; i \\u003c 6 \\u0026\\u0026 cur; i++) {\\n const s = getComputedStyle(cur);\\n if (s.position === 'fixed' || s.position === 'sticky') return true;\\n const idcls = ((cur.id || '') + ' ' + (cur.className || '')).toLowerCase();\\n if (/cookie|consent|gdpr|cmp|privacy/.test(idcls)) return true;\\n cur = cur.parentElement;\\n }\\n } catch {}\\n return false;\\n };\\n const candidates = document.querySelectorAll('button, [role=\\\"button\\\"], input[type=\\\"button\\\"], input[type=\\\"submit\\\"]');\\n let clicked = 0;\\n for (const b of candidates) {\\n if (clicked \\u003e= 2) break;\\n const t = (b.innerText || b.value || b.getAttribute('aria-label') || '').trim();\\n if (!t || !TEXT.test(t)) continue;\\n if (b.offsetParent === null) continue;\\n if (!isOverlayish(b)) continue;\\n try { b.click(); clicked++; } catch {}\\n }\\n document.querySelectorAll('[id*=\\\"cookie\\\" i],[id*=\\\"consent\\\" i],[class*=\\\"cookie\\\" i],[class*=\\\"consent\\\" i],[id*=\\\"gdpr\\\" i],[class*=\\\"gdpr\\\" i],[id*=\\\"cmp\\\" i],[class*=\\\"cmp\\\" i]')\\n .forEach(el =\\u003e { try {\\n const s = getComputedStyle(el);\\n if (s.position === 'fixed' || s.position === 'sticky') el.remove();\\n } catch {} });\\n document.documentElement.style.overflow = 'auto';\\n if (document.body) document.body.style.overflow = 'auto';\\n} catch {} })();\"]},\"type\":\"CrawlerRunConfig\"},\"urls\":[\"https://exemple.test/vide\"]}" + } + ], + "images": [], + "go_fetch": [], + "go_search": [] + }, + { + "name": "réponse tableau", + "tool": "web_open", + "args": { + "url": "https://exemple.test/tableau" + }, + "result": "❌ Crawl4AI : réponse vide", + "crawls": [ + { + "path": "POST /crawl", + "auth": "Bearer sesame", + "ctype": "application/json", + "body": "{\"browser_config\":{\"params\":{\"headless\":true},\"type\":\"BrowserConfig\"},\"crawler_config\":{\"params\":{\"js_code\":[\"(() =\\u003e { try {\\n const TEXT = /^(accept all|accept|i accept|agree|i agree|got it|i understand|j'accepte|tout accepter|accepter|d'accord|allow all|allow|consent|continue|ok)$/i;\\n const isOverlayish = (el) =\\u003e {\\n try {\\n let cur = el;\\n for (let i = 0; i \\u003c 6 \\u0026\\u0026 cur; i++) {\\n const s = getComputedStyle(cur);\\n if (s.position === 'fixed' || s.position === 'sticky') return true;\\n const idcls = ((cur.id || '') + ' ' + (cur.className || '')).toLowerCase();\\n if (/cookie|consent|gdpr|cmp|privacy/.test(idcls)) return true;\\n cur = cur.parentElement;\\n }\\n } catch {}\\n return false;\\n };\\n const candidates = document.querySelectorAll('button, [role=\\\"button\\\"], input[type=\\\"button\\\"], input[type=\\\"submit\\\"]');\\n let clicked = 0;\\n for (const b of candidates) {\\n if (clicked \\u003e= 2) break;\\n const t = (b.innerText || b.value || b.getAttribute('aria-label') || '').trim();\\n if (!t || !TEXT.test(t)) continue;\\n if (b.offsetParent === null) continue;\\n if (!isOverlayish(b)) continue;\\n try { b.click(); clicked++; } catch {}\\n }\\n document.querySelectorAll('[id*=\\\"cookie\\\" i],[id*=\\\"consent\\\" i],[class*=\\\"cookie\\\" i],[class*=\\\"consent\\\" i],[id*=\\\"gdpr\\\" i],[class*=\\\"gdpr\\\" i],[id*=\\\"cmp\\\" i],[class*=\\\"cmp\\\" i]')\\n .forEach(el =\\u003e { try {\\n const s = getComputedStyle(el);\\n if (s.position === 'fixed' || s.position === 'sticky') el.remove();\\n } catch {} });\\n document.documentElement.style.overflow = 'auto';\\n if (document.body) document.body.style.overflow = 'auto';\\n} catch {} })();\"]},\"type\":\"CrawlerRunConfig\"},\"urls\":[\"https://exemple.test/tableau\"]}" + } + ], + "images": [], + "go_fetch": [], + "go_search": [] + }, + { + "name": "réponse illisible", + "tool": "web_open", + "args": { + "url": "https://exemple.test/illisible" + }, + "result": "❌ Crawl4AI : réponse vide", + "crawls": [ + { + "path": "POST /crawl", + "auth": "Bearer sesame", + "ctype": "application/json", + "body": "{\"browser_config\":{\"params\":{\"headless\":true},\"type\":\"BrowserConfig\"},\"crawler_config\":{\"params\":{\"js_code\":[\"(() =\\u003e { try {\\n const TEXT = /^(accept all|accept|i accept|agree|i agree|got it|i understand|j'accepte|tout accepter|accepter|d'accord|allow all|allow|consent|continue|ok)$/i;\\n const isOverlayish = (el) =\\u003e {\\n try {\\n let cur = el;\\n for (let i = 0; i \\u003c 6 \\u0026\\u0026 cur; i++) {\\n const s = getComputedStyle(cur);\\n if (s.position === 'fixed' || s.position === 'sticky') return true;\\n const idcls = ((cur.id || '') + ' ' + (cur.className || '')).toLowerCase();\\n if (/cookie|consent|gdpr|cmp|privacy/.test(idcls)) return true;\\n cur = cur.parentElement;\\n }\\n } catch {}\\n return false;\\n };\\n const candidates = document.querySelectorAll('button, [role=\\\"button\\\"], input[type=\\\"button\\\"], input[type=\\\"submit\\\"]');\\n let clicked = 0;\\n for (const b of candidates) {\\n if (clicked \\u003e= 2) break;\\n const t = (b.innerText || b.value || b.getAttribute('aria-label') || '').trim();\\n if (!t || !TEXT.test(t)) continue;\\n if (b.offsetParent === null) continue;\\n if (!isOverlayish(b)) continue;\\n try { b.click(); clicked++; } catch {}\\n }\\n document.querySelectorAll('[id*=\\\"cookie\\\" i],[id*=\\\"consent\\\" i],[class*=\\\"cookie\\\" i],[class*=\\\"consent\\\" i],[id*=\\\"gdpr\\\" i],[class*=\\\"gdpr\\\" i],[id*=\\\"cmp\\\" i],[class*=\\\"cmp\\\" i]')\\n .forEach(el =\\u003e { try {\\n const s = getComputedStyle(el);\\n if (s.position === 'fixed' || s.position === 'sticky') el.remove();\\n } catch {} });\\n document.documentElement.style.overflow = 'auto';\\n if (document.body) document.body.style.overflow = 'auto';\\n} catch {} })();\"]},\"type\":\"CrawlerRunConfig\"},\"urls\":[\"https://exemple.test/illisible\"]}" + } + ], + "images": [], + "go_fetch": [], + "go_search": [] + }, + { + "name": "résultat mal typé", + "tool": "web_open", + "args": { + "url": "https://exemple.test/mal-type" + }, + "result": "❌ Crawl4AI : échec du crawl", + "crawls": [ + { + "path": "POST /crawl", + "auth": "Bearer sesame", + "ctype": "application/json", + "body": "{\"browser_config\":{\"params\":{\"headless\":true},\"type\":\"BrowserConfig\"},\"crawler_config\":{\"params\":{\"js_code\":[\"(() =\\u003e { try {\\n const TEXT = /^(accept all|accept|i accept|agree|i agree|got it|i understand|j'accepte|tout accepter|accepter|d'accord|allow all|allow|consent|continue|ok)$/i;\\n const isOverlayish = (el) =\\u003e {\\n try {\\n let cur = el;\\n for (let i = 0; i \\u003c 6 \\u0026\\u0026 cur; i++) {\\n const s = getComputedStyle(cur);\\n if (s.position === 'fixed' || s.position === 'sticky') return true;\\n const idcls = ((cur.id || '') + ' ' + (cur.className || '')).toLowerCase();\\n if (/cookie|consent|gdpr|cmp|privacy/.test(idcls)) return true;\\n cur = cur.parentElement;\\n }\\n } catch {}\\n return false;\\n };\\n const candidates = document.querySelectorAll('button, [role=\\\"button\\\"], input[type=\\\"button\\\"], input[type=\\\"submit\\\"]');\\n let clicked = 0;\\n for (const b of candidates) {\\n if (clicked \\u003e= 2) break;\\n const t = (b.innerText || b.value || b.getAttribute('aria-label') || '').trim();\\n if (!t || !TEXT.test(t)) continue;\\n if (b.offsetParent === null) continue;\\n if (!isOverlayish(b)) continue;\\n try { b.click(); clicked++; } catch {}\\n }\\n document.querySelectorAll('[id*=\\\"cookie\\\" i],[id*=\\\"consent\\\" i],[class*=\\\"cookie\\\" i],[class*=\\\"consent\\\" i],[id*=\\\"gdpr\\\" i],[class*=\\\"gdpr\\\" i],[id*=\\\"cmp\\\" i],[class*=\\\"cmp\\\" i]')\\n .forEach(el =\\u003e { try {\\n const s = getComputedStyle(el);\\n if (s.position === 'fixed' || s.position === 'sticky') el.remove();\\n } catch {} });\\n document.documentElement.style.overflow = 'auto';\\n if (document.body) document.body.style.overflow = 'auto';\\n} catch {} })();\"]},\"type\":\"CrawlerRunConfig\"},\"urls\":[\"https://exemple.test/mal-type\"]}" + } + ], + "images": [], + "go_fetch": [], + "go_search": [] + }, + { + "name": "page blanche", + "tool": "web_open", + "args": { + "url": "https://exemple.test/blanc" + }, + "result": "❌ page vide", + "crawls": [ + { + "path": "POST /crawl", + "auth": "Bearer sesame", + "ctype": "application/json", + "body": "{\"browser_config\":{\"params\":{\"headless\":true},\"type\":\"BrowserConfig\"},\"crawler_config\":{\"params\":{\"js_code\":[\"(() =\\u003e { try {\\n const TEXT = /^(accept all|accept|i accept|agree|i agree|got it|i understand|j'accepte|tout accepter|accepter|d'accord|allow all|allow|consent|continue|ok)$/i;\\n const isOverlayish = (el) =\\u003e {\\n try {\\n let cur = el;\\n for (let i = 0; i \\u003c 6 \\u0026\\u0026 cur; i++) {\\n const s = getComputedStyle(cur);\\n if (s.position === 'fixed' || s.position === 'sticky') return true;\\n const idcls = ((cur.id || '') + ' ' + (cur.className || '')).toLowerCase();\\n if (/cookie|consent|gdpr|cmp|privacy/.test(idcls)) return true;\\n cur = cur.parentElement;\\n }\\n } catch {}\\n return false;\\n };\\n const candidates = document.querySelectorAll('button, [role=\\\"button\\\"], input[type=\\\"button\\\"], input[type=\\\"submit\\\"]');\\n let clicked = 0;\\n for (const b of candidates) {\\n if (clicked \\u003e= 2) break;\\n const t = (b.innerText || b.value || b.getAttribute('aria-label') || '').trim();\\n if (!t || !TEXT.test(t)) continue;\\n if (b.offsetParent === null) continue;\\n if (!isOverlayish(b)) continue;\\n try { b.click(); clicked++; } catch {}\\n }\\n document.querySelectorAll('[id*=\\\"cookie\\\" i],[id*=\\\"consent\\\" i],[class*=\\\"cookie\\\" i],[class*=\\\"consent\\\" i],[id*=\\\"gdpr\\\" i],[class*=\\\"gdpr\\\" i],[id*=\\\"cmp\\\" i],[class*=\\\"cmp\\\" i]')\\n .forEach(el =\\u003e { try {\\n const s = getComputedStyle(el);\\n if (s.position === 'fixed' || s.position === 'sticky') el.remove();\\n } catch {} });\\n document.documentElement.style.overflow = 'auto';\\n if (document.body) document.body.style.overflow = 'auto';\\n} catch {} })();\"]},\"type\":\"CrawlerRunConfig\"},\"urls\":[\"https://exemple.test/blanc\"]}" + } + ], + "images": [], + "go_fetch": [], + "go_search": [] + }, + { + "name": "markdown objet faux", + "tool": "web_open", + "args": { + "url": "https://exemple.test/objet-faux" + }, + "result": "❌ page vide", + "crawls": [ + { + "path": "POST /crawl", + "auth": "Bearer sesame", + "ctype": "application/json", + "body": "{\"browser_config\":{\"params\":{\"headless\":true},\"type\":\"BrowserConfig\"},\"crawler_config\":{\"params\":{\"js_code\":[\"(() =\\u003e { try {\\n const TEXT = /^(accept all|accept|i accept|agree|i agree|got it|i understand|j'accepte|tout accepter|accepter|d'accord|allow all|allow|consent|continue|ok)$/i;\\n const isOverlayish = (el) =\\u003e {\\n try {\\n let cur = el;\\n for (let i = 0; i \\u003c 6 \\u0026\\u0026 cur; i++) {\\n const s = getComputedStyle(cur);\\n if (s.position === 'fixed' || s.position === 'sticky') return true;\\n const idcls = ((cur.id || '') + ' ' + (cur.className || '')).toLowerCase();\\n if (/cookie|consent|gdpr|cmp|privacy/.test(idcls)) return true;\\n cur = cur.parentElement;\\n }\\n } catch {}\\n return false;\\n };\\n const candidates = document.querySelectorAll('button, [role=\\\"button\\\"], input[type=\\\"button\\\"], input[type=\\\"submit\\\"]');\\n let clicked = 0;\\n for (const b of candidates) {\\n if (clicked \\u003e= 2) break;\\n const t = (b.innerText || b.value || b.getAttribute('aria-label') || '').trim();\\n if (!t || !TEXT.test(t)) continue;\\n if (b.offsetParent === null) continue;\\n if (!isOverlayish(b)) continue;\\n try { b.click(); clicked++; } catch {}\\n }\\n document.querySelectorAll('[id*=\\\"cookie\\\" i],[id*=\\\"consent\\\" i],[class*=\\\"cookie\\\" i],[class*=\\\"consent\\\" i],[id*=\\\"gdpr\\\" i],[class*=\\\"gdpr\\\" i],[id*=\\\"cmp\\\" i],[class*=\\\"cmp\\\" i]')\\n .forEach(el =\\u003e { try {\\n const s = getComputedStyle(el);\\n if (s.position === 'fixed' || s.position === 'sticky') el.remove();\\n } catch {} });\\n document.documentElement.style.overflow = 'auto';\\n if (document.body) document.body.style.overflow = 'auto';\\n} catch {} })();\"]},\"type\":\"CrawlerRunConfig\"},\"urls\":[\"https://exemple.test/objet-faux\"]}" + } + ], + "images": [], + "go_fetch": [], + "go_search": [] + }, + { + "name": "page longue", + "tool": "web_open", + "args": { + "url": "https://exemple.test/longue" + }, + "result": "# Ouvert : https://exemple.test/longue\nTotal : 60 lignes, 13.9 KB (14210 caractères)\nEn cache 10 min. Utilise web_read ou web_grep pour lire.\n\n## Plan (n° de ligne des titres)\n```\n(aucun titre markdown trouvé)\n```", + "crawls": [ + { + "path": "POST /crawl", + "auth": "Bearer sesame", + "ctype": "application/json", + "body": "{\"browser_config\":{\"params\":{\"headless\":true},\"type\":\"BrowserConfig\"},\"crawler_config\":{\"params\":{\"js_code\":[\"(() =\\u003e { try {\\n const TEXT = /^(accept all|accept|i accept|agree|i agree|got it|i understand|j'accepte|tout accepter|accepter|d'accord|allow all|allow|consent|continue|ok)$/i;\\n const isOverlayish = (el) =\\u003e {\\n try {\\n let cur = el;\\n for (let i = 0; i \\u003c 6 \\u0026\\u0026 cur; i++) {\\n const s = getComputedStyle(cur);\\n if (s.position === 'fixed' || s.position === 'sticky') return true;\\n const idcls = ((cur.id || '') + ' ' + (cur.className || '')).toLowerCase();\\n if (/cookie|consent|gdpr|cmp|privacy/.test(idcls)) return true;\\n cur = cur.parentElement;\\n }\\n } catch {}\\n return false;\\n };\\n const candidates = document.querySelectorAll('button, [role=\\\"button\\\"], input[type=\\\"button\\\"], input[type=\\\"submit\\\"]');\\n let clicked = 0;\\n for (const b of candidates) {\\n if (clicked \\u003e= 2) break;\\n const t = (b.innerText || b.value || b.getAttribute('aria-label') || '').trim();\\n if (!t || !TEXT.test(t)) continue;\\n if (b.offsetParent === null) continue;\\n if (!isOverlayish(b)) continue;\\n try { b.click(); clicked++; } catch {}\\n }\\n document.querySelectorAll('[id*=\\\"cookie\\\" i],[id*=\\\"consent\\\" i],[class*=\\\"cookie\\\" i],[class*=\\\"consent\\\" i],[id*=\\\"gdpr\\\" i],[class*=\\\"gdpr\\\" i],[id*=\\\"cmp\\\" i],[class*=\\\"cmp\\\" i]')\\n .forEach(el =\\u003e { try {\\n const s = getComputedStyle(el);\\n if (s.position === 'fixed' || s.position === 'sticky') el.remove();\\n } catch {} });\\n document.documentElement.style.overflow = 'auto';\\n if (document.body) document.body.style.overflow = 'auto';\\n} catch {} })();\"]},\"type\":\"CrawlerRunConfig\"},\"urls\":[\"https://exemple.test/longue\"]}" + } + ], + "images": [], + "go_fetch": [], + "go_search": [] + }, + { + "name": "lecture tronquée", + "tool": "web_read", + "args": { + "limit": 500, + "url": "https://exemple.test/longue" + }, + "result": "# https://exemple.test/longue\nLignes 1–60 sur 60 (fin de page)\n\n```\n 1 | 0 une ligne assez longue pour remplir la fenêtre de lecture, encore et encore. une ligne assez longue pour remplir la fenêtre de lecture, encore et encore. une ligne assez longue pour remplir la fenêtre de lecture, encore et encore.\n 2 | 1 une ligne assez longue pour remplir la fenêtre de lecture, encore et encore. une ligne assez longue pour remplir la fenêtre de lecture, encore et encore. une ligne assez longue pour remplir la fenêtre de lecture, encore et encore.\n 3 | 2 une ligne assez longue pour remplir la fenêtre de lecture, encore et encore. une ligne assez longue pour remplir la fenêtre de lecture, encore et encore. une ligne assez longue pour remplir la fenêtre de lecture, encore et encore.\n 4 | 3 une ligne assez longue pour remplir la fenêtre de lecture, encore et encore. une ligne assez longue pour remplir la fenêtre de lecture, encore et encore. une ligne assez longue pour remplir la fenêtre de lecture, encore et encore.\n 5 | 4 une ligne assez longue pour remplir la fenêtre de lecture, encore et encore. une ligne assez longue pour remplir la fenêtre de lecture, encore et encore. une ligne assez longue pour remplir la fenêtre de lecture, encore et encore.\n 6 | 5 une ligne assez longue pour remplir la fenêtre de lecture, encore et encore. une ligne assez longue pour remplir la fenêtre de lecture, encore et encore. une ligne assez longue pour remplir la fenêtre de lecture, encore et encore.\n 7 | 6 une ligne assez longue pour remplir la fenêtre de lecture, encore et encore. une ligne assez longue pour remplir la fenêtre de lecture, encore et encore. une ligne assez longue pour remplir la fenêtre de lecture, encore et encore.\n 8 | 7 une ligne assez longue pour remplir la fenêtre de lecture, encore et encore. une ligne assez longue pour remplir la fenêtre de lecture, encore et encore. une ligne assez longue pour remplir la fenêtre de lecture, encore et encore.\n 9 | 8 une ligne assez longue pour remplir la fenêtre de lecture, encore et encore. une ligne assez longue pour remplir la fenêtre de lecture, encore et encore. une ligne assez longue pour remplir la fenêtre de lecture, encore et encore.\n 10 | 9 une ligne assez longue pour remplir la fenêtre de lecture, encore et encore. une ligne assez longue pour remplir la fenêtre de lecture, encore et encore. une ligne assez longue pour remplir la fenêtre de lecture, encore et encore.\n 11 | 10 une ligne assez longue pour remplir la fenêtre de lecture, encore et encore. une ligne assez longue pour remplir la fenêtre de lecture, encore et encore. une ligne assez longue pour remplir la fenêtre de lecture, encore et encore.\n 12 | 11 une ligne assez longue pour remplir la fenêtre de lecture, encore et encore. une ligne assez longue pour remplir la fenêtre de lecture, encore et encore. une ligne assez longue pour remplir la fenêtre de lecture, encore et encore.\n 13 | 12 une ligne assez longue pour remplir la fenêtre de lecture, encore et encore. une ligne assez longue pour remplir la fenêtre de lecture, encore et encore. une ligne assez longue pour remplir la fenêtre de lecture, encore et encore.\n 14 | 13 une ligne assez longue pour remplir la fenêtre de lecture, encore et encore. une ligne assez longue pour remplir la fenêtre de lecture, encore et encore. une ligne assez longue pour remplir la fenêtre de lecture, encore et encore.\n 15 | 14 une ligne assez longue pour remplir la fenêtre de lecture, encore et encore. une ligne assez longue pour remplir la fenêtre de lecture, encore et encore. une ligne assez longue pour remplir la fenêtre de lecture, encore et encore.\n 16 | 15 une ligne assez longue pour remplir la fenêtre de lecture, encore et encore. une ligne assez longue pour remplir la fenêtre de lecture, encore et encore. une ligne assez longue pour remplir la fenêtre de lecture, encore et encore.\n 17 | 16 une ligne assez longue pour remplir la fenêtre de lecture, encore et encore. une ligne assez longue pour remplir la fenêtre de lecture, encore et encore. une ligne assez longue pour remplir la fenêtre de lecture, encore et encore.\n 18 | 17 une ligne assez longue pour remplir la fenêtre de lecture, encore et encore. une ligne assez longue pour remplir la fenêtre de lecture, encore et encore. une ligne assez longue pour remplir la fenêtre de lecture, encore et encore.\n 19 | 18 une ligne assez longue pour remplir la fenêtre de lecture, encore et encore. une ligne assez longue pour remplir la fenêtre de lecture, encore et encore. une ligne assez longue pour remplir la fenêtre de lecture, encore et encore.\n 20 | 19 une ligne assez longue pour remplir la fenêtre de lecture, encore et encore. une ligne assez longue pour remplir la fenêtre de lecture, encore et encore. une ligne assez longue pour remplir la fenêtre de lecture, encore et encore.\n 21 | 20 une ligne assez longue pour remplir la fenêtre de lecture, encore et encore. une ligne assez longue pour remplir la fenêtre de lecture, encore et encore. une ligne assez longue pour remplir la fenêtre de lecture, encore et encore.\n 22 | 21 une ligne assez longue pour remplir la fenêtre de lecture, encore et encore. une ligne assez longue pour remplir la fenêtre de lecture, encore et encore. une ligne assez longue pour remplir la fenêtre de lecture, encore et encore.\n 23 | 22 une ligne assez longue pour remplir la fenêtre de lecture, encore et encore. une ligne assez longue pour remplir la fenêtre de lecture, encore et encore. une ligne assez longue pour remplir la fenêtre de lecture, encore et encore.\n 24 | 23 une ligne assez longue pour remplir la fenêtre de lecture, encore et encore. une ligne assez longue pour remplir la fenêtre de lecture, encore et encore. une ligne assez longue pour remplir la fenêtre de lecture, encore et encore.\n 25 | 24 une ligne assez longue pour remplir la fenêtre de lecture, encore et encore. une ligne assez longue pour remplir la fenêtre de lecture, encore et encore. une ligne assez longue pour remplir la fenêtre de lecture, encore et encore.\n 26 | 25 une ligne assez longue pour remplir la fenêtre de lecture, encore et encore. une ligne assez longue pour remplir la fenêtre de lecture, encore et encore. une ligne assez longue pour remplir la fenêtre de lecture, encore et encore.\n 27 | 26 une ligne assez longue pour remplir la fenêtre de lecture, encore et encore. une ligne assez longue pour remplir la fenêtre de lecture, encore et encore. une ligne assez longue pour remplir la fenêtre de lecture, encore et encore.\n 28 | 27 une ligne assez longue pour remplir la fenêtre de lecture, encore et encore. une ligne assez longue pour remplir la fenêtre de lecture, encore et encore. une ligne assez longue pour remplir la fenêtre de lecture, encore et encore.\n 29 | 28 une ligne assez longue pour remplir la fenêtre de lecture, encore et encore. une ligne assez longue pour remplir la fenêtre de lecture, encore et encore. une ligne assez longue pour remplir la fenêtre de lecture, encore et encore.\n 30 | 29 une ligne assez longue pour remplir la fenêtre de lecture, encore et encore. une ligne assez longue pour remplir la fenêtre de lecture, encore et encore. une ligne assez longue pour remplir la fenêtre de lecture, encore et encore.\n 31 | 30 une ligne assez longue pour remplir la fenêtre de lecture, encore et encore. une ligne assez longue pour remplir la fenêtre de lecture, encore et encore. une ligne assez longue pour remplir la fenêtre de lecture, encore et encore.\n 32 | 31 une ligne assez longue pour remplir la fenêtre de lecture, encore et encore. une ligne assez longue pour remplir la fenêtre de lecture, encore et encore. une ligne assez longue pour remplir la fenêtre de lecture, encore et encore.\n 33 | 32 une ligne assez longue pour remplir la fenêtre de lecture, encore et encore. une ligne assez longue pour remplir la fenêtre de lecture, encore et encore. une ligne assez longue pour rempl\n…[tronqué : réponse trop longue. Relis par tranches avec web_read(offset, limit) ou cible avec web_grep.]", + "crawls": [], + "images": [], + "go_fetch": [], + "go_search": [] + }, + { + "name": "URL inconnue du faux", + "tool": "web_open", + "args": { + "url": "https://exemple.test/inconnue" + }, + "result": "❌ Crawl4AI HTTP 404: inconnu : https://exemple.test/inconnue", + "crawls": [ + { + "path": "POST /crawl", + "auth": "Bearer sesame", + "ctype": "application/json", + "body": "{\"browser_config\":{\"params\":{\"headless\":true},\"type\":\"BrowserConfig\"},\"crawler_config\":{\"params\":{\"js_code\":[\"(() =\\u003e { try {\\n const TEXT = /^(accept all|accept|i accept|agree|i agree|got it|i understand|j'accepte|tout accepter|accepter|d'accord|allow all|allow|consent|continue|ok)$/i;\\n const isOverlayish = (el) =\\u003e {\\n try {\\n let cur = el;\\n for (let i = 0; i \\u003c 6 \\u0026\\u0026 cur; i++) {\\n const s = getComputedStyle(cur);\\n if (s.position === 'fixed' || s.position === 'sticky') return true;\\n const idcls = ((cur.id || '') + ' ' + (cur.className || '')).toLowerCase();\\n if (/cookie|consent|gdpr|cmp|privacy/.test(idcls)) return true;\\n cur = cur.parentElement;\\n }\\n } catch {}\\n return false;\\n };\\n const candidates = document.querySelectorAll('button, [role=\\\"button\\\"], input[type=\\\"button\\\"], input[type=\\\"submit\\\"]');\\n let clicked = 0;\\n for (const b of candidates) {\\n if (clicked \\u003e= 2) break;\\n const t = (b.innerText || b.value || b.getAttribute('aria-label') || '').trim();\\n if (!t || !TEXT.test(t)) continue;\\n if (b.offsetParent === null) continue;\\n if (!isOverlayish(b)) continue;\\n try { b.click(); clicked++; } catch {}\\n }\\n document.querySelectorAll('[id*=\\\"cookie\\\" i],[id*=\\\"consent\\\" i],[class*=\\\"cookie\\\" i],[class*=\\\"consent\\\" i],[id*=\\\"gdpr\\\" i],[class*=\\\"gdpr\\\" i],[id*=\\\"cmp\\\" i],[class*=\\\"cmp\\\" i]')\\n .forEach(el =\\u003e { try {\\n const s = getComputedStyle(el);\\n if (s.position === 'fixed' || s.position === 'sticky') el.remove();\\n } catch {} });\\n document.documentElement.style.overflow = 'auto';\\n if (document.body) document.body.style.overflow = 'auto';\\n} catch {} })();\"]},\"type\":\"CrawlerRunConfig\"},\"urls\":[\"https://exemple.test/inconnue\"]}" + } + ], + "images": [], + "go_fetch": [], + "go_search": [] + }, + { + "name": "sans clé", + "setup": { + "crawl_key": "-" + }, + "tool": "web_open", + "args": { + "refresh": true, + "url": "https://exemple.test/seul" + }, + "result": "# Ouvert : https://exemple.test/seul\nTotal : 3 lignes, 26 B (26 caractères)\nEn cache 10 min. Utilise web_read ou web_grep pour lire.\n\n## Plan (n° de ligne des titres)\n```\n 1 | Seul\n```", + "crawls": [ + { + "path": "POST /crawl", + "ctype": "application/json", + "body": "{\"browser_config\":{\"params\":{\"headless\":true},\"type\":\"BrowserConfig\"},\"crawler_config\":{\"params\":{\"js_code\":[\"(() =\\u003e { try {\\n const TEXT = /^(accept all|accept|i accept|agree|i agree|got it|i understand|j'accepte|tout accepter|accepter|d'accord|allow all|allow|consent|continue|ok)$/i;\\n const isOverlayish = (el) =\\u003e {\\n try {\\n let cur = el;\\n for (let i = 0; i \\u003c 6 \\u0026\\u0026 cur; i++) {\\n const s = getComputedStyle(cur);\\n if (s.position === 'fixed' || s.position === 'sticky') return true;\\n const idcls = ((cur.id || '') + ' ' + (cur.className || '')).toLowerCase();\\n if (/cookie|consent|gdpr|cmp|privacy/.test(idcls)) return true;\\n cur = cur.parentElement;\\n }\\n } catch {}\\n return false;\\n };\\n const candidates = document.querySelectorAll('button, [role=\\\"button\\\"], input[type=\\\"button\\\"], input[type=\\\"submit\\\"]');\\n let clicked = 0;\\n for (const b of candidates) {\\n if (clicked \\u003e= 2) break;\\n const t = (b.innerText || b.value || b.getAttribute('aria-label') || '').trim();\\n if (!t || !TEXT.test(t)) continue;\\n if (b.offsetParent === null) continue;\\n if (!isOverlayish(b)) continue;\\n try { b.click(); clicked++; } catch {}\\n }\\n document.querySelectorAll('[id*=\\\"cookie\\\" i],[id*=\\\"consent\\\" i],[class*=\\\"cookie\\\" i],[class*=\\\"consent\\\" i],[id*=\\\"gdpr\\\" i],[class*=\\\"gdpr\\\" i],[id*=\\\"cmp\\\" i],[class*=\\\"cmp\\\" i]')\\n .forEach(el =\\u003e { try {\\n const s = getComputedStyle(el);\\n if (s.position === 'fixed' || s.position === 'sticky') el.remove();\\n } catch {} });\\n document.documentElement.style.overflow = 'auto';\\n if (document.body) document.body.style.overflow = 'auto';\\n} catch {} })();\"]},\"type\":\"CrawlerRunConfig\"},\"urls\":[\"https://exemple.test/seul\"]}" + } + ], + "images": [], + "go_fetch": [], + "go_search": [] + }, + { + "name": "Crawl4AI injoignable", + "setup": { + "CRAWL4AI_URL": "{{DEAD}}/" + }, + "tool": "web_open", + "args": { + "url": "https://exemple.test/x" + }, + "result": "❌ Crawl4AI injoignable ({{DEAD}}): Post \"{{DEAD}}/crawl\": dial tcp {{DEADADDR}}: connect: connection refused", + "crawls": [], + "images": [], + "go_fetch": [], + "go_search": [] + }, + { + "name": "sans serveur Crawl4AI", + "setup": { + "CRAWL4AI_URL": "-" + }, + "tool": "web_open", + "args": { + "url": "https://exemple.test/x" + }, + "result": "❌ aucun serveur Crawl4AI configuré (CRAWL4AI_URL)", + "crawls": [], + "images": [], + "go_fetch": [], + "go_search": [] + }, + { + "name": "lecture d'une page vue par Crawl4AI", + "tool": "web_read", + "args": { + "limit": 1, + "url": "https://exemple.test/seul" + }, + "result": "# https://exemple.test/seul\nLignes 1–1 sur 3 (2 de plus en dessous)\n\n```\n 1 | # Seul\n```", + "crawls": [], + "images": [], + "go_fetch": [], + "go_search": [] + }, + { + "name": "intégré : ouverture", + "setup": { + "WEB_ENGINE": "go" + }, + "tool": "web_open", + "args": { + "url": "{{PAGES}}/article.html" + }, + "result": "# Ouvert : {{PAGES}}/article.html\nTotal : 13 lignes, 692 B (692 caractères)\nEn cache 10 min. Utilise web_read ou web_grep pour lire.\n\n## Plan (n° de ligne des titres)\n```\n 1 | Guide de Loki\n 5 | Installation\n 9 | Configuration\n```", + "crawls": [], + "images": [], + "go_fetch": [ + "{{PAGES}}/article.html" + ], + "go_search": [] + }, + { + "name": "intégré : grep", + "tool": "web_grep", + "args": { + "context": 0, + "pattern": "erreur", + "url": "{{PAGES}}/article.html" + }, + "result": "# {{PAGES}}/article.html\n2 match(es) pour /erreur/i\n\n```\n 11 | Les réglages se font dans l'interface : modèle, contexte, outils. Erreur fréquente : un port déjà pris par un autre service. Changez alors le port dans les réglages avancés.\n```\n\n---\n\n```\n 13 | Erreur moins fréquente : un disque plein empêche le téléchargement du modèle.\n```", + "crawls": [], + "images": [], + "go_fetch": [], + "go_search": [] + }, + { + "name": "intégré : texte brut", + "tool": "web_open", + "args": { + "actions": [ + "x()" + ], + "url": "{{PAGES}}/texte.txt" + }, + "result": "# Ouvert : {{PAGES}}/texte.txt\nTotal : 2 lignes, 28 B (28 caractères)\nEn cache 10 min. Utilise web_read ou web_grep pour lire.\n\n## Plan (n° de ligne des titres)\n```\n(aucun titre markdown trouvé)\n```", + "crawls": [], + "images": [], + "go_fetch": [ + "{{PAGES}}/texte.txt" + ], + "go_search": [] + }, + { + "name": "intégré : lecture", + "tool": "web_read", + "args": { + "url": "{{PAGES}}/texte.txt" + }, + "result": "# {{PAGES}}/texte.txt\nLignes 1–2 sur 2 (fin de page)\n\n```\n 1 | Ligne brute 1\n 2 | Ligne brute 2\n```", + "crawls": [], + "images": [], + "go_fetch": [], + "go_search": [] + }, + { + "name": "intégré : page absente", + "tool": "web_open", + "args": { + "url": "{{PAGES}}/absente" + }, + "result": "❌ HTTP 404 sur {{PAGES}}/absente", + "crawls": [], + "images": [], + "go_fetch": [ + "{{PAGES}}/absente" + ], + "go_search": [] + }, + { + "name": "intégré : recherche", + "tool": "web_search", + "args": { + "query": "loki" + }, + "result": "# Recherche : loki\n2 résultat(s) DuckDuckGo\n\n1. Loki local\n https://loki.example/\n Un assistant local et privé.\n\n2. Deux\n https://deux.example/\n ", + "crawls": [], + "images": [], + "go_fetch": [], + "go_search": [ + "loki" + ] + }, + { + "name": "intégré : recherche limitée", + "tool": "web_search", + "args": { + "limit": 1, + "query": "loki" + }, + "result": "# Recherche : loki\n1 résultat(s) DuckDuckGo\n\n1. Loki local\n https://loki.example/\n Un assistant local et privé.", + "crawls": [], + "images": [], + "go_fetch": [], + "go_search": [ + "loki" + ] + }, + { + "name": "intégré : anti-bot", + "tool": "web_search", + "args": { + "query": "bot" + }, + "result": "❌ Recherche échouée : DuckDuckGo a renvoyé un défi anti-bot", + "crawls": [], + "images": [], + "go_fetch": [], + "go_search": [ + "bot" + ] + }, + { + "name": "images", + "tool": "web_images", + "args": { + "query": "chats" + }, + "result": "# Images : chats\n3 résultat(s) DuckDuckGo\n\n1. Chat roux (800x600)\n image : https://img.example/1.jpg\n page : https://page.example/1\n\n2. Chat & chien <3 (1024x768)\n image : http://img.example/4.png\n page : https://page.example/4\n\n3. Cinquième (10x20)\n image : https://img.example/5.jpg\n page : https://page.example/5\n\nTo show an image to the user, write ![title](image URL) in your answer.", + "crawls": [], + "images": [ + { + "path": "/?q=chats&iax=images&ia=images", + "lang": "fr-FR,fr;q=0.9,en;q=0.8", + "ua": "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36" + }, + { + "path": "/i.js?l=fr-fr&o=json&f=,,,,,&p=1&q=chats&vqd=4-123-456", + "referer": "https://duckduckgo.com/", + "accept": "application/json, text/javascript, */*; q=0.01", + "lang": "fr-FR,fr;q=0.9,en;q=0.8", + "ua": "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36", + "cookie": "dcm=7" + } + ], + "go_fetch": [], + "go_search": [] + }, + { + "name": "images, cookie gardé", + "tool": "web_images", + "args": { + "limit": 1, + "query": "chats" + }, + "result": "# Images : chats\n1 résultat(s) DuckDuckGo\n\n1. Chat roux (800x600)\n image : https://img.example/1.jpg\n page : https://page.example/1\n\nTo show an image to the user, write ![title](image URL) in your answer.", + "crawls": [], + "images": [ + { + "path": "/?q=chats&iax=images&ia=images", + "lang": "fr-FR,fr;q=0.9,en;q=0.8", + "ua": "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36", + "cookie": "dcm=7" + }, + { + "path": "/i.js?l=fr-fr&o=json&f=,,,,,&p=1&q=chats&vqd=4-123-456", + "referer": "https://duckduckgo.com/", + "accept": "application/json, text/javascript, */*; q=0.01", + "lang": "fr-FR,fr;q=0.9,en;q=0.8", + "ua": "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36", + "cookie": "dcm=7" + } + ], + "go_fetch": [], + "go_search": [] + }, + { + "name": "images : sans jeton", + "tool": "web_images", + "args": { + "query": "sans jeton" + }, + "result": "❌ Recherche d'images échouée : jeton DuckDuckGo introuvable (défi anti-bot ?)", + "crawls": [], + "images": [ + { + "path": "/?q=sans+jeton&iax=images&ia=images", + "lang": "fr-FR,fr;q=0.9,en;q=0.8", + "ua": "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36", + "cookie": "dcm=7" + } + ], + "go_fetch": [], + "go_search": [] + }, + { + "name": "images : api en panne", + "tool": "web_images", + "args": { + "query": "api en panne" + }, + "result": "❌ Recherche d'images échouée : HTTP 500", + "crawls": [], + "images": [ + { + "path": "/?q=api+en+panne&iax=images&ia=images", + "lang": "fr-FR,fr;q=0.9,en;q=0.8", + "ua": "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36", + "cookie": "dcm=7" + }, + { + "path": "/i.js?l=fr-fr&o=json&f=,,,,,&p=1&q=api+en+panne&vqd=1", + "referer": "https://duckduckgo.com/", + "accept": "application/json, text/javascript, */*; q=0.01", + "lang": "fr-FR,fr;q=0.9,en;q=0.8", + "ua": "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36", + "cookie": "dcm=7" + } + ], + "go_fetch": [], + "go_search": [] + }, + { + "name": "images : page en panne", + "tool": "web_images", + "args": { + "query": "page en panne" + }, + "result": "❌ Recherche d'images échouée : HTTP 403", + "crawls": [], + "images": [ + { + "path": "/?q=page+en+panne&iax=images&ia=images", + "lang": "fr-FR,fr;q=0.9,en;q=0.8", + "ua": "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36", + "cookie": "dcm=7" + } + ], + "go_fetch": [], + "go_search": [] + }, + { + "name": "images : illisible", + "tool": "web_images", + "args": { + "query": "illisible" + }, + "result": "❌ Recherche d'images échouée : réponse illisible : invalid character 'p' looking for beginning of value", + "crawls": [], + "images": [ + { + "path": "/?q=illisible&iax=images&ia=images", + "lang": "fr-FR,fr;q=0.9,en;q=0.8", + "ua": "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36", + "cookie": "dcm=7" + }, + { + "path": "/i.js?l=fr-fr&o=json&f=,,,,,&p=1&q=illisible&vqd=2", + "referer": "https://duckduckgo.com/", + "accept": "application/json, text/javascript, */*; q=0.01", + "lang": "fr-FR,fr;q=0.9,en;q=0.8", + "ua": "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36", + "cookie": "dcm=7" + } + ], + "go_fetch": [], + "go_search": [] + }, + { + "name": "images : apres", + "tool": "web_images", + "args": { + "query": "apres" + }, + "result": "❌ Recherche d'images échouée : réponse illisible : invalid character 'x' after top-level value", + "crawls": [], + "images": [ + { + "path": "/?q=apres&iax=images&ia=images", + "lang": "fr-FR,fr;q=0.9,en;q=0.8", + "ua": "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36", + "cookie": "dcm=7" + }, + { + "path": "/i.js?l=fr-fr&o=json&f=,,,,,&p=1&q=apres&vqd=3", + "referer": "https://duckduckgo.com/", + "accept": "application/json, text/javascript, */*; q=0.01", + "lang": "fr-FR,fr;q=0.9,en;q=0.8", + "ua": "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36", + "cookie": "dcm=7" + } + ], + "go_fetch": [], + "go_search": [] + }, + { + "name": "images : largeur texte", + "tool": "web_images", + "args": { + "query": "largeur texte" + }, + "result": "❌ Recherche d'images échouée : réponse illisible : json: cannot unmarshal string into Go struct field imageResult.results.width of type int", + "crawls": [], + "images": [ + { + "path": "/?q=largeur+texte&iax=images&ia=images", + "lang": "fr-FR,fr;q=0.9,en;q=0.8", + "ua": "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36", + "cookie": "dcm=7" + }, + { + "path": "/i.js?l=fr-fr&o=json&f=,,,,,&p=1&q=largeur+texte&vqd=4", + "referer": "https://duckduckgo.com/", + "accept": "application/json, text/javascript, */*; q=0.01", + "lang": "fr-FR,fr;q=0.9,en;q=0.8", + "ua": "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36", + "cookie": "dcm=7" + } + ], + "go_fetch": [], + "go_search": [] + }, + { + "name": "images : vide", + "tool": "web_images", + "args": { + "query": "vide" + }, + "result": "Aucune image pour « vide »", + "crawls": [], + "images": [ + { + "path": "/?q=vide&iax=images&ia=images", + "lang": "fr-FR,fr;q=0.9,en;q=0.8", + "ua": "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36", + "cookie": "dcm=7" + }, + { + "path": "/i.js?l=fr-fr&o=json&f=,,,,,&p=1&q=vide&vqd=5", + "referer": "https://duckduckgo.com/", + "accept": "application/json, text/javascript, */*; q=0.01", + "lang": "fr-FR,fr;q=0.9,en;q=0.8", + "ua": "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36", + "cookie": "dcm=7" + } + ], + "go_fetch": [], + "go_search": [] + }, + { + "name": "images : null", + "tool": "web_images", + "args": { + "query": "null" + }, + "result": "Aucune image pour « null »", + "crawls": [], + "images": [ + { + "path": "/?q=null&iax=images&ia=images", + "lang": "fr-FR,fr;q=0.9,en;q=0.8", + "ua": "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36", + "cookie": "dcm=7" + }, + { + "path": "/i.js?l=fr-fr&o=json&f=,,,,,&p=1&q=null&vqd=6", + "referer": "https://duckduckgo.com/", + "accept": "application/json, text/javascript, */*; q=0.01", + "lang": "fr-FR,fr;q=0.9,en;q=0.8", + "ua": "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36", + "cookie": "dcm=7" + } + ], + "go_fetch": [], + "go_search": [] + }, + { + "name": "images : resultats faux", + "tool": "web_images", + "args": { + "query": "resultats faux" + }, + "result": "❌ Recherche d'images échouée : réponse illisible : json: cannot unmarshal string into Go struct field .results of type []loki.imageResult", + "crawls": [], + "images": [ + { + "path": "/?q=resultats+faux&iax=images&ia=images", + "lang": "fr-FR,fr;q=0.9,en;q=0.8", + "ua": "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36", + "cookie": "dcm=7" + }, + { + "path": "/i.js?l=fr-fr&o=json&f=,,,,,&p=1&q=resultats+faux&vqd=7", + "referer": "https://duckduckgo.com/", + "accept": "application/json, text/javascript, */*; q=0.01", + "lang": "fr-FR,fr;q=0.9,en;q=0.8", + "ua": "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36", + "cookie": "dcm=7" + } + ], + "go_fetch": [], + "go_search": [] + }, + { + "name": "images : apostrophe", + "tool": "web_images", + "args": { + "query": "apostrophe" + }, + "result": "# Images : apostrophe\n1 résultat(s) DuckDuckGo\n\n1. A (1x2)\n image : https://i/a\n page : https://p/a\n\nTo show an image to the user, write ![title](image URL) in your answer.", + "crawls": [], + "images": [ + { + "path": "/?q=apostrophe&iax=images&ia=images", + "lang": "fr-FR,fr;q=0.9,en;q=0.8", + "ua": "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36", + "cookie": "dcm=7" + }, + { + "path": "/i.js?l=fr-fr&o=json&f=,,,,,&p=1&q=apostrophe&vqd=77-88", + "referer": "https://duckduckgo.com/", + "accept": "application/json, text/javascript, */*; q=0.01", + "lang": "fr-FR,fr;q=0.9,en;q=0.8", + "ua": "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36", + "cookie": "dcm=7" + } + ], + "go_fetch": [], + "go_search": [] + } + ] +} diff --git a/rust/loki-rs/tests/webtools.rs b/rust/loki-rs/tests/webtools.rs new file mode 100644 index 0000000..dea18f6 --- /dev/null +++ b/rust/loki-rs/tests/webtools.rs @@ -0,0 +1,291 @@ +//! Lot 42 : outils web de l'agent (`web_search`, `web_images`, `web_open`, +//! `web_read`, `web_grep`) exécutés par la façade. +//! +//! Le journal tiré du Go (`internal/loki/parity_web_tools_test.go` → +//! `tests/parity_web/outils.json`) est rejoué contre le port : mêmes +//! réponses du faux Crawl4AI et du faux DuckDuckGo, ce que le moteur intégré +//! du Go a tiré des pages servi par le faux Go ; chaque appel doit rendre le +//! même résultat et faire les mêmes requêtes (corps envoyés à Crawl4AI, +//! en-têtes et cookies envoyés à DuckDuckGo, pages et recherches demandées +//! au Go). + +mod common; + +use std::collections::HashMap; +use std::convert::Infallible; +use std::sync::{Arc, Mutex}; +use std::time::Duration; + +use bytes::Bytes; +use common::fakego; +use http_body_util::{BodyExt, Full}; +use hyper::service::service_fn; +use hyper::{Request, Response}; +use hyper_util::rt::TokioIo; +use loki_rs::store::Store; +use loki_rs::webtools::Web; +use serde_json::{Map, Value, json}; +use tokio::net::TcpListener; + +fn journal() -> Value { + serde_json::from_str(include_str!("parity_web/outils.json")).expect("journal") +} + +fn s(v: &Value) -> &str { + v.as_str().unwrap_or("") +} + +type Log = Arc>>; + +/// Un petit serveur HTTP : `f(méthode, chemin?requête, en-têtes, corps)` +/// rend (statut, en-têtes, corps). +async fn serve(f: F) -> String +where + F: Fn(String, String, hyper::HeaderMap, Vec) -> (u16, Vec<(String, String)>, String) + + Send + + Sync + + 'static, +{ + let l = TcpListener::bind("127.0.0.1:0").await.unwrap(); + let addr = l.local_addr().unwrap(); + let f = Arc::new(f); + tokio::spawn(async move { + while let Ok((st, _)) = l.accept().await { + let f = f.clone(); + tokio::spawn(async move { + let svc = service_fn(move |req: Request| { + let f = f.clone(); + async move { + let method = req.method().to_string(); + let uri = req + .uri() + .path_and_query() + .map(|p| p.to_string()) + .unwrap_or_default(); + let headers = req.headers().clone(); + let body = req.into_body().collect().await.unwrap().to_bytes().to_vec(); + let (code, hs, out) = f(method, uri, headers, body); + let mut r = Response::new(Full::new(Bytes::from(out))); + *r.status_mut() = hyper::StatusCode::from_u16(code).unwrap(); + for (k, v) in hs { + r.headers_mut().insert( + hyper::header::HeaderName::from_bytes(k.as_bytes()).unwrap(), + v.parse().unwrap(), + ); + } + Ok::<_, Infallible>(r) + } + }); + let _ = hyper::server::conn::http1::Builder::new() + .serve_connection(TokioIo::new(st), svc) + .await; + }); + } + }); + format!("http://{addr}") +} + +fn header(h: &hyper::HeaderMap, k: &str) -> String { + h.get(k) + .and_then(|v| v.to_str().ok()) + .unwrap_or("") + .to_string() +} + +/// Une requête reçue, champs vides omis (comme `omitempty`). +fn req(fields: &[(&str, String)]) -> Value { + let mut m = Map::new(); + for (k, v) in fields { + if !v.is_empty() { + m.insert((*k).into(), json!(v)); + } + } + Value::Object(m) +} + +/// Le faux Crawl4AI : la réponse rangée pour l'URL demandée (dans l'ordre, +/// la dernière reste). +async fn fake_crawl(fx: Value, log: Log) -> String { + let served: Arc>> = Default::default(); + serve(move |method, uri, h, body| { + let path = uri.split('?').next().unwrap_or("").to_string(); + log.lock().unwrap().push(req(&[ + ("path", format!("{method} {path}")), + ("auth", header(&h, "authorization")), + ("ctype", header(&h, "content-type")), + ("body", String::from_utf8_lossy(&body).into_owned()), + ])); + let v: Value = serde_json::from_slice(&body).unwrap_or_default(); + let target = s(&v["urls"][0]).to_string(); + let Some(list) = fx[&target].as_array().filter(|l| !l.is_empty()) else { + return (404, vec![], format!("inconnu : {target}")); + }; + let mut served = served.lock().unwrap(); + let n = served.entry(target).or_default(); + let r = &list[(*n).min(list.len() - 1)]; + *n += 1; + ( + r["status"].as_u64().unwrap() as u16, + vec![], + s(&r["body"]).to_string(), + ) + }) + .await +} + +/// Le faux DuckDuckGo des images. +async fn fake_ddg(fx: Value, log: Log) -> String { + serve(move |_, uri, h, _| { + log.lock().unwrap().push(req(&[ + ("path", uri.clone()), + ("referer", header(&h, "referer")), + ("accept", header(&h, "accept")), + ("lang", header(&h, "accept-language")), + ("ua", header(&h, "user-agent")), + ("cookie", header(&h, "cookie")), + ])); + let (path, query) = uri.split_once('?').unwrap_or((&uri, "")); + let q = url::form_urlencoded::parse(query.as_bytes()) + .find(|(k, _)| k == "q") + .map(|(_, v)| v.into_owned()) + .unwrap_or_default(); + let f = &fx[&q]; + if path == "/i.js" { + return ( + f["api_status"].as_u64().unwrap() as u16, + vec![], + s(&f["api"]).to_string(), + ); + } + let mut hs = vec![]; + if !s(&f["cookie"]).is_empty() { + hs.push(("set-cookie".to_string(), s(&f["cookie"]).to_string())); + } + ( + f["page_status"].as_u64().unwrap() as u16, + hs, + s(&f["page"]).to_string(), + ) + }) + .await +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn outils_web_comme_le_go() { + let fx = journal(); + let tmp = tempfile::tempdir().unwrap(); + let (_, go, _stop) = fakego::start(tmp.path().to_path_buf()).await; + let backend = go.backend.lock().unwrap().unwrap(); + let store = Store::new(backend, fakego::JETON.into()); + + let (crawl_log, img_log): (Log, Log) = Default::default(); + let crawl = fake_crawl(fx["crawl"].clone(), crawl_log.clone()).await; + let ddg = fake_ddg(fx["images"].clone(), img_log.clone()).await; + let dead = { + let l = std::net::TcpListener::bind("127.0.0.1:0").unwrap(); + l.local_addr().unwrap().to_string() + }; + let pages = "http://pages.invalid".to_string(); + let unmask = |v: &str| { + v.replace("{{CRAWL}}", &crawl) + .replace("{{DDG}}", &ddg) + .replace("{{PAGES}}", &pages) + .replace("{{DEADADDR}}", &dead) + .replace("{{DEAD}}", &format!("http://{dead}")) + }; + let mask = |v: &str| { + v.replace(&crawl, "{{CRAWL}}") + .replace(&ddg, "{{DDG}}") + .replace(&pages, "{{PAGES}}") + .replace(&format!("http://{dead}"), "{{DEAD}}") + .replace(&dead, "{{DEADADDR}}") + }; + for (k, v) in fx["go_fetch"].as_object().unwrap() { + let v: Value = serde_json::from_str(&unmask(&v.to_string())).unwrap(); + go.web_fetch.lock().unwrap().insert(unmask(k), v); + } + for (k, v) in fx["go_search"].as_object().unwrap() { + go.web_search.lock().unwrap().insert(k.clone(), v.clone()); + } + + let web = Web::with_ddg_base(&ddg); + for c in fx["calls"].as_array().unwrap() { + let name = s(&c["name"]); + for (k, v) in c["setup"].as_object().into_iter().flatten() { + let (bucket, v) = if k == "crawl_key" { + ("state", s(v).to_string()) + } else { + ("config", unmask(s(v))) + }; + if v == "-" { + go.del(bucket, k); + } else { + go.set(bucket, k, &v); + } + } + if let Some(n) = c["advance_s"].as_u64() { + web.advance(Duration::from_secs(n)); + } + let args: Map = + serde_json::from_str(&unmask(&c["args"].to_string())).unwrap(); + let got = web + .exec(&store, s(&c["tool"]), &args) + .await + .expect("un outil web"); + assert_eq!(mask(&got), s(&c["result"]), "{name}"); + + let masked = |log: &Log| -> Value { + let v = Value::Array(std::mem::take(&mut *log.lock().unwrap())); + serde_json::from_str(&mask(&v.to_string())).unwrap() + }; + assert_eq!(masked(&crawl_log), c["crawls"], "{name} : Crawl4AI"); + // reqwest pose « Accept: */* » quand la requête n'en a pas ; le + // client du Go n'en pose aucun (sans effet sur DuckDuckGo). + let mut imgs = masked(&img_log); + for r in imgs.as_array_mut().into_iter().flatten() { + if r["accept"] == "*/*" { + r.as_object_mut().unwrap().remove("accept"); + } + } + assert_eq!(imgs, c["images"], "{name} : DuckDuckGo"); + let calls = std::mem::take(&mut *go.web_log.lock().unwrap()); + let fetched: Vec = calls + .iter() + .filter_map(|l| l.strip_prefix("fetch ")) + .map(&mask) + .collect(); + let searched: Vec = calls + .iter() + .filter_map(|l| l.strip_prefix("search ")) + .map(|q| q.rsplit_once('|').map_or(q, |(q, _)| q).to_string()) + .collect(); + assert_eq!(json!(fetched), c["go_fetch"], "{name} : pages du Go"); + assert_eq!(json!(searched), c["go_search"], "{name} : recherches du Go"); + } +} + +/// Le Go injoignable : le moteur intégré (par défaut sans configuration) +/// le dit ; un autre outil n'est pas servi ici. +#[tokio::test] +async fn moteur_integre_injoignable() { + let dead = std::net::TcpListener::bind("127.0.0.1:0") + .unwrap() + .local_addr() + .unwrap(); + let store = Store::new(dead, fakego::JETON.into()); + let web = Web::new(); + let mut args = Map::new(); + args.insert("url".into(), json!("https://exemple.test/")); + let got = web.exec(&store, "web_open", &args).await.unwrap(); + assert!( + got.starts_with("❌ moteur web intégré injoignable : "), + "{got}" + ); + args.insert("query".into(), json!("x")); + let got = web.exec(&store, "web_search", &args).await.unwrap(); + assert!( + got.starts_with("❌ Recherche échouée : moteur web intégré injoignable : "), + "{got}" + ); + assert_eq!(web.exec(&store, "web_screenshot", &args).await, None); +} From 7288db632ed9405ab7813a340404ab3ce7261cb8 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 16 Oct 2026 05:42:32 +0000 Subject: [PATCH 46/52] =?UTF-8?q?Lot=2043=20:=20jobs=20d'arri=C3=A8re-plan?= =?UTF-8?q?=20du=20mode=20Code=20tenus=20par=20la=20fa=C3=A7ade?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit bash_bg et bash_tail des tours de la façade ne passent plus par /tool/exec du Go : src/tools/jobs.rs porte code_jobs.go. Refus avant lancement (commande vide ou dangereuse, optimisation en cours), journal .loki/jobs/.log dans le dossier de la discussion, processus qui survit au tour, shell sans le jeton interne ; relecture bornée (40 lignes par défaut, 200 au plus), état et durée, arrêt relu aussitôt comme le Go. paths::go_mkdir_all reproduit os.MkdirAll, erreur comprise. Les jobs d'une discussion supprimée s'arrêtent avec elle. L'optimiseur du Go compte ceux de la façade (POST /internal/facade/v1/bgjobs) avant de mesurer ; façade injoignable : les siens seulement. Parité : journal de 24 étapes tiré du Go (parity_jobs_test.go → tests/parity_jobs/jobs.json), rejoué par tests/jobs.rs ; un tour de la façade qui lance un job (tests/lot6.rs) ; TestFacadeJobsArrierePlan. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_016tcGfJWefW5kUGoC2wL8cv --- ...igration-lot43-jobs-arriere-plan-design.md | 82 +++++ internal/loki/code_jobs.go | 21 +- internal/loki/facade_bgjobs_test.go | 35 ++ internal/loki/parity_jobs_test.go | 165 +++++++++ rust/loki-rs/src/chat/generate.rs | 1 + rust/loki-rs/src/chat/host.rs | 31 ++ rust/loki-rs/src/chat/sessions.rs | 2 + rust/loki-rs/src/chatapi.rs | 7 + rust/loki-rs/src/paths.rs | 49 +++ rust/loki-rs/src/tools/jobs.rs | 245 ++++++++++++++ rust/loki-rs/src/tools/jobs/tests.rs | 76 +++++ rust/loki-rs/src/tools/mod.rs | 1 + rust/loki-rs/src/tools/shell.rs | 2 +- rust/loki-rs/tests/jobs.rs | 54 +++ rust/loki-rs/tests/lot6.rs | 52 +++ rust/loki-rs/tests/parity_jobs/jobs.json | 313 ++++++++++++++++++ 16 files changed, 1132 insertions(+), 4 deletions(-) create mode 100644 docs/superpowers/specs/2026-10-16-rust-migration-lot43-jobs-arriere-plan-design.md create mode 100644 internal/loki/facade_bgjobs_test.go create mode 100644 internal/loki/parity_jobs_test.go create mode 100644 rust/loki-rs/src/tools/jobs.rs create mode 100644 rust/loki-rs/src/tools/jobs/tests.rs create mode 100644 rust/loki-rs/tests/jobs.rs create mode 100644 rust/loki-rs/tests/parity_jobs/jobs.json diff --git a/docs/superpowers/specs/2026-10-16-rust-migration-lot43-jobs-arriere-plan-design.md b/docs/superpowers/specs/2026-10-16-rust-migration-lot43-jobs-arriere-plan-design.md new file mode 100644 index 0000000..e2a5173 --- /dev/null +++ b/docs/superpowers/specs/2026-10-16-rust-migration-lot43-jobs-arriere-plan-design.md @@ -0,0 +1,82 @@ +# Lot 43 — Jobs d'arrière-plan du mode Code dans la façade + +## Contexte + +En mode Code, le modèle lance un serveur de dev, un watcher ou un build long +avec `bash_bg`. Il en suit la sortie avec `bash_tail`, qui sait aussi +l'arrêter. Les deux outils partaient au Go (`/internal/v1/tool/exec`), qui +gardait les processus. C'étaient les derniers outils shell du tour encore au +Go, `bash` étant porté depuis le lot 7. + +## Le lot + +`src/tools/jobs.rs` porte `code_jobs.go`. + +**`bash_bg`** : + +- refus avant tout lancement : + - commande vide ; + - commande dangereuse (même politique que `bash`) ; + - optimisation en cours (`/internal/v1/tune/guard`) ; +- journal `.loki/jobs/.log` dans le dossier de la discussion (le + contexte des outils du Go) ; +- numéros `job1`, `job2`… ; +- shell de la plateforme, sans le jeton interne dans l'environnement ; +- le processus survit au tour ; +- code de sortie du Go : -1 pour un processus tué. + +**`bash_tail`** : + +- 40 lignes par défaut, 200 au plus ; un nombre est tronqué comme + `int(float64)` ; +- état « en cours » ou « terminé (exit N) » et durée arrondie à la + seconde ; +- sortie vide signalée ; +- fin gardée en caractères ; +- octets invalides en U+FFFD un à un ; +- `kill:true` arrête le job et relit aussitôt, comme le Go : l'état peut + encore dire « en cours ». + +Les erreurs d'écriture ont le texte du Go. `paths::go_mkdir_all` reproduit +`os.MkdirAll` : l'erreur nomme l'élément du chemin qui a échoué. + +`ChatShared.jobs` (les jobs du processus) sert les tours : +`GoTurnHost::exec_tool` exécute les deux outils. Sans contexte d'outils, +`bash_bg` passe au Go comme avant. + +Les jobs d'une discussion supprimée s'arrêtent avec elle +(`GoSessionHooks::dropped`, `stopConvJobs`). + +L'optimiseur refuse de mesurer tant qu'un job tourne. Côté Go, +`bgJobsRunning` ajoute ceux de la façade quand elle tient la conversation +(`POST /internal/facade/v1/bgjobs` → `{running}`) ; façade injoignable : +les siens seulement. + +## Parité + +`internal/loki/parity_jobs_test.go` (Linux) tire du Go un journal de 24 +étapes (`tests/parity_jobs/jobs.json`). Chaque étape porte le résultat (pid +et durées masqués) et les journaux présents. Il couvre : + +- commande vide, refusée ; +- sortie des deux flux, lignes demandées (nulles, en texte, tronquées) ; +- arrêt d'un job fini ; +- job inconnu, sans id ; +- sortie en erreur sans rien d'écrit ; +- job long, en cours, arrêté (état relu masqué), puis fini à -1 ; +- quarante lignes par défaut, deux cents au plus ; +- sortie démesurée en caractères multi-octets ; +- octets invalides. + +`tests/jobs.rs` le rejoue contre le port. + +D'autres tests complètent le journal : + +- `tests/lot6.rs` (`job_arriere_plan_par_la_facade`) : un tour de la façade + lance un job dans le dossier de la discussion sans passer par le Go ; + supprimer une autre discussion n'y touche pas, la sienne l'arrête ; +- `anneau_de_la_facade_pour_le_go` : le compte des jobs par l'API interne ; +- `TestFacadeJobsArrierePlan` : le Go les ajoute aux siens, façade en panne + comprise ; +- tests unitaires : refus de l'optimiseur, discussion supprimée, dossier + impossible. diff --git a/internal/loki/code_jobs.go b/internal/loki/code_jobs.go index 294748d..0df04c3 100644 --- a/internal/loki/code_jobs.go +++ b/internal/loki/code_jobs.go @@ -177,10 +177,25 @@ func toolBashTail(args map[string]any) string { return out + "\n" + tailRunes(tail, toolMaxOutput) } -// bgJobsRunning : jobs d'arrière-plan encore en vie. L'optimiseur refuse de -// mesurer à côté d'eux : un build ou un serveur de dev prend le CPU et le -// disque que mesurent ses essais. +// bgJobsRunning : jobs d'arrière-plan encore en vie, ceux de la façade +// compris quand elle tient la conversation (lot 43 : ses tours lancent les +// leurs). L'optimiseur refuse de mesurer à côté d'eux : un build ou un +// serveur de dev prend le CPU et le disque que mesurent ses essais. func bgJobsRunning() int { + n := localBgJobsRunning() + if facadeChat() { + var out struct { + Running int `json:"running"` + } + if err := facadePerfJSON("/internal/facade/v1/bgjobs", map[string]any{}, &out); err == nil { + n += out.Running + } + } + return n +} + +// localBgJobsRunning : les jobs lancés par ce processus. +func localBgJobsRunning() int { jobsMu.Lock() defer jobsMu.Unlock() n := 0 diff --git a/internal/loki/facade_bgjobs_test.go b/internal/loki/facade_bgjobs_test.go new file mode 100644 index 0000000..f3ec680 --- /dev/null +++ b/internal/loki/facade_bgjobs_test.go @@ -0,0 +1,35 @@ +package loki + +import ( + "strings" + "testing" +) + +// Lot 43 : les jobs d'arrière-plan des tours de la façade comptent pour +// l'optimiseur du Go ; façade en panne, seuls les siens. +func TestFacadeJobsArrierePlan(t *testing.T) { + testHome(t) + jobsMu.Lock() + saved := jobs + jobs = map[string]*bgJob{"job1": {ID: "job1"}} + jobsMu.Unlock() + t.Cleanup(func() { + jobsMu.Lock() + jobs = saved + jobsMu.Unlock() + }) + f := demarrerFacade(t) + f.internalStatus = 200 + f.internalBody = `{"running":2}` + if n := bgJobsRunning(); n != 3 { + t.Fatalf("jobs : %d, attendu 3 (1 ici, 2 à la façade)", n) + } + vu := f.recu() + if len(vu) != 1 || !strings.HasPrefix(vu[0], "POST /internal/facade/v1/bgjobs jeton ") { + t.Fatalf("appel : %v", vu) + } + f.internalStatus = 500 + if n := bgJobsRunning(); n != 1 { + t.Fatalf("façade en panne : %d", n) + } +} diff --git a/internal/loki/parity_jobs_test.go b/internal/loki/parity_jobs_test.go new file mode 100644 index 0000000..11505d0 --- /dev/null +++ b/internal/loki/parity_jobs_test.go @@ -0,0 +1,165 @@ +//go:build linux + +package loki + +import ( + "bytes" + "encoding/json" + "os" + "path/filepath" + "regexp" + "testing" + "time" +) + +// parity_jobs_test.go — journal de référence des jobs d'arrière-plan du mode +// Code (lot 43 de la migration Rust) : bash_bg et bash_tail, de la commande +// refusée au job tué. Pid et durées sont masqués ; l'état relu juste après +// un arrêt aussi (le Go relit sans attendre la fin). Rangé dans +// rust/loki-rs/tests/parity_jobs/jobs.json ; tests/jobs.rs le rejoue contre +// le port. +// +// LOKI_PARITY_UPDATE=1 go test ./internal/loki -run TestPariteJobs + +const parityJobsFile = "../../rust/loki-rs/tests/parity_jobs/jobs.json" + +type parityJobStep struct { + Name string `json:"name"` + Tool string `json:"tool"` + Args map[string]any `json:"args"` + Wait string `json:"wait,omitempty"` // job dont attendre la fin avant l'appel + After string `json:"after,omitempty"` + Result string `json:"result"` + Logs []string `json:"logs"` // journaux présents dans .loki/jobs +} + +var ( + parityJobsPid = regexp.MustCompile(`\(pid [0-9]+\)`) + parityJobsSince = regexp.MustCompile(`depuis [0-9hms.]+`) + parityJobsState = regexp.MustCompile(`— (en cours|terminé \(exit -1\)) —`) +) + +func TestPariteJobs(t *testing.T) { + root := testHome(t) + if r, err := filepath.EvalSymlinks(root); err == nil { + root = r + } + ws := filepath.Join(root, "ws") + if err := os.MkdirAll(ws, 0o755); err != nil { + t.Fatal(err) + } + setTaskWorkspace(ws) + t.Cleanup(func() { setTaskWorkspace("") }) + jobsMu.Lock() + jobs, jobSeq = map[string]*bgJob{}, 0 + jobsMu.Unlock() + t.Cleanup(func() { + for _, j := range jobs { + if j.Cmd.Process != nil { + _ = j.Cmd.Process.Kill() + } + } + }) + + wait := func(id string) { + deadline := time.Now().Add(10 * time.Second) + for time.Now().Before(deadline) { + jobsMu.Lock() + j := jobs[id] + jobsMu.Unlock() + if j != nil { + j.mu.Lock() + done := j.done + j.mu.Unlock() + if done { + return + } + } + time.Sleep(10 * time.Millisecond) + } + t.Fatalf("job %s : pas fini", id) + } + var steps []parityJobStep + run := func(st parityJobStep) { + if st.Wait != "" { + wait(st.Wait) + } + args := map[string]any{} + b, _ := json.Marshal(st.Args) + _ = json.Unmarshal(b, &args) + res := execTool(t.Context(), st.Tool, args, toolCallLabel(st.Tool, args), Caps{Agent: true, Code: true}) + r := parityJobsPid.ReplaceAllString(res.Result, "(pid {{PID}})") + r = parityJobsSince.ReplaceAllString(r, "depuis {{D}}") + if st.After == "kill" { + r = parityJobsState.ReplaceAllString(r, "— {{ETAT}} —") + } + st.Result = r + st.Logs = []string{} + entries, _ := os.ReadDir(filepath.Join(ws, ".loki", "jobs")) + for _, e := range entries { + st.Logs = append(st.Logs, e.Name()) + } + if st.Args == nil { + st.Args = map[string]any{} + } + steps = append(steps, st) + } + a := func(kv ...any) map[string]any { + m := map[string]any{} + for i := 0; i+1 < len(kv); i += 2 { + m[kv[i].(string)] = kv[i+1] + } + return m + } + + run(parityJobStep{Name: "commande vide", Tool: "bash_bg", Args: a("command", " ")}) + run(parityJobStep{Name: "commande refusée", Tool: "bash_bg", Args: a("command", "rm -rf /")}) + run(parityJobStep{Name: "lancement", Tool: "bash_bg", Args: a("command", "echo un; echo deux >&2; printf 'trois\\n\\n'")}) + run(parityJobStep{Name: "sortie", Wait: "job1", Tool: "bash_tail", Args: a("id", "job1")}) + run(parityJobStep{Name: "deux lignes", Tool: "bash_tail", Args: a("id", "job1", "lines", 2.0)}) + run(parityJobStep{Name: "lignes nulles", Tool: "bash_tail", Args: a("id", "job1", "lines", 0.0)}) + run(parityJobStep{Name: "lignes en texte", Tool: "bash_tail", Args: a("id", "job1", "lines", "1")}) + run(parityJobStep{Name: "lignes tronquées", Tool: "bash_tail", Args: a("id", "job1", "lines", 1.9)}) + run(parityJobStep{Name: "arrêt d'un job fini", Tool: "bash_tail", Args: a("id", "job1", "kill", true)}) + run(parityJobStep{Name: "job inconnu", Tool: "bash_tail", Args: a("id", "job9")}) + run(parityJobStep{Name: "sans id", Tool: "bash_tail", Args: a()}) + run(parityJobStep{Name: "sortie en erreur", Tool: "bash_bg", Args: a("command", "exit 3")}) + run(parityJobStep{Name: "rien d'écrit", Wait: "job2", Tool: "bash_tail", Args: a("id", "job2")}) + run(parityJobStep{Name: "job long", Tool: "bash_bg", Args: a("command", "sleep 30")}) + run(parityJobStep{Name: "en cours", Tool: "bash_tail", Args: a("id", "job3")}) + run(parityJobStep{Name: "arrêt", Tool: "bash_tail", Args: a("id", "job3", "kill", true), After: "kill"}) + run(parityJobStep{Name: "après l'arrêt", Wait: "job3", Tool: "bash_tail", Args: a("id", "job3")}) + run(parityJobStep{Name: "beaucoup de lignes", Tool: "bash_bg", Args: a("command", "for i in $(seq 1 300); do echo ligne $i; done")}) + run(parityJobStep{Name: "quarante par défaut", Wait: "job4", Tool: "bash_tail", Args: a("id", "job4")}) + run(parityJobStep{Name: "deux cents au plus", Tool: "bash_tail", Args: a("id", "job4", "lines", 500.0)}) + run(parityJobStep{Name: "sortie démesurée", Tool: "bash_bg", Args: a("command", "for i in $(seq 1 9000); do printf 'é'; done; echo")}) + run(parityJobStep{Name: "fin gardée", Wait: "job5", Tool: "bash_tail", Args: a("id", "job5")}) + run(parityJobStep{Name: "octets invalides", Tool: "bash_bg", Args: a("command", "printf 'a\\377b\\n'")}) + run(parityJobStep{Name: "octets relus", Wait: "job6", Tool: "bash_tail", Args: a("id", "job6")}) + + var buf bytes.Buffer + enc := json.NewEncoder(&buf) + enc.SetEscapeHTML(false) + enc.SetIndent("", " ") + if err := enc.Encode(map[string]any{"steps": steps}); err != nil { + t.Fatal(err) + } + got := buf.Bytes() + if os.Getenv("LOKI_PARITY_UPDATE") == "1" { + if err := os.MkdirAll(filepath.Dir(parityJobsFile), 0o755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(parityJobsFile, got, 0o644); err != nil { + t.Fatal(err) + } + return + } + want, err := os.ReadFile(parityJobsFile) + if err != nil { + t.Fatalf("journal de référence absent (%v) : LOKI_PARITY_UPDATE=1 pour le créer", err) + } + if !bytes.Equal(got, want) { + t.Errorf("les jobs d'arrière-plan ne produisent plus le journal rangé dans %s.\n"+ + "LOKI_PARITY_UPDATE=1 pour le régénérer si le changement est voulu.", parityJobsFile) + } +} diff --git a/rust/loki-rs/src/chat/generate.rs b/rust/loki-rs/src/chat/generate.rs index 0d4ef4c..f76a266 100644 --- a/rust/loki-rs/src/chat/generate.rs +++ b/rust/loki-rs/src/chat/generate.rs @@ -1323,6 +1323,7 @@ impl ChatShared { tasks: None, memory: None, web: Default::default(), + jobs: crate::tools::jobs::global(), go_version: Default::default(), } } diff --git a/rust/loki-rs/src/chat/host.rs b/rust/loki-rs/src/chat/host.rs index ebb4d7d..22e6669 100644 --- a/rust/loki-rs/src/chat/host.rs +++ b/rust/loki-rs/src/chat/host.rs @@ -349,6 +349,8 @@ pub struct ChatShared { pub memory: Option>, /// Les outils web (lot 42) : cache des pages, Crawl4AI, DuckDuckGo. pub web: Arc, + /// Les jobs d'arrière-plan du mode Code (lot 43). + pub jobs: Arc, /// La version du Go, annoncée aux serveurs MCP à une reconnexion ; lue /// une fois. pub go_version: tokio::sync::OnceCell, @@ -707,6 +709,35 @@ impl TurnHost for GoTurnHost { ..Default::default() }; } + // Jobs d'arrière-plan du mode Code (lot 43), dans le dossier de la + // discussion ; contexte indisponible : le Go s'en charge. + if name == "bash_tail" { + return ToolOutcome { + result: self.shared.jobs.bash_tail(args).await, + ..Default::default() + }; + } + if name == "bash_bg" { + let st = crate::tools::GoToolState { + store: self.shared.store.clone(), + caps: self.caps.clone(), + }; + match crate::tools::ToolState::context(&st).await { + Ok(ctx) => { + let busy = crate::backends::tune_busy(&self.shared.store).await; + let result = self + .shared + .jobs + .bash_bg(&ctx.cwd, &self.compact.main_conv, args, busy) + .await; + return ToolOutcome { + result, + ..Default::default() + }; + } + Err(e) => eprintln!("[outils] contexte indisponible, bash_bg par le Go : {e}"), + } + } // Outils task_*, quand la façade tient les tâches (lot 37). if crate::tasks::tools::is_tool(name) && let Some(tasks) = &self.shared.tasks diff --git a/rust/loki-rs/src/chat/sessions.rs b/rust/loki-rs/src/chat/sessions.rs index d45832b..a83b824 100644 --- a/rust/loki-rs/src/chat/sessions.rs +++ b/rust/loki-rs/src/chat/sessions.rs @@ -65,6 +65,8 @@ pub struct GoSessionHooks(pub Store); impl SessionHooks for GoSessionHooks { async fn dropped(&self, id: &str) { + // Ses jobs d'arrière-plan s'arrêtent avec elle (`stopConvJobs`). + crate::tools::jobs::global().stop_conv(id); let body = serde_json::json!({ "id": id }); if let Err(e) = self .0 diff --git a/rust/loki-rs/src/chatapi.rs b/rust/loki-rs/src/chatapi.rs index 3bd7833..719b9a9 100644 --- a/rust/loki-rs/src/chatapi.rs +++ b/rust/loki-rs/src/chatapi.rs @@ -71,6 +71,7 @@ pub fn is_internal(path: &str) -> bool { | "/internal/facade/v1/chat/prewarm/cancel" | "/internal/facade/v1/perf/record" | "/internal/facade/v1/perf/snapshot" + | "/internal/facade/v1/bgjobs" ) } @@ -136,6 +137,12 @@ pub async fn serve_internal(chat: &Arc, req: Request) -> }, // L'anneau, de la plus ancienne à la plus récente (poids de // l'optimiseur). + // Jobs d'arrière-plan encore en vie (lot 43) : l'optimiseur du Go + // refuse de mesurer à côté. + "/internal/facade/v1/bgjobs" => send_json( + StatusCode::OK, + &json!({ "running": chat.shared.jobs.running() }), + ), "/internal/facade/v1/perf/snapshot" => send_json( StatusCode::OK, &json!({ "recs": chat.shared.perf.snapshot() }), diff --git a/rust/loki-rs/src/paths.rs b/rust/loki-rs/src/paths.rs index 3fb894f..49435c4 100644 --- a/rust/loki-rs/src/paths.rs +++ b/rust/loki-rs/src/paths.rs @@ -176,6 +176,55 @@ fn mkdir_all(dir: &str) -> io::Result<()> { b.create(dir) } +/// `os.MkdirAll(p, 0o755)` du Go, erreur comprise : elle nomme l'élément du +/// chemin qui a échoué (un parent qui est un fichier, par exemple). +pub fn go_mkdir_all(p: &Path) -> Result<(), String> { + let err = |e: &io::Error| os_err("mkdir", &p.to_string_lossy(), e); + match std::fs::metadata(p) { + Ok(m) if m.is_dir() => return Ok(()), + Ok(_) => return Err(err(&io::Error::from_raw_os_error(libc_enotdir()))), + Err(_) => {} + } + let s = p.to_string_lossy(); + let b = s.as_bytes(); + let mut i = b.len(); + while i > 0 && b[i - 1] == b'/' { + i -= 1; + } + let mut j = i; + while j > 0 && b[j - 1] != b'/' { + j -= 1; + } + if j > 1 { + go_mkdir_all(Path::new(&s[..j - 1]))?; + } + let mut d = std::fs::DirBuilder::new(); + #[cfg(unix)] + { + use std::os::unix::fs::DirBuilderExt; + d.mode(0o755); + } + if let Err(e) = d.create(p) { + if std::fs::symlink_metadata(p).is_ok_and(|m| m.is_dir()) { + return Ok(()); + } + return Err(err(&e)); + } + Ok(()) +} + +/// ENOTDIR de la plateforme. +fn libc_enotdir() -> i32 { + #[cfg(unix)] + { + libc::ENOTDIR + } + #[cfg(not(unix))] + { + 267 // ERROR_DIRECTORY + } +} + /// Équivalent de `os.CreateTemp(dir, ".probe-*")` suivi de sa suppression. fn probe_writable(dir: &str) -> bool { for _ in 0..16 { diff --git a/rust/loki-rs/src/tools/jobs.rs b/rust/loki-rs/src/tools/jobs.rs new file mode 100644 index 0000000..81f569f --- /dev/null +++ b/rust/loki-rs/src/tools/jobs.rs @@ -0,0 +1,245 @@ +//! Commandes shell d'arrière-plan du mode Code (lot 43) — port de +//! `code_jobs.go` : `bash_bg` lance un processus détaché (serveur de dev, +//! watcher, build long) et rend la main ; `bash_tail` relit sa sortie et son +//! état, et l'arrête sur demande. +//! +//! La sortie va dans `.loki/jobs/.log` du dossier de la discussion : elle +//! survit au tour, et le panneau Fichiers la montre. Les jobs d'une +//! discussion supprimée sont arrêtés ; l'optimiseur refuse de mesurer tant +//! qu'il en tourne (le Go demande leur nombre à la façade). + +use std::collections::HashMap; +use std::path::{Path, PathBuf}; +use std::sync::{Arc, Mutex, OnceLock}; +use std::time::{Duration, Instant}; + +use serde_json::{Map, Value}; + +use super::policy; +use super::shell::{TOOL_MAX_OUTPUT, go_duration, shell_cmd}; +use crate::system::go_lossy; + +/// Un job lancé par `bash_bg`. +struct Job { + id: String, + command: String, + log_path: PathBuf, + /// Discussion du tour qui l'a lancé. + conv: String, + started: Instant, + /// `None` tant qu'il tourne, puis son code de sortie. + exit: Mutex>, + kill: tokio::sync::Notify, +} + +impl Job { + fn running(&self) -> bool { + self.exit + .lock() + .unwrap_or_else(|e| e.into_inner()) + .is_none() + } +} + +/// Les jobs d'arrière-plan de la façade. +#[derive(Default)] +pub struct BgJobs { + state: Mutex<(u64, HashMap>)>, +} + +/// Les jobs de ce processus (les tours et la suppression des discussions). +pub fn global() -> Arc { + static G: OnceLock> = OnceLock::new(); + G.get_or_init(Default::default).clone() +} + +/// `args[k].(string)`. +fn arg_str<'a>(args: &'a Map, k: &str) -> &'a str { + args.get(k).and_then(Value::as_str).unwrap_or("") +} + +impl BgJobs { + /// `toolBashBg` : `tune_busy`, le refus de l'optimiseur en cours s'il y + /// en a un. + pub async fn bash_bg( + &self, + cwd: &Path, + conv: &str, + args: &Map, + tune_busy: Option, + ) -> String { + let command = arg_str(args, "command"); + if command.trim().is_empty() { + return "[erreur] commande vide".into(); + } + if let Some(reason) = policy::dangerous_command(command) { + return policy::refused_command_result(reason); + } + // Un serveur ou un build lancé pendant une optimisation fausserait + // ses mesures. + if let Some(e) = tune_busy { + return format!("[erreur] {e}"); + } + let log_dir = cwd.join(".loki").join("jobs"); + if let Err(e) = crate::paths::go_mkdir_all(&log_dir) { + return format!("[erreur] {e}"); + } + let id = { + let mut st = self.state.lock().unwrap_or_else(|e| e.into_inner()); + st.0 += 1; + format!("job{}", st.0) + }; + let log_path = log_dir.join(format!("{id}.log")); + let log = match std::fs::File::create(&log_path) { + Ok(f) => f, + Err(e) => { + return format!( + "[erreur] {}", + crate::paths::os_err("open", &log_path.to_string_lossy(), &e) + ); + } + }; + let err_log = match log.try_clone() { + Ok(f) => f, + Err(e) => return format!("[erreur] {e}"), + }; + // Pas le contexte du tour : le job lui survit, c'est sa raison d'être. + let mut cmd = shell_cmd(command); + cmd.current_dir(cwd) + .stdin(std::process::Stdio::null()) + .stdout(log) + .stderr(err_log) + .kill_on_drop(false); + let mut child = match cmd.spawn() { + Ok(c) => c, + Err(e) => { + return format!( + "[erreur] {}", + crate::paths::os_err("fork/exec", "/bin/bash", &e) + ); + } + }; + let pid = child.id().unwrap_or(0); + let job = Arc::new(Job { + id: id.clone(), + command: command.to_string(), + log_path, + conv: conv.to_string(), + started: Instant::now(), + exit: Mutex::new(None), + kill: tokio::sync::Notify::new(), + }); + self.state + .lock() + .unwrap_or_else(|e| e.into_inner()) + .1 + .insert(id.clone(), job.clone()); + tokio::spawn(async move { + let status = tokio::select! { + s = child.wait() => s, + _ = job.kill.notified() => { + let _ = child.start_kill(); + child.wait().await + } + }; + // `ExitCode()` du Go : -1 pour un processus tué par un signal ; + // 0 si l'attente elle-même a échoué. + let code = match status { + Ok(s) => s.code().unwrap_or(-1), + Err(_) => 0, + }; + *job.exit.lock().unwrap_or_else(|e| e.into_inner()) = Some(code); + }); + format!( + "[ok] job {id} lancé (pid {pid}) — sortie dans .loki/jobs/{id}.log, suis-la avec bash_tail" + ) + } + + /// `toolBashTail` : la fin du journal d'un job et son état ; `kill` + /// l'arrête d'abord. + pub async fn bash_tail(&self, args: &Map) -> String { + let id = arg_str(args, "id"); + let job = self + .state + .lock() + .unwrap_or_else(|e| e.into_inner()) + .1 + .get(id) + .cloned(); + let Some(job) = job else { + return format!("[erreur] job inconnu : {id}"); + }; + // Tué puis relu aussitôt, comme le Go : l'état peut encore dire « en + // cours » le temps que l'attente constate la fin. + if args.get("kill").and_then(Value::as_bool) == Some(true) && job.running() { + job.kill.notify_one(); + } + let mut lines = 40usize; + if let Some(v) = args.get("lines").and_then(Value::as_f64) { + let n = crate::webtools::go_int(v); + if n > 0 { + lines = n.min(200) as usize; + } + } + let b = match std::fs::read(&job.log_path) { + Ok(b) => b, + Err(e) => { + return format!( + "[erreur] {}", + crate::paths::os_err("open", &job.log_path.to_string_lossy(), &e) + ); + } + }; + let text = go_lossy(&b); + let all: Vec<&str> = text.trim_end_matches('\n').split('\n').collect(); + let all = &all[all.len().saturating_sub(lines)..]; + let status = match *job.exit.lock().unwrap_or_else(|e| e.into_inner()) { + None => "en cours".to_string(), + Some(c) => format!("terminé (exit {c})"), + }; + let since = Duration::from_secs((job.started.elapsed().as_millis() as u64 + 500) / 1000); + let out = format!( + "job {} — {status} — depuis {}\ncommande : {}\n", + job.id, + go_duration(since), + job.command + ); + let mut tail = all.join("\n"); + if tail.trim().is_empty() { + tail = "(aucune sortie pour l'instant)".into(); + } + format!( + "{out}\n{}", + crate::webtools::tail_runes(&tail, TOOL_MAX_OUTPUT) + ) + } + + /// `bgJobsRunning` : les jobs encore en vie. + pub fn running(&self) -> usize { + self.state + .lock() + .unwrap_or_else(|e| e.into_inner()) + .1 + .values() + .filter(|j| j.running()) + .count() + } + + /// `stopConvJobs` : les jobs d'une discussion supprimée, arrêtés et + /// oubliés. + pub fn stop_conv(&self, conv: &str) { + let mut st = self.state.lock().unwrap_or_else(|e| e.into_inner()); + st.1.retain(|_, j| { + if j.conv != conv { + return true; + } + if j.running() { + j.kill.notify_one(); + } + false + }); + } +} + +#[cfg(test)] +mod tests; diff --git a/rust/loki-rs/src/tools/jobs/tests.rs b/rust/loki-rs/src/tools/jobs/tests.rs new file mode 100644 index 0000000..e070314 --- /dev/null +++ b/rust/loki-rs/src/tools/jobs/tests.rs @@ -0,0 +1,76 @@ +use super::*; + +fn args(kv: &[(&str, Value)]) -> Map { + kv.iter().map(|(k, v)| (k.to_string(), v.clone())).collect() +} + +/// Optimisation en cours : refus du Go recopié ; discussion supprimée : ses +/// jobs arrêtés et oubliés, ceux des autres gardés. +#[cfg(unix)] +#[tokio::test] +async fn refus_et_discussion_supprimee() { + let tmp = tempfile::tempdir().unwrap(); + let jobs = BgJobs::default(); + let a = args(&[("command", Value::from("sleep 30"))]); + assert_eq!( + jobs.bash_bg(tmp.path(), "c", &a, Some("optimisation en cours".into())) + .await, + "[erreur] optimisation en cours" + ); + assert!(!tmp.path().join(".loki").exists()); + assert!( + jobs.bash_bg(tmp.path(), "c1", &a, None) + .await + .starts_with("[ok] job job1 lancé") + ); + assert!( + jobs.bash_bg(tmp.path(), "c2", &a, None) + .await + .starts_with("[ok] job job2 lancé") + ); + assert_eq!(jobs.running(), 2); + jobs.stop_conv("c1"); + for _ in 0..500 { + if jobs.running() == 1 { + break; + } + tokio::time::sleep(Duration::from_millis(10)).await; + } + assert_eq!(jobs.running(), 1); + assert_eq!( + jobs.bash_tail(&args(&[("id", Value::from("job1"))])).await, + "[erreur] job inconnu : job1" + ); + jobs.stop_conv("c2"); + for _ in 0..500 { + if jobs.running() == 0 { + break; + } + tokio::time::sleep(Duration::from_millis(10)).await; + } + assert_eq!(jobs.running(), 0); +} + +/// Dossier des journaux impossible à créer : l'erreur du Go. +#[cfg(unix)] +#[tokio::test] +async fn dossier_impossible() { + let tmp = tempfile::tempdir().unwrap(); + std::fs::write(tmp.path().join(".loki"), "un fichier").unwrap(); + let jobs = BgJobs::default(); + let got = jobs + .bash_bg( + tmp.path(), + "c", + &args(&[("command", Value::from("true"))]), + None, + ) + .await; + assert_eq!( + got, + format!( + "[erreur] mkdir {}: not a directory", + tmp.path().join(".loki").display() + ) + ); +} diff --git a/rust/loki-rs/src/tools/mod.rs b/rust/loki-rs/src/tools/mod.rs index 8e9f1ac..bd01f60 100644 --- a/rust/loki-rs/src/tools/mod.rs +++ b/rust/loki-rs/src/tools/mod.rs @@ -16,6 +16,7 @@ pub mod diff; pub mod files; pub mod git; +pub mod jobs; pub mod policy; pub mod shell; diff --git a/rust/loki-rs/src/tools/shell.rs b/rust/loki-rs/src/tools/shell.rs index b952000..dacb6a7 100644 --- a/rust/loki-rs/src/tools/shell.rs +++ b/rust/loki-rs/src/tools/shell.rs @@ -140,7 +140,7 @@ fn drain( /// La commande du shell de la plateforme. Le jeton de l'API interne n'a rien /// à faire dans le shell de l'agent. -fn shell_cmd(command: &str) -> tokio::process::Command { +pub(crate) fn shell_cmd(command: &str) -> tokio::process::Command { #[cfg(windows)] let mut c = { let mut c = tokio::process::Command::new("cmd"); diff --git a/rust/loki-rs/tests/jobs.rs b/rust/loki-rs/tests/jobs.rs new file mode 100644 index 0000000..acdcefe --- /dev/null +++ b/rust/loki-rs/tests/jobs.rs @@ -0,0 +1,54 @@ +//! Lot 43 : jobs d'arrière-plan du mode Code (`bash_bg`, `bash_tail`) tenus +//! par la façade, rejoués sur le journal du Go +//! (`internal/loki/parity_jobs_test.go` → `tests/parity_jobs/jobs.json`). +#![cfg(target_os = "linux")] + +use std::time::Duration; + +use loki_rs::tools::jobs::BgJobs; +use regex::Regex; +use serde_json::{Map, Value}; + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn jobs_comme_le_go() { + let fx: Value = serde_json::from_str(include_str!("parity_jobs/jobs.json")).expect("journal"); + let tmp = tempfile::tempdir().unwrap(); + let ws = tmp.path().join("ws"); + std::fs::create_dir_all(&ws).unwrap(); + let jobs = BgJobs::default(); + let pid = Regex::new(r"\(pid [0-9]+\)").unwrap(); + let since = Regex::new(r"depuis [0-9hms.]+").unwrap(); + let state = Regex::new(r"— (en cours|terminé \(exit -1\)) —").unwrap(); + for st in fx["steps"].as_array().unwrap() { + let name = st["name"].as_str().unwrap(); + if let Some(id) = st["wait"].as_str() { + // Le job attendu est le seul qui tourne encore. + for _ in 0..1000 { + if jobs.running() == 0 { + break; + } + tokio::time::sleep(Duration::from_millis(10)).await; + } + assert_eq!(jobs.running(), 0, "{name} : {id} pas fini"); + } + let args: Map = serde_json::from_value(st["args"].clone()).unwrap(); + let got = match st["tool"].as_str().unwrap() { + "bash_bg" => jobs.bash_bg(&ws, "c1", &args, None).await, + _ => jobs.bash_tail(&args).await, + }; + let got = pid.replace_all(&got, "(pid {{PID}})"); + let mut got = since.replace_all(&got, "depuis {{D}}").into_owned(); + if st["after"] == "kill" { + got = state.replace_all(&got, "— {{ETAT}} —").into_owned(); + } + assert_eq!(got, st["result"].as_str().unwrap(), "{name}"); + let mut logs: Vec = std::fs::read_dir(ws.join(".loki/jobs")) + .into_iter() + .flatten() + .flatten() + .map(|e| e.file_name().to_string_lossy().into_owned()) + .collect(); + logs.sort(); + assert_eq!(serde_json::json!(logs), st["logs"], "{name} : journaux"); + } +} diff --git a/rust/loki-rs/tests/lot6.rs b/rust/loki-rs/tests/lot6.rs index 4fb1323..353c4c3 100644 --- a/rust/loki-rs/tests/lot6.rs +++ b/rust/loki-rs/tests/lot6.rs @@ -672,6 +672,7 @@ async fn tour_complet_par_le_go() { tasks: None, memory: None, web: Default::default(), + jobs: Default::default(), go_version: Default::default(), }); let cfg = std::collections::HashMap::new(); @@ -2686,6 +2687,9 @@ async fn anneau_de_la_facade_pour_le_go() { .collect(); assert_eq!(kinds, ["subagent", "foreign", "subagent"]); assert_eq!(chat.shared.perf.snapshot().len(), 3); + // Lot 43 : les jobs d'arrière-plan encore en vie, pour l'optimiseur du Go. + let jobs = post(serde_json::json!({}), "/internal/facade/v1/bgjobs").await; + assert_eq!(jobs["running"], chat.shared.jobs.running() as u64); // Sans jeton : refusé. let (code, _) = fakego::send( facade, @@ -2788,3 +2792,51 @@ async fn outil_web_par_la_facade() { ) ); } + +/// Lot 43 : `bash_bg` d'un tour de la façade lance le job chez elle, dans le +/// dossier de la discussion ; le Go n'exécute rien et l'API interne compte +/// le job pour son optimiseur. +#[cfg(unix)] +#[tokio::test] +async fn job_arriere_plan_par_la_facade() { + let call = sse(&[ + serde_json::json!({"choices":[{"delta":{"tool_calls":[{"index":0,"id":"b1","type":"function","function":{"name":"bash_bg","arguments":"{\"command\":\"sleep 30\"}"}}]}}]}), + serde_json::json!({"choices":[{"delta":{},"finish_reason":"tool_calls"}]}), + ]); + let (url, seen) = fake_engine(vec![call, answer("lancé")]).await; + let ws = tempfile::tempdir().unwrap(); + let jobs = Arc::new(loki_rs::tools::jobs::BgJobs::default()); + let (svc, go, _stop, _t) = service_with(Some(&url), |s| s.jobs = jobs.clone()).await; + *go.tool_ctx.lock().unwrap() = Some(serde_json::json!({ + "cwd": ws.path(), "memory_dir": ws.path().join("mem"), "target": "" + })); + svc.send(serde_json::json!({"message": "lance le serveur", "cid": "j"})) + .await + .unwrap(); + until_done(&svc, 1).await; + assert!(go.tool_log.lock().unwrap().is_empty(), "{:?}", go.tool_log); + assert!(ws.path().join(".loki/jobs/job1.log").is_file()); + assert_eq!(jobs.running(), 1); + let seen = seen.lock().unwrap().clone(); + let tool = seen[1].1["messages"] + .as_array() + .unwrap() + .iter() + .rfind(|m| m["role"] == "tool") + .unwrap()["content"] + .as_str() + .unwrap() + .to_string(); + assert!(tool.starts_with("[ok] job job1 lancé (pid "), "{tool}"); + // Une autre discussion n'y touche pas ; la sienne l'arrête. + jobs.stop_conv("autre"); + assert_eq!(jobs.running(), 1); + jobs.stop_conv(&svc.sessions.conv().id()); + for _ in 0..500 { + if jobs.running() == 0 { + break; + } + tokio::time::sleep(std::time::Duration::from_millis(10)).await; + } + assert_eq!(jobs.running(), 0); +} diff --git a/rust/loki-rs/tests/parity_jobs/jobs.json b/rust/loki-rs/tests/parity_jobs/jobs.json new file mode 100644 index 0000000..400af08 --- /dev/null +++ b/rust/loki-rs/tests/parity_jobs/jobs.json @@ -0,0 +1,313 @@ +{ + "steps": [ + { + "name": "commande vide", + "tool": "bash_bg", + "args": { + "command": " " + }, + "result": "[erreur] commande vide", + "logs": [] + }, + { + "name": "commande refusée", + "tool": "bash_bg", + "args": { + "command": "rm -rf /" + }, + "result": "[refusé] Commande bloquée par la politique de sécurité : efface la racine du disque ou le home. Ne la réessaie pas, même reformulée. Si l'utilisateur la veut vraiment, dis-lui de la lancer lui-même dans un terminal.", + "logs": [] + }, + { + "name": "lancement", + "tool": "bash_bg", + "args": { + "command": "echo un; echo deux >&2; printf 'trois\\n\\n'" + }, + "result": "[ok] job job1 lancé (pid {{PID}}) — sortie dans .loki/jobs/job1.log, suis-la avec bash_tail", + "logs": [ + "job1.log" + ] + }, + { + "name": "sortie", + "tool": "bash_tail", + "args": { + "id": "job1" + }, + "wait": "job1", + "result": "job job1 — terminé (exit 0) — depuis {{D}}\ncommande : echo un; echo deux >&2; printf 'trois\\n\\n'\n\nun\ndeux\ntrois", + "logs": [ + "job1.log" + ] + }, + { + "name": "deux lignes", + "tool": "bash_tail", + "args": { + "id": "job1", + "lines": 2 + }, + "result": "job job1 — terminé (exit 0) — depuis {{D}}\ncommande : echo un; echo deux >&2; printf 'trois\\n\\n'\n\ndeux\ntrois", + "logs": [ + "job1.log" + ] + }, + { + "name": "lignes nulles", + "tool": "bash_tail", + "args": { + "id": "job1", + "lines": 0 + }, + "result": "job job1 — terminé (exit 0) — depuis {{D}}\ncommande : echo un; echo deux >&2; printf 'trois\\n\\n'\n\nun\ndeux\ntrois", + "logs": [ + "job1.log" + ] + }, + { + "name": "lignes en texte", + "tool": "bash_tail", + "args": { + "id": "job1", + "lines": "1" + }, + "result": "job job1 — terminé (exit 0) — depuis {{D}}\ncommande : echo un; echo deux >&2; printf 'trois\\n\\n'\n\nun\ndeux\ntrois", + "logs": [ + "job1.log" + ] + }, + { + "name": "lignes tronquées", + "tool": "bash_tail", + "args": { + "id": "job1", + "lines": 1.9 + }, + "result": "job job1 — terminé (exit 0) — depuis {{D}}\ncommande : echo un; echo deux >&2; printf 'trois\\n\\n'\n\ntrois", + "logs": [ + "job1.log" + ] + }, + { + "name": "arrêt d'un job fini", + "tool": "bash_tail", + "args": { + "id": "job1", + "kill": true + }, + "result": "job job1 — terminé (exit 0) — depuis {{D}}\ncommande : echo un; echo deux >&2; printf 'trois\\n\\n'\n\nun\ndeux\ntrois", + "logs": [ + "job1.log" + ] + }, + { + "name": "job inconnu", + "tool": "bash_tail", + "args": { + "id": "job9" + }, + "result": "[erreur] job inconnu : job9", + "logs": [ + "job1.log" + ] + }, + { + "name": "sans id", + "tool": "bash_tail", + "args": {}, + "result": "[erreur] job inconnu : ", + "logs": [ + "job1.log" + ] + }, + { + "name": "sortie en erreur", + "tool": "bash_bg", + "args": { + "command": "exit 3" + }, + "result": "[ok] job job2 lancé (pid {{PID}}) — sortie dans .loki/jobs/job2.log, suis-la avec bash_tail", + "logs": [ + "job1.log", + "job2.log" + ] + }, + { + "name": "rien d'écrit", + "tool": "bash_tail", + "args": { + "id": "job2" + }, + "wait": "job2", + "result": "job job2 — terminé (exit 3) — depuis {{D}}\ncommande : exit 3\n\n(aucune sortie pour l'instant)", + "logs": [ + "job1.log", + "job2.log" + ] + }, + { + "name": "job long", + "tool": "bash_bg", + "args": { + "command": "sleep 30" + }, + "result": "[ok] job job3 lancé (pid {{PID}}) — sortie dans .loki/jobs/job3.log, suis-la avec bash_tail", + "logs": [ + "job1.log", + "job2.log", + "job3.log" + ] + }, + { + "name": "en cours", + "tool": "bash_tail", + "args": { + "id": "job3" + }, + "result": "job job3 — en cours — depuis {{D}}\ncommande : sleep 30\n\n(aucune sortie pour l'instant)", + "logs": [ + "job1.log", + "job2.log", + "job3.log" + ] + }, + { + "name": "arrêt", + "tool": "bash_tail", + "args": { + "id": "job3", + "kill": true + }, + "after": "kill", + "result": "job job3 — {{ETAT}} — depuis {{D}}\ncommande : sleep 30\n\n(aucune sortie pour l'instant)", + "logs": [ + "job1.log", + "job2.log", + "job3.log" + ] + }, + { + "name": "après l'arrêt", + "tool": "bash_tail", + "args": { + "id": "job3" + }, + "wait": "job3", + "result": "job job3 — terminé (exit -1) — depuis {{D}}\ncommande : sleep 30\n\n(aucune sortie pour l'instant)", + "logs": [ + "job1.log", + "job2.log", + "job3.log" + ] + }, + { + "name": "beaucoup de lignes", + "tool": "bash_bg", + "args": { + "command": "for i in $(seq 1 300); do echo ligne $i; done" + }, + "result": "[ok] job job4 lancé (pid {{PID}}) — sortie dans .loki/jobs/job4.log, suis-la avec bash_tail", + "logs": [ + "job1.log", + "job2.log", + "job3.log", + "job4.log" + ] + }, + { + "name": "quarante par défaut", + "tool": "bash_tail", + "args": { + "id": "job4" + }, + "wait": "job4", + "result": "job job4 — terminé (exit 0) — depuis {{D}}\ncommande : for i in $(seq 1 300); do echo ligne $i; done\n\nligne 261\nligne 262\nligne 263\nligne 264\nligne 265\nligne 266\nligne 267\nligne 268\nligne 269\nligne 270\nligne 271\nligne 272\nligne 273\nligne 274\nligne 275\nligne 276\nligne 277\nligne 278\nligne 279\nligne 280\nligne 281\nligne 282\nligne 283\nligne 284\nligne 285\nligne 286\nligne 287\nligne 288\nligne 289\nligne 290\nligne 291\nligne 292\nligne 293\nligne 294\nligne 295\nligne 296\nligne 297\nligne 298\nligne 299\nligne 300", + "logs": [ + "job1.log", + "job2.log", + "job3.log", + "job4.log" + ] + }, + { + "name": "deux cents au plus", + "tool": "bash_tail", + "args": { + "id": "job4", + "lines": 500 + }, + "result": "job job4 — terminé (exit 0) — depuis {{D}}\ncommande : for i in $(seq 1 300); do echo ligne $i; done\n\nligne 101\nligne 102\nligne 103\nligne 104\nligne 105\nligne 106\nligne 107\nligne 108\nligne 109\nligne 110\nligne 111\nligne 112\nligne 113\nligne 114\nligne 115\nligne 116\nligne 117\nligne 118\nligne 119\nligne 120\nligne 121\nligne 122\nligne 123\nligne 124\nligne 125\nligne 126\nligne 127\nligne 128\nligne 129\nligne 130\nligne 131\nligne 132\nligne 133\nligne 134\nligne 135\nligne 136\nligne 137\nligne 138\nligne 139\nligne 140\nligne 141\nligne 142\nligne 143\nligne 144\nligne 145\nligne 146\nligne 147\nligne 148\nligne 149\nligne 150\nligne 151\nligne 152\nligne 153\nligne 154\nligne 155\nligne 156\nligne 157\nligne 158\nligne 159\nligne 160\nligne 161\nligne 162\nligne 163\nligne 164\nligne 165\nligne 166\nligne 167\nligne 168\nligne 169\nligne 170\nligne 171\nligne 172\nligne 173\nligne 174\nligne 175\nligne 176\nligne 177\nligne 178\nligne 179\nligne 180\nligne 181\nligne 182\nligne 183\nligne 184\nligne 185\nligne 186\nligne 187\nligne 188\nligne 189\nligne 190\nligne 191\nligne 192\nligne 193\nligne 194\nligne 195\nligne 196\nligne 197\nligne 198\nligne 199\nligne 200\nligne 201\nligne 202\nligne 203\nligne 204\nligne 205\nligne 206\nligne 207\nligne 208\nligne 209\nligne 210\nligne 211\nligne 212\nligne 213\nligne 214\nligne 215\nligne 216\nligne 217\nligne 218\nligne 219\nligne 220\nligne 221\nligne 222\nligne 223\nligne 224\nligne 225\nligne 226\nligne 227\nligne 228\nligne 229\nligne 230\nligne 231\nligne 232\nligne 233\nligne 234\nligne 235\nligne 236\nligne 237\nligne 238\nligne 239\nligne 240\nligne 241\nligne 242\nligne 243\nligne 244\nligne 245\nligne 246\nligne 247\nligne 248\nligne 249\nligne 250\nligne 251\nligne 252\nligne 253\nligne 254\nligne 255\nligne 256\nligne 257\nligne 258\nligne 259\nligne 260\nligne 261\nligne 262\nligne 263\nligne 264\nligne 265\nligne 266\nligne 267\nligne 268\nligne 269\nligne 270\nligne 271\nligne 272\nligne 273\nligne 274\nligne 275\nligne 276\nligne 277\nligne 278\nligne 279\nligne 280\nligne 281\nligne 282\nligne 283\nligne 284\nligne 285\nligne 286\nligne 287\nligne 288\nligne 289\nligne 290\nligne 291\nligne 292\nligne 293\nligne 294\nligne 295\nligne 296\nligne 297\nligne 298\nligne 299\nligne 300", + "logs": [ + "job1.log", + "job2.log", + "job3.log", + "job4.log" + ] + }, + { + "name": "sortie démesurée", + "tool": "bash_bg", + "args": { + "command": "for i in $(seq 1 9000); do printf 'é'; done; echo" + }, + "result": "[ok] job job5 lancé (pid {{PID}}) — sortie dans .loki/jobs/job5.log, suis-la avec bash_tail", + "logs": [ + "job1.log", + "job2.log", + "job3.log", + "job4.log", + "job5.log" + ] + }, + { + "name": "fin gardée", + "tool": "bash_tail", + "args": { + "id": "job5" + }, + "wait": "job5", + "result": "job job5 — terminé (exit 0) — depuis {{D}}\ncommande : for i in $(seq 1 9000); do printf 'é'; done; echo\n\néééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééé", + "logs": [ + "job1.log", + "job2.log", + "job3.log", + "job4.log", + "job5.log" + ] + }, + { + "name": "octets invalides", + "tool": "bash_bg", + "args": { + "command": "printf 'a\\377b\\n'" + }, + "result": "[ok] job job6 lancé (pid {{PID}}) — sortie dans .loki/jobs/job6.log, suis-la avec bash_tail", + "logs": [ + "job1.log", + "job2.log", + "job3.log", + "job4.log", + "job5.log", + "job6.log" + ] + }, + { + "name": "octets relus", + "tool": "bash_tail", + "args": { + "id": "job6" + }, + "wait": "job6", + "result": "job job6 — terminé (exit 0) — depuis {{D}}\ncommande : printf 'a\\377b\\n'\n\na\ufffdb", + "logs": [ + "job1.log", + "job2.log", + "job3.log", + "job4.log", + "job5.log", + "job6.log" + ] + } + ] +} From fe51bdeee903d2fffaf580ace737976d46641f30 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 16 Oct 2026 06:17:51 +0000 Subject: [PATCH 47/52] =?UTF-8?q?Lot=2044=20:=20navigateur=20pilot=C3=A9?= =?UTF-8?q?=20tenu=20par=20la=20fa=C3=A7ade?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit La façade tient la session Chrome des outils browser_* (src/browser/, port de computer_cdp.go, computer_use.go, browser_grid.go et cu_autoshot.go) : lancement (profil temporaire, mêmes arguments, tué avec la façade), client CDP multiplexé sur websocket, attente du chargement et du calme réseau, scripts injectés copiés à l'octet près, les neuf outils aux textes du Go (snapshot dédoublonné, recherche, clics, saisie et listes déroulantes, touches, défilement, navigation et ses erreurs), capture rangée propre et quadrillée pour le modèle (mêmes pixels), aperçus JPEG en anneau servis par /api/chat/cushot. Un seul navigateur : quand la façade tient la conversation, le Go lui passe les outils de ses sous-agents et tâches IA, ses aperçus et la fermeture (POST /internal/facade/v1/browser) ; façade injoignable, le navigateur du Go prend le relais. Couper l'interrupteur ferme les deux. Parité : journal de 42 étapes tiré du Go contre un faux Chrome (script + faux serveur CDP qui note chaque commande, scripts résumés par leur empreinte, pixels de la grille), rejoué par tests/browser.rs ; un vrai Chromium de bout en bout avec LOKI_CU_SMOKE=1 ; un tour de la façade qui ouvre une page (tests/lot6.rs) ; TestFacadeNavigateur côté Go. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_016tcGfJWefW5kUGoC2wL8cv --- README.md | 2 +- ...-rust-migration-lot44-navigateur-design.md | 138 ++ internal/loki/computer_cdp.go | 1 + internal/loki/computer_use.go | 30 + internal/loki/cu_autoshot.go | 3 + internal/loki/facade_browser.go | 86 ++ internal/loki/facade_browser_test.go | 53 + internal/loki/facade_chat.go | 2 + internal/loki/facade_chat_test.go | 8 +- internal/loki/parity_browser_test.go | 399 ++++++ rust/loki-rs/src/browser/grid.rs | 126 ++ rust/loki-rs/src/browser/js.rs | 69 + rust/loki-rs/src/browser/mod.rs | 1114 +++++++++++++++++ rust/loki-rs/src/browser/tests.rs | 103 ++ rust/loki-rs/src/chat/generate.rs | 1 + rust/loki-rs/src/chat/host.rs | 37 +- rust/loki-rs/src/chatapi.rs | 61 + rust/loki-rs/src/lib.rs | 1 + rust/loki-rs/src/system.rs | 14 +- rust/loki-rs/tests/browser.rs | 250 ++++ rust/loki-rs/tests/common/fakecdp.rs | 222 ++++ rust/loki-rs/tests/common/mod.rs | 1 + rust/loki-rs/tests/lot6.rs | 118 ++ .../loki-rs/tests/parity_browser/browser.json | 1082 ++++++++++++++++ 24 files changed, 3910 insertions(+), 11 deletions(-) create mode 100644 docs/superpowers/specs/2026-10-16-rust-migration-lot44-navigateur-design.md create mode 100644 internal/loki/facade_browser.go create mode 100644 internal/loki/facade_browser_test.go create mode 100644 internal/loki/parity_browser_test.go create mode 100644 rust/loki-rs/src/browser/grid.rs create mode 100644 rust/loki-rs/src/browser/js.rs create mode 100644 rust/loki-rs/src/browser/mod.rs create mode 100644 rust/loki-rs/src/browser/tests.rs create mode 100644 rust/loki-rs/tests/browser.rs create mode 100644 rust/loki-rs/tests/common/fakecdp.rs create mode 100644 rust/loki-rs/tests/parity_browser/browser.json diff --git a/README.md b/README.md index ced6484..89624d1 100644 --- a/README.md +++ b/README.md @@ -245,7 +245,7 @@ redémarrages (volume `/data`). Variables d'environnement du conteneur : | `LOKI_TUNE` | `rust` : l'optimiseur — lancement, essais, progression, annulation, application dans une copie ou dans le preset, dernier résultat — est tenu par la façade Rust, sous Unix ; le verrou reste le fichier `tune.lock` lu par le Go, les essais des « loki serve » du Go (expérimental, migration en cours) | Go | | `LOKI_ENGINE_IMAGE` | `rust` (avec `LOKI_BACKENDS=rust`) : le panneau « moteur de l'image » — version qui tourne, dernier build publié, version recommandée, mise à jour depuis le registre officiel avec essai à blanc, bascules, retour à la version précédente, vérification du rendu du gabarit — est tenu par la façade Rust (expérimental, migration en cours) | Go | | `LOKI_PROJECTS` | `rust` : le hub des projets (liste et comptes, création, renommage, description, suppression, bascule) est tenu par la façade Rust ; les discussions restent à qui tient la conversation (expérimental, migration en cours) | Go | -| `LOKI_SYSTEM` | `rust` : les interrupteurs système — accès internet et Crawl4AI, pilotage du navigateur, clé de complétion, écoute réseau du moteur — sont tenus par la façade Rust ; le navigateur piloté reste au Go (expérimental, migration en cours) | Go | +| `LOKI_SYSTEM` | `rust` : les interrupteurs système — accès internet et Crawl4AI, pilotage du navigateur, clé de complétion, écoute réseau du moteur — sont tenus par la façade Rust (expérimental, migration en cours) ; le navigateur piloté, lui, est celui de la façade dès qu'elle tient la conversation | Go | | `LOKI_ENGINE` | `rust` : le pilotage du moteur (démarrage, arrêt, redémarrage, déchargement et rechargement de la VRAM) est tenu par la façade Rust, en conteneur avec son superviseur ; le verrou de l'optimiseur reste au Go (expérimental, migration en cours) | Go | **Accès par nom de domaine.** Sans clé de pilotage, l'API n'accepte que les diff --git a/docs/superpowers/specs/2026-10-16-rust-migration-lot44-navigateur-design.md b/docs/superpowers/specs/2026-10-16-rust-migration-lot44-navigateur-design.md new file mode 100644 index 0000000..c753ee4 --- /dev/null +++ b/docs/superpowers/specs/2026-10-16-rust-migration-lot44-navigateur-design.md @@ -0,0 +1,138 @@ +# Lot 44 — Navigateur piloté dans la façade + +## Contexte + +Le contrôle du navigateur donne à l'agent un Chromium local, piloté par le +protocole DevTools (CDP). L'agent l'utilise ainsi : + +- il ouvre une page et lit les éléments interactifs numérotés ; +- il clique, tape, défile par numéro ; +- avec la vision, il voit une capture quadrillée et clique en + coordonnées. + +La façade tient la conversation par défaut, mais la session Chrome, les +outils `browser_*` et l'anneau des aperçus montrés à l'utilisateur étaient +au Go (`/internal/v1/tool/exec`). + +## Le lot + +`src/browser/` porte quatre fichiers Go : + +- `computer_cdp.go` : client CDP, lancement, scripts ; +- `computer_use.go` : les neuf outils ; +- `browser_grid.go` : la grille de la capture ; +- `cu_autoshot.go` : les aperçus. + +Ce qui est porté : + +- **le lancement** : + - binaire imposé, celui de Playwright ou du système ; + - profil temporaire `loki-cu-*`, et ceux de plus d'une heure balayés ; + - mêmes arguments (headless sauf `LOKI_CU_HEADFUL`) ; + - tué avec la façade (`PR_SET_PDEATHSIG`) ; + - port lu dans `DevToolsActivePort`, cible « page » par `/json` ; + - viewport fixe 1280×800 ; + - script qui garde les nouveaux onglets dans l'onglet courant ; +- **la session** : + - une seule, lancée à la demande ; + - vérifiée par un ping avant chaque outil et relancée si elle ne répond + plus ; + - commandes multiplexées par numéro, 30 s au plus ; + - requêtes réseau suivies pour attendre le calme (600 ms, 6 s au plus) + après un chargement ; +- **les scripts** injectés, copiés du Go à l'octet près (le journal + compare leurs empreintes) : + - éléments actionnables et visibles ; + - shadow DOM ; + - nom d'un champ par son libellé, sans révéler un mot de passe ; + - recherche non destructive ; + - liste déroulante native réglée par son option ; +- **les outils**, mêmes textes : + - snapshot dédoublonné ; + - indices de défilement ; + - erreurs de navigation (`errorText`, page `chrome-error://`, + `ERR_ABORTED` bénin) ; + - `back` et `forward` ; + - touches nommées ; + - plafond de 8000 caractères ; +- **la capture** (`browser_screenshot`, vision active) : + - PNG propre rangé dans le dossier de travail + (`capture-AAAAMMJJ-HHMMSS.png`, heure locale) ; + - image du modèle quadrillée tous les 100 px, mêmes pixels que le Go ; + à 1280×800 elle est sous la taille maximale et part telle quelle ; + - relayée au modèle comme `see_image` ; +- **les aperçus** : + - après une action qui change la page, jamais après une erreur ; + - JPEG en mémoire, anneau de huit ; + - servis par `/api/chat/cushot`, que le Go remet désormais à la façade. + +`ChatShared.browser` porte le navigateur du processus ; +`GoTurnHost::exec_tool` exécute les outils et prend l'aperçu. + +Un seul navigateur : quand la façade tient la conversation, le Go lui passe +ses propres appels (`facade_browser.go`) : + +- outils des sous-agents et des tâches IA ; +- aperçus ; +- fermeture (interrupteur coupé, ligne de commande). + +Ils passent par `POST /internal/facade/v1/browser`, avec trois opérations : +`tool`, `shot`, `shutdown`. Si la façade ne répond pas, le navigateur du Go +prend le relais. Couper l'interrupteur côté façade (lot 17) ferme les deux. + +## Écarts connus + +- Encodeurs PNG et JPEG : mêmes pixels, octets différents. +- Une capture au-delà de 1568 px ne serait pas réduite (le viewport fixe + rend le cas impossible). + +## Parité + +`internal/loki/parity_browser_test.go` (Linux) tire du Go un journal de 42 +étapes (`tests/parity_browser/browser.json`) contre un faux Chrome : + +- un script annonce le port d'un faux serveur CDP ; +- le faux CDP répond selon le scénario de chaque étape et note chaque + commande. + +Chaque étape porte : + +- le résultat ; +- les commandes CDP (méthode, paramètres, scripts résumés par leur + empreinte) ; +- pour une capture, ses dimensions et dix pixels de la grille ; +- les captures rangées. + +Les arguments de lancement sont relevés aussi. Le journal couvre : + +- ouverture, snapshot inchangé, réouverture complète ; +- snapshots vide, `null`, sans valeur, illisible, exception JS ; +- défilements ; +- clics par numéro, introuvable, refusé, en coordonnées ; +- saisies libre, dans un champ, liste déroulante, option absente, champ + introuvable ; +- touches ; +- recherches ; +- navigations en échec, page d'erreur, interruption bénigne, `back`, + `forward`, commande refusée ; +- captures sans vision, quadrillée, refusée ; +- sortie plafonnée. + +`tests/browser.rs` le rejoue contre le port, puis vérifie l'aperçu (JPEG +de l'anneau) et la fermeture (profil retiré). + +`vrai_navigateur` (avec `LOKI_CU_SMOKE=1`, comme `TestCDPSmoke`) pilote un +vrai Chromium sur une page locale : ouverture, recherche, saisie, liste +déroulante, clic qui navigue, `back`, capture 1280×800, aperçu, navigation +en échec. + +D'autres tests complètent : + +- `tests/lot6.rs` (`navigateur_par_la_facade`) : un tour de la façade ouvre + une page sans passer par le Go, avec aperçu ; l'API du Go et + `/api/chat/cushot` par une façade complète ; +- `TestFacadeNavigateur` : le Go passe ses outils, captures, aperçus et + fermeture à la façade, et se replie sur son navigateur si elle est en + panne ; +- tests unitaires : outils, nombres au format du Go, arguments, grille, + profils, lancement impossible. diff --git a/internal/loki/computer_cdp.go b/internal/loki/computer_cdp.go index 462aafe..1ac7e23 100644 --- a/internal/loki/computer_cdp.go +++ b/internal/loki/computer_cdp.go @@ -457,6 +457,7 @@ func sweepStaleCUDirs() { } func cdpShutdown() { + facadeBrowserShutdown() // lot 44 : celui de la façade, quand elle tient la conversation cdpMu.Lock() defer cdpMu.Unlock() if cdpCur != nil { diff --git a/internal/loki/computer_use.go b/internal/loki/computer_use.go index e6c7fb1..b432d0b 100644 --- a/internal/loki/computer_use.go +++ b/internal/loki/computer_use.go @@ -169,6 +169,9 @@ func cuPromptLine() string { // ─── exécution (appelée par le dispatch de llm_client.go) ──────────────────── func toolCUOpen(args map[string]any) string { + if r, _, ok := facadeBrowserTool("browser_open", args); ok { + return r + } url, _ := args["url"].(string) if strings.TrimSpace(url) == "" { return "[erreur] url manquante" @@ -188,6 +191,9 @@ func toolCUOpen(args map[string]any) string { } func toolCUSnapshot() string { + if r, _, ok := facadeBrowserTool("browser_snapshot", map[string]any{}); ok { + return r + } s, err := cdpGet() if err != nil { return "[erreur] " + err.Error() @@ -200,6 +206,9 @@ func toolCUSnapshot() string { } func toolCUFind(args map[string]any) string { + if r, _, ok := facadeBrowserTool("browser_find", args); ok { + return r + } text, _ := args["text"].(string) if strings.TrimSpace(text) == "" { return "[erreur] texte de recherche manquant" @@ -216,6 +225,9 @@ func toolCUFind(args map[string]any) string { } func toolCUClick(args map[string]any) string { + if r, _, ok := facadeBrowserTool("browser_click", args); ok { + return r + } ref, ok := intArg(args, "ref") if !ok { return "[erreur] ref manquant (numéro de l'élément)" @@ -232,6 +244,9 @@ func toolCUClick(args map[string]any) string { } func toolCUType(args map[string]any) string { + if r, _, ok := facadeBrowserTool("browser_type", args); ok { + return r + } text, _ := args["text"].(string) s, err := cdpGet() if err != nil { @@ -249,6 +264,9 @@ func toolCUType(args map[string]any) string { } func toolCUKey(args map[string]any) string { + if r, _, ok := facadeBrowserTool("browser_key", args); ok { + return r + } key, _ := args["key"].(string) s, err := cdpGet() if err != nil { @@ -262,6 +280,9 @@ func toolCUKey(args map[string]any) string { } func toolCUClickXY(args map[string]any) string { + if r, _, ok := facadeBrowserTool("browser_click_xy", args); ok { + return r + } x, okx := floatArg(args, "x") y, oky := floatArg(args, "y") if !okx || !oky { @@ -279,6 +300,9 @@ func toolCUClickXY(args map[string]any) string { } func toolCUScroll(args map[string]any) string { + if r, _, ok := facadeBrowserTool("browser_scroll", args); ok { + return r + } dir, _ := args["direction"].(string) s, err := cdpGet() if err != nil { @@ -299,6 +323,12 @@ func toolCUScroll(args map[string]any) string { // PNG inexistant sur tout le disque (vécu sur le test IONOS, ~12 bash find pour // rien). Avec le chemin en retour, il n'a plus qu'à le donner en lien Markdown. func toolCUScreenshot() (string, map[string]any) { + if r, img, ok := facadeBrowserTool("browser_screenshot", map[string]any{}); ok { + if len(img) == 0 { + img = nil + } + return r, img + } if !visionEnabled() { return "[erreur] la vision n'est pas active sur ce modèle — impossible de capturer l'écran", nil } diff --git a/internal/loki/cu_autoshot.go b/internal/loki/cu_autoshot.go index 3e8a79a..de97fed 100644 --- a/internal/loki/cu_autoshot.go +++ b/internal/loki/cu_autoshot.go @@ -46,6 +46,9 @@ func cuLog(format string, a ...any) { fmt.Fprintf(os.Stderr, "[cu] "+format+"\n" // renvoie l'identifiant de la capture en mémoire ("" si impossible : jamais // bloquant pour l'outil). func cuAutoShot() string { + if id, ok := facadeBrowserShot(); ok { + return id + } s, err := cdpGet() if err != nil { cuLog("aperçu : navigateur indisponible : %v", err) diff --git a/internal/loki/facade_browser.go b/internal/loki/facade_browser.go new file mode 100644 index 0000000..857f189 --- /dev/null +++ b/internal/loki/facade_browser.go @@ -0,0 +1,86 @@ +package loki + +import ( + "bytes" + "encoding/json" + "fmt" + "net/http" + "os" + "time" +) + +// facade_browser.go — le navigateur piloté tenu par la façade (lot 44 de la +// migration Rust). Quand elle tient la conversation, la session Chrome est +// la sienne : ses tours exécutent les outils browser_* chez elle, et ceux du +// Go (sous-agents, tâches IA) passent par /internal/facade/v1/browser — un +// seul navigateur. L'aperçu après une action et la fermeture aussi. +// Façade injoignable : le navigateur du Go prend le relais. + +// facadeBrowserClient : une action du navigateur peut attendre le +// lancement de Chrome, puis le chargement et le calme réseau de la page. +var facadeBrowserClient = &http.Client{Timeout: 3 * time.Minute} + +// facadeBrowserCall appelle l'API du navigateur de la façade. +func facadeBrowserCall(body map[string]any, out any) error { + b, err := json.Marshal(body) + if err != nil { + return err + } + req, err := http.NewRequest(http.MethodPost, "http://"+facadeAddr()+"/internal/facade/v1/browser", bytes.NewReader(b)) + if err != nil { + return err + } + req.Header.Set("Content-Type", "application/json") + req.Header.Set(internalTokenHeader, internalToken()) + resp, err := facadeBrowserClient.Do(req) + if err != nil { + return err + } + defer resp.Body.Close() + if resp.StatusCode != http.StatusOK { + return fmt.Errorf("façade : %s", resp.Status) + } + return json.NewDecoder(resp.Body).Decode(out) +} + +// facadeBrowserTool exécute un outil browser_* à la façade ; ok faux : elle +// ne tient pas la conversation, ou ne répond pas (le Go le fait lui-même). +func facadeBrowserTool(name string, args map[string]any) (result string, image map[string]any, ok bool) { + if !facadeChat() { + return "", nil, false + } + var out struct { + Result string `json:"result"` + Image map[string]any `json:"image"` + } + if err := facadeBrowserCall(map[string]any{"op": "tool", "name": name, "args": args}, &out); err != nil { + fmt.Fprintf(os.Stderr, "[cu] navigateur de la façade injoignable, repli local : %v\n", err) + return "", nil, false + } + return out.Result, out.Image, true +} + +// facadeBrowserShot : l'aperçu pris par la façade. +func facadeBrowserShot() (string, bool) { + if !facadeChat() { + return "", false + } + var out struct { + ID string `json:"id"` + } + if err := facadeBrowserCall(map[string]any{"op": "shot"}, &out); err != nil { + return "", false + } + return out.ID, true +} + +// facadeBrowserShutdown ferme le navigateur de la façade. +func facadeBrowserShutdown() { + if !facadeChat() { + return + } + var out struct{} + if err := facadeBrowserCall(map[string]any{"op": "shutdown"}, &out); err != nil { + fmt.Fprintf(os.Stderr, "[cu] fermeture du navigateur de la façade : %v\n", err) + } +} diff --git a/internal/loki/facade_browser_test.go b/internal/loki/facade_browser_test.go new file mode 100644 index 0000000..452183b --- /dev/null +++ b/internal/loki/facade_browser_test.go @@ -0,0 +1,53 @@ +package loki + +import ( + "strings" + "testing" +) + +// Lot 44 : quand la façade tient la conversation, le navigateur est le sien — +// les outils browser_* du Go, l'aperçu et la fermeture passent par elle ; +// façade en panne, le navigateur du Go prend le relais. +func TestFacadeNavigateur(t *testing.T) { + testHome(t) + t.Setenv("LOKI_CHROME", "/nulle/part/chrome") + f := demarrerFacade(t) + f.internalStatus = 200 + f.internalBody = `{"result":"# Exemple\nhttps://exemple.test/","image":null}` + if got := toolCUOpen(map[string]any{"url": "exemple.test"}); got != "# Exemple\nhttps://exemple.test/" { + t.Fatalf("ouverture : %q", got) + } + vu := f.recu() + if len(vu) != 1 || !strings.HasPrefix(vu[0], "POST /internal/facade/v1/browser jeton ") || + !strings.Contains(vu[0], `"op":"tool"`) || !strings.Contains(vu[0], `"name":"browser_open"`) || + !strings.Contains(vu[0], `"url":"exemple.test"`) { + t.Fatalf("appel : %v", vu) + } + f.internalBody = `{"result":"[ok] capture","image":{"type":"image_url","image_url":{"url":"data:image/png;base64,AA=="}}}` + res, img := toolCUScreenshot() + if res != "[ok] capture" || img["type"] != "image_url" { + t.Fatalf("capture : %q %v", res, img) + } + f.internalBody = `{"result":"[erreur] la vision n'est pas active","image":null}` + if res, img := toolCUScreenshot(); img != nil || !strings.HasPrefix(res, "[erreur]") { + t.Fatalf("capture refusée : %q %v", res, img) + } + f.internalBody = `{"id":"abc"}` + if id := cuAutoShot(); id != "abc" { + t.Fatalf("aperçu : %q", id) + } + f.internalBody = `{}` + cdpShutdown() + vu = f.recu() + if last := vu[len(vu)-1]; !strings.Contains(last, `"op":"shutdown"`) { + t.Fatalf("fermeture : %v", vu) + } + if !facadeChatRoutes["/api/chat/cushot"] { + t.Fatal("/api/chat/cushot n'est pas remis à la façade") + } + // Façade en panne : le Go pilote lui-même. + f.internalStatus = 500 + if got := toolCUSnapshot(); got != "[erreur] fork/exec /nulle/part/chrome: no such file or directory" { + t.Fatalf("repli : %q", got) + } +} diff --git a/internal/loki/facade_chat.go b/internal/loki/facade_chat.go index 4c72ed1..43f6a22 100644 --- a/internal/loki/facade_chat.go +++ b/internal/loki/facade_chat.go @@ -91,6 +91,8 @@ var facadeChatRoutes = map[string]bool{ "/api/chat/criteria": true, // Lot 39 : l'anneau de télémétrie est celui de la façade. "/api/perf/summary": true, + // Lot 44 : les aperçus du navigateur piloté, tenu par la façade. + "/api/chat/cushot": true, } // withFacadeChat remet la requête à la façade quand elle tient la diff --git a/internal/loki/facade_chat_test.go b/internal/loki/facade_chat_test.go index 15fe9d6..184110c 100644 --- a/internal/loki/facade_chat_test.go +++ b/internal/loki/facade_chat_test.go @@ -93,17 +93,17 @@ func TestFacadeRelaieLesRoutesDuChat(t *testing.T) { t.Fatalf("%s non relayé : %q", p, w.Body.String()) } } - // L'aperçu du navigateur piloté reste ici. + // L'aperçu du navigateur piloté aussi : le navigateur est le sien (lot 44). w = httptest.NewRecorder() withFacadeChat("/api/chat/cushot", local)(w, httptest.NewRequest("GET", "/api/chat/cushot", nil)) - if w.Body.String() != "go" { - t.Fatalf("aperçu relayé : %q", w.Body.String()) + if w.Body.String() != f.reply { + t.Fatalf("aperçu non relayé : %q", w.Body.String()) } // Sans LOKI_CHAT=rust : tout reste ici. t.Setenv(facadeChatEnv, "") w = httptest.NewRecorder() withFacadeChat("/api/chat/send", local)(w, httptest.NewRequest("POST", "/api/chat/send", nil)) - if w.Body.String() != "go" || len(f.recu()) != 4 { + if w.Body.String() != "go" || len(f.recu()) != 5 { t.Fatalf("relayé sans la variable : %q", w.Body.String()) } } diff --git a/internal/loki/parity_browser_test.go b/internal/loki/parity_browser_test.go new file mode 100644 index 0000000..1593a3e --- /dev/null +++ b/internal/loki/parity_browser_test.go @@ -0,0 +1,399 @@ +//go:build linux + +package loki + +import ( + "bytes" + "crypto/sha256" + "encoding/base64" + "encoding/hex" + "encoding/json" + "fmt" + "image" + "image/color" + "image/png" + "net/http" + "net/http/httptest" + "os" + "path/filepath" + "regexp" + "strings" + "sync" + "testing" + + "github.com/coder/websocket" +) + +// parity_browser_test.go — journal de référence du navigateur piloté (lot 44 +// de la migration Rust) : les outils browser_*, de bout en bout contre un faux +// Chrome. Un script tient lieu de binaire (il annonce le port d'un faux +// serveur CDP dans DevToolsActivePort) ; le faux CDP répond selon un scénario +// réglé à chaque étape et note chaque commande reçue (méthode, paramètres, +// scripts résumés par leur empreinte). Chaque étape porte le résultat, les +// commandes, et pour une capture ses dimensions et quelques pixels de la +// grille. Rangé dans rust/loki-rs/tests/parity_browser/browser.json ; +// tests/browser.rs le rejoue contre le port. +// +// LOKI_PARITY_UPDATE=1 go test ./internal/loki -run TestPariteNavigateur + +const parityBrowserFile = "../../rust/loki-rs/tests/parity_browser/browser.json" + +// parityFakeChrome : le faux navigateur. Il annonce le port du faux CDP, +// range ses arguments, et attend. +const parityFakeChrome = `#!/bin/sh +for a in "$@"; do + case "$a" in + --user-data-dir=*) dir="${a#--user-data-dir=}" ;; + esac +done +printf '%s\n/devtools/browser/faux\n' "$FAKE_CDP_PORT" > "$dir/DevToolsActivePort" +printf '%s\n' "$@" > "$FAKE_CDP_ARGS" +exec sleep 600 +` + +// parityCDPScene : ce que le faux CDP répond (réglé étape par étape). +type parityCDPScene struct { + Snap any `json:"snap,omitempty"` // valeur du script de snapshot ; "" : sans valeur + Find any `json:"find,omitempty"` // valeur de browser_find + Center any `json:"center,omitempty"` // valeur de refCenter + Typed any `json:"typed,omitempty"` // valeur de typeInto + Href string `json:"href,omitempty"` + NavError string `json:"nav_error,omitempty"` + Throw bool `json:"throw,omitempty"` // les scripts lèvent une exception + Errors map[string]string `json:"errors,omitempty"` // méthode → message d'erreur CDP +} + +type parityBrowserStep struct { + Name string `json:"name"` + Scene *parityCDPScene `json:"scene,omitempty"` // remplace le scénario courant + Vision bool `json:"vision,omitempty"` + Tool string `json:"tool"` + Args map[string]any `json:"args"` + Result string `json:"result"` + Image map[string]any `json:"image,omitempty"` // dimensions et pixels de la capture du modèle + Saved []string `json:"saved"` // captures rangées dans le dossier de travail + Calls []string `json:"calls"` +} + +// parityCDPAbbrev résume un script : début, longueur, empreinte. +func parityCDPAbbrev(s string) string { + if len(s) <= 60 { + return s + } + h := sha256.Sum256([]byte(s)) + r := []rune(s) + head := string(r[:40]) + return fmt.Sprintf("%s…[%d:%s]", head, len(s), hex.EncodeToString(h[:6])) +} + +// parityCDPCall : une commande reçue, en une ligne stable. +func parityCDPCall(method string, params map[string]any) string { + if params == nil { + return method + } + p := map[string]any{} + for k, v := range params { + if s, ok := v.(string); ok && (k == "expression" || k == "source") { + v = parityCDPAbbrev(s) + } + p[k] = v + } + b, _ := json.Marshal(p) + return method + " " + string(b) +} + +// parityPNG : une capture unie (la grille s'y lit sans ambiguïté). +func parityPNG(w, h int) string { + img := image.NewRGBA(image.Rect(0, 0, w, h)) + for y := 0; y < h; y++ { + for x := 0; x < w; x++ { + img.Set(x, y, color.RGBA{R: 40, G: 80, B: 120, A: 255}) + } + } + var b bytes.Buffer + _ = png.Encode(&b, img) + return base64.StdEncoding.EncodeToString(b.Bytes()) +} + +// parityImageFacts : dimensions et pixels témoins d'une image relayée. +func parityImageFacts(t *testing.T, part map[string]any) map[string]any { + u, _ := part["image_url"].(map[string]any)["url"].(string) + const pre = "data:image/png;base64," + if !strings.HasPrefix(u, pre) { + t.Fatalf("image relayée : %.60s", u) + } + raw, _ := base64.StdEncoding.DecodeString(strings.TrimPrefix(u, pre)) + img, err := png.Decode(bytes.NewReader(raw)) + if err != nil { + t.Fatal(err) + } + b := img.Bounds() + px := map[string]string{} + for _, p := range [][2]int{{0, 0}, {100, 50}, {101, 50}, {50, 100}, {2, 2}, {104, 4}, {110, 12}, {300, 799}, {1279, 700}, {4, 204}} { + if p[0] >= b.Dx() || p[1] >= b.Dy() { + continue + } + r, g, bl, a := img.At(p[0], p[1]).RGBA() + px[fmt.Sprintf("%d,%d", p[0], p[1])] = fmt.Sprintf("%d,%d,%d,%d", r>>8, g>>8, bl>>8, a>>8) + } + return map[string]any{"w": b.Dx(), "h": b.Dy(), "px": px} +} + +func TestPariteNavigateur(t *testing.T) { + testHome(t) + // Le dossier de travail est mis en cache par processus (workspaceOnce) : + // celui d'un test précédent a pu disparaître avec son TempDir. + ws := agentWorkspace() + if err := os.MkdirAll(ws, 0o755); err != nil { + t.Fatal(err) + } + tools := t.TempDir() + chrome := filepath.Join(tools, "chrome") + if err := os.WriteFile(chrome, []byte(parityFakeChrome), 0o755); err != nil { + t.Fatal(err) + } + argsFile := filepath.Join(tools, "args") + t.Setenv("LOKI_CHROME", chrome) + t.Setenv("FAKE_CDP_ARGS", argsFile) + t.Setenv("LOKI_CU_HEADFUL", "") + + var mu sync.Mutex + scene := parityCDPScene{} + var calls []string + reply := func(method string, params map[string]any) (any, string) { + mu.Lock() + defer mu.Unlock() + calls = append(calls, parityCDPCall(method, params)) + if msg, ok := scene.Errors[method]; ok { + return nil, msg + } + val := func(v any) any { + if v == "" { + return map[string]any{"result": map[string]any{"type": "undefined"}} + } + return map[string]any{"result": map[string]any{"type": "object", "value": v}} + } + switch method { + case "Runtime.evaluate": + expr, _ := params["expression"].(string) + switch { + case expr == "1": + return val(1), "" + case expr == "document.readyState": + return val("complete"), "" + case expr == "location.href": + return val(scene.Href), "" + } + if scene.Throw { + return map[string]any{"result": map[string]any{"type": "object"}, + "exceptionDetails": map[string]any{"text": "Uncaught TypeError: x is not a function"}}, "" + } + switch { + case strings.Contains(expr, "window.__lokiRefs=map"): + return val(scene.Snap), "" + case strings.Contains(expr, "var pool=ajQueryAll();"): + return val(scene.Find), "" + case strings.Contains(expr, "inline:'center'"): + return val(scene.Center), "" + case strings.Contains(expr, "return 'TEXT';"): + return val(scene.Typed), "" + case strings.HasPrefix(expr, "history.") || strings.HasPrefix(expr, "window.scrollBy("): + return val("ok"), "" + } + return val(""), "" + case "Page.navigate": + r := map[string]any{"frameId": "F1", "loaderId": "L1"} + if scene.NavError != "" { + r["errorText"] = scene.NavError + } + return r, "" + case "Page.captureScreenshot": + return map[string]any{"data": parityPNG(1280, 800)}, "" + } + return map[string]any{}, "" + } + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path == "/json" { + _ = json.NewEncoder(w).Encode([]map[string]string{ + {"type": "service_worker", "webSocketDebuggerUrl": "ws://" + r.Host + "/devtools/sw/0"}, + {"type": "page", "webSocketDebuggerUrl": "ws://" + r.Host + "/devtools/page/1"}, + }) + return + } + c, err := websocket.Accept(w, r, nil) + if err != nil { + return + } + c.SetReadLimit(-1) + for { + _, data, err := c.Read(r.Context()) + if err != nil { + return + } + var in struct { + ID int `json:"id"` + Method string `json:"method"` + Params map[string]any `json:"params"` + } + _ = json.Unmarshal(data, &in) + res, errMsg := reply(in.Method, in.Params) + out := map[string]any{"id": in.ID} + if errMsg != "" { + out["error"] = map[string]any{"code": -32000, "message": errMsg} + } else { + out["result"] = res + } + b, _ := json.Marshal(out) + if err := c.Write(r.Context(), websocket.MessageText, b); err != nil { + return + } + } + })) + defer srv.Close() + t.Setenv("FAKE_CDP_PORT", strings.TrimPrefix(srv.URL, "http://127.0.0.1:")) + cdpShutdown() + t.Cleanup(cdpShutdown) + + capRe := regexp.MustCompile(`capture-[0-9]{8}-[0-9]{6}\.png`) + var steps []parityBrowserStep + run := func(st parityBrowserStep) { + mu.Lock() + if st.Scene != nil { + scene = *st.Scene + } + calls = nil + mu.Unlock() + if st.Vision { + _ = SetConfigKey("MMPROJ", "vision.gguf") + } else { + _ = SetConfigKey("MMPROJ", "") + } + args := map[string]any{} + b, _ := json.Marshal(st.Args) + _ = json.Unmarshal(b, &args) + res := execTool(t.Context(), st.Tool, args, toolCallLabel(st.Tool, args), Caps{Agent: true}) + st.Result = capRe.ReplaceAllString(res.Result, "capture-{{T}}.png") + if res.VisionImg != nil { + st.Image = parityImageFacts(t, res.VisionImg) + } + st.Saved = []string{} + entries, _ := os.ReadDir(ws) + for _, e := range entries { + if !capRe.MatchString(e.Name()) { + continue + } + st.Saved = append(st.Saved, capRe.ReplaceAllString(e.Name(), "capture-{{T}}.png")) + _ = os.Remove(filepath.Join(ws, e.Name())) + } + mu.Lock() + st.Calls = append([]string{}, calls...) + mu.Unlock() + if st.Args == nil { + st.Args = map[string]any{} + } + steps = append(steps, st) + } + a := func(kv ...any) map[string]any { + m := map[string]any{} + for i := 0; i+1 < len(kv); i += 2 { + m[kv[i].(string)] = kv[i+1] + } + return m + } + snapPage := map[string]any{"title": "Exemple", "url": "https://exemple.test/", "above": 0, "below": 900, + "refs": []any{`[1] link "Accueil"`, `[2] button "Créer un compte"`, `[3] textbox "Nom = Jean"`}} + snapScrolled := map[string]any{"title": "Exemple", "url": "https://exemple.test/", "above": 600, "below": 10, + "refs": []any{`[4] link "Mentions"`}} + snapEmpty := map[string]any{"title": "", "url": "about:blank", "refs": []any{}, "above": 0, "below": 0} + + run(parityBrowserStep{Name: "ouverture sans url", Tool: "browser_open", Args: a("url", " ")}) + run(parityBrowserStep{Name: "ouverture", Scene: &parityCDPScene{Snap: snapPage, Href: "https://exemple.test/"}, + Tool: "browser_open", Args: a("url", "exemple.test")}) + run(parityBrowserStep{Name: "snapshot inchangé", Tool: "browser_snapshot"}) + run(parityBrowserStep{Name: "réouverture : page complète", Tool: "browser_open", Args: a("url", "https://exemple.test/")}) + run(parityBrowserStep{Name: "page vide", Scene: &parityCDPScene{Snap: snapEmpty, Href: "about:blank"}, Tool: "browser_snapshot"}) + run(parityBrowserStep{Name: "snapshot null", Scene: &parityCDPScene{Snap: nil, Href: "about:blank"}, Tool: "browser_snapshot"}) + run(parityBrowserStep{Name: "snapshot sans valeur", Scene: &parityCDPScene{Snap: ""}, Tool: "browser_snapshot"}) + run(parityBrowserStep{Name: "snapshot illisible", Scene: &parityCDPScene{Snap: "texte"}, Tool: "browser_snapshot"}) + run(parityBrowserStep{Name: "exception JS", Scene: &parityCDPScene{Throw: true}, Tool: "browser_snapshot"}) + run(parityBrowserStep{Name: "défilé", Scene: &parityCDPScene{Snap: snapScrolled, Href: "https://exemple.test/"}, + Tool: "browser_scroll", Args: a("direction", "down")}) + run(parityBrowserStep{Name: "défilement haut", Scene: &parityCDPScene{Snap: snapPage}, Tool: "browser_scroll", Args: a("direction", "UP")}) + run(parityBrowserStep{Name: "défilement gauche puis droite", Tool: "browser_scroll", Args: a("direction", "left")}) + run(parityBrowserStep{Name: "défilement par défaut", Tool: "browser_scroll", Args: a()}) + run(parityBrowserStep{Name: "direction inconnue", Tool: "browser_scroll", Args: a("direction", "diagonale")}) + run(parityBrowserStep{Name: "clic", Scene: &parityCDPScene{Snap: snapScrolled, Center: map[string]any{"x": 640, "y": 412.5}}, + Tool: "browser_click", Args: a("ref", 2.0)}) + run(parityBrowserStep{Name: "clic introuvable", Scene: &parityCDPScene{Snap: snapPage, Center: nil}, Tool: "browser_click", Args: a("ref", 9.0)}) + run(parityBrowserStep{Name: "clic sans ref", Tool: "browser_click", Args: a("ref", "2")}) + run(parityBrowserStep{Name: "clic refusé", Scene: &parityCDPScene{Snap: snapPage, Center: map[string]any{"x": 1, "y": 2}, + Errors: map[string]string{"Input.dispatchMouseEvent": "Cannot dispatch"}}, Tool: "browser_click", Args: a("ref", 1.0)}) + run(parityBrowserStep{Name: "clic en coordonnées", Scene: &parityCDPScene{Snap: snapPage}, Tool: "browser_click_xy", Args: a("x", 100.4, "y", 250.5)}) + run(parityBrowserStep{Name: "coordonnées manquantes", Tool: "browser_click_xy", Args: a("x", 10.0)}) + run(parityBrowserStep{Name: "saisie libre", Scene: &parityCDPScene{Snap: snapScrolled}, Tool: "browser_type", Args: a("text", "bonjour & co")}) + run(parityBrowserStep{Name: "saisie dans un champ", Scene: &parityCDPScene{Snap: snapPage, Typed: "TEXT"}, Tool: "browser_type", Args: a("text", "Jean", "ref", 3.0)}) + run(parityBrowserStep{Name: "liste déroulante", Scene: &parityCDPScene{Snap: snapScrolled, Typed: "SELECT"}, Tool: "browser_type", Args: a("text", "France", "ref", 5.0)}) + run(parityBrowserStep{Name: "option absente", Scene: &parityCDPScene{Typed: "NOOPT"}, Tool: "browser_type", Args: a("text", "Mars", "ref", 5.0)}) + run(parityBrowserStep{Name: "champ introuvable", Scene: &parityCDPScene{Typed: "NOTFOUND"}, Tool: "browser_type", Args: a("text", "x", "ref", 7.0)}) + run(parityBrowserStep{Name: "touche", Scene: &parityCDPScene{Snap: snapPage}, Tool: "browser_key", Args: a("key", " Enter ")}) + run(parityBrowserStep{Name: "touche esc", Tool: "browser_key", Args: a("key", "esc")}) + run(parityBrowserStep{Name: "touche inconnue", Tool: "browser_key", Args: a("key", "F13")}) + run(parityBrowserStep{Name: "recherche vide", Tool: "browser_find", Args: a("text", "")}) + run(parityBrowserStep{Name: "recherche", Scene: &parityCDPScene{Snap: snapPage, Find: map[string]any{"refs": []any{`[2] button "Créer un compte"`, `[7] link "Créer"`}}}, + Tool: "browser_find", Args: a("text", "créer ")}) + run(parityBrowserStep{Name: "recherche sans résultat", Scene: &parityCDPScene{Find: map[string]any{"refs": []any{}}}, Tool: "browser_find", Args: a("text", "zzz")}) + run(parityBrowserStep{Name: "navigation en échec", Scene: &parityCDPScene{Snap: snapPage, Href: "https://nulle-part.test/", NavError: "net::ERR_NAME_NOT_RESOLVED"}, + Tool: "browser_open", Args: a("url", "nulle-part.test")}) + run(parityBrowserStep{Name: "page d'erreur", Scene: &parityCDPScene{Snap: snapPage, Href: "chrome-error://chromewebdata/"}, + Tool: "browser_open", Args: a("url", "http://x.test")}) + run(parityBrowserStep{Name: "navigation interrompue bénigne", Scene: &parityCDPScene{Snap: snapPage, Href: "https://dl.test/f.zip", NavError: "net::ERR_ABORTED"}, + Tool: "browser_open", Args: a("url", "https://dl.test/f.zip")}) + run(parityBrowserStep{Name: "retour", Scene: &parityCDPScene{Snap: snapScrolled, Href: "https://exemple.test/"}, Tool: "browser_open", Args: a("url", " Back ")}) + run(parityBrowserStep{Name: "avance", Tool: "browser_open", Args: a("url", "forward")}) + run(parityBrowserStep{Name: "commande refusée", Scene: &parityCDPScene{Snap: snapPage, Errors: map[string]string{"Page.navigate": "Cannot navigate to invalid URL"}}, + Tool: "browser_open", Args: a("url", "https://exemple.test/")}) + run(parityBrowserStep{Name: "capture sans vision", Scene: &parityCDPScene{Snap: snapPage}, Tool: "browser_screenshot"}) + run(parityBrowserStep{Name: "capture", Vision: true, Tool: "browser_screenshot"}) + run(parityBrowserStep{Name: "capture refusée", Vision: true, Scene: &parityCDPScene{Errors: map[string]string{"Page.captureScreenshot": "Unable to capture"}}, Tool: "browser_screenshot"}) + long := []any{} + for i := 1; i <= 200; i++ { + long = append(long, fmt.Sprintf(`[%d] link "Un lien assez long pour remplir la sortie, numéro %d"`, i, i)) + } + run(parityBrowserStep{Name: "sortie plafonnée", Scene: &parityCDPScene{Snap: map[string]any{"title": "Long", "url": "https://long.test/", "refs": long}}, Tool: "browser_snapshot"}) + + // Le lancement : arguments du faux navigateur. + b, _ := os.ReadFile(argsFile) + launch := []string{} + for _, l := range strings.Split(strings.TrimRight(string(b), "\n"), "\n") { + if strings.HasPrefix(l, "--user-data-dir=") { + l = "--user-data-dir={{PROFIL}}" + } + launch = append(launch, l) + } + + var buf bytes.Buffer + enc := json.NewEncoder(&buf) + enc.SetEscapeHTML(false) + enc.SetIndent("", " ") + if err := enc.Encode(map[string]any{"launch": launch, "steps": steps}); err != nil { + t.Fatal(err) + } + got := buf.Bytes() + if os.Getenv("LOKI_PARITY_UPDATE") == "1" { + if err := os.MkdirAll(filepath.Dir(parityBrowserFile), 0o755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(parityBrowserFile, got, 0o644); err != nil { + t.Fatal(err) + } + return + } + want, err := os.ReadFile(parityBrowserFile) + if err != nil { + t.Fatalf("journal de référence absent (%v) : LOKI_PARITY_UPDATE=1 pour le créer", err) + } + if !bytes.Equal(got, want) { + t.Errorf("le navigateur piloté ne produit plus le journal rangé dans %s.\n"+ + "LOKI_PARITY_UPDATE=1 pour le régénérer si le changement est voulu.", parityBrowserFile) + } +} diff --git a/rust/loki-rs/src/browser/grid.rs b/rust/loki-rs/src/browser/grid.rs new file mode 100644 index 0000000..5ade864 --- /dev/null +++ b/rust/loki-rs/src/browser/grid.rs @@ -0,0 +1,126 @@ +//! Grille de coordonnées sur la capture envoyée au modèle — port de +//! `browser_grid.go`. Lignes graduées tous les 100 px et étiquettes en +//! police bitmap 3×5 : un petit modèle y lit x/y pour `browser_click_xy`. +//! Mêmes pixels que le Go (l'encodeur PNG, lui, diffère). + +use image::{ImageFormat, Rgba, RgbaImage}; + +/// `gridStep`. +const GRID_STEP: u32 = 100; + +/// `digits5x3`. +fn glyph(c: char) -> Option<[&'static str; 5]> { + Some(match c { + '0' => ["###", "# #", "# #", "# #", "###"], + '1' => [" # ", "## ", " # ", " # ", "###"], + '2' => ["###", " #", "###", "# ", "###"], + '3' => ["###", " #", "###", " #", "###"], + '4' => ["# #", "# #", "###", " #", " #"], + '5' => ["###", "# ", "###", " #", "###"], + '6' => ["###", "# ", "###", "# #", "###"], + '7' => ["###", " #", " #", " #", " #"], + '8' => ["###", "# #", "###", "# #", "###"], + '9' => ["###", "# #", "###", " #", "###"], + _ => return None, + }) +} + +const LINE: [u8; 4] = [255, 0, 200, 130]; +const LABEL_BG: [u8; 4] = [0, 0, 0, 200]; +const LABEL_FG: [u8; 4] = [255, 255, 0, 255]; + +/// `overlayGrid` : le PNG avec sa grille ; illisible, rendu tel quel. +pub fn overlay_grid(raw: &[u8]) -> Vec { + let Ok(src) = image::load_from_memory_with_format(raw, ImageFormat::Png) else { + return raw.to_vec(); + }; + let mut img = src.to_rgba8(); + draw(&mut img); + let mut out = std::io::Cursor::new(Vec::new()); + if img.write_to(&mut out, ImageFormat::Png).is_err() { + return raw.to_vec(); + } + out.into_inner() +} + +/// La grille et ses graduations, dessinées sur l'image. +pub fn draw(img: &mut RgbaImage) { + let (w, h) = img.dimensions(); + let mut x = GRID_STEP; + while x < w { + for y in 0..h { + blend(img, x as i64, y as i64, LINE); + } + draw_label(img, x as i64 + 2, 2, &x.to_string()); + x += GRID_STEP; + } + let mut y = GRID_STEP; + while y < h { + for x in 0..w { + blend(img, x as i64, y as i64, LINE); + } + draw_label(img, 2, y as i64 + 2, &y.to_string()); + y += GRID_STEP; + } +} + +fn inside(img: &RgbaImage, x: i64, y: i64) -> bool { + x >= 0 && y >= 0 && (x as u32) < img.width() && (y as u32) < img.height() +} + +/// `blend` : couleur translucide sur un pixel (opaque ensuite). +fn blend(img: &mut RgbaImage, x: i64, y: i64, c: [u8; 4]) { + if !inside(img, x, y) { + return; + } + let a = f64::from(c[3]) / 255.0; + let o = img.get_pixel(x as u32, y as u32).0; + let mix = |n: u8, o: u8| (f64::from(n) * a + f64::from(o) * (1.0 - a)) as u8; + img.put_pixel( + x as u32, + y as u32, + Rgba([mix(c[0], o[0]), mix(c[1], o[1]), mix(c[2], o[2]), 255]), + ); +} + +fn set_opaque(img: &mut RgbaImage, x: i64, y: i64, c: [u8; 4]) { + if inside(img, x, y) { + img.put_pixel(x as u32, y as u32, Rgba([c[0], c[1], c[2], 255])); + } +} + +/// `drawLabel` : un nombre en police 3×5 (×2) sur fond sombre. +fn draw_label(img: &mut RgbaImage, x: i64, y: i64, s: &str) { + const SC: i64 = 2; + let (glyph_w, glyph_h, gap) = (3 * SC, 5 * SC, SC); + let box_w = s.chars().count() as i64 * (glyph_w + gap) + gap; + let box_h = glyph_h + 2 * gap; + for dy in -gap..box_h - gap { + for dx in -gap..box_w - gap { + set_opaque(img, x + dx, y + dy, LABEL_BG); + } + } + let mut cx = x; + for c in s.chars() { + if let Some(g) = glyph(c) { + for (row, line) in g.iter().enumerate() { + for (col, b) in line.bytes().enumerate() { + if b != b'#' { + continue; + } + for iy in 0..SC { + for ix in 0..SC { + set_opaque( + img, + cx + col as i64 * SC + ix, + y + row as i64 * SC + iy, + LABEL_FG, + ); + } + } + } + } + } + cx += glyph_w + gap; + } +} diff --git a/rust/loki-rs/src/browser/js.rs b/rust/loki-rs/src/browser/js.rs new file mode 100644 index 0000000..19f2ac2 --- /dev/null +++ b/rust/loki-rs/src/browser/js.rs @@ -0,0 +1,69 @@ +//! Scripts injectés dans la page (copiés de `computer_cdp.go`, à l'octet près : +//! le journal de parité compare les expressions envoyées). + +/// `cdpSameTabJS` : les navigations « nouvel onglet » restent dans l'onglet. +pub const SAME_TAB_JS: &str = r#"(function(){try{ + window.open=function(u){if(u)location.href=u;return window;}; + document.addEventListener('click',function(e){ + var a=e.target&&e.target.closest&&e.target.closest('a[target]'); + if(a)a.removeAttribute('target'); + },true); +}catch(e){}})();"#; + +/// `cdpHelpersJS` : rôle, nom, actionnable, visible, shadow DOM. +pub const HELPERS_JS: &str = r#" +function ajRole(el){var r=el.getAttribute('role');if(r)return r;var t=el.tagName.toLowerCase();if(t==='a')return 'link';if(t==='button')return 'button';if(t==='select')return 'combobox';if(t==='textarea')return 'textbox';if(t==='input'){var ty=(el.type||'text').toLowerCase();var m={checkbox:'checkbox',radio:'radio',button:'button',submit:'button',reset:'button',file:'button',range:'slider',password:'password',search:'searchbox'};return m[ty]||'textbox';}return t;} +function ajName(el){var t=el.tagName.toLowerCase();var lbl=el.getAttribute('aria-label')||'';if(!lbl){var lb=el.getAttribute('aria-labelledby');if(lb){var e2=document.getElementById(lb.split(' ')[0]);if(e2)lbl=e2.innerText||'';}}if(!lbl&&el.labels&&el.labels.length)lbl=el.labels[0].innerText||'';if(!lbl)lbl=el.getAttribute('placeholder')||el.getAttribute('title')||el.getAttribute('alt')||'';var val='';if((t==='input'||t==='textarea')&&el.type!=='password'&&el.type!=='hidden')val=el.value||'';var n=val?(lbl?(lbl+' = '+val):val):lbl;if(!n&&t!=='input'&&t!=='select'&&t!=='textarea')n=el.innerText||'';if(!n){var c=el.querySelector('img[alt],[aria-label],[title],svg title');if(c)n=c.getAttribute&&(c.getAttribute('alt')||c.getAttribute('aria-label')||c.getAttribute('title'))||c.textContent||'';}if(!n)n=el.getAttribute('name')||el.getAttribute('value')||'';return (n||'').replace(/\s+/g,' ').trim().slice(0,100);} +function ajActionable(el){var t=el.tagName.toLowerCase();if(t==='a')return !!el.getAttribute('href');if(t==='button'||t==='input'||t==='select'||t==='textarea'||t==='summary')return el.type!=='hidden';if(el.isContentEditable)return true;if(el.getAttribute('onclick'))return true;var r=(el.getAttribute('role')||'').toLowerCase();return {button:1,link:1,checkbox:1,radio:1,tab:1,menuitem:1,menuitemcheckbox:1,menuitemradio:1,option:1,'switch':1,combobox:1,textbox:1,searchbox:1,slider:1,spinbutton:1}[r]===1;} +function ajVis(el){var r=el.getBoundingClientRect();if(r.width<3||r.height<3)return false;var s=getComputedStyle(el);if(s.visibility==='hidden'||s.display==='none'||s.opacity==='0')return false;if(r.bottom<0||r.top>innerHeight||r.right<0||r.left>innerWidth)return false;return true;} +var ajSel='a[href],button,input,select,textarea,[role],[onclick],[tabindex],summary,[contenteditable="true"]'; +// Traversée du SHADOW DOM : les bandeaux cookies et composants web (usercentrics, +// cookiebot…) vivent dans des shadow roots que querySelectorAll ne voit pas → le +// modèle ne pouvait ni les lister ni les cliquer (test Lady Sushi). ajQueryAll / +// ajByRef / ajClear descendent récursivement dans chaque shadowRoot ouvert. +function ajQueryAll(){var out=[];function w(r){var e=r.querySelectorAll(ajSel);for(var i=0;imaxRef)maxRef=v;} + var els=pool.filter(ajActionable); + var refs=[],count=0,first=null; + for(var k=0;k String { + format!("{HELPERS_JS}{SNAPSHOT_TAIL_JS}") +} diff --git a/rust/loki-rs/src/browser/mod.rs b/rust/loki-rs/src/browser/mod.rs new file mode 100644 index 0000000..b4e4f64 --- /dev/null +++ b/rust/loki-rs/src/browser/mod.rs @@ -0,0 +1,1114 @@ +//! Navigateur piloté (lot 44 de la migration) — port de `computer_cdp.go`, +//! `computer_use.go`, `browser_grid.go` et `cu_autoshot.go`. +//! +//! Un Chromium/Chrome local piloté par le protocole DevTools (CDP) : la +//! façade tient la session (une seule, lancée à la demande et gardée entre +//! les appels), exécute les outils `browser_*` de ses tours et garde +//! l'anneau des aperçus montrés à l'utilisateur. Le Go, quand elle tient la +//! conversation, lui passe ses propres appels (sous-agents, tâches IA, +//! ligne de commande) : un seul navigateur. + +pub mod grid; +pub mod js; + +use std::collections::HashMap; +use std::path::{Path, PathBuf}; +use std::sync::atomic::{AtomicBool, AtomicI64, Ordering}; +use std::sync::{Arc, Mutex}; +use std::time::{Duration, Instant}; + +use base64::Engine as _; +use futures_util::{SinkExt as _, StreamExt as _}; +use serde_json::{Map, Value, json}; +use tokio::sync::{mpsc, oneshot}; +use tokio_tungstenite::tungstenite::Message as WsMessage; + +/// `cuViewW`, `cuViewH` : le viewport (et donc la capture), fixe. +pub const VIEW_W: i64 = 1280; +pub const VIEW_H: i64 = 800; +/// `cuMaxOutput`. +pub const MAX_OUTPUT: usize = 8000; +/// `cuShotRing` : aperçus gardés en mémoire. +const SHOT_RING: usize = 8; + +/// Les outils servis ici. +pub fn is_tool(name: &str) -> bool { + matches!( + name, + "browser_open" + | "browser_snapshot" + | "browser_find" + | "browser_click" + | "browser_click_xy" + | "browser_type" + | "browser_key" + | "browser_scroll" + | "browser_screenshot" + ) +} + +/// `cuAutoShotTool` : les outils après lesquels la page est capturée pour +/// l'aperçu. +pub fn auto_shot_tool(name: &str) -> bool { + matches!( + name, + "browser_open" + | "browser_click" + | "browser_click_xy" + | "browser_type" + | "browser_key" + | "browser_scroll" + | "browser_find" + ) +} + +/// `capCUOutput`. +pub fn cap_output(s: &str) -> String { + match s.char_indices().nth(MAX_OUTPUT) { + None => s.to_string(), + Some((i, _)) => format!("{}\n…[tronqué]", &s[..i]), + } +} + +/// Un nombre comme `json.Marshal` l'écrit (`640`, pas `640.0`). +fn go_num(f: f64) -> Value { + if f.is_finite() && f.fract() == 0.0 && f.abs() < 1e15 { + json!(f as i64) + } else { + json!(f) + } +} + +// --- Session CDP --------------------------------------------------------------- + +type Reply = Result; + +/// `cdpSession` : le processus du navigateur, la connexion à la cible +/// « page » et le multiplexage requête/réponse. +pub struct Session { + child: Mutex>, + user_dir: PathBuf, + out: mpsc::UnboundedSender, + pending: Arc>>>, + next: AtomicI64, + /// Requêtes réseau en vol et dernier événement réseau (`settle`). + net: Arc)>>, + last_snap: Mutex, + closed: Arc, + tasks: Mutex>>, +} + +fn lock(m: &Mutex) -> std::sync::MutexGuard<'_, T> { + m.lock().unwrap_or_else(|e| e.into_inner()) +} + +/// `sweepStaleCUDirs` : les profils temporaires abandonnés (plus d'une heure). +fn sweep_stale(tmp: &Path) { + let Ok(rd) = std::fs::read_dir(tmp) else { + return; + }; + for e in rd.flatten() { + let name = e.file_name().to_string_lossy().into_owned(); + if !name.starts_with("loki-cu-") || !e.file_type().is_ok_and(|t| t.is_dir()) { + continue; + } + if e.metadata() + .and_then(|m| m.modified()) + .is_ok_and(|t| t.elapsed().is_ok_and(|d| d > Duration::from_secs(3600))) + { + let _ = std::fs::remove_dir_all(e.path()); + } + } +} + +/// `os.MkdirTemp(tmp, "loki-cu-")`. +fn mkdtemp(tmp: &Path) -> Result { + for _ in 0..16 { + let mut b = [0u8; 5]; + let _ = getrandom::fill(&mut b); + let n = u64::from_le_bytes([b[0], b[1], b[2], b[3], b[4], 0, 0, 0]) % 10_000_000_000; + let p = tmp.join(format!("loki-cu-{n}")); + match std::fs::create_dir(&p) { + Ok(()) => return Ok(p), + Err(e) if e.kind() == std::io::ErrorKind::AlreadyExists => continue, + Err(e) => { + return Err(crate::paths::os_err( + "mkdirtemp", + &tmp.join("loki-cu-*").to_string_lossy(), + &e, + )); + } + } + } + Err("mkdirtemp: trop d'essais".into()) +} + +/// Les arguments de lancement du navigateur. +pub fn launch_args(user_dir: &Path, headful: bool) -> Vec { + let mut a: Vec = Vec::new(); + if !headful { + a.push("--headless=new".into()); + } + a.extend([ + "--remote-debugging-port=0".to_string(), + format!("--user-data-dir={}", user_dir.to_string_lossy()), + "--no-first-run".into(), + "--no-default-browser-check".into(), + "--disable-gpu".into(), + "--disable-extensions".into(), + "--disable-background-networking".into(), + "--disable-features=Translate,BackForwardCache".into(), + "--no-sandbox".into(), + "--disable-dev-shm-usage".into(), + "--window-size=1280,900".into(), + "about:blank".into(), + ]); + a +} + +/// `cdpPageWS` : l'URL websocket d'une cible « page ». +async fn page_ws(port: &str) -> Result { + crate::chat::engine::install_crypto(); + let cl = reqwest::Client::builder() + .timeout(Duration::from_secs(5)) + .build() + .unwrap_or_default(); + let deadline = Instant::now() + Duration::from_secs(10); + while Instant::now() < deadline { + if let Ok(resp) = cl.get(format!("http://127.0.0.1:{port}/json")).send().await { + let body = resp.bytes().await.unwrap_or_default(); + let targets: Vec = serde_json::from_slice(&body).unwrap_or_default(); + for t in &targets { + let ws = t + .get("webSocketDebuggerUrl") + .and_then(Value::as_str) + .unwrap_or(""); + if t.get("type").and_then(Value::as_str) == Some("page") && !ws.is_empty() { + return Ok(ws.to_string()); + } + } + } + tokio::time::sleep(Duration::from_millis(150)).await; + } + Err("aucune cible 'page' exposée par Chrome".into()) +} + +impl Session { + /// `cdpLaunch`. + async fn launch(bin: &str, tmp: &Path) -> Result, String> { + if bin.is_empty() { + return Err("navigateur Chromium/Chrome/Edge introuvable — installe-le ou définis LOKI_CHROME=".into()); + } + sweep_stale(tmp); + let user_dir = mkdtemp(tmp)?; + let headful = std::env::var("LOKI_CU_HEADFUL").is_ok_and(|v| !v.is_empty()); + let mut cmd = tokio::process::Command::new(bin); + cmd.args(launch_args(&user_dir, headful)) + .stdin(std::process::Stdio::null()) + .stdout(std::process::Stdio::null()) + .stderr(std::process::Stdio::null()) + .kill_on_drop(true); + // Tué avec la façade, même brutalement (`prepareBoundChild`). + #[cfg(target_os = "linux")] + // SAFETY: prctl(2) est async-signal-safe, seul appel entre fork et exec. + unsafe { + cmd.pre_exec(|| { + libc::prctl(libc::PR_SET_PDEATHSIG, libc::SIGKILL); + Ok(()) + }); + } + let mut child = match cmd.spawn() { + Ok(c) => c, + Err(e) => { + let _ = std::fs::remove_dir_all(&user_dir); + return Err(crate::paths::os_err("fork/exec", bin, &e)); + } + }; + // Le port réel, en tête de DevToolsActivePort. + let port_file = user_dir.join("DevToolsActivePort"); + let deadline = Instant::now() + Duration::from_secs(15); + let mut port = String::new(); + while Instant::now() < deadline { + if let Ok(b) = std::fs::read_to_string(&port_file) { + let line = b.trim().split('\n').next().unwrap_or("").to_string(); + if !line.is_empty() { + port = line; + break; + } + } + tokio::time::sleep(Duration::from_millis(100)).await; + } + let fail = |child: &mut tokio::process::Child, e: String| { + let _ = child.start_kill(); + let _ = std::fs::remove_dir_all(&user_dir); + Err(e) + }; + if port.is_empty() { + return fail( + &mut child, + "Chrome n'a pas exposé son port de debug (DevToolsActivePort)".into(), + ); + } + let ws_url = match page_ws(&port).await { + Ok(u) => u, + Err(e) => return fail(&mut child, e), + }; + let cfg = tokio_tungstenite::tungstenite::protocol::WebSocketConfig::default() + .max_message_size(None) + .max_frame_size(None); + let ws = match tokio_tungstenite::connect_async_with_config(&ws_url, Some(cfg), false).await + { + Ok((ws, _)) => ws, + Err(e) => return fail(&mut child, format!("failed to WebSocket dial: {e}")), + }; + let (mut sink, mut stream) = ws.split(); + let (out, mut out_rx) = mpsc::unbounded_channel::(); + let pending: Arc>>> = Default::default(); + let net: Arc)>> = Default::default(); + let closed = Arc::new(AtomicBool::new(false)); + let writer = tokio::spawn(async move { + while let Some(m) = out_rx.recv().await { + if sink.send(WsMessage::Text(m.into())).await.is_err() { + break; + } + } + let _ = sink.close().await; + }); + let (p2, n2, c2) = (pending.clone(), net.clone(), closed.clone()); + let reader = tokio::spawn(async move { + while let Some(Ok(msg)) = stream.next().await { + let data = match msg { + WsMessage::Text(t) => t.as_bytes().to_vec(), + WsMessage::Binary(b) => b.to_vec(), + WsMessage::Close(_) => break, + _ => continue, + }; + let Ok(m) = serde_json::from_slice::(&data) else { + continue; + }; + let id = m.get("id").and_then(Value::as_i64).unwrap_or(0); + if id != 0 { + let tx = lock(&p2).remove(&id); + if let Some(tx) = tx { + let reply = match m.get("error").filter(|e| !e.is_null()) { + Some(e) => Err(e + .get("message") + .and_then(Value::as_str) + .unwrap_or("") + .to_string()), + None => Ok(m.get("result").cloned().unwrap_or(Value::Null)), + }; + let _ = tx.send(reply); + } + continue; + } + match m.get("method").and_then(Value::as_str) { + Some("Network.requestWillBeSent") => { + let mut n = lock(&n2); + n.0 += 1; + n.1 = Some(Instant::now()); + } + Some("Network.loadingFinished" | "Network.loadingFailed") => { + let mut n = lock(&n2); + if n.0 > 0 { + n.0 -= 1; + } + n.1 = Some(Instant::now()); + } + _ => {} + } + } + c2.store(true, Ordering::SeqCst); + lock(&p2).clear(); + }); + let s = Arc::new(Session { + child: Mutex::new(Some(child)), + user_dir, + out, + pending, + next: AtomicI64::new(0), + net, + last_snap: Mutex::new(String::new()), + closed, + tasks: Mutex::new(vec![writer, reader]), + }); + for m in [ + "Page.enable", + "Runtime.enable", + "DOM.enable", + "Network.enable", + ] { + let _ = s.call(m, None).await; + } + let _ = s + .call( + "Emulation.setDeviceMetricsOverride", + Some(json!({ "width": VIEW_W, "height": VIEW_H, "deviceScaleFactor": 1, "mobile": false })), + ) + .await; + let _ = s + .call( + "Page.addScriptToEvaluateOnNewDocument", + Some(json!({ "source": js::SAME_TAB_JS })), + ) + .await; + Ok(s) + } + + /// `call` : une commande et sa réponse (30 s au plus). + pub async fn call(&self, method: &str, params: Option) -> Result { + let id = self.next.fetch_add(1, Ordering::SeqCst) + 1; + let (tx, rx) = oneshot::channel(); + lock(&self.pending).insert(id, tx); + let mut payload = Map::new(); + payload.insert("id".into(), json!(id)); + payload.insert("method".into(), json!(method)); + if let Some(p) = params { + payload.insert("params".into(), p); + } + if self.closed.load(Ordering::SeqCst) + || self + .out + .send(crate::gojson::marshal(&Value::Object(payload))) + .is_err() + { + lock(&self.pending).remove(&id); + return Err("connexion CDP fermée".into()); + } + match tokio::time::timeout(Duration::from_secs(30), rx).await { + Ok(Ok(Ok(v))) => Ok(v), + Ok(Ok(Err(msg))) => Err(format!("{method}: {msg}")), + Ok(Err(_)) => Err("connexion CDP fermée".into()), + Err(_) => { + lock(&self.pending).remove(&id); + Err(format!("{method}: délai dépassé")) + } + } + } + + /// `close`. + fn close(&self) { + for t in lock(&self.tasks).drain(..) { + t.abort(); + } + if let Some(mut c) = lock(&self.child).take() { + let _ = c.start_kill(); + } + let _ = std::fs::remove_dir_all(&self.user_dir); + } + + /// `eval` : la valeur d'une expression (`None` : pas de valeur). + pub async fn eval(&self, expr: &str) -> Result, String> { + let res = self + .call( + "Runtime.evaluate", + Some(json!({ "expression": expr, "returnByValue": true, "awaitPromise": true })), + ) + .await?; + if let Some(d) = res.get("exceptionDetails").filter(|d| d.is_object()) { + return Err(format!( + "JS: {}", + d.get("text").and_then(Value::as_str).unwrap_or("") + )); + } + Ok(res.get("result").and_then(|r| r.get("value")).cloned()) + } + + /// `snapshot` : les éléments interactifs visibles, numérotés. + pub async fn snapshot(&self) -> Result { + let val = self.eval(&js::snapshot_js()).await?; + let snap = match val { + None => return Err("snapshot illisible: unexpected end of JSON input".into()), + Some(Value::Null) => Value::Object(Map::new()), + Some(o @ Value::Object(_)) => o, + Some(other) => { + return Err(format!( + "snapshot illisible: json: cannot unmarshal {} into Go value of type loki.cuSnap", + go_kind(&other) + )); + } + }; + let s = |k: &str| { + snap.get(k) + .and_then(Value::as_str) + .unwrap_or("") + .to_string() + }; + let n = |k: &str| snap.get(k).and_then(Value::as_i64).unwrap_or(0); + let refs: Vec = snap + .get("refs") + .and_then(Value::as_array) + .into_iter() + .flatten() + .map(|r| r.as_str().unwrap_or("").to_string()) + .collect(); + let mut b = format!("# {}\n{}\n\n", s("title"), s("url")); + if refs.is_empty() { + b.push_str("(aucun élément interactif détecté — page vide, en cours de chargement, ou rendue sur canvas ; réessaie browser_snapshot, ou utilise browser_screenshot pour voir)"); + } else { + b.push_str(&format!( + "{} élément(s) interactif(s) VISIBLE(s) — clique par numéro avec browser_click(ref) :\n", + refs.len() + )); + b.push_str(&refs.join("\n")); + } + let (above, below) = (n("above"), n("below")); + if below > 20 || above > 20 { + b.push_str("\n\n"); + if below > 20 { + b.push_str(&format!("↓ encore ~{below} px sous la vue — browser_scroll(down) pour révéler d'autres éléments. ")); + } + if above > 20 { + b.push_str(&format!( + "↑ ~{above} px au-dessus — browser_scroll(up) pour y revenir." + )); + } + } + Ok(b.trim_end_matches(' ').to_string()) + } + + /// `snapshotDedup` : une ligne courte si rien n'a changé. + pub async fn snapshot_dedup(&self) -> Result { + let full = self.snapshot().await?; + let mut last = lock(&self.last_snap); + if *last == full { + return Ok( + "(page inchangée depuis le dernier affichage — les mêmes numéros restent valides)" + .into(), + ); + } + *last = full.clone(); + Ok(full) + } + + /// `find` : les éléments dont le rôle ou le nom contient la requête. + pub async fn find(&self, query: &str) -> Result { + let expr = format!( + "{}({})({})", + js::HELPERS_JS, + js::FIND_JS, + crate::gojson::marshal(query) + ); + let val = self.eval(&expr).await?; + let refs: Vec = match val { + None => return Err("recherche illisible: unexpected end of JSON input".into()), + Some(v) => v + .get("refs") + .and_then(Value::as_array) + .into_iter() + .flatten() + .map(|r| r.as_str().unwrap_or("").to_string()) + .collect(), + }; + if refs.is_empty() { + return Ok(format!( + "Aucun élément contenant « {query} » sur cette page. Essaie un autre mot, ou c'est peut-être ailleurs (autre page/lien)." + )); + } + Ok(format!( + "{} élément(s) contenant « {query} » (le 1er est amené dans la vue) — clique par numéro :\n{}", + refs.len(), + refs.join("\n") + )) + } + + /// `waitLoad` : chargement fini, puis réseau au calme. + async fn wait_load(&self) { + let deadline = Instant::now() + Duration::from_secs(10); + while Instant::now() < deadline { + if let Ok(Some(Value::String(s))) = self.eval("document.readyState").await + && s == "complete" + { + break; + } + tokio::time::sleep(Duration::from_millis(150)).await; + } + self.settle().await; + } + + /// `settle` : plus de requête en vol depuis 600 ms (6 s au plus). + async fn settle(&self) { + let quiet = Duration::from_millis(600); + let deadline = Instant::now() + Duration::from_secs(6); + while Instant::now() < deadline { + let (inflight, last) = *lock(&self.net); + if inflight <= 0 && last.is_none_or(|t| t.elapsed() >= quiet) { + return; + } + tokio::time::sleep(Duration::from_millis(120)).await; + } + } + + /// `navigate` : une URL, « back » ou « forward ». + pub async fn navigate(&self, url: &str) -> Result<(), String> { + lock(&self.last_snap).clear(); + match url.trim().to_lowercase().as_str() { + "back" => { + let r = self.eval("history.back();'ok'").await; + self.wait_load().await; + return r.map(|_| ()); + } + "forward" => { + let r = self.eval("history.forward();'ok'").await; + self.wait_load().await; + return r.map(|_| ()); + } + _ => {} + } + let url = if url.contains("://") { + url.to_string() + } else { + format!("https://{url}") + }; + let res = self + .call("Page.navigate", Some(json!({ "url": url }))) + .await?; + let error_text = res + .get("errorText") + .and_then(Value::as_str) + .unwrap_or("") + .to_string(); + self.wait_load().await; + let loc = self + .eval("location.href") + .await + .ok() + .flatten() + .map(|v| v.to_string()) + .unwrap_or_default(); + let failed = loc.contains("chrome-error") + || (!error_text.is_empty() && error_text != "net::ERR_ABORTED"); + if failed { + let reason = if error_text.is_empty() { + "page injoignable".to_string() + } else { + error_text + }; + return Err(format!( + "chargement de {url} échoué ({reason}) — URL/hôte invalide, DNS, ou site bloqué ; corrige l'URL, essaie 'back', ou passe par un lien de la page" + )); + } + Ok(()) + } + + /// `refCenter` : le centre d'un élément marqué, amené au milieu. + async fn ref_center(&self, r: i64) -> Result<(f64, f64), String> { + let expr = format!( + "{}(function(){{var el=ajByRef({r});if(!el){{return null;}}el.scrollIntoView({{block:'center',inline:'center'}});var r=el.getBoundingClientRect();return {{x:r.left+r.width/2,y:r.top+r.height/2}};}})()", + js::HELPERS_JS + ); + let val = self.eval(&expr).await?; + let p = match val { + None | Some(Value::Null) => { + return Err(format!( + "élément [{r}] introuvable — refais un browser_snapshot (la page a peut-être changé)" + )); + } + Some(p) => p, + }; + let f = |k: &str| { + p.as_object() + .and_then(|o| { + o.get(k).or_else(|| { + o.iter() + .find(|(n, _)| n.eq_ignore_ascii_case(k)) + .map(|(_, v)| v) + }) + }) + .and_then(Value::as_f64) + .unwrap_or(0.0) + }; + Ok((f("x"), f("y"))) + } + + async fn mouse_click(&self, x: f64, y: f64) -> Result<(), String> { + for typ in ["mousePressed", "mouseReleased"] { + self.call( + "Input.dispatchMouseEvent", + Some(json!({ "type": typ, "x": go_num(x), "y": go_num(y), "button": "left", "clickCount": 1 })), + ) + .await?; + } + self.wait_load().await; + Ok(()) + } + + /// `clickRef`. + pub async fn click_ref(&self, r: i64) -> Result<(), String> { + let (x, y) = self.ref_center(r).await?; + self.mouse_click(x, y).await + } + + /// `clickXY`. + pub async fn click_xy(&self, x: f64, y: f64) -> Result<(), String> { + self.mouse_click(x, y).await + } + + /// `typeText`. + pub async fn type_text(&self, text: &str) -> Result<(), String> { + self.call("Input.insertText", Some(json!({ "text": text }))) + .await + .map(|_| ()) + } + + /// `typeInto` : un champ, ou l'option d'une liste déroulante native. + pub async fn type_into(&self, r: i64, text: &str) -> Result<(), String> { + let expr = format!( + r#"{}(function(ref,val){{ + var el=ajByRef(ref); + if(!el)return 'NOTFOUND'; + el.scrollIntoView({{block:'center'}}); + if(el.tagName.toLowerCase()==='select'){{ + var o=el.options,lv=val.toLowerCase(),k=-1; + for(var i=0;i=0||(o[i].value||'').toLowerCase()===lv){{k=i;break;}}}} + if(k<0)return 'NOOPT'; + el.selectedIndex=k;el.dispatchEvent(new Event('input',{{bubbles:true}}));el.dispatchEvent(new Event('change',{{bubbles:true}})); + return 'SELECT'; + }} + el.focus(); + try{{if(typeof el.select==='function')el.select();else if(window.getSelection){{var r=document.createRange();r.selectNodeContents(el);var s=getSelection();s.removeAllRanges();s.addRange(r);}}}}catch(e){{}} + return 'TEXT'; +}})({r},{})"#, + js::HELPERS_JS, + crate::gojson::marshal(text) + ); + let val = self.eval(&expr).await?; + let v = val.map(|v| v.to_string()).unwrap_or_default(); + match v.trim_matches('"') { + "NOTFOUND" => Err(format!( + "champ [{r}] introuvable — refais un browser_snapshot" + )), + "NOOPT" => Err(format!( + "aucune option de la liste [{r}] ne correspond à « {text} » — fais un browser_screenshot pour voir les choix" + )), + "SELECT" => Ok(()), + _ => self.type_text(text).await, + } + } + + /// `pressKey`. + pub async fn press_key(&self, name: &str) -> Result<(), String> { + let (key, code, vk) = match name.trim().to_lowercase().as_str() { + "enter" => ("Enter", "Enter", 13), + "tab" => ("Tab", "Tab", 9), + "backspace" => ("Backspace", "Backspace", 8), + "escape" | "esc" => ("Escape", "Escape", 27), + "delete" => ("Delete", "Delete", 46), + "up" => ("ArrowUp", "ArrowUp", 38), + "down" => ("ArrowDown", "ArrowDown", 40), + "left" => ("ArrowLeft", "ArrowLeft", 37), + "right" => ("ArrowRight", "ArrowRight", 39), + "home" => ("Home", "Home", 36), + "end" => ("End", "End", 35), + "pageup" => ("PageUp", "PageUp", 33), + "pagedown" => ("PageDown", "PageDown", 34), + _ => { + return Err(format!( + "touche inconnue « {name} » — connues : enter, tab, backspace, escape, delete, up/down/left/right, home, end, pageup, pagedown" + )); + } + }; + for typ in ["rawKeyDown", "keyUp"] { + self.call( + "Input.dispatchKeyEvent", + Some(json!({ "type": typ, "key": key, "code": code, + "windowsVirtualKeyCode": vk, "nativeVirtualKeyCode": vk })), + ) + .await?; + } + self.wait_load().await; + Ok(()) + } + + /// `scroll`. + pub async fn scroll(&self, dir: &str) -> Result<(), String> { + let (dx, dy) = match dir.trim().to_lowercase().as_str() { + "down" | "" => (0, 600), + "up" => (0, -600), + "right" => (600, 0), + "left" => (-600, 0), + _ => { + return Err(format!( + "direction inconnue « {dir} » (up, down, left, right)" + )); + } + }; + self.eval(&format!("window.scrollBy({dx},{dy});'ok'")) + .await?; + tokio::time::sleep(Duration::from_millis(200)).await; + Ok(()) + } + + /// `screenshot` : le viewport en PNG. + pub async fn screenshot(&self) -> Result, String> { + let res = self + .call("Page.captureScreenshot", Some(json!({ "format": "png" }))) + .await?; + let data = res.get("data").and_then(Value::as_str).unwrap_or(""); + base64::engine::general_purpose::STANDARD + .decode(data) + .map_err(|e| format!("illegal base64 data: {e}")) + } +} + +fn go_kind(v: &Value) -> &'static str { + match v { + Value::Null => "null", + Value::Bool(_) => "bool", + Value::Number(_) => "number", + Value::String(_) => "string", + Value::Array(_) => "array", + Value::Object(_) => "object", + } +} + +// --- Le navigateur de la façade ------------------------------------------------- + +/// Le résultat d'un outil du navigateur. +#[derive(Debug, Default, Clone, PartialEq)] +pub struct Outcome { + pub result: String, + /// `browser_screenshot` : la partie image relayée au modèle. + pub image: Option, +} + +/// L'anneau des aperçus : ordre d'arrivée, JPEG par identifiant. +type ShotRing = (Vec, HashMap>); + +/// La session du navigateur et l'anneau des aperçus. +#[derive(Default)] +pub struct Browser { + cur: tokio::sync::Mutex>>, + shots: Mutex, + /// Binaire et dossier des profils imposés (tests) ; sinon + /// `chrome_path` et le dossier temporaire du système. + imposed: Mutex<(Option, Option)>, +} + +impl Browser { + /// Pour les tests : binaire et dossier des profils imposés. + pub fn with(chrome: &str, tmp: PathBuf) -> Self { + let b = Browser::default(); + b.impose(chrome, tmp); + b + } + + /// Impose le binaire et le dossier des profils (tests). + pub fn impose(&self, chrome: &str, tmp: PathBuf) { + *lock(&self.imposed) = (Some(chrome.to_string()), Some(tmp)); + } + + /// Le dernier aperçu de l'anneau. + pub fn last_shot(&self) -> Option { + lock(&self.shots).0.last().cloned() + } +} + +/// Le navigateur de ce processus. +pub fn global() -> Arc { + static G: std::sync::OnceLock> = std::sync::OnceLock::new(); + G.get_or_init(Default::default).clone() +} + +fn int_arg(args: &Map, k: &str) -> Option { + args.get(k) + .and_then(Value::as_f64) + .map(crate::webtools::go_int) +} + +fn arg_str(args: &Map, k: &str) -> String { + args.get(k) + .and_then(Value::as_str) + .unwrap_or("") + .to_string() +} + +impl Browser { + /// `cdpGet` : la session, (re)lancée au besoin ; un ping vérifie + /// qu'une session existante répond encore. + pub async fn get(&self) -> Result, String> { + let mut cur = self.cur.lock().await; + if let Some(s) = cur.as_ref() { + if s.call( + "Runtime.evaluate", + Some(json!({ "expression": "1", "returnByValue": true })), + ) + .await + .is_ok() + { + return Ok(s.clone()); + } + s.close(); + *cur = None; + } + let (chrome, tmp) = lock(&self.imposed).clone(); + let bin = chrome.unwrap_or_else(crate::system::chrome_path); + let tmp = tmp.unwrap_or_else(std::env::temp_dir); + let s = Session::launch(&bin, &tmp).await?; + *cur = Some(s.clone()); + Ok(s) + } + + /// `cdpShutdown`. + pub async fn shutdown(&self) { + if let Some(s) = self.cur.lock().await.take() { + s.close(); + } + } + + /// Une session est-elle ouverte ? + pub async fn running(&self) -> bool { + self.cur.lock().await.is_some() + } + + /// Un outil `browser_*` (`None` pour un autre). `vision` : le modèle + /// voit-il les images ; `workspace` : où ranger la capture. + pub async fn exec( + &self, + name: &str, + args: &Map, + vision: bool, + workspace: &str, + ) -> Option { + let result = match name { + "browser_open" => self.tool_open(args).await, + "browser_snapshot" => self.tool_snapshot().await, + "browser_find" => self.tool_find(args).await, + "browser_click" => self.tool_click(args).await, + "browser_click_xy" => self.tool_click_xy(args).await, + "browser_type" => self.tool_type(args).await, + "browser_key" => self.tool_key(args).await, + "browser_scroll" => self.tool_scroll(args).await, + "browser_screenshot" => return Some(self.tool_screenshot(vision, workspace).await), + _ => return None, + }; + Some(Outcome { + result: cap_output(&result), + image: None, + }) + } + + async fn tool_open(&self, args: &Map) -> String { + let url = arg_str(args, "url"); + if url.trim().is_empty() { + return "[erreur] url manquante".into(); + } + let s = match self.get().await { + Ok(s) => s, + Err(e) => return format!("[erreur] {e}"), + }; + if let Err(e) = s.navigate(&url).await { + return format!("[erreur] navigation : {e}"); + } + s.snapshot_dedup() + .await + .unwrap_or_else(|e| format!("[erreur] {e}")) + } + + async fn tool_snapshot(&self) -> String { + let s = match self.get().await { + Ok(s) => s, + Err(e) => return format!("[erreur] {e}"), + }; + s.snapshot_dedup() + .await + .unwrap_or_else(|e| format!("[erreur] {e}")) + } + + async fn tool_find(&self, args: &Map) -> String { + let text = arg_str(args, "text"); + if text.trim().is_empty() { + return "[erreur] texte de recherche manquant".into(); + } + let s = match self.get().await { + Ok(s) => s, + Err(e) => return format!("[erreur] {e}"), + }; + s.find(&text) + .await + .unwrap_or_else(|e| format!("[erreur] {e}")) + } + + async fn tool_click(&self, args: &Map) -> String { + let Some(r) = int_arg(args, "ref") else { + return "[erreur] ref manquant (numéro de l'élément)".into(); + }; + let s = match self.get().await { + Ok(s) => s, + Err(e) => return format!("[erreur] {e}"), + }; + if let Err(e) = s.click_ref(r).await { + return format!("[erreur] {e}"); + } + let snap = s.snapshot_dedup().await.unwrap_or_default(); + format!("[ok] cliqué [{r}]\n\n{snap}") + } + + async fn tool_type(&self, args: &Map) -> String { + let text = arg_str(args, "text"); + let s = match self.get().await { + Ok(s) => s, + Err(e) => return format!("[erreur] {e}"), + }; + let r = match int_arg(args, "ref") { + Some(r) => s.type_into(r, &text).await, + None => s.type_text(&text).await, + }; + if let Err(e) = r { + return format!("[erreur] {e}"); + } + let snap = s.snapshot_dedup().await.unwrap_or_default(); + format!("[ok] saisi « {text} »\n\n{snap}") + } + + async fn tool_key(&self, args: &Map) -> String { + let key = arg_str(args, "key"); + let s = match self.get().await { + Ok(s) => s, + Err(e) => return format!("[erreur] {e}"), + }; + if let Err(e) = s.press_key(&key).await { + return format!("[erreur] {e}"); + } + let snap = s.snapshot_dedup().await.unwrap_or_default(); + format!("[ok] touche {key}\n\n{snap}") + } + + async fn tool_click_xy(&self, args: &Map) -> String { + let (Some(x), Some(y)) = ( + args.get("x").and_then(Value::as_f64), + args.get("y").and_then(Value::as_f64), + ) else { + return "[erreur] coordonnées x et y requises (en pixels du screenshot)".into(); + }; + let s = match self.get().await { + Ok(s) => s, + Err(e) => return format!("[erreur] {e}"), + }; + if let Err(e) = s.click_xy(x, y).await { + return format!("[erreur] {e}"); + } + let snap = s.snapshot_dedup().await.unwrap_or_default(); + format!("[ok] clic en ({x:.0}, {y:.0})\n\n{snap}") + } + + async fn tool_scroll(&self, args: &Map) -> String { + let dir = arg_str(args, "direction"); + let s = match self.get().await { + Ok(s) => s, + Err(e) => return format!("[erreur] {e}"), + }; + if let Err(e) = s.scroll(&dir).await { + return format!("[erreur] {e}"); + } + s.snapshot_dedup().await.unwrap_or_default() + } + + /// `toolCUScreenshot` : la capture, rangée propre dans le dossier de + /// travail ; celle du modèle porte la grille. + async fn tool_screenshot(&self, vision: bool, workspace: &str) -> Outcome { + let err = |e: String| Outcome { + result: format!("[erreur] {e}"), + image: None, + }; + if !vision { + return err( + "la vision n'est pas active sur ce modèle — impossible de capturer l'écran".into(), + ); + } + let s = match self.get().await { + Ok(s) => s, + Err(e) => return err(e), + }; + let png = match s.screenshot().await { + Ok(p) => p, + Err(e) => return err(e), + }; + let mut saved = String::new(); + let name = format!("capture-{}.png", local_stamp()); + if !workspace.is_empty() && std::fs::write(Path::new(workspace).join(&name), &png).is_ok() { + saved = name; + } + // 1280×800 : sous la taille maximale de `prepareImageForModel`, la + // capture quadrillée part telle quelle. + let b = grid::overlay_grid(&png); + let part = json!({ + "type": "image_url", + "image_url": { "url": format!("data:image/png;base64,{}", base64::engine::general_purpose::STANDARD.encode(&b)) }, + }); + let mut msg = format!( + "[ok] capture prise (tu la vois ci-dessous, {VIEW_W}x{VIEW_H} px, 1:1 avec la page). Une GRILLE de repères y est surimprimée (lignes + chiffres tous les 100 px) POUR TOI seulement, afin de viser précisément — le fichier enregistré, lui, est PROPRE (sans grille), donc l'aperçu ![capture] montré à l'utilisateur est net. Si un élément visible n'est PAS dans la liste numérotée (bandeau cookies en iframe, canvas), clique-le du premier coup avec browser_click_xy(x,y), coordonnées lues sur la grille." + ); + if !saved.is_empty() { + msg += &format!( + " Enregistrée sous « {saved} ». Pour l'AFFICHER à l'utilisateur dans le chat, insère-la en IMAGE Markdown : ![capture]({saved}). Un simple lien [capture]({saved}) ne donne qu'un bouton de téléchargement, sans aperçu. Ne cherche pas de fichier sur le disque, c'est celui-ci." + ); + } + Outcome { + result: msg, + image: Some(part), + } + } + + /// `cuAutoShot` : la page capturée pour l'aperçu (JPEG en mémoire) ; + /// "" si impossible, jamais bloquant. + pub async fn auto_shot(&self) -> String { + let s = match self.get().await { + Ok(s) => s, + Err(e) => { + eprintln!("[cu] aperçu : navigateur indisponible : {e}"); + return String::new(); + } + }; + let raw = match s.screenshot().await { + Ok(r) => r, + Err(e) => { + eprintln!("[cu] aperçu : capture impossible : {e}"); + return String::new(); + } + }; + let Ok(img) = image::load_from_memory(&raw) else { + eprintln!("[cu] aperçu : image illisible ({} octets)", raw.len()); + return String::new(); + }; + let mut out = std::io::Cursor::new(Vec::new()); + let enc = image::codecs::jpeg::JpegEncoder::new_with_quality(&mut out, 90); + if img.to_rgb8().write_with_encoder(enc).is_err() { + return String::new(); + } + let nanos = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .map(|d| d.as_nanos()) + .unwrap_or_default(); + let id = format!("{nanos:x}"); + let mut shots = lock(&self.shots); + shots.1.insert(id.clone(), out.into_inner()); + shots.0.push(id.clone()); + while shots.0.len() > SHOT_RING { + let old = shots.0.remove(0); + shots.1.remove(&old); + } + id + } + + /// `handleCUShot` : un aperçu encore dans l'anneau. + pub fn shot(&self, id: &str) -> Option> { + lock(&self.shots).1.get(id.trim()).cloned() + } +} + +/// `time.Now().Format("20060102-150405")`, heure locale. +fn local_stamp() -> String { + let sec = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .map(|d| d.as_secs() as i64) + .unwrap_or_default(); + let c = crate::tz::Location::local().civil(sec); + format!( + "{:04}{:02}{:02}-{:02}{:02}{:02}", + c.year, c.month, c.day, c.hour, c.minute, c.second + ) +} + +#[cfg(test)] +mod tests; diff --git a/rust/loki-rs/src/browser/tests.rs b/rust/loki-rs/src/browser/tests.rs new file mode 100644 index 0000000..850b30c --- /dev/null +++ b/rust/loki-rs/src/browser/tests.rs @@ -0,0 +1,103 @@ +use super::*; + +#[test] +fn outils_et_plafond() { + for n in [ + "browser_open", + "browser_snapshot", + "browser_find", + "browser_click", + "browser_click_xy", + "browser_type", + "browser_key", + "browser_scroll", + "browser_screenshot", + ] { + assert!(is_tool(n), "{n}"); + } + assert!(!is_tool("web_screenshot")); + assert!(!auto_shot_tool("browser_snapshot") && !auto_shot_tool("browser_screenshot")); + assert!(auto_shot_tool("browser_find")); + let s = "é".repeat(MAX_OUTPUT); + assert_eq!(cap_output(&s), s); + assert_eq!(cap_output(&format!("{s}x")), format!("{s}\n…[tronqué]")); +} + +#[test] +fn nombres_comme_le_go() { + assert_eq!(go_num(640.0).to_string(), "640"); + assert_eq!(go_num(412.5).to_string(), "412.5"); + assert_eq!(go_num(-3.0).to_string(), "-3"); +} + +#[test] +fn arguments_de_lancement() { + let a = launch_args(Path::new("/tmp/p"), false); + assert_eq!(a[0], "--headless=new"); + assert!(a.contains(&"--user-data-dir=/tmp/p".to_string())); + assert_eq!(a.last().unwrap(), "about:blank"); + let h = launch_args(Path::new("/tmp/p"), true); + assert_eq!(h.len(), a.len() - 1); + assert_eq!(h[0], "--remote-debugging-port=0"); +} + +/// La grille : lignes translucides tous les 100 px, étiquettes opaques, +/// rien d'autre de touché. +#[test] +fn grille() { + let mut img = image::RgbaImage::from_pixel(250, 150, image::Rgba([40, 80, 120, 255])); + grid::draw(&mut img); + let px = |x, y| img.get_pixel(x, y).0; + assert_eq!(px(0, 0), [40, 80, 120, 255]); + // 255×130/255 + 40×125/255 → 149 ; 0 + 80×(1−a) → 39 ; 200a + 120(1−a) → 160. + assert_eq!(px(100, 50), [149, 39, 160, 255]); + assert_eq!(px(200, 140), [149, 39, 160, 255]); + assert_eq!(px(50, 100), [149, 39, 160, 255]); + assert_eq!(px(101, 50), [40, 80, 120, 255]); + // « 100 » en haut de la ligne : fond sombre, chiffre jaune. + assert_eq!(px(100, 1), [0, 0, 0, 255]); + assert_eq!(px(104, 4), [255, 255, 0, 255]); + // Une image illisible passe telle quelle. + assert_eq!(grid::overlay_grid(b"pas une image"), b"pas une image"); +} + +#[test] +fn profils_temporaires() { + let tmp = tempfile::tempdir().unwrap(); + let p = mkdtemp(tmp.path()).unwrap(); + assert!( + p.file_name() + .unwrap() + .to_string_lossy() + .starts_with("loki-cu-") + ); + assert!(p.is_dir()); + // Récent : gardé ; un autre dossier : jamais touché. + std::fs::create_dir(tmp.path().join("autre")).unwrap(); + sweep_stale(tmp.path()); + assert!(p.is_dir() && tmp.path().join("autre").is_dir()); +} + +#[tokio::test] +async fn lancement_impossible() { + let tmp = tempfile::tempdir().unwrap(); + let b = Browser::with("", tmp.path().to_path_buf()); + let mut a = Map::new(); + a.insert("url".into(), json!("x.test")); + let o = b.exec("browser_open", &a, false, "").await.unwrap(); + // Sans binaire imposé ni trouvé, ou introuvable : l'erreur dit quoi faire. + assert!(o.result.starts_with("[erreur] "), "{}", o.result); + let b = Browser::with("/nulle/part/chrome", tmp.path().to_path_buf()); + let o = b + .exec("browser_snapshot", &Map::new(), false, "") + .await + .unwrap(); + assert_eq!( + o.result, + "[erreur] fork/exec /nulle/part/chrome: no such file or directory" + ); + // Le profil du lancement raté est retiré. + assert_eq!(std::fs::read_dir(tmp.path()).unwrap().count(), 0); + assert_eq!(b.exec("web_open", &a, false, "").await, None); + assert!(b.shot("rien").is_none()); +} diff --git a/rust/loki-rs/src/chat/generate.rs b/rust/loki-rs/src/chat/generate.rs index f76a266..5a84078 100644 --- a/rust/loki-rs/src/chat/generate.rs +++ b/rust/loki-rs/src/chat/generate.rs @@ -1324,6 +1324,7 @@ impl ChatShared { memory: None, web: Default::default(), jobs: crate::tools::jobs::global(), + browser: crate::browser::global(), go_version: Default::default(), } } diff --git a/rust/loki-rs/src/chat/host.rs b/rust/loki-rs/src/chat/host.rs index 22e6669..dd1c1f1 100644 --- a/rust/loki-rs/src/chat/host.rs +++ b/rust/loki-rs/src/chat/host.rs @@ -27,7 +27,7 @@ //! d'écriture du mode Code (état du Go) : `cache_hint` et `preflight` absents. use std::collections::{HashMap, VecDeque}; -use std::path::PathBuf; +use std::path::{Path, PathBuf}; use std::sync::atomic::{AtomicBool, Ordering}; use std::sync::{Arc, Mutex}; use std::time::Duration; @@ -116,6 +116,11 @@ pub struct ImageCache { } impl ImageCache { + /// `LOKI_HOME` (le parent du dossier des images). + pub fn home(&self) -> PathBuf { + self.dir.parent().map(Path::to_path_buf).unwrap_or_default() + } + /// `dir` : `LOKI_HOME/chatimg`. pub fn new(dir: PathBuf) -> Self { ImageCache { @@ -351,6 +356,8 @@ pub struct ChatShared { pub web: Arc, /// Les jobs d'arrière-plan du mode Code (lot 43). pub jobs: Arc, + /// Le navigateur piloté (lot 44). + pub browser: Arc, /// La version du Go, annoncée aux serveurs MCP à une reconnexion ; lue /// une fois. pub go_version: tokio::sync::OnceCell, @@ -709,6 +716,34 @@ impl TurnHost for GoTurnHost { ..Default::default() }; } + // Navigateur piloté (lot 44) : la session est ici ; aperçu de la page + // après une action qui la change (jamais après une erreur), capture + // relayée au modèle. + if crate::browser::is_tool(name) { + let cfg = self.shared.store.config().await; + let vision = crate::system::vision_enabled(&cfg); + let home = self.shared.images.home(); + let ws = tokio::task::spawn_blocking(move || { + let env = std::env::var(crate::paths::WORKSPACE_ENV).ok(); + crate::paths::agent_workspace(&home, env.as_deref()) + }) + .await + .unwrap_or_default(); + if let Some(o) = self.shared.browser.exec(name, args, vision, &ws).await { + let live_shot = + if !o.result.starts_with("[erreur]") && crate::browser::auto_shot_tool(name) { + self.shared.browser.auto_shot().await + } else { + String::new() + }; + return ToolOutcome { + relay: o.image.map(|img| see_image_message(label, img)), + result: o.result, + live_shot, + ..Default::default() + }; + } + } // Jobs d'arrière-plan du mode Code (lot 43), dans le dossier de la // discussion ; contexte indisponible : le Go s'en charge. if name == "bash_tail" { diff --git a/rust/loki-rs/src/chatapi.rs b/rust/loki-rs/src/chatapi.rs index 719b9a9..d65a37e 100644 --- a/rust/loki-rs/src/chatapi.rs +++ b/rust/loki-rs/src/chatapi.rs @@ -52,6 +52,7 @@ pub fn handles(path: &str) -> bool { | "/api/conversations/rename" | "/api/conversations/delete" | "/api/perf/summary" + | "/api/chat/cushot" ) } @@ -72,6 +73,7 @@ pub fn is_internal(path: &str) -> bool { | "/internal/facade/v1/perf/record" | "/internal/facade/v1/perf/snapshot" | "/internal/facade/v1/bgjobs" + | "/internal/facade/v1/browser" ) } @@ -137,6 +139,49 @@ pub async fn serve_internal(chat: &Arc, req: Request) -> }, // L'anneau, de la plus ancienne à la plus récente (poids de // l'optimiseur). + // Navigateur piloté (lot 44) : les appels du Go (sous-agents, tâches + // IA) passent par la session tenue ici — un seul navigateur. + "/internal/facade/v1/browser" => { + let v = read_json(req.into_body()).await.unwrap_or_default(); + let b = &chat.shared.browser; + match str_of(&v, "op") { + "tool" => { + let cfg = chat.shared.store.config().await; + let vision = crate::system::vision_enabled(&cfg); + let home = chat.shared.images.home(); + let ws = tokio::task::spawn_blocking(move || { + let env = std::env::var(crate::paths::WORKSPACE_ENV).ok(); + crate::paths::agent_workspace(&home, env.as_deref()) + }) + .await + .unwrap_or_default(); + let args = v + .get("args") + .and_then(Value::as_object) + .cloned() + .unwrap_or_default(); + match b.exec(str_of(&v, "name"), &args, vision, &ws).await { + Some(o) => send_json( + StatusCode::OK, + &json!({ "result": o.result, "image": o.image }), + ), + None => send_json( + StatusCode::BAD_REQUEST, + &json!({ "error": "outil du navigateur inconnu" }), + ), + } + } + "shot" => send_json(StatusCode::OK, &json!({ "id": b.auto_shot().await })), + "shutdown" => { + b.shutdown().await; + send_json(StatusCode::OK, &json!({})) + } + _ => send_json( + StatusCode::BAD_REQUEST, + &json!({ "error": "opération inconnue" }), + ), + } + } // Jobs d'arrière-plan encore en vie (lot 43) : l'optimiseur du Go // refuse de mesurer à côté. "/internal/facade/v1/bgjobs" => send_json( @@ -276,6 +321,22 @@ pub async fn serve( chat.stop().await; ok() } + // `handleCUShot` (lot 44) : un aperçu du navigateur piloté, en base64 + // (JSON, pour traverser aussi le tunnel du relais). + "/api/chat/cushot" => { + let id = query_param(&parts.uri, "id").unwrap_or_default(); + match chat.shared.browser.shot(&id) { + Some(b) => send_json( + StatusCode::OK, + &json!({ "ok": true, "mime": "image/jpeg", + "data": base64::Engine::encode(&base64::engine::general_purpose::STANDARD, b) }), + ), + None => send_json( + StatusCode::NOT_FOUND, + &json!({ "ok": false, "error": "capture expirée" }), + ), + } + } "/api/chat/reset" => { chat.reset().await; ok() diff --git a/rust/loki-rs/src/lib.rs b/rust/loki-rs/src/lib.rs index 3faa175..9f55911 100644 --- a/rust/loki-rs/src/lib.rs +++ b/rust/loki-rs/src/lib.rs @@ -14,6 +14,7 @@ pub mod api; pub mod auth; pub mod backends; pub mod bench; +pub mod browser; pub mod chat; pub mod chatapi; pub mod chatx; diff --git a/rust/loki-rs/src/system.rs b/rust/loki-rs/src/system.rs index ba39da7..cc95a56 100644 --- a/rust/loki-rs/src/system.rs +++ b/rust/loki-rs/src/system.rs @@ -13,9 +13,10 @@ //! //! Les réglages restent des clés de la base. Ce qui vit dans le processus Go //! reste au Go : son cache de joignabilité (vidé par -//! `/internal/v1/web/reach/reset`), le Chromium des outils `browser_*` -//! (`/internal/v1/cdp/shutdown`) et le redémarrage du moteur quand la façade ne -//! le pilote pas (`/internal/v1/service/action`). +//! `/internal/v1/web/reach/reset`), le Chromium qu'il pilote quand la façade +//! ne tient pas la conversation (`/internal/v1/cdp/shutdown` ; sinon le +//! navigateur est celui de la façade, lot 44) et le redémarrage du moteur +//! quand la façade ne le pilote pas (`/internal/v1/service/action`). use std::time::{Duration, Instant}; @@ -528,8 +529,11 @@ pub async fn serve( if let Err(e) = put_bool(store, "computer", on).await { return fail(StatusCode::INTERNAL_SERVER_ERROR, e); } - // Couper l'interrupteur ferme le navigateur (au Go, qui le - // pilote pour les outils browser_*). + // Couper l'interrupteur ferme le navigateur : celui de la + // façade (lot 44), et celui du Go s'il en a ouvert un. + if !on { + crate::browser::global().shutdown().await; + } if !on && let Err(e) = store .internal_post("/internal/v1/cdp/shutdown", &json!({})) diff --git a/rust/loki-rs/tests/browser.rs b/rust/loki-rs/tests/browser.rs new file mode 100644 index 0000000..5aeea4d --- /dev/null +++ b/rust/loki-rs/tests/browser.rs @@ -0,0 +1,250 @@ +//! Lot 44 : navigateur piloté (outils `browser_*`) tenu par la façade, +//! rejoué sur le journal du Go (`internal/loki/parity_browser_test.go` → +//! `tests/parity_browser/browser.json`) contre le même faux Chrome : un +//! script qui annonce le port d'un faux serveur CDP, qui répond selon le +//! scénario de chaque étape et note chaque commande reçue. +#![cfg(target_os = "linux")] + +mod common; + +use std::sync::{Arc, Mutex}; + +use common::fakecdp::{self, Calls, Scene, image_facts}; +use loki_rs::browser::Browser; +use regex::Regex; +use serde_json::{Map, Value, json}; +use tokio::io::{AsyncReadExt as _, AsyncWriteExt as _}; +use tokio::net::TcpListener; + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn navigateur_comme_le_go() { + let fx: Value = + serde_json::from_str(include_str!("parity_browser/browser.json")).expect("journal"); + let tmp = tempfile::tempdir().unwrap(); + let args_file = tmp.path().join("args"); + let ws = tmp.path().join("ws"); + let profiles = tmp.path().join("profils"); + std::fs::create_dir_all(&ws).unwrap(); + std::fs::create_dir_all(&profiles).unwrap(); + let scene: Scene = Arc::new(Mutex::new(json!({}))); + let calls: Calls = Default::default(); + let port = fakecdp::start(scene.clone(), calls.clone()).await; + let chrome = fakecdp::chrome_script(tmp.path(), port, &args_file); + let browser = Browser::with(&chrome.to_string_lossy(), profiles.clone()); + let cap = Regex::new(r"capture-[0-9]{8}-[0-9]{6}\.png").unwrap(); + for st in fx["steps"].as_array().unwrap() { + let name = st["name"].as_str().unwrap(); + if let Some(sc) = st.get("scene") { + *scene.lock().unwrap() = sc.clone(); + } + calls.lock().unwrap().clear(); + let args: Map = serde_json::from_value(st["args"].clone()).unwrap(); + let o = browser + .exec( + st["tool"].as_str().unwrap(), + &args, + st["vision"] == true, + &ws.to_string_lossy(), + ) + .await + .expect("un outil du navigateur"); + assert_eq!( + cap.replace_all(&o.result, "capture-{{T}}.png"), + st["result"].as_str().unwrap(), + "{name}" + ); + assert_eq!( + o.image.as_ref().map(image_facts), + st.get("image").cloned(), + "{name} : image" + ); + let mut saved: Vec = std::fs::read_dir(&ws) + .unwrap() + .flatten() + .map(|e| { + let n = e.file_name().to_string_lossy().into_owned(); + let _ = std::fs::remove_file(e.path()); + cap.replace_all(&n, "capture-{{T}}.png").into_owned() + }) + .collect(); + saved.sort(); + assert_eq!(json!(saved), st["saved"], "{name} : captures rangées"); + assert_eq!( + json!(*calls.lock().unwrap()), + st["calls"], + "{name} : commandes CDP" + ); + } + let launch: Vec = std::fs::read_to_string(&args_file) + .unwrap() + .trim_end_matches('\n') + .split('\n') + .map(|l| { + if l.starts_with("--user-data-dir=") { + "--user-data-dir={{PROFIL}}".into() + } else { + l.to_string() + } + }) + .collect(); + assert_eq!(json!(launch), fx["launch"], "lancement"); + + // Aperçu : une capture JPEG gardée dans l'anneau, servie par son id. + let id = browser.auto_shot().await; + assert!(!id.is_empty()); + let jpeg = browser.shot(&id).unwrap(); + assert_eq!(&jpeg[..2], &[0xff, 0xd8], "JPEG"); + assert_eq!( + image::load_from_memory(&jpeg) + .unwrap() + .to_rgb8() + .dimensions(), + (1280, 800) + ); + // Fermeture : le profil temporaire part avec le navigateur. + assert_eq!(std::fs::read_dir(&profiles).unwrap().count(), 1); + browser.shutdown().await; + assert_eq!(std::fs::read_dir(&profiles).unwrap().count(), 0); + assert!(!browser.running().await); +} + +/// Un vrai Chromium, de bout en bout (comme `TestCDPSmoke`) : page locale, +/// éléments numérotés, recherche, saisie, clic qui change la page, capture +/// quadrillée, aperçu. Seulement avec `LOKI_CU_SMOKE=1` et un navigateur. +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn vrai_navigateur() { + if std::env::var("LOKI_CU_SMOKE").as_deref() != Ok("1") { + eprintln!("LOKI_CU_SMOKE=1 pour piloter un vrai navigateur"); + return; + } + let bin = loki_rs::system::chrome_path(); + if bin.is_empty() { + eprintln!("aucun navigateur"); + return; + } + let page = r#"Banc Loki +

Bonjour

+Voir la suite + + + +
Tout en bas +"#; + let l = TcpListener::bind("127.0.0.1:0").await.unwrap(); + let addr = l.local_addr().unwrap(); + tokio::spawn(async move { + while let Ok((mut st, _)) = l.accept().await { + let mut buf = vec![0u8; 4096]; + let n = st.read(&mut buf).await.unwrap_or(0); + let req = String::from_utf8_lossy(&buf[..n]); + let body = if req.starts_with("GET /suite") { + "SuiteRetour" + } else { + page + }; + let _ = st + .write_all( + format!("HTTP/1.1 200 OK\r\nContent-Type: text/html; charset=utf-8\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{body}", body.len()).as_bytes(), + ) + .await; + } + }); + let tmp = tempfile::tempdir().unwrap(); + let b = Browser::with(&bin, tmp.path().to_path_buf()); + let ws = tmp.path().to_string_lossy().into_owned(); + let run = |name: &'static str, args: Value| { + let b = &b; + let ws = ws.clone(); + async move { + let args: Map = serde_json::from_value(args).unwrap(); + b.exec(name, &args, true, &ws).await.unwrap() + } + }; + let open = run("browser_open", json!({ "url": format!("http://{addr}/") })).await; + assert!(open.result.starts_with("# Banc Loki\n"), "{}", open.result); + assert!( + open.result.contains(r#"link "Voir la suite""#), + "{}", + open.result + ); + assert!(open.result.contains("↓ encore ~"), "{}", open.result); + let find = run("browser_find", json!({ "text": "tout en bas" })).await; + assert!( + find.result.contains(r#"link "Tout en bas""#), + "{}", + find.result + ); + let snap = run("browser_snapshot", json!({})).await; + let re = Regex::new(r#"\[(\d+)\] textbox "Nom""#).unwrap(); + let typed = if let Some(c) = re.captures(&snap.result) { + run( + "browser_type", + json!({ "text": "Jean", "ref": c[1].parse::().unwrap() }), + ) + .await + } else { + let open = run("browser_open", json!({ "url": format!("http://{addr}/") })).await; + let c = re.captures(&open.result).expect("champ Nom"); + run( + "browser_type", + json!({ "text": "Jean", "ref": c[1].parse::().unwrap() }), + ) + .await + }; + assert!( + typed.result.contains(r#"textbox "Nom = Jean""#), + "{}", + typed.result + ); + let sel = Regex::new(r#"\[(\d+)\] combobox"#).unwrap(); + let c = sel.captures(&typed.result).expect("liste"); + let chosen = run( + "browser_type", + json!({ "text": "fra", "ref": c[1].parse::().unwrap() }), + ) + .await; + assert!( + chosen.result.starts_with("[ok] saisi « fra »"), + "{}", + chosen.result + ); + // Le lien est hors de la vue depuis le défilement de browser_find. + let found = run("browser_find", json!({ "text": "voir la suite" })).await; + let link = Regex::new(r#"\[(\d+)\] link "Voir la suite""#).unwrap(); + let c = link.captures(&found.result).expect("lien"); + let click = run( + "browser_click", + json!({ "ref": c[1].parse::().unwrap() }), + ) + .await; + assert!(click.result.contains("# Suite\n"), "{}", click.result); + let back = run("browser_open", json!({ "url": "back" })).await; + assert!(back.result.starts_with("# Banc Loki"), "{}", back.result); + let shot = run("browser_screenshot", json!({})).await; + assert!( + shot.result.starts_with("[ok] capture prise"), + "{}", + shot.result + ); + let facts = image_facts(shot.image.as_ref().unwrap()); + assert_eq!( + (facts["w"].clone(), facts["h"].clone()), + (json!(1280), json!(800)) + ); + assert!( + std::fs::read_dir(tmp.path()) + .unwrap() + .flatten() + .any(|e| e.file_name().to_string_lossy().starts_with("capture-")) + ); + let id = b.auto_shot().await; + assert!(b.shot(&id).is_some()); + let bad = run("browser_open", json!({ "url": "http://127.0.0.1:9/" })).await; + assert!( + bad.result + .starts_with("[erreur] navigation : chargement de http://127.0.0.1:9/ échoué"), + "{}", + bad.result + ); + b.shutdown().await; +} diff --git a/rust/loki-rs/tests/common/fakecdp.rs b/rust/loki-rs/tests/common/fakecdp.rs new file mode 100644 index 0000000..ba68c48 --- /dev/null +++ b/rust/loki-rs/tests/common/fakecdp.rs @@ -0,0 +1,222 @@ +//! Faux Chrome pour les tests du navigateur piloté (lot 44) : un script qui +//! annonce le port d'un faux serveur CDP dans DevToolsActivePort, et ce +//! serveur, qui répond selon un scénario et note chaque commande reçue +//! (comme `parity_browser_test.go`). +#![allow(dead_code)] + +use std::path::{Path, PathBuf}; +use std::sync::{Arc, Mutex}; + +use base64::Engine as _; +use futures_util::{SinkExt as _, StreamExt as _}; +use serde_json::{Map, Value, json}; +use sha2::Digest as _; +use tokio::io::{AsyncReadExt as _, AsyncWriteExt as _}; +use tokio::net::TcpListener; +use tokio_tungstenite::tungstenite::Message; + +/// Le faux navigateur : il annonce `port`, range ses arguments dans +/// `args`, et attend. +pub fn chrome_script(dir: &Path, port: u16, args: &Path) -> PathBuf { + let p = dir.join("chrome"); + let script = format!( + r#"#!/bin/sh +for a in "$@"; do + case "$a" in + --user-data-dir=*) dir="${{a#--user-data-dir=}}" ;; + esac +done +printf '%s\n/devtools/browser/faux\n' '{port}' > "$dir/DevToolsActivePort" +printf '%s\n' "$@" > '{}' +exec sleep 600 +"#, + args.display() + ); + std::fs::write(&p, script).unwrap(); + use std::os::unix::fs::PermissionsExt as _; + std::fs::set_permissions(&p, std::fs::Permissions::from_mode(0o755)).unwrap(); + p +} + +/// `parityCDPAbbrev`. +pub fn abbrev(s: &str) -> String { + if s.len() <= 60 { + return s.to_string(); + } + let h = sha2::Sha256::digest(s.as_bytes()); + let head: String = s.chars().take(40).collect(); + format!("{head}…[{}:{}]", s.len(), hex::encode(&h[..6])) +} + +/// `parityCDPCall`. +pub fn call_line(method: &str, params: Option<&Value>) -> String { + let Some(Value::Object(p)) = params else { + return method.to_string(); + }; + let mut q = Map::new(); + for (k, v) in p { + let v = match v { + Value::String(s) if k == "expression" || k == "source" => json!(abbrev(s)), + v => v.clone(), + }; + q.insert(k.clone(), v); + } + format!("{method} {}", loki_rs::gojson::marshal(&Value::Object(q))) +} + +/// La capture unie du faux CDP (`parityPNG`). +pub fn png(w: u32, h: u32) -> String { + let img = image::RgbaImage::from_pixel(w, h, image::Rgba([40, 80, 120, 255])); + let mut out = std::io::Cursor::new(Vec::new()); + img.write_to(&mut out, image::ImageFormat::Png).unwrap(); + base64::engine::general_purpose::STANDARD.encode(out.into_inner()) +} + +pub type Scene = Arc>; +pub type Calls = Arc>>; + +/// La réponse du faux CDP à une commande (`reply` du journal Go). +pub fn reply(scene: &Value, method: &str, params: &Value) -> Result { + if let Some(msg) = scene["errors"].get(method).and_then(Value::as_str) { + return Err(msg.to_string()); + } + let val = |v: &Value| { + if v == "" { + json!({ "result": { "type": "undefined" } }) + } else { + json!({ "result": { "type": "object", "value": v } }) + } + }; + match method { + "Runtime.evaluate" => { + let expr = params["expression"].as_str().unwrap_or(""); + match expr { + "1" => return Ok(val(&json!(1))), + "document.readyState" => return Ok(val(&json!("complete"))), + "location.href" => return Ok(val(&json!(scene["href"].as_str().unwrap_or("")))), + _ => {} + } + if scene["throw"] == true { + return Ok(json!({ "result": { "type": "object" }, + "exceptionDetails": { "text": "Uncaught TypeError: x is not a function" } })); + } + let pick = |k: &str| scene.get(k).cloned().unwrap_or(Value::Null); + Ok(if expr.contains("window.__lokiRefs=map") { + val(&pick("snap")) + } else if expr.contains("var pool=ajQueryAll();") { + val(&pick("find")) + } else if expr.contains("inline:'center'") { + val(&pick("center")) + } else if expr.contains("return 'TEXT';") { + val(&pick("typed")) + } else if expr.starts_with("history.") || expr.starts_with("window.scrollBy(") { + val(&json!("ok")) + } else { + val(&json!("")) + }) + } + "Page.navigate" => { + let mut r = json!({ "frameId": "F1", "loaderId": "L1" }); + if let Some(e) = scene["nav_error"].as_str().filter(|e| !e.is_empty()) { + r["errorText"] = json!(e); + } + Ok(r) + } + "Page.captureScreenshot" => Ok(json!({ "data": png(1280, 800) })), + _ => Ok(json!({})), + } +} + +/// Le faux serveur CDP : `/json` puis la cible « page » en websocket. +pub async fn start(scene: Scene, calls: Calls) -> u16 { + let l = TcpListener::bind("127.0.0.1:0").await.unwrap(); + let port = l.local_addr().unwrap().port(); + tokio::spawn(async move { + while let Ok((mut st, _)) = l.accept().await { + let (scene, calls) = (scene.clone(), calls.clone()); + tokio::spawn(async move { + let mut head = [0u8; 16]; + let n = st.peek(&mut head).await.unwrap_or(0); + if head[..n].starts_with(b"GET /json") { + let mut buf = vec![0u8; 4096]; + let _ = st.read(&mut buf).await; + let body = json!([ + { "type": "service_worker", "webSocketDebuggerUrl": format!("ws://127.0.0.1:{port}/devtools/sw/0") }, + { "type": "page", "webSocketDebuggerUrl": format!("ws://127.0.0.1:{port}/devtools/page/1") }, + ]) + .to_string(); + let _ = st + .write_all( + format!( + "HTTP/1.1 200 OK\r\nContent-Type: application/json\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{body}", + body.len() + ) + .as_bytes(), + ) + .await; + return; + } + let Ok(mut ws) = tokio_tungstenite::accept_async(st).await else { + return; + }; + while let Some(Ok(m)) = ws.next().await { + let Message::Text(t) = m else { continue }; + let v: Value = serde_json::from_str(t.as_str()).unwrap_or_default(); + let method = v["method"].as_str().unwrap_or("").to_string(); + calls + .lock() + .unwrap() + .push(call_line(&method, v.get("params"))); + let sc = scene.lock().unwrap().clone(); + let out = match reply(&sc, &method, &v["params"]) { + Ok(r) => json!({ "id": v["id"], "result": r }), + Err(e) => { + json!({ "id": v["id"], "error": { "code": -32000, "message": e } }) + } + }; + if ws + .send(Message::Text(out.to_string().into())) + .await + .is_err() + { + return; + } + } + }); + } + }); + port +} + +/// Dimensions et pixels témoins de l'image relayée (`parityImageFacts`). +pub fn image_facts(part: &Value) -> Value { + let u = part["image_url"]["url"].as_str().unwrap(); + let raw = base64::engine::general_purpose::STANDARD + .decode(u.strip_prefix("data:image/png;base64,").unwrap()) + .unwrap(); + let img = image::load_from_memory(&raw).unwrap().to_rgba8(); + let (w, h) = img.dimensions(); + let mut px = Map::new(); + for (x, y) in [ + (0, 0), + (100, 50), + (101, 50), + (50, 100), + (2, 2), + (104, 4), + (110, 12), + (300, 799), + (1279, 700), + (4, 204), + ] { + if x >= w || y >= h { + continue; + } + let p = img.get_pixel(x, y).0; + px.insert( + format!("{x},{y}"), + json!(format!("{},{},{},{}", p[0], p[1], p[2], p[3])), + ); + } + json!({ "w": w, "h": h, "px": px }) +} diff --git a/rust/loki-rs/tests/common/mod.rs b/rust/loki-rs/tests/common/mod.rs index 4060910..a8f4bd5 100644 --- a/rust/loki-rs/tests/common/mod.rs +++ b/rust/loki-rs/tests/common/mod.rs @@ -2,6 +2,7 @@ //! journalise les chemins demandés. #![allow(dead_code)] +pub mod fakecdp; pub mod fakego; pub mod parity; pub mod toolsfx; diff --git a/rust/loki-rs/tests/lot6.rs b/rust/loki-rs/tests/lot6.rs index 353c4c3..0028010 100644 --- a/rust/loki-rs/tests/lot6.rs +++ b/rust/loki-rs/tests/lot6.rs @@ -673,6 +673,7 @@ async fn tour_complet_par_le_go() { memory: None, web: Default::default(), jobs: Default::default(), + browser: Default::default(), go_version: Default::default(), }); let cfg = std::collections::HashMap::new(); @@ -2840,3 +2841,120 @@ async fn job_arriere_plan_par_la_facade() { } assert_eq!(jobs.running(), 0); } + +/// Lot 44 : le navigateur piloté est celui de la façade. Un tour qui ouvre +/// une page l'exécute chez elle (rien par le Go), l'aperçu est servi par +/// /api/chat/cushot, et le Go passe par /internal/facade/v1/browser. +#[cfg(target_os = "linux")] +#[tokio::test] +async fn navigateur_par_la_facade() { + use common::fakecdp; + let t = tempfile::tempdir().unwrap(); + let scene: fakecdp::Scene = Arc::new(std::sync::Mutex::new(serde_json::json!({ + "snap": { "title": "Exemple", "url": "https://exemple.test/", "refs": ["[1] link \"Accueil\""] }, + "href": "https://exemple.test/", + }))); + let calls: fakecdp::Calls = Default::default(); + let port = fakecdp::start(scene, calls.clone()).await; + let chrome = fakecdp::chrome_script(t.path(), port, &t.path().join("args")); + std::fs::create_dir_all(t.path().join("profils")).unwrap(); + + // Le tour. + let call = sse(&[ + serde_json::json!({"choices":[{"delta":{"tool_calls":[{"index":0,"id":"n1","type":"function","function":{"name":"browser_open","arguments":"{\"url\":\"exemple.test\"}"}}]}}]}), + serde_json::json!({"choices":[{"delta":{},"finish_reason":"tool_calls"}]}), + ]); + let (url, seen) = fake_engine(vec![call, answer("ouvert")]).await; + let browser = Arc::new(loki_rs::browser::Browser::with( + &chrome.to_string_lossy(), + t.path().join("profils"), + )); + let (svc, go, _stop, _t) = service_with(Some(&url), |s| s.browser = browser.clone()).await; + svc.send(serde_json::json!({"message": "ouvre exemple", "cid": "n"})) + .await + .unwrap(); + until_done(&svc, 1).await; + assert!(go.tool_log.lock().unwrap().is_empty(), "{:?}", go.tool_log); + let seen = seen.lock().unwrap().clone(); + let tool = seen[1].1["messages"] + .as_array() + .unwrap() + .iter() + .rfind(|m| m["role"] == "tool") + .unwrap()["content"] + .clone(); + assert_eq!( + tool, + serde_json::json!( + "# Exemple\nhttps://exemple.test/\n\n1 élément(s) interactif(s) VISIBLE(s) — clique par numéro avec browser_click(ref) :\n[1] link \"Accueil\"" + ) + ); + assert!(browser.last_shot().is_some(), "aperçu après l'ouverture"); + assert!( + calls + .lock() + .unwrap() + .iter() + .any(|c| c == "Page.navigate {\"url\":\"https://exemple.test\"}") + ); + browser.shutdown().await; + + // L'API du Go et l'aperçu servi, par une façade complète. + let t2 = tempfile::tempdir().unwrap(); + let (facade, _go, _stop2, chat) = fakego::start_with( + t2.path().into(), + fakego::Opts { + chat: true, + ..Default::default() + }, + ) + .await; + let chat = chat.unwrap(); + chat.shared + .browser + .impose(&chrome.to_string_lossy(), t.path().join("profils")); + let tok = [(loki_rs::auth::INTERNAL_HEADER, fakego::JETON)]; + let post = |body: serde_json::Value| { + let b = body.to_string(); + async move { + let (code, out) = + fakego::send(facade, "POST", "/internal/facade/v1/browser", &tok, &b).await; + ( + code, + serde_json::from_slice::(&out).unwrap_or_default(), + ) + } + }; + let (code, out) = + post(serde_json::json!({ "op": "tool", "name": "browser_snapshot", "args": {} })).await; + assert_eq!(code, 200, "{out}"); + assert!( + out["result"].as_str().unwrap().starts_with("# Exemple\n"), + "{out}" + ); + assert_eq!(out["image"], serde_json::Value::Null); + let (_, out) = post(serde_json::json!({ "op": "shot" })).await; + let id = out["id"].as_str().unwrap().to_string(); + assert!(!id.is_empty()); + let (code, out) = + fakego::send(facade, "GET", &format!("/api/chat/cushot?id={id}"), &[], "").await; + assert_eq!(code, 200); + let v: serde_json::Value = serde_json::from_slice(&out).unwrap(); + assert_eq!( + (v["ok"].clone(), v["mime"].clone()), + (serde_json::json!(true), serde_json::json!("image/jpeg")) + ); + let (code, out) = fakego::send(facade, "GET", "/api/chat/cushot?id=perdu", &[], "").await; + assert_eq!(code, 404); + assert_eq!( + serde_json::from_slice::(&out).unwrap(), + serde_json::json!({ "ok": false, "error": "capture expirée" }) + ); + let (code, _) = post(serde_json::json!({ "op": "outil", "name": "x" })).await; + assert_eq!(code, 400); + let (code, _) = post(serde_json::json!({ "op": "tool", "name": "browser_voler" })).await; + assert_eq!(code, 400); + let (code, _) = post(serde_json::json!({ "op": "shutdown" })).await; + assert_eq!(code, 200); + assert!(!chat.shared.browser.running().await); +} diff --git a/rust/loki-rs/tests/parity_browser/browser.json b/rust/loki-rs/tests/parity_browser/browser.json new file mode 100644 index 0000000..a214d38 --- /dev/null +++ b/rust/loki-rs/tests/parity_browser/browser.json @@ -0,0 +1,1082 @@ +{ + "launch": [ + "--headless=new", + "--remote-debugging-port=0", + "--user-data-dir={{PROFIL}}", + "--no-first-run", + "--no-default-browser-check", + "--disable-gpu", + "--disable-extensions", + "--disable-background-networking", + "--disable-features=Translate,BackForwardCache", + "--no-sandbox", + "--disable-dev-shm-usage", + "--window-size=1280,900", + "about:blank" + ], + "steps": [ + { + "name": "ouverture sans url", + "tool": "browser_open", + "args": { + "url": " " + }, + "result": "[erreur] url manquante", + "saved": [], + "calls": [] + }, + { + "name": "ouverture", + "scene": { + "snap": { + "above": 0, + "below": 900, + "refs": [ + "[1] link \"Accueil\"", + "[2] button \"Créer un compte\"", + "[3] textbox \"Nom = Jean\"" + ], + "title": "Exemple", + "url": "https://exemple.test/" + }, + "href": "https://exemple.test/" + }, + "tool": "browser_open", + "args": { + "url": "exemple.test" + }, + "result": "# Exemple\nhttps://exemple.test/\n\n3 élément(s) interactif(s) VISIBLE(s) — clique par numéro avec browser_click(ref) :\n[1] link \"Accueil\"\n[2] button \"Créer un compte\"\n[3] textbox \"Nom = Jean\"\n\n↓ encore ~900 px sous la vue — browser_scroll(down) pour révéler d'autres éléments.", + "saved": [], + "calls": [ + "Page.enable", + "Runtime.enable", + "DOM.enable", + "Network.enable", + "Emulation.setDeviceMetricsOverride {\"deviceScaleFactor\":1,\"height\":800,\"mobile\":false,\"width\":1280}", + "Page.addScriptToEvaluateOnNewDocument {\"source\":\"(function(){try{\\n window.open=function(…[264:24e8477d927f]\"}", + "Page.navigate {\"url\":\"https://exemple.test\"}", + "Runtime.evaluate {\"awaitPromise\":true,\"expression\":\"document.readyState\",\"returnByValue\":true}", + "Runtime.evaluate {\"awaitPromise\":true,\"expression\":\"location.href\",\"returnByValue\":true}", + "Runtime.evaluate {\"awaitPromise\":true,\"expression\":\"\\nfunction ajRole(el){var r=el.getAttribu…[4073:d76a42af8356]\",\"returnByValue\":true}" + ] + }, + { + "name": "snapshot inchangé", + "tool": "browser_snapshot", + "args": {}, + "result": "(page inchangée depuis le dernier affichage — les mêmes numéros restent valides)", + "saved": [], + "calls": [ + "Runtime.evaluate {\"expression\":\"1\",\"returnByValue\":true}", + "Runtime.evaluate {\"awaitPromise\":true,\"expression\":\"\\nfunction ajRole(el){var r=el.getAttribu…[4073:d76a42af8356]\",\"returnByValue\":true}" + ] + }, + { + "name": "réouverture : page complète", + "tool": "browser_open", + "args": { + "url": "https://exemple.test/" + }, + "result": "# Exemple\nhttps://exemple.test/\n\n3 élément(s) interactif(s) VISIBLE(s) — clique par numéro avec browser_click(ref) :\n[1] link \"Accueil\"\n[2] button \"Créer un compte\"\n[3] textbox \"Nom = Jean\"\n\n↓ encore ~900 px sous la vue — browser_scroll(down) pour révéler d'autres éléments.", + "saved": [], + "calls": [ + "Runtime.evaluate {\"expression\":\"1\",\"returnByValue\":true}", + "Page.navigate {\"url\":\"https://exemple.test/\"}", + "Runtime.evaluate {\"awaitPromise\":true,\"expression\":\"document.readyState\",\"returnByValue\":true}", + "Runtime.evaluate {\"awaitPromise\":true,\"expression\":\"location.href\",\"returnByValue\":true}", + "Runtime.evaluate {\"awaitPromise\":true,\"expression\":\"\\nfunction ajRole(el){var r=el.getAttribu…[4073:d76a42af8356]\",\"returnByValue\":true}" + ] + }, + { + "name": "page vide", + "scene": { + "snap": { + "above": 0, + "below": 0, + "refs": [], + "title": "", + "url": "about:blank" + }, + "href": "about:blank" + }, + "tool": "browser_snapshot", + "args": {}, + "result": "# \nabout:blank\n\n(aucun élément interactif détecté — page vide, en cours de chargement, ou rendue sur canvas ; réessaie browser_snapshot, ou utilise browser_screenshot pour voir)", + "saved": [], + "calls": [ + "Runtime.evaluate {\"expression\":\"1\",\"returnByValue\":true}", + "Runtime.evaluate {\"awaitPromise\":true,\"expression\":\"\\nfunction ajRole(el){var r=el.getAttribu…[4073:d76a42af8356]\",\"returnByValue\":true}" + ] + }, + { + "name": "snapshot null", + "scene": { + "href": "about:blank" + }, + "tool": "browser_snapshot", + "args": {}, + "result": "# \n\n\n(aucun élément interactif détecté — page vide, en cours de chargement, ou rendue sur canvas ; réessaie browser_snapshot, ou utilise browser_screenshot pour voir)", + "saved": [], + "calls": [ + "Runtime.evaluate {\"expression\":\"1\",\"returnByValue\":true}", + "Runtime.evaluate {\"awaitPromise\":true,\"expression\":\"\\nfunction ajRole(el){var r=el.getAttribu…[4073:d76a42af8356]\",\"returnByValue\":true}" + ] + }, + { + "name": "snapshot sans valeur", + "scene": { + "snap": "" + }, + "tool": "browser_snapshot", + "args": {}, + "result": "[erreur] snapshot illisible: unexpected end of JSON input", + "saved": [], + "calls": [ + "Runtime.evaluate {\"expression\":\"1\",\"returnByValue\":true}", + "Runtime.evaluate {\"awaitPromise\":true,\"expression\":\"\\nfunction ajRole(el){var r=el.getAttribu…[4073:d76a42af8356]\",\"returnByValue\":true}" + ] + }, + { + "name": "snapshot illisible", + "scene": { + "snap": "texte" + }, + "tool": "browser_snapshot", + "args": {}, + "result": "[erreur] snapshot illisible: json: cannot unmarshal string into Go value of type loki.cuSnap", + "saved": [], + "calls": [ + "Runtime.evaluate {\"expression\":\"1\",\"returnByValue\":true}", + "Runtime.evaluate {\"awaitPromise\":true,\"expression\":\"\\nfunction ajRole(el){var r=el.getAttribu…[4073:d76a42af8356]\",\"returnByValue\":true}" + ] + }, + { + "name": "exception JS", + "scene": { + "throw": true + }, + "tool": "browser_snapshot", + "args": {}, + "result": "[erreur] JS: Uncaught TypeError: x is not a function", + "saved": [], + "calls": [ + "Runtime.evaluate {\"expression\":\"1\",\"returnByValue\":true}", + "Runtime.evaluate {\"awaitPromise\":true,\"expression\":\"\\nfunction ajRole(el){var r=el.getAttribu…[4073:d76a42af8356]\",\"returnByValue\":true}" + ] + }, + { + "name": "défilé", + "scene": { + "snap": { + "above": 600, + "below": 10, + "refs": [ + "[4] link \"Mentions\"" + ], + "title": "Exemple", + "url": "https://exemple.test/" + }, + "href": "https://exemple.test/" + }, + "tool": "browser_scroll", + "args": { + "direction": "down" + }, + "result": "# Exemple\nhttps://exemple.test/\n\n1 élément(s) interactif(s) VISIBLE(s) — clique par numéro avec browser_click(ref) :\n[4] link \"Mentions\"\n\n↑ ~600 px au-dessus — browser_scroll(up) pour y revenir.", + "saved": [], + "calls": [ + "Runtime.evaluate {\"expression\":\"1\",\"returnByValue\":true}", + "Runtime.evaluate {\"awaitPromise\":true,\"expression\":\"window.scrollBy(0,600);'ok'\",\"returnByValue\":true}", + "Runtime.evaluate {\"awaitPromise\":true,\"expression\":\"\\nfunction ajRole(el){var r=el.getAttribu…[4073:d76a42af8356]\",\"returnByValue\":true}" + ] + }, + { + "name": "défilement haut", + "scene": { + "snap": { + "above": 0, + "below": 900, + "refs": [ + "[1] link \"Accueil\"", + "[2] button \"Créer un compte\"", + "[3] textbox \"Nom = Jean\"" + ], + "title": "Exemple", + "url": "https://exemple.test/" + } + }, + "tool": "browser_scroll", + "args": { + "direction": "UP" + }, + "result": "# Exemple\nhttps://exemple.test/\n\n3 élément(s) interactif(s) VISIBLE(s) — clique par numéro avec browser_click(ref) :\n[1] link \"Accueil\"\n[2] button \"Créer un compte\"\n[3] textbox \"Nom = Jean\"\n\n↓ encore ~900 px sous la vue — browser_scroll(down) pour révéler d'autres éléments.", + "saved": [], + "calls": [ + "Runtime.evaluate {\"expression\":\"1\",\"returnByValue\":true}", + "Runtime.evaluate {\"awaitPromise\":true,\"expression\":\"window.scrollBy(0,-600);'ok'\",\"returnByValue\":true}", + "Runtime.evaluate {\"awaitPromise\":true,\"expression\":\"\\nfunction ajRole(el){var r=el.getAttribu…[4073:d76a42af8356]\",\"returnByValue\":true}" + ] + }, + { + "name": "défilement gauche puis droite", + "tool": "browser_scroll", + "args": { + "direction": "left" + }, + "result": "(page inchangée depuis le dernier affichage — les mêmes numéros restent valides)", + "saved": [], + "calls": [ + "Runtime.evaluate {\"expression\":\"1\",\"returnByValue\":true}", + "Runtime.evaluate {\"awaitPromise\":true,\"expression\":\"window.scrollBy(-600,0);'ok'\",\"returnByValue\":true}", + "Runtime.evaluate {\"awaitPromise\":true,\"expression\":\"\\nfunction ajRole(el){var r=el.getAttribu…[4073:d76a42af8356]\",\"returnByValue\":true}" + ] + }, + { + "name": "défilement par défaut", + "tool": "browser_scroll", + "args": {}, + "result": "(page inchangée depuis le dernier affichage — les mêmes numéros restent valides)", + "saved": [], + "calls": [ + "Runtime.evaluate {\"expression\":\"1\",\"returnByValue\":true}", + "Runtime.evaluate {\"awaitPromise\":true,\"expression\":\"window.scrollBy(0,600);'ok'\",\"returnByValue\":true}", + "Runtime.evaluate {\"awaitPromise\":true,\"expression\":\"\\nfunction ajRole(el){var r=el.getAttribu…[4073:d76a42af8356]\",\"returnByValue\":true}" + ] + }, + { + "name": "direction inconnue", + "tool": "browser_scroll", + "args": { + "direction": "diagonale" + }, + "result": "[erreur] direction inconnue « diagonale » (up, down, left, right)", + "saved": [], + "calls": [ + "Runtime.evaluate {\"expression\":\"1\",\"returnByValue\":true}" + ] + }, + { + "name": "clic", + "scene": { + "snap": { + "above": 600, + "below": 10, + "refs": [ + "[4] link \"Mentions\"" + ], + "title": "Exemple", + "url": "https://exemple.test/" + }, + "center": { + "x": 640, + "y": 412.5 + } + }, + "tool": "browser_click", + "args": { + "ref": 2 + }, + "result": "[ok] cliqué [2]\n\n# Exemple\nhttps://exemple.test/\n\n1 élément(s) interactif(s) VISIBLE(s) — clique par numéro avec browser_click(ref) :\n[4] link \"Mentions\"\n\n↑ ~600 px au-dessus — browser_scroll(up) pour y revenir.", + "saved": [], + "calls": [ + "Runtime.evaluate {\"expression\":\"1\",\"returnByValue\":true}", + "Runtime.evaluate {\"awaitPromise\":true,\"expression\":\"\\nfunction ajRole(el){var r=el.getAttribu…[3557:d543b8f1c16e]\",\"returnByValue\":true}", + "Input.dispatchMouseEvent {\"button\":\"left\",\"clickCount\":1,\"type\":\"mousePressed\",\"x\":640,\"y\":412.5}", + "Input.dispatchMouseEvent {\"button\":\"left\",\"clickCount\":1,\"type\":\"mouseReleased\",\"x\":640,\"y\":412.5}", + "Runtime.evaluate {\"awaitPromise\":true,\"expression\":\"document.readyState\",\"returnByValue\":true}", + "Runtime.evaluate {\"awaitPromise\":true,\"expression\":\"\\nfunction ajRole(el){var r=el.getAttribu…[4073:d76a42af8356]\",\"returnByValue\":true}" + ] + }, + { + "name": "clic introuvable", + "scene": { + "snap": { + "above": 0, + "below": 900, + "refs": [ + "[1] link \"Accueil\"", + "[2] button \"Créer un compte\"", + "[3] textbox \"Nom = Jean\"" + ], + "title": "Exemple", + "url": "https://exemple.test/" + } + }, + "tool": "browser_click", + "args": { + "ref": 9 + }, + "result": "[erreur] élément [9] introuvable — refais un browser_snapshot (la page a peut-être changé)", + "saved": [], + "calls": [ + "Runtime.evaluate {\"expression\":\"1\",\"returnByValue\":true}", + "Runtime.evaluate {\"awaitPromise\":true,\"expression\":\"\\nfunction ajRole(el){var r=el.getAttribu…[3557:22298800a28a]\",\"returnByValue\":true}" + ] + }, + { + "name": "clic sans ref", + "tool": "browser_click", + "args": { + "ref": "2" + }, + "result": "[erreur] ref manquant (numéro de l'élément)", + "saved": [], + "calls": [] + }, + { + "name": "clic refusé", + "scene": { + "snap": { + "above": 0, + "below": 900, + "refs": [ + "[1] link \"Accueil\"", + "[2] button \"Créer un compte\"", + "[3] textbox \"Nom = Jean\"" + ], + "title": "Exemple", + "url": "https://exemple.test/" + }, + "center": { + "x": 1, + "y": 2 + }, + "errors": { + "Input.dispatchMouseEvent": "Cannot dispatch" + } + }, + "tool": "browser_click", + "args": { + "ref": 1 + }, + "result": "[erreur] Input.dispatchMouseEvent: Cannot dispatch", + "saved": [], + "calls": [ + "Runtime.evaluate {\"expression\":\"1\",\"returnByValue\":true}", + "Runtime.evaluate {\"awaitPromise\":true,\"expression\":\"\\nfunction ajRole(el){var r=el.getAttribu…[3557:d1528b7be679]\",\"returnByValue\":true}", + "Input.dispatchMouseEvent {\"button\":\"left\",\"clickCount\":1,\"type\":\"mousePressed\",\"x\":1,\"y\":2}" + ] + }, + { + "name": "clic en coordonnées", + "scene": { + "snap": { + "above": 0, + "below": 900, + "refs": [ + "[1] link \"Accueil\"", + "[2] button \"Créer un compte\"", + "[3] textbox \"Nom = Jean\"" + ], + "title": "Exemple", + "url": "https://exemple.test/" + } + }, + "tool": "browser_click_xy", + "args": { + "x": 100.4, + "y": 250.5 + }, + "result": "[ok] clic en (100, 250)\n\n# Exemple\nhttps://exemple.test/\n\n3 élément(s) interactif(s) VISIBLE(s) — clique par numéro avec browser_click(ref) :\n[1] link \"Accueil\"\n[2] button \"Créer un compte\"\n[3] textbox \"Nom = Jean\"\n\n↓ encore ~900 px sous la vue — browser_scroll(down) pour révéler d'autres éléments.", + "saved": [], + "calls": [ + "Runtime.evaluate {\"expression\":\"1\",\"returnByValue\":true}", + "Input.dispatchMouseEvent {\"button\":\"left\",\"clickCount\":1,\"type\":\"mousePressed\",\"x\":100.4,\"y\":250.5}", + "Input.dispatchMouseEvent {\"button\":\"left\",\"clickCount\":1,\"type\":\"mouseReleased\",\"x\":100.4,\"y\":250.5}", + "Runtime.evaluate {\"awaitPromise\":true,\"expression\":\"document.readyState\",\"returnByValue\":true}", + "Runtime.evaluate {\"awaitPromise\":true,\"expression\":\"\\nfunction ajRole(el){var r=el.getAttribu…[4073:d76a42af8356]\",\"returnByValue\":true}" + ] + }, + { + "name": "coordonnées manquantes", + "tool": "browser_click_xy", + "args": { + "x": 10 + }, + "result": "[erreur] coordonnées x et y requises (en pixels du screenshot)", + "saved": [], + "calls": [] + }, + { + "name": "saisie libre", + "scene": { + "snap": { + "above": 600, + "below": 10, + "refs": [ + "[4] link \"Mentions\"" + ], + "title": "Exemple", + "url": "https://exemple.test/" + } + }, + "tool": "browser_type", + "args": { + "text": "bonjour & co" + }, + "result": "[ok] saisi « bonjour & co »\n\n# Exemple\nhttps://exemple.test/\n\n1 élément(s) interactif(s) VISIBLE(s) — clique par numéro avec browser_click(ref) :\n[4] link \"Mentions\"\n\n↑ ~600 px au-dessus — browser_scroll(up) pour y revenir.", + "saved": [], + "calls": [ + "Runtime.evaluate {\"expression\":\"1\",\"returnByValue\":true}", + "Input.insertText {\"text\":\"bonjour \\u003cmonde\\u003e \\u0026 co\"}", + "Runtime.evaluate {\"awaitPromise\":true,\"expression\":\"\\nfunction ajRole(el){var r=el.getAttribu…[4073:d76a42af8356]\",\"returnByValue\":true}" + ] + }, + { + "name": "saisie dans un champ", + "scene": { + "snap": { + "above": 0, + "below": 900, + "refs": [ + "[1] link \"Accueil\"", + "[2] button \"Créer un compte\"", + "[3] textbox \"Nom = Jean\"" + ], + "title": "Exemple", + "url": "https://exemple.test/" + }, + "typed": "TEXT" + }, + "tool": "browser_type", + "args": { + "ref": 3, + "text": "Jean" + }, + "result": "[ok] saisi « Jean »\n\n# Exemple\nhttps://exemple.test/\n\n3 élément(s) interactif(s) VISIBLE(s) — clique par numéro avec browser_click(ref) :\n[1] link \"Accueil\"\n[2] button \"Créer un compte\"\n[3] textbox \"Nom = Jean\"\n\n↓ encore ~900 px sous la vue — browser_scroll(down) pour révéler d'autres éléments.", + "saved": [], + "calls": [ + "Runtime.evaluate {\"expression\":\"1\",\"returnByValue\":true}", + "Runtime.evaluate {\"awaitPromise\":true,\"expression\":\"\\nfunction ajRole(el){var r=el.getAttribu…[4147:bba886791418]\",\"returnByValue\":true}", + "Input.insertText {\"text\":\"Jean\"}", + "Runtime.evaluate {\"awaitPromise\":true,\"expression\":\"\\nfunction ajRole(el){var r=el.getAttribu…[4073:d76a42af8356]\",\"returnByValue\":true}" + ] + }, + { + "name": "liste déroulante", + "scene": { + "snap": { + "above": 600, + "below": 10, + "refs": [ + "[4] link \"Mentions\"" + ], + "title": "Exemple", + "url": "https://exemple.test/" + }, + "typed": "SELECT" + }, + "tool": "browser_type", + "args": { + "ref": 5, + "text": "France" + }, + "result": "[ok] saisi « France »\n\n# Exemple\nhttps://exemple.test/\n\n1 élément(s) interactif(s) VISIBLE(s) — clique par numéro avec browser_click(ref) :\n[4] link \"Mentions\"\n\n↑ ~600 px au-dessus — browser_scroll(up) pour y revenir.", + "saved": [], + "calls": [ + "Runtime.evaluate {\"expression\":\"1\",\"returnByValue\":true}", + "Runtime.evaluate {\"awaitPromise\":true,\"expression\":\"\\nfunction ajRole(el){var r=el.getAttribu…[4149:abf8fb89d6ea]\",\"returnByValue\":true}", + "Runtime.evaluate {\"awaitPromise\":true,\"expression\":\"\\nfunction ajRole(el){var r=el.getAttribu…[4073:d76a42af8356]\",\"returnByValue\":true}" + ] + }, + { + "name": "option absente", + "scene": { + "typed": "NOOPT" + }, + "tool": "browser_type", + "args": { + "ref": 5, + "text": "Mars" + }, + "result": "[erreur] aucune option de la liste [5] ne correspond à « Mars » — fais un browser_screenshot pour voir les choix", + "saved": [], + "calls": [ + "Runtime.evaluate {\"expression\":\"1\",\"returnByValue\":true}", + "Runtime.evaluate {\"awaitPromise\":true,\"expression\":\"\\nfunction ajRole(el){var r=el.getAttribu…[4147:5fd2e2486627]\",\"returnByValue\":true}" + ] + }, + { + "name": "champ introuvable", + "scene": { + "typed": "NOTFOUND" + }, + "tool": "browser_type", + "args": { + "ref": 7, + "text": "x" + }, + "result": "[erreur] champ [7] introuvable — refais un browser_snapshot", + "saved": [], + "calls": [ + "Runtime.evaluate {\"expression\":\"1\",\"returnByValue\":true}", + "Runtime.evaluate {\"awaitPromise\":true,\"expression\":\"\\nfunction ajRole(el){var r=el.getAttribu…[4144:04f4774b7d77]\",\"returnByValue\":true}" + ] + }, + { + "name": "touche", + "scene": { + "snap": { + "above": 0, + "below": 900, + "refs": [ + "[1] link \"Accueil\"", + "[2] button \"Créer un compte\"", + "[3] textbox \"Nom = Jean\"" + ], + "title": "Exemple", + "url": "https://exemple.test/" + } + }, + "tool": "browser_key", + "args": { + "key": " Enter " + }, + "result": "[ok] touche Enter \n\n# Exemple\nhttps://exemple.test/\n\n3 élément(s) interactif(s) VISIBLE(s) — clique par numéro avec browser_click(ref) :\n[1] link \"Accueil\"\n[2] button \"Créer un compte\"\n[3] textbox \"Nom = Jean\"\n\n↓ encore ~900 px sous la vue — browser_scroll(down) pour révéler d'autres éléments.", + "saved": [], + "calls": [ + "Runtime.evaluate {\"expression\":\"1\",\"returnByValue\":true}", + "Input.dispatchKeyEvent {\"code\":\"Enter\",\"key\":\"Enter\",\"nativeVirtualKeyCode\":13,\"type\":\"rawKeyDown\",\"windowsVirtualKeyCode\":13}", + "Input.dispatchKeyEvent {\"code\":\"Enter\",\"key\":\"Enter\",\"nativeVirtualKeyCode\":13,\"type\":\"keyUp\",\"windowsVirtualKeyCode\":13}", + "Runtime.evaluate {\"awaitPromise\":true,\"expression\":\"document.readyState\",\"returnByValue\":true}", + "Runtime.evaluate {\"awaitPromise\":true,\"expression\":\"\\nfunction ajRole(el){var r=el.getAttribu…[4073:d76a42af8356]\",\"returnByValue\":true}" + ] + }, + { + "name": "touche esc", + "tool": "browser_key", + "args": { + "key": "esc" + }, + "result": "[ok] touche esc\n\n(page inchangée depuis le dernier affichage — les mêmes numéros restent valides)", + "saved": [], + "calls": [ + "Runtime.evaluate {\"expression\":\"1\",\"returnByValue\":true}", + "Input.dispatchKeyEvent {\"code\":\"Escape\",\"key\":\"Escape\",\"nativeVirtualKeyCode\":27,\"type\":\"rawKeyDown\",\"windowsVirtualKeyCode\":27}", + "Input.dispatchKeyEvent {\"code\":\"Escape\",\"key\":\"Escape\",\"nativeVirtualKeyCode\":27,\"type\":\"keyUp\",\"windowsVirtualKeyCode\":27}", + "Runtime.evaluate {\"awaitPromise\":true,\"expression\":\"document.readyState\",\"returnByValue\":true}", + "Runtime.evaluate {\"awaitPromise\":true,\"expression\":\"\\nfunction ajRole(el){var r=el.getAttribu…[4073:d76a42af8356]\",\"returnByValue\":true}" + ] + }, + { + "name": "touche inconnue", + "tool": "browser_key", + "args": { + "key": "F13" + }, + "result": "[erreur] touche inconnue « F13 » — connues : enter, tab, backspace, escape, delete, up/down/left/right, home, end, pageup, pagedown", + "saved": [], + "calls": [ + "Runtime.evaluate {\"expression\":\"1\",\"returnByValue\":true}" + ] + }, + { + "name": "recherche vide", + "tool": "browser_find", + "args": { + "text": "" + }, + "result": "[erreur] texte de recherche manquant", + "saved": [], + "calls": [] + }, + { + "name": "recherche", + "scene": { + "snap": { + "above": 0, + "below": 900, + "refs": [ + "[1] link \"Accueil\"", + "[2] button \"Créer un compte\"", + "[3] textbox \"Nom = Jean\"" + ], + "title": "Exemple", + "url": "https://exemple.test/" + }, + "find": { + "refs": [ + "[2] button \"Créer un compte\"", + "[7] link \"Créer\"" + ] + } + }, + "tool": "browser_find", + "args": { + "text": "créer " + }, + "result": "2 élément(s) contenant « créer » (le 1er est amené dans la vue) — clique par numéro :\n[2] button \"Créer un compte\"\n[7] link \"Créer\"", + "saved": [], + "calls": [ + "Runtime.evaluate {\"expression\":\"1\",\"returnByValue\":true}", + "Runtime.evaluate {\"awaitPromise\":true,\"expression\":\"\\nfunction ajRole(el){var r=el.getAttribu…[4257:2e4488312149]\",\"returnByValue\":true}" + ] + }, + { + "name": "recherche sans résultat", + "scene": { + "find": { + "refs": [] + } + }, + "tool": "browser_find", + "args": { + "text": "zzz" + }, + "result": "Aucun élément contenant « zzz » sur cette page. Essaie un autre mot, ou c'est peut-être ailleurs (autre page/lien).", + "saved": [], + "calls": [ + "Runtime.evaluate {\"expression\":\"1\",\"returnByValue\":true}", + "Runtime.evaluate {\"awaitPromise\":true,\"expression\":\"\\nfunction ajRole(el){var r=el.getAttribu…[4240:c7d0f581011e]\",\"returnByValue\":true}" + ] + }, + { + "name": "navigation en échec", + "scene": { + "snap": { + "above": 0, + "below": 900, + "refs": [ + "[1] link \"Accueil\"", + "[2] button \"Créer un compte\"", + "[3] textbox \"Nom = Jean\"" + ], + "title": "Exemple", + "url": "https://exemple.test/" + }, + "href": "https://nulle-part.test/", + "nav_error": "net::ERR_NAME_NOT_RESOLVED" + }, + "tool": "browser_open", + "args": { + "url": "nulle-part.test" + }, + "result": "[erreur] navigation : chargement de https://nulle-part.test échoué (net::ERR_NAME_NOT_RESOLVED) — URL/hôte invalide, DNS, ou site bloqué ; corrige l'URL, essaie 'back', ou passe par un lien de la page", + "saved": [], + "calls": [ + "Runtime.evaluate {\"expression\":\"1\",\"returnByValue\":true}", + "Page.navigate {\"url\":\"https://nulle-part.test\"}", + "Runtime.evaluate {\"awaitPromise\":true,\"expression\":\"document.readyState\",\"returnByValue\":true}", + "Runtime.evaluate {\"awaitPromise\":true,\"expression\":\"location.href\",\"returnByValue\":true}" + ] + }, + { + "name": "page d'erreur", + "scene": { + "snap": { + "above": 0, + "below": 900, + "refs": [ + "[1] link \"Accueil\"", + "[2] button \"Créer un compte\"", + "[3] textbox \"Nom = Jean\"" + ], + "title": "Exemple", + "url": "https://exemple.test/" + }, + "href": "chrome-error://chromewebdata/" + }, + "tool": "browser_open", + "args": { + "url": "http://x.test" + }, + "result": "[erreur] navigation : chargement de http://x.test échoué (page injoignable) — URL/hôte invalide, DNS, ou site bloqué ; corrige l'URL, essaie 'back', ou passe par un lien de la page", + "saved": [], + "calls": [ + "Runtime.evaluate {\"expression\":\"1\",\"returnByValue\":true}", + "Page.navigate {\"url\":\"http://x.test\"}", + "Runtime.evaluate {\"awaitPromise\":true,\"expression\":\"document.readyState\",\"returnByValue\":true}", + "Runtime.evaluate {\"awaitPromise\":true,\"expression\":\"location.href\",\"returnByValue\":true}" + ] + }, + { + "name": "navigation interrompue bénigne", + "scene": { + "snap": { + "above": 0, + "below": 900, + "refs": [ + "[1] link \"Accueil\"", + "[2] button \"Créer un compte\"", + "[3] textbox \"Nom = Jean\"" + ], + "title": "Exemple", + "url": "https://exemple.test/" + }, + "href": "https://dl.test/f.zip", + "nav_error": "net::ERR_ABORTED" + }, + "tool": "browser_open", + "args": { + "url": "https://dl.test/f.zip" + }, + "result": "# Exemple\nhttps://exemple.test/\n\n3 élément(s) interactif(s) VISIBLE(s) — clique par numéro avec browser_click(ref) :\n[1] link \"Accueil\"\n[2] button \"Créer un compte\"\n[3] textbox \"Nom = Jean\"\n\n↓ encore ~900 px sous la vue — browser_scroll(down) pour révéler d'autres éléments.", + "saved": [], + "calls": [ + "Runtime.evaluate {\"expression\":\"1\",\"returnByValue\":true}", + "Page.navigate {\"url\":\"https://dl.test/f.zip\"}", + "Runtime.evaluate {\"awaitPromise\":true,\"expression\":\"document.readyState\",\"returnByValue\":true}", + "Runtime.evaluate {\"awaitPromise\":true,\"expression\":\"location.href\",\"returnByValue\":true}", + "Runtime.evaluate {\"awaitPromise\":true,\"expression\":\"\\nfunction ajRole(el){var r=el.getAttribu…[4073:d76a42af8356]\",\"returnByValue\":true}" + ] + }, + { + "name": "retour", + "scene": { + "snap": { + "above": 600, + "below": 10, + "refs": [ + "[4] link \"Mentions\"" + ], + "title": "Exemple", + "url": "https://exemple.test/" + }, + "href": "https://exemple.test/" + }, + "tool": "browser_open", + "args": { + "url": " Back " + }, + "result": "# Exemple\nhttps://exemple.test/\n\n1 élément(s) interactif(s) VISIBLE(s) — clique par numéro avec browser_click(ref) :\n[4] link \"Mentions\"\n\n↑ ~600 px au-dessus — browser_scroll(up) pour y revenir.", + "saved": [], + "calls": [ + "Runtime.evaluate {\"expression\":\"1\",\"returnByValue\":true}", + "Runtime.evaluate {\"awaitPromise\":true,\"expression\":\"history.back();'ok'\",\"returnByValue\":true}", + "Runtime.evaluate {\"awaitPromise\":true,\"expression\":\"document.readyState\",\"returnByValue\":true}", + "Runtime.evaluate {\"awaitPromise\":true,\"expression\":\"\\nfunction ajRole(el){var r=el.getAttribu…[4073:d76a42af8356]\",\"returnByValue\":true}" + ] + }, + { + "name": "avance", + "tool": "browser_open", + "args": { + "url": "forward" + }, + "result": "# Exemple\nhttps://exemple.test/\n\n1 élément(s) interactif(s) VISIBLE(s) — clique par numéro avec browser_click(ref) :\n[4] link \"Mentions\"\n\n↑ ~600 px au-dessus — browser_scroll(up) pour y revenir.", + "saved": [], + "calls": [ + "Runtime.evaluate {\"expression\":\"1\",\"returnByValue\":true}", + "Runtime.evaluate {\"awaitPromise\":true,\"expression\":\"history.forward();'ok'\",\"returnByValue\":true}", + "Runtime.evaluate {\"awaitPromise\":true,\"expression\":\"document.readyState\",\"returnByValue\":true}", + "Runtime.evaluate {\"awaitPromise\":true,\"expression\":\"\\nfunction ajRole(el){var r=el.getAttribu…[4073:d76a42af8356]\",\"returnByValue\":true}" + ] + }, + { + "name": "commande refusée", + "scene": { + "snap": { + "above": 0, + "below": 900, + "refs": [ + "[1] link \"Accueil\"", + "[2] button \"Créer un compte\"", + "[3] textbox \"Nom = Jean\"" + ], + "title": "Exemple", + "url": "https://exemple.test/" + }, + "errors": { + "Page.navigate": "Cannot navigate to invalid URL" + } + }, + "tool": "browser_open", + "args": { + "url": "https://exemple.test/" + }, + "result": "[erreur] navigation : Page.navigate: Cannot navigate to invalid URL", + "saved": [], + "calls": [ + "Runtime.evaluate {\"expression\":\"1\",\"returnByValue\":true}", + "Page.navigate {\"url\":\"https://exemple.test/\"}" + ] + }, + { + "name": "capture sans vision", + "scene": { + "snap": { + "above": 0, + "below": 900, + "refs": [ + "[1] link \"Accueil\"", + "[2] button \"Créer un compte\"", + "[3] textbox \"Nom = Jean\"" + ], + "title": "Exemple", + "url": "https://exemple.test/" + } + }, + "tool": "browser_screenshot", + "args": {}, + "result": "[erreur] la vision n'est pas active sur ce modèle — impossible de capturer l'écran", + "saved": [], + "calls": [] + }, + { + "name": "capture", + "vision": true, + "tool": "browser_screenshot", + "args": {}, + "result": "[ok] capture prise (tu la vois ci-dessous, 1280x800 px, 1:1 avec la page). Une GRILLE de repères y est surimprimée (lignes + chiffres tous les 100 px) POUR TOI seulement, afin de viser précisément — le fichier enregistré, lui, est PROPRE (sans grille), donc l'aperçu ![capture] montré à l'utilisateur est net. Si un élément visible n'est PAS dans la liste numérotée (bandeau cookies en iframe, canvas), clique-le du premier coup avec browser_click_xy(x,y), coordonnées lues sur la grille. Enregistrée sous « capture-{{T}}.png ». Pour l'AFFICHER à l'utilisateur dans le chat, insère-la en IMAGE Markdown : ![capture](capture-{{T}}.png). Un simple lien [capture](capture-{{T}}.png) ne donne qu'un bouton de téléchargement, sans aperçu. Ne cherche pas de fichier sur le disque, c'est celui-ci.", + "image": { + "h": 800, + "px": { + "0,0": "40,80,120,255", + "100,50": "149,39,160,255", + "101,50": "40,80,120,255", + "104,4": "255,255,0,255", + "110,12": "0,0,0,255", + "1279,700": "149,39,160,255", + "2,2": "40,80,120,255", + "300,799": "149,39,160,255", + "4,204": "0,0,0,255", + "50,100": "149,39,160,255" + }, + "w": 1280 + }, + "saved": [ + "capture-{{T}}.png" + ], + "calls": [ + "Runtime.evaluate {\"expression\":\"1\",\"returnByValue\":true}", + "Page.captureScreenshot {\"format\":\"png\"}" + ] + }, + { + "name": "capture refusée", + "scene": { + "errors": { + "Page.captureScreenshot": "Unable to capture" + } + }, + "vision": true, + "tool": "browser_screenshot", + "args": {}, + "result": "[erreur] Page.captureScreenshot: Unable to capture", + "saved": [], + "calls": [ + "Runtime.evaluate {\"expression\":\"1\",\"returnByValue\":true}", + "Page.captureScreenshot {\"format\":\"png\"}" + ] + }, + { + "name": "sortie plafonnée", + "scene": { + "snap": { + "refs": [ + "[1] link \"Un lien assez long pour remplir la sortie, numéro 1\"", + "[2] link \"Un lien assez long pour remplir la sortie, numéro 2\"", + "[3] link \"Un lien assez long pour remplir la sortie, numéro 3\"", + "[4] link \"Un lien assez long pour remplir la sortie, numéro 4\"", + "[5] link \"Un lien assez long pour remplir la sortie, numéro 5\"", + "[6] link \"Un lien assez long pour remplir la sortie, numéro 6\"", + "[7] link \"Un lien assez long pour remplir la sortie, numéro 7\"", + "[8] link \"Un lien assez long pour remplir la sortie, numéro 8\"", + "[9] link \"Un lien assez long pour remplir la sortie, numéro 9\"", + "[10] link \"Un lien assez long pour remplir la sortie, numéro 10\"", + "[11] link \"Un lien assez long pour remplir la sortie, numéro 11\"", + "[12] link \"Un lien assez long pour remplir la sortie, numéro 12\"", + "[13] link \"Un lien assez long pour remplir la sortie, numéro 13\"", + "[14] link \"Un lien assez long pour remplir la sortie, numéro 14\"", + "[15] link \"Un lien assez long pour remplir la sortie, numéro 15\"", + "[16] link \"Un lien assez long pour remplir la sortie, numéro 16\"", + "[17] link \"Un lien assez long pour remplir la sortie, numéro 17\"", + "[18] link \"Un lien assez long pour remplir la sortie, numéro 18\"", + "[19] link \"Un lien assez long pour remplir la sortie, numéro 19\"", + "[20] link \"Un lien assez long pour remplir la sortie, numéro 20\"", + "[21] link \"Un lien assez long pour remplir la sortie, numéro 21\"", + "[22] link \"Un lien assez long pour remplir la sortie, numéro 22\"", + "[23] link \"Un lien assez long pour remplir la sortie, numéro 23\"", + "[24] link \"Un lien assez long pour remplir la sortie, numéro 24\"", + "[25] link \"Un lien assez long pour remplir la sortie, numéro 25\"", + "[26] link \"Un lien assez long pour remplir la sortie, numéro 26\"", + "[27] link \"Un lien assez long pour remplir la sortie, numéro 27\"", + "[28] link \"Un lien assez long pour remplir la sortie, numéro 28\"", + "[29] link \"Un lien assez long pour remplir la sortie, numéro 29\"", + "[30] link \"Un lien assez long pour remplir la sortie, numéro 30\"", + "[31] link \"Un lien assez long pour remplir la sortie, numéro 31\"", + "[32] link \"Un lien assez long pour remplir la sortie, numéro 32\"", + "[33] link \"Un lien assez long pour remplir la sortie, numéro 33\"", + "[34] link \"Un lien assez long pour remplir la sortie, numéro 34\"", + "[35] link \"Un lien assez long pour remplir la sortie, numéro 35\"", + "[36] link \"Un lien assez long pour remplir la sortie, numéro 36\"", + "[37] link \"Un lien assez long pour remplir la sortie, numéro 37\"", + "[38] link \"Un lien assez long pour remplir la sortie, numéro 38\"", + "[39] link \"Un lien assez long pour remplir la sortie, numéro 39\"", + "[40] link \"Un lien assez long pour remplir la sortie, numéro 40\"", + "[41] link \"Un lien assez long pour remplir la sortie, numéro 41\"", + "[42] link \"Un lien assez long pour remplir la sortie, numéro 42\"", + "[43] link \"Un lien assez long pour remplir la sortie, numéro 43\"", + "[44] link \"Un lien assez long pour remplir la sortie, numéro 44\"", + "[45] link \"Un lien assez long pour remplir la sortie, numéro 45\"", + "[46] link \"Un lien assez long pour remplir la sortie, numéro 46\"", + "[47] link \"Un lien assez long pour remplir la sortie, numéro 47\"", + "[48] link \"Un lien assez long pour remplir la sortie, numéro 48\"", + "[49] link \"Un lien assez long pour remplir la sortie, numéro 49\"", + "[50] link \"Un lien assez long pour remplir la sortie, numéro 50\"", + "[51] link \"Un lien assez long pour remplir la sortie, numéro 51\"", + "[52] link \"Un lien assez long pour remplir la sortie, numéro 52\"", + "[53] link \"Un lien assez long pour remplir la sortie, numéro 53\"", + "[54] link \"Un lien assez long pour remplir la sortie, numéro 54\"", + "[55] link \"Un lien assez long pour remplir la sortie, numéro 55\"", + "[56] link \"Un lien assez long pour remplir la sortie, numéro 56\"", + "[57] link \"Un lien assez long pour remplir la sortie, numéro 57\"", + "[58] link \"Un lien assez long pour remplir la sortie, numéro 58\"", + "[59] link \"Un lien assez long pour remplir la sortie, numéro 59\"", + "[60] link \"Un lien assez long pour remplir la sortie, numéro 60\"", + "[61] link \"Un lien assez long pour remplir la sortie, numéro 61\"", + "[62] link \"Un lien assez long pour remplir la sortie, numéro 62\"", + "[63] link \"Un lien assez long pour remplir la sortie, numéro 63\"", + "[64] link \"Un lien assez long pour remplir la sortie, numéro 64\"", + "[65] link \"Un lien assez long pour remplir la sortie, numéro 65\"", + "[66] link \"Un lien assez long pour remplir la sortie, numéro 66\"", + "[67] link \"Un lien assez long pour remplir la sortie, numéro 67\"", + "[68] link \"Un lien assez long pour remplir la sortie, numéro 68\"", + "[69] link \"Un lien assez long pour remplir la sortie, numéro 69\"", + "[70] link \"Un lien assez long pour remplir la sortie, numéro 70\"", + "[71] link \"Un lien assez long pour remplir la sortie, numéro 71\"", + "[72] link \"Un lien assez long pour remplir la sortie, numéro 72\"", + "[73] link \"Un lien assez long pour remplir la sortie, numéro 73\"", + "[74] link \"Un lien assez long pour remplir la sortie, numéro 74\"", + "[75] link \"Un lien assez long pour remplir la sortie, numéro 75\"", + "[76] link \"Un lien assez long pour remplir la sortie, numéro 76\"", + "[77] link \"Un lien assez long pour remplir la sortie, numéro 77\"", + "[78] link \"Un lien assez long pour remplir la sortie, numéro 78\"", + "[79] link \"Un lien assez long pour remplir la sortie, numéro 79\"", + "[80] link \"Un lien assez long pour remplir la sortie, numéro 80\"", + "[81] link \"Un lien assez long pour remplir la sortie, numéro 81\"", + "[82] link \"Un lien assez long pour remplir la sortie, numéro 82\"", + "[83] link \"Un lien assez long pour remplir la sortie, numéro 83\"", + "[84] link \"Un lien assez long pour remplir la sortie, numéro 84\"", + "[85] link \"Un lien assez long pour remplir la sortie, numéro 85\"", + "[86] link \"Un lien assez long pour remplir la sortie, numéro 86\"", + "[87] link \"Un lien assez long pour remplir la sortie, numéro 87\"", + "[88] link \"Un lien assez long pour remplir la sortie, numéro 88\"", + "[89] link \"Un lien assez long pour remplir la sortie, numéro 89\"", + "[90] link \"Un lien assez long pour remplir la sortie, numéro 90\"", + "[91] link \"Un lien assez long pour remplir la sortie, numéro 91\"", + "[92] link \"Un lien assez long pour remplir la sortie, numéro 92\"", + "[93] link \"Un lien assez long pour remplir la sortie, numéro 93\"", + "[94] link \"Un lien assez long pour remplir la sortie, numéro 94\"", + "[95] link \"Un lien assez long pour remplir la sortie, numéro 95\"", + "[96] link \"Un lien assez long pour remplir la sortie, numéro 96\"", + "[97] link \"Un lien assez long pour remplir la sortie, numéro 97\"", + "[98] link \"Un lien assez long pour remplir la sortie, numéro 98\"", + "[99] link \"Un lien assez long pour remplir la sortie, numéro 99\"", + "[100] link \"Un lien assez long pour remplir la sortie, numéro 100\"", + "[101] link \"Un lien assez long pour remplir la sortie, numéro 101\"", + "[102] link \"Un lien assez long pour remplir la sortie, numéro 102\"", + "[103] link \"Un lien assez long pour remplir la sortie, numéro 103\"", + "[104] link \"Un lien assez long pour remplir la sortie, numéro 104\"", + "[105] link \"Un lien assez long pour remplir la sortie, numéro 105\"", + "[106] link \"Un lien assez long pour remplir la sortie, numéro 106\"", + "[107] link \"Un lien assez long pour remplir la sortie, numéro 107\"", + "[108] link \"Un lien assez long pour remplir la sortie, numéro 108\"", + "[109] link \"Un lien assez long pour remplir la sortie, numéro 109\"", + "[110] link \"Un lien assez long pour remplir la sortie, numéro 110\"", + "[111] link \"Un lien assez long pour remplir la sortie, numéro 111\"", + "[112] link \"Un lien assez long pour remplir la sortie, numéro 112\"", + "[113] link \"Un lien assez long pour remplir la sortie, numéro 113\"", + "[114] link \"Un lien assez long pour remplir la sortie, numéro 114\"", + "[115] link \"Un lien assez long pour remplir la sortie, numéro 115\"", + "[116] link \"Un lien assez long pour remplir la sortie, numéro 116\"", + "[117] link \"Un lien assez long pour remplir la sortie, numéro 117\"", + "[118] link \"Un lien assez long pour remplir la sortie, numéro 118\"", + "[119] link \"Un lien assez long pour remplir la sortie, numéro 119\"", + "[120] link \"Un lien assez long pour remplir la sortie, numéro 120\"", + "[121] link \"Un lien assez long pour remplir la sortie, numéro 121\"", + "[122] link \"Un lien assez long pour remplir la sortie, numéro 122\"", + "[123] link \"Un lien assez long pour remplir la sortie, numéro 123\"", + "[124] link \"Un lien assez long pour remplir la sortie, numéro 124\"", + "[125] link \"Un lien assez long pour remplir la sortie, numéro 125\"", + "[126] link \"Un lien assez long pour remplir la sortie, numéro 126\"", + "[127] link \"Un lien assez long pour remplir la sortie, numéro 127\"", + "[128] link \"Un lien assez long pour remplir la sortie, numéro 128\"", + "[129] link \"Un lien assez long pour remplir la sortie, numéro 129\"", + "[130] link \"Un lien assez long pour remplir la sortie, numéro 130\"", + "[131] link \"Un lien assez long pour remplir la sortie, numéro 131\"", + "[132] link \"Un lien assez long pour remplir la sortie, numéro 132\"", + "[133] link \"Un lien assez long pour remplir la sortie, numéro 133\"", + "[134] link \"Un lien assez long pour remplir la sortie, numéro 134\"", + "[135] link \"Un lien assez long pour remplir la sortie, numéro 135\"", + "[136] link \"Un lien assez long pour remplir la sortie, numéro 136\"", + "[137] link \"Un lien assez long pour remplir la sortie, numéro 137\"", + "[138] link \"Un lien assez long pour remplir la sortie, numéro 138\"", + "[139] link \"Un lien assez long pour remplir la sortie, numéro 139\"", + "[140] link \"Un lien assez long pour remplir la sortie, numéro 140\"", + "[141] link \"Un lien assez long pour remplir la sortie, numéro 141\"", + "[142] link \"Un lien assez long pour remplir la sortie, numéro 142\"", + "[143] link \"Un lien assez long pour remplir la sortie, numéro 143\"", + "[144] link \"Un lien assez long pour remplir la sortie, numéro 144\"", + "[145] link \"Un lien assez long pour remplir la sortie, numéro 145\"", + "[146] link \"Un lien assez long pour remplir la sortie, numéro 146\"", + "[147] link \"Un lien assez long pour remplir la sortie, numéro 147\"", + "[148] link \"Un lien assez long pour remplir la sortie, numéro 148\"", + "[149] link \"Un lien assez long pour remplir la sortie, numéro 149\"", + "[150] link \"Un lien assez long pour remplir la sortie, numéro 150\"", + "[151] link \"Un lien assez long pour remplir la sortie, numéro 151\"", + "[152] link \"Un lien assez long pour remplir la sortie, numéro 152\"", + "[153] link \"Un lien assez long pour remplir la sortie, numéro 153\"", + "[154] link \"Un lien assez long pour remplir la sortie, numéro 154\"", + "[155] link \"Un lien assez long pour remplir la sortie, numéro 155\"", + "[156] link \"Un lien assez long pour remplir la sortie, numéro 156\"", + "[157] link \"Un lien assez long pour remplir la sortie, numéro 157\"", + "[158] link \"Un lien assez long pour remplir la sortie, numéro 158\"", + "[159] link \"Un lien assez long pour remplir la sortie, numéro 159\"", + "[160] link \"Un lien assez long pour remplir la sortie, numéro 160\"", + "[161] link \"Un lien assez long pour remplir la sortie, numéro 161\"", + "[162] link \"Un lien assez long pour remplir la sortie, numéro 162\"", + "[163] link \"Un lien assez long pour remplir la sortie, numéro 163\"", + "[164] link \"Un lien assez long pour remplir la sortie, numéro 164\"", + "[165] link \"Un lien assez long pour remplir la sortie, numéro 165\"", + "[166] link \"Un lien assez long pour remplir la sortie, numéro 166\"", + "[167] link \"Un lien assez long pour remplir la sortie, numéro 167\"", + "[168] link \"Un lien assez long pour remplir la sortie, numéro 168\"", + "[169] link \"Un lien assez long pour remplir la sortie, numéro 169\"", + "[170] link \"Un lien assez long pour remplir la sortie, numéro 170\"", + "[171] link \"Un lien assez long pour remplir la sortie, numéro 171\"", + "[172] link \"Un lien assez long pour remplir la sortie, numéro 172\"", + "[173] link \"Un lien assez long pour remplir la sortie, numéro 173\"", + "[174] link \"Un lien assez long pour remplir la sortie, numéro 174\"", + "[175] link \"Un lien assez long pour remplir la sortie, numéro 175\"", + "[176] link \"Un lien assez long pour remplir la sortie, numéro 176\"", + "[177] link \"Un lien assez long pour remplir la sortie, numéro 177\"", + "[178] link \"Un lien assez long pour remplir la sortie, numéro 178\"", + "[179] link \"Un lien assez long pour remplir la sortie, numéro 179\"", + "[180] link \"Un lien assez long pour remplir la sortie, numéro 180\"", + "[181] link \"Un lien assez long pour remplir la sortie, numéro 181\"", + "[182] link \"Un lien assez long pour remplir la sortie, numéro 182\"", + "[183] link \"Un lien assez long pour remplir la sortie, numéro 183\"", + "[184] link \"Un lien assez long pour remplir la sortie, numéro 184\"", + "[185] link \"Un lien assez long pour remplir la sortie, numéro 185\"", + "[186] link \"Un lien assez long pour remplir la sortie, numéro 186\"", + "[187] link \"Un lien assez long pour remplir la sortie, numéro 187\"", + "[188] link \"Un lien assez long pour remplir la sortie, numéro 188\"", + "[189] link \"Un lien assez long pour remplir la sortie, numéro 189\"", + "[190] link \"Un lien assez long pour remplir la sortie, numéro 190\"", + "[191] link \"Un lien assez long pour remplir la sortie, numéro 191\"", + "[192] link \"Un lien assez long pour remplir la sortie, numéro 192\"", + "[193] link \"Un lien assez long pour remplir la sortie, numéro 193\"", + "[194] link \"Un lien assez long pour remplir la sortie, numéro 194\"", + "[195] link \"Un lien assez long pour remplir la sortie, numéro 195\"", + "[196] link \"Un lien assez long pour remplir la sortie, numéro 196\"", + "[197] link \"Un lien assez long pour remplir la sortie, numéro 197\"", + "[198] link \"Un lien assez long pour remplir la sortie, numéro 198\"", + "[199] link \"Un lien assez long pour remplir la sortie, numéro 199\"", + "[200] link \"Un lien assez long pour remplir la sortie, numéro 200\"" + ], + "title": "Long", + "url": "https://long.test/" + } + }, + "tool": "browser_snapshot", + "args": {}, + "result": "# Long\nhttps://long.test/\n\n200 élément(s) interactif(s) VISIBLE(s) — clique par numéro avec browser_click(ref) :\n[1] link \"Un lien assez long pour remplir la sortie, numéro 1\"\n[2] link \"Un lien assez long pour remplir la sortie, numéro 2\"\n[3] link \"Un lien assez long pour remplir la sortie, numéro 3\"\n[4] link \"Un lien assez long pour remplir la sortie, numéro 4\"\n[5] link \"Un lien assez long pour remplir la sortie, numéro 5\"\n[6] link \"Un lien assez long pour remplir la sortie, numéro 6\"\n[7] link \"Un lien assez long pour remplir la sortie, numéro 7\"\n[8] link \"Un lien assez long pour remplir la sortie, numéro 8\"\n[9] link \"Un lien assez long pour remplir la sortie, numéro 9\"\n[10] link \"Un lien assez long pour remplir la sortie, numéro 10\"\n[11] link \"Un lien assez long pour remplir la sortie, numéro 11\"\n[12] link \"Un lien assez long pour remplir la sortie, numéro 12\"\n[13] link \"Un lien assez long pour remplir la sortie, numéro 13\"\n[14] link \"Un lien assez long pour remplir la sortie, numéro 14\"\n[15] link \"Un lien assez long pour remplir la sortie, numéro 15\"\n[16] link \"Un lien assez long pour remplir la sortie, numéro 16\"\n[17] link \"Un lien assez long pour remplir la sortie, numéro 17\"\n[18] link \"Un lien assez long pour remplir la sortie, numéro 18\"\n[19] link \"Un lien assez long pour remplir la sortie, numéro 19\"\n[20] link \"Un lien assez long pour remplir la sortie, numéro 20\"\n[21] link \"Un lien assez long pour remplir la sortie, numéro 21\"\n[22] link \"Un lien assez long pour remplir la sortie, numéro 22\"\n[23] link \"Un lien assez long pour remplir la sortie, numéro 23\"\n[24] link \"Un lien assez long pour remplir la sortie, numéro 24\"\n[25] link \"Un lien assez long pour remplir la sortie, numéro 25\"\n[26] link \"Un lien assez long pour remplir la sortie, numéro 26\"\n[27] link \"Un lien assez long pour remplir la sortie, numéro 27\"\n[28] link \"Un lien assez long pour remplir la sortie, numéro 28\"\n[29] link \"Un lien assez long pour remplir la sortie, numéro 29\"\n[30] link \"Un lien assez long pour remplir la sortie, numéro 30\"\n[31] link \"Un lien assez long pour remplir la sortie, numéro 31\"\n[32] link \"Un lien assez long pour remplir la sortie, numéro 32\"\n[33] link \"Un lien assez long pour remplir la sortie, numéro 33\"\n[34] link \"Un lien assez long pour remplir la sortie, numéro 34\"\n[35] link \"Un lien assez long pour remplir la sortie, numéro 35\"\n[36] link \"Un lien assez long pour remplir la sortie, numéro 36\"\n[37] link \"Un lien assez long pour remplir la sortie, numéro 37\"\n[38] link \"Un lien assez long pour remplir la sortie, numéro 38\"\n[39] link \"Un lien assez long pour remplir la sortie, numéro 39\"\n[40] link \"Un lien assez long pour remplir la sortie, numéro 40\"\n[41] link \"Un lien assez long pour remplir la sortie, numéro 41\"\n[42] link \"Un lien assez long pour remplir la sortie, numéro 42\"\n[43] link \"Un lien assez long pour remplir la sortie, numéro 43\"\n[44] link \"Un lien assez long pour remplir la sortie, numéro 44\"\n[45] link \"Un lien assez long pour remplir la sortie, numéro 45\"\n[46] link \"Un lien assez long pour remplir la sortie, numéro 46\"\n[47] link \"Un lien assez long pour remplir la sortie, numéro 47\"\n[48] link \"Un lien assez long pour remplir la sortie, numéro 48\"\n[49] link \"Un lien assez long pour remplir la sortie, numéro 49\"\n[50] link \"Un lien assez long pour remplir la sortie, numéro 50\"\n[51] link \"Un lien assez long pour remplir la sortie, numéro 51\"\n[52] link \"Un lien assez long pour remplir la sortie, numéro 52\"\n[53] link \"Un lien assez long pour remplir la sortie, numéro 53\"\n[54] link \"Un lien assez long pour remplir la sortie, numéro 54\"\n[55] link \"Un lien assez long pour remplir la sortie, numéro 55\"\n[56] link \"Un lien assez long pour remplir la sortie, numéro 56\"\n[57] link \"Un lien assez long pour remplir la sortie, numéro 57\"\n[58] link \"Un lien assez long pour remplir la sortie, numéro 58\"\n[59] link \"Un lien assez long pour remplir la sortie, numéro 59\"\n[60] link \"Un lien assez long pour remplir la sortie, numéro 60\"\n[61] link \"Un lien assez long pour remplir la sortie, numéro 61\"\n[62] link \"Un lien assez long pour remplir la sortie, numéro 62\"\n[63] link \"Un lien assez long pour remplir la sortie, numéro 63\"\n[64] link \"Un lien assez long pour remplir la sortie, numéro 64\"\n[65] link \"Un lien assez long pour remplir la sortie, numéro 65\"\n[66] link \"Un lien assez long pour remplir la sortie, numéro 66\"\n[67] link \"Un lien assez long pour remplir la sortie, numéro 67\"\n[68] link \"Un lien assez long pour remplir la sortie, numéro 68\"\n[69] link \"Un lien assez long pour remplir la sortie, numéro 69\"\n[70] link \"Un lien assez long pour remplir la sortie, numéro 70\"\n[71] link \"Un lien assez long pour remplir la sortie, numéro 71\"\n[72] link \"Un lien assez long pour remplir la sortie, numéro 72\"\n[73] link \"Un lien assez long pour remplir la sortie, numéro 73\"\n[74] link \"Un lien assez long pour remplir la sortie, numéro 74\"\n[75] link \"Un lien assez long pour remplir la sortie, numéro 75\"\n[76] link \"Un lien assez long pour remplir la sortie, numéro 76\"\n[77] link \"Un lien assez long pour remplir la sortie, numéro 77\"\n[78] link \"Un lien assez long pour remplir la sortie, numéro 78\"\n[79] link \"Un lien assez long pour remplir la sortie, numéro 79\"\n[80] link \"Un lien assez long pour remplir la sortie, numéro 80\"\n[81] link \"Un lien assez long pour remplir la sortie, numéro 81\"\n[82] link \"Un lien assez long pour remplir la sortie, numéro 82\"\n[83] link \"Un lien assez long pour remplir la sortie, numéro 83\"\n[84] link \"Un lien assez long pour remplir la sortie, numéro 84\"\n[85] link \"Un lien assez long pour remplir la sortie, numéro 85\"\n[86] link \"Un lien assez long pour remplir la sortie, numéro 86\"\n[87] link \"Un lien assez long pour remplir la sortie, numéro 87\"\n[88] link \"Un lien assez long pour remplir la sortie, numéro 88\"\n[89] link \"Un lien assez long pour remplir la sortie, numéro 89\"\n[90] link \"Un lien assez long pour remplir la sortie, numéro 90\"\n[91] link \"Un lien assez long pour remplir la sortie, numéro 91\"\n[92] link \"Un lien assez long pour remplir la sortie, numéro 92\"\n[93] link \"Un lien assez long pour remplir la sortie, numéro 93\"\n[94] link \"Un lien assez long pour remplir la sortie, numéro 94\"\n[95] link \"Un lien assez long pour remplir la sortie, numéro 95\"\n[96] link \"Un lien assez long pour remplir la sortie, numéro 96\"\n[97] link \"Un lien assez long pour remplir la sortie, numéro 97\"\n[98] link \"Un lien assez long pour remplir la sortie, numéro 98\"\n[99] link \"Un lien assez long pour remplir la sortie, numéro 99\"\n[100] link \"Un lien assez long pour remplir la sortie, numéro 100\"\n[101] link \"Un lien assez long pour remplir la sortie, numéro 101\"\n[102] link \"Un lien assez long pour remplir la sortie, numéro 102\"\n[103] link \"Un lien assez long pour remplir la sortie, numéro 103\"\n[104] link \"Un lien assez long pour remplir la sortie, numéro 104\"\n[105] link \"Un lien assez long pour remplir la sortie, numéro 105\"\n[106] link \"Un lien assez long pour remplir la sortie, numéro 106\"\n[107] link \"Un lien assez long pour remplir la sortie, numéro 107\"\n[108] link \"Un lien assez long pour remplir la sortie, numéro 108\"\n[109] link \"Un lien assez long pour remplir la sortie, numéro 109\"\n[110] link \"Un lien assez long pour remplir la sortie, numéro 110\"\n[111] link \"Un lien assez long pour remplir la sortie, numéro 111\"\n[112] link \"Un lien assez long pour remplir la sortie, numéro 112\"\n[113] link \"Un lien assez long pour remplir la sortie, numéro 113\"\n[114] link \"Un lien assez long pour remplir la sortie, numéro 114\"\n[115] link \"Un lien assez long pour remplir la sortie, numéro 115\"\n[116] link \"Un lien assez long pour remplir la sortie, numéro 116\"\n[117] link \"Un lien assez long pour remplir la sortie, numéro 117\"\n[118] link \"Un lien assez long pour remplir la sortie, numéro 118\"\n[119] link \"Un lien assez long pour remplir la sortie, numéro 119\"\n[120] link \"Un lien assez long pour remplir la sortie, numéro 120\"\n[121] link \"Un lien assez long pour remplir la sortie, numéro 1\n…[tronqué]", + "saved": [], + "calls": [ + "Runtime.evaluate {\"expression\":\"1\",\"returnByValue\":true}", + "Runtime.evaluate {\"awaitPromise\":true,\"expression\":\"\\nfunction ajRole(el){var r=el.getAttribu…[4073:d76a42af8356]\",\"returnByValue\":true}" + ] + } + ] +} From db1f20c0a4c6e3a0567b43176920cbaf2f3b7650 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 16 Oct 2026 06:36:08 +0000 Subject: [PATCH 48/52] =?UTF-8?q?Lot=2045=20:=20outils=20d'image=20(see=5F?= =?UTF-8?q?image,=20web=5Fscreenshot)=20tenus=20par=20la=20fa=C3=A7ade?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit La façade exécute les deux outils qui montrent une image au modèle (src/imagetools.rs, port de chat_vision_tool.go, chat_screenshot.go et prepareImageForModel) : préparation de l'image comme les pièces jointes (orientation EXIF, grand côté sous 1568 px, mêmes pixels), sonde du moteur (/props authentifié, gardée 10 s ; preset externe : sa case), see_image aux textes du Go, web_screenshot par Playwright (adresses locales quand internet est coupé, mêmes arguments, erreur résumée, capture rangée dans le dossier de la discussion, ménage à 20 captures et 40 Mo, relayée au modèle s'il voit les images). Le Go garde les siens pour ses sous-agents et tâches IA. Parité : journal tiré du Go avec ses fichiers, un faux moteur et un faux Playwright (tests/parity_images/images.json), rejoué par tests/imagetools.rs ; un tour de la façade sans passer par le Go (tests/lot6.rs) ; tests unitaires. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_016tcGfJWefW5kUGoC2wL8cv --- ...ust-migration-lot45-outils-image-design.md | 116 ++++ internal/loki/parity_image_tools_test.go | 344 ++++++++++ rust/loki-rs/src/chat/generate.rs | 1 + rust/loki-rs/src/chat/host.rs | 70 ++ rust/loki-rs/src/convfiles.rs | 11 + rust/loki-rs/src/imagetools.rs | 456 +++++++++++++ rust/loki-rs/src/imagetools/tests.rs | 150 +++++ rust/loki-rs/src/lib.rs | 1 + rust/loki-rs/tests/imagetools.rs | 286 ++++++++ rust/loki-rs/tests/lot6.rs | 41 ++ rust/loki-rs/tests/parity_images/images.json | 614 ++++++++++++++++++ 11 files changed, 2090 insertions(+) create mode 100644 docs/superpowers/specs/2026-10-16-rust-migration-lot45-outils-image-design.md create mode 100644 internal/loki/parity_image_tools_test.go create mode 100644 rust/loki-rs/src/imagetools.rs create mode 100644 rust/loki-rs/src/imagetools/tests.rs create mode 100644 rust/loki-rs/tests/imagetools.rs create mode 100644 rust/loki-rs/tests/parity_images/images.json diff --git a/docs/superpowers/specs/2026-10-16-rust-migration-lot45-outils-image-design.md b/docs/superpowers/specs/2026-10-16-rust-migration-lot45-outils-image-design.md new file mode 100644 index 0000000..0aff8aa --- /dev/null +++ b/docs/superpowers/specs/2026-10-16-rust-migration-lot45-outils-image-design.md @@ -0,0 +1,116 @@ +# Lot 45 — Outils d'image de l'agent dans la façade + +## Contexte + +Deux outils de l'agent montrent une image au modèle : + +- `see_image` charge un fichier image du disque dans la vision du + modèle ; +- `web_screenshot` photographie une page rendue par Playwright, la range + dans le dossier de la discussion et donne à l'agent la ligne markdown + qui la montre à l'utilisateur. + +La façade tient la conversation par défaut, mais ces deux outils passaient +encore par le Go (`/internal/v1/tool/exec`), qui renvoyait l'image à +relayer dans `vision_img`. + +## Le lot + +`src/imagetools.rs` porte trois fichiers Go : + +- `chat_vision_tool.go` (`see_image`) ; +- `chat_screenshot.go` (`web_screenshot`) ; +- `prepareImageForModel` (`web_upload_orient.go`). + +Ce qui est porté : + +- **la préparation de l'image**, la même que pour les pièces jointes : + - orientation EXIF d'une JPEG redressée dans les pixels ; + - grand côté ramené sous 1568 px (mêmes pixels que le Go) ; + - PNG réencodée en PNG, le reste en JPEG qualité 88 ; + - rien à faire, ou image que la bibliothèque standard du Go ne décode + pas (WebP, BMP, PNG cassée) : les octets d'origine ; +- **la sonde du moteur** (`engineSeesImages`) : + - preset externe : sa case « vision » ; + - sinon `GET /props` du moteur local, authentifié par la clé de + complétion, gardé 10 s ; + - appelée en dernier, après toutes les vérifications du fichier ; +- **`see_image`**, mêmes textes : + - vision coupée, poste distant, chemin manquant ; + - format non reconnu, fichier introuvable, dossier ; + - trop lourde (12 Mo au plus) ; + - moteur aveugle, fichier vide ; + - chemin relatif au dossier de travail de l'agent, ou absolu ; +- **`web_screenshot`**, mêmes textes : + - Playwright absent, url manquante ou invalide ; + - internet coupé : seule une adresse locale passe (`localhost`, + `127.0.0.1`, `::1`, `0.0.0.0`, `*.localhost`, casse comprise comme + le Go) ; + - mêmes arguments de Playwright (viewport, page entière, attente, + 90 s au plus) ; + - erreur résumée par ses trois dernières lignes non vides ; + - aucune image ou image vide refusée ; + - capture rangée dans `captures/` du dossier de la discussion, nommée + par l'hôte et l'heure locale ; + - ménage : 20 captures et 40 Mo au plus, la nouvelle reste ; + - relayée au modèle (« Voici la capture demandée. ») seulement si la + vision est active et que le moteur voit les images. + +`ChatShared.vision` porte la sonde du processus ; `GoTurnHost::exec_tool` +exécute les deux outils : + +- `see_image` prend le dossier de travail et le poste visé du contexte + des outils (lot 7) ; contexte indisponible : le Go prend le relais ; +- `web_screenshot` range ses captures dans le dossier de la discussion + principale, sous le dossier de travail de l'agent, et les montre par + `ToolOutcome.image`. + +Rien ne change côté Go : ces outils n'ont pas d'état partagé, et le Go +garde les siens pour ses sous-agents et ses tâches IA. + +## Écarts connus + +- Encodeurs PNG et JPEG : mêmes pixels, octets différents. + +## Parité + +`internal/loki/parity_image_tools_test.go` (Linux) tire du Go un journal +(`tests/parity_images/images.json`) avec ses fichiers (en base64), un faux +moteur (`/props` authentifié, vision réglée par étape, sondes comptées) et +un faux Playwright (script qui note ses arguments et produit l'image, rien, +un fichier vide ou une erreur). + +Chaque étape porte : + +- le résultat, avec le texte relayé au modèle ; +- l'image relayée : + - identique à un fichier : son nom ; + - réencodée : son type et ses dimensions ; + - en PNG : cinq pixels ; +- la capture montrée, les arguments de Playwright, le nombre de sondes ; +- le dossier des captures. + +Le journal couvre : + +- `see_image` : + - vision coupée ; + - chemin vide, pas une image, introuvable, dossier, trop lourde ; + - moteur aveugle, fichier vide ; + - onze images : PNG, JPEG, GIF, WebP, BMP, PNG cassée, JPEG tournée, + grandes images réduites en largeur et en hauteur, sous-dossier, + chemin absolu ; +- `web_screenshot` : + - url manquante ou invalide, internet coupé ; + - capture locale sans vision, capture vue par le modèle ; + - page entière réduite ; + - vision mais moteur aveugle ; + - Playwright en échec, image vide, aucune image ; + - ménage des captures. + +`tests/imagetools.rs` le rejoue contre le port. D'autres tests +complètent : + +- `tests/lot6.rs` (`outils_image_par_la_facade`) : un tour de la façade + appelle les deux outils sans passer par le Go ; +- tests unitaires : types d'image, noms de capture, adresses locales, + dernières lignes, préparation, ménage, refus avant Playwright. diff --git a/internal/loki/parity_image_tools_test.go b/internal/loki/parity_image_tools_test.go new file mode 100644 index 0000000..b71d48d --- /dev/null +++ b/internal/loki/parity_image_tools_test.go @@ -0,0 +1,344 @@ +//go:build linux + +package loki + +import ( + "bytes" + "crypto/sha256" + "encoding/base64" + "encoding/hex" + "encoding/json" + "fmt" + "image" + "image/color" + "image/jpeg" + "image/png" + "net/http" + "net/http/httptest" + "os" + "path/filepath" + "regexp" + "sort" + "strings" + "sync" + "testing" + "time" +) + +// parity_image_tools_test.go — journal de référence des outils d'image de +// l'agent (lot 45 de la migration Rust) : see_image et web_screenshot, avec la +// préparation des images (orientation EXIF, grand côté sous 1568 px) et la +// sonde « le moteur voit-il les images » (faux /props). +// +// Une image laissée telle quelle est comparée à l'octet (empreinte) ; une +// image réencodée, par ses dimensions et son type (l'encodeur diffère), et +// une PNG réduite par quelques pixels (même réduction des deux côtés). +// web_screenshot passe par un faux Playwright qui range ses arguments et +// écrit l'image demandée par le scénario. Rangé dans +// rust/loki-rs/tests/parity_images/images.json ; tests/imagetools.rs le +// rejoue contre le port. +// +// LOKI_PARITY_UPDATE=1 go test ./internal/loki -run TestPariteOutilsImage + +const parityImagesFile = "../../rust/loki-rs/tests/parity_images/images.json" + +// parityFakePlaywright : écrit le fichier « image » de son dossier à la +// place de la capture (dernier argument), ou échoue selon « mode ». +const parityFakePlaywright = `#!/bin/sh +d=$(dirname "$0") +printf '%s\n' "$@" > "$d/args" +for a in "$@"; do out="$a"; done +mode=$(cat "$d/mode" 2>/dev/null) +case "$mode" in + echec) echo "Launching chromium" ; echo "" ; echo "page.goto: net::ERR_NAME_NOT_RESOLVED at https://x" >&2 ; echo "Call log:" >&2 ; echo " - navigating" >&2 ; exit 1 ;; + vide) : > "$out" ;; + rien) ;; + *) cat "$d/image" > "$out" ;; +esac +` + +type parityImageStep struct { + Name string `json:"name"` + Vision bool `json:"vision,omitempty"` + Sees bool `json:"sees,omitempty"` // le faux /props annonce la vision + Mode string `json:"mode,omitempty"` // faux Playwright + Image string `json:"image,omitempty"` // fichier rendu par le faux Playwright + Internet bool `json:"internet,omitempty"` + Tool string `json:"tool"` + Args map[string]any `json:"args"` + Label string `json:"label"` + Result string `json:"result"` + Part map[string]any `json:"part,omitempty"` // image relayée + Shown string `json:"shown,omitempty"` // web_screenshot : la capture montrée dans le fil + PWArgs []string `json:"pw_args,omitempty"` + Probes int `json:"probes"` + Captures []string `json:"captures,omitempty"` +} + +var parityImgStampRe = regexp.MustCompile(`[0-9]{8}-[0-9]{6}`) + +// parityImageFiles : les images du dossier de travail. +func parityImageFiles() map[string][]byte { + grad := func(w, h int) *image.NRGBA { + img := image.NewNRGBA(image.Rect(0, 0, w, h)) + for y := 0; y < h; y++ { + for x := 0; x < w; x++ { + img.SetNRGBA(x, y, color.NRGBA{R: uint8(x * 255 / w), G: uint8(y * 255 / h), B: uint8((x + y) % 256), A: 255}) + } + } + return img + } + enc := func(img image.Image, kind string) []byte { + var b bytes.Buffer + if kind == "png" { + _ = png.Encode(&b, img) + } else { + _ = jpeg.Encode(&b, img, &jpeg.Options{Quality: 90}) + } + return b.Bytes() + } + // JPEG avec un tag EXIF Orientation=6 (photo de téléphone tenue debout). + withExif := func(j []byte, orient byte) []byte { + tiff := []byte{'M', 'M', 0, 0x2A, 0, 0, 0, 8, 0, 1, 0x01, 0x12, 0, 3, 0, 0, 0, 1, 0, orient, 0, 0, 0, 0, 0, 0} + payload := append([]byte("Exif\x00\x00"), tiff...) + seg := []byte{0xFF, 0xE1, byte((len(payload) + 2) >> 8), byte(len(payload) + 2)} + seg = append(seg, payload...) + return append(append(append([]byte{}, j[:2]...), seg...), j[2:]...) + } + return map[string][]byte{ + "petite.png": enc(grad(10, 8), "png"), + "grande.png": enc(grad(2000, 1000), "png"), + "haute.png": enc(grad(300, 1700), "png"), + "photo.jpg": enc(grad(40, 20), "jpeg"), + "tournee.jpg": withExif(enc(grad(40, 20), "jpeg"), 6), + "grande.jpeg": enc(grad(1800, 900), "jpeg"), + "anim.gif": []byte("GIF89a\x01\x00\x01\x00\x80\x00\x00\x00\x00\x00\xff\xff\xff!\xf9\x04\x01\x00\x00\x00\x00,\x00\x00\x00\x00\x01\x00\x01\x00\x00\x02\x02D\x01\x00;"), + "image.webp": []byte("RIFF\x10\x00\x00\x00WEBPVP8 faux"), + "image.BMP": []byte("BM pas vraiment une image"), + "cassee.png": []byte("\x89PNG\r\n\x1a\nabîmé"), + "texte.txt": []byte("pas une image"), + "vide.png": {}, + "capture.jpg": enc(grad(1280, 800), "jpeg"), + "pleine.jpg": enc(grad(1280, 3000), "jpeg"), + "capture.png": enc(grad(64, 40), "png"), + "sous/tres.png": enc(grad(3, 3), "png"), + } +} + +// parityPartFacts : ce qu'on compare d'une image relayée. +func parityPartFacts(t *testing.T, part map[string]any, raw map[string][]byte) map[string]any { + u, _ := part["image_url"].(map[string]any)["url"].(string) + i := strings.Index(u, ";base64,") + mime := strings.TrimPrefix(u[:i], "data:") + b, err := base64.StdEncoding.DecodeString(u[i+8:]) + if err != nil { + t.Fatal(err) + } + out := map[string]any{"mime": mime} + for name, r := range raw { + if bytes.Equal(r, b) { + out["same_as"] = name // laissée telle quelle : à l'octet + return out + } + } + img, _, err := image.Decode(bytes.NewReader(b)) + if err != nil { + h := sha256.Sum256(b) + out["sha"] = hex.EncodeToString(h[:8]) + return out + } + bd := img.Bounds() + out["w"], out["h"] = bd.Dx(), bd.Dy() + if mime == "image/png" { + px := map[string]string{} + for _, p := range [][2]int{{0, 0}, {1, 1}, {100, 50}, {bd.Dx() - 1, bd.Dy() - 1}, {bd.Dx() / 2, bd.Dy() / 3}} { + c := color.NRGBAModel.Convert(img.At(p[0], p[1])).(color.NRGBA) + px[fmt.Sprintf("%d,%d", p[0], p[1])] = fmt.Sprintf("%d,%d,%d,%d", c.R, c.G, c.B, c.A) + } + out["px"] = px + } + return out +} + +func TestPariteOutilsImage(t *testing.T) { + testHome(t) + root := t.TempDir() + files := filepath.Join(root, "fichiers") + raw := parityImageFiles() + for name, b := range raw { + p := filepath.Join(files, name) + _ = os.MkdirAll(filepath.Dir(p), 0o755) + if err := os.WriteFile(p, b, 0o644); err != nil { + t.Fatal(err) + } + } + _ = os.MkdirAll(filepath.Join(files, "dossier.png"), 0o755) + lourd, _ := os.Create(filepath.Join(files, "lourd.png")) + _ = lourd.Truncate(13 << 20) + lourd.Close() + setTaskWorkspace(files) + t.Cleanup(func() { setTaskWorkspace("") }) + + // Faux moteur : /props, authentifié. + var mu sync.Mutex + sees, probes := false, 0 + eng := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + mu.Lock() + defer mu.Unlock() + if r.URL.Path != "/props" || r.Header.Get("Authorization") != "Bearer cle-moteur" { + w.WriteHeader(401) + return + } + probes++ + _ = json.NewEncoder(w).Encode(map[string]any{"modalities": map[string]any{"vision": sees}}) + })) + defer eng.Close() + _ = SetConfigKey("PORT", strings.TrimPrefix(eng.URL, "http://127.0.0.1:")) + _ = putStr(bkState, "api_key", "cle-moteur") + + // Faux Playwright, seul « playwright » du PATH. + pwDir := t.TempDir() + if err := os.WriteFile(filepath.Join(pwDir, "playwright"), []byte(parityFakePlaywright), 0o755); err != nil { + t.Fatal(err) + } + t.Setenv("PATH", pwDir+string(os.PathListSeparator)+os.Getenv("PATH")) + conv := convEnsureActive() + capDir, _ := captureDirFor(conv) + + var steps []parityImageStep + run := func(st parityImageStep) { + if st.Vision { + _ = SetConfigKey("MMPROJ", "vision.gguf") + } else { + _ = SetConfigKey("MMPROJ", "") + } + mu.Lock() + sees, probes = st.Sees, 0 + mu.Unlock() + visionProbeMu.Lock() + visionProbeAt = time.Time{} + visionProbeMu.Unlock() + _ = os.WriteFile(filepath.Join(pwDir, "mode"), []byte(st.Mode), 0o644) + if st.Image != "" { + _ = os.WriteFile(filepath.Join(pwDir, "image"), raw[st.Image], 0o644) + } + _ = os.Remove(filepath.Join(pwDir, "args")) + args := map[string]any{} + b, _ := json.Marshal(st.Args) + _ = json.Unmarshal([]byte(strings.ReplaceAll(string(b), "{{FICHIERS}}", files)), &args) + label := toolCallLabel(st.Tool, args) + st.Label = strings.ReplaceAll(label, files, "{{FICHIERS}}") + res := execTool(t.Context(), st.Tool, args, label, Caps{Agent: true, Internet: st.Internet}) + image, _, relay := toolStepExtras(st.Tool, label, res) + st.Result = strings.ReplaceAll(parityImgStampRe.ReplaceAllString(res.Result, "{{T}}"), files, "{{FICHIERS}}") + st.Shown = parityImgStampRe.ReplaceAllString(image, "{{T}}") + var part map[string]any + if relay != nil { + parts, _ := json.Marshal(relay.Content) + var ps []map[string]any + _ = json.Unmarshal(parts, &ps) + for _, p := range ps { + if p["type"] == "image_url" { + part = p + } + if p["type"] == "text" { + st.Result += "\n[relais] " + strings.ReplaceAll(fmt.Sprint(p["text"]), files, "{{FICHIERS}}") + } + } + } + if part != nil { + st.Part = parityPartFacts(t, part, raw) + } + if b, err := os.ReadFile(filepath.Join(pwDir, "args")); err == nil { + for _, l := range strings.Split(strings.TrimRight(string(b), "\n"), "\n") { + st.PWArgs = append(st.PWArgs, parityImgStampRe.ReplaceAllString(strings.ReplaceAll(l, capDir, "{{CAPTURES}}"), "{{T}}")) + } + } + mu.Lock() + st.Probes = probes + mu.Unlock() + if st.Tool == "web_screenshot" { + entries, _ := os.ReadDir(capDir) + st.Captures = []string{} + for _, e := range entries { + st.Captures = append(st.Captures, parityImgStampRe.ReplaceAllString(e.Name(), "{{T}}")) + } + sort.Strings(st.Captures) + } + if st.Args == nil { + st.Args = map[string]any{} + } + steps = append(steps, st) + } + a := func(kv ...any) map[string]any { + m := map[string]any{} + for i := 0; i+1 < len(kv); i += 2 { + m[kv[i].(string)] = kv[i+1] + } + return m + } + + // see_image. + run(parityImageStep{Name: "sans vision", Tool: "see_image", Args: a("file", "petite.png")}) + run(parityImageStep{Name: "chemin manquant", Vision: true, Tool: "see_image", Args: a("file", " ")}) + run(parityImageStep{Name: "pas une image", Vision: true, Tool: "see_image", Args: a("file", "texte.txt")}) + run(parityImageStep{Name: "introuvable", Vision: true, Tool: "see_image", Args: a("file", "absente.png")}) + run(parityImageStep{Name: "dossier", Vision: true, Tool: "see_image", Args: a("file", "dossier.png")}) + run(parityImageStep{Name: "trop lourde", Vision: true, Tool: "see_image", Args: a("file", "lourd.png")}) + run(parityImageStep{Name: "le moteur ne voit pas", Vision: true, Tool: "see_image", Args: a("file", "petite.png")}) + run(parityImageStep{Name: "vide", Vision: true, Sees: true, Tool: "see_image", Args: a("file", "vide.png")}) + for _, f := range []string{"petite.png", "grande.png", "haute.png", "photo.jpg", "tournee.jpg", "grande.jpeg", "anim.gif", "image.webp", "image.BMP", "cassee.png", "sous/tres.png"} { + run(parityImageStep{Name: "voir " + f, Vision: true, Sees: true, Tool: "see_image", Args: a("file", f)}) + } + run(parityImageStep{Name: "chemin absolu", Vision: true, Sees: true, Tool: "see_image", Args: a("file", "{{FICHIERS}}/petite.png")}) + + // web_screenshot. + run(parityImageStep{Name: "capture sans url", Tool: "web_screenshot", Args: a()}) + run(parityImageStep{Name: "url invalide", Tool: "web_screenshot", Args: a("url", "ftp://x")}) + run(parityImageStep{Name: "internet coupé", Tool: "web_screenshot", Args: a("url", "https://exemple.test/")}) + run(parityImageStep{Name: "capture locale sans vision", Image: "capture.jpg", Tool: "web_screenshot", Args: a("url", "http://localhost:3000/app?x=1")}) + run(parityImageStep{Name: "capture vue par le modèle", Vision: true, Sees: true, Internet: true, Image: "capture.jpg", + Tool: "web_screenshot", Args: a("url", "https://www.exemple.test/page#ancre", "width", 1600.0)}) + run(parityImageStep{Name: "page entière réduite", Vision: true, Sees: true, Internet: true, Image: "pleine.jpg", + Tool: "web_screenshot", Args: a("url", "https://pleine.test/", "full_page", true, "width", 100.0)}) + run(parityImageStep{Name: "vision mais moteur aveugle", Vision: true, Internet: true, Image: "capture.png", + Tool: "web_screenshot", Args: a("url", "https://aveugle.test/")}) + run(parityImageStep{Name: "Playwright en échec", Internet: true, Mode: "echec", Tool: "web_screenshot", Args: a("url", "https://x.test/")}) + run(parityImageStep{Name: "image vide", Internet: true, Mode: "vide", Tool: "web_screenshot", Args: a("url", "https://vide.test/")}) + run(parityImageStep{Name: "aucune image", Internet: true, Mode: "rien", Tool: "web_screenshot", Args: a("url", "https://rien.test/")}) + // Ménage : 22 vieilles captures, plus la nouvelle → 20. + old := time.Now().Add(-time.Hour) + for i := 0; i < 22; i++ { + p := filepath.Join(capDir, fmt.Sprintf("vieille-%02d.jpg", i)) + _ = os.WriteFile(p, []byte("x"), 0o644) + mt := old.Add(time.Duration(i) * time.Second) + _ = os.Chtimes(p, mt, mt) + } + run(parityImageStep{Name: "ménage des captures", Internet: true, Image: "capture.png", Tool: "web_screenshot", Args: a("url", "http://127.0.0.1:8080/")}) + + var buf bytes.Buffer + enc := json.NewEncoder(&buf) + enc.SetEscapeHTML(false) + enc.SetIndent("", " ") + if err := enc.Encode(map[string]any{"files": raw, "steps": steps}); err != nil { + t.Fatal(err) + } + got := buf.Bytes() + if os.Getenv("LOKI_PARITY_UPDATE") == "1" { + if err := os.MkdirAll(filepath.Dir(parityImagesFile), 0o755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(parityImagesFile, got, 0o644); err != nil { + t.Fatal(err) + } + return + } + want, err := os.ReadFile(parityImagesFile) + if err != nil { + t.Fatalf("journal de référence absent (%v) : LOKI_PARITY_UPDATE=1 pour le créer", err) + } + if !bytes.Equal(got, want) { + t.Errorf("les outils d'image ne produisent plus le journal rangé dans %s.\n"+ + "LOKI_PARITY_UPDATE=1 pour le régénérer si le changement est voulu.", parityImagesFile) + } +} diff --git a/rust/loki-rs/src/chat/generate.rs b/rust/loki-rs/src/chat/generate.rs index 5a84078..4ef9a23 100644 --- a/rust/loki-rs/src/chat/generate.rs +++ b/rust/loki-rs/src/chat/generate.rs @@ -1325,6 +1325,7 @@ impl ChatShared { web: Default::default(), jobs: crate::tools::jobs::global(), browser: crate::browser::global(), + vision: Default::default(), go_version: Default::default(), } } diff --git a/rust/loki-rs/src/chat/host.rs b/rust/loki-rs/src/chat/host.rs index dd1c1f1..5ff33ef 100644 --- a/rust/loki-rs/src/chat/host.rs +++ b/rust/loki-rs/src/chat/host.rs @@ -358,6 +358,8 @@ pub struct ChatShared { pub jobs: Arc, /// Le navigateur piloté (lot 44). pub browser: Arc, + /// La sonde « le moteur voit-il les images » (lot 45). + pub vision: Arc, /// La version du Go, annoncée aux serveurs MCP à une reconnexion ; lue /// une fois. pub go_version: tokio::sync::OnceCell, @@ -716,6 +718,74 @@ impl TurnHost for GoTurnHost { ..Default::default() }; } + // Outils d'image (lot 45) : see_image dans le dossier de la + // discussion, web_screenshot par Playwright ; l'image relayée au + // modèle s'il la voit. + if name == "see_image" || name == "web_screenshot" { + let cfg = self.shared.store.config().await; + let vision = crate::system::vision_enabled(&cfg); + if name == "see_image" { + let st = crate::tools::GoToolState { + store: self.shared.store.clone(), + caps: self.caps.clone(), + }; + match crate::tools::ToolState::context(&st).await { + Ok(ctx) => { + let sees = self.shared.vision.sees(&self.shared.store, &cfg); + let (result, img) = crate::imagetools::see_image( + label, + &ctx.cwd, + &ctx.target, + vision, + sees, + ) + .await; + return ToolOutcome { + result, + relay: img.map(|i| see_image_message(label, i)), + ..Default::default() + }; + } + Err(e) => { + eprintln!("[outils] contexte indisponible, see_image par le Go : {e}") + } + } + } else { + let home = self.shared.images.home(); + let ws = tokio::task::spawn_blocking(move || { + let env = std::env::var(crate::paths::WORKSPACE_ENV).ok(); + crate::paths::agent_workspace(&home, env.as_deref()) + }) + .await + .unwrap_or_default(); + let conv_dir = crate::convfiles::conv_dir(&ws, &self.compact.main_conv); + let internet = self.caps.get("Internet").and_then(Value::as_bool) == Some(true); + let pw = crate::paths::look_path("playwright").unwrap_or_default(); + let shot = crate::imagetools::web_screenshot( + args, + &pw, + Path::new(&conv_dir), + internet, + &crate::imagetools::local_stamp(), + ) + .await; + let relay = if !shot.rel.is_empty() + && vision + && self.shared.vision.sees(&self.shared.store, &cfg).await + { + crate::imagetools::screenshot_message(&shot.abs) + .and_then(|m| serde_json::from_value::(m).ok()) + } else { + None + }; + return ToolOutcome { + result: shot.result, + image: shot.rel, + relay, + ..Default::default() + }; + } + } // Navigateur piloté (lot 44) : la session est ici ; aperçu de la page // après une action qui la change (jamais après une erreur), capture // relayée au modèle. diff --git a/rust/loki-rs/src/convfiles.rs b/rust/loki-rs/src/convfiles.rs index 1cd8608..f7b492c 100644 --- a/rust/loki-rs/src/convfiles.rs +++ b/rust/loki-rs/src/convfiles.rs @@ -145,6 +145,17 @@ fn safe_conv_id(id: &str) -> String { id.to_string() } +/// `convDirFor` : le dossier d'une discussion sous la racine de travail (la +/// racine elle-même pour un identifiant douteux). +pub fn conv_dir(root: &str, id: &str) -> String { + let id = safe_conv_id(id); + if id.is_empty() { + root.to_string() + } else { + go_join(&[root, CONV_FILES_ROOT, &id]) + } +} + /// `exifOrientation` : le tag Orientation (1..8) d'un JPEG, 0 sinon. pub fn exif_orientation(b: &[u8]) -> i32 { if b.len() < 4 || b[0] != 0xFF || b[1] != 0xD8 { diff --git a/rust/loki-rs/src/imagetools.rs b/rust/loki-rs/src/imagetools.rs new file mode 100644 index 0000000..837e5ed --- /dev/null +++ b/rust/loki-rs/src/imagetools.rs @@ -0,0 +1,456 @@ +//! Outils d'image de l'agent (lot 45) — port de `chat_vision_tool.go` +//! (`see_image`), de `chat_screenshot.go` (`web_screenshot`, Playwright) et +//! de `prepareImageForModel` (`web_upload_orient.go`). +//! +//! `see_image` charge une image du disque dans la vision du modèle ; +//! `web_screenshot` photographie une page rendue par Playwright, la range +//! dans le dossier de la discussion et la montre au modèle s'il voit les +//! images. Les deux passent par la même préparation que les pièces jointes : +//! orientation EXIF redressée, grand côté ramené sous 1568 px (mêmes pixels +//! que le Go ; l'encodeur, lui, diffère). + +use std::path::{Path, PathBuf}; +use std::time::{Duration, Instant, SystemTime}; + +use base64::Engine as _; +use serde_json::{Map, Value, json}; + +use crate::convfiles::{Rgba, apply_orientation, exif_orientation, scale_down_to_max}; +use crate::store::{BK_STATE, Store}; + +/// `maxImageDim`. +pub const MAX_IMAGE_DIM: usize = 1568; +/// `imageJPEGQuality`. +const JPEG_QUALITY: u8 = 88; +/// `maxVisionBytes`. +pub const MAX_VISION_BYTES: i64 = 12 << 20; +/// `screenshotTimeout`. +const SCREENSHOT_TIMEOUT: Duration = Duration::from_secs(90); +/// `maxCaptureFiles`, `maxCaptureBytes`. +const MAX_CAPTURE_FILES: usize = 20; +const MAX_CAPTURE_BYTES: u64 = 40 << 20; + +/// `imageMimes` : les images qu'on sait montrer au modèle. +pub fn image_mime(name: &str) -> &'static str { + let ext = Path::new(name) + .extension() + .map(|e| format!(".{}", e.to_string_lossy().to_lowercase())) + .unwrap_or_default(); + match ext.as_str() { + ".png" => "image/png", + ".jpg" | ".jpeg" => "image/jpeg", + ".gif" => "image/gif", + ".webp" => "image/webp", + ".bmp" => "image/bmp", + _ => "", + } +} + +/// `prepareImageForModel` : orientation cuite dans les pixels, grand côté +/// sous [`MAX_IMAGE_DIM`] ; rien à faire ou image indécodable : les octets +/// d'origine. +pub fn prepare_image_for_model(raw: &[u8], mime: &str) -> (Vec, String) { + let orient = if mime == "image/jpeg" { + exif_orientation(raw) + } else { + 0 + }; + let need_orient = (2..=8).contains(&orient); + // Les formats que décode la bibliothèque standard du Go. + let fmt = match image::guess_format(raw) { + Ok(f @ (image::ImageFormat::Png | image::ImageFormat::Jpeg | image::ImageFormat::Gif)) => f, + _ => return (raw.to_vec(), mime.to_string()), + }; + let Ok(src) = image::load_from_memory_with_format(raw, fmt) else { + return (raw.to_vec(), mime.to_string()); + }; + let (w, h) = (src.width() as usize, src.height() as usize); + let need_resize = w > MAX_IMAGE_DIM || h > MAX_IMAGE_DIM; + if !need_orient && !need_resize { + return (raw.to_vec(), mime.to_string()); + } + let mut img = Rgba { + w, + h, + pix: src.to_rgba8().into_raw(), + }; + if need_orient { + img = apply_orientation(&img, orient); + } + if img.w > MAX_IMAGE_DIM || img.h > MAX_IMAGE_DIM { + img = scale_down_to_max(&img, MAX_IMAGE_DIM); + } + let Some(buf) = image::RgbaImage::from_raw(img.w as u32, img.h as u32, img.pix) else { + return (raw.to_vec(), mime.to_string()); + }; + let mut out = std::io::Cursor::new(Vec::new()); + if mime == "image/png" { + if buf.write_to(&mut out, image::ImageFormat::Png).is_err() { + return (raw.to_vec(), mime.to_string()); + } + return (out.into_inner(), "image/png".into()); + } + let enc = image::codecs::jpeg::JpegEncoder::new_with_quality(&mut out, JPEG_QUALITY); + let rgb = image::DynamicImage::ImageRgba8(buf).to_rgb8(); + if rgb.write_with_encoder(enc).is_err() { + return (raw.to_vec(), mime.to_string()); + } + (out.into_inner(), "image/jpeg".into()) +} + +/// `imageURLPart`. +pub fn image_url_part(b: &[u8], mime: &str) -> Value { + json!({ + "type": "image_url", + "image_url": { "url": format!("data:{mime};base64,{}", base64::engine::general_purpose::STANDARD.encode(b)) }, + }) +} + +/// `engineSeesImages` : le moteur en service accepte-t-il des images ? +/// Sonde `/props` (authentifiée), gardée 10 s ; preset externe : sa case. +#[derive(Default)] +pub struct VisionProbe { + last: tokio::sync::Mutex>, +} + +impl VisionProbe { + pub async fn sees(&self, store: &Store, cfg: &crate::serve::Cfg) -> bool { + if crate::serve::is_external(cfg) { + return crate::system::vision_enabled(cfg); + } + let mut last = self.last.lock().await; + if let Some((at, seen)) = *last + && at.elapsed() < Duration::from_secs(10) + { + return seen; + } + *last = Some((Instant::now(), false)); + crate::chat::engine::install_crypto(); + let client = reqwest::Client::builder() + .timeout(Duration::from_secs(2)) + .build() + .unwrap_or_default(); + let url = format!("http://localhost:{}/props", crate::engstatus::llm_port(cfg)); + let mut rq = client.get(url); + let key = store.get_str(BK_STATE, "api_key").await; + if !key.is_empty() { + rq = rq.header("Authorization", format!("Bearer {key}")); + } + let Ok(resp) = rq.send().await else { + return false; + }; + if resp.status().as_u16() != 200 { + return false; + } + let v: Value = match resp.bytes().await { + Ok(b) => serde_json::from_slice(&b).unwrap_or_default(), + Err(_) => return false, + }; + let seen = v["modalities"]["vision"].as_bool().unwrap_or(false); + *last = Some((Instant::now(), seen)); + seen + } + + /// Oublie la dernière sonde (tests). + pub async fn reset(&self) { + *self.last.lock().await = None; + } +} + +/// `toolSeeImage` : (accusé, partie image). `cwd` et `target` : le contexte +/// des outils ; `sees` : la sonde du moteur, appelée en dernier. +pub async fn see_image( + path: &str, + cwd: &Path, + target: &str, + vision: bool, + sees: impl std::future::Future, +) -> (String, Option) { + let err = |m: String| (m, None); + if !vision { + return err("[erreur] la vision n'est pas active sur ce preset (aucun projecteur MMPROJ configuré) — impossible de voir une image".into()); + } + if !target.is_empty() { + return err("[erreur] voir une image n'est pas possible sur un poste distant (le fichier est sur l'autre machine)".into()); + } + if path.is_empty() { + return err("[erreur] chemin de fichier manquant".into()); + } + let abs = crate::tools::policy::resolve_agent_path(cwd, path); + let mime = image_mime(&abs.to_string_lossy()); + if mime.is_empty() { + return err(format!( + "[erreur] format non reconnu comme image (attendu : png, jpg, gif, webp, bmp) : {path}" + )); + } + let st = match std::fs::metadata(&abs) { + Ok(m) => m, + Err(_) => return err(format!("[erreur] fichier introuvable : {path}")), + }; + if st.is_dir() { + return err(format!("[erreur] c'est un dossier, pas une image : {path}")); + } + if st.len() as i64 > MAX_VISION_BYTES { + return err(format!( + "[erreur] image trop lourde ({}, max {}) : {path}", + crate::downloads::human_bytes(st.len() as i64), + crate::downloads::human_bytes(MAX_VISION_BYTES) + )); + } + if !sees.await { + return err(format!( + "[erreur] le moteur en service ne traite pas les images (projecteur non chargé ?) — impossible de voir {path}" + )); + } + let b = match std::fs::read(&abs) { + Ok(b) => b, + Err(e) => { + return err(format!( + "[erreur] lecture impossible : {}", + crate::paths::os_err("open", &abs.to_string_lossy(), &e) + )); + } + }; + if b.is_empty() { + return err(format!("[erreur] fichier vide : {path}")); + } + let (b, mime) = prepare_image_for_model(&b, mime); + let base = abs + .file_name() + .map(|n| n.to_string_lossy().into_owned()) + .unwrap_or_default(); + ( + format!("[ok] image chargée : {base}"), + Some(image_url_part(&b, &mime)), + ) +} + +// --- web_screenshot ------------------------------------------------------------- + +/// Le résultat de `web_screenshot` : le texte, et la capture rangée +/// (chemin relatif à la discussion, et absolu). +#[derive(Debug, Default, Clone, PartialEq)] +pub struct Shot { + pub result: String, + pub rel: String, + pub abs: PathBuf, +} + +/// `safeSlug`. +pub fn safe_slug(s: &str) -> String { + let re = regex::Regex::new(r"[^a-zA-Z0-9._-]+").expect("regex"); + let s = re.replace_all(s, "-"); + let s = s.trim_matches(['-', '.']); + let s: String = s.chars().take(40).collect(); + if s.is_empty() { "page".into() } else { s } +} + +/// `isLocalURL`. Le nom d'hôte tel qu'écrit (`url.Hostname()` du Go +/// garde la casse : `LOCALHOST` n'est pas local). +pub fn is_local_url(raw: &str) -> bool { + if url::Url::parse(raw).is_err() { + return false; + } + let host = go_hostname(raw); + matches!(host, "localhost" | "127.0.0.1" | "::1" | "0.0.0.0") || host.ends_with(".localhost") +} + +/// `url.Hostname()` : l'hôte de l'autorité, sans identifiants ni port, +/// sans crochets pour une IPv6. +fn go_hostname(raw: &str) -> &str { + let Some(i) = raw.find("://") else { + return ""; + }; + let rest = &raw[i + 3..]; + let auth = &rest[..rest.find(['/', '?', '#']).unwrap_or(rest.len())]; + let hp = auth.rfind('@').map_or(auth, |j| &auth[j + 1..]); + if let Some(v6) = hp.strip_prefix('[') { + return v6.find(']').map_or(v6, |j| &v6[..j]); + } + match hp.rfind(':') { + Some(c) if hp[c + 1..].bytes().all(|b| b.is_ascii_digit()) => &hp[..c], + _ => hp, + } +} + +/// `lastLines` : les n dernières lignes non vides, jointes par « · ». +pub fn last_lines(s: &str, n: usize) -> String { + let keep: Vec<&str> = s.split('\n').filter(|l| !l.trim().is_empty()).collect(); + keep[keep.len().saturating_sub(n)..].join(" · ") +} + +/// `pruneCaptures` : les plus anciennes captures partent tant que le +/// dossier dépasse 20 fichiers ou 40 Mo ; celle qui vient d'être prise reste. +pub fn prune_captures(dir: &Path, keep: &Path) { + let Ok(rd) = std::fs::read_dir(dir) else { + return; + }; + let mut shots: Vec<(PathBuf, SystemTime, u64)> = Vec::new(); + let mut total = 0u64; + for e in rd.flatten() { + let Ok(m) = e.metadata() else { continue }; + if m.is_dir() { + continue; + } + let p = e.path(); + total += m.len(); + if p == keep { + continue; + } + shots.push((p, m.modified().unwrap_or(SystemTime::UNIX_EPOCH), m.len())); + } + shots.sort_by_key(|s| s.1); + // Tous les fichiers comptent, celle qui reste comprise. + let mut nb = shots.len() + usize::from(!keep.as_os_str().is_empty()); + for (p, _, size) in shots { + if nb <= MAX_CAPTURE_FILES && total <= MAX_CAPTURE_BYTES { + break; + } + if std::fs::remove_file(&p).is_ok() { + total -= size; + nb -= 1; + } + } +} + +/// `toolWebScreenshot`. `playwright` : le binaire (vide : absent) ; +/// `conv_dir` : le dossier de la discussion ; `internet` : l'accès est-il +/// ouvert ; `stamp` : l'heure du nom de fichier. +pub async fn web_screenshot( + args: &Map, + playwright: &str, + conv_dir: &Path, + internet: bool, + stamp: &str, +) -> Shot { + let fail = |m: &str| Shot { + result: m.to_string(), + ..Default::default() + }; + if playwright.is_empty() { + return fail( + "[erreur] Playwright n'est pas installé dans cette image (bâtie avec PLAYWRIGHT=0).", + ); + } + let url = args + .get("url") + .and_then(Value::as_str) + .unwrap_or("") + .trim() + .to_string(); + if url.is_empty() { + return fail("[erreur] url manquante"); + } + if !url.starts_with("http://") && !url.starts_with("https://") { + return fail("[erreur] url invalide : elle doit commencer par http:// ou https://"); + } + if !internet && !is_local_url(&url) { + return fail( + "[refusé] L'accès internet est coupé : la capture ne marche que sur une adresse locale (localhost / 127.0.0.1), par exemple ton serveur de dev. Active l'accès internet pour le web.", + ); + } + let mut width = 1280i64; + if let Some(v) = args.get("width").and_then(Value::as_f64) + && (320.0..=3840.0).contains(&v) + { + width = v as i64; + } + let full_page = args.get("full_page").and_then(Value::as_bool) == Some(true); + let dir = conv_dir.join("captures"); + if let Err(e) = crate::paths::go_mkdir_all(&dir) { + return fail(&format!("[erreur] création du dossier de captures : {e}")); + } + let mut host = url.as_str(); + if let Some(i) = host.find("://") { + host = &host[i + 3..]; + } + if let Some(i) = host.find(['/', '?', '#']) { + host = &host[..i]; + } + let host = host.to_string(); + let name = format!("{}-{stamp}.jpg", safe_slug(&host)); + let out = dir.join(&name); + let mut cmd_args: Vec = [ + "screenshot", + "--browser", + "chromium", + "--viewport-size", + &format!("{width},800"), + "--wait-for-timeout", + "3500", + ] + .map(String::from) + .to_vec(); + if full_page { + cmd_args.push("--full-page".into()); + } + cmd_args.push(url.clone()); + cmd_args.push(out.to_string_lossy().into_owned()); + let run = tokio::process::Command::new(playwright) + .args(&cmd_args) + .current_dir(&dir) + .stdin(std::process::Stdio::null()) + .kill_on_drop(true) + .output(); + match tokio::time::timeout(SCREENSHOT_TIMEOUT, run).await { + Err(_) => return fail("[erreur] la page n'a pas fini de charger en 90 s"), + // Lancement impossible : le Go ne rend que la fin de la sortie, vide. + Ok(Err(_)) => return fail("[erreur] capture impossible : "), + Ok(Ok(o)) if !o.status.success() => { + let mut combined = o.stdout.clone(); + combined.extend_from_slice(&o.stderr); + return fail(&format!( + "[erreur] capture impossible : {}", + last_lines(&crate::system::go_lossy(&combined), 3).trim() + )); + } + Ok(Ok(_)) => {} + } + let size = match std::fs::metadata(&out) { + Ok(m) if m.len() > 0 => m.len(), + _ => return fail("[erreur] Playwright n'a produit aucune image"), + }; + prune_captures(&dir, &out); + let rel = format!("captures/{name}"); + Shot { + result: format!( + "Capture enregistrée ({rel}, {} Ko).\nPour la montrer à l'utilisateur, recopie TELLE QUELLE cette ligne markdown dans ta réponse :\n![capture de {host}](/api/chat/image?path={rel})", + size / 1024 + ), + rel, + abs: out, + } +} + +/// `screenshotImageMessage` : le message qui porte la capture au modèle. +pub fn screenshot_message(abs: &Path) -> Option { + let mime = image_mime(&abs.to_string_lossy()); + if mime.is_empty() { + return None; + } + let b = std::fs::read(abs).ok().filter(|b| !b.is_empty())?; + let (b, mime) = prepare_image_for_model(&b, mime); + Some(json!({ + "role": "user", + "content": [ + { "type": "text", "text": "Voici la capture demandée." }, + image_url_part(&b, &mime), + ], + })) +} + +/// `time.Now().Format("20060102-150405")`, heure locale. +pub fn local_stamp() -> String { + let sec = SystemTime::now() + .duration_since(SystemTime::UNIX_EPOCH) + .map(|d| d.as_secs() as i64) + .unwrap_or_default(); + let c = crate::tz::Location::local().civil(sec); + format!( + "{:04}{:02}{:02}-{:02}{:02}{:02}", + c.year, c.month, c.day, c.hour, c.minute, c.second + ) +} + +#[cfg(test)] +mod tests; diff --git a/rust/loki-rs/src/imagetools/tests.rs b/rust/loki-rs/src/imagetools/tests.rs new file mode 100644 index 0000000..a8812a5 --- /dev/null +++ b/rust/loki-rs/src/imagetools/tests.rs @@ -0,0 +1,150 @@ +//! Tests unitaires des outils d'image (lot 45) ; la parité avec le Go est +//! dans `tests/imagetools.rs`. + +use super::*; + +#[test] +fn types_d_image() { + assert_eq!(image_mime("a.PNG"), "image/png"); + assert_eq!(image_mime("/x/b.jpeg"), "image/jpeg"); + assert_eq!(image_mime("c.Jpg"), "image/jpeg"); + assert_eq!(image_mime("d.gif"), "image/gif"); + assert_eq!(image_mime("e.webp"), "image/webp"); + assert_eq!(image_mime("f.bmp"), "image/bmp"); + assert_eq!(image_mime("g.svg"), ""); + assert_eq!(image_mime("png"), ""); + assert_eq!(image_mime(""), ""); +} + +#[test] +fn noms_de_capture() { + assert_eq!(safe_slug("localhost:3000"), "localhost-3000"); + assert_eq!(safe_slug("www.exemple.test"), "www.exemple.test"); + assert_eq!(safe_slug("..--a b--.."), "a-b"); + assert_eq!(safe_slug("été"), "t"); + assert_eq!(safe_slug("::"), "page"); + assert_eq!(safe_slug(""), "page"); + assert_eq!(safe_slug(&"x".repeat(60)).len(), 40); +} + +#[test] +fn adresses_locales() { + for u in [ + "http://localhost:3000/app", + "http://127.0.0.1/", + "http://[::1]:8080/", + "http://0.0.0.0:5173", + "https://app.localhost/", + "http://moi:secret@localhost/", + ] { + assert!(is_local_url(u), "{u}"); + } + for u in [ + "https://exemple.test/", + "http://localhost.exemple.test/", + "http://LOCALHOST/", + "http://127.0.0.2/", + "pas une url", + "http://localhost:abc/", + ] { + assert!(!is_local_url(u), "{u}"); + } +} + +#[test] +fn dernieres_lignes() { + assert_eq!(last_lines("a\n\nb\n \nc\nd\n", 3), "b · c · d"); + assert_eq!(last_lines("seule", 6), "seule"); + assert_eq!(last_lines("", 6), ""); + assert_eq!(last_lines("\n \n", 6), ""); +} + +#[test] +fn image_preparee() { + // Petite PNG : rien à faire, les octets d'origine. + let mut png = Vec::new(); + image::RgbaImage::from_pixel(10, 5, image::Rgba([1, 2, 3, 255])) + .write_to(&mut std::io::Cursor::new(&mut png), image::ImageFormat::Png) + .unwrap(); + assert_eq!( + prepare_image_for_model(&png, "image/png"), + (png.clone(), "image/png".into()) + ); + // Indécodable : telle quelle. + assert_eq!( + prepare_image_for_model(b"pas une image", "image/png"), + (b"pas une image".to_vec(), "image/png".into()) + ); + // Trop grande : réduite sous 1568 px, en PNG. + let mut big = Vec::new(); + image::RgbaImage::from_pixel(3136, 100, image::Rgba([9, 9, 9, 255])) + .write_to(&mut std::io::Cursor::new(&mut big), image::ImageFormat::Png) + .unwrap(); + let (b, mime) = prepare_image_for_model(&big, "image/png"); + assert_eq!(mime, "image/png"); + let img = image::load_from_memory(&b).unwrap(); + assert_eq!((img.width(), img.height()), (1568, 50)); + let part = image_url_part(b"ab", "image/gif"); + assert_eq!(part["image_url"]["url"], "data:image/gif;base64,YWI="); +} + +#[test] +fn menage_des_captures() { + let d = tempfile::tempdir().unwrap(); + let base = SystemTime::now() - Duration::from_secs(1000); + for i in 0..25u64 { + let p = d.path().join(format!("c{i:02}.jpg")); + std::fs::write(&p, "x").unwrap(); + std::fs::File::options() + .write(true) + .open(&p) + .unwrap() + .set_modified(base + Duration::from_secs(i)) + .unwrap(); + } + // La plus ancienne est celle qu'on vient de prendre : elle reste. + let keep = d.path().join("c00.jpg"); + prune_captures(d.path(), &keep); + let mut left: Vec = std::fs::read_dir(d.path()) + .unwrap() + .flatten() + .map(|e| e.file_name().to_string_lossy().into_owned()) + .collect(); + left.sort(); + assert_eq!(left.len(), 20); + assert_eq!(left[0], "c00.jpg"); + assert_eq!(left[1], "c06.jpg"); +} + +#[tokio::test] +async fn capture_refusee_avant_playwright() { + let d = tempfile::tempdir().unwrap(); + let args = |v: Value| serde_json::from_value::>(v).unwrap(); + let s = web_screenshot(&args(json!({"url": "http://x/"})), "", d.path(), true, "t").await; + assert!( + s.result.contains("Playwright n'est pas installé"), + "{}", + s.result + ); + let s = web_screenshot(&args(json!({"url": 3})), "/bin/false", d.path(), true, "t").await; + assert_eq!(s.result, "[erreur] url manquante"); + assert!(s.rel.is_empty()); + assert!(!d.path().join("captures").exists()); +} + +#[tokio::test] +async fn voir_sans_poste_distant() { + let d = tempfile::tempdir().unwrap(); + let (r, img) = see_image("a.png", d.path(), "nœud", true, async { true }).await; + assert!(r.contains("poste distant"), "{r}"); + assert!(img.is_none()); + let (r, _) = see_image("", d.path(), "", true, async { true }).await; + assert_eq!(r, "[erreur] chemin de fichier manquant"); +} + +#[test] +fn horodatage_local() { + let s = local_stamp(); + assert_eq!(s.len(), 15); + assert_eq!(&s[8..9], "-"); +} diff --git a/rust/loki-rs/src/lib.rs b/rust/loki-rs/src/lib.rs index 9f55911..a1ee2a7 100644 --- a/rust/loki-rs/src/lib.rs +++ b/rust/loki-rs/src/lib.rs @@ -29,6 +29,7 @@ pub mod engstatus; pub mod gguf; pub mod gojson; pub mod hf; +pub mod imagetools; pub mod mcp; pub mod memory; pub mod models; diff --git a/rust/loki-rs/tests/imagetools.rs b/rust/loki-rs/tests/imagetools.rs new file mode 100644 index 0000000..6a52453 --- /dev/null +++ b/rust/loki-rs/tests/imagetools.rs @@ -0,0 +1,286 @@ +//! Lot 45 : outils d'image de l'agent (`see_image`, `web_screenshot`) +//! tenus par la façade, rejoués sur le journal du Go +//! (`internal/loki/parity_image_tools_test.go` → +//! `tests/parity_images/images.json`) : mêmes fichiers, même faux moteur +//! (`/props`), même faux Playwright. Une image laissée telle quelle se +//! compare à l'octet ; une image réencodée par ses dimensions et son type, +//! et une PNG réduite par ses pixels. +#![cfg(target_os = "linux")] + +mod common; + +use std::convert::Infallible; +use std::sync::{Arc, Mutex}; + +use base64::Engine as _; +use bytes::Bytes; +use common::fakego; +use http_body_util::Full; +use hyper::service::service_fn; +use hyper::{Request, Response}; +use hyper_util::rt::TokioIo; +use loki_rs::imagetools::{self, VisionProbe}; +use loki_rs::store::Store; +use regex::Regex; +use serde_json::{Map, Value, json}; +use tokio::net::TcpListener; + +const FAKE_PLAYWRIGHT: &str = r#"#!/bin/sh +d=$(dirname "$0") +printf '%s\n' "$@" > "$d/args" +for a in "$@"; do out="$a"; done +mode=$(cat "$d/mode" 2>/dev/null) +case "$mode" in + echec) echo "Launching chromium" ; echo "" ; echo "page.goto: net::ERR_NAME_NOT_RESOLVED at https://x" >&2 ; echo "Call log:" >&2 ; echo " - navigating" >&2 ; exit 1 ;; + vide) : > "$out" ;; + rien) ;; + *) cat "$d/image" > "$out" ;; +esac +"#; + +/// Le faux moteur : `/props` authentifié, la vision réglée par le test. +async fn fake_engine(sees: Arc>) -> u16 { + let l = TcpListener::bind("127.0.0.1:0").await.unwrap(); + let port = l.local_addr().unwrap().port(); + tokio::spawn(async move { + while let Ok((st, _)) = l.accept().await { + let sees = sees.clone(); + tokio::spawn(async move { + let svc = service_fn(move |req: Request| { + let sees = sees.clone(); + async move { + let ok = req.uri().path() == "/props" + && req + .headers() + .get("authorization") + .and_then(|h| h.to_str().ok()) + == Some("Bearer cle-moteur"); + let mut r = Response::new(Full::new(Bytes::new())); + if ok { + let mut s = sees.lock().unwrap(); + s.1 += 1; + r = Response::new(Full::new(Bytes::from( + json!({ "modalities": { "vision": s.0 } }).to_string(), + ))); + } else { + *r.status_mut() = hyper::StatusCode::UNAUTHORIZED; + } + Ok::<_, Infallible>(r) + } + }); + let _ = hyper::server::conn::http1::Builder::new() + .serve_connection(TokioIo::new(st), svc) + .await; + }); + } + }); + port +} + +/// `parityPartFacts`. +fn part_facts(part: &Value, raw: &Map) -> Value { + let u = part["image_url"]["url"].as_str().unwrap(); + let i = u.find(";base64,").unwrap(); + let mime = u[..i].trim_start_matches("data:"); + let b = base64::engine::general_purpose::STANDARD + .decode(&u[i + 8..]) + .unwrap(); + let mut out = Map::new(); + out.insert("mime".into(), json!(mime)); + for (name, r) in raw { + let rb = base64::engine::general_purpose::STANDARD + .decode(r.as_str().unwrap_or("")) + .unwrap(); + if rb == b { + out.insert("same_as".into(), json!(name)); + return Value::Object(out); + } + } + let Ok(img) = image::load_from_memory(&b) else { + panic!("image relayée illisible"); + }; + let img = img.to_rgba8(); + let (w, h) = img.dimensions(); + out.insert("w".into(), json!(w)); + out.insert("h".into(), json!(h)); + if mime == "image/png" { + let mut px = Map::new(); + for (x, y) in [(0, 0), (1, 1), (100, 50), (w - 1, h - 1), (w / 2, h / 3)] { + let p = img.get_pixel(x, y).0; + px.insert( + format!("{x},{y}"), + json!(format!("{},{},{},{}", p[0], p[1], p[2], p[3])), + ); + } + out.insert("px".into(), Value::Object(px)); + } + Value::Object(out) +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn outils_image_comme_le_go() { + let fx: Value = + serde_json::from_str(include_str!("parity_images/images.json")).expect("journal"); + let tmp = tempfile::tempdir().unwrap(); + let files = tmp.path().join("fichiers"); + let raw = fx["files"].as_object().unwrap(); + for (name, b) in raw { + let p = files.join(name); + std::fs::create_dir_all(p.parent().unwrap()).unwrap(); + std::fs::write( + &p, + base64::engine::general_purpose::STANDARD + .decode(b.as_str().unwrap_or("")) + .unwrap(), + ) + .unwrap(); + } + std::fs::create_dir_all(files.join("dossier.png")).unwrap(); + std::fs::File::create(files.join("lourd.png")) + .unwrap() + .set_len(13 << 20) + .unwrap(); + let files_s = files.to_string_lossy().into_owned(); + + let sees = Arc::new(Mutex::new((false, 0u32))); + let port = fake_engine(sees.clone()).await; + let (_, go, _stop) = fakego::start(tmp.path().join("home")).await; + let backend = go.backend.lock().unwrap().unwrap(); + let store = Store::new(backend, fakego::JETON.into()); + go.set("state", "api_key", "cle-moteur"); + let mut cfg = std::collections::HashMap::new(); + cfg.insert("PORT".to_string(), port.to_string()); + let probe = VisionProbe::default(); + + let pw_dir = tmp.path().join("pw"); + std::fs::create_dir_all(&pw_dir).unwrap(); + let pw = pw_dir.join("playwright"); + std::fs::write(&pw, FAKE_PLAYWRIGHT).unwrap(); + use std::os::unix::fs::PermissionsExt as _; + std::fs::set_permissions(&pw, std::fs::Permissions::from_mode(0o755)).unwrap(); + let conv_dir = tmp.path().join("conv"); + let cap_dir = conv_dir.join("captures"); + let stamp_re = Regex::new(r"[0-9]{8}-[0-9]{6}").unwrap(); + + for (n, st) in fx["steps"].as_array().unwrap().iter().enumerate() { + let name = st["name"].as_str().unwrap(); + *sees.lock().unwrap() = (st["sees"] == true, 0); + probe.reset().await; + std::fs::write(pw_dir.join("mode"), st["mode"].as_str().unwrap_or("")).unwrap(); + if let Some(img) = st["image"].as_str() { + let b = base64::engine::general_purpose::STANDARD + .decode(raw[img].as_str().unwrap()) + .unwrap(); + std::fs::write(pw_dir.join("image"), b).unwrap(); + } + let _ = std::fs::remove_file(pw_dir.join("args")); + if name == "ménage des captures" { + let old = std::time::SystemTime::now() - std::time::Duration::from_secs(3600); + for i in 0..22 { + let p = cap_dir.join(format!("vieille-{i:02}.jpg")); + std::fs::write(&p, "x").unwrap(); + let f = std::fs::File::options().write(true).open(&p).unwrap(); + f.set_modified(old + std::time::Duration::from_secs(i)) + .unwrap(); + } + } + let args: Map = + serde_json::from_str(&st["args"].to_string().replace("{{FICHIERS}}", &files_s)) + .unwrap(); + let label = st["label"] + .as_str() + .unwrap() + .replace("{{FICHIERS}}", &files_s); + let vision = st["vision"] == true; + let (mut result, part, shown) = match st["tool"].as_str().unwrap() { + "see_image" => { + let (r, img) = + imagetools::see_image(&label, &files, "", vision, probe.sees(&store, &cfg)) + .await; + let relay = img.map(|i| loki_rs::chat::host::see_image_message(&label, i)); + (r, relay, String::new()) + } + _ => { + let stamp = format!("20260101-1200{:02}", n % 60); + let shot = imagetools::web_screenshot( + &args, + &pw.to_string_lossy(), + &conv_dir, + st["internet"] == true, + &stamp, + ) + .await; + let relay = if !shot.rel.is_empty() && vision && probe.sees(&store, &cfg).await { + imagetools::screenshot_message(&shot.abs) + .and_then(|m| serde_json::from_value(m).ok()) + } else { + None + }; + (shot.result, relay, shot.rel) + } + }; + let mut facts = None; + if let Some(m) = &relay_parts(&part) { + for p in m { + if p["type"] == "image_url" { + facts = Some(part_facts(p, raw)); + } + if p["type"] == "text" { + result += &format!("\n[relais] {}", p["text"].as_str().unwrap()); + } + } + } + let mask = |s: &str| { + stamp_re + .replace_all(&s.replace(&files_s, "{{FICHIERS}}"), "{{T}}") + .into_owned() + }; + assert_eq!(mask(&result), st["result"].as_str().unwrap(), "{name}"); + assert_eq!(facts, st.get("part").cloned(), "{name} : image relayée"); + assert_eq!( + mask(&shown), + st["shown"].as_str().unwrap_or(""), + "{name} : capture montrée" + ); + assert_eq!( + sees.lock().unwrap().1 as u64, + st["probes"].as_u64().unwrap(), + "{name} : sondes du moteur" + ); + let pw_args: Vec = std::fs::read_to_string(pw_dir.join("args")) + .map(|b| { + b.trim_end_matches('\n') + .split('\n') + .map(|l| { + mask(&l.replace(&cap_dir.to_string_lossy().into_owned(), "{{CAPTURES}}")) + }) + .collect() + }) + .unwrap_or_default(); + assert_eq!( + json!(pw_args), + st.get("pw_args").cloned().unwrap_or(json!([])), + "{name} : Playwright" + ); + if st["tool"] == "web_screenshot" { + let mut caps: Vec = std::fs::read_dir(&cap_dir) + .into_iter() + .flatten() + .flatten() + .map(|e| mask(&e.file_name().to_string_lossy())) + .collect(); + caps.sort(); + assert_eq!( + json!(caps), + st.get("captures").cloned().unwrap_or(json!([])), + "{name} : dossier des captures" + ); + } + } +} + +/// Les parties d'un message relayé. +fn relay_parts(m: &Option) -> Option> { + let m = m.as_ref()?; + m.content.as_ref()?.as_array().cloned() +} diff --git a/rust/loki-rs/tests/lot6.rs b/rust/loki-rs/tests/lot6.rs index 0028010..9be8e31 100644 --- a/rust/loki-rs/tests/lot6.rs +++ b/rust/loki-rs/tests/lot6.rs @@ -674,6 +674,7 @@ async fn tour_complet_par_le_go() { web: Default::default(), jobs: Default::default(), browser: Default::default(), + vision: Default::default(), go_version: Default::default(), }); let cfg = std::collections::HashMap::new(); @@ -2842,6 +2843,46 @@ async fn job_arriere_plan_par_la_facade() { assert_eq!(jobs.running(), 0); } +/// Lot 45 : `see_image` et `web_screenshot` sont exécutés par la façade +/// (rien par le Go) : sans projecteur, l'image n'est pas lue ; une capture +/// sans url est refusée avant Playwright. +#[tokio::test] +async fn outils_image_par_la_facade() { + let calls = sse(&[ + serde_json::json!({"choices":[{"delta":{"tool_calls":[ + {"index":0,"id":"v1","type":"function","function":{"name":"see_image","arguments":"{\"path\":\"photo.png\"}"}}, + {"index":1,"id":"v2","type":"function","function":{"name":"web_screenshot","arguments":"{}"}} + ]}}]}), + serde_json::json!({"choices":[{"delta":{},"finish_reason":"tool_calls"}]}), + ]); + let (url, seen) = fake_engine(vec![calls, answer("vu")]).await; + let ws = tempfile::tempdir().unwrap(); + std::fs::write(ws.path().join("photo.png"), b"x").unwrap(); + let (svc, go, _stop, _t) = service_with(Some(&url), |_| {}).await; + *go.tool_ctx.lock().unwrap() = Some(serde_json::json!({ + "cwd": ws.path(), "memory_dir": ws.path().join("mem"), "target": "" + })); + svc.send(serde_json::json!({"message": "regarde", "cid": "v"})) + .await + .unwrap(); + until_done(&svc, 1).await; + assert!(go.tool_log.lock().unwrap().is_empty(), "{:?}", go.tool_log); + let seen = seen.lock().unwrap().clone(); + let tools: Vec = seen[1].1["messages"] + .as_array() + .unwrap() + .iter() + .filter(|m| m["role"] == "tool") + .map(|m| m["content"].as_str().unwrap().to_string()) + .collect(); + assert_eq!(tools.len(), 2, "{tools:?}"); + assert!(tools[0].contains("la vision n'est pas active"), "{tools:?}"); + assert!( + tools[1] == "[erreur] url manquante" || tools[1].contains("Playwright n'est pas installé"), + "{tools:?}" + ); +} + /// Lot 44 : le navigateur piloté est celui de la façade. Un tour qui ouvre /// une page l'exécute chez elle (rien par le Go), l'aperçu est servi par /// /api/chat/cushot, et le Go passe par /internal/facade/v1/browser. diff --git a/rust/loki-rs/tests/parity_images/images.json b/rust/loki-rs/tests/parity_images/images.json new file mode 100644 index 0000000..5c424cb --- /dev/null +++ b/rust/loki-rs/tests/parity_images/images.json @@ -0,0 +1,614 @@ +{ + "files": { + "anim.gif": "R0lGODlhAQABAIAAAAAAAP///yH5BAEAAAAALAAAAAABAAEAAAICRAEAOw==", + "capture.jpg": "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", + "capture.png": "iVBORw0KGgoAAAANSUhEUgAAAEAAAAAoCAIAAADBrGu+AAAAZ0lEQVR4nOzS0QmAMBBEwQsG7b9ji9iPJTpwBdywb8/MNWsffPeaOfg+AHj6T/x8geMBEmoDLAAQAiQEEAIk1AZYACAESAggBEioDbAAQAiQEEAIkFAbYAGAECAhgBAgoe69AQAA//86MgyTWSqBGAAAAABJRU5ErkJggg==", + "cassee.png": "iVBORw0KGgphYsOubcOp", + "grande.jpeg": "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", + "grande.png": "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", + "haute.png": "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", + "image.BMP": "Qk0gcGFzIHZyYWltZW50IHVuZSBpbWFnZQ==", + "image.webp": "UklGRhAAAABXRUJQVlA4IGZhdXg=", + "petite.png": "iVBORw0KGgoAAAANSUhEUgAAAAoAAAAICAIAAABPmPnhAAAAJUlEQVR4nGJhYGCQZGCUYmDESrIwyDMyMOBELAwKQ1QaEAAA//9xYwI0YqlbiQAAAABJRU5ErkJggg==", + "photo.jpg": "/9j/2wCEAAMCAgMCAgMDAwMEAwMEBQgFBQQEBQoHBwYIDAoMDAsKCwsNDhIQDQ4RDgsLEBYQERMUFRUVDA8XGBYUGBIUFRQBAwQEBQQFCQUFCRQNCw0UFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFP/AABEIABQAKAMBIgACEQEDEQH/xAGiAAABBQEBAQEBAQAAAAAAAAAAAQIDBAUGBwgJCgsQAAIBAwMCBAMFBQQEAAABfQECAwAEEQUSITFBBhNRYQcicRQygZGhCCNCscEVUtHwJDNicoIJChYXGBkaJSYnKCkqNDU2Nzg5OkNERUZHSElKU1RVVldYWVpjZGVmZ2hpanN0dXZ3eHl6g4SFhoeIiYqSk5SVlpeYmZqio6Slpqeoqaqys7S1tre4ubrCw8TFxsfIycrS09TV1tfY2drh4uPk5ebn6Onq8fLz9PX29/j5+gEAAwEBAQEBAQEBAQAAAAAAAAECAwQFBgcICQoLEQACAQIEBAMEBwUEBAABAncAAQIDEQQFITEGEkFRB2FxEyIygQgUQpGhscEJIzNS8BVictEKFiQ04SXxFxgZGiYnKCkqNTY3ODk6Q0RFRkdISUpTVFVWV1hZWmNkZWZnaGlqc3R1dnd4eXqCg4SFhoeIiYqSk5SVlpeYmZqio6Slpqeoqaqys7S1tre4ubrCw8TFxsfIycrS09TV1tfY2dri4+Tl5ufo6ery8/T19vf4+fr/2gAMAwEAAhEDEQA/APgLTfh10/dfpXWab8Oun7r9K93034ddP3X6V1em/Drp+6/SvyqrxF/ePkcj4x2948I034c9P3X6V1em/Dnp+6/Svd9N+HXT91+ldXpvw56fuv0rxqvEX94/oTI+MdvePCNN+HXT91+laf8Awrr/AKZfpX0fpvw56fuv0rT/AOFdf9Mv0rypcRa/EfueC4x/dL3jiNN0S04/d11em6Jacfu6w9N7V1em9q/P6tSfc/xoyOrU095m7puiWnH7uur03QrTj93WHpvaus03tXjVak+5/QmR1amnvM29N0K04/d1p/2Faf8APOmab2rSryp1J33P3LBVqnsV7zP/2Q==", + "pleine.jpg": "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", + "sous/tres.png": "iVBORw0KGgoAAAANSUhEUgAAAAMAAAADCAIAAADZSiLoAAAAH0lEQVR4nGJhYGAIZWAMZWBkYQhlZGAAIQQLEAAA//8aWAFpD3kcfgAAAABJRU5ErkJggg==", + "texte.txt": "cGFzIHVuZSBpbWFnZQ==", + "tournee.jpg": "/9j/4QAiRXhpZgAATU0AKgAAAAgAAQESAAMAAAABAAYAAAAAAAD/2wCEAAMCAgMCAgMDAwMEAwMEBQgFBQQEBQoHBwYIDAoMDAsKCwsNDhIQDQ4RDgsLEBYQERMUFRUVDA8XGBYUGBIUFRQBAwQEBQQFCQUFCRQNCw0UFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFP/AABEIABQAKAMBIgACEQEDEQH/xAGiAAABBQEBAQEBAQAAAAAAAAAAAQIDBAUGBwgJCgsQAAIBAwMCBAMFBQQEAAABfQECAwAEEQUSITFBBhNRYQcicRQygZGhCCNCscEVUtHwJDNicoIJChYXGBkaJSYnKCkqNDU2Nzg5OkNERUZHSElKU1RVVldYWVpjZGVmZ2hpanN0dXZ3eHl6g4SFhoeIiYqSk5SVlpeYmZqio6Slpqeoqaqys7S1tre4ubrCw8TFxsfIycrS09TV1tfY2drh4uPk5ebn6Onq8fLz9PX29/j5+gEAAwEBAQEBAQEBAQAAAAAAAAECAwQFBgcICQoLEQACAQIEBAMEBwUEBAABAncAAQIDEQQFITEGEkFRB2FxEyIygQgUQpGhscEJIzNS8BVictEKFiQ04SXxFxgZGiYnKCkqNTY3ODk6Q0RFRkdISUpTVFVWV1hZWmNkZWZnaGlqc3R1dnd4eXqCg4SFhoeIiYqSk5SVlpeYmZqio6Slpqeoqaqys7S1tre4ubrCw8TFxsfIycrS09TV1tfY2dri4+Tl5ufo6ery8/T19vf4+fr/2gAMAwEAAhEDEQA/APgLTfh10/dfpXWab8Oun7r9K93034ddP3X6V1em/Drp+6/SvyqrxF/ePkcj4x2948I034c9P3X6V1em/Dnp+6/Svd9N+HXT91+ldXpvw56fuv0rxqvEX94/oTI+MdvePCNN+HXT91+laf8Awrr/AKZfpX0fpvw56fuv0rT/AOFdf9Mv0rypcRa/EfueC4x/dL3jiNN0S04/d11em6Jacfu6w9N7V1em9q/P6tSfc/xoyOrU095m7puiWnH7uur03QrTj93WHpvaus03tXjVak+5/QmR1amnvM29N0K04/d1p/2Faf8APOmab2rSryp1J33P3LBVqnsV7zP/2Q==", + "vide.png": "" + }, + "steps": [ + { + "name": "sans vision", + "tool": "see_image", + "args": { + "file": "petite.png" + }, + "label": "petite.png", + "result": "[erreur] la vision n'est pas active sur ce preset (aucun projecteur MMPROJ configuré) — impossible de voir une image", + "probes": 0 + }, + { + "name": "chemin manquant", + "vision": true, + "tool": "see_image", + "args": { + "file": " " + }, + "label": " ", + "result": "[erreur] format non reconnu comme image (attendu : png, jpg, gif, webp, bmp) : ", + "probes": 0 + }, + { + "name": "pas une image", + "vision": true, + "tool": "see_image", + "args": { + "file": "texte.txt" + }, + "label": "texte.txt", + "result": "[erreur] format non reconnu comme image (attendu : png, jpg, gif, webp, bmp) : texte.txt", + "probes": 0 + }, + { + "name": "introuvable", + "vision": true, + "tool": "see_image", + "args": { + "file": "absente.png" + }, + "label": "absente.png", + "result": "[erreur] fichier introuvable : absente.png", + "probes": 0 + }, + { + "name": "dossier", + "vision": true, + "tool": "see_image", + "args": { + "file": "dossier.png" + }, + "label": "dossier.png", + "result": "[erreur] c'est un dossier, pas une image : dossier.png", + "probes": 0 + }, + { + "name": "trop lourde", + "vision": true, + "tool": "see_image", + "args": { + "file": "lourd.png" + }, + "label": "lourd.png", + "result": "[erreur] image trop lourde (13 Mo, max 12 Mo) : lourd.png", + "probes": 0 + }, + { + "name": "le moteur ne voit pas", + "vision": true, + "tool": "see_image", + "args": { + "file": "petite.png" + }, + "label": "petite.png", + "result": "[erreur] le moteur en service ne traite pas les images (projecteur non chargé ?) — impossible de voir petite.png", + "probes": 1 + }, + { + "name": "vide", + "vision": true, + "sees": true, + "tool": "see_image", + "args": { + "file": "vide.png" + }, + "label": "vide.png", + "result": "[erreur] fichier vide : vide.png", + "probes": 1 + }, + { + "name": "voir petite.png", + "vision": true, + "sees": true, + "tool": "see_image", + "args": { + "file": "petite.png" + }, + "label": "petite.png", + "result": "[ok] image chargée : petite.png\n[relais] Image demandée (petite.png) :", + "part": { + "mime": "image/png", + "same_as": "petite.png" + }, + "probes": 1 + }, + { + "name": "voir grande.png", + "vision": true, + "sees": true, + "tool": "see_image", + "args": { + "file": "grande.png" + }, + "label": "grande.png", + "result": "[ok] image chargée : grande.png\n[relais] Image demandée (grande.png) :", + "part": { + "h": 784, + "mime": "image/png", + "px": { + "0,0": "0,0,0,255", + "1,1": "0,0,2,255", + "100,50": "16,16,190,255", + "1567,783": "254,254,181,255", + "784,261": "127,84,52,255" + }, + "w": 1568 + }, + "probes": 1 + }, + { + "name": "voir haute.png", + "vision": true, + "sees": true, + "tool": "see_image", + "args": { + "file": "haute.png" + }, + "label": "haute.png", + "result": "[ok] image chargée : haute.png\n[relais] Image demandée (haute.png) :", + "part": { + "h": 1568, + "mime": "image/png", + "px": { + "0,0": "0,0,0,255", + "1,1": "0,0,2,255", + "100,50": "91,8,162,255", + "138,522": "126,84,202,255", + "276,1567": "253,254,205,255" + }, + "w": 277 + }, + "probes": 1 + }, + { + "name": "voir photo.jpg", + "vision": true, + "sees": true, + "tool": "see_image", + "args": { + "file": "photo.jpg" + }, + "label": "photo.jpg", + "result": "[ok] image chargée : photo.jpg\n[relais] Image demandée (photo.jpg) :", + "part": { + "mime": "image/jpeg", + "same_as": "photo.jpg" + }, + "probes": 1 + }, + { + "name": "voir tournee.jpg", + "vision": true, + "sees": true, + "tool": "see_image", + "args": { + "file": "tournee.jpg" + }, + "label": "tournee.jpg", + "result": "[ok] image chargée : tournee.jpg\n[relais] Image demandée (tournee.jpg) :", + "part": { + "h": 40, + "mime": "image/jpeg", + "w": 20 + }, + "probes": 1 + }, + { + "name": "voir grande.jpeg", + "vision": true, + "sees": true, + "tool": "see_image", + "args": { + "file": "grande.jpeg" + }, + "label": "grande.jpeg", + "result": "[ok] image chargée : grande.jpeg\n[relais] Image demandée (grande.jpeg) :", + "part": { + "h": 784, + "mime": "image/jpeg", + "w": 1568 + }, + "probes": 1 + }, + { + "name": "voir anim.gif", + "vision": true, + "sees": true, + "tool": "see_image", + "args": { + "file": "anim.gif" + }, + "label": "anim.gif", + "result": "[ok] image chargée : anim.gif\n[relais] Image demandée (anim.gif) :", + "part": { + "mime": "image/gif", + "same_as": "anim.gif" + }, + "probes": 1 + }, + { + "name": "voir image.webp", + "vision": true, + "sees": true, + "tool": "see_image", + "args": { + "file": "image.webp" + }, + "label": "image.webp", + "result": "[ok] image chargée : image.webp\n[relais] Image demandée (image.webp) :", + "part": { + "mime": "image/webp", + "same_as": "image.webp" + }, + "probes": 1 + }, + { + "name": "voir image.BMP", + "vision": true, + "sees": true, + "tool": "see_image", + "args": { + "file": "image.BMP" + }, + "label": "image.BMP", + "result": "[ok] image chargée : image.BMP\n[relais] Image demandée (image.BMP) :", + "part": { + "mime": "image/bmp", + "same_as": "image.BMP" + }, + "probes": 1 + }, + { + "name": "voir cassee.png", + "vision": true, + "sees": true, + "tool": "see_image", + "args": { + "file": "cassee.png" + }, + "label": "cassee.png", + "result": "[ok] image chargée : cassee.png\n[relais] Image demandée (cassee.png) :", + "part": { + "mime": "image/png", + "same_as": "cassee.png" + }, + "probes": 1 + }, + { + "name": "voir sous/tres.png", + "vision": true, + "sees": true, + "tool": "see_image", + "args": { + "file": "sous/tres.png" + }, + "label": "sous/tres.png", + "result": "[ok] image chargée : tres.png\n[relais] Image demandée (sous/tres.png) :", + "part": { + "mime": "image/png", + "same_as": "sous/tres.png" + }, + "probes": 1 + }, + { + "name": "chemin absolu", + "vision": true, + "sees": true, + "tool": "see_image", + "args": { + "file": "{{FICHIERS}}/petite.png" + }, + "label": "{{FICHIERS}}/petite.png", + "result": "[ok] image chargée : petite.png\n[relais] Image demandée ({{FICHIERS}}/petite.png) :", + "part": { + "mime": "image/png", + "same_as": "petite.png" + }, + "probes": 1 + }, + { + "name": "capture sans url", + "tool": "web_screenshot", + "args": {}, + "label": "", + "result": "[erreur] url manquante", + "probes": 0 + }, + { + "name": "url invalide", + "tool": "web_screenshot", + "args": { + "url": "ftp://x" + }, + "label": "", + "result": "[erreur] url invalide : elle doit commencer par http:// ou https://", + "probes": 0 + }, + { + "name": "internet coupé", + "tool": "web_screenshot", + "args": { + "url": "https://exemple.test/" + }, + "label": "", + "result": "[refusé] L'accès internet est coupé : la capture ne marche que sur une adresse locale (localhost / 127.0.0.1), par exemple ton serveur de dev. Active l'accès internet pour le web.", + "probes": 0 + }, + { + "name": "capture locale sans vision", + "image": "capture.jpg", + "tool": "web_screenshot", + "args": { + "url": "http://localhost:3000/app?x=1" + }, + "label": "", + "result": "Capture enregistrée (captures/localhost-3000-{{T}}.jpg, 74 Ko).\nPour la montrer à l'utilisateur, recopie TELLE QUELLE cette ligne markdown dans ta réponse :\n![capture de localhost:3000](/api/chat/image?path=captures/localhost-3000-{{T}}.jpg)", + "shown": "captures/localhost-3000-{{T}}.jpg", + "pw_args": [ + "screenshot", + "--browser", + "chromium", + "--viewport-size", + "1280,800", + "--wait-for-timeout", + "3500", + "http://localhost:3000/app?x=1", + "{{CAPTURES}}/localhost-3000-{{T}}.jpg" + ], + "probes": 0, + "captures": [ + "localhost-3000-{{T}}.jpg" + ] + }, + { + "name": "capture vue par le modèle", + "vision": true, + "sees": true, + "image": "capture.jpg", + "internet": true, + "tool": "web_screenshot", + "args": { + "url": "https://www.exemple.test/page#ancre", + "width": 1600 + }, + "label": "", + "result": "Capture enregistrée (captures/www.exemple.test-{{T}}.jpg, 74 Ko).\nPour la montrer à l'utilisateur, recopie TELLE QUELLE cette ligne markdown dans ta réponse :\n![capture de www.exemple.test](/api/chat/image?path=captures/www.exemple.test-{{T}}.jpg)\n[relais] Voici la capture demandée.", + "part": { + "mime": "image/jpeg", + "same_as": "capture.jpg" + }, + "shown": "captures/www.exemple.test-{{T}}.jpg", + "pw_args": [ + "screenshot", + "--browser", + "chromium", + "--viewport-size", + "1600,800", + "--wait-for-timeout", + "3500", + "https://www.exemple.test/page#ancre", + "{{CAPTURES}}/www.exemple.test-{{T}}.jpg" + ], + "probes": 1, + "captures": [ + "localhost-3000-{{T}}.jpg", + "www.exemple.test-{{T}}.jpg" + ] + }, + { + "name": "page entière réduite", + "vision": true, + "sees": true, + "image": "pleine.jpg", + "internet": true, + "tool": "web_screenshot", + "args": { + "full_page": true, + "url": "https://pleine.test/", + "width": 100 + }, + "label": "", + "result": "Capture enregistrée (captures/pleine.test-{{T}}.jpg, 253 Ko).\nPour la montrer à l'utilisateur, recopie TELLE QUELLE cette ligne markdown dans ta réponse :\n![capture de pleine.test](/api/chat/image?path=captures/pleine.test-{{T}}.jpg)\n[relais] Voici la capture demandée.", + "part": { + "h": 1568, + "mime": "image/jpeg", + "w": 669 + }, + "shown": "captures/pleine.test-{{T}}.jpg", + "pw_args": [ + "screenshot", + "--browser", + "chromium", + "--viewport-size", + "1280,800", + "--wait-for-timeout", + "3500", + "--full-page", + "https://pleine.test/", + "{{CAPTURES}}/pleine.test-{{T}}.jpg" + ], + "probes": 1, + "captures": [ + "localhost-3000-{{T}}.jpg", + "pleine.test-{{T}}.jpg", + "www.exemple.test-{{T}}.jpg" + ] + }, + { + "name": "vision mais moteur aveugle", + "vision": true, + "image": "capture.png", + "internet": true, + "tool": "web_screenshot", + "args": { + "url": "https://aveugle.test/" + }, + "label": "", + "result": "Capture enregistrée (captures/aveugle.test-{{T}}.jpg, 0 Ko).\nPour la montrer à l'utilisateur, recopie TELLE QUELLE cette ligne markdown dans ta réponse :\n![capture de aveugle.test](/api/chat/image?path=captures/aveugle.test-{{T}}.jpg)", + "shown": "captures/aveugle.test-{{T}}.jpg", + "pw_args": [ + "screenshot", + "--browser", + "chromium", + "--viewport-size", + "1280,800", + "--wait-for-timeout", + "3500", + "https://aveugle.test/", + "{{CAPTURES}}/aveugle.test-{{T}}.jpg" + ], + "probes": 1, + "captures": [ + "aveugle.test-{{T}}.jpg", + "localhost-3000-{{T}}.jpg", + "pleine.test-{{T}}.jpg", + "www.exemple.test-{{T}}.jpg" + ] + }, + { + "name": "Playwright en échec", + "mode": "echec", + "internet": true, + "tool": "web_screenshot", + "args": { + "url": "https://x.test/" + }, + "label": "", + "result": "[erreur] capture impossible : page.goto: net::ERR_NAME_NOT_RESOLVED at https://x · Call log: · - navigating", + "pw_args": [ + "screenshot", + "--browser", + "chromium", + "--viewport-size", + "1280,800", + "--wait-for-timeout", + "3500", + "https://x.test/", + "{{CAPTURES}}/x.test-{{T}}.jpg" + ], + "probes": 0, + "captures": [ + "aveugle.test-{{T}}.jpg", + "localhost-3000-{{T}}.jpg", + "pleine.test-{{T}}.jpg", + "www.exemple.test-{{T}}.jpg" + ] + }, + { + "name": "image vide", + "mode": "vide", + "internet": true, + "tool": "web_screenshot", + "args": { + "url": "https://vide.test/" + }, + "label": "", + "result": "[erreur] Playwright n'a produit aucune image", + "pw_args": [ + "screenshot", + "--browser", + "chromium", + "--viewport-size", + "1280,800", + "--wait-for-timeout", + "3500", + "https://vide.test/", + "{{CAPTURES}}/vide.test-{{T}}.jpg" + ], + "probes": 0, + "captures": [ + "aveugle.test-{{T}}.jpg", + "localhost-3000-{{T}}.jpg", + "pleine.test-{{T}}.jpg", + "vide.test-{{T}}.jpg", + "www.exemple.test-{{T}}.jpg" + ] + }, + { + "name": "aucune image", + "mode": "rien", + "internet": true, + "tool": "web_screenshot", + "args": { + "url": "https://rien.test/" + }, + "label": "", + "result": "[erreur] Playwright n'a produit aucune image", + "pw_args": [ + "screenshot", + "--browser", + "chromium", + "--viewport-size", + "1280,800", + "--wait-for-timeout", + "3500", + "https://rien.test/", + "{{CAPTURES}}/rien.test-{{T}}.jpg" + ], + "probes": 0, + "captures": [ + "aveugle.test-{{T}}.jpg", + "localhost-3000-{{T}}.jpg", + "pleine.test-{{T}}.jpg", + "vide.test-{{T}}.jpg", + "www.exemple.test-{{T}}.jpg" + ] + }, + { + "name": "ménage des captures", + "image": "capture.png", + "internet": true, + "tool": "web_screenshot", + "args": { + "url": "http://127.0.0.1:8080/" + }, + "label": "", + "result": "Capture enregistrée (captures/127.0.0.1-8080-{{T}}.jpg, 0 Ko).\nPour la montrer à l'utilisateur, recopie TELLE QUELLE cette ligne markdown dans ta réponse :\n![capture de 127.0.0.1:8080](/api/chat/image?path=captures/127.0.0.1-8080-{{T}}.jpg)", + "shown": "captures/127.0.0.1-8080-{{T}}.jpg", + "pw_args": [ + "screenshot", + "--browser", + "chromium", + "--viewport-size", + "1280,800", + "--wait-for-timeout", + "3500", + "http://127.0.0.1:8080/", + "{{CAPTURES}}/127.0.0.1-8080-{{T}}.jpg" + ], + "probes": 0, + "captures": [ + "127.0.0.1-8080-{{T}}.jpg", + "aveugle.test-{{T}}.jpg", + "localhost-3000-{{T}}.jpg", + "pleine.test-{{T}}.jpg", + "vide.test-{{T}}.jpg", + "vieille-08.jpg", + "vieille-09.jpg", + "vieille-10.jpg", + "vieille-11.jpg", + "vieille-12.jpg", + "vieille-13.jpg", + "vieille-14.jpg", + "vieille-15.jpg", + "vieille-16.jpg", + "vieille-17.jpg", + "vieille-18.jpg", + "vieille-19.jpg", + "vieille-20.jpg", + "vieille-21.jpg", + "www.exemple.test-{{T}}.jpg" + ] + } + ] +} From 92e9adee7d3e76ef74527d653adf49d2767bd2a4 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 16 Oct 2026 06:54:35 +0000 Subject: [PATCH 49/52] =?UTF-8?q?Lot=2046=20:=20outils=20recall=20et=20rec?= =?UTF-8?q?all=5Fsearch=20ex=C3=A9cut=C3=A9s=20par=20la=20fa=C3=A7ade?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Les deux outils qui rappellent un bloc archivé par la compaction ne passent plus par /tool/exec du Go : src/recall.rs porte toolRecall et toolRecallSearch (id tolérant comme recallIDToSeq, mots-clés en minuscules rune par rune comme strings.ToLower, score libellé double et plus récent d'abord, limites, extraits et textes du Go). L'archive reste au Go, seul à tenir la séquence de la base, qui la sert à la façade (GET /internal/v1/recall/block?seq=N et /internal/v1/recall/blocks, blocs chiffrés sous une mémoire verrouillée exclus) ; ses propres outils passent par les mêmes lectures. Go injoignable : l'appel passe par lui. Parité : journal de 41 appels sur une archive de 35 blocs tiré du Go (parity_recall_test.go → tests/parity_recall/recall.json), rejoué par tests/recall.rs ; un tour de la façade qui rappelle et cherche (tests/lot6.rs) ; TestRecallInterneLecture côté Go. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_016tcGfJWefW5kUGoC2wL8cv --- ...0-16-rust-migration-lot46-recall-design.md | 97 +++ internal/loki/chat_recall.go | 65 +- internal/loki/parity_recall_test.go | 143 ++++ internal/loki/web_internal.go | 4 + internal/loki/web_internal_recall.go | 39 ++ internal/loki/web_internal_recall_test.go | 42 ++ rust/loki-rs/src/chat/host.rs | 10 + rust/loki-rs/src/lib.rs | 1 + rust/loki-rs/src/recall.rs | 194 ++++++ rust/loki-rs/src/recall/tests.rs | 91 +++ rust/loki-rs/src/store.rs | 11 + rust/loki-rs/tests/common/fakego.rs | 32 + rust/loki-rs/tests/lot6.rs | 39 ++ rust/loki-rs/tests/parity_recall/recall.json | 615 ++++++++++++++++++ rust/loki-rs/tests/recall.rs | 58 ++ 15 files changed, 1416 insertions(+), 25 deletions(-) create mode 100644 docs/superpowers/specs/2026-10-16-rust-migration-lot46-recall-design.md create mode 100644 internal/loki/parity_recall_test.go create mode 100644 rust/loki-rs/src/recall.rs create mode 100644 rust/loki-rs/src/recall/tests.rs create mode 100644 rust/loki-rs/tests/parity_recall/recall.json create mode 100644 rust/loki-rs/tests/recall.rs diff --git a/docs/superpowers/specs/2026-10-16-rust-migration-lot46-recall-design.md b/docs/superpowers/specs/2026-10-16-rust-migration-lot46-recall-design.md new file mode 100644 index 0000000..5da22f9 --- /dev/null +++ b/docs/superpowers/specs/2026-10-16-rust-migration-lot46-recall-design.md @@ -0,0 +1,97 @@ +# Lot 46 — Outils recall et recall_search dans la façade + +## Contexte + +La compaction résume les vieux tours d'une discussion. Avant de résumer un +gros bloc (un code, une page, un diff), elle l'archive verbatim sous un id +court (`r7`) que le résumé cite. L'agent a deux outils pour le retrouver : + +- `recall(id)` rend le bloc exact ; +- `recall_search(query)` retrouve un bloc et son id par mots-clés, même + sorti du résumé courant. + +La façade compacte elle-même et archive par le Go +(`/internal/v1/recall/archive`, lot 6) : l'id vient de la séquence de la +base, que seul le Go tient. Les deux outils passaient encore par +`/internal/v1/tool/exec`. + +## Le lot + +`src/recall.rs` porte `toolRecall` et `toolRecallSearch` +(`chat_recall.go`) : + +- **l'id tolérant** (`recallIDToSeq`) : + - espaces, un préfixe `r` puis un préfixe `R`, espaces encore ; + - des chiffres seulement : ni signe, ni dépassement de 64 bits ; + - un id mal formé ne lit pas l'archive ; +- **les mots-clés** (`recallTokens`) : + - mise en minuscules rune par rune, comme `strings.ToLower` (`İ` + donne `i`, `Σ` donne `σ`) ; + - coupés sur tout ce qui n'est ni `a`–`z`, ni `0`–`9`, ni `à`–`ÿ` ; + - deux runes au moins ; +- **le score** : + - un point par terme présent dans le contenu, deux dans le libellé ; + - à égalité, le plus récent d'abord ; + - 5 résultats par défaut, 20 au plus, limite tronquée comme + `int(float64)` ; +- **les textes**, mêmes que le Go : + - id ou requête manquants ; + - bloc introuvable (id cité par `%q`) ; + - bloc rappelé verbatim ; + - liste des résultats avec un extrait d'une ligne de 160 runes ; + - aucun résultat. + +L'archive reste au Go, qui la sert à la façade : + +- `GET /internal/v1/recall/block?seq=N` : le bloc (200), ou 404 s'il est + absent ou chiffré sous une mémoire verrouillée ; +- `GET /internal/v1/recall/blocks` : tous les blocs lisibles, par séquence + croissante. + +`recallGet` et `recallSearch` du Go passent par les mêmes lectures +(`recallGetSeq`, `recallAll`). + +`GoTurnHost::exec_tool` exécute les deux outils avant le repli sur le Go. +Si le Go ne répond pas, l'appel passe par lui. + +## Parité + +`internal/loki/parity_recall_test.go` archive 35 blocs variés : + +- accents et casse Unicode ; +- balises et entités ; +- bloc vide ; +- premières lignes longues ; +- vingt-cinq blocs semblables. + +Il tire ensuite du Go un journal de 41 appels +(`tests/parity_recall/recall.json`), avec les blocs tels que l'archive les +sert : + +- `recall` : + - id absent, non texte, blanc ; + - variantes de préfixe et d'espaces ; + - zéros de tête ; + - mal formé, négatif, signe plus, zéro, absent, démesuré ; + - guillemets dans l'id ; +- `recall_search` : + - requête absente, non texte, blanche ; + - mots trop courts ; + - casse, accents, `İ`, grec hors des mots ; + - souligné coupé ; + - extraits bornés ; + - limites : une, tronquée, négative, par défaut, au plus, en texte + ignorée ; + - aucun résultat ; + - ponctuation. + +`tests/recall.rs` le rejoue : le faux Go sert les mêmes blocs. Il vérifie +aussi le repli quand le Go ne répond pas. + +D'autres tests complètent : + +- `tests/lot6.rs` (`recall_par_la_facade`) : un tour de la façade rappelle + et cherche sans passer par `/tool/exec` ; +- `TestRecallInterneLecture` : les deux lectures du Go (absent, seq + illisible, méthode refusée, ordre) ; +- tests unitaires : ids, mots-clés, score, extraits, textes. diff --git a/internal/loki/chat_recall.go b/internal/loki/chat_recall.go index e439769..959093d 100644 --- a/internal/loki/chat_recall.go +++ b/internal/loki/chat_recall.go @@ -138,6 +138,11 @@ func recallGet(id string) (recallBlock, bool) { if !ok { return recallBlock{}, false } + return recallGetSeq(seq) +} + +// recallGetSeq ramène un bloc par sa séquence (aussi servi à la façade). +func recallGetSeq(seq uint64) (recallBlock, bool) { var blk recallBlock found := false _ = view(bkRecall, func(b *bolt.Bucket) error { @@ -155,6 +160,27 @@ func recallGet(id string) (recallBlock, bool) { return blk, found } +// recallAll : tous les blocs lisibles, par séquence croissante (un bloc +// chiffré sous une mémoire verrouillée n'en fait pas partie). +func recallAll() []recallBlock { + var out []recallBlock + _ = view(bkRecall, func(b *bolt.Bucket) error { + return b.ForEach(func(_, v []byte) error { + var blk recallBlock + dec, err := decodeMemContent(v) + if err != nil { + return nil + } + if json.Unmarshal(dec, &blk) != nil { + return nil + } + out = append(out, blk) + return nil + }) + }) + return out +} + // recallSearch cherche dans toute l'archive par recouvrement de mots-clés. // Score = nombre de termes distincts de la requête présents dans (libellé + // contenu), le libellé comptant double (un mot du titre est plus signifiant). @@ -176,33 +202,22 @@ func recallSearch(query string, limit int) []recallBlock { score int } var hits []scored - _ = view(bkRecall, func(b *bolt.Bucket) error { - return b.ForEach(func(_, v []byte) error { - var blk recallBlock - dec, err := decodeMemContent(v) - if err != nil { - return nil // chiffré et verrouillé : ce bloc ne participe pas + for _, blk := range recallAll() { + label := strings.ToLower(blk.Label) + body := strings.ToLower(blk.Content) + score := 0 + for t := range terms { + if strings.Contains(label, t) { + score += 2 } - if json.Unmarshal(dec, &blk) != nil { - return nil + if strings.Contains(body, t) { + score++ } - label := strings.ToLower(blk.Label) - body := strings.ToLower(blk.Content) - score := 0 - for t := range terms { - if strings.Contains(label, t) { - score += 2 - } - if strings.Contains(body, t) { - score++ - } - } - if score > 0 { - hits = append(hits, scored{blk, score}) - } - return nil - }) - }) + } + if score > 0 { + hits = append(hits, scored{blk, score}) + } + } sort.Slice(hits, func(i, j int) bool { if hits[i].score != hits[j].score { return hits[i].score > hits[j].score diff --git a/internal/loki/parity_recall_test.go b/internal/loki/parity_recall_test.go new file mode 100644 index 0000000..c662c0d --- /dev/null +++ b/internal/loki/parity_recall_test.go @@ -0,0 +1,143 @@ +package loki + +import ( + "bytes" + "encoding/json" + "fmt" + "os" + "path/filepath" + "strings" + "testing" +) + +// parity_recall_test.go — journal de référence des outils recall et +// recall_search (lot 46 de la migration Rust) : une archive variée (accents, +// casse Unicode, contenus longs, beaucoup de blocs), puis les appels de +// l'agent, des ids tolérés aux limites de recherche. Rangé dans +// rust/loki-rs/tests/parity_recall/recall.json avec les blocs tels que +// l'archive les sert ; tests/recall.rs le rejoue contre le port. +// +// LOKI_PARITY_UPDATE=1 go test ./internal/loki -run TestPariteRecall + +const parityRecallFile = "../../rust/loki-rs/tests/parity_recall/recall.json" + +type parityRecallStep struct { + Name string `json:"name"` + Tool string `json:"tool"` + Args map[string]any `json:"args"` + Result string `json:"result"` +} + +func TestPariteRecall(t *testing.T) { + testHome(t) + arch := func(role, tool, content string) { + t.Helper() + if _, err := archiveRecallBlock(recallLabel(role, tool, content), role, content); err != nil { + t.Fatal(err) + } + } + arch("tool", "web_read", "Recette du gratin dauphinois\n"+strings.Repeat("pommes de terre, crème, ail ", 30)) + arch("tool", "web_read", "Horaires SNCF\nle train de 14h12 part quai 3, le TRAIN suivant à 15h") + arch("tool", "bash", "\n\n $ ls -la\na.go b.go\tc.go") + arch("assistant", "", "Voici le résumé de l'ÉTÉ : plage, soleil, Été indien.") + arch("user", "", "İSTANBUL et ΣΟΦΙΑ en majuscules") + arch("tool", "", "outil sans nom : train de marchandises") + arch("tool", "read_file", strings.Repeat("é", 200)+" fin") + arch("tool", "web_read", "balises & entités & \"guillemets\"") + arch("tool", "bash", "") + arch("tool", "mem_search", strings.Repeat("très longue première ligne sans fin ", 5)+"\nsuite") + for i := 0; i < 25; i++ { + arch("tool", "bash", fmt.Sprintf("lot %d de la migration : sortie numéro %d", i, i)) + } + + var steps []parityRecallStep + run := func(name, tool string, args map[string]any) { + var r string + if tool == "recall" { + r = toolRecall(args) + } else { + r = toolRecallSearch(args) + } + steps = append(steps, parityRecallStep{Name: name, Tool: tool, Args: args, Result: r}) + } + id := func(v any) map[string]any { return map[string]any{"id": v} } + q := func(v any, kv ...any) map[string]any { + m := map[string]any{"query": v} + for i := 0; i+1 < len(kv); i += 2 { + m[kv[i].(string)] = kv[i+1] + } + return m + } + run("sans id", "recall", map[string]any{}) + run("id non texte", "recall", id(7.0)) + run("id blanc", "recall", id(" ")) + run("r1", "recall", id("r1")) + run("R2 majuscule", "recall", id("R2")) + run("espaces", "recall", id(" 3 ")) + run("sans préfixe", "recall", id("4")) + run("espace après le préfixe", "recall", id("r 5")) + run("deux préfixes", "recall", id("rR6")) + run("préfixes à l'envers", "recall", id("Rr6")) + run("zéros de tête", "recall", id("r007")) + run("contenu long", "recall", id("r7")) + run("caractères spéciaux", "recall", id("r8")) + run("bloc vide", "recall", id("r9")) + run("mal formé", "recall", id("rr")) + run("négatif", "recall", id("r-1")) + run("signe plus", "recall", id("+1")) + run("zéro", "recall", id("r0")) + run("absent", "recall", id("r999")) + run("démesuré", "recall", id("r99999999999999999999")) + run("guillemets dans l'id", "recall", id(`r"9\`)) + + run("sans requête", "recall_search", map[string]any{}) + run("requête non texte", "recall_search", q(3.0)) + run("requête blanche", "recall_search", q(" ")) + run("mots trop courts", "recall_search", q("a b ! ?")) + run("train horaires", "recall_search", q("train horaires")) + run("casse", "recall_search", q("TRAIN")) + run("accents majuscules", "recall_search", q("ÉTÉ")) + run("i turc", "recall_search", q("İstanbul")) + run("sigma", "recall_search", q("σοφια")) + run("souligné coupé", "recall_search", q("web_read")) + run("extrait borné", "recall_search", q("fin éé")) + run("première ligne longue", "recall_search", q("suite")) + run("limite 1", "recall_search", q("train", "limit", 1.0)) + run("limite tronquée", "recall_search", q("train", "limit", 2.7)) + run("limite négative", "recall_search", q("lot", "limit", -3.0)) + run("défaut", "recall_search", q("migration")) + run("vingt au plus", "recall_search", q("lot migration", "limit", 50.0)) + run("limite texte ignorée", "recall_search", q("lot", "limit", "3")) + run("rien ne correspond", "recall_search", q("motabsolumentabsent")) + run("ponctuation", "recall_search", q("«entités»,&")) + + blocks := recallAll() + for i := range blocks { + blocks[i].Created = 0 + } + var buf bytes.Buffer + enc := json.NewEncoder(&buf) + enc.SetEscapeHTML(false) + enc.SetIndent("", " ") + if err := enc.Encode(map[string]any{"blocks": blocks, "steps": steps}); err != nil { + t.Fatal(err) + } + got := buf.Bytes() + if os.Getenv("LOKI_PARITY_UPDATE") == "1" { + if err := os.MkdirAll(filepath.Dir(parityRecallFile), 0o755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(parityRecallFile, got, 0o644); err != nil { + t.Fatal(err) + } + return + } + want, err := os.ReadFile(parityRecallFile) + if err != nil { + t.Fatalf("journal de référence absent (%v) : LOKI_PARITY_UPDATE=1 pour le créer", err) + } + if !bytes.Equal(got, want) { + t.Errorf("recall ne produit plus le journal rangé dans %s.\n"+ + "LOKI_PARITY_UPDATE=1 pour le régénérer si le changement est voulu.", parityRecallFile) + } +} diff --git a/internal/loki/web_internal.go b/internal/loki/web_internal.go index 3a28ccb..7df4cbd 100644 --- a/internal/loki/web_internal.go +++ b/internal/loki/web_internal.go @@ -107,6 +107,10 @@ func withInternalAPI(next http.Handler) http.Handler { handleInternalEngineStop(w, r) case "/internal/v1/recall/archive": handleInternalRecallArchive(w, r) + case "/internal/v1/recall/block": + handleInternalRecallBlock(w, r) + case "/internal/v1/recall/blocks": + handleInternalRecallBlocks(w, r) case "/internal/v1/turn/start": handleInternalTurnStart(w, r) case "/internal/v1/turn/view": diff --git a/internal/loki/web_internal_recall.go b/internal/loki/web_internal_recall.go index a811693..bd0a16f 100644 --- a/internal/loki/web_internal_recall.go +++ b/internal/loki/web_internal_recall.go @@ -5,6 +5,7 @@ import ( "errors" "io" "net/http" + "strconv" ) // API interne de l'archive recall (lot 6 de la migration Rust). @@ -16,6 +17,12 @@ import ( // // POST /internal/v1/recall/archive {"label":…,"role":…,"content":…} // → 200 {"id":"r7"} · 423 mémoire verrouillée (rien d'écrit en clair) +// +// Les outils recall et recall_search de la façade (lot 46) lisent l'archive +// ici ; le reste (id tolérant, score, mise en forme) est chez elle : +// +// GET /internal/v1/recall/block?seq=7 → 200 bloc · 404 absent ou illisible +// GET /internal/v1/recall/blocks → 200 [blocs lisibles, par séquence] const internalRecallMaxBody = 32 << 20 @@ -46,3 +53,35 @@ func handleInternalRecallArchive(w http.ResponseWriter, r *http.Request) { } sendJSON(w, http.StatusOK, map[string]any{"id": id}) } + +func handleInternalRecallBlock(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodGet { + w.Header().Set("Allow", http.MethodGet) + sendJSON(w, http.StatusMethodNotAllowed, map[string]any{"error": "GET attendu"}) + return + } + seq, err := strconv.ParseUint(r.URL.Query().Get("seq"), 10, 64) + if err != nil { + sendJSON(w, http.StatusBadRequest, map[string]any{"error": "seq illisible"}) + return + } + blk, ok := recallGetSeq(seq) + if !ok { + sendJSON(w, http.StatusNotFound, map[string]any{"error": "bloc introuvable"}) + return + } + sendJSON(w, http.StatusOK, blk) +} + +func handleInternalRecallBlocks(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodGet { + w.Header().Set("Allow", http.MethodGet) + sendJSON(w, http.StatusMethodNotAllowed, map[string]any{"error": "GET attendu"}) + return + } + blks := recallAll() + if blks == nil { + blks = []recallBlock{} + } + sendJSON(w, http.StatusOK, blks) +} diff --git a/internal/loki/web_internal_recall_test.go b/internal/loki/web_internal_recall_test.go index 63ec8cd..ca5c20e 100644 --- a/internal/loki/web_internal_recall_test.go +++ b/internal/loki/web_internal_recall_test.go @@ -1,6 +1,7 @@ package loki import ( + "encoding/json" "strings" "testing" ) @@ -27,3 +28,44 @@ func TestRecallInterneArchive(t *testing.T) { } } } + +// Lot 46 : la façade lit l'archive (un bloc par sa séquence, ou tous les +// blocs lisibles par séquence croissante) ; le reste est chez elle. +func TestRecallInterneLecture(t *testing.T) { + testHome(t) + t.Setenv(internalTokenEnv, jetonTest) + if w := appelOAIInterne(t, "GET", "/internal/v1/recall/blocks", ""); w.Code != 200 || strings.TrimSpace(w.Body.String()) != "[]" { + t.Fatalf("archive vide : %d %s", w.Code, w.Body.String()) + } + for _, c := range []string{"premier ", "second bloc"} { + if _, err := archiveRecallBlock("bash: "+c, "tool", c); err != nil { + t.Fatal(err) + } + } + w := appelOAIInterne(t, "GET", "/internal/v1/recall/block?seq=1", "") + if w.Code != 200 { + t.Fatalf("bloc 1 : %d %s", w.Code, w.Body.String()) + } + var blk recallBlock + if err := json.Unmarshal(w.Body.Bytes(), &blk); err != nil || blk.ID != "r1" || blk.Content != "premier " || blk.Label != "bash: premier " { + t.Fatalf("bloc 1 : %v %+v", err, blk) + } + if w := appelOAIInterne(t, "GET", "/internal/v1/recall/block?seq=9", ""); w.Code != 404 { + t.Fatalf("absent : %d", w.Code) + } + for _, q := range []string{"", "?seq=", "?seq=r1", "?seq=-1"} { + if w := appelOAIInterne(t, "GET", "/internal/v1/recall/block"+q, ""); w.Code != 400 { + t.Fatalf("%q : %d", q, w.Code) + } + } + for _, p := range []string{"/internal/v1/recall/block?seq=1", "/internal/v1/recall/blocks"} { + if w := appelOAIInterne(t, "POST", p, ""); w.Code != 405 { + t.Fatalf("POST %s : %d", p, w.Code) + } + } + w = appelOAIInterne(t, "GET", "/internal/v1/recall/blocks", "") + var all []recallBlock + if err := json.Unmarshal(w.Body.Bytes(), &all); err != nil || len(all) != 2 || all[0].Seq != 1 || all[1].Content != "second bloc" { + t.Fatalf("tous : %v %s", err, w.Body.String()) + } +} diff --git a/rust/loki-rs/src/chat/host.rs b/rust/loki-rs/src/chat/host.rs index 5ff33ef..3d71b63 100644 --- a/rust/loki-rs/src/chat/host.rs +++ b/rust/loki-rs/src/chat/host.rs @@ -718,6 +718,16 @@ impl TurnHost for GoTurnHost { ..Default::default() }; } + // Mémoire longue de la conversation (lot 46) : l'archive est au Go, + // l'id, le score et la mise en forme ici ; Go injoignable : par lui. + if crate::recall::is_tool(name) + && let Some(result) = crate::recall::exec(&self.shared.store, name, args).await + { + return ToolOutcome { + result, + ..Default::default() + }; + } // Outils d'image (lot 45) : see_image dans le dossier de la // discussion, web_screenshot par Playwright ; l'image relayée au // modèle s'il la voit. diff --git a/rust/loki-rs/src/lib.rs b/rust/loki-rs/src/lib.rs index a1ee2a7..4040d2c 100644 --- a/rust/loki-rs/src/lib.rs +++ b/rust/loki-rs/src/lib.rs @@ -41,6 +41,7 @@ pub mod presets_api; pub mod projects_api; pub mod proxy; pub mod push; +pub mod recall; pub mod serve; pub mod settings; pub mod store; diff --git a/rust/loki-rs/src/recall.rs b/rust/loki-rs/src/recall.rs new file mode 100644 index 0000000..bfdadd1 --- /dev/null +++ b/rust/loki-rs/src/recall.rs @@ -0,0 +1,194 @@ +//! Outils `recall` et `recall_search` de l'agent (lot 46) — port de +//! `toolRecall` / `toolRecallSearch` (`chat_recall.go`). +//! +//! La compaction archive verbatim les gros blocs qu'elle résume ; l'archive +//! reste au Go (numérotée par la séquence de sa base), qui sert les blocs +//! lisibles (`/internal/v1/recall/block`, `/internal/v1/recall/blocks`). +//! Ici : l'id tolérant, le score par mots-clés, la mise en forme. + +use std::collections::BTreeSet; + +use serde::Deserialize; +use serde_json::{Map, Value}; + +use crate::store::Store; + +/// `recallSearchDefault`, `recallSearchMax`. +const SEARCH_DEFAULT: usize = 5; +const SEARCH_MAX: usize = 20; + +/// `recallBlock`. +#[derive(Debug, Clone, Default, PartialEq, Deserialize)] +#[serde(default)] +pub struct Block { + pub id: String, + pub seq: u64, + pub label: String, + pub role: String, + pub content: String, +} + +/// Les outils de ce module. +pub fn is_tool(name: &str) -> bool { + name == "recall" || name == "recall_search" +} + +/// `recallIDToSeq` : « r7 », « R7 », « 7 », espaces tolérés. +pub fn id_to_seq(id: &str) -> Option { + let s = id.trim(); + let s = s.strip_prefix('r').unwrap_or(s); + let s = s.strip_prefix('R').unwrap_or(s); + let s = s.trim(); + // `strconv.ParseUint(s, 10, 64)` : des chiffres seulement (ni signe). + if s.is_empty() || !s.bytes().all(|b| b.is_ascii_digit()) { + return None; + } + s.parse().ok() +} + +/// `strings.ToLower` du Go : rune par rune, sans casse contextuelle. +pub fn go_lower(s: &str) -> String { + s.chars() + .map(|c| { + let mut l = c.to_lowercase(); + match (l.next(), l.next()) { + (Some(a), None) => a, + // « İ » (U+0130), seule minuscule de deux runes : `i` en Go. + _ => 'i', + } + }) + .collect() +} + +/// `recallTokens` : mots d'au moins deux runes, coupés sur tout ce qui n'est +/// ni a–z, ni 0–9, ni à–ÿ. +pub fn tokens(s: &str) -> BTreeSet { + go_lower(s) + .split(|c: char| { + !(c.is_ascii_lowercase() || c.is_ascii_digit() || ('à'..='ÿ').contains(&c)) + }) + .filter(|w| w.chars().count() >= 2) + .map(str::to_string) + .collect() +} + +/// `recallSearch` : score = termes présents, le libellé comptant double ; à +/// égalité, le plus récent d'abord. +pub fn search(blocks: Vec, query: &str, limit: i64) -> Vec { + let terms = tokens(query); + if terms.is_empty() { + return Vec::new(); + } + let limit = if limit <= 0 { + SEARCH_DEFAULT + } else { + (limit as usize).min(SEARCH_MAX) + }; + let mut hits: Vec<(Block, usize)> = blocks + .into_iter() + .filter_map(|b| { + let label = go_lower(&b.label); + let body = go_lower(&b.content); + let score: usize = terms + .iter() + .map(|t| { + 2 * usize::from(label.contains(t.as_str())) + + usize::from(body.contains(t.as_str())) + }) + .sum(); + (score > 0).then_some((b, score)) + }) + .collect(); + hits.sort_by(|a, b| b.1.cmp(&a.1).then(b.0.seq.cmp(&a.0.seq))); + hits.truncate(limit); + hits.into_iter().map(|h| h.0).collect() +} + +/// `recallSnippet` : une ligne, bornée à `n` runes. +pub fn snippet(s: &str, n: usize) -> String { + let s = s.split_whitespace().collect::>().join(" "); + if s.chars().count() > n { + let head: String = s.chars().take(n).collect(); + return format!("{}…", head.trim()); + } + s +} + +/// `toolRecall`, le bloc lu (`None` : absent ou illisible). +pub fn recall_text(args: &Map, blk: Option) -> String { + let id = args.get("id").and_then(Value::as_str).unwrap_or(""); + let Some(b) = blk else { + return format!( + "[erreur] bloc {} introuvable — vérifie l'id, ou utilise recall_search pour le retrouver par mots-clés", + crate::gojson::quote(id) + ); + }; + format!("[{} — {}, rappelé verbatim]\n{}", b.id, b.label, b.content) +} + +/// `toolRecallSearch`, sur les résultats déjà triés. +pub fn search_text(hits: &[Block]) -> String { + if hits.is_empty() { + return "[aucun bloc archivé ne correspond]".into(); + } + let mut out = String::from("Blocs trouvés (rappelle le bon avec recall(id)) :\n"); + for h in hits { + out += &format!("- {} — {}\n {}\n", h.id, h.label, snippet(&h.content, 160)); + } + out.trim_end_matches('\n').to_string() +} + +/// `limit` du Go : `args["limit"].(float64)` tronqué. +fn arg_limit(args: &Map) -> i64 { + args.get("limit") + .and_then(Value::as_f64) + .map_or(0, |f| f as i64) +} + +/// Exécute `recall` ou `recall_search` sur l'archive du Go. `None` : le Go +/// n'a pas répondu (l'appel passe alors par lui). +pub async fn exec(store: &Store, name: &str, args: &Map) -> Option { + match name { + "recall" => { + let id = args.get("id").and_then(Value::as_str).unwrap_or(""); + if id.trim().is_empty() { + return Some("[erreur] id manquant".into()); + } + let Some(seq) = id_to_seq(id) else { + return Some(recall_text(args, None)); + }; + let (status, v) = store + .internal_get(&format!("/internal/v1/recall/block?seq={seq}")) + .await + .ok()?; + let blk = match status.as_u16() { + 200 => serde_json::from_value(v).ok(), + 404 => None, + _ => return None, + }; + Some(recall_text(args, blk)) + } + "recall_search" => { + let q = args.get("query").and_then(Value::as_str).unwrap_or(""); + if q.trim().is_empty() { + return Some("[erreur] query manquante".into()); + } + if tokens(q).is_empty() { + return Some(search_text(&[])); + } + let (status, v) = store + .internal_get("/internal/v1/recall/blocks") + .await + .ok()?; + if status.as_u16() != 200 { + return None; + } + let blocks: Vec = serde_json::from_value(v).ok()?; + Some(search_text(&search(blocks, q, arg_limit(args)))) + } + _ => None, + } +} + +#[cfg(test)] +mod tests; diff --git a/rust/loki-rs/src/recall/tests.rs b/rust/loki-rs/src/recall/tests.rs new file mode 100644 index 0000000..ae902d4 --- /dev/null +++ b/rust/loki-rs/src/recall/tests.rs @@ -0,0 +1,91 @@ +//! Tests unitaires de recall (lot 46) ; la parité avec le Go est dans +//! `tests/recall.rs`. + +use super::*; + +fn blk(seq: u64, label: &str, content: &str) -> Block { + Block { + id: format!("r{seq}"), + seq, + label: label.into(), + role: "tool".into(), + content: content.into(), + } +} + +#[test] +fn id_tolerant() { + for s in ["r7", "R7", " r7 ", "7", "rR7", "r 7", "007"] { + assert_eq!(id_to_seq(s), Some(7), "{s}"); + } + for s in [ + "", + "rr", + "abc", + "r-1", + "+7", + "r+7", + "7a", + "Rr7", + "99999999999999999999", + ] { + assert_eq!(id_to_seq(s), None, "{s}"); + } +} + +#[test] +fn mots_cles() { + let t: Vec = tokens("Le TRAIN de 14h12, quai n°3 — Été ÿ x") + .into_iter() + .collect(); + assert_eq!(t, ["14h12", "de", "le", "quai", "train", "été"]); + assert_eq!(go_lower("İSTANBUL Σ"), "istanbul σ"); + assert!(tokens("a b ! ?").is_empty()); +} + +#[test] +fn score_et_ordre() { + let blocks = vec![ + blk(1, "web_read: recette", "gratin dauphinois"), + blk(2, "web_read: horaires", "le train part quai 3"), + blk(3, "bash", "train train"), + blk(4, "autre", "rien"), + ]; + let ids: Vec = search(blocks.clone(), "train horaires", 0) + .into_iter() + .map(|b| b.id) + .collect(); + assert_eq!(ids, ["r2", "r3"]); + // À égalité, le plus récent d'abord ; limite respectée. + let ids: Vec = search(blocks, "web_read", 1) + .into_iter() + .map(|b| b.id) + .collect(); + assert_eq!(ids, ["r2"]); +} + +#[test] +fn extraits() { + assert_eq!(snippet(" a\n\tb c ", 160), "a b c"); + assert_eq!(snippet("abcdef", 3), "abc…"); + assert_eq!(snippet("ab cdef", 3), "ab…"); +} + +#[test] +fn textes() { + let mut args = Map::new(); + args.insert("id".into(), Value::from("r\"9")); + assert_eq!( + recall_text(&args, None), + "[erreur] bloc \"r\\\"9\" introuvable — vérifie l'id, ou utilise recall_search pour le retrouver par mots-clés" + ); + assert_eq!( + recall_text(&args, Some(blk(9, "bash: ls", "a\nb"))), + "[r9 — bash: ls, rappelé verbatim]\na\nb" + ); + assert_eq!(search_text(&[]), "[aucun bloc archivé ne correspond]"); + assert_eq!( + search_text(&[blk(2, "x", "y z")]), + "Blocs trouvés (rappelle le bon avec recall(id)) :\n- r2 — x\n y z" + ); +} diff --git a/rust/loki-rs/src/store.rs b/rust/loki-rs/src/store.rs index 8d74dc3..2727932 100644 --- a/rust/loki-rs/src/store.rs +++ b/rust/loki-rs/src/store.rs @@ -104,6 +104,17 @@ impl Store { serde_json::from_slice(&bytes).map_err(|e| e.to_string()) } + /// GET à l'API interne, statut compris (404 à distinguer d'une panne). + /// Corps vide ou illisible → `Value::Null`. + pub async fn internal_get( + &self, + path: &str, + ) -> Result<(StatusCode, serde_json::Value), String> { + let (status, bytes) = self.call(Method::GET, path, Vec::new()).await?; + let v = serde_json::from_slice(&bytes).unwrap_or(serde_json::Value::Null); + Ok((status, v)) + } + /// POST JSON à l'API interne, statut compris (refus métier à distinguer : /// 409, 410…). Corps vide ou illisible → `Value::Null`. pub async fn internal_post( diff --git a/rust/loki-rs/tests/common/fakego.rs b/rust/loki-rs/tests/common/fakego.rs index a97ab71..c76e841 100644 --- a/rust/loki-rs/tests/common/fakego.rs +++ b/rust/loki-rs/tests/common/fakego.rs @@ -52,6 +52,10 @@ pub struct FakeGo { /// Résultat imposé à tout outil (sinon « exec: »), et archive recall. pub tool_result: Mutex>, pub recall: Mutex>, + /// Blocs servis par /internal/v1/recall/block(s) (lot 46), et lectures + /// reçues (« block », « blocks »). + pub recall_blocks: Mutex>, + pub recall_log: Mutex>, /// Discussion active annoncée et discussions supprimées. pub conv_log: Mutex>, /// Tours : débuts, vues et fins demandés. @@ -502,6 +506,34 @@ async fn handle(st: Arc, req: Request) -> Response ), None => resp(200, r#"{"busy":false}"#), }, + "/internal/v1/recall/block" => { + let seq = req + .uri() + .query() + .unwrap_or("") + .strip_prefix("seq=") + .unwrap_or("") + .to_string(); + st.recall_log.lock().unwrap().push(format!("block {seq}")); + let found = st + .recall_blocks + .lock() + .unwrap() + .iter() + .find(|b| b["seq"].as_u64().map(|n| n.to_string()).as_deref() == Some(seq.as_str())) + .cloned(); + match found { + Some(b) => resp(200, b.to_string()), + None => resp(404, r#"{"error":"bloc introuvable"}"#), + } + } + "/internal/v1/recall/blocks" => { + st.recall_log.lock().unwrap().push("blocks".into()); + resp( + 200, + serde_json::Value::Array(st.recall_blocks.lock().unwrap().clone()).to_string(), + ) + } "/internal/v1/service/prestart" => { *st.prestarts.lock().unwrap() += 1; let e = st.tune_busy.lock().unwrap().clone().unwrap_or_default(); diff --git a/rust/loki-rs/tests/lot6.rs b/rust/loki-rs/tests/lot6.rs index 9be8e31..e7542dd 100644 --- a/rust/loki-rs/tests/lot6.rs +++ b/rust/loki-rs/tests/lot6.rs @@ -2843,6 +2843,45 @@ async fn job_arriere_plan_par_la_facade() { assert_eq!(jobs.running(), 0); } +/// Lot 46 : `recall` et `recall_search` sont exécutés par la façade sur +/// l'archive du Go (rien par /tool/exec). +#[tokio::test] +async fn recall_par_la_facade() { + let calls = sse(&[ + serde_json::json!({"choices":[{"delta":{"tool_calls":[ + {"index":0,"id":"c1","type":"function","function":{"name":"recall","arguments":"{\"id\":\"R2 \"}"}}, + {"index":1,"id":"c2","type":"function","function":{"name":"recall_search","arguments":"{\"query\":\"Gratin\"}"}} + ]}}]}), + serde_json::json!({"choices":[{"delta":{},"finish_reason":"tool_calls"}]}), + ]); + let (url, seen) = fake_engine(vec![calls, answer("rappelé")]).await; + let (svc, go, _stop, _t) = service_with(Some(&url), |_| {}).await; + *go.recall_blocks.lock().unwrap() = vec![ + serde_json::json!({"id":"r1","seq":1,"label":"web_read: gratin","role":"tool","content":"recette du gratin"}), + serde_json::json!({"id":"r2","seq":2,"label":"bash: ls","role":"tool","content":"a.go\nb.go"}), + ]; + svc.send(serde_json::json!({"message": "rappelle", "cid": "r"})) + .await + .unwrap(); + until_done(&svc, 1).await; + assert!(go.tool_log.lock().unwrap().is_empty(), "{:?}", go.tool_log); + let seen = seen.lock().unwrap().clone(); + let tools: Vec = seen[1].1["messages"] + .as_array() + .unwrap() + .iter() + .filter(|m| m["role"] == "tool") + .map(|m| m["content"].as_str().unwrap().to_string()) + .collect(); + assert_eq!( + tools, + [ + "[r2 — bash: ls, rappelé verbatim]\na.go\nb.go", + "Blocs trouvés (rappelle le bon avec recall(id)) :\n- r1 — web_read: gratin\n recette du gratin", + ] + ); +} + /// Lot 45 : `see_image` et `web_screenshot` sont exécutés par la façade /// (rien par le Go) : sans projecteur, l'image n'est pas lue ; une capture /// sans url est refusée avant Playwright. diff --git a/rust/loki-rs/tests/parity_recall/recall.json b/rust/loki-rs/tests/parity_recall/recall.json new file mode 100644 index 0000000..396ee7b --- /dev/null +++ b/rust/loki-rs/tests/parity_recall/recall.json @@ -0,0 +1,615 @@ +{ + "blocks": [ + { + "id": "r1", + "seq": 1, + "label": "web_read: Recette du gratin dauphinois", + "role": "tool", + "content": "Recette du gratin dauphinois\npommes de terre, crème, ail pommes de terre, crème, ail pommes de terre, crème, ail pommes de terre, crème, ail pommes de terre, crème, ail pommes de terre, crème, ail pommes de terre, crème, ail pommes de terre, crème, ail pommes de terre, crème, ail pommes de terre, crème, ail pommes de terre, crème, ail pommes de terre, crème, ail pommes de terre, crème, ail pommes de terre, crème, ail pommes de terre, crème, ail pommes de terre, crème, ail pommes de terre, crème, ail pommes de terre, crème, ail pommes de terre, crème, ail pommes de terre, crème, ail pommes de terre, crème, ail pommes de terre, crème, ail pommes de terre, crème, ail pommes de terre, crème, ail pommes de terre, crème, ail pommes de terre, crème, ail pommes de terre, crème, ail pommes de terre, crème, ail pommes de terre, crème, ail pommes de terre, crème, ail ", + "created": 0 + }, + { + "id": "r2", + "seq": 2, + "label": "web_read: Horaires SNCF", + "role": "tool", + "content": "Horaires SNCF\nle train de 14h12 part quai 3, le TRAIN suivant à 15h", + "created": 0 + }, + { + "id": "r3", + "seq": 3, + "label": "bash: $ ls -la", + "role": "tool", + "content": "\n\n $ ls -la\na.go b.go\tc.go", + "created": 0 + }, + { + "id": "r4", + "seq": 4, + "label": "assistant: Voici le résumé de l'ÉTÉ : plage, soleil, Été indien.", + "role": "assistant", + "content": "Voici le résumé de l'ÉTÉ : plage, soleil, Été indien.", + "created": 0 + }, + { + "id": "r5", + "seq": 5, + "label": "user: İSTANBUL et ΣΟΦΙΑ en majuscules", + "role": "user", + "content": "İSTANBUL et ΣΟΦΙΑ en majuscules", + "created": 0 + }, + { + "id": "r6", + "seq": 6, + "label": "tool: outil sans nom : train de marchandises", + "role": "tool", + "content": "outil sans nom : train de marchandises", + "created": 0 + }, + { + "id": "r7", + "seq": 7, + "label": "read_file: éééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééé…", + "role": "tool", + "content": "éééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééé fin", + "created": 0 + }, + { + "id": "r8", + "seq": 8, + "label": "web_read: balises & entités & \"guillemets\"", + "role": "tool", + "content": "balises & entités & \"guillemets\"", + "created": 0 + }, + { + "id": "r9", + "seq": 9, + "label": "bash", + "role": "tool", + "content": "", + "created": 0 + }, + { + "id": "r10", + "seq": 10, + "label": "mem_search: très longue première ligne sans fin très longue première ligne sans fi…", + "role": "tool", + "content": "très longue première ligne sans fin très longue première ligne sans fin très longue première ligne sans fin très longue première ligne sans fin très longue première ligne sans fin \nsuite", + "created": 0 + }, + { + "id": "r11", + "seq": 11, + "label": "bash: lot 0 de la migration : sortie numéro 0", + "role": "tool", + "content": "lot 0 de la migration : sortie numéro 0", + "created": 0 + }, + { + "id": "r12", + "seq": 12, + "label": "bash: lot 1 de la migration : sortie numéro 1", + "role": "tool", + "content": "lot 1 de la migration : sortie numéro 1", + "created": 0 + }, + { + "id": "r13", + "seq": 13, + "label": "bash: lot 2 de la migration : sortie numéro 2", + "role": "tool", + "content": "lot 2 de la migration : sortie numéro 2", + "created": 0 + }, + { + "id": "r14", + "seq": 14, + "label": "bash: lot 3 de la migration : sortie numéro 3", + "role": "tool", + "content": "lot 3 de la migration : sortie numéro 3", + "created": 0 + }, + { + "id": "r15", + "seq": 15, + "label": "bash: lot 4 de la migration : sortie numéro 4", + "role": "tool", + "content": "lot 4 de la migration : sortie numéro 4", + "created": 0 + }, + { + "id": "r16", + "seq": 16, + "label": "bash: lot 5 de la migration : sortie numéro 5", + "role": "tool", + "content": "lot 5 de la migration : sortie numéro 5", + "created": 0 + }, + { + "id": "r17", + "seq": 17, + "label": "bash: lot 6 de la migration : sortie numéro 6", + "role": "tool", + "content": "lot 6 de la migration : sortie numéro 6", + "created": 0 + }, + { + "id": "r18", + "seq": 18, + "label": "bash: lot 7 de la migration : sortie numéro 7", + "role": "tool", + "content": "lot 7 de la migration : sortie numéro 7", + "created": 0 + }, + { + "id": "r19", + "seq": 19, + "label": "bash: lot 8 de la migration : sortie numéro 8", + "role": "tool", + "content": "lot 8 de la migration : sortie numéro 8", + "created": 0 + }, + { + "id": "r20", + "seq": 20, + "label": "bash: lot 9 de la migration : sortie numéro 9", + "role": "tool", + "content": "lot 9 de la migration : sortie numéro 9", + "created": 0 + }, + { + "id": "r21", + "seq": 21, + "label": "bash: lot 10 de la migration : sortie numéro 10", + "role": "tool", + "content": "lot 10 de la migration : sortie numéro 10", + "created": 0 + }, + { + "id": "r22", + "seq": 22, + "label": "bash: lot 11 de la migration : sortie numéro 11", + "role": "tool", + "content": "lot 11 de la migration : sortie numéro 11", + "created": 0 + }, + { + "id": "r23", + "seq": 23, + "label": "bash: lot 12 de la migration : sortie numéro 12", + "role": "tool", + "content": "lot 12 de la migration : sortie numéro 12", + "created": 0 + }, + { + "id": "r24", + "seq": 24, + "label": "bash: lot 13 de la migration : sortie numéro 13", + "role": "tool", + "content": "lot 13 de la migration : sortie numéro 13", + "created": 0 + }, + { + "id": "r25", + "seq": 25, + "label": "bash: lot 14 de la migration : sortie numéro 14", + "role": "tool", + "content": "lot 14 de la migration : sortie numéro 14", + "created": 0 + }, + { + "id": "r26", + "seq": 26, + "label": "bash: lot 15 de la migration : sortie numéro 15", + "role": "tool", + "content": "lot 15 de la migration : sortie numéro 15", + "created": 0 + }, + { + "id": "r27", + "seq": 27, + "label": "bash: lot 16 de la migration : sortie numéro 16", + "role": "tool", + "content": "lot 16 de la migration : sortie numéro 16", + "created": 0 + }, + { + "id": "r28", + "seq": 28, + "label": "bash: lot 17 de la migration : sortie numéro 17", + "role": "tool", + "content": "lot 17 de la migration : sortie numéro 17", + "created": 0 + }, + { + "id": "r29", + "seq": 29, + "label": "bash: lot 18 de la migration : sortie numéro 18", + "role": "tool", + "content": "lot 18 de la migration : sortie numéro 18", + "created": 0 + }, + { + "id": "r30", + "seq": 30, + "label": "bash: lot 19 de la migration : sortie numéro 19", + "role": "tool", + "content": "lot 19 de la migration : sortie numéro 19", + "created": 0 + }, + { + "id": "r31", + "seq": 31, + "label": "bash: lot 20 de la migration : sortie numéro 20", + "role": "tool", + "content": "lot 20 de la migration : sortie numéro 20", + "created": 0 + }, + { + "id": "r32", + "seq": 32, + "label": "bash: lot 21 de la migration : sortie numéro 21", + "role": "tool", + "content": "lot 21 de la migration : sortie numéro 21", + "created": 0 + }, + { + "id": "r33", + "seq": 33, + "label": "bash: lot 22 de la migration : sortie numéro 22", + "role": "tool", + "content": "lot 22 de la migration : sortie numéro 22", + "created": 0 + }, + { + "id": "r34", + "seq": 34, + "label": "bash: lot 23 de la migration : sortie numéro 23", + "role": "tool", + "content": "lot 23 de la migration : sortie numéro 23", + "created": 0 + }, + { + "id": "r35", + "seq": 35, + "label": "bash: lot 24 de la migration : sortie numéro 24", + "role": "tool", + "content": "lot 24 de la migration : sortie numéro 24", + "created": 0 + } + ], + "steps": [ + { + "name": "sans id", + "tool": "recall", + "args": {}, + "result": "[erreur] id manquant" + }, + { + "name": "id non texte", + "tool": "recall", + "args": { + "id": 7 + }, + "result": "[erreur] id manquant" + }, + { + "name": "id blanc", + "tool": "recall", + "args": { + "id": " " + }, + "result": "[erreur] id manquant" + }, + { + "name": "r1", + "tool": "recall", + "args": { + "id": "r1" + }, + "result": "[r1 — web_read: Recette du gratin dauphinois, rappelé verbatim]\nRecette du gratin dauphinois\npommes de terre, crème, ail pommes de terre, crème, ail pommes de terre, crème, ail pommes de terre, crème, ail pommes de terre, crème, ail pommes de terre, crème, ail pommes de terre, crème, ail pommes de terre, crème, ail pommes de terre, crème, ail pommes de terre, crème, ail pommes de terre, crème, ail pommes de terre, crème, ail pommes de terre, crème, ail pommes de terre, crème, ail pommes de terre, crème, ail pommes de terre, crème, ail pommes de terre, crème, ail pommes de terre, crème, ail pommes de terre, crème, ail pommes de terre, crème, ail pommes de terre, crème, ail pommes de terre, crème, ail pommes de terre, crème, ail pommes de terre, crème, ail pommes de terre, crème, ail pommes de terre, crème, ail pommes de terre, crème, ail pommes de terre, crème, ail pommes de terre, crème, ail pommes de terre, crème, ail " + }, + { + "name": "R2 majuscule", + "tool": "recall", + "args": { + "id": "R2" + }, + "result": "[r2 — web_read: Horaires SNCF, rappelé verbatim]\nHoraires SNCF\nle train de 14h12 part quai 3, le TRAIN suivant à 15h" + }, + { + "name": "espaces", + "tool": "recall", + "args": { + "id": " 3 " + }, + "result": "[r3 — bash: $ ls -la, rappelé verbatim]\n\n\n $ ls -la\na.go b.go\tc.go" + }, + { + "name": "sans préfixe", + "tool": "recall", + "args": { + "id": "4" + }, + "result": "[r4 — assistant: Voici le résumé de l'ÉTÉ : plage, soleil, Été indien., rappelé verbatim]\nVoici le résumé de l'ÉTÉ : plage, soleil, Été indien." + }, + { + "name": "espace après le préfixe", + "tool": "recall", + "args": { + "id": "r 5" + }, + "result": "[r5 — user: İSTANBUL et ΣΟΦΙΑ en majuscules, rappelé verbatim]\nİSTANBUL et ΣΟΦΙΑ en majuscules" + }, + { + "name": "deux préfixes", + "tool": "recall", + "args": { + "id": "rR6" + }, + "result": "[r6 — tool: outil sans nom : train de marchandises, rappelé verbatim]\noutil sans nom : train de marchandises" + }, + { + "name": "préfixes à l'envers", + "tool": "recall", + "args": { + "id": "Rr6" + }, + "result": "[erreur] bloc \"Rr6\" introuvable — vérifie l'id, ou utilise recall_search pour le retrouver par mots-clés" + }, + { + "name": "zéros de tête", + "tool": "recall", + "args": { + "id": "r007" + }, + "result": "[r7 — read_file: éééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééé…, rappelé verbatim]\néééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééé fin" + }, + { + "name": "contenu long", + "tool": "recall", + "args": { + "id": "r7" + }, + "result": "[r7 — read_file: éééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééé…, rappelé verbatim]\néééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééé fin" + }, + { + "name": "caractères spéciaux", + "tool": "recall", + "args": { + "id": "r8" + }, + "result": "[r8 — web_read: balises & entités & \"guillemets\", rappelé verbatim]\nbalises & entités & \"guillemets\"" + }, + { + "name": "bloc vide", + "tool": "recall", + "args": { + "id": "r9" + }, + "result": "[r9 — bash, rappelé verbatim]\n" + }, + { + "name": "mal formé", + "tool": "recall", + "args": { + "id": "rr" + }, + "result": "[erreur] bloc \"rr\" introuvable — vérifie l'id, ou utilise recall_search pour le retrouver par mots-clés" + }, + { + "name": "négatif", + "tool": "recall", + "args": { + "id": "r-1" + }, + "result": "[erreur] bloc \"r-1\" introuvable — vérifie l'id, ou utilise recall_search pour le retrouver par mots-clés" + }, + { + "name": "signe plus", + "tool": "recall", + "args": { + "id": "+1" + }, + "result": "[erreur] bloc \"+1\" introuvable — vérifie l'id, ou utilise recall_search pour le retrouver par mots-clés" + }, + { + "name": "zéro", + "tool": "recall", + "args": { + "id": "r0" + }, + "result": "[erreur] bloc \"r0\" introuvable — vérifie l'id, ou utilise recall_search pour le retrouver par mots-clés" + }, + { + "name": "absent", + "tool": "recall", + "args": { + "id": "r999" + }, + "result": "[erreur] bloc \"r999\" introuvable — vérifie l'id, ou utilise recall_search pour le retrouver par mots-clés" + }, + { + "name": "démesuré", + "tool": "recall", + "args": { + "id": "r99999999999999999999" + }, + "result": "[erreur] bloc \"r99999999999999999999\" introuvable — vérifie l'id, ou utilise recall_search pour le retrouver par mots-clés" + }, + { + "name": "guillemets dans l'id", + "tool": "recall", + "args": { + "id": "r\"9\\" + }, + "result": "[erreur] bloc \"r\\\"9\\\\\" introuvable — vérifie l'id, ou utilise recall_search pour le retrouver par mots-clés" + }, + { + "name": "sans requête", + "tool": "recall_search", + "args": {}, + "result": "[erreur] query manquante" + }, + { + "name": "requête non texte", + "tool": "recall_search", + "args": { + "query": 3 + }, + "result": "[erreur] query manquante" + }, + { + "name": "requête blanche", + "tool": "recall_search", + "args": { + "query": " " + }, + "result": "[erreur] query manquante" + }, + { + "name": "mots trop courts", + "tool": "recall_search", + "args": { + "query": "a b ! ?" + }, + "result": "[aucun bloc archivé ne correspond]" + }, + { + "name": "train horaires", + "tool": "recall_search", + "args": { + "query": "train horaires" + }, + "result": "Blocs trouvés (rappelle le bon avec recall(id)) :\n- r2 — web_read: Horaires SNCF\n Horaires SNCF le train de 14h12 part quai 3, le TRAIN suivant à 15h\n- r6 — tool: outil sans nom : train de marchandises\n outil sans nom : train de marchandises" + }, + { + "name": "casse", + "tool": "recall_search", + "args": { + "query": "TRAIN" + }, + "result": "Blocs trouvés (rappelle le bon avec recall(id)) :\n- r6 — tool: outil sans nom : train de marchandises\n outil sans nom : train de marchandises\n- r2 — web_read: Horaires SNCF\n Horaires SNCF le train de 14h12 part quai 3, le TRAIN suivant à 15h" + }, + { + "name": "accents majuscules", + "tool": "recall_search", + "args": { + "query": "ÉTÉ" + }, + "result": "Blocs trouvés (rappelle le bon avec recall(id)) :\n- r4 — assistant: Voici le résumé de l'ÉTÉ : plage, soleil, Été indien.\n Voici le résumé de l'ÉTÉ : plage, soleil, Été indien." + }, + { + "name": "i turc", + "tool": "recall_search", + "args": { + "query": "İstanbul" + }, + "result": "Blocs trouvés (rappelle le bon avec recall(id)) :\n- r5 — user: İSTANBUL et ΣΟΦΙΑ en majuscules\n İSTANBUL et ΣΟΦΙΑ en majuscules" + }, + { + "name": "sigma", + "tool": "recall_search", + "args": { + "query": "σοφια" + }, + "result": "[aucun bloc archivé ne correspond]" + }, + { + "name": "souligné coupé", + "tool": "recall_search", + "args": { + "query": "web_read" + }, + "result": "Blocs trouvés (rappelle le bon avec recall(id)) :\n- r8 — web_read: balises & entités & \"guillemets\"\n balises & entités & \"guillemets\"\n- r2 — web_read: Horaires SNCF\n Horaires SNCF le train de 14h12 part quai 3, le TRAIN suivant à 15h\n- r1 — web_read: Recette du gratin dauphinois\n Recette du gratin dauphinois pommes de terre, crème, ail pommes de terre, crème, ail pommes de terre, crème, ail pommes de terre, crème, ail pommes de terre, cr…\n- r7 — read_file: éééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééé…\n éééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééé…" + }, + { + "name": "extrait borné", + "tool": "recall_search", + "args": { + "query": "fin éé" + }, + "result": "Blocs trouvés (rappelle le bon avec recall(id)) :\n- r7 — read_file: éééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééé…\n éééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééé…\n- r10 — mem_search: très longue première ligne sans fin très longue première ligne sans fi…\n très longue première ligne sans fin très longue première ligne sans fin très longue première ligne sans fin très longue première ligne sans fin très longue prem…" + }, + { + "name": "première ligne longue", + "tool": "recall_search", + "args": { + "query": "suite" + }, + "result": "Blocs trouvés (rappelle le bon avec recall(id)) :\n- r10 — mem_search: très longue première ligne sans fin très longue première ligne sans fi…\n très longue première ligne sans fin très longue première ligne sans fin très longue première ligne sans fin très longue première ligne sans fin très longue prem…" + }, + { + "name": "limite 1", + "tool": "recall_search", + "args": { + "limit": 1, + "query": "train" + }, + "result": "Blocs trouvés (rappelle le bon avec recall(id)) :\n- r6 — tool: outil sans nom : train de marchandises\n outil sans nom : train de marchandises" + }, + { + "name": "limite tronquée", + "tool": "recall_search", + "args": { + "limit": 2.7, + "query": "train" + }, + "result": "Blocs trouvés (rappelle le bon avec recall(id)) :\n- r6 — tool: outil sans nom : train de marchandises\n outil sans nom : train de marchandises\n- r2 — web_read: Horaires SNCF\n Horaires SNCF le train de 14h12 part quai 3, le TRAIN suivant à 15h" + }, + { + "name": "limite négative", + "tool": "recall_search", + "args": { + "limit": -3, + "query": "lot" + }, + "result": "Blocs trouvés (rappelle le bon avec recall(id)) :\n- r35 — bash: lot 24 de la migration : sortie numéro 24\n lot 24 de la migration : sortie numéro 24\n- r34 — bash: lot 23 de la migration : sortie numéro 23\n lot 23 de la migration : sortie numéro 23\n- r33 — bash: lot 22 de la migration : sortie numéro 22\n lot 22 de la migration : sortie numéro 22\n- r32 — bash: lot 21 de la migration : sortie numéro 21\n lot 21 de la migration : sortie numéro 21\n- r31 — bash: lot 20 de la migration : sortie numéro 20\n lot 20 de la migration : sortie numéro 20" + }, + { + "name": "défaut", + "tool": "recall_search", + "args": { + "query": "migration" + }, + "result": "Blocs trouvés (rappelle le bon avec recall(id)) :\n- r35 — bash: lot 24 de la migration : sortie numéro 24\n lot 24 de la migration : sortie numéro 24\n- r34 — bash: lot 23 de la migration : sortie numéro 23\n lot 23 de la migration : sortie numéro 23\n- r33 — bash: lot 22 de la migration : sortie numéro 22\n lot 22 de la migration : sortie numéro 22\n- r32 — bash: lot 21 de la migration : sortie numéro 21\n lot 21 de la migration : sortie numéro 21\n- r31 — bash: lot 20 de la migration : sortie numéro 20\n lot 20 de la migration : sortie numéro 20" + }, + { + "name": "vingt au plus", + "tool": "recall_search", + "args": { + "limit": 50, + "query": "lot migration" + }, + "result": "Blocs trouvés (rappelle le bon avec recall(id)) :\n- r35 — bash: lot 24 de la migration : sortie numéro 24\n lot 24 de la migration : sortie numéro 24\n- r34 — bash: lot 23 de la migration : sortie numéro 23\n lot 23 de la migration : sortie numéro 23\n- r33 — bash: lot 22 de la migration : sortie numéro 22\n lot 22 de la migration : sortie numéro 22\n- r32 — bash: lot 21 de la migration : sortie numéro 21\n lot 21 de la migration : sortie numéro 21\n- r31 — bash: lot 20 de la migration : sortie numéro 20\n lot 20 de la migration : sortie numéro 20\n- r30 — bash: lot 19 de la migration : sortie numéro 19\n lot 19 de la migration : sortie numéro 19\n- r29 — bash: lot 18 de la migration : sortie numéro 18\n lot 18 de la migration : sortie numéro 18\n- r28 — bash: lot 17 de la migration : sortie numéro 17\n lot 17 de la migration : sortie numéro 17\n- r27 — bash: lot 16 de la migration : sortie numéro 16\n lot 16 de la migration : sortie numéro 16\n- r26 — bash: lot 15 de la migration : sortie numéro 15\n lot 15 de la migration : sortie numéro 15\n- r25 — bash: lot 14 de la migration : sortie numéro 14\n lot 14 de la migration : sortie numéro 14\n- r24 — bash: lot 13 de la migration : sortie numéro 13\n lot 13 de la migration : sortie numéro 13\n- r23 — bash: lot 12 de la migration : sortie numéro 12\n lot 12 de la migration : sortie numéro 12\n- r22 — bash: lot 11 de la migration : sortie numéro 11\n lot 11 de la migration : sortie numéro 11\n- r21 — bash: lot 10 de la migration : sortie numéro 10\n lot 10 de la migration : sortie numéro 10\n- r20 — bash: lot 9 de la migration : sortie numéro 9\n lot 9 de la migration : sortie numéro 9\n- r19 — bash: lot 8 de la migration : sortie numéro 8\n lot 8 de la migration : sortie numéro 8\n- r18 — bash: lot 7 de la migration : sortie numéro 7\n lot 7 de la migration : sortie numéro 7\n- r17 — bash: lot 6 de la migration : sortie numéro 6\n lot 6 de la migration : sortie numéro 6\n- r16 — bash: lot 5 de la migration : sortie numéro 5\n lot 5 de la migration : sortie numéro 5" + }, + { + "name": "limite texte ignorée", + "tool": "recall_search", + "args": { + "limit": "3", + "query": "lot" + }, + "result": "Blocs trouvés (rappelle le bon avec recall(id)) :\n- r35 — bash: lot 24 de la migration : sortie numéro 24\n lot 24 de la migration : sortie numéro 24\n- r34 — bash: lot 23 de la migration : sortie numéro 23\n lot 23 de la migration : sortie numéro 23\n- r33 — bash: lot 22 de la migration : sortie numéro 22\n lot 22 de la migration : sortie numéro 22\n- r32 — bash: lot 21 de la migration : sortie numéro 21\n lot 21 de la migration : sortie numéro 21\n- r31 — bash: lot 20 de la migration : sortie numéro 20\n lot 20 de la migration : sortie numéro 20" + }, + { + "name": "rien ne correspond", + "tool": "recall_search", + "args": { + "query": "motabsolumentabsent" + }, + "result": "[aucun bloc archivé ne correspond]" + }, + { + "name": "ponctuation", + "tool": "recall_search", + "args": { + "query": "«entités»,&" + }, + "result": "Blocs trouvés (rappelle le bon avec recall(id)) :\n- r8 — web_read: balises & entités & \"guillemets\"\n balises & entités & \"guillemets\"" + } + ] +} diff --git a/rust/loki-rs/tests/recall.rs b/rust/loki-rs/tests/recall.rs new file mode 100644 index 0000000..cfc1a6a --- /dev/null +++ b/rust/loki-rs/tests/recall.rs @@ -0,0 +1,58 @@ +//! Lot 46 : outils `recall` et `recall_search` de l'agent exécutés par la +//! façade, rejoués sur le journal du Go (`internal/loki/parity_recall_test.go` +//! → `tests/parity_recall/recall.json`) : la même archive, servie par le faux +//! Go comme le vrai la sert, les mêmes appels, les mêmes textes. + +mod common; + +use common::fakego; +use loki_rs::store::Store; +use serde_json::{Map, Value}; + +#[tokio::test] +async fn recall_comme_le_go() { + let fx: Value = + serde_json::from_str(include_str!("parity_recall/recall.json")).expect("journal"); + let tmp = tempfile::tempdir().unwrap(); + let (_, go, _stop) = fakego::start(tmp.path().join("home")).await; + *go.recall_blocks.lock().unwrap() = fx["blocks"].as_array().unwrap().clone(); + let backend = go.backend.lock().unwrap().unwrap(); + let store = Store::new(backend, fakego::JETON.into()); + for st in fx["steps"].as_array().unwrap() { + let name = st["name"].as_str().unwrap(); + let args: Map = serde_json::from_value(st["args"].clone()).unwrap(); + let got = loki_rs::recall::exec(&store, st["tool"].as_str().unwrap(), &args) + .await + .expect("le Go répond"); + assert_eq!(got, st["result"].as_str().unwrap(), "{name}"); + } + // Une recherche lit l'archive une fois ; un id mal formé ne la lit pas. + let log = go.recall_log.lock().unwrap().clone(); + assert!(log.contains(&"block 7".to_string()), "{log:?}"); + assert!(!log.iter().any(|l| l == "block "), "{log:?}"); +} + +/// Go injoignable : `None`, l'appel passe alors par lui (`/tool/exec`). +#[tokio::test] +async fn go_injoignable() { + let l = std::net::TcpListener::bind("127.0.0.1:0").unwrap(); + let addr = l.local_addr().unwrap(); + drop(l); + let store = Store::new(addr, "x".into()); + let mut args = Map::new(); + args.insert("id".into(), Value::from("r1")); + assert_eq!(loki_rs::recall::exec(&store, "recall", &args).await, None); + let mut args = Map::new(); + args.insert("query".into(), Value::from("train")); + assert_eq!( + loki_rs::recall::exec(&store, "recall_search", &args).await, + None + ); + // Les refus avant lecture, eux, ne demandent rien au Go. + assert_eq!( + loki_rs::recall::exec(&store, "recall", &Map::new()) + .await + .as_deref(), + Some("[erreur] id manquant") + ); +} From ded1c97c177793eb5bc1ec60ceacb2dc997a0327 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 16 Oct 2026 07:12:29 +0000 Subject: [PATCH 50/52] =?UTF-8?q?Lot=2047=20:=20outil=20subagent=20du=20mo?= =?UTF-8?q?de=20Code=20ex=C3=A9cut=C3=A9=20par=20la=20fa=C3=A7ade?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Le builder délègue à un rôle en lecture seule (explorer, code-reviewer, planner) sans passer par /tool/exec : src/chat/subagent.rs porte toolSubagent. Mêmes refus avant tout appel au moteur (rôle inconnu, verifier compris, tâche vide, récursion), contexte isolé servi par le Go (POST /internal/v1/turn/subagent : prompt du rôle + briefing machine, outils, capacités ; subagentView et subagentFraming partagés), tour imbriqué run_turn avec l'endpoint, le raisonnement, l'écho et l'arrêt du tour qui délègue, température 0,6, travail annexe (second slot, effacé à la fin), télémétrie à part. Seul le texte écrit après le dernier outil revient, rogné, préfixé du rôle, tronqué à 6000 runes. Le tour du rôle est porté par SubHost, qui délègue tout à son GoTurnHost sauf un nouvel appel à subagent ; les outils de la façade passent dans GoTurnHost::exec_tool_base, que seul le tour principal complète de subagent (le type du tour imbriqué ne se contient pas lui-même). Parité : journal tiré du Go contre un faux moteur scripté (parity_subagent_test.go → tests/parity_subagent/subagent.json : refus, réponse directe, rapport après deux outils, texte vide relancé, rapport tronqué, cadrage du planner, moteur qui refuse ; requêtes du rôle), rejoué par un tour complet de la façade (tests/lot6.rs) ; TestSubagentVueInterne côté Go. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_016tcGfJWefW5kUGoC2wL8cv --- ...-rust-migration-lot47-sous-agent-design.md | 115 +++ internal/loki/code_subagent.go | 31 +- internal/loki/code_subagent_test.go | 29 + internal/loki/parity_subagent_test.go | 215 +++++ internal/loki/web_internal.go | 2 + internal/loki/web_internal_turn.go | 22 + rust/loki-rs/src/chat/generate.rs | 9 +- rust/loki-rs/src/chat/host.rs | 140 +-- rust/loki-rs/src/chat/mod.rs | 1 + rust/loki-rs/src/chat/perf.rs | 2 + rust/loki-rs/src/chat/subagent.rs | 309 +++++++ rust/loki-rs/src/chat/subagent/tests.rs | 65 ++ rust/loki-rs/src/chat/verify.rs | 15 +- rust/loki-rs/tests/common/fakego.rs | 15 + rust/loki-rs/tests/lot6.rs | 106 ++- .../tests/parity_subagent/subagent.json | 843 ++++++++++++++++++ 16 files changed, 1846 insertions(+), 73 deletions(-) create mode 100644 docs/superpowers/specs/2026-10-16-rust-migration-lot47-sous-agent-design.md create mode 100644 internal/loki/parity_subagent_test.go create mode 100644 rust/loki-rs/src/chat/subagent.rs create mode 100644 rust/loki-rs/src/chat/subagent/tests.rs create mode 100644 rust/loki-rs/tests/parity_subagent/subagent.json diff --git a/docs/superpowers/specs/2026-10-16-rust-migration-lot47-sous-agent-design.md b/docs/superpowers/specs/2026-10-16-rust-migration-lot47-sous-agent-design.md new file mode 100644 index 0000000..0e306a5 --- /dev/null +++ b/docs/superpowers/specs/2026-10-16-rust-migration-lot47-sous-agent-design.md @@ -0,0 +1,115 @@ +# Lot 47 — Outil subagent du mode Code dans la façade + +## Contexte + +En mode Code, le builder peut déléguer une question bornée à un rôle en +lecture seule : + +- `explorer` cartographie le code ; +- `code-reviewer` relit un changement ; +- `planner` découpe un travail et pose les critères d'acceptation. + +Le rôle travaille dans son propre contexte : le prompt du rôle, le +briefing machine et la tâche, rien de l'historique. Sa trace n'est ni +rangée ni affichée. Seul son texte final revient au builder, comme +résultat d'outil. Sur un modèle local, à la fenêtre étroite, c'est ce qui +évite de payer dix lectures de fichiers dans le fil principal. + +La façade tient le tour du builder et sa passe de vérification, mais +`subagent` passait encore par `/internal/v1/tool/exec` : le Go faisait +tourner un `runChat` à lui. + +## Le lot + +`src/chat/subagent.rs` porte `toolSubagent` (`code_subagent.go`). + +Avant tout appel au moteur, il fait les mêmes refus que le Go, dans le +même ordre, avec les mêmes textes : + +- rôle inconnu (`verifier` compris : seule la boucle du contrat le lance) ; +- tâche vide ; +- appel depuis un tour qui n'est ni le fil principal ni le builder + (garde-fou anti-récursion). + +Le contexte du rôle vient du Go +(`POST /internal/v1/turn/subagent {role}`), qui rend : + +- le prompt du rôle suivi du briefing machine ; +- les outils du rôle ; +- ses capacités (agent, Code, mémoire coupée). + +`subagentView` et `subagentFraming` sont partagés avec `toolSubagent`. + +Le tour du rôle tourne ici avec `run_turn`, comme la passe de +vérification : + +- l'endpoint, le raisonnement, l'écho, les motifs de relance et l'arrêt + sont ceux du tour qui délègue (`SubTurn`) ; +- la température est 0,6 (le TEMP du preset l'emporte) ; +- la tâche porte le cadrage du rôle : + - planner : critères et étapes, sans demander confirmation ; + - autres rôles : seulement les trouvailles, `fichier:ligne` ; +- c'est un travail annexe (`engineSideJob`) : second slot, puis slot + effacé à la fin pour que le builder reparte du cache RAM ; +- la télémétrie le compte à part (`subagent`) ; +- les critères posés par le planner s'affichent comme ceux du fil. + +Le rapport se construit ainsi : + +- seul le texte écrit après le dernier outil compte ; +- il est rogné de ses blancs, puis préfixé du rôle ; +- au-delà de 6000 runes, il est tronqué (`…[tronqué]`) ; +- vide : « n'a rien répondu » ; +- erreur du tour : « sous-agent : ». + +Le tour du rôle est porté par `SubHost`. Ce `TurnHost` délègue tout au +`GoTurnHost` du rôle, sauf un nouvel appel à `subagent`, toujours refusé. +Les outils de la façade sont dans `GoTurnHost::exec_tool_base` ; seul le +`exec_tool` du tour principal y ajoute `subagent`. Ainsi le type du tour +imbriqué ne se contient pas lui-même. + +Le tour du builder et ses relances par la boucle du contrat prêtent leur +`SubTurn`. La vérification aussi, mais son rôle la fait refuser. + +## Parité + +`internal/loki/parity_subagent_test.go` tire du Go un journal +(`tests/parity_subagent/subagent.json`) contre un faux moteur scripté. Les +vues des trois rôles sont rangées avec lui. + +Pour chaque tour délégué, le journal garde chaque requête du rôle : + +- système identique à la vue ; +- message de la tâche cadrée ; +- température ; +- outils ; +- nombre de messages. + +Il couvre : + +- les refus : + - rôle inconnu, `verifier`, rôle absent ; + - tâche vide ou absente ; + - récursion depuis un rôle ou depuis la vérification ; +- les tours : + - réponse directe rognée ; + - rapport après deux outils, narration écartée ; + - texte vide relancé puis remplacé, comme le fait le tour ; + - rien après l'outil ; + - rapport tronqué ; + - cadrage du planner ; + - moteur qui refuse. + +`subagent_comme_le_go` (`tests/lot6.rs`) le rejoue : + +- les refus par `precheck` ; +- les tours par un tour complet de la façade qui délègue, contre le même + script. + +Il vérifie les requêtes du rôle, le rapport rendu au builder et le travail +annexe ouvert puis fermé. + +D'autres tests complètent : + +- `TestSubagentVueInterne` : la vue servie par le Go, rôles refusés ; +- tests unitaires : refus et leur ordre, rapports, cadrages. diff --git a/internal/loki/code_subagent.go b/internal/loki/code_subagent.go index 0a18127..1b25e7a 100644 --- a/internal/loki/code_subagent.go +++ b/internal/loki/code_subagent.go @@ -90,17 +90,10 @@ func toolSubagent(ctx context.Context, args map[string]any, parent Caps) string return "[erreur] un sous-agent ne peut pas en appeler un autre — réponds avec ce que tu as" } - caps := Caps{Agent: true, Code: true, Role: role, Mem: MemOff} - // Cadrage : le rôle ne voit ni la discussion ni l'utilisateur. Son prompt de - // rôle parle à un humain (« termine en demandant confirmation ») ; ici son - // interlocuteur est le builder, et sa réponse est un résultat d'outil. - framing := "\n\nAnswer with your findings only: what you found and where (file:line). No preamble." - if role == "planner" { - framing = "\n\nYou are a delegated pass: nobody will answer you. Record the acceptance criteria with the criteria tool, then give the ordered steps. Do not ask for confirmation." - } + caps, system := subagentView(role) msgs := []Message{ - {Role: "system", Content: rolePrompt(role) + "\n\n" + machineSystemPrompt(caps)}, - {Role: "user", Content: task + framing}, + {Role: "system", Content: system}, + {Role: "user", Content: task + subagentFraming(role)}, } var out strings.Builder // Le sous-agent occupe le slot de la conversation du builder : une fois son @@ -133,3 +126,21 @@ func toolSubagent(ctx context.Context, args map[string]any, parent Caps) string } return "[" + role + "]\n" + res } + +// subagentView : les capacités et le prompt système d'un rôle délégué — +// servis aussi à la façade (/internal/v1/turn/subagent, lot 47). +func subagentView(role string) (Caps, string) { + caps := Caps{Agent: true, Code: true, Role: role, Mem: MemOff} + return caps, rolePrompt(role) + "\n\n" + machineSystemPrompt(caps) +} + +// subagentFraming : le cadrage ajouté à la tâche. Le rôle ne voit ni la +// discussion ni l'utilisateur. Son prompt de rôle parle à un humain +// (« termine en demandant confirmation ») ; ici son interlocuteur est le +// builder, et sa réponse est un résultat d'outil. +func subagentFraming(role string) string { + if role == "planner" { + return "\n\nYou are a delegated pass: nobody will answer you. Record the acceptance criteria with the criteria tool, then give the ordered steps. Do not ask for confirmation." + } + return "\n\nAnswer with your findings only: what you found and where (file:line). No preamble." +} diff --git a/internal/loki/code_subagent_test.go b/internal/loki/code_subagent_test.go index 54898e4..d0f8175 100644 --- a/internal/loki/code_subagent_test.go +++ b/internal/loki/code_subagent_test.go @@ -2,6 +2,7 @@ package loki import ( "context" + "encoding/json" "strings" "testing" ) @@ -54,3 +55,31 @@ func TestSubagentRolesAreReadOnly(t *testing.T) { } } } + +// Lot 47 : la façade demande au Go le contexte isolé d'un rôle délégué — +// le même prompt système, les mêmes outils et capacités que toolSubagent. +func TestSubagentVueInterne(t *testing.T) { + testHome(t) + t.Setenv(internalTokenEnv, jetonTest) + w := appelOAIInterne(t, "POST", "/internal/v1/turn/subagent", `{"role":"planner"}`) + if w.Code != 200 { + t.Fatalf("vue : %d %s", w.Code, w.Body.String()) + } + var v struct { + System string `json:"system"` + Tools []Tool `json:"tools"` + Caps Caps `json:"caps"` + } + if err := json.Unmarshal(w.Body.Bytes(), &v); err != nil { + t.Fatal(err) + } + caps, system := subagentView("planner") + if v.System != system || v.Caps != caps || len(v.Tools) != len(EnabledTools(caps)) { + t.Fatalf("vue : %+v", v.Caps) + } + for _, body := range []string{`{"role":"verifier"}`, `{"role":""}`, `{`} { + if w := appelOAIInterne(t, "POST", "/internal/v1/turn/subagent", body); w.Code != 400 { + t.Fatalf("%s : %d", body, w.Code) + } + } +} diff --git a/internal/loki/parity_subagent_test.go b/internal/loki/parity_subagent_test.go new file mode 100644 index 0000000..4d9dcf2 --- /dev/null +++ b/internal/loki/parity_subagent_test.go @@ -0,0 +1,215 @@ +package loki + +import ( + "bytes" + "context" + "encoding/json" + "io" + "net/http" + "net/http/httptest" + "net/url" + "os" + "path/filepath" + "strings" + "sync" + "testing" +) + +// parity_subagent_test.go — journal de référence de l'outil subagent du mode +// Code (lot 47 de la migration Rust) : refus avant tout appel au moteur, puis +// des tours délégués contre un faux moteur scripté (rapport après le dernier +// outil, texte vide, rapport tronqué, refus du moteur, cadrage du planner). +// Chaque étape garde ce que le rôle a envoyé au moteur (système identique à +// la vue du rôle, message de la tâche, température, outils). Les vues des +// rôles (/internal/v1/turn/subagent) sont rangées avec le journal. +// +// Rangé dans rust/loki-rs/tests/parity_subagent/subagent.json ; le test +// subagent_comme_le_go de tests/lot6.rs le rejoue par un tour de la façade. +// +// LOKI_PARITY_UPDATE=1 go test ./internal/loki -run TestPariteSubagent + +const paritySubagentFile = "../../rust/loki-rs/tests/parity_subagent/subagent.json" + +// Une réponse scriptée du faux moteur. +type paritySubReply struct { + Tool string `json:"tool,omitempty"` // appel d'outil (arguments : Args) + Args string `json:"args,omitempty"` + Content string `json:"content,omitempty"` + Status int `json:"status,omitempty"` // refus du moteur (corps : Content) +} + +type paritySubRequest struct { + SystemIsView bool `json:"system_is_view"` + User string `json:"user"` + Temperature any `json:"temperature"` + Tools []string `json:"tools"` + Messages int `json:"messages"` +} + +type paritySubStep struct { + Name string `json:"name"` + Args map[string]any `json:"args"` + ParentRole string `json:"parent_role,omitempty"` + Replies []paritySubReply `json:"replies,omitempty"` + Result string `json:"result"` + Requests []paritySubRequest `json:"requests"` +} + +func paritySubSSE(r paritySubReply) string { + chunk := func(v any) string { + b, _ := json.Marshal(v) + return "data: " + string(b) + "\n\n" + } + var b strings.Builder + if r.Tool != "" { + b.WriteString(chunk(map[string]any{"choices": []any{map[string]any{"delta": map[string]any{"tool_calls": []any{map[string]any{ + "index": 0, "id": "s1", "type": "function", "function": map[string]any{"name": r.Tool, "arguments": r.Args}, + }}}}}})) + if r.Content != "" { + // Texte écrit avant l'appel : la narration, pas le rapport. + b.Reset() + b.WriteString(chunk(map[string]any{"choices": []any{map[string]any{"delta": map[string]any{"content": r.Content}}}})) + b.WriteString(chunk(map[string]any{"choices": []any{map[string]any{"delta": map[string]any{"tool_calls": []any{map[string]any{ + "index": 0, "id": "s1", "type": "function", "function": map[string]any{"name": r.Tool, "arguments": r.Args}, + }}}}}})) + } + b.WriteString(chunk(map[string]any{"choices": []any{map[string]any{"delta": map[string]any{}, "finish_reason": "tool_calls"}}})) + } else { + b.WriteString(chunk(map[string]any{"choices": []any{map[string]any{"delta": map[string]any{"content": r.Content}}}})) + b.WriteString(chunk(map[string]any{"choices": []any{map[string]any{"delta": map[string]any{}, "finish_reason": "stop"}}, "usage": map[string]any{"prompt_tokens": 40, "completion_tokens": 2}})) + } + b.WriteString("data: [DONE]\n\n") + return b.String() +} + +func TestPariteSubagent(t *testing.T) { + ws := withWorkspace(t) + convEnsureActive() + _ = os.WriteFile(filepath.Join(ws, "cache.go"), []byte("package x\n// cache LRU\n"), 0o644) + + var mu sync.Mutex + var queue []paritySubReply + var bodies []map[string]any + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path != "/v1/chat/completions" { + w.WriteHeader(404) + return + } + raw, _ := io.ReadAll(r.Body) + var body map[string]any + _ = json.Unmarshal(raw, &body) + mu.Lock() + bodies = append(bodies, body) + var rep paritySubReply + if len(queue) > 0 { + rep, queue = queue[0], queue[1:] + } + mu.Unlock() + if rep.Status != 0 { + w.WriteHeader(rep.Status) + _, _ = w.Write([]byte(rep.Content)) + return + } + w.Header().Set("Content-Type", "text/event-stream") + w.WriteHeader(200) + _, _ = w.Write([]byte(paritySubSSE(rep))) + })) + t.Cleanup(srv.Close) + u, _ := url.Parse(srv.URL) + if err := SetConfigKey("PORT", u.Port()); err != nil { + t.Fatal(err) + } + + views := map[string]any{} + for _, role := range subagentRoles { + caps, system := subagentView(role) + views[role] = map[string]any{"system": system, "tools": EnabledTools(caps), "caps": caps} + } + + var steps []paritySubStep + run := func(st paritySubStep) { + mu.Lock() + queue, bodies = append([]paritySubReply(nil), st.Replies...), nil + mu.Unlock() + parent := Caps{Agent: true, Code: true, Role: st.ParentRole} + st.Result = toolSubagent(context.Background(), st.Args, parent) + mu.Lock() + defer mu.Unlock() + st.Requests = []paritySubRequest{} + for _, b := range bodies { + msgs, _ := b["messages"].([]any) + req := paritySubRequest{Temperature: b["temperature"], Messages: len(msgs), Tools: []string{}} + if len(msgs) >= 2 { + sys, _ := msgs[0].(map[string]any)["content"].(string) + role, _ := st.Args["role"].(string) + _, want := subagentView(strings.TrimSpace(role)) + req.SystemIsView = sys == want + req.User, _ = msgs[1].(map[string]any)["content"].(string) + } + tools, _ := b["tools"].([]any) + for _, tl := range tools { + f, _ := tl.(map[string]any)["function"].(map[string]any) + n, _ := f["name"].(string) + req.Tools = append(req.Tools, n) + } + st.Requests = append(st.Requests, req) + } + steps = append(steps, st) + } + a := func(role, task string) map[string]any { return map[string]any{"role": role, "task": task} } + + run(paritySubStep{Name: "rôle inconnu", Args: a("architecte", "x")}) + run(paritySubStep{Name: "verifier refusé", Args: a("verifier", "x")}) + run(paritySubStep{Name: "sans rôle", Args: map[string]any{"task": "x"}}) + run(paritySubStep{Name: "tâche vide", Args: a("explorer", " ")}) + run(paritySubStep{Name: "tâche absente", Args: map[string]any{"role": "planner"}}) + run(paritySubStep{Name: "pas de récursion", Args: a("explorer", "x"), ParentRole: "explorer"}) + run(paritySubStep{Name: "vérificateur qui délègue", Args: a("planner", "x"), ParentRole: "verifier"}) + run(paritySubStep{Name: "réponse directe", Args: a("explorer", " Où est le cache ? "), + Replies: []paritySubReply{{Content: " Dans cache.go:2. \n"}}}) + run(paritySubStep{Name: "rapport après le dernier outil", Args: a(" code-reviewer ", "Relis cache.go"), + Replies: []paritySubReply{ + {Tool: "grep", Args: `{"pattern":"cache"}`, Content: "Je cherche d'abord."}, + {Tool: "read", Args: `{"path":"cache.go"}`}, + {Content: "RAS : cache.go:2 documente le LRU."}, + }}) + run(paritySubStep{Name: "rien répondu", Args: a("explorer", "Vide ?"), + Replies: []paritySubReply{{Content: " "}}}) + run(paritySubStep{Name: "rien après l'outil", Args: a("explorer", "Encore ?"), + Replies: []paritySubReply{{Tool: "grep", Args: `{"pattern":"x"}`, Content: "narration"}, {Content: ""}}}) + run(paritySubStep{Name: "rapport tronqué", Args: a("explorer", "Tout ?"), + Replies: []paritySubReply{{Content: strings.Repeat("é", 6100)}}}) + run(paritySubStep{Name: "cadrage du planner", Args: a("planner", "Découpe le chantier"), + Replies: []paritySubReply{{Content: "1. lire\n2. écrire"}}}) + run(paritySubStep{Name: "moteur qui refuse", Args: a("explorer", "Panne ?"), + Replies: []paritySubReply{{Status: 400, Content: `{"error":{"message":"requête refusée"}}`}}}) + + var buf bytes.Buffer + enc := json.NewEncoder(&buf) + enc.SetEscapeHTML(false) + enc.SetIndent("", " ") + if err := enc.Encode(map[string]any{"views": views, "steps": steps}); err != nil { + t.Fatal(err) + } + got := bytes.ReplaceAll(buf.Bytes(), []byte(ws), []byte("{{WS}}")) + if h := os.Getenv("LOKI_HOME"); h != "" { + got = bytes.ReplaceAll(got, []byte(h), []byte("{{HOME}}")) + } + if os.Getenv("LOKI_PARITY_UPDATE") == "1" { + if err := os.MkdirAll(filepath.Dir(paritySubagentFile), 0o755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(paritySubagentFile, got, 0o644); err != nil { + t.Fatal(err) + } + return + } + want, err := os.ReadFile(paritySubagentFile) + if err != nil { + t.Fatalf("journal de référence absent (%v) : LOKI_PARITY_UPDATE=1 pour le créer", err) + } + if !bytes.Equal(got, want) { + t.Errorf("subagent ne produit plus le journal rangé dans %s.\n"+ + "LOKI_PARITY_UPDATE=1 pour le régénérer si le changement est voulu.", paritySubagentFile) + } +} diff --git a/internal/loki/web_internal.go b/internal/loki/web_internal.go index 7df4cbd..dc78969 100644 --- a/internal/loki/web_internal.go +++ b/internal/loki/web_internal.go @@ -119,6 +119,8 @@ func withInternalAPI(next http.Handler) http.Handler { handleInternalTurnDone(w, r) case "/internal/v1/turn/verifier": handleInternalTurnVerifier(w, r) + case "/internal/v1/turn/subagent": + handleInternalTurnSubagent(w, r) case "/internal/v1/project/activate": handleInternalProjectActivate(w, r) case "/internal/v1/chat/holder": diff --git a/internal/loki/web_internal_turn.go b/internal/loki/web_internal_turn.go index 5f8025d..2867abd 100644 --- a/internal/loki/web_internal_turn.go +++ b/internal/loki/web_internal_turn.go @@ -137,6 +137,28 @@ func handleInternalTurnVerifier(w http.ResponseWriter, r *http.Request) { }) } +// handleInternalTurnSubagent : le contexte isolé d'un rôle délégué par +// l'outil subagent de la façade (lot 47) — prompt système, outils et +// capacités du rôle ; la tâche et son cadrage restent chez elle. +func handleInternalTurnSubagent(w http.ResponseWriter, r *http.Request) { + var in struct { + Role string `json:"role"` + } + if !readInternalJSON(w, r, &in) { + return + } + if !isSubagentRole(in.Role) { + sendJSON(w, http.StatusBadRequest, map[string]any{"error": "rôle inconnu : " + in.Role}) + return + } + caps, system := subagentView(in.Role) + sendJSON(w, http.StatusOK, map[string]any{ + "system": system, + "tools": EnabledTools(caps), + "caps": caps, + }) +} + // handleInternalProjectActivate : setActiveProject pour la façade (projet // rangé en base, cache de ceux qui le lisent ici — mémoire, trackers, outils). func handleInternalProjectActivate(w http.ResponseWriter, r *http.Request) { diff --git a/rust/loki-rs/src/chat/generate.rs b/rust/loki-rs/src/chat/generate.rs index 4ef9a23..a0f1cbe 100644 --- a/rust/loki-rs/src/chat/generate.rs +++ b/rust/loki-rs/src/chat/generate.rs @@ -1018,12 +1018,19 @@ impl ChatService { queued: Box::new(move || me.drain_queued(epoch)), deltas: { let me = self.clone(); - Box::new(move |d| me.push_delta(d)) + Arc::new(move |d| me.push_delta(d)) }, perf_kind: super::perf::KIND_MAIN, side_slot: Default::default(), projsnap: snap_turn, snap_refreshed: Default::default(), + sub: Some(super::subagent::SubTurn { + ep: ep.clone(), + reasoning: reasoning.clone(), + echo: echo.clone(), + patterns: self.shared.patterns.clone(), + cancel: cancel.clone(), + }), }; // Chaque événement devient un delta du journal. diff --git a/rust/loki-rs/src/chat/host.rs b/rust/loki-rs/src/chat/host.rs index 3d71b63..51ce101 100644 --- a/rust/loki-rs/src/chat/host.rs +++ b/rust/loki-rs/src/chat/host.rs @@ -415,7 +415,7 @@ pub struct GoTurnHost { pub queued: Box Vec + Send + Sync>, /// Un événement pour l'écran, hors du flux du tour (la liste des /// critères après l'outil `criteria`). - pub deltas: Box, + pub deltas: Arc, /// Nature des complétions pour la télémétrie : `main` (tour de la /// discussion), `verify` (passe de vérification du mode Code). pub perf_kind: &'static str, @@ -426,6 +426,9 @@ pub struct GoTurnHost { /// l'instantané posé par un rafraîchissement en cours de tour. pub projsnap: Option, pub snap_refreshed: Mutex>, + /// Ce que le tour prête à l'outil `subagent` (lot 47) ; `None` : l'outil + /// passe par le Go. + pub sub: Option, } /// PROJ_SNAPSHOT (lot 34) : ce qu'un tour avec la clé garde pour rafraîchir @@ -614,63 +617,15 @@ pub fn see_image_message(label: &str, img: Value) -> Message { } } -impl TurnHost for GoTurnHost { - async fn send( +impl GoTurnHost { + /// Les outils exécutés par la façade, sauf `subagent` ; le reste par le + /// Go (`/tool/exec`). Aussi celui des tours d'un sous-agent. + pub(crate) async fn exec_tool_base( &self, - ep: &ChatEndpoint, - body: Vec, - keep_prewarm: bool, - ) -> Result { - // Moteur local : requête comptée en vol jusqu'à la fin de lecture du - // flux (le ticket voyage avec le corps). Rien pour une API externe. - // Un préchauffage qu'elle ne prolonge pas est coupé tout de suite (le - // Go l'annulerait aussi, un aller-retour plus tard). - let gate = if ep.external { - None - } else { - if !keep_prewarm { - self.shared.prewarm.stop(); - } - self.gate_begin(keep_prewarm).await - }; - match self.shared.http.post(&ep.url, &ep.key, body).await { - Ok(resp) => { - let status = resp.status().as_u16(); - if status == 200 - && let Some(g) = &gate - { - // Le slot porte désormais cette requête (engineServed, - // engineMarkMain pour une étape de la discussion). - let _ = self - .shared - .store - .internal_json( - hyper::Method::POST, - "/internal/v1/oai/served", - Some(&json!({ "ticket": g.ticket })), - ) - .await; - } - let body = resp.bytes_stream().map(move |r| { - let _keep = &gate; - r.map_err(|e| e.to_string()) - }); - Ok(Reply { - status, - body: Box::pin(body), - }) - } - Err(e) => { - let kind = classify(&e); - Err(SendError { - kind, - message: friendly_error(kind, &e.to_string(), self.port, ""), - }) - } - } - } - - async fn exec_tool(&self, name: &str, args: &Map, label: &str) -> ToolOutcome { + name: &str, + args: &Map, + label: &str, + ) -> ToolOutcome { // Outils MCP, quand la façade tient le pool : ce que ferait le Go // (`mcpCall` → /internal/facade/v1/mcp/call), sans ses deux // allers-retours. Le Go n'en tire rien d'autre que le texte. @@ -907,6 +862,77 @@ impl TurnHost for GoTurnHost { }, } } +} + +impl TurnHost for GoTurnHost { + async fn send( + &self, + ep: &ChatEndpoint, + body: Vec, + keep_prewarm: bool, + ) -> Result { + // Moteur local : requête comptée en vol jusqu'à la fin de lecture du + // flux (le ticket voyage avec le corps). Rien pour une API externe. + // Un préchauffage qu'elle ne prolonge pas est coupé tout de suite (le + // Go l'annulerait aussi, un aller-retour plus tard). + let gate = if ep.external { + None + } else { + if !keep_prewarm { + self.shared.prewarm.stop(); + } + self.gate_begin(keep_prewarm).await + }; + match self.shared.http.post(&ep.url, &ep.key, body).await { + Ok(resp) => { + let status = resp.status().as_u16(); + if status == 200 + && let Some(g) = &gate + { + // Le slot porte désormais cette requête (engineServed, + // engineMarkMain pour une étape de la discussion). + let _ = self + .shared + .store + .internal_json( + hyper::Method::POST, + "/internal/v1/oai/served", + Some(&json!({ "ticket": g.ticket })), + ) + .await; + } + let body = resp.bytes_stream().map(move |r| { + let _keep = &gate; + r.map_err(|e| e.to_string()) + }); + Ok(Reply { + status, + body: Box::pin(body), + }) + } + Err(e) => { + let kind = classify(&e); + Err(SendError { + kind, + message: friendly_error(kind, &e.to_string(), self.port, ""), + }) + } + } + } + + async fn exec_tool(&self, name: &str, args: &Map, label: &str) -> ToolOutcome { + // Sous-agent du mode Code (lot 47) : un tour imbriqué, sur un + // contexte isolé, dont seul le rapport revient. + if name == "subagent" + && let Some(sub) = &self.sub + { + return ToolOutcome { + result: super::subagent::run(self, sub, args).await, + ..Default::default() + }; + } + self.exec_tool_base(name, args, label).await + } fn save_tool_result(&self, result: &str) -> String { let id = tool_res_id(&self.conv_id); diff --git a/rust/loki-rs/src/chat/mod.rs b/rust/loki-rs/src/chat/mod.rs index e368a7f..658ad93 100644 --- a/rust/loki-rs/src/chat/mod.rs +++ b/rust/loki-rs/src/chat/mod.rs @@ -37,6 +37,7 @@ pub mod persist; pub mod prewarm; pub mod sessions; pub mod stream; +pub mod subagent; pub mod tplprobe; pub mod turn; pub mod types; diff --git a/rust/loki-rs/src/chat/perf.rs b/rust/loki-rs/src/chat/perf.rs index 3c63b41..f11342d 100644 --- a/rust/loki-rs/src/chat/perf.rs +++ b/rust/loki-rs/src/chat/perf.rs @@ -18,6 +18,8 @@ use crate::store::{BK_STATE, Store}; /// Nature d'une complétion. pub const KIND_MAIN: &str = "main"; pub const KIND_VERIFY: &str = "verify"; +/// Sous-agent du mode Code (`perfSubagent`). +pub const KIND_SUBAGENT: &str = "subagent"; pub const KIND_COMPACT: &str = "compact"; pub const KIND_PREWARM: &str = "prewarm"; diff --git a/rust/loki-rs/src/chat/subagent.rs b/rust/loki-rs/src/chat/subagent.rs new file mode 100644 index 0000000..7805ebc --- /dev/null +++ b/rust/loki-rs/src/chat/subagent.rs @@ -0,0 +1,309 @@ +//! L'outil `subagent` du mode Code (lot 47) — port de `toolSubagent` +//! (`code_subagent.go`). +//! +//! Le builder délègue une question bornée à un rôle en lecture seule +//! (explorer, code-reviewer, planner) qui travaille dans SON PROPRE contexte : +//! le prompt du rôle, le briefing machine et la tâche, rien de l'historique. +//! Sa trace n'est ni rangée ni affichée : seul son texte final revient, comme +//! résultat d'outil. Le contexte du rôle (prompt système, outils, capacités) +//! vient du Go (`/internal/v1/turn/subagent`) ; le tour imbriqué tourne ici, +//! comme la passe de vérification : un travail annexe (`engineSideJob`). +//! +//! Le tour du rôle est porté par [`SubHost`], qui délègue tout à son +//! [`GoTurnHost`] sauf un nouvel appel à `subagent` : un sous-agent ne +//! délègue pas à son tour (et le type du tour imbriqué ne se contient pas +//! lui-même). + +use std::sync::Arc; + +use serde_json::{Map, Value, json}; + +use super::compact::CompactConfig; +use super::host::{GoTurnHost, TurnCompact}; +use super::stream::{CacheHintFn, PreflightFn, RetryPatterns}; +use super::turn::{ + Cancel, CompactPhase, PerfMarks, PerfRec, Reply, SendError, ToolOutcome, TurnConfig, TurnHost, + run_turn, +}; +use super::types::{Message, StreamEvent, Tool}; +use super::wire::{ChatEndpoint, EchoPolicy, TurnReasoning}; + +/// `subagentRoles` : les rôles délégables (`verifier` n'en est pas : seule +/// la boucle du contrat le lance). +pub const ROLES: [&str; 3] = ["explorer", "code-reviewer", "planner"]; +/// `subagentTemperature` (le TEMP du preset, s'il est posé, l'emporte). +pub const TEMPERATURE: f64 = 0.6; +/// `subagentMaxOutput` : un rapport, pas un vidage de fichiers. +pub const MAX_OUTPUT: usize = 6000; + +/// Ce que le tour du builder prête à ses sous-agents : l'endpoint, le +/// raisonnement, l'écho, les motifs de relance et l'arrêt du tour. +#[derive(Clone)] +pub struct SubTurn { + pub ep: ChatEndpoint, + pub reasoning: TurnReasoning, + pub echo: EchoPolicy, + pub patterns: Arc, + pub cancel: Cancel, +} + +/// `subagentFraming` : le cadrage ajouté à la tâche — l'interlocuteur du rôle +/// est le builder, pas un humain. +pub fn framing(role: &str) -> &'static str { + if role == "planner" { + "\n\nYou are a delegated pass: nobody will answer you. Record the acceptance criteria with the criteria tool, then give the ordered steps. Do not ask for confirmation." + } else { + "\n\nAnswer with your findings only: what you found and where (file:line). No preamble." + } +} + +/// Les vérifications de `toolSubagent` avant tout appel au moteur : rôle, +/// tâche, puis le garde-fou anti-récursion (`parent_role` : le rôle du tour +/// qui délègue, vide ou `builder` pour le fil principal). +pub fn precheck(args: &Map, parent_role: &str) -> Result<(String, String), String> { + let s = |k: &str| { + args.get(k) + .and_then(Value::as_str) + .unwrap_or("") + .trim() + .to_string() + }; + let (role, task) = (s("role"), s("task")); + if !ROLES.contains(&role.as_str()) { + return Err(format!( + "[erreur] rôle inconnu : {role} (attendu : {})", + ROLES.join(", ") + )); + } + if task.is_empty() { + return Err("[erreur] tâche vide — décris ce que le rôle doit chercher ou relire".into()); + } + if !parent_role.is_empty() && parent_role != "builder" { + return Err( + "[erreur] un sous-agent ne peut pas en appeler un autre — réponds avec ce que tu as" + .into(), + ); + } + Ok((role, task)) +} + +/// Le rapport rendu au builder : texte écrit après le dernier outil, borné. +pub fn report(role: &str, content: &str, err: Option<&str>) -> String { + if let Some(e) = err { + return format!("[erreur] sous-agent {role} : {e}"); + } + let res = content.trim(); + if res.is_empty() { + return format!("[erreur] le sous-agent {role} n'a rien répondu"); + } + let res = if res.chars().count() > MAX_OUTPUT { + format!( + "{}\n…[tronqué]", + res.chars().take(MAX_OUTPUT).collect::() + ) + } else { + res.to_string() + }; + format!("[{role}]\n{res}") +} + +fn caps_role(caps: &Value) -> &str { + caps.get("Role").and_then(Value::as_str).unwrap_or("") +} + +/// `toolSubagent` : délègue au rôle et rend son rapport. +pub async fn run(parent: &GoTurnHost, sub: &SubTurn, args: &Map) -> String { + let (role, task) = match precheck(args, caps_role(&parent.caps)) { + Ok(rt) => rt, + Err(e) => return e, + }; + let store = &parent.shared.store; + let view = match store + .internal_post("/internal/v1/turn/subagent", &json!({ "role": role })) + .await + { + Ok((s, v)) if s.as_u16() == 200 => v, + Ok((_, v)) => { + let e = v["error"].as_str().unwrap_or("vue du sous-agent refusée"); + return report(&role, "", Some(e)); + } + Err(e) => return report(&role, "", Some(&e)), + }; + let tools: Vec = serde_json::from_value(view["tools"].clone()).unwrap_or_default(); + let caps = view["caps"].clone(); + let msgs = vec![ + Message { + role: "system".into(), + content: Some(view["system"].clone()), + ..Default::default() + }, + Message { + role: "user".into(), + content: Some(Value::String(format!("{task}{}", framing(&role)))), + ..Default::default() + }, + ]; + let compact: CompactConfig = parent.compact.cfg.clone(); + let tc = TurnConfig { + cfg: parent.cfg.clone(), + ep: sub.ep.clone(), + tools: tools.clone(), + temperature: TEMPERATURE, + code: caps.get("Code").and_then(Value::as_bool) == Some(true), + reasoning: sub.reasoning.clone(), + echo: sub.echo.clone(), + compact: compact.clone(), + patterns: sub.patterns.clone(), + }; + let host = SubHost(GoTurnHost { + shared: parent.shared.clone(), + conv_id: parent.conv_id.clone(), + caps, + cfg: parent.cfg.clone(), + port: parent.port, + summarizer: parent.summarizer.clone(), + compact: TurnCompact { + cfg: compact, + caps: parent.compact.caps, + criteria: String::new(), + main_conv: parent.compact.main_conv.clone(), + tools, + effort: sub.reasoning.effort.clone(), + kwargs: sub.reasoning.kwargs.clone().unwrap_or_default(), + }, + queued: Box::new(Vec::new), + // Les critères posés par le planner s'affichent comme ceux du fil. + deltas: parent.deltas.clone(), + perf_kind: super::perf::KIND_SUBAGENT, + side_slot: Default::default(), + projsnap: None, + snap_refreshed: Default::default(), + sub: None, + }); + // Le rôle occupe le slot de la discussion du builder : effacé à la fin + // pour que le builder reparte du cache RAM (pas entre ses lectures). + let side = super::verify::side_job_begin(store).await; + let mut out = String::new(); + let mut cb = |ev: StreamEvent| -> bool { + match ev { + // Seul le texte écrit APRÈS le dernier outil est le rapport. + StreamEvent::ToolUsed(_) => out.clear(), + StreamEvent::Content(c) => out.push_str(&c), + _ => {} + } + true + }; + let r = run_turn(&host, &tc, msgs, &sub.cancel, &mut cb).await; + super::verify::side_job_end(store, side).await; + report(&role, &out, r.err.as_deref()) +} + +/// Le [`TurnHost`] du tour d'un rôle délégué : celui de la façade, sans +/// `subagent`. +pub struct SubHost(pub GoTurnHost); + +impl TurnHost for SubHost { + async fn send( + &self, + ep: &ChatEndpoint, + body: Vec, + keep_prewarm: bool, + ) -> Result { + self.0.send(ep, body, keep_prewarm).await + } + + async fn project_refresh(&self, msgs: Vec) -> Vec { + self.0.project_refresh(msgs).await + } + + fn prewarm_keeps(&self, prefix: &[u64], payload: &Value) -> bool { + self.0.prewarm_keeps(prefix, payload) + } + + async fn exec_tool(&self, name: &str, args: &Map, label: &str) -> ToolOutcome { + if name == "subagent" { + let result = match precheck(args, caps_role(&self.0.caps)) { + Err(e) => e, + // Un rôle sans `Role` : ne peut pas arriver (le Go le pose). + Ok(_) => "[erreur] un sous-agent ne peut pas en appeler un autre — réponds avec ce que tu as".into(), + }; + return ToolOutcome { + result, + ..Default::default() + }; + } + self.0.exec_tool_base(name, args, label).await + } + + fn save_tool_result(&self, result: &str) -> String { + self.0.save_tool_result(result) + } + + async fn compact(&self, phase: CompactPhase, msgs: &[Message]) -> Option> { + self.0.compact(phase, msgs).await + } + + fn compact_refused(&self, used: i64, msgs: &[Message]) -> bool { + self.0.compact_refused(used, msgs) + } + + fn drain_queued(&self) -> Vec { + self.0.drain_queued() + } + + fn completed(&self, tools: &[Tool], kwargs: Option<&Map>, effort: &str) { + self.0.completed(tools, kwargs, effort) + } + + fn prefix_unstable( + &self, + tools: &[Tool], + kwargs: Option<&Map>, + effort: &str, + ) -> bool { + self.0.prefix_unstable(tools, kwargs, effort) + } + + async fn prepare_images(&self, msgs: &[Message]) { + self.0.prepare_images(msgs).await + } + + fn image_data_url(&self, r: &str) -> Option { + self.0.image_data_url(r) + } + + fn effort_remember(&self, want: &str, got: &str) { + self.0.effort_remember(want, got) + } + + fn echo_refuse(&self, model: &str) { + self.0.echo_refuse(model) + } + + fn cache_hint(&self) -> Option { + self.0.cache_hint() + } + + fn preflight(&self) -> Option { + self.0.preflight() + } + + async fn keep_images(&self) -> Option { + self.0.keep_images().await + } + + async fn keep_image(&self, m: &Message) -> Option<(Message, i64)> { + self.0.keep_image(m).await + } + + async fn engine_slot(&self, ep: &ChatEndpoint) -> Option { + self.0.engine_slot(ep).await + } + + async fn perf_record(&self, rec: PerfRec) -> PerfMarks { + self.0.perf_record(rec).await + } +} + +#[cfg(test)] +mod tests; diff --git a/rust/loki-rs/src/chat/subagent/tests.rs b/rust/loki-rs/src/chat/subagent/tests.rs new file mode 100644 index 0000000..f116eaf --- /dev/null +++ b/rust/loki-rs/src/chat/subagent/tests.rs @@ -0,0 +1,65 @@ +//! Tests unitaires du sous-agent (lot 47) ; la parité avec le Go est dans +//! `tests/lot6.rs` (`subagent_comme_le_go`). + +use super::*; + +fn args(v: Value) -> Map { + serde_json::from_value(v).unwrap() +} + +#[test] +fn refus_avant_le_moteur() { + let e = |v, p| precheck(&args(v), p).unwrap_err(); + assert!( + e(json!({"role": "verifier", "task": "x"}), "") + .starts_with("[erreur] rôle inconnu : verifier") + ); + assert!(e(json!({"role": 3, "task": "x"}), "").starts_with("[erreur] rôle inconnu : (")); + assert!(e(json!({"role": "planner", "task": " \n"}), "").contains("tâche vide")); + // Le rôle et la tâche d'abord, la récursion ensuite. + assert!(e(json!({"role": "x", "task": "y"}), "explorer").contains("rôle inconnu")); + assert!( + e(json!({"role": "explorer", "task": "y"}), "explorer") + .contains("ne peut pas en appeler un autre") + ); + assert_eq!( + precheck( + &args(json!({"role": " planner ", "task": " découpe "})), + "builder" + ), + Ok(("planner".into(), "découpe".into())) + ); + assert!(precheck(&args(json!({"role": "explorer", "task": "t"})), "").is_ok()); +} + +#[test] +fn rapports() { + assert_eq!(report("explorer", " vu \n", None), "[explorer]\nvu"); + assert_eq!( + report("planner", " ", None), + "[erreur] le sous-agent planner n'a rien répondu" + ); + assert_eq!( + report("explorer", "x", Some("panne")), + "[erreur] sous-agent explorer : panne" + ); + let long = "é".repeat(MAX_OUTPUT + 1); + let r = report("explorer", &long, None); + assert!(r.ends_with("\n…[tronqué]")); + let kept = r + .trim_start_matches("[explorer]\n") + .trim_end_matches("\n…[tronqué]"); + assert_eq!(kept.chars().count(), MAX_OUTPUT); + let pile = "é".repeat(MAX_OUTPUT); + assert_eq!( + report("explorer", &pile, None), + format!("[explorer]\n{pile}") + ); +} + +#[test] +fn cadrages() { + assert!(framing("planner").contains("criteria tool")); + assert!(framing("explorer").contains("file:line")); + assert_eq!(framing("code-reviewer"), framing("explorer")); +} diff --git a/rust/loki-rs/src/chat/verify.rs b/rust/loki-rs/src/chat/verify.rs index 81bed19..cf882a3 100644 --- a/rust/loki-rs/src/chat/verify.rs +++ b/rust/loki-rs/src/chat/verify.rs @@ -58,7 +58,7 @@ pub struct Criterion { /// `engineSideJob` : début d'un travail annexe auprès du Go ; `None` s'il ne /// répond pas. -async fn side_job_begin(store: &Store) -> Option { +pub(super) async fn side_job_begin(store: &Store) -> Option { store .internal_post("/internal/v1/engine/sidejob", &json!({})) .await @@ -68,7 +68,7 @@ async fn side_job_begin(store: &Store) -> Option { /// Fin du travail annexe. Les requêtes servies ont déjà été comptées une à /// une (`/internal/v1/oai/served`) : rien à ajouter. -async fn side_job_end(store: &Store, id: Option) { +pub(super) async fn side_job_end(store: &Store, id: Option) { if let Some(id) = id { let _ = store .internal_post( @@ -478,7 +478,7 @@ impl ChatService { queued: Box::new(Vec::new), deltas: { let conv = conv.clone(); - Box::new(move |d| { + Arc::new(move |d| { conv.append_delta(conv.epoch(), d); }) }, @@ -491,6 +491,15 @@ impl ChatService { side_slot: Default::default(), projsnap: snap_turn, snap_refreshed: Default::default(), + // Le builder relancé délègue comme dans son tour ; la + // vérification, elle, ne délègue pas (garde-fou du rôle). + sub: Some(super::subagent::SubTurn { + ep: env.ep.clone(), + reasoning: pe.reasoning.clone(), + echo: env.echo.clone(), + patterns: self.shared.patterns.clone(), + cancel: run.cancel.clone(), + }), }; let mut out = PassOut::default(); let mut cb = |ev: StreamEvent| -> bool { diff --git a/rust/loki-rs/tests/common/fakego.rs b/rust/loki-rs/tests/common/fakego.rs index c76e841..298937d 100644 --- a/rust/loki-rs/tests/common/fakego.rs +++ b/rust/loki-rs/tests/common/fakego.rs @@ -56,6 +56,8 @@ pub struct FakeGo { /// reçues (« block », « blocks »). pub recall_blocks: Mutex>, pub recall_log: Mutex>, + /// Vues des rôles délégués (/internal/v1/turn/subagent, lot 47), par rôle. + pub subagent_views: Mutex>, /// Discussion active annoncée et discussions supprimées. pub conv_log: Mutex>, /// Tours : débuts, vues et fins demandés. @@ -474,6 +476,19 @@ async fn handle(st: Arc, req: Request) -> Response } resp(204, "") } + "/internal/v1/turn/subagent" => { + let body = req.into_body().collect().await.unwrap().to_bytes(); + let v: serde_json::Value = serde_json::from_slice(&body).unwrap_or_default(); + let role = v["role"].as_str().unwrap_or_default().to_string(); + st.turn_log.lock().unwrap().push(format!("subagent {role}")); + match st.subagent_views.lock().unwrap().get(&role) { + Some(view) => resp(200, view.to_string()), + None => resp( + 400, + serde_json::json!({ "error": format!("rôle inconnu : {role}") }).to_string(), + ), + } + } "/internal/v1/turn/verifier" => { let body = req.into_body().collect().await.unwrap().to_bytes(); let v: serde_json::Value = serde_json::from_slice(&body).unwrap_or_default(); diff --git a/rust/loki-rs/tests/lot6.rs b/rust/loki-rs/tests/lot6.rs index e7542dd..d666720 100644 --- a/rust/loki-rs/tests/lot6.rs +++ b/rust/loki-rs/tests/lot6.rs @@ -702,11 +702,12 @@ async fn tour_complet_par_le_go() { kwargs: Default::default(), }, queued: Box::new(Vec::new), - deltas: Box::new(|_| {}), + deltas: Arc::new(|_| {}), perf_kind: "main", side_slot: Default::default(), projsnap: None, snap_refreshed: Default::default(), + sub: None, }; let bash = Tool { kind: "function".into(), @@ -1102,11 +1103,12 @@ async fn hote_et_sonde_de_gabarit() { kwargs: Default::default(), }, queued: Box::new(Vec::new), - deltas: Box::new(|_| {}), + deltas: Arc::new(|_| {}), perf_kind: "main", side_slot: Default::default(), projsnap: None, snap_refreshed: Default::default(), + sub: None, } }; let bash = Tool { @@ -2843,6 +2845,106 @@ async fn job_arriere_plan_par_la_facade() { assert_eq!(jobs.running(), 0); } +/// Lot 47 : l'outil `subagent` exécuté par la façade, rejoué sur le journal +/// du Go (`internal/loki/parity_subagent_test.go` → +/// `tests/parity_subagent/subagent.json`) : refus avant tout appel au moteur, +/// puis un tour de la façade qui délègue — le rôle reçoit la vue du Go et sa +/// tâche cadrée, le moteur ses réponses scriptées, et le builder le même +/// rapport que le Go lui aurait rendu. +#[tokio::test] +async fn subagent_comme_le_go() { + let fx: serde_json::Value = + serde_json::from_str(include_str!("parity_subagent/subagent.json")).expect("journal"); + let views = fx["views"].as_object().unwrap(); + let sub_sse = |r: &serde_json::Value| -> String { + if let Some(code) = r["status"].as_u64() { + return format!("STATUS {code}\n{}", r["content"].as_str().unwrap_or("")); + } + let content = r["content"].as_str().unwrap_or(""); + if let Some(tool) = r["tool"].as_str() { + let call = serde_json::json!({"choices":[{"delta":{"tool_calls":[{"index":0,"id":"s1","type":"function","function":{"name":tool,"arguments":r["args"].as_str().unwrap_or("")}}]}}]}); + let mut chunks = Vec::new(); + if !content.is_empty() { + chunks.push(serde_json::json!({"choices":[{"delta":{"content":content}}]})); + } + chunks.push(call); + chunks.push(serde_json::json!({"choices":[{"delta":{},"finish_reason":"tool_calls"}]})); + return sse(&chunks); + } + answer(content) + }; + for st in fx["steps"].as_array().unwrap() { + let name = st["name"].as_str().unwrap(); + let args: serde_json::Map = + serde_json::from_value(st["args"].clone()).unwrap(); + let want = st["result"].as_str().unwrap(); + let reqs = st["requests"].as_array().unwrap(); + if reqs.is_empty() { + let got = + loki_rs::chat::subagent::precheck(&args, st["parent_role"].as_str().unwrap_or("")); + assert_eq!(got.err().as_deref(), Some(want), "{name}"); + continue; + } + let call = sse(&[ + serde_json::json!({"choices":[{"delta":{"tool_calls":[{"index":0,"id":"d1","type":"function","function":{"name":"subagent","arguments":serde_json::Value::Object(args.clone()).to_string()}}]}}]}), + serde_json::json!({"choices":[{"delta":{},"finish_reason":"tool_calls"}]}), + ]); + let mut replies = vec![call]; + let scripted = st["replies"].as_array().cloned().unwrap_or_default(); + for i in 0..reqs.len() { + replies.push(scripted.get(i).map(sub_sse).unwrap_or_else(|| answer(""))); + } + replies.push(answer("fini")); + let (url, seen) = fake_engine(replies).await; + let ws = tempfile::tempdir().unwrap(); + std::fs::write(ws.path().join("cache.go"), "package x\n// cache LRU\n").unwrap(); + let (svc, go, _stop, _t) = service_with(Some(&url), |_| {}).await; + *go.tool_ctx.lock().unwrap() = Some(serde_json::json!({ + "cwd": ws.path(), "memory_dir": ws.path().join("mem"), "target": "" + })); + for (role, v) in views { + go.subagent_views + .lock() + .unwrap() + .insert(role.clone(), v.clone()); + } + svc.send(serde_json::json!({"message": "délègue", "cid": "s"})) + .await + .unwrap(); + until_done(&svc, 1).await; + let seen = seen.lock().unwrap().clone(); + assert_eq!(seen.len(), reqs.len() + 2, "{name} : requêtes au moteur"); + let role = args["role"].as_str().unwrap().trim(); + for (i, want_req) in reqs.iter().enumerate() { + let b = &seen[1 + i].1; + let msgs = b["messages"].as_array().unwrap(); + let tools: Vec<&str> = b["tools"] + .as_array() + .into_iter() + .flatten() + .filter_map(|t| t["function"]["name"].as_str()) + .collect(); + let got = serde_json::json!({ + "system_is_view": msgs[0]["content"] == views[role]["system"], + "user": msgs[1]["content"], + "temperature": b["temperature"], + "tools": tools, + "messages": msgs.len(), + }); + assert_eq!(&got, want_req, "{name} : requête {i} du rôle"); + } + let last = seen.last().unwrap().1["messages"] + .as_array() + .unwrap() + .clone(); + let tool = last.iter().rfind(|m| m["role"] == "tool").unwrap(); + assert_eq!(tool["content"], want, "{name} : rapport rendu au builder"); + // Travail annexe : ouvert et fermé autour du tour du rôle. + let side = go.side_jobs.lock().unwrap().clone(); + assert_eq!(side.len(), 2, "{name} : {side:?}"); + } +} + /// Lot 46 : `recall` et `recall_search` sont exécutés par la façade sur /// l'archive du Go (rien par /tool/exec). #[tokio::test] diff --git a/rust/loki-rs/tests/parity_subagent/subagent.json b/rust/loki-rs/tests/parity_subagent/subagent.json new file mode 100644 index 0000000..3080251 --- /dev/null +++ b/rust/loki-rs/tests/parity_subagent/subagent.json @@ -0,0 +1,843 @@ +{ + "steps": [ + { + "name": "rôle inconnu", + "args": { + "role": "architecte", + "task": "x" + }, + "result": "[erreur] rôle inconnu : architecte (attendu : explorer, code-reviewer, planner)", + "requests": [] + }, + { + "name": "verifier refusé", + "args": { + "role": "verifier", + "task": "x" + }, + "result": "[erreur] rôle inconnu : verifier (attendu : explorer, code-reviewer, planner)", + "requests": [] + }, + { + "name": "sans rôle", + "args": { + "task": "x" + }, + "result": "[erreur] rôle inconnu : (attendu : explorer, code-reviewer, planner)", + "requests": [] + }, + { + "name": "tâche vide", + "args": { + "role": "explorer", + "task": " " + }, + "result": "[erreur] tâche vide — décris ce que le rôle doit chercher ou relire", + "requests": [] + }, + { + "name": "tâche absente", + "args": { + "role": "planner" + }, + "result": "[erreur] tâche vide — décris ce que le rôle doit chercher ou relire", + "requests": [] + }, + { + "name": "pas de récursion", + "args": { + "role": "explorer", + "task": "x" + }, + "parent_role": "explorer", + "result": "[erreur] un sous-agent ne peut pas en appeler un autre — réponds avec ce que tu as", + "requests": [] + }, + { + "name": "vérificateur qui délègue", + "args": { + "role": "planner", + "task": "x" + }, + "parent_role": "verifier", + "result": "[erreur] un sous-agent ne peut pas en appeler un autre — réponds avec ce que tu as", + "requests": [] + }, + { + "name": "réponse directe", + "args": { + "role": "explorer", + "task": " Où est le cache ? " + }, + "replies": [ + { + "content": " Dans cache.go:2. \n" + } + ], + "result": "[explorer]\nDans cache.go:2.", + "requests": [ + { + "system_is_view": true, + "user": "Où est le cache ?\n\nAnswer with your findings only: what you found and where (file:line). No preamble.", + "temperature": 0.6, + "tools": [ + "read", + "grep", + "glob", + "git_status", + "git_diff", + "bash" + ], + "messages": 2 + } + ] + }, + { + "name": "rapport après le dernier outil", + "args": { + "role": " code-reviewer ", + "task": "Relis cache.go" + }, + "replies": [ + { + "tool": "grep", + "args": "{\"pattern\":\"cache\"}", + "content": "Je cherche d'abord." + }, + { + "tool": "read", + "args": "{\"path\":\"cache.go\"}" + }, + { + "content": "RAS : cache.go:2 documente le LRU." + } + ], + "result": "[code-reviewer]\nRAS : cache.go:2 documente le LRU.", + "requests": [ + { + "system_is_view": true, + "user": "Relis cache.go\n\nAnswer with your findings only: what you found and where (file:line). No preamble.", + "temperature": 0.6, + "tools": [ + "read", + "grep", + "glob", + "git_status", + "git_diff", + "bash" + ], + "messages": 2 + }, + { + "system_is_view": true, + "user": "Relis cache.go\n\nAnswer with your findings only: what you found and where (file:line). No preamble.", + "temperature": 0.6, + "tools": [ + "read", + "grep", + "glob", + "git_status", + "git_diff", + "bash" + ], + "messages": 4 + }, + { + "system_is_view": true, + "user": "Relis cache.go\n\nAnswer with your findings only: what you found and where (file:line). No preamble.", + "temperature": 0.6, + "tools": [ + "read", + "grep", + "glob", + "git_status", + "git_diff", + "bash" + ], + "messages": 6 + } + ] + }, + { + "name": "rien répondu", + "args": { + "role": "explorer", + "task": "Vide ?" + }, + "replies": [ + { + "content": " " + } + ], + "result": "[explorer]\n_(le modèle n'a pas produit de réponse — finish: stop)_", + "requests": [ + { + "system_is_view": true, + "user": "Vide ?\n\nAnswer with your findings only: what you found and where (file:line). No preamble.", + "temperature": 0.6, + "tools": [ + "read", + "grep", + "glob", + "git_status", + "git_diff", + "bash" + ], + "messages": 2 + }, + { + "system_is_view": true, + "user": "Vide ?\n\nAnswer with your findings only: what you found and where (file:line). No preamble.", + "temperature": 0.6, + "tools": [ + "read", + "grep", + "glob", + "git_status", + "git_diff", + "bash" + ], + "messages": 3 + }, + { + "system_is_view": true, + "user": "Vide ?\n\nAnswer with your findings only: what you found and where (file:line). No preamble.", + "temperature": 0.6, + "tools": [ + "read", + "grep", + "glob", + "git_status", + "git_diff", + "bash" + ], + "messages": 4 + } + ] + }, + { + "name": "rien après l'outil", + "args": { + "role": "explorer", + "task": "Encore ?" + }, + "replies": [ + { + "tool": "grep", + "args": "{\"pattern\":\"x\"}", + "content": "narration" + }, + {} + ], + "result": "[explorer]\n_(le modèle n'a pas produit de réponse — finish: stop)_", + "requests": [ + { + "system_is_view": true, + "user": "Encore ?\n\nAnswer with your findings only: what you found and where (file:line). No preamble.", + "temperature": 0.6, + "tools": [ + "read", + "grep", + "glob", + "git_status", + "git_diff", + "bash" + ], + "messages": 2 + }, + { + "system_is_view": true, + "user": "Encore ?\n\nAnswer with your findings only: what you found and where (file:line). No preamble.", + "temperature": 0.6, + "tools": [ + "read", + "grep", + "glob", + "git_status", + "git_diff", + "bash" + ], + "messages": 4 + }, + { + "system_is_view": true, + "user": "Encore ?\n\nAnswer with your findings only: what you found and where (file:line). No preamble.", + "temperature": 0.6, + "tools": [ + "read", + "grep", + "glob", + "git_status", + "git_diff", + "bash" + ], + "messages": 5 + }, + { + "system_is_view": true, + "user": "Encore ?\n\nAnswer with your findings only: what you found and where (file:line). No preamble.", + "temperature": 0.6, + "tools": [ + "read", + "grep", + "glob", + "git_status", + "git_diff", + "bash" + ], + "messages": 6 + } + ] + }, + { + "name": "rapport tronqué", + "args": { + "role": "explorer", + "task": "Tout ?" + }, + "replies": [ + { + "content": "éééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééé" + } + ], + "result": "[explorer]\néééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééé\n…[tronqué]", + "requests": [ + { + "system_is_view": true, + "user": "Tout ?\n\nAnswer with your findings only: what you found and where (file:line). No preamble.", + "temperature": 0.6, + "tools": [ + "read", + "grep", + "glob", + "git_status", + "git_diff", + "bash" + ], + "messages": 2 + } + ] + }, + { + "name": "cadrage du planner", + "args": { + "role": "planner", + "task": "Découpe le chantier" + }, + "replies": [ + { + "content": "1. lire\n2. écrire" + } + ], + "result": "[planner]\n1. lire\n2. écrire", + "requests": [ + { + "system_is_view": true, + "user": "Découpe le chantier\n\nYou are a delegated pass: nobody will answer you. Record the acceptance criteria with the criteria tool, then give the ordered steps. Do not ask for confirmation.", + "temperature": 0.6, + "tools": [ + "read", + "grep", + "glob", + "git_status", + "git_diff", + "criteria" + ], + "messages": 2 + } + ] + }, + { + "name": "moteur qui refuse", + "args": { + "role": "explorer", + "task": "Panne ?" + }, + "replies": [ + { + "content": "{\"error\":{\"message\":\"requête refusée\"}}", + "status": 400 + } + ], + "result": "[erreur] sous-agent explorer : llama-server a renvoyé 400 : {\"error\":{\"message\":\"requête refusée\"}}", + "requests": [ + { + "system_is_view": true, + "user": "Panne ?\n\nAnswer with your findings only: what you found and where (file:line). No preamble.", + "temperature": 0.6, + "tools": [ + "read", + "grep", + "glob", + "git_status", + "git_diff", + "bash" + ], + "messages": 2 + } + ] + } + ], + "views": { + "code-reviewer": { + "caps": { + "Agent": true, + "Internet": false, + "Mem": "off", + "Code": true, + "Role": "code-reviewer", + "ComputerUse": false + }, + "system": "You are the CODE REVIEW pass of code mode. Review the current changes (git_diff) for real defects.\n\nLook for, in this order: correctness bugs (wrong logic, unhandled errors, races), security issues (injection, path traversal, secrets), then significant simplifications. Ignore style and taste.\n\nFor each finding: file:line, one-line problem, one-line fix. If the diff is sound, say so in one sentence. Do not modify anything.\n\nMachine: host=vm, linux/amd64, user=root, cwd={{WS}}. This is your working folder: relative paths in write/edit/bash resolve here, and it is the DEFAULT place for scratch work — notes, outputs, a clone, a test. But it is DISPOSABLE: deleting the discussion wipes it. Any script you want to KEEP (or schedule), write it into your scripts folder {{HOME}}/scripts instead — a separate folder a workspace wipe won't touch; you write and run scripts there normally. Do NOT install or write files into system directories such as /usr/local/bin, /usr, /bin or /etc: those need root and are not yours. Only use an absolute path outside this folder (except your scripts folder) when the user explicitly named that location.", + "tools": [ + { + "type": "function", + "function": { + "name": "read", + "description": "Read a file with 1-indexed line numbers (offset/limit for long files). ALWAYS use this instead of cat/type: output is bounded and reading a file is REQUIRED before you may edit or overwrite it.", + "parameters": { + "properties": { + "file": { + "description": "Path (relative to the working folder)", + "type": "string" + }, + "limit": { + "description": "Lines (default 400, max 1000)", + "type": "integer" + }, + "offset": { + "description": "Start line (default 1)", + "type": "integer" + } + }, + "required": [ + "file" + ], + "type": "object" + } + } + }, + { + "type": "function", + "function": { + "name": "grep", + "description": "Search file contents with a Go regular expression. Returns path:line: text matches (bounded). path may be a file or a directory (default: whole working folder).", + "parameters": { + "properties": { + "glob": { + "description": "Only files matching this glob (e.g. *.go)", + "type": "string" + }, + "path": { + "description": "File or directory to search (default .)", + "type": "string" + }, + "pattern": { + "description": "Go regexp", + "type": "string" + } + }, + "required": [ + "pattern" + ], + "type": "object" + } + } + }, + { + "type": "function", + "function": { + "name": "glob", + "description": "List files matching a glob pattern (** supported, e.g. **/*.go), most recently modified first.", + "parameters": { + "properties": { + "pattern": { + "description": "Glob, e.g. src/**/*.ts", + "type": "string" + } + }, + "required": [ + "pattern" + ], + "type": "object" + } + } + }, + { + "type": "function", + "function": { + "name": "git_status", + "description": "git status of the working folder (branch + changed files, porcelain format).", + "parameters": { + "properties": { + "dir": { + "description": "Repository subfolder (default: auto-detected)", + "type": "string" + } + }, + "type": "object" + } + } + }, + { + "type": "function", + "function": { + "name": "git_diff", + "description": "git diff of the working folder (unstaged + staged). Optional file to diff a single path.", + "parameters": { + "properties": { + "dir": { + "description": "Repository subfolder (default: auto-detected)", + "type": "string" + }, + "file": { + "description": "Limit the diff to this path", + "type": "string" + } + }, + "type": "object" + } + } + }, + { + "type": "function", + "function": { + "name": "bash", + "description": "Run a shell command (bash syntax) and return stdout, stderr and exit code: inspect the system, read files and logs, run scripts. To CREATE or REWRITE a file use write instead — never echo/cat/python -c. Avoid destructive commands unless asked.", + "parameters": { + "properties": { + "command": { + "description": "The command", + "type": "string" + }, + "timeout": { + "description": "Timeout s (default 30, max 300)", + "type": "integer" + } + }, + "required": [ + "command" + ], + "type": "object" + } + } + } + ] + }, + "explorer": { + "caps": { + "Agent": true, + "Internet": false, + "Mem": "off", + "Code": true, + "Role": "explorer", + "ComputerUse": false + }, + "system": "You are the EXPLORATION pass of code mode. Answer questions about the codebase without modifying anything.\n\nUse read, grep, glob and git tools to locate and understand code. Cite files as path:line. Be concise: report what you found and where, not everything you read. If the answer spans several places, list them with one line each.\n\nMachine: host=vm, linux/amd64, user=root, cwd={{WS}}. This is your working folder: relative paths in write/edit/bash resolve here, and it is the DEFAULT place for scratch work — notes, outputs, a clone, a test. But it is DISPOSABLE: deleting the discussion wipes it. Any script you want to KEEP (or schedule), write it into your scripts folder {{HOME}}/scripts instead — a separate folder a workspace wipe won't touch; you write and run scripts there normally. Do NOT install or write files into system directories such as /usr/local/bin, /usr, /bin or /etc: those need root and are not yours. Only use an absolute path outside this folder (except your scripts folder) when the user explicitly named that location.", + "tools": [ + { + "type": "function", + "function": { + "name": "read", + "description": "Read a file with 1-indexed line numbers (offset/limit for long files). ALWAYS use this instead of cat/type: output is bounded and reading a file is REQUIRED before you may edit or overwrite it.", + "parameters": { + "properties": { + "file": { + "description": "Path (relative to the working folder)", + "type": "string" + }, + "limit": { + "description": "Lines (default 400, max 1000)", + "type": "integer" + }, + "offset": { + "description": "Start line (default 1)", + "type": "integer" + } + }, + "required": [ + "file" + ], + "type": "object" + } + } + }, + { + "type": "function", + "function": { + "name": "grep", + "description": "Search file contents with a Go regular expression. Returns path:line: text matches (bounded). path may be a file or a directory (default: whole working folder).", + "parameters": { + "properties": { + "glob": { + "description": "Only files matching this glob (e.g. *.go)", + "type": "string" + }, + "path": { + "description": "File or directory to search (default .)", + "type": "string" + }, + "pattern": { + "description": "Go regexp", + "type": "string" + } + }, + "required": [ + "pattern" + ], + "type": "object" + } + } + }, + { + "type": "function", + "function": { + "name": "glob", + "description": "List files matching a glob pattern (** supported, e.g. **/*.go), most recently modified first.", + "parameters": { + "properties": { + "pattern": { + "description": "Glob, e.g. src/**/*.ts", + "type": "string" + } + }, + "required": [ + "pattern" + ], + "type": "object" + } + } + }, + { + "type": "function", + "function": { + "name": "git_status", + "description": "git status of the working folder (branch + changed files, porcelain format).", + "parameters": { + "properties": { + "dir": { + "description": "Repository subfolder (default: auto-detected)", + "type": "string" + } + }, + "type": "object" + } + } + }, + { + "type": "function", + "function": { + "name": "git_diff", + "description": "git diff of the working folder (unstaged + staged). Optional file to diff a single path.", + "parameters": { + "properties": { + "dir": { + "description": "Repository subfolder (default: auto-detected)", + "type": "string" + }, + "file": { + "description": "Limit the diff to this path", + "type": "string" + } + }, + "type": "object" + } + } + }, + { + "type": "function", + "function": { + "name": "bash", + "description": "Run a shell command (bash syntax) and return stdout, stderr and exit code: inspect the system, read files and logs, run scripts. To CREATE or REWRITE a file use write instead — never echo/cat/python -c. Avoid destructive commands unless asked.", + "parameters": { + "properties": { + "command": { + "description": "The command", + "type": "string" + }, + "timeout": { + "description": "Timeout s (default 30, max 300)", + "type": "integer" + } + }, + "required": [ + "command" + ], + "type": "object" + } + } + } + ] + }, + "planner": { + "caps": { + "Agent": true, + "Internet": false, + "Mem": "off", + "Code": true, + "Role": "planner", + "ComputerUse": false + }, + "system": "You are the PLANNING pass of code mode. Break the user's task down BEFORE any code is written.\n\nProduce:\n1. A short restatement of the goal (one sentence).\n2. Acceptance criteria via the criteria tool (action=add): 2-6 short, testable statements of what DONE means.\n3. The implementation steps, ordered, with the files each step touches.\n\nExplore the code (read, grep, glob, git_status) as needed to make the plan concrete — real file paths, real function names. Do not modify anything. End by asking the user to confirm the plan or amend it.\n\nMachine: host=vm, linux/amd64, user=root, cwd={{WS}}. This is your working folder: relative paths in write/edit/bash resolve here, and it is the DEFAULT place for scratch work — notes, outputs, a clone, a test. But it is DISPOSABLE: deleting the discussion wipes it. Any script you want to KEEP (or schedule), write it into your scripts folder {{HOME}}/scripts instead — a separate folder a workspace wipe won't touch; you write and run scripts there normally. Do NOT install or write files into system directories such as /usr/local/bin, /usr, /bin or /etc: those need root and are not yours. Only use an absolute path outside this folder (except your scripts folder) when the user explicitly named that location.", + "tools": [ + { + "type": "function", + "function": { + "name": "read", + "description": "Read a file with 1-indexed line numbers (offset/limit for long files). ALWAYS use this instead of cat/type: output is bounded and reading a file is REQUIRED before you may edit or overwrite it.", + "parameters": { + "properties": { + "file": { + "description": "Path (relative to the working folder)", + "type": "string" + }, + "limit": { + "description": "Lines (default 400, max 1000)", + "type": "integer" + }, + "offset": { + "description": "Start line (default 1)", + "type": "integer" + } + }, + "required": [ + "file" + ], + "type": "object" + } + } + }, + { + "type": "function", + "function": { + "name": "grep", + "description": "Search file contents with a Go regular expression. Returns path:line: text matches (bounded). path may be a file or a directory (default: whole working folder).", + "parameters": { + "properties": { + "glob": { + "description": "Only files matching this glob (e.g. *.go)", + "type": "string" + }, + "path": { + "description": "File or directory to search (default .)", + "type": "string" + }, + "pattern": { + "description": "Go regexp", + "type": "string" + } + }, + "required": [ + "pattern" + ], + "type": "object" + } + } + }, + { + "type": "function", + "function": { + "name": "glob", + "description": "List files matching a glob pattern (** supported, e.g. **/*.go), most recently modified first.", + "parameters": { + "properties": { + "pattern": { + "description": "Glob, e.g. src/**/*.ts", + "type": "string" + } + }, + "required": [ + "pattern" + ], + "type": "object" + } + } + }, + { + "type": "function", + "function": { + "name": "git_status", + "description": "git status of the working folder (branch + changed files, porcelain format).", + "parameters": { + "properties": { + "dir": { + "description": "Repository subfolder (default: auto-detected)", + "type": "string" + } + }, + "type": "object" + } + } + }, + { + "type": "function", + "function": { + "name": "git_diff", + "description": "git diff of the working folder (unstaged + staged). Optional file to diff a single path.", + "parameters": { + "properties": { + "dir": { + "description": "Repository subfolder (default: auto-detected)", + "type": "string" + }, + "file": { + "description": "Limit the diff to this path", + "type": "string" + } + }, + "type": "object" + } + } + }, + { + "type": "function", + "function": { + "name": "criteria", + "description": "Manage the acceptance criteria of the current task (the contract that defines DONE). action=add posits new criteria (texts[]), action=set updates one (id + status, optional note), action=list shows them, action=clear removes them all. Set criteria BEFORE building. Builder: mark each criterion completed once you have done AND checked it — verification only starts when nothing is left pending. Only the verification pass may mark passed or failed.", + "parameters": { + "properties": { + "action": { + "enum": [ + "add", + "set", + "list", + "clear" + ], + "type": "string" + }, + "id": { + "description": "set: criterion id", + "type": "integer" + }, + "note": { + "description": "set: why it failed / how it was verified", + "type": "string" + }, + "status": { + "enum": [ + "pending", + "completed", + "passed", + "failed" + ], + "type": "string" + }, + "texts": { + "description": "add: one entry per criterion, short and testable", + "items": { + "type": "string" + }, + "type": "array" + } + }, + "required": [ + "action" + ], + "type": "object" + } + } + } + ] + } + } +} From e8563d992cb7706546d4d7f9b8a531ee4c099e22 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 16 Oct 2026 07:15:53 +0000 Subject: [PATCH 51/52] =?UTF-8?q?Lot=2047=20:=20journal=20du=20sous-agent?= =?UTF-8?q?=20ind=C3=A9pendant=20du=20poste?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Le briefing machine des vues des rôles nomme l'hôte et le compte : masqués dans le journal ({{HOTE}}, {{COMPTE}}), qui différait sur le poste de la CI. Il nomme aussi le système et l'architecture : journal tiré sous Linux seulement, comme les autres journaux qui dépendent du poste. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_016tcGfJWefW5kUGoC2wL8cv --- internal/loki/parity_subagent_test.go | 10 ++++++++++ rust/loki-rs/tests/parity_subagent/subagent.json | 6 +++--- 2 files changed, 13 insertions(+), 3 deletions(-) diff --git a/internal/loki/parity_subagent_test.go b/internal/loki/parity_subagent_test.go index 4d9dcf2..b3796dd 100644 --- a/internal/loki/parity_subagent_test.go +++ b/internal/loki/parity_subagent_test.go @@ -1,3 +1,5 @@ +//go:build linux + package loki import ( @@ -9,6 +11,7 @@ import ( "net/http/httptest" "net/url" "os" + "os/user" "path/filepath" "strings" "sync" @@ -195,6 +198,13 @@ func TestPariteSubagent(t *testing.T) { if h := os.Getenv("LOKI_HOME"); h != "" { got = bytes.ReplaceAll(got, []byte(h), []byte("{{HOME}}")) } + // Le briefing machine nomme l'hôte et le compte : propres au poste. + if host, _ := os.Hostname(); host != "" { + got = bytes.ReplaceAll(got, []byte("host="+host+","), []byte("host={{HOTE}},")) + } + if u, err := user.Current(); err == nil && u.Username != "" { + got = bytes.ReplaceAll(got, []byte(", user="+u.Username+","), []byte(", user={{COMPTE}},")) + } if os.Getenv("LOKI_PARITY_UPDATE") == "1" { if err := os.MkdirAll(filepath.Dir(paritySubagentFile), 0o755); err != nil { t.Fatal(err) diff --git a/rust/loki-rs/tests/parity_subagent/subagent.json b/rust/loki-rs/tests/parity_subagent/subagent.json index 3080251..c742fa1 100644 --- a/rust/loki-rs/tests/parity_subagent/subagent.json +++ b/rust/loki-rs/tests/parity_subagent/subagent.json @@ -388,7 +388,7 @@ "Role": "code-reviewer", "ComputerUse": false }, - "system": "You are the CODE REVIEW pass of code mode. Review the current changes (git_diff) for real defects.\n\nLook for, in this order: correctness bugs (wrong logic, unhandled errors, races), security issues (injection, path traversal, secrets), then significant simplifications. Ignore style and taste.\n\nFor each finding: file:line, one-line problem, one-line fix. If the diff is sound, say so in one sentence. Do not modify anything.\n\nMachine: host=vm, linux/amd64, user=root, cwd={{WS}}. This is your working folder: relative paths in write/edit/bash resolve here, and it is the DEFAULT place for scratch work — notes, outputs, a clone, a test. But it is DISPOSABLE: deleting the discussion wipes it. Any script you want to KEEP (or schedule), write it into your scripts folder {{HOME}}/scripts instead — a separate folder a workspace wipe won't touch; you write and run scripts there normally. Do NOT install or write files into system directories such as /usr/local/bin, /usr, /bin or /etc: those need root and are not yours. Only use an absolute path outside this folder (except your scripts folder) when the user explicitly named that location.", + "system": "You are the CODE REVIEW pass of code mode. Review the current changes (git_diff) for real defects.\n\nLook for, in this order: correctness bugs (wrong logic, unhandled errors, races), security issues (injection, path traversal, secrets), then significant simplifications. Ignore style and taste.\n\nFor each finding: file:line, one-line problem, one-line fix. If the diff is sound, say so in one sentence. Do not modify anything.\n\nMachine: host={{HOTE}}, linux/amd64, user={{COMPTE}}, cwd={{WS}}. This is your working folder: relative paths in write/edit/bash resolve here, and it is the DEFAULT place for scratch work — notes, outputs, a clone, a test. But it is DISPOSABLE: deleting the discussion wipes it. Any script you want to KEEP (or schedule), write it into your scripts folder {{HOME}}/scripts instead — a separate folder a workspace wipe won't touch; you write and run scripts there normally. Do NOT install or write files into system directories such as /usr/local/bin, /usr, /bin or /etc: those need root and are not yours. Only use an absolute path outside this folder (except your scripts folder) when the user explicitly named that location.", "tools": [ { "type": "function", @@ -533,7 +533,7 @@ "Role": "explorer", "ComputerUse": false }, - "system": "You are the EXPLORATION pass of code mode. Answer questions about the codebase without modifying anything.\n\nUse read, grep, glob and git tools to locate and understand code. Cite files as path:line. Be concise: report what you found and where, not everything you read. If the answer spans several places, list them with one line each.\n\nMachine: host=vm, linux/amd64, user=root, cwd={{WS}}. This is your working folder: relative paths in write/edit/bash resolve here, and it is the DEFAULT place for scratch work — notes, outputs, a clone, a test. But it is DISPOSABLE: deleting the discussion wipes it. Any script you want to KEEP (or schedule), write it into your scripts folder {{HOME}}/scripts instead — a separate folder a workspace wipe won't touch; you write and run scripts there normally. Do NOT install or write files into system directories such as /usr/local/bin, /usr, /bin or /etc: those need root and are not yours. Only use an absolute path outside this folder (except your scripts folder) when the user explicitly named that location.", + "system": "You are the EXPLORATION pass of code mode. Answer questions about the codebase without modifying anything.\n\nUse read, grep, glob and git tools to locate and understand code. Cite files as path:line. Be concise: report what you found and where, not everything you read. If the answer spans several places, list them with one line each.\n\nMachine: host={{HOTE}}, linux/amd64, user={{COMPTE}}, cwd={{WS}}. This is your working folder: relative paths in write/edit/bash resolve here, and it is the DEFAULT place for scratch work — notes, outputs, a clone, a test. But it is DISPOSABLE: deleting the discussion wipes it. Any script you want to KEEP (or schedule), write it into your scripts folder {{HOME}}/scripts instead — a separate folder a workspace wipe won't touch; you write and run scripts there normally. Do NOT install or write files into system directories such as /usr/local/bin, /usr, /bin or /etc: those need root and are not yours. Only use an absolute path outside this folder (except your scripts folder) when the user explicitly named that location.", "tools": [ { "type": "function", @@ -678,7 +678,7 @@ "Role": "planner", "ComputerUse": false }, - "system": "You are the PLANNING pass of code mode. Break the user's task down BEFORE any code is written.\n\nProduce:\n1. A short restatement of the goal (one sentence).\n2. Acceptance criteria via the criteria tool (action=add): 2-6 short, testable statements of what DONE means.\n3. The implementation steps, ordered, with the files each step touches.\n\nExplore the code (read, grep, glob, git_status) as needed to make the plan concrete — real file paths, real function names. Do not modify anything. End by asking the user to confirm the plan or amend it.\n\nMachine: host=vm, linux/amd64, user=root, cwd={{WS}}. This is your working folder: relative paths in write/edit/bash resolve here, and it is the DEFAULT place for scratch work — notes, outputs, a clone, a test. But it is DISPOSABLE: deleting the discussion wipes it. Any script you want to KEEP (or schedule), write it into your scripts folder {{HOME}}/scripts instead — a separate folder a workspace wipe won't touch; you write and run scripts there normally. Do NOT install or write files into system directories such as /usr/local/bin, /usr, /bin or /etc: those need root and are not yours. Only use an absolute path outside this folder (except your scripts folder) when the user explicitly named that location.", + "system": "You are the PLANNING pass of code mode. Break the user's task down BEFORE any code is written.\n\nProduce:\n1. A short restatement of the goal (one sentence).\n2. Acceptance criteria via the criteria tool (action=add): 2-6 short, testable statements of what DONE means.\n3. The implementation steps, ordered, with the files each step touches.\n\nExplore the code (read, grep, glob, git_status) as needed to make the plan concrete — real file paths, real function names. Do not modify anything. End by asking the user to confirm the plan or amend it.\n\nMachine: host={{HOTE}}, linux/amd64, user={{COMPTE}}, cwd={{WS}}. This is your working folder: relative paths in write/edit/bash resolve here, and it is the DEFAULT place for scratch work — notes, outputs, a clone, a test. But it is DISPOSABLE: deleting the discussion wipes it. Any script you want to KEEP (or schedule), write it into your scripts folder {{HOME}}/scripts instead — a separate folder a workspace wipe won't touch; you write and run scripts there normally. Do NOT install or write files into system directories such as /usr/local/bin, /usr, /bin or /etc: those need root and are not yours. Only use an absolute path outside this folder (except your scripts folder) when the user explicitly named that location.", "tools": [ { "type": "function", From cbbe62ff97ba8b116b9eeed4dfd3ae9edc96925d Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 16 Oct 2026 07:26:36 +0000 Subject: [PATCH 52/52] =?UTF-8?q?Tests=20:=20faux=20outils=20=C3=A9crits?= =?UTF-8?q?=20par=20un=20sh=20=C3=A0=20part=20(ETXTBSY)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit plan_par_machine (lot 41) a vu « cpu » au lieu de « cuda » en CI : le faux nvidia-smi, écrit par le process des tests puis lancé aussitôt, peut être encore ouvert en écriture dans le fils qu'un test voisin vient de forker ; son exec échoue alors sur ETXTBSY et l'outil paraît absent. Les scripts des tests de la compilation et le faux Chrome (lot 44, lancé à côté des tests parallèles de tests/lot6.rs) sont désormais écrits par un sh à part, comme les autres faux binaires du dépôt. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_016tcGfJWefW5kUGoC2wL8cv --- rust/loki-rs/src/backends/build/tests.rs | 30 +++++++++++++++++------- rust/loki-rs/tests/common/fakecdp.rs | 13 +++++++--- 2 files changed, 32 insertions(+), 11 deletions(-) diff --git a/rust/loki-rs/src/backends/build/tests.rs b/rust/loki-rs/src/backends/build/tests.rs index 238f8bc..2be2bf4 100644 --- a/rust/loki-rs/src/backends/build/tests.rs +++ b/rust/loki-rs/src/backends/build/tests.rs @@ -135,10 +135,7 @@ async fn plan_par_machine() { root: root.path().to_path_buf(), }; let script = |name: &str, body: &str| { - let p = tools.path().join(name); - std::fs::write(&p, format!("#!/bin/sh\n{body}\n")).unwrap(); - use std::os::unix::fs::PermissionsExt as _; - std::fs::set_permissions(&p, std::fs::Permissions::from_mode(0o755)).unwrap(); + write_script(&tools.path().join(name), &format!("#!/bin/sh\n{body}\n")); }; let base = [ "-DCMAKE_BUILD_TYPE=Release", @@ -230,9 +227,10 @@ async fn erreurs_des_commandes() { root: tools.path().to_path_buf(), }; let p = tools.path().join("outil"); - std::fs::write(&p, "#!/bin/sh\necho un\necho deux >&2\nprintf trois\n[ -n \"$X\" ] && echo \"X=$X\"\nexit $1\n").unwrap(); - use std::os::unix::fs::PermissionsExt as _; - std::fs::set_permissions(&p, std::fs::Permissions::from_mode(0o755)).unwrap(); + write_script( + &p, + "#!/bin/sh\necho un\necho deux >&2\nprintf trois\n[ -n \"$X\" ] && echo \"X=$X\"\nexit $1\n", + ); let args = |a: &[&str]| a.iter().map(|s| s.to_string()).collect::>(); let mut got = Vec::new(); let log = tools.path().join("x.log"); @@ -265,7 +263,23 @@ async fn erreurs_des_commandes() { r, Err("exec: \"absent\": executable file not found in $PATH".into()) ); - std::fs::write(&p, "#!/bin/sh\nkill -9 $$\n").unwrap(); + write_script(&p, "#!/bin/sh\nkill -9 $$\n"); let r = run_lines_with(&probe, None, &[], "outil", &[], true, None, |_| {}).await; assert_eq!(r, Err("signal: killed".into())); } + +/// Écrit un script exécutable par un `sh` à part : si ce process tenait +/// lui-même le fichier ouvert en écriture, un `fork` lancé au même moment +/// par un test voisin en hériterait, et l'`exec` du script échouerait sur +/// ETXTBSY (l'outil paraîtrait absent). +#[cfg(unix)] +fn write_script(p: &std::path::Path, body: &str) { + let ok = std::process::Command::new("sh") + .args(["-c", r#"printf '%s' "$1" > "$2" && chmod 755 "$2""#, "sh"]) + .arg(body) + .arg(p) + .status() + .unwrap() + .success(); + assert!(ok, "script non écrit : {}", p.display()); +} diff --git a/rust/loki-rs/tests/common/fakecdp.rs b/rust/loki-rs/tests/common/fakecdp.rs index ba68c48..ddd552e 100644 --- a/rust/loki-rs/tests/common/fakecdp.rs +++ b/rust/loki-rs/tests/common/fakecdp.rs @@ -32,9 +32,16 @@ exec sleep 600 "#, args.display() ); - std::fs::write(&p, script).unwrap(); - use std::os::unix::fs::PermissionsExt as _; - std::fs::set_permissions(&p, std::fs::Permissions::from_mode(0o755)).unwrap(); + // Écrit par un `sh` à part : un `fork` d'un test voisin hériterait sinon + // du fichier ouvert en écriture, et son `exec` échouerait sur ETXTBSY. + let ok = std::process::Command::new("sh") + .args(["-c", r#"printf '%s' "$1" > "$2" && chmod 755 "$2""#, "sh"]) + .arg(script) + .arg(&p) + .status() + .unwrap() + .success(); + assert!(ok, "faux Chrome non écrit"); p }