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 e97567b..89624d1 100644 --- a/README.md +++ b/README.md @@ -227,7 +227,26 @@ 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` | qui tient la conversation : la façade Rust par défaut (outils fichiers, shell et git compris ; le Go garde les autres outils, les pièces jointes, les fichiers et l'export). `go` : la rend au Go (porte de sortie). Avec `PROJ_SNAPSHOT`, `KEEP_TURN_IMAGES`, `NUDGE_IN_TOOL`, `SIDE_SLOT`, `SLOT_PERSIST`, `PREWARM` ou des motifs `retry_patterns`, le Go la garde de toute façon (décidé au démarrage) | Rust | +| `LOKI_CHAT` | qui tient la conversation : la façade Rust par défaut, outils fichiers, shell et git 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`), range la télémétrie de ses complétions et ses requêtes en vol auprès du Go, honore les motifs de relance personnalisés (`retry_patterns`) et les slots du moteur (`SIDE_SLOT`, `SLOT_PERSIST`, isolation après la vérification) les images gardées (`KEEP_TURN_IMAGES`), le préchauffage (`PREWARM`) et le bloc projet figé (`PROJ_SNAPSHOT`). `go` : la rend au Go (porte de sortie) | Rust | +| `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 | +| `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), et les outils mémoire de l'agent (mem_search, mem_read, mem_add, mem_edit, tracker) dans les tours de la façade, sont tenus 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 ; les tours de la façade appellent son pool directement (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, qui compile aussi llama.cpp depuis les sources sous Linux et macOS ; les jobs du Go (compilation sous Windows, moteur de l'image, Strata) lui rapportent leurs étapes (expérimental, migration en cours) | Go | +| `LOKI_STRATA` | `rust` (avec `LOKI_BACKENDS=rust`) : l'écran de Strata — machine, ce qui y tient, recommandation, modèles installés —, ses réglages et son installation sont tenus par la façade Rust ; le lancement reste au Go (expérimental, migration en cours) | Go | +| `LOKI_TASKS` | `rust` : les tâches planifiées — liste, enregistrement, bascule, interrupteur maître, lancement, arrêt —, le planificateur et les tâches « script seul » sont tenus par la façade Rust ; les outils task_* de l'agent aussi, dans les tours de la façade ; une tâche IA reste exécutée par le Go, à la demande de la façade (expérimental, migration en cours) | Go | +| `LOKI_BENCH` | `rust` : le banc d'essai — lancement, progression, annulation, dernière mesure, mode rapide et mode complet en profondeur — est tenu par la façade Rust ; le verrou de génération reste celui du Go, pris par le bail du moteur (expérimental, migration en cours) | Go | +| `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 (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 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/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/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/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/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/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/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/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/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/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/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/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/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/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/docs/superpowers/specs/2026-10-15-rust-migration-lot24-moteur-image-design.md b/docs/superpowers/specs/2026-10-15-rust-migration-lot24-moteur-image-design.md new file mode 100644 index 0000000..df19f28 --- /dev/null +++ b/docs/superpowers/specs/2026-10-15-rust-migration-lot24-moteur-image-design.md @@ -0,0 +1,130 @@ +# Lot 24 — panneau « moteur de l'image » tenu par la façade + +## Contexte + +Le panneau Moteur permet de mettre à jour llama.cpp sans reconstruire +l'image. Le moteur est pris dans l'image de conteneur officielle +(`ghcr.io/ggml-org/llama.cpp:server-cuda-b#####`), dont on ne télécharge que +les couches du sommet, celles qui portent `/app`. Trois fichiers du Go le +servent : + +- `web_engine.go` : statut, dernier build publié, mise à jour, bascule, + suppression ; +- `web_engine_reco.go` : version recommandée, risque de rendu du + raisonnement (défaut de b10763), retour à la version précédente, + vérification du rendu du gabarit avant/après la bascule ; +- `backend_engine_oci.go` : dialogue avec le registre, extraction des + couches, essai à blanc. + +La mise à jour est un job, le job des moteurs que la façade tient depuis le +lot 22. Le risque de rendu et la vérification du rendu reposent sur la sonde +de gabarit, portée au lot 23. + +## Le lot + +Avec `LOKI_ENGINE_IMAGE=rust` **et** `LOKI_BACKENDS=rust` (expérimental), la +façade (`rust/loki-rs/src/engineimg`) sert les routes suivantes : + +| Route | Rôle | +| --- | --- | +| `/api/engine` | statut : binaire, build, source, variante, versions installées, avis, encart « version recommandée », version précédente, relevé du rendu | +| `/api/engine/check` | dernier build publié pour la variante, comparé au courant | +| `/api/engine/update` | job : recherche, téléchargement des couches du sommet, essai à blanc, REASONING_PRESERVE=off si demandé, bascule, ménage, vérification du rendu | +| `/api/engine/use` | bascule vers une version installée ou vers le moteur de l'image | +| `/api/engine/remove` | suppression d'une version installée (refusée pour celle qui tourne) | +| `/api/engine/plan` | sur clic : version visée (dernière ou recommandée) et risque de rendu | +| `/api/engine/rollback` | retour au moteur d'avant la dernière bascule | + +Sans les backends, loki-rs ne passe pas la variable au Go : le panneau +reste au Go et le dit au démarrage. `/api/engine/cmdline` reste au Go. + +La sonde de gabarit est celle de la conversation quand la façade la tient +(`LOKI_CHAT=rust`) : le relevé du rendu rejoue alors la forme de ses vraies +requêtes. Sinon, la façade a sa propre sonde, qui sonde une requête nue. + +Pour REASONING_PRESERVE=off posée dans le preset actif, la façade réutilise +l'enregistrement du lot 15 (`save_applying`). Le service est arrêté puis +relancé comme au lot 22 : par la façade si elle pilote le moteur, sinon par +le Go (`/internal/v1/service/action`). + +Côté Go, `facade_engineimg.go` relaie à la façade les routes reçues par le +tunnel. + +## Parité + +`internal/loki/parity_engineimg_test.go` (Linux) tire du Go un journal +rejoué par `tests/engineimg.rs`. Il tourne contre : + +- un faux registre OCI : jeton, index multi-architecture, manifeste annoté, + couches .tar.gz ; +- un faux moteur local (le `fakeTpl` du Go, porté) pour la sonde et le rendu. + +Le journal couvre : + +- **le statut** : moteur de l'image, téléchargé ou compilé, avec la version + précédente (absente quand c'est le moteur courant), le relevé du rendu, + sans moteur d'image, sans aucun moteur ; +- **le dernier build** : publié, sans numéro, ou refusé (jeton refusé ou + illisible, manifeste refusé ou illisible, aucune image pour + l'architecture) ; +- **le plan** : + - dernière version (avec ou sans numéro) ou recommandée (build trop + ancien, tag figé absent, sans numéro), et déjà à jour ; + - chaque verdict du risque de rendu : moteur arrêté, gabarit vide, + REASONING_PRESERVE posée, EXTRA_ARGS, gabarit sans les variables, gabarit + qui garde la réflexion, build antérieur au défaut, preset externe ; +- **la mise à jour** : + - couches du sommet vers la base ; une couche du dessus masque celle du + dessous ; liens absolus ramenés dans `/app` ; marqueurs de suppression et + sous-dossiers ignorés ; couche sans fichier du moteur ; couche du runtime + ignorée ; phase du téléchargement ; + - essai à blanc ; + - risque de rendu rappelé dans le journal, établi ou impossible à vérifier + (moteur local arrêté) ; + - REASONING_PRESERVE=off : posée dans le preset actif ou dans la + configuration, déjà posée, ou inconnue du moteur ; + - ménage : versions triées par build, dossier sans moteur épargné, version + précédente gardée ; + - rendu comparé avant/après : changé, identique, ou impossible sur le + nouveau moteur ; + - déjà à jour, corps illisible ; +- **les échecs** : + - optimisation en cours, version inconnue, image sans serveur ou sans + version annoncée, manifeste sans couche, runtime au sommet, couche + introuvable, tag illisible ; + - moteur qui ne démarre pas (12 dernières lignes de sa sortie) ou qui ne + voit aucune carte ; +- **les bascules** : vers une version installée, vers l'image, vers une + version absente, pendant une optimisation ; retour, deux fois, et en GET ; +- **la suppression** : version (`../` neutralisé), version absente, moteur + utilisé ; +- **après chaque appel** : BIN, REASONING_PRESERVE, `engine/previous-bin` et + l'arborescence de `engine/`. + +Autres tests : + +- la façade vérifie le service arrêté puis relancé autour d'une mise à jour + et redémarré par une bascule (`service_arrete_puis_relance`), le relevé + « non vérifié à temps », le 503 sans backends, les variantes et les + vérifications périmées ; +- le Go (`TestFacadeMoteurImage`) vérifie que les sept routes partent à la + façade, et elles seulement. + +## Mutations + +74 mutations du port Rust, toutes tuées sauf trois, équivalentes : + +- la garde « moteur d'image absent » de la source : `same_path` ne + rapproche jamais un chemin vide ; +- l'essai à blanc qui relance l'inventaire des cartes quand l'ancien moteur + est le nouveau : même résultat ; +- une vérification du rendu périmée qui continue d'attendre son moteur : son + relevé est de toute façon écarté au rangement. + +## Écarts assumés + +- Les erreurs réseau (registre injoignable) gardent le texte de `reqwest`. +- Un manifeste dont un champ a le mauvais type ne donne pas le message du + Go : le nom Go du type anonyme n'est pas reproduit. +- La vérification du rendu attend son délai à la cadence choisie (5 s), sans + le contexte Go qui coupe aussi une requête en cours au bout de 20 min. diff --git a/docs/superpowers/specs/2026-10-15-rust-migration-lot25-strata-design.md b/docs/superpowers/specs/2026-10-15-rust-migration-lot25-strata-design.md new file mode 100644 index 0000000..e77ed51 --- /dev/null +++ b/docs/superpowers/specs/2026-10-15-rust-migration-lot25-strata-design.md @@ -0,0 +1,121 @@ +# Lot 25 — Strata tenu par la façade + +## Contexte + +Strata est le deuxième moteur de Loki. Il répartit les experts d'un MoE de +125 B (famille Qwen3.8-Flash-Next) entre VRAM, RAM et disque. Le moteur est +le paquet **figé** publié par AJEAN : les sources et le binaire Linux CUDA 12, +vérifiés par SHA-256. Un preset le choisit (`ENGINE=strata`). + +`backend_strata.go` sert trois routes : + +- `/api/strata` : la machine (cartes, RAM, disque, Python), ce qui y tient, + la recommandation par version et les modèles installés, avec leur + configuration de lancement en clair ; +- `/api/strata/settings` : les réglages d'un modèle installé. S'il est en + service, la configuration est réappliquée et le moteur relancé ; +- `/api/strata/install` : un job des moteurs. Il télécharge et vérifie le + paquet, lance l'installeur de Strata, écrit le preset et bascule dessus. + +Le lancement (`loki serve` d'un preset Strata) vit aussi dans ce fichier. + +## Le lot + +Avec `LOKI_STRATA=rust` **et** `LOKI_BACKENDS=rust` (expérimental), la façade +(`rust/loki-rs/src/strata`) sert les trois routes : + +- **la décision pure** (`strata/mod.rs`) : + - placement des experts : drop, ram, arena ou disk, selon la RAM + disponible, la carte d'appoint et `experts.bin` ; + - configuration de lancement : réglages validés, carte d'aide, mode des + experts, réglages du preset ; + - ce qui tient sur la machine, la recommandation, les réglages, les noms ; +- **la machine** (`strata/api.rs`) : + - cartes via `nvidia-smi` (la sonde des backends) ; + - Python 3.10+ et son module venv ; + - RAM totale et disponible (`/proc/meminfo`) ; + - RAM du moteur Strata en marche (arbre du service dans `/proc`) ; + - place libre ; +- **l'installation** : le job des moteurs. La sortie de l'installeur est + découpée en lignes comme le `sinkWriter` du Go : une barre `\r` n'est + montrée que toutes les 10 s. La bascule finale passe par le Go + (`/internal/v1/preset/apply`, mode `switch`), qui applique le preset et + relance le service. + +Sans les backends, loki-rs ne passe pas la variable au Go et Strata reste au +Go. Le lancement reste au Go (`loki serve`, que `loki-rs engine serve` lui +délègue pour un preset Strata). + +Côté Go, `facade_strata.go` relaie à la façade les routes reçues par le +tunnel. + +## Correction du Go + +`/api/strata/settings` décidait si le preset était en service **après** y +avoir écrit les nouveaux réglages. L'empreinte du nouveau contenu ne +correspondait alors plus à la configuration active : changer un réglage du +modèle en service ne l'appliquait jamais, et le moteur n'était pas relancé. +« En service » se décide désormais avant l'écriture, comme dans +`SavePresetApplying`. Le port Rust suit. + +## Parité + +`internal/loki/parity_strata_test.go` (Linux x86_64) tire du Go un journal +rejoué par `tests/strata.rs`. Pour le tirer, le Go reçoit des crochets de +test : `strataReleaseBase` (variable), `meminfoPath`, `strataDiskFree` et +`serviceActionOSFn`. Le journal couvre : + +- **la machine** : + - sans carte, nvidia-smi en panne ou vide, carte trop ancienne ou trop + petite, carte d'aide trop ancienne ; + - trois cartes de même VRAM : la plus récente devant (capacité de calcul + arrondie au dixième) ; + - Python absent, en panne, illisible, trop ancien, sans venv ; + - 16, 47 ou 64 Go de RAM, meminfo sans MemAvailable ; + - disque presque plein ou juste trop petit (marge de 5 Go), données déjà + présentes ; +- **les réglages** : + - GET, corps illisible, preset vide ou avec chemin, preset absent ou + llama.cpp ; + - hors limites, experts inconnus ; + - preset inactif ; preset actif (appliqué et relancé), et pendant une + optimisation ; +- **l'installation** : + - GET, corps illisible, quant inconnu, optimisation en cours ; + - machine sans carte, modèle qui ne tient pas ; + - release introuvable, moteur altéré (empreinte) ; + - installation complète, puis réinstallation (sources déjà là et + décompressées, moteur téléchargé périmé retéléchargé) ; + - installeur en échec, installeur sans configuration ; +- **les modèles installés** : + - détails de lancement du modèle actif et de modèles inactifs (experts + sur disque ou en RAM, vision coupée, sans carte d'aide) ; + - mode du dernier lancement ; + - `experts.bin` avec le moteur du paquet ou d'un autre paquet ; + - RAM juste sous ou au-dessus du seuil du mode hors RAM, mode hors RAM + coupé dans le preset ; + - configuration de lancement disparue ou illisible ; +- **après chaque appel** : les actions du service, les clés de la + configuration et l'arborescence de `presets/` et `strata/` (sans le + contenu des archives). + +Autres tests : + +- les tests du Go (`backend_strata_test.go`), portés (`strata/tests.rs`) : + configuration de lancement, placement des experts, recommandation, + réglages, noms ; +- la RAM du moteur en marche, le découpage en lignes et les nombres écrits + à la Go ; +- le Go (`TestFacadeStrata`) : les trois routes partent à la façade, et + elles seules. + +## Mutations + +71 mutations du port Rust, toutes tuées (les dix survivantes du premier +passage l'ont été par les étapes et les tests ajoutés ci-dessus). + +## Écarts assumés + +- Les erreurs réseau (release injoignable) gardent le texte de `reqwest`. +- Une copie du moteur qui échoue (`prebuilt/`) ne donne pas exactement le + message du Go, qui ouvre la source puis la destination. diff --git a/docs/superpowers/specs/2026-10-15-rust-migration-lot26-taches-design.md b/docs/superpowers/specs/2026-10-15-rust-migration-lot26-taches-design.md new file mode 100644 index 0000000..3576a04 --- /dev/null +++ b/docs/superpowers/specs/2026-10-15-rust-migration-lot26-taches-design.md @@ -0,0 +1,193 @@ +# Lot 26 — Tâches planifiées tenues par la façade + +## Contexte + +Une tâche planifiée tourne seule, sans utilisateur en face. Il en existe +deux sortes : + +- **une tâche IA** : une consigne, exécutée par l'agent dans un fil + éphémère ; +- **une tâche « script seul »** : un script du dossier protégé + (`$LOKI_HOME/scripts`), lancé sans modèle. Sa sortie devient le + compte-rendu. + +Le Go les tient dans plusieurs fichiers : + +- `tasks.go` : la base (`bkTasks`) et l'interrupteur maître ; +- `tasks_cron.go` : les fréquences ; +- `tasks_sched.go` : le planificateur, un tic par minute et une tâche due + par tic ; +- `tasks_run.go` et `tasks_script.go` : l'exécution ; +- `web_tasks.go` : sept routes. + +Les fréquences prennent quatre formes : + +- `@every 2h` : intervalle glissant d'au moins une minute ; +- `@every 3d@9:30` : tous les N jours, à une heure murale ; +- `@once 2026-10-01 16:54` : une seule fois ; +- une expression cron à cinq champs. + +L'heure murale est celle du fuseau de la tâche. Sans fuseau, ou avec un +fuseau inconnu, c'est l'heure locale du serveur. + +## Le lot + +Avec `LOKI_TASKS=rust` (expérimental), la façade (`rust/loki-rs/src/tasks`) +sert les routes `/api/tasks*` et fait tourner le planificateur, que le Go ne +démarre plus. Elle exécute elle-même les tâches script : + +- le shell de l'agent (`run_shell`), dans le dossier de la discussion + ouverte ; +- une limite de 10 min ; +- un arrêt par `/api/tasks/stop`. + +Une tâche IA passe par le verrou de génération et les outils de l'agent, qui +restent au Go. La façade la lui fait exécuter par `/internal/v1/tasks/run`, +que le Go traite comme son propre planificateur l'aurait fait : + +- preset épinglé ; +- projet forcé ; +- `RunAutonomous` ; +- état persisté ; +- notification. + +Moteur occupé ou modèle pas prêt : la tâche ne tourne pas, son état ne bouge +pas, et le tic suivant la réessaie. Le Go rend la main à la fin du passage. + +Les notifications des tâches script partent par la façade si elle tient les +notifications, sinon par le Go (`/internal/v1/push/send`). + +Les fréquences demandent l'heure murale d'un fuseau IANA. `src/tz.rs` porte +ce que le Go fait de la zoneinfo du système : + +- lecture des TZif v1 et v2, et de la règle POSIX qui prolonge les + transitions ; +- `time.Local` (`$TZ`, `/etc/localtime`) ; +- `time.Date`, avec la normalisation des débordements ; +- les heures qui sautent ou qui se répètent, résolues comme le Go. + +`tasks/cron.rs` porte `time.ParseDuration` du Go, messages d'erreur compris. + +Côté Go : + +- `facade_tasks.go` : le relais des routes reçues par le tunnel et les deux + routes internes ; +- `web_server.go` : le planificateur n'est pas démarré ; +- `tasksNow` : l'heure des tâches, remplaçable en test. + +## Correction de la façade + +Les appels de la façade à l'API interne du Go étaient tous bornés à 10 s. Un +outil exécuté par le Go (`/internal/v1/tool/exec`) qui dure plus longtemps +était coupé : + +- une page web lente ; +- le navigateur ; +- un serveur MCP tenu par le Go. + +Le résultat partait en erreur alors que l'outil continuait. Ces appels sont +désormais bornés à 30 min. Une tâche IA exécutée par le Go est attendue sans +autre limite que 24 h. + +## Ce que les mutations ont corrigé dans le port + +Le premier passage des mutations a laissé survivre des cas que le journal ne +voyait pas. Confrontés au Go, plusieurs étaient de vrais écarts : + +- **dates lointaines** : un instant en nanosecondes sur 64 bits s'arrête en + 2262. Le Go accepte `@once 2400-02-29 10:00`, ou un intervalle en jours + qui mène au-delà. Les instants des fréquences passent sur 128 bits ; +- **intervalle en jours démesuré** : `@every 200000d` déborde dans le Go + (`time.Duration`), qui rend une date passée. Le port déborde de même ; +- **l'heure qui saute** : `@every 1d@02:30` à Paris, le jour du passage à + l'heure d'été, tombe à 3 h 30. Le Go avance ensuite de jour en jour depuis + ce candidat (`AddDate`) et reste à 3 h 30. Le port repartait de 2 h 30 ; +- **compte-rendu tronqué** : le Go coupe à 4000 octets, au milieu d'un + caractère s'il le faut, et chaque octet orphelin devient U+FFFD à + l'encodage JSON. Le port reculait à une frontière de caractère ; +- **nom de fuseau** : le Go refuse tout nom qui contient `..`, pas seulement + un composant `..`. + +## Parité + +`internal/loki/parity_tasks_test.go` (Linux) tire du Go un journal rejoué +par `tests/tasks.rs`. L'heure des tâches est fixée pas à pas ; les +identifiants tirés au hasard deviennent `{{ID1}}`, `{{ID2}}`… dans l'ordre où +ils apparaissent. Le journal couvre : + +- **l'écran** : + - scripts du dossier (sous-dossiers compris) ; + - mode agent, interrupteur maître (rangé à « 0 » : pas suspendu) ; + - projets, mémoire coupée, internet armé ; + - la tâche en cours pendant un script ; +- **l'enregistrement** : + - corps illisible, sans nom, sans consigne ; + - script absent, dossier, remontée, chemin absolu, séparateur Windows, + caractère interdit ; + - chaque erreur de fréquence : vide, intervalle trop court, illisible ou + sans unité, jours ou heure invalides, cron à 4 champs, hors bornes, en + lettres, plage inversée, pas nul, jamais satisfait, date illisible ou + déjà passée ; + - chaque forme de fréquence, dans plusieurs fuseaux : Paris (dont l'heure + qui saute), New York, Calcutta, fuseau inconnu ; + - id inconnu gardé, projet inconnu remplacé par l'actif ; + - modification avec ou sans changement de fréquence ; +- **la bascule, l'interrupteur maître, la suppression** ; +- **les lancements** : + - tâche absente ; + - tâche IA avec moteur occupé (409) ou modèle pas prêt (503) ; + - script en succès, en échec, arrêté à la main ; + - script qui échoue après un succès, puis se rétablit ; + - tâche script rangée sans script, au nom refusé, au script disparu ; +- **le planificateur** : + - tic suspendu, tic pendant une optimisation ; + - une tâche IA due qui attend le modèle ; + - une tâche due par tic, dans l'ordre des noms ; + - la tâche « une fois » qui s'éteint ; + - une tâche sans prochain passage (posé sans la lancer) ; + - une fréquence devenue invalide ; + - deux tâches de même nom (rangées par id). + +Après chaque étape, le journal enregistre les tâches en base. + +Autres tests : + +- fuseaux et fréquences confrontés au Go : + - Paris aux deux changements d'heure, New York, Calcutta ; + - avant la première transition, pile à une transition, la table avant la + règle prolongée ; + - `$TZ` (vide, « : », chemin, nom), `/etc/localtime` ; + - noms refusés, « UTC » sans base ; + - mois hors bornes, années bissextiles, mois de 30 jours ; + - `ParseDuration` et ses débordements, cron (plages, pas, bornes, à 78 et + 364 jours) ; +- le compte-rendu tronqué, les noms de script ; +- l'amorçage au démarrage (tâches actives sans prochain passage) ; +- la durée d'un passage, à une horloge qui avance ; +- la tâche IA en cours vue par l'écran ; +- la tâche IA lancée à la main part au Go (`tache_ia_executee_par_le_go`) ; +- le planificateur tourne à son pas sans demande ; +- sans `LOKI_TASKS`, les routes partent au Go ; +- le Go (`TestFacadeTaches`) : + - les sept routes partent à la façade, et elles seules ; + - `/internal/v1/tasks/run` sur une tâche IA sans modèle (occupée, rien + d'enregistré) et sur une tâche script ; + - `/internal/v1/push/send`. + +## Mutations + +79 mutations du port Rust, dont 77 tuées. Les survivantes du premier passage +l'ont été par les tests et les étapes ajoutés ci-dessus. Restent deux +équivalentes : + +- le refus d'un nom de script absolu **après** nettoyage : sous Unix, un nom + qui ne commence pas par `/` ne le devient pas (le contrôle du Go vaut pour + Windows) ; +- `web_on` sans `internet_enabled` : la joignabilité refuse déjà un moteur + Crawl4AI sans adresse, seul cas où les deux diffèrent. + +## Écarts assumés + +- Pendant une tâche IA, le Go force le projet de la tâche. Une tâche + enregistrée sans projet à ce moment-là prend, côté façade, le projet actif + rangé. diff --git a/docs/superpowers/specs/2026-10-15-rust-migration-lot27-banc-d-essai-design.md b/docs/superpowers/specs/2026-10-15-rust-migration-lot27-banc-d-essai-design.md new file mode 100644 index 0000000..9e29e94 --- /dev/null +++ b/docs/superpowers/specs/2026-10-15-rust-migration-lot27-banc-d-essai-design.md @@ -0,0 +1,151 @@ +# Lot 27 — Banc d'essai tenu par la façade + +## Contexte + +Le banc d'essai mesure le moteur local tel que le chat s'en sert, sur +`/v1/chat/completions`, avec le gabarit, le raisonnement et +l'échantillonnage du preset (`llm_bench.go`, `llm_bench_job.go`). Il a deux +modes : + +- **rapide** : un préchauffage jeté, puis la ligne courte (~2000 jetons de + prompt, ~300 générés) ; +- **complet** : la ligne courte, puis une discussion à profondeur réelle — + un prefill à froid de D jetons, et trois tours qui y ajoutent chacun ~2048 + jetons neufs. La reprise du cache est lue dans la réponse, jamais + supposée. + +Il tourne en tâche de fond derrière quatre routes : + +- `POST /api/bench` lance (202 et un id) ; +- `GET /api/bench/status` dit la phase, puis le résultat ou l'erreur ; +- `POST /api/bench/cancel` annule ; +- `GET /api/bench/last` rend la dernière mesure enregistrée. + +Pendant la mesure, le bench tient le verrou de génération de la +conversation. Un message, une tâche planifiée ou un client `/v1` reçoivent +le refus du bench. Seule une mesure complète est enregistrée : la +dernière, et celle du preset actif. + +## Le lot + +Avec `LOKI_BENCH=rust` (expérimental), la façade (`rust/loki-rs/src/bench`) +sert les quatre routes et déroule le protocole contre le moteur : + +- `bench/mod.rs` : le protocole, porté du Go à l'octet près. On y trouve : + - les mêmes corpus : la prose du Go et l'interface web embarquée, de la + même source ; + - les mêmes requêtes ; + - le même calcul de profondeur, de reprise du cache et de débit ; + - les mêmes messages d'erreur ; +- `bench/job.rs` : le job, les routes et l'enregistrement (`last_bench`, + `bench_presets`, avec l'empreinte de configuration du Go). + +Le verrou de génération reste celui de la conversation du Go. La façade le +prend par le **bail du moteur**, avec le motif `bench` : + +- le Go note alors `benching` et donne aux autres le refus du benchmark ; +- changer de discussion ne rompt pas le bail ; +- `benchRunning()` le voit aussi : les clients `/v1`, le préchauffage et + l'optimiseur attendent ; +- un stop du chat côté Go (signalé au renouvellement), un Reset ou un bail + perdu annulent le bench. + +Le Go prête aussi à la façade : + +- **le travail annexe** (`/internal/v1/engine/sidejob`) : à la fin, le slot + du bench est effacé si c'est sans risque, comme pour un bench du Go ; +- **la télémétrie** (`/internal/v1/bench/perf`) : la trace du bench dans + `perf_log`. + +Le port, la clé de l'API locale et le second slot (`SIDE_SLOT`) viennent de +`/internal/v1/oai/route`. Les niveaux de raisonnement appris sont ceux de la +conversation de la façade si elle la tient, sinon ceux du bench. + +La ligne de commande (`loki bench`) reste au Go. + +## Correction des tests du Go + +Trois tests du préchauffage échouaient dix minutes par jour, avant minuit : +le préchauffage se tait alors (`prewarmNearMidnight`), et ils lisaient +l'heure réelle. L'heure de ce garde-fou devient remplaçable (`prewarmNow`), +et les tests la fixent à midi. + +## Parité + +`internal/loki/parity_bench_test.go` (Linux) tire du Go un journal rejoué +par `tests/bench.rs` contre le même faux moteur (`fakeBenchEngine`, porté). +Chaque requête reçue par le moteur est comparée ; les longs textes (corpus) +sont réduits à leur taille et à leur empreinte. Le journal couvre : + +- **les routes** : statut et dernière mesure sans bench, GET refusés, + annulation sans bench ; +- **les refus au lancement** : preset externe, optimisation en cours, modèle + pas prêt, génération en cours, second lancement pendant un bench ; +- **les échecs dans le job** : moteur occupé par un autre (`/slots`), preset + Strata ; +- **les mesures** : + - mode rapide, puis mode complet (profondeur, trois tours, reprise du + cache, brouillon) ; + - contexte trop petit (profondeur sautée), contexte inconnu du moteur + (celui du preset) ; + - paramètres bornés, petits paramètres, corps illisible ; +- **les échecs d'une mesure** : + - préchauffage, ligne courte, prefill à froid ou tour en profondeur en + erreur ; + - réponse sans timings ; +- **le raisonnement** : niveau refusé par le gabarit puis rejoué, puis le + niveau appris envoyé d'emblée ; +- **budget et annulation** : budget d'une requête dépassé (profondeur + partielle, non enregistrée), annulation pendant la profondeur ; +- **après chaque étape** : la dernière mesure et les mesures par preset. + +Autres tests : + +- ceux du Go (`llm_bench_test.go`), portés (`bench/tests.rs`) : profondeur, + types de cache, requête, morceaux du corpus, reprise du cache, relectures + du disque, empreinte de l'enregistrement ; +- la réponse du moteur (timings, cache, brouillon, nombres illisibles), + `Duration.String` du Go, les flottants écrits à la Go ; +- la façade : le bail « bench », le travail annexe (slot servi ou non), la + trace, la clé de l'API locale, le second slot, le stop du chat et le bail + perdu, les routes sans la façade ; +- le Go (`TestFacadeBench`) : + - les quatre routes partent à la façade, et elles seules ; + - le bail « bench » : refus, bascule de discussion, libération, + expiration ; + - le travail annexe et la trace. + +## Mutations + +84 mutations du port Rust, dont 81 tuées. Les survivantes du premier passage +l'ont été par les tests ajoutés : + +- `bench/tests.rs` : la tranche pile à la fin du corpus, un tour sans + attendu à côté d'un tour mesuré, les débits nuls rendus par le moteur + (recalculés), le niveau de raisonnement rejoué une seule fois, le + préchauffage et la ligne courte d'un `run` rapide (paramètres par défaut, + 64 jetons de préchauffage au moins, code ajouté juste au-delà de la + prose), la profondeur arrêtée (contexte plein, corpus épuisé, réponse du + tour précédent reprise, note de reprise selon le modèle hybride) ; +- `tests/bench.rs` : un second lancement pendant la prise du bail, le preset + relu au départ du job (devenu externe, ou optimiseur lancé entre-temps), + la mesure enregistrée sous le preset ACTIF ; +- le journal : un prompt juste au-delà de la prose. + +Restent trois équivalentes : + +- l'annulation vérifiée avant d'envoyer : déjà annulé, le `select!` rend + l'annulation à son premier tour, avant que la requête ne parte ; +- la progression d'un job remplacé : un seul bench à la fois, un job fini ne + rapporte plus rien ; +- le PID nul du moteur : `/proc/0` n'existe pas, la lecture échoue de toute + façon. + +## Écarts assumés + +- Une erreur réseau vers le moteur garde le texte de `reqwest` après le + préfixe du Go (`Post "url": …`). +- Un niveau de raisonnement appris par le bench de la façade n'est pas + transmis au chat du Go, quand le Go tient la conversation (et + inversement) : la première requête paie le refus, puis la traduction est + retenue. 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-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/docs/superpowers/specs/2026-10-16-rust-migration-lot28-optimiseur-design.md b/docs/superpowers/specs/2026-10-16-rust-migration-lot28-optimiseur-design.md new file mode 100644 index 0000000..057f3b6 --- /dev/null +++ b/docs/superpowers/specs/2026-10-16-rust-migration-lot28-optimiseur-design.md @@ -0,0 +1,263 @@ +# Lot 28 — Optimiseur tenu par la façade + +## Contexte + +L'optimiseur sans perte (`backend_tune*.go`) cherche des réglages du moteur +plus rapides **sans rien changer à ce que le modèle calcule** : même +contexte, mêmes slots, mêmes types de cache, même échantillonnage. Il +procède ainsi : + +1. Il arrête le vrai moteur. +2. Il lance chaque essai sur une COPIE de la configuration : + - posée dans `LOKI_HOME/tune/run//config.env`, jamais + `config.env` ; + - composée par « loki serve » à blanc ; + - puis lancée sur 127.0.0.1 et un port libre. +3. Il mesure chaque essai par le bench complet, à une profondeur fixe. +4. Il garde ce qui gagne au-delà du bruit. +5. Il relance le vrai moteur. + +Rien n'est écrit dans un preset sans un geste de l'utilisateur : dans une +copie, ou dans le preset lui-même — moteur redémarré, sondé, et ancienne +version rétablie si la sonde échoue. + +Cinq routes : + +- `POST /api/tune` lance (202) ; +- `GET /api/tune/status` dit la phase, les essais, l'ETA, puis le résultat ; +- `POST /api/tune/cancel` annule ; +- `POST /api/tune/apply` écrit le résultat, sur un clic (`copy` ou + `preset`) ; +- `GET /api/tune/last` rend le dernier résultat d'un preset, et si le preset + ou le moteur ont changé depuis. + +Le moteur, pendant une optimisation, appartient aux essais. Le verrou est un +**fichier** de `LOKI_HOME` (`tune.lock`), créé de façon exclusive, que tout +le monde lit : + +- le Go, dans tous ses refus (`tuneGuard`) ; +- « loki tune » en ligne de commande ; +- désormais la façade. + +## Le lot + +Avec `LOKI_TUNE=rust` (expérimental, Unix seulement), la façade +(`rust/loki-rs/src/tune`) sert les cinq routes et déroule l'optimisation : + +- `tune/core.rs` : la partie pure, portée à l'octet près : + - découpe et réécriture d'`EXTRA_ARGS` ; + - réglages par clé ou par drapeau ; + - essais de chaque étape selon la machine et les cases cochées ; + - garde-fous : cœur de la ligne, dénature, clés permises ; + - diff et réécriture du preset ; + - journal d'un essai ; + - score et départage ; +- `tune/lock.rs` : le verrou, **au format du Go** : + - contenu du fichier, clé de prise, binaires comparés par leur nom ; + - identité d'un processus comme `procIdentity` l'écrit : champ 22 de + `/proc//stat`, préfixé de l'identifiant du démarrage ; `ps` ailleurs ; + - écriture d'un bloc ; + - rendu neutre avant retrait ; + - ménage par déplacement-relecture ; +- `tune/job.rs` : les routes, le job, les essais et l'application. + +**La reprise reste au Go.** Sa reprise (`tuneRecoverWatch`, toutes les +30 s) lit le verrou de la façade et la reconnaît vivante, par la même +identité. Si la façade meurt en plein essai, le Go écarte son verrou comme +celui d'un « loki tune » tué : + +- l'essai orphelin est arrêté (identité complète vérifiée) ; +- une application interrompue est défaite ; +- le moteur est relancé s'il tournait. + +La façade fait le même ménage quand elle trouve un verrou périmé en prenant +le sien. + +**Les essais restent des « loki serve » du Go**, lancés par la façade avec +`LOKI_TUNE_TRIAL` (et `LOKI_TUNE_DRYRUN` à blanc), dans leur propre groupe de +processus. Le groupe est arrêté en sortie, quoi qu'il arrive. L'essai est +noté dans le verrou avant de charger quoi que ce soit. + +Le verrou de génération reste celui de la conversation du Go. La façade le +prend par le **bail du moteur**, avec le motif `tune` : + +- le Go note alors `benching` et donne aux autres le refus de l'optimiseur ; +- changer de discussion ne rompt pas le bail ; +- un stop du chat, un Reset ou un bail perdu annulent l'optimisation. + +Le Go prête à la façade ce qu'il est seul à voir +(`/internal/v1/tune/context`) : + +- **le refus du processus web** : un benchmark (le sien ou celui de la + façade), une génération, des jobs d'arrière-plan du mode code ; +- **l'état du vrai moteur** (`engineNeedsStop`) ; +- **la télémétrie**, d'où viennent les poids d'un tour type. + +Il prête aussi le démarrage et l'arrêt du moteur **sans le refus de +l'optimiseur** (`/internal/v1/service/action`, `os`), puisque c'est la +façade qui tient le verrou. Si la façade pilote elle-même le moteur +(`LOKI_ENGINE=rust`), elle passe par son superviseur. + +Le preset est réécrit et appliqué par les fonctions des presets +(`save_applying`, mode `apply` : la configuration, sans redémarrage). Le +redémarrage, la sonde et le retour arrière sont ceux de l'optimiseur. + +La ligne de commande (`loki tune`) reste au Go. + +Hors Unix, la façade ne tient pas l'optimiseur. Elle passe alors +`LOKI_TUNE` vide au Go, qui le garde et ne relaie rien. + +## Parité + +`internal/loki/parity_tune_test.go` tire du Go un journal +(`tests/parity_tune/core.json`) rejoué par `tests/tune_core.rs`. Il couvre : + +- toute la partie pure ; +- le **verrou** : contenu du fichier octet pour octet, clé de prise, + comparaison des binaires ; +- le **résultat** tel qu'enregistré et montré (listes vides écrites `null` + comme une tranche nil du Go) ; +- la vérification **après application** (la configuration est-elle l'essai + retenu ?). + +`tests/tune.rs` déroule de vraies optimisations, essais compris : le binaire +de test joue « loki serve ». + +- À blanc, il écrit la ligne de commande de l'essai. +- Pour de vrai, il sert un faux llama-server dont le prefill est d'autant + plus rapide que le micro-lot est petit. + +Les tests couvrent : + +- **une optimisation entière** : + - bail « tune » et verrou au nom de la façade ; + - vrai moteur arrêté puis relancé sans le refus de l'optimiseur, dictée + coupée ; + - quatre essais composés, lancés, mesurés, l'un retenu ; + - copies de configuration sur une adresse privée ; + - résultat enregistré par preset et rendu par `/api/tune/last` ; +- **l'application** : + - dans une copie (rien d'autre ne bouge) ; + - dans le preset (réécrit, appliqué, moteur redémarré et sondé) ; + - refusée sur un preset changé depuis la mesure ; +- **une sonde qui échoue** (contexte perdu) : preset et configuration + rétablis, moteur redémarré dessus, sauvegarde faite avant d'écrire ; +- **les essais refusés** : à la composition, au chargement (mémoire + insuffisante) ; la référence qui ne se compose pas ; la relance qui + échoue, dite dans l'erreur ; +- **l'annulation**, par la route et par le stop du chat ; +- **les refus d'entrée**, avec les messages du Go : + - preset externe, Strata, aucun preset actif ; + - jobs du mode code, moteur occupé, génération en cours ; + - optimisation d'un autre processus (et `elsewhere` dans le statut) ; +- **un verrou périmé** : + - l'essai orphelin arrêté seulement si son identité concorde ; + - une application interrompue défaite ; +- les routes sans la façade. + +Les tests du verrou du Go sont portés (`tune/tests.rs`) : + +- prise, refus, essai noté, rendu ; +- propriétaire mort ; +- verrou à notre PID qui n'est pas l'un des nôtres ; +- illisible neuf ou vieux ; +- ménage jamais sur un verrou vivant ; +- binaire remplacé ; +- identité d'un processus ; +- arrêt du groupe. + +Côté Go, `TestFacadeTune` vérifie : + +- les cinq routes partent à la façade, et elles seules ; +- le bail « tune » : refus, bascule de discussion, libération ; +- le contexte : au repos, pendant l'optimisation, pendant un bench ; +- le démarrage et l'arrêt sans le refus de l'optimiseur. + +## Mutations + +105 mutations du port Rust (verrou et job). Le premier passage en a tué 39. +Les survivantes l'ont été ensuite par les tests ajoutés : + +- `tune/tests.rs` : + - un verrou sans PID ; + - l'identité d'un processus (démarrage noté, binaire admis) ; + - l'application planifiée : clé sur option refusée sans l'option, valeur + vide qui retire la clé ; +- `tune/job.rs` : l'ETA du suivi, l'arrondi à la minute, le numéro de build ; +- `tests/tune.rs` : + - **une optimisation entière** : + - l'essai noté dans le verrou pendant la mesure ; + - l'application refusée pendant la mesure ; + - la progression, les confirmations, le gain ; + - aucun changement de moteur signalé quand son build est inconnu ; + - la cible inconnue ; + - **l'application gardée** : + - preset qui n'est plus celui en service, API externe ; + - clé changée ailleurs pendant l'application (défaite) ; + - sauvegarde notée dans le verrou avant d'écrire ; + - **les essais** : + - écartés (ligne dénaturée, doublon, contexte perdu) ; + - muets (délai de chargement) ; + - **la mesure** : + - VRAM mesurée par un faux nvidia-smi ; + - budget atteint, profondeur commune ; + - notes et poids tirés de la télémétrie ; + - écarts de mesure (sous le seuil, nul, decode plus lent) ; + - **le moteur** : + - `/props` sans `default_generation_settings` ; + - SIDE_SLOT sur un seul slot ; + - threads comptés sur les cœurs de la sonde ; + - piloté par la façade (LOKI_ENGINE=rust) ; + - **les refus** : + - un second lancement pendant la prise du bail ; + - une application refusée sans journal ; + - hors phase d'application, un verrou périmé n'est pas défait. + +Une mutation du premier passage ne changeait rien (`plan_apply(…).and(Ok(()))` +garde l'erreur) : elle est remplacée par une qui retire vraiment le contrôle, +et tuée. + +Restent vingt équivalentes, ou hors de portée d'un test : + +- **le verrou** : + - le fichier retiré quand son écriture échoue après sa création (disque + plein), que les tests ne provoquent pas ; + - deux re-vérifications du ménage, contre un propriétaire qui revient + entre deux lectures : courses seulement ; + - la réécriture neutre (essai, moteur actif, sauvegarde effacés) avant le + retrait du verrou : sous Unix le retrait suit aussitôt ; elle ne compte + que si le retrait échoue ; +- **le suivi** : + - le résultat montré hors mesure : il est effacé au départ d'une mesure ; + - l'annulation n'est posée que pendant une mesure ; + - la borne de 6 h du budget, la durée d'un doublon, les étapes restantes : + l'estimation seulement ; + - l'annulation vérifiée entre deux essais et à la fin : l'essai en cours + l'a déjà dite (course) ; +- **la mesure** : + - VRAM nulle et nvidia-smi absent vont ensemble ; + - les candidats générés passent toujours `cfg_check` (garde contre une + génération fautive) ; + - la fin d'un essai constatée avant d'attendre : un réveil perdu, quand + l'essai meurt entre deux attentes (course) ; + - l'essai effacé du verrou après son arrêt : le suivant le remplace, ou le + verrou est rendu ; laissé, un Loki redémarré vérifie son identité avant + tout arrêt ; +- **l'application** : + - une valeur vide gardée dans la configuration gagnante : `cfg_check` et la + réécriture la traitent en clé absente ; + - la configuration active comparée à celle de la mesure : les empreintes + du preset et de la configuration sont prises ensemble, sur le preset dont + l'empreinte est celle de la configuration ; les deux contrôles qui + précèdent (preset inchangé, preset en service) l'impliquent ; + - le preset « non appliqué » : la configuration a changé entre le contrôle + et l'écriture (course) ; le contrôle suivant défait aussi, avec un autre + message. + +## Écarts assumés + +- Une sortie à blanc qui échoue est lue dans un fichier où vont les deux + sorties (comme `CombinedOutput`), mais une erreur de lecture de + `launch.json` ou d'une requête garde le texte de Rust. +- Un niveau de raisonnement appris pendant les essais n'est pas transmis au + chat du Go quand le Go tient la conversation. diff --git a/docs/superpowers/specs/2026-10-16-rust-migration-lot29-telemetrie-design.md b/docs/superpowers/specs/2026-10-16-rust-migration-lot29-telemetrie-design.md new file mode 100644 index 0000000..c9c56c9 --- /dev/null +++ b/docs/superpowers/specs/2026-10-16-rust-migration-lot29-telemetrie-design.md @@ -0,0 +1,82 @@ +# Lot 29 — Télémétrie des complétions de la façade + +## Contexte + +Le Go range chaque complétion dans un anneau en mémoire (`perf_log.go`). +Pour chacune, il note : + +- combien du prompt venait du cache, combien a été recalculé ; +- à quelle vitesse ; +- ce que le décodage spéculatif a gardé. + +Il expose des agrégats sur `/api/perf/summary`. Il calcule aussi la **perte +de cache** d'une complétion : ce que le cache aurait dû reprendre de la +précédente complétion de la même discussion, et n'a pas repris. Il nomme ce +qui s'est intercalé entre les deux, et signale la perte à l'interface +au-delà d'un seuil tiré du preset. + +Quand la façade tient la conversation (`LOKI_CHAT=rust`), ses complétions +n'apparaissaient nulle part. Le résumé des performances ignorait les tours ; +l'optimiseur tirait ses poids d'une télémétrie vide ; l'interface ne +recevait jamais la perte de cache. + +## Le lot + +Les complétions de la façade rejoignent l'anneau du Go, par un seul point +d'entrée, `/internal/v1/turn/perf`. Il est arrivé sur `main` en même temps que +ce lot (« Conversation tenue par Rust : télémétrie et requêtes en vol comme +le Go »), et la fusion n'en garde qu'un. + +- `TurnHost::perf_record` range une complétion. La boucle du tour l'appelle : + - après chaque complétion lue, complète ou non, numérotée dans le tour + (`perfIter`) ; + - avec les empreintes seulement si elle est complète (`perf_prefix`, + FNV-1a cumulé des messages envoyés) ; + - pour une complétion coupée par un stop, rangée incomplète. + + La dernière statistique d'une complétion complète porte ce que le Go en + dit : la nature, la perte de cache, ce qui s'est intercalé et l'alerte. +- La nature, posée par l'hôte : + - `main` pour un tour et pour les corrections du builder du mode Code ; + - `verify` pour la passe de vérification isolée ; + - `compact` pour un résumé de compaction, rangé dès que sa réponse se lit, + sans empreintes. +- `chat/perf.rs` porte ce que la façade relève elle-même : la réponse entière + d'un résumé (`perfRecFromWire`, sans échec possible, avec les mêmes + tolérances que `perfNum`), traduite en statistiques d'une complétion. +- Le Go range le relevé comme les siens (`perfRecFromStats`) : numéro, + instant, perte de cache, ce qui s'est intercalé. Il calcule l'alerte au + seuil du preset actif (`perfLostAlertAt`), jamais pour un preset externe. +- La sonde du gabarit n'en part pas : la façade la tient (lot 23) et la lance + elle-même après une complétion complète de forme normale. Le Go lui demande + son verdict (`facadeTplCaps`). + +`/api/perf/summary` reste au Go, qui tient l'anneau. + +## Tests + +- `chat/perf.rs` : + - relevé d'une réponse entière : nombres en chaîne, valeurs hors bornes, + octet parasite, cache d'OpenAI, timings d'un autre type ; + - empreintes cumulées, FNV-1a de référence. +- La boucle du tour : + - deux complétions numérotées, totaux, empreintes qui se prolongent ; + - dernière statistique avec la nature et la perte vues par le Go ; + - complétion coupée rangée incomplète, sans statistique finale. +- Le vrai hôte (`tests/lot6.rs`) : + - complétions d'un tour rangées par le Go, rattachées à la discussion ; + - résumés de compaction (rangés si la réponse se lit, pas sinon). +- Le Go (`web_internal_turnperf_test.go`) : + - complétion rangée dans l'anneau ; + - perte et ce qui s'est intercalé, alerte ; + - preset externe sans alerte, complétion incomplète sans empreintes ; + - nature d'un résumé gardée, un tour par défaut ; + - corps illisible, GET refusé. + +## Écarts assumés + +- Les empreintes sont calculées sur le JSON des messages tel que Rust + l'écrit (sans l'échappement HTML du Go). Elles ne se comparent qu'entre + complétions de la même discussion et de la même nature, toutes produites + par la façade. +- Une complétion coupée par un stop est rangée sans ses compteurs partiels. diff --git a/docs/superpowers/specs/2026-10-16-rust-migration-lot30-motifs-de-relance-design.md b/docs/superpowers/specs/2026-10-16-rust-migration-lot30-motifs-de-relance-design.md new file mode 100644 index 0000000..b7c942c --- /dev/null +++ b/docs/superpowers/specs/2026-10-16-rust-migration-lot30-motifs-de-relance-design.md @@ -0,0 +1,54 @@ +# Lot 30 — Motifs de relance personnalisés tenus par la façade + +## Contexte + +Quand le modèle écrit un appel d'outil en toutes lettres au lieu de +l'émettre par le protocole, Loki le reconnaît et relance le tour avec une +consigne corrective. Les motifs embarqués couvrent les formats connus. Un +expert peut les remplacer par la clé d'état `retry_patterns` : + +- c'est un tableau JSON de regex du Go, chacune compilée en `(?mi)` ; +- une regex illisible est sautée ; +- une surcharge illisible ou vide laisse les motifs embarqués. + +Jusqu'ici, une surcharge posée gardait la conversation au Go +(`facadeChatBlocker`), même avec `LOKI_CHAT=rust`. + +## Le lot + +La façade lit la surcharge au démarrage de sa conversation +(`chat::generate::retry_patterns`), comme le Go la compile une fois par +processus. Le Go ne garde plus la conversation pour cette clé. + +Les regex sont celles du Go (paquet `regexp`), pas celles de `regex`. +`chat::stream::go_regex` les réécrit pour garder le sens du Go : + +- les classes Perl et les limites de mot du Go sont ASCII ; celles de + `regex` sont Unicode : + - hors d'une classe : `\w`, `\W`, `\d`, `\D`, `\s`, `\S` deviennent des + classes ASCII ; + - dans une classe : leur équivalent dans la classe ; + - `\b`, `\B` deviennent `(?-u:\b)`, `(?-u:\B)` ; +- `\Q…\E` (texte littéral) devient le texte échappé ; le Go le refuse dans + une classe, la traduction aussi ; +- dans une classe, `[`, `&&`, `~~` sont littéraux pour le Go, et `--` est un + intervalle : ils sont échappés ; +- l'octal du Go (`\101`) est permis (`octal(true)`). Une référence arrière + (`\1`), que le Go refuse, est refusée. + +## Parité + +`internal/loki/parity_retry_test.go` compile côté Go une soixantaine de +regex (dont celles qu'il refuse). Il note leur première correspondance sur +onze textes : accents, chiffres non ASCII, espaces insécables, crochets, +majuscules accentuées. Le journal (`tests/parity_retry/patterns.json`) est +rejoué par `tests/retry.rs` : même compilation, même correspondance, même +position. + +Autres tests : + +- la lecture de la surcharge : absente, `null`, vide, mal typée, suivie de + texte ; +- les regex sautées, les correspondances ; +- la surcharge lue au démarrage de la conversation de la façade ; +- côté Go, la clé ne garde plus la conversation. diff --git a/docs/superpowers/specs/2026-10-16-rust-migration-lot31-slots-design.md b/docs/superpowers/specs/2026-10-16-rust-migration-lot31-slots-design.md new file mode 100644 index 0000000..a87fc52 --- /dev/null +++ b/docs/superpowers/specs/2026-10-16-rust-migration-lot31-slots-design.md @@ -0,0 +1,88 @@ +# Lot 31 — Slots du moteur pour le tour tenu par la façade + +## Contexte + +Trois clés opt-in du moteur local gardaient la conversation au Go +(`facadeChatBlocker`), faute d'être honorées par la façade : + +- `SIDE_SLOT` (`llm_sideslot.go`) : un second slot pour les travaux annexes. + Chaque requête de Loki porte son `id_slot` : 0 pour le fil de la + discussion (étape d'un tour, résumé en continuation), 1 pour le reste + (vérification, résumé sur transcription). Un cache KV à court de place en + plein calcul est rejoué tel quel, sans compaction : c'est la faute du + voisin, pas un prompt trop long. +- `SLOT_PERSIST` (`llm_slotpersist.go`) : l'état du slot de la discussion est + gardé sur disque à la bascule de preset, et rechargé avant la première + requête de la discussion au retour. + +Et l'isolation des travaux annexes (`engineSideJob`, `CACHE_ISOLATE`) : après +la passe de vérification du mode Code, le Go efface le slot local s'il a +servi. La façade la sautait (écart du lot 7), le Go ne voyant pas passer ses +requêtes. + +Depuis la fusion de `main`, le Go voit passer chaque requête de la façade au +moteur local (tickets « en vol », `/internal/v1/oai/begin`, `served`, `end`). +La machinerie peut donc rester au Go ; il suffit que la façade la consulte. + +## Le lot + +### Go + +- `/internal/v1/turn/slot` (`facade_slots.go`) : + - entrée : `{"conv":"…","main":true}` ; + - sortie : `{"slot":0|1|-1}`, la valeur d'`engineSlotFor` ; + - avec `main` et `conv`, recharge d'abord l'état gardé de la discussion + (`slotPersistRestore`), comme la boucle Go avant une requête d'un tour. +- La sauvegarde à la bascule ne change pas. Le slot porte la discussion dès + que le moteur accepte une étape de son tour : `/internal/v1/oai/served` + appelle `engineMarkMain`. +- `SIDE_SLOT` et `SLOT_PERSIST` ne gardent plus la conversation. + +### Rust + +- `TurnHost::engine_slot`, appelé avant chaque requête. La boucle pose + l'`id_slot` rendu dans le corps. +- Le rejeu « cache KV plein » (`sideSlotPoolError`) n'a lieu qu'avec un slot + en service et un moteur local : + - deux fois au plus, après l'attente des reprises réseau ; + - puis l'erreur telle quelle ; + - ni compaction ni réduction. +- `GoTurnHost` : + - moteur externe : aucun slot ; + - étape d'un tour : `main` avec la discussion (donc le rechargement + `SLOT_PERSIST`) ; + - vérification : le second slot, et le ticket « en vol » ouvert sur lui + (`side`). +- `EngineSummarizer` : un résumé est désormais compté en vol, comme + `postSummary`. Il part sur le second slot s'il est fait sur la + transcription, sur le slot 0 s'il est fait en continuation. +- La passe de vérification est encadrée par `/internal/v1/engine/sidejob`. + À la fin, le Go efface le slot si le moteur a servi. + +## Tests + +- Go (`facade_slots_test.go`) : + - sans la clé, aucun slot et `/props` jamais lu ; + - fil de la discussion sur le 0, le reste sur le 1 ; + - second slot hors service ; + - rechargement une seule fois, pour la bonne discussion et une étape de tour + seulement ; + - une étape de la façade acceptée par le moteur suffit à la sauvegarde ; + - corps illisible, GET refusé. +- La boucle du tour : + - `id_slot` dans chaque requête, slot demandé avant chaque envoi ; + - cache KV plein : rejoué deux fois sans compaction, puis l'erreur ; + - sans slot, ou moteur externe : un débordement comme un autre ; + - motif `sideSlotPoolError` repris des cas du Go. +- Le vrai hôte (`tests/lot6.rs`) : + - contrat du mode Code : le tour sur le 0, la vérification sur le 1 ; + - tickets ouverts sur le second slot, travail annexe ouvert puis rendu ; + - résumés : transcription sur le 1, continuation sur le 0, tickets rendus. + +## Écarts assumés + +- Le rejeu « cache KV plein » se fie au slot demandé pour cette requête. Le Go + relit `sideSlotLive` au moment de l'erreur ; les deux lectures partagent + le même cache de dix secondes. +- Le slot est demandé avant de composer la requête, et non juste avant de + l'envoyer : rien ne se passe entre les deux. diff --git a/docs/superpowers/specs/2026-10-16-rust-migration-lot32-images-gardees-design.md b/docs/superpowers/specs/2026-10-16-rust-migration-lot32-images-gardees-design.md new file mode 100644 index 0000000..254406a --- /dev/null +++ b/docs/superpowers/specs/2026-10-16-rust-migration-lot32-images-gardees-design.md @@ -0,0 +1,97 @@ +# Lot 32 — Images gardées (KEEP_TURN_IMAGES) dans le tour de la façade + +## Contexte + +Sans la clé, une image montrée par un outil (capture, `see_image`) est +éphémère. Le modèle la voit pendant le tour, puis elle disparaît de +l'historique. Le tour suivant renvoie donc un fil où elle manque : le +préfixe en cache diverge à elle, et toute la boucle d'outils qui suivait est +recalculée. + +`KEEP_TURN_IMAGES` (`chat_keep_images.go`) garde l'image telle qu'envoyée, +sous conditions : + +- vision réellement active ; +- image rangée par référence et relue à l'octet près ; +- coût **mesuré** par le moteur, jamais estimé ; +- toutes les images gardées tiennent dans 10 % de la fenêtre. + +Les images gardées partent avant tout résumé. La clé gardait la conversation +au Go. + +## Le lot + +### Go (`facade_keepimg.go`) + +- `/internal/v1/turn/keepimg` rend deux choses : + - si la vision est réellement active (`visionEnabled`, `engineSeesImages`) ; + - le budget des images gardées (`keepImgBudget`). +- `/internal/v1/turn/keepimg/store` range une image relayée par référence + (`refRelayImage`) : + - fichier chiffré si la mémoire l'est ; + - date rafraîchie ; + - relu à l'octet près. + + Il rend le message qui la porte ainsi et son poids en octets, ou un refus. +- `KEEP_TURN_IMAGES` ne garde plus la conversation. + +### Rust + +- `chat/keepimg.rs` porte l'état du tour (`keepImages`) : + - `relay` : image gardée à l'essai, dans `messages` et `extra` ; + - `sending` ; + - `observe` : mesure de la requête qui l'a montrée la première, partage au + prorata des octets, seuil de 16 jetons, budget ; + - `rewritten` ; + - `publishable` ; + - `finish` : une image à l'essai jamais mesurée redevient éphémère. + + Une image refusée arrête le tour pour les suivantes. +- La boucle du tour : + - `TurnHost::keep_images` donne le budget, mais seulement pour le fil + principal d'une discussion, avec la clé et la vision active ; + - `TurnHost::keep_image` range l'image par le Go ; + - l'historique réécrit en cours de tour est publié sans les relais non + gardés. +- Les images gardées partent d'abord, avant tout résumé : + - au filet réactif (prompt trop long) : requête rejouée sans elles ; + - en cours de tour, au seuil : la compaction ne suit que si elle reste + nécessaire ; + - sur une complétion coupée par une fenêtre pleine : rejouée sans résumé + si leur retrait suffit. +- Début et fin de tour (`keptImagesTurnStart`) : les images gardées sont + retirées si le contexte le demande, ou si elles ne doivent plus partir + (clé retirée, vision absente). + +La compaction et la réduction forcée retiraient déjà les images gardées +d'abord (`chat/compact.rs`). + +## Tests + +- `chat/keepimg` : + - clé ; + - gardée au coût mesuré ; + - budget ; + - sans mesure (pas d'usage, complétion coupée, pas de base) ; + - requête relancée ; + - rangement refusé ; + - partage entre deux images ; + - publication, réécriture, fin de tour, budget plein avant rangement. +- La boucle du tour : + - requête identique avec et sans la clé, image gardée par référence au + coût mesuré ; + - refus : budget, pas d'usage, rangement refusé ; + - image à l'essai en fin de tour ; + - retrait en cours de tour, au filet réactif et sur une fenêtre pleine, à + chaque fois sans résumé. +- Le vrai hôte (`tests/lot6.rs`) : + - image rangée par le Go, vue en data-URL par le moteur, gardée avec son + coût ; + - clé retirée : l'image part au début du tour suivant, légende et marque + gardées. +- Le Go (`facade_keepimg_test.go`) : + - état sans projecteur puis vu par le moteur, budget ; + - rangement relu à l'octet près ; + - refus : texte seul, URL distante, format inconnu, base64 illisible, deux + images ; + - corps illisible, GET refusé. diff --git a/docs/superpowers/specs/2026-10-16-rust-migration-lot33-prechauffage-design.md b/docs/superpowers/specs/2026-10-16-rust-migration-lot33-prechauffage-design.md new file mode 100644 index 0000000..158cded --- /dev/null +++ b/docs/superpowers/specs/2026-10-16-rust-migration-lot33-prechauffage-design.md @@ -0,0 +1,112 @@ +# Lot 33 — Préchauffage (PREWARM) tenu par la façade + +## Contexte + +Certains recalculs sont inévitables : + +- le prompt réécrit par une compaction ; +- le dernier message assistant rendu autrement au tour suivant ; +- la discussion reprise après une tâche qui a pris le slot. + +Sans la clé, ils tombent sur le premier jeton du message suivant. + +`PREWARM` (`chat_prewarm.go`) envoie au moteur local, dès qu'il est libre, la +requête du prochain tour telle qu'elle partira, suivie d'une sentinelle « . » +et avec `max_tokens` 1. Le moteur calcule tout le préfixe ; le vrai tour ne +calcule plus que son propre message. + +Le préchauffage ne passe jamais devant un vrai travail : + +- toute requête de Loki l'annule, sauf un tour qui le prolonge exactement ; +- un seul est en vol à la fois ; +- aucune erreur ne remonte. + +La clé gardait la conversation au Go. + +## Le lot + +La façade tient l'historique : c'est elle qui assemble et envoie. Le Go +garde le compteur des requêtes en vol (`engineGate`) et ce qui annule. + +### Go (`facade_prewarm.go`) + +- `/internal/v1/prewarm/check` dit si le préchauffage peut partir : + - clé posée, moteur local ; + - ni travail annexe, ni bench, ni optimiseur en cours ; + - loin de minuit ; + - moteur en bonne santé ; + - slot visé : un seul slot, ou celui de la discussion avec SIDE_SLOT. +- `/internal/v1/prewarm/begin` : + - recharge l'état SLOT_PERSIST de la discussion ; + - inscrit le préchauffage comme LA requête en vol (`prewarmBegin`) ; + - son annulation (une autre requête part) est relayée à la façade + (`/internal/facade/v1/chat/prewarm/cancel`). +- `/internal/v1/prewarm/end` : + - rend la place ; + - note ce que le moteur a repris (`noteEnginePrompt`). +- `/internal/v1/oai/begin` accepte `prewarm_keep` : l'étape d'un tour de la + façade qui prolonge le préchauffage ne l'annule pas. +- Après une tâche planifiée, le déclencheur part à la façade + (`/internal/facade/v1/chat/prewarm`). +- PREWARM ne garde plus la conversation. + +### Rust (`chat/prewarm.rs`) + +- L'état partagé : + - le préchauffage en vol, son inscription, son annulation ; + - les capacités du dernier tour de chaque discussion (64 au plus). +- L'assemblage : les mêmes fonctions que le tour. + - Vue du Go, historique sans blocs ``, préambule. + - Écho, messages d'envoi, corps. + - Sentinelle « . » (rôle `builder` en mode Code), non streamé, un seul jeton. +- On s'abstient dans ces cas : + - capacités inconnues ; + - tour en cours ; + - discussion vide ; + - compaction attendue ; + - assemblage inattendu. +- Déclencheurs : + - fin de tour quand rien n'attend dans la file ; + - fin de tâche (relayée par le Go) ; + - changement de discussion ou de projet : le préchauffage en cours est + annulé ; avec `full`, la nouvelle discussion est préparée après 3 s. +- Le tour : + - `TurnHost::prewarm_keeps` compare les empreintes des messages envoyés + et des champs qui façonnent le rendu (`prewarmOptsHash`) ; + - une requête qui ne prolonge pas le préchauffage le coupe tout de suite, + sans attendre l'aller-retour par le Go. +- Télémétrie : le préchauffage est rangé dans l'anneau (`kind=prewarm`). + +## Tests + +- `chat/prewarm` : + - mode (jamais pour un preset externe) ; + - empreinte des options (l'échantillonnage n'y entre pas, un champ absent + n'est pas un champ vide) ; + - rôle de la sentinelle ; + - prolongement ; + - état partagé (un seul en vol, garde, annulation, fin) ; + - capacités bornées. +- La boucle du tour : le drapeau transmis à l'envoi, jamais vers une API + externe. +- Le vrai hôte (`tests/lot6.rs`) : + - fin de tour : requête préchauffée (sentinelle, un seul jeton, même + préfixe et mêmes outils que le tour), inscrite au Go, pas de ticket de + la façade ; + - le tour suivant la prolonge et la garde (`prewarm_keep`) ; + - fin rendue avec total et cache, télémétrie ; + - refus du Go : rien ne part ; + - annulation relayée : connexion coupée, place rendue. +- Le Go (`facade_prewarm_test.go`) : + - contrôle : clé, slots, minuit, travail annexe, moteur muet ; + - inscription et deuxième préchauffage refusé ; + - gardé par le tour qui le prolonge, annulé et relayé sinon ; + - fin unique ; + - requête déjà en vol ; + - déclencheur après tâche relayé. + +## Écarts assumés + +- L'annulation d'un préchauffage pas encore inscrit au Go (aller-retour en + cours) annule celui qui part, quel que soit son numéro : il n'y en a + qu'un. diff --git a/docs/superpowers/specs/2026-10-16-rust-migration-lot34-bloc-projet-fige-design.md b/docs/superpowers/specs/2026-10-16-rust-migration-lot34-bloc-projet-fige-design.md new file mode 100644 index 0000000..3a3de30 --- /dev/null +++ b/docs/superpowers/specs/2026-10-16-rust-migration-lot34-bloc-projet-fige-design.md @@ -0,0 +1,92 @@ +# Lot 34 — Bloc projet figé (PROJ_SNAPSHOT) dans le tour de la façade + +## Contexte + +Le contexte du projet part en tête du premier message utilisateur : + +- description ; +- index mémoire ; +- trackers ; +- consignes du dépôt ; +- avec la clé, la date et le dossier de travail. + +Reconstruit à chaque tour, il change dès qu'une page est créée ou qu'un +tracker reçoit un point. Tout ce qui le suit dans le prompt est alors +recalculé. + +`PROJ_SNAPSHOT` (`chat_projsnap.go`) garde une copie figée du bloc par +discussion. Les changements arrivent en `` en tête du +nouveau message utilisateur, rangés tels quels dans l'historique. Le bloc +n'est rafraîchi que quand le début du prompt change de toute façon : +compaction, système ou outils, clé du moteur, projet, mode, trop de mises à +jour accumulées. + +C'était le dernier réglage qui gardait la conversation au Go. + +## Le lot + +L'instantané est déjà persisté avec la discussion (`ConvState::proj_snap`), +et la compaction le remet à zéro comme le Go. Il manquait la décision, qui +lit le contexte du projet : elle reste au Go. + +### Go (`facade_projsnap.go`) + +- `/internal/v1/turn/view`, avec la clé et l'agent : + - reçoit l'instantané de la discussion (`snap`) ; + - rend la tête du tour (prompt du preset, puis le bloc figé, ou le vivant + s'il faut rafraîchir) et le préambule sans date ni dossier + (`envInCtx`) ; + - rend aussi `projsnap` : rafraîchir ou non, la mise à jour à poser, + l'instantané à ranger, la clé et l'empreinte du système du tour. +- `dry` (préchauffage) : la même décision, rien à ranger. +- `/internal/v1/turn/projsnap/refresh` : le bloc vivant et l'instantané qu'il + devient (`projSnapTurn.refresh`). +- Plus aucun réglage ne garde la conversation : `facadeChatBlocker` et la + décision prise une fois par processus disparaissent. Le Go la remet dès + que `LOKI_CHAT=rust`, l'adresse de la façade et le jeton sont là. + +### Rust + +- `ChatService::assemble_turn`, partagé par le tour et la relance du builder + du mode Code : + - rafraîchi : instantané neuf, blocs `` retirés de + l'historique ; + - mise à jour : posée en tête du dernier message utilisateur + (`prependCtxUpdate`), dans la discussion et dans la requête ; l'état + annoncé ne bouge que si elle a trouvé où se poser ; + - sans la clé : la discussion qui porte encore des blocs en est nettoyée, + comme avant. +- `TurnHost::project_refresh` : après une compaction ou une réduction en + plein tour, le bloc vivant remplace le figé (`replaceProjectHead`) et les + mises à jour sont retirées. L'instantané posé devient celui de la + discussion à la fin du tour ou de la passe. Le retrait d'images gardées ne + touche pas au début du prompt : pas de rafraîchissement. +- Le préchauffage demande la vue en `dry`. Il garde les mises à jour de + l'historique tant que le bloc n'est pas rafraîchi, comme le vrai tour. + +## Tests + +- Go (`facade_projsnap_test.go`) : + - la séquence assemblée par la façade égale celle de `turnView`, outils et + instantané compris ; + - une page ajoutée : bloc gardé, mise à jour égale à celle de `turnView`, + état annoncé suivi ; + - sans changement : rien à poser ; + - `dry` sans rien à ranger ; + - clé changée : rafraîchi, bloc vivant en préchauffage ; + - rafraîchissement en plein tour : bloc vivant, instantané à la clé du + tour. +- Go (`facade_chat_test.go`) : avec toutes les clés opt-in posées, la façade + tient la conversation ; sans adresse ou sans `LOKI_CHAT`, le Go et + pourquoi. +- Rust, `chat/compact` : mise à jour en tête (texte, parties, refus) ; bloc + projet remplacé à sa place (cas du Go). +- Rust, boucle du tour : rafraîchi après une compaction et une réduction ; + pas après un retrait d'images gardées. +- Rust, vrai hôte (`tests/lot6.rs`) : + - instantané rangé ; + - mise à jour posée dans la requête et l'historique (marque retirée à + l'envoi) ; + - rafraîchissement qui retire les mises à jour ; + - compaction en plein tour : bloc vivant envoyé, instantané rangé ; + - préchauffage en `dry`. diff --git a/docs/superpowers/specs/2026-10-16-rust-migration-lot35-routes-de-base-tunnel-design.md b/docs/superpowers/specs/2026-10-16-rust-migration-lot35-routes-de-base-tunnel-design.md new file mode 100644 index 0000000..f9d6a74 --- /dev/null +++ b/docs/superpowers/specs/2026-10-16-rust-migration-lot35-routes-de-base-tunnel-design.md @@ -0,0 +1,48 @@ +# Lot 35 — Routes de base par le tunnel du relais + +## Contexte + +La façade sert toujours neuf routes, sans variable (`api.rs`) : + +- `/api/ping` ; +- `/api/status` ; +- `/api/config` ; +- `/api/engine/cmdline` ; +- `/api/model/layers` ; +- `/api/paths` ; +- `/api/ram` ; +- `/api/vram` ; +- `/api/service/log`. + +Mais le tunnel du relais (accès distant, E2E) sert le mux du Go tel quel. +`withFacadeChat` ne remet à la façade que les routes des lots. Un utilisateur +distant tombait donc encore sur les handlers Go de ces neuf routes : deux +implémentations à garder d'accord. + +## Le lot + +- Go (`facade_core.go`) : une table des routes de base, remises à la façade + par `withFacadeChat` dès qu'elle tourne (adresse et jeton interne + présents), sans variable. Façade muette : 502 « façade indisponible ». + Sans façade (`LOKI_FACADE=0`), le Go les sert lui-même. +- Rust (`api.rs`) : une requête relayée par le Go (jeton interne) a déjà été + authentifiée par lui. Ni clé ni hôte local ne sont exigés de nouveau, + comme pour les routes des lots ; la version du Go est lue quand même pour + `/api/ping`. + +## Tests + +- Go (`facade_core_test.go`) : + - les neuf routes relayées avec le jeton ; + - une route du Go gardée ; + - façade muette ; + - sans façade, servies ici. +- Rust (`tests/lot2.rs`) : + - relayée par le Go avec un hôte distant et une clé posée : servie par la + façade, pas renvoyée au Go ; + - sans jeton, ou avec un faux : refusée. + +## Reste à faire + +L'interface statique et `/v1` passent encore par le Go sur le tunnel. Ses +handlers sont gardés pour `LOKI_FACADE=0`. diff --git a/docs/superpowers/specs/2026-10-16-rust-migration-lot36-appels-mcp-locaux-design.md b/docs/superpowers/specs/2026-10-16-rust-migration-lot36-appels-mcp-locaux-design.md new file mode 100644 index 0000000..04aa311 --- /dev/null +++ b/docs/superpowers/specs/2026-10-16-rust-migration-lot36-appels-mcp-locaux-design.md @@ -0,0 +1,56 @@ +# Lot 36 — Appels d'outils MCP sans détour par le Go + +## Contexte + +Depuis le lot 20, la façade tient le pool MCP (`LOKI_MCP=rust`) : sessions +stdio et HTTP, registre des outils exposés. Le tour de la façade, lui, +faisait encore exécuter un appel `mcp__*` par le Go +(`/internal/v1/tool/exec`). + +Le Go ne l'exécutait pas lui-même : `mcpCall` renvoyait l'appel à la façade +(`/internal/facade/v1/mcp/call`). Chaque appel faisait donc deux +allers-retours locaux pour revenir au même pool. + +Pour un outil MCP, le Go n'ajoute rien au résultat (`toolStepExtras`) : + +- ni capture ; +- ni aperçu du navigateur ; +- ni image relayée. + +Seul le texte revient. + +## Le lot + +- `ChatShared` porte le pool (`mcp`), le même que celui des routes (créé une + fois dans `main.rs`). +- `GoTurnHost::exec_tool` : un nom `mcp__*`, pool tenu → `Mcp::call` + directement. Le résultat est celui de la route interne : + - texte aplati et coupé ; + - « [l'outil a renvoyé une erreur] » ; + - « [ok] (aucune sortie) » ; + - une reconnexion si la session est morte, puis « [erreur MCP] … ». +- Une reconnexion annonce la version du Go dans `clientInfo`, comme la route + interne. Elle est lue une fois (`/internal/v1/auth`) et retenue. +- Sans pool tenu (`LOKI_MCP` absent), rien ne change : l'appel part au Go, + qui l'exécute avec son propre pool. + +Le Go garde la route interne pour les tours qu'il exécute encore : + +- sous-agents ; +- tâches IA ; +- discussion rendue au Go. + +## Tests + +`tests/lot6.rs` (`outil_mcp_par_le_pool_de_la_facade`) fait tourner un faux +serveur MCP stdio. Un tour de la façade appelle : + +- un outil qui répond ; +- un outil dont le serveur meurt. + +Le test vérifie : + +- aucun appel au Go ; +- le résultat de chaque outil tel que le modèle le reçoit ; +- l'erreur après une reconnexion ; +- la version du Go annoncée à cette reconnexion. diff --git a/docs/superpowers/specs/2026-10-16-rust-migration-lot37-outils-taches-design.md b/docs/superpowers/specs/2026-10-16-rust-migration-lot37-outils-taches-design.md new file mode 100644 index 0000000..8a207c2 --- /dev/null +++ b/docs/superpowers/specs/2026-10-16-rust-migration-lot37-outils-taches-design.md @@ -0,0 +1,82 @@ +# Lot 37 — Outils task_* de l'agent en Rust + +## Contexte + +Depuis le lot 26, la façade tient les tâches planifiées (`LOKI_TASKS=rust`) : + +- les routes ; +- le planificateur ; +- les tâches « script seul ». + +Les quatre outils par lesquels le modèle gère lui-même ses tâches +(`tasks_tools.go`) restaient au Go, appelés par `/internal/v1/tool/exec` : + +- `task_list` ; +- `task_create` ; +- `task_update` ; +- `task_delete`. + +## Le lot + +`src/tasks/tools.rs` porte les quatre outils, mêmes messages au caractère +près : + +- **`task_list`** : + - liste du projet actif, aperçu d'une ligne par tâche ; + - compte-rendu coupé à 300 octets, un caractère coupé valant U+FFFD par + octet comme à l'encodage JSON du Go ; + - avec un id, la tâche seule et son compte-rendu complet ; + - une tâche d'un autre projet est « introuvable » ; +- **`task_create`** : + - tâche unique « dans N minutes » (durée tronquée comme + `time.Duration(v)`) ou « à HH:MM » (le lendemain si l'heure est passée, + à la même heure murale) ou « à AAAA-MM-JJ HH:MM » ; + - fréquence validée ; + - script vérifié dans le dossier protégé ; + - tâche rattachée au projet actif ; + - fuseau : celui du navigateur, sinon celui de la première tâche qui en a + un ; +- **`task_update`** : modification partielle ; la prochaine exécution est + recalculée sur une nouvelle fréquence, ou à la reprise d'une tâche qui + n'en a pas ; +- **`task_delete`**. + +Un projet disparu vaut le projet par défaut (`taskProjectOf`). + +`GoTurnHost::exec_tool` les exécute quand la façade tient les tâches +(`ChatShared.tasks`, la même base que celle des routes). Sinon l'appel part +au Go comme avant. + +Côté Go, les outils prennent l'heure des tâches (`tasksNow`) au lieu de +`time.Now`. Rien ne change en service ; le journal de parité peut ainsi la +fixer. + +## Parité + +`internal/loki/parity_tasks_tools_test.go` tire du Go un journal de 44 +appels (`tests/parity_tasks/tools.json`). Chaque étape porte : + +- l'heure, le fuseau, le projet actif, les projets connus ; +- les scripts présents ; +- une tâche posée telle quelle en base ; +- les arguments tels que le modèle les envoie ; +- le résultat tel qu'il le reçoit ; +- les tâches en base après coup. + +Le journal couvre : + +- l'heure du trou du changement d'heure ; +- une date passée ou impossible ; +- des arguments d'un autre type ; +- un script absent, hostile ou dossier ; +- le compte-rendu coupé au milieu d'un caractère ; +- le fuseau d'une tâche existante. + +`tests/tasks.rs` (`parite_des_outils`) le rejoue contre le port Rust. + +`tests/lot6.rs` (`outil_task_par_la_facade`) fait tourner un tour de la +façade qui crée une tâche. Il vérifie : + +- rien ne passe par le Go ; +- la tâche est rangée au fuseau de l'utilisateur, dans le projet actif ; +- le résultat est celui que le modèle reçoit. diff --git a/docs/superpowers/specs/2026-10-16-rust-migration-lot38-outil-criteres-design.md b/docs/superpowers/specs/2026-10-16-rust-migration-lot38-outil-criteres-design.md new file mode 100644 index 0000000..4c9d862 --- /dev/null +++ b/docs/superpowers/specs/2026-10-16-rust-migration-lot38-outil-criteres-design.md @@ -0,0 +1,77 @@ +# Lot 38 — L'outil criteria du mode Code dans la façade + +## Contexte + +En mode Code, le modèle tient les critères d'acceptation de sa tâche avec +l'outil `criteria` : + +- les poser ; +- les marquer faits ; +- les lister ; +- les effacer. + +La passe de vérification les marque ensuite `passed` ou `failed`. + +Depuis le lot 19, la façade sert déjà les critères de l'écran +(`/api/chat/criteria`, `chat/crit:` scellés). La passe de vérification +les relit depuis le lot 7. Mais l'outil lui-même partait au Go +(`/internal/v1/tool/exec`), qui le renvoyait à la façade comme delta +(`critNotify` → `/internal/facade/v1/delta`). + +## Le lot + +`chat/crittool.rs` porte `toolCriteria`, mêmes messages au caractère près : + +- **list** : action vide ou d'un autre type comprise ; +- **add** : + - textes vides ou d'un autre type ignorés ; + - ids repris après le plus grand ; + - vingt critères au plus ; +- **set** : + - id numérique (tronqué) ou en chaîne (« "2" », « "#2" », « +1 ») ; + - statut libre, note rognée ; + - `passed` refusé hors de la passe de vérification ; +- **clear**. + +Les règles de rangement et d'affichage : + +- une liste vide supprime la clé ; une erreur d'écriture est ignorée, comme + dans le Go ; +- une liste rangée illisible vaut « aucun critère » ; +- la note vide n'est pas écrite (`omitempty`). + +`GoTurnHost::exec_tool` l'exécute pour la discussion du tour +(`main_conv`). La passe de vérification (`Role` = verifier) est la seule à +pouvoir marquer `passed`. Chaque changement pousse la liste à l'écran (delta +`criteria` + `ts`) par un nouveau champ `deltas` de l'hôte : + +- le journal de la discussion pour un tour ; +- celui de la conversation pour une passe de vérification. + +Écart assumé : le Go rangeait les critères sous la discussion **active** +au moment de l'appel (`convEnsureActive`). La façade les range sous celle du +tour. Elles ne diffèrent que si l'utilisateur change de discussion pendant +la génération, et c'est alors la façade qui a raison. + +`see_image` reste au Go. Il redresse et réduit l'image (EXIF, taille) avant +de l'encoder, ce qu'un port ne rendrait pas octet pour octet. + +## Parité + +`internal/loki/parity_criteria_test.go` tire du Go un journal de 22 appels +(`tests/parity_tools/criteria.json`). Chaque étape porte : + +- les arguments, builder ou vérification ; +- le résultat ; +- les critères rangés ensuite. + +`tests/tools.rs` (`criteria_comme_le_go`) le rejoue contre le port. Il +vérifie aussi : + +- le rangement scellé ; +- la liste poussée à l'écran à chaque changement, et seulement alors. + +`tests/lot6.rs` (`contrat_verifie_par_la_facade`) fait tourner un vrai +contrat. La passe de vérification marque le critère `passed` dans la +façade : rien ne passe par le Go, le critère est rangé scellé, l'écran +reçoit la liste. diff --git a/docs/superpowers/specs/2026-10-16-rust-migration-lot39-anneau-telemetrie-design.md b/docs/superpowers/specs/2026-10-16-rust-migration-lot39-anneau-telemetrie-design.md new file mode 100644 index 0000000..916baef --- /dev/null +++ b/docs/superpowers/specs/2026-10-16-rust-migration-lot39-anneau-telemetrie-design.md @@ -0,0 +1,92 @@ +# Lot 39 — Anneau de télémétrie tenu par la façade + +## Contexte + +Chaque complétion qui passe par le moteur est rangée dans un anneau +(`perf_log.go`). L'anneau : + +- calcule la perte de cache d'une complétion contre la précédente de la même + discussion et de la même nature ; +- nomme ce qui s'est intercalé (sous-agent, client externe…) ; +- signale une perte au-delà du seuil du preset (stats finales) ; +- nourrit `/api/perf/summary` et les poids de l'optimiseur. + +Depuis le lot 29, la façade envoyait chaque complétion de ses tours au Go +(`/internal/v1/turn/perf`). C'était un aller-retour par complétion pour un +anneau tenu ailleurs. + +## Le lot + +**Rust** (`src/perfring.rs`, port de `perf_log.go`) : + +- l'anneau : 5000 entrées, 128 discussions suivies, 500 entrées remontées au + plus pour nommer ce qui s'est intercalé ; +- la perte : seulement si la complétion prolonge strictement la précédente + (empreintes cumulées des messages), cache connu, complétion allée au bout ; +- la ligne `[perf]` (LOKI_PERF_LOG) ; +- le seuil d'alerte, dans le même ordre de priorité qu'au lancement : + - `-cms` d'EXTRA_ARGS ; + - `LLAMA_ARG_CHECKPOINT_MIN_SPACING_NT` ; + - `CKPT_MIN_STEP` ; + - 2048 sur un modèle hybride ; + - plus le micro-batch ; +- le résumé de `/api/perf/summary` (par nature, par profondeur, brouillon, + dernier verdict de la sonde de gabarit). + +`ChatShared` porte l'anneau. Les tours, les passes de vérification, les +résumés de compaction et le préchauffage de la façade y rangent leurs +complétions directement. Les marques (perte, ce qui s'est intercalé, +alerte) partent sur les stats finales comme avant. + +La façade sert : + +- `POST /internal/facade/v1/perf/record` : une complétion faite par le Go + (sous-agent, tâche IA, bench, compaction et préchauffage de ses tours, + client externe de `/v1`), rendue telle que rangée ; +- `POST /internal/facade/v1/perf/snapshot` : l'anneau, pour les poids de + l'optimiseur ; +- `GET /api/perf/summary`. + +**Go** (`facade_perf.go`) : quand la façade tient la conversation, +`perfRecord` range chez elle, et `perfSnapshot` relit son anneau +(optimiseur, contexte de `/internal/v1/tune/context`). `/api/perf/summary` +lui est remis (`facadeChatRoutes`). Façade injoignable : l'anneau du Go +prend le relais. Sans façade, ou la conversation rendue au Go +(`LOKI_CHAT=go`), rien ne change. + +La route `/internal/v1/turn/perf` est retirée : plus personne ne l'appelle. + +## Parité + +`internal/loki/parity_perf_test.go` tire du Go un journal +(`tests/parity_perf/perf.json`). Quatorze complétions y sont rangées, avec +les empreintes de leurs messages ; elles couvrent : + +- prolongement ; +- perte après un sous-agent et un client externe ; +- cache inconnu ; +- divergence ; +- autre discussion ; +- complétion coupée ; +- compaction, bench, vérification. + +Pour chaque complétion, le journal garde ce qui est rangé, la ligne +`[perf]` et l'alerte. Il porte aussi le résumé, le résumé vide, et onze +seuils d'alerte. `src/perfring/tests.rs` le rejoue, avec l'anneau borné et +l'heure posée. + +`tests/lot6.rs` : + +- `cache_perdu_mesure_par_la_facade` : deux tours de la même discussion ; + la perte est mesurée par la façade et signalée dans les stats finales ; +- `anneau_de_la_facade_pour_le_go` : ce que le Go confie (perte après un + client externe), ce qu'il relit, le jeton exigé, `/api/perf/summary` + servi par la façade ; +- les tests du lot 29 lisent désormais l'anneau de la façade. + +`internal/loki/facade_perf_test.go` (`TestFacadePerfAnneau`) : + +- complétion confiée à la façade ; +- anneau relu ; +- repli sur l'anneau du Go quand la façade est en panne ; +- route remise. diff --git a/docs/superpowers/specs/2026-10-16-rust-migration-lot40-outils-memoire-design.md b/docs/superpowers/specs/2026-10-16-rust-migration-lot40-outils-memoire-design.md new file mode 100644 index 0000000..de22f16 --- /dev/null +++ b/docs/superpowers/specs/2026-10-16-rust-migration-lot40-outils-memoire-design.md @@ -0,0 +1,87 @@ +# Lot 40 — Outils mémoire de l'agent dans la façade + +## Contexte + +Depuis le lot 13, la façade tient la mémoire quand `LOKI_MEM=rust` : + +- pages ; +- index `MEMORY.md` ; +- trackers ; +- mode mémoire. + +Les outils par lesquels le modèle s'en sert partaient pourtant au Go +(`/internal/v1/tool/exec`) : + +- `mem_search`, `mem_read`, `mem_add`, `mem_edit` ; +- `tracker`. + +Deux processus écrivaient donc les mêmes pages et le même index. + +## Le lot + +`src/memory/tools.rs` porte les outils (`chat_memory.go`, `tracker.go`), +mêmes résultats au caractère près. Il réutilise les lectures et écritures des +routes de la mémoire : + +- pages scellées selon le chiffrement ; +- `.bak` de la version précédente ; +- écriture vérifiée ; +- index tenu par le code. + +Chaque outil : + +- **`mem_search`** : classement par couverture des termes puis par + pertinence (rareté IDF, fréquence amortie, bonus du nom et du titre) ; + extrait coupé à l'octet comme le Go (U+FFFD par octet d'un caractère + coupé) ; limite 8 par défaut, 30 au plus ; +- **`mem_read`** : lignes numérotées, plage, 500 au plus ; +- **`mem_add`** : jamais par-dessus une page existante, index tenu (seed au + nom du projet), diff d'ajout ; +- **`mem_edit`** : `old` unique, édition déjà en place reconnue, diff ; +- **`tracker`** : + - vue d'ensemble ; + - squelette (années, année en cours par mois) ; + - une année par mois ; + - points (60 plus récents au plus) ; + - ajout, modification, suppression d'un point (jamais d'un tracker + entier) ; + - arguments d'un autre type rendus comme `str` du Go. + +`tracker_add` prend désormais l'heure en paramètre (`tracker_add_at`). + +`ChatShared.memory` porte la même mémoire que les routes. `GoTurnHost::exec_tool` +y exécute les outils quand la façade la tient ; sinon l'appel part au Go +comme avant. + +Côté Go, l'heure des trackers (« maintenant », année en cours) passe par +`trackerNow`, que le journal fixe ; rien ne change en service. + +## Parité + +`internal/loki/parity_mem_tools_test.go` tire du Go un journal de 57 appels +(`tests/parity_mem/outils.json`), en fuseau UTC et à heure fixée. Chaque +étape porte : + +- les arguments ; +- le libellé du tour ; +- le résultat, le diff ; +- les fichiers de la mémoire du projet (`.bak` et index compris) ; +- les trackers en base. + +Il couvre : + +- recherche : couverture, rareté, requête vide, limites, extrait coupé ; +- lecture : plage, au-delà, page absente, nom invalide ; +- ajout : existant, nom invalide, majuscules ; +- modification : rejouée, déjà en place, absente, en double, vide ; +- trackers : chaque vue, dates illisibles, nom en nombre, identifiants + inconnus. + +`tests/memory.rs` (`outils_memoire_comme_le_go`) le rejoue contre le port. + +`tests/lot6.rs` (`outil_memoire_par_la_facade`) fait tourner un tour de la +façade qui crée une page. Il vérifie : + +- rien ne passe par le Go ; +- la page est rangée dans le projet actif, l'index tenu ; +- le résultat est celui que le modèle reçoit. diff --git a/docs/superpowers/specs/2026-10-16-rust-migration-lot41-compilation-design.md b/docs/superpowers/specs/2026-10-16-rust-migration-lot41-compilation-design.md new file mode 100644 index 0000000..2634532 --- /dev/null +++ b/docs/superpowers/specs/2026-10-16-rust-migration-lot41-compilation-design.md @@ -0,0 +1,133 @@ +# Lot 41 — Compilation de llama.cpp depuis les sources dans la façade + +## Contexte + +Depuis le lot 22, la façade tient les backends llama.cpp quand +`LOKI_BACKENDS=rust` : + +- statut, vérification git ; +- binaires précompilés ; +- bascule fast/opt ; +- backends custom ; +- le job des moteurs. + +La compilation depuis les sources restait au Go. Il en rapportait chaque +étape au job de la façade par l'API interne +(`/internal/facade/v1/build-job/*`). Elle couvre trois routes : + +- `POST /api/llamacpp/install` `{force}` ; +- `POST /api/llamacpp/update` `{clean}` ; +- `POST /api/llamacpp/install-custom` `{repo, name, ref}`. + +## Le lot + +`src/backends/build.rs` porte la compilation sous Linux et macOS. Sources Go : + +- `backend_build.go` : plan, CMake, étapes suivies ; +- `lcRunInstall`, `lcRunUpdate`, `lcBuildAndSwitch`, `lcAppendLogTail` + (`web_llamacpp.go`) ; +- `installCustomBackend`, `deriveBackendName`, `looksLikeGitURL` + (`backend_llamacpp.go`) ; +- `autoInstallTool`, `ensureCompiler` (`sys_platform_unix.go`). + +Le port couvre : + +- **le plan complet** (`detectBuildPlan`) : + - drapeaux Release, natif, UI de llama-server coupée ; + - Metal sous macOS ; + - CUDA quand nvcc et un GPU NVIDIA sont là : F16, racine du toolkit et + compilateur explicites hors `/usr`, architectures lues par + `nvidia-smi` ; + - HIP, puis Vulkan ; +- **CMake** : + - `build/` configuré ailleurs (dépôt déplacé) ou `clean` : mis de côté en + `build.old` ; + - nvcc dans le PATH et `CUDACXX` ; + - `configure.log` et `build.log` dans le dépôt ; + - erreurs « configuration CMake échouée » et « compilation échouée » ; +- **les étapes suivies** : + - sorties standard et d'erreur dans un même tube, ordre gardé ; + - git : découpage du `sinkWriter` (barre qui se redessine montrée toutes + les 10 s, reste sans fin de ligne perdu) ; + - CMake : découpage du `bufio.Scanner` (dernier morceau rendu, ligne de + plus de 1 Mio qui arrête la lecture) ; + - octets invalides en U+FFFD un à un ; + - erreurs au format d'`exec` (`exit status N`, `signal: killed`, + programme introuvable) ; +- **les outils** : + - git et cmake, puis cc, c++, make ; + - un manquant est installé au mieux par apt-get, dnf, pacman ou brew + (sudo hors root) ; + - sinon le message du Go ; +- **les jobs** : + - installation : clone, compilation propre, BIN ; dépôt présent : bascule + en mise à jour ; `force` : supprimé puis recloné ; + - mise à jour : fetch, pull au besoin, commits avant et après ; + « déjà à jour » sans recompiler ; service arrêté le temps du build et + relancé même en échec ; + - échec du build : fin des deux journaux (30 lignes) remontée dans le job ; + - backend custom : URL vérifiée, nom tiré du dépôt (propriétaire ajouté à + un fork nommé llama.cpp) ou assaini, clone ou mise à jour, ref, sans + toucher à BIN. + +Les routes répondent comme le Go : + +- moteur fourni par l'image : 409 et le message du Go ; +- custom sans URL : 400 « URL du dépôt requise » ; +- optimisation en cours : 409 ; +- job déjà lancé : 409 ; +- sinon 200 `{"ok":true}` et le job tourne. + +Le service moteur est celui de la façade si elle le pilote, sinon celui du +Go (`/internal/v1/service/action`), comme pour les binaires précompilés. + +Côté Go, `facadeBackendsRoutes` gagne les trois routes hors Windows. Sous +Windows, la compilation reste au Go, qui rapporte toujours ses étapes à la +façade : + +- générateur Visual Studio ; +- intégration CUDA de MSBuild ; +- `CUDA_PATH`. + +Les messages du terminal du Go (outils manquants, dépendance connue absente +après un configure en échec) vont à la sortie d'erreur de la façade. + +## Parité + +`internal/loki/parity_build_test.go` (Linux) tire du Go un journal de 15 +étapes (`tests/parity_backends/build.json`). De faux git, cmake et +compilateur, rangés dans le journal, sont seuls sur le PATH. Chaque étape +porte : + +- la vue du job (lignes, phase, erreur, commits, fichiers compilés) ; +- BIN ; +- les dépôts qui ont un `build.old`. + +Il couvre : + +- mise à jour sans dépôt ; +- installation, puis installation sur un dépôt présent et installation + forcée ; +- déjà à jour, nouveau commit, recompilation propre ; +- build en échec (fin des journaux) ; +- cmake absent, compilateur absent ; +- clone en échec ; +- custom : URL invalide, nouveau fork, nom assaini sur un ref, dossier + existant sur un ref (URL `git@`). + +`tests/backends.rs` (`compilation_comme_le_go`) rejoue le journal par la +façade : mêmes faux outils dans le PATH des sondes, POST de la route, job +suivi jusqu'au bout. `compilation_refusee` vérifie les refus des routes. + +Les tests unitaires de `build.rs` couvrent : + +- la barre de progression (`TestSinkWriterProgression`) ; +- le scanner (fin `\r\n`, dernier morceau, octets invalides, ligne + démesurée) ; +- la racine du toolkit CUDA (`TestCudaToolkitRoot`) ; +- le cache CMake déplacé ; +- noms et URL des backends custom ; +- le plan par machine (CPU, nvcc du PATH, toolkit versionné, + architectures, HIP, Vulkan) ; +- les erreurs des commandes (code de sortie, signal, programme absent, + journal écrit). diff --git a/docs/superpowers/specs/2026-10-16-rust-migration-lot42-outils-web-design.md b/docs/superpowers/specs/2026-10-16-rust-migration-lot42-outils-web-design.md new file mode 100644 index 0000000..a13f100 --- /dev/null +++ b/docs/superpowers/specs/2026-10-16-rust-migration-lot42-outils-web-design.md @@ -0,0 +1,121 @@ +# Lot 42 — Outils web de l'agent dans la façade + +## Contexte + +La façade tient la conversation par défaut. Les outils web de ses tours +partaient pourtant au Go (`/internal/v1/tool/exec`) : + +- `web_search` ; +- `web_images` ; +- `web_open`, `web_read`, `web_grep`. + +Le cache des pages (10 min) était donc au Go : `web_open` puis `web_read` +faisaient chacun un aller-retour. + +## Le lot + +`src/webtools.rs` porte les outils. Sources Go : + +- `chat_internet_tools.go` : les quatre outils de lecture et de recherche ; +- `chat_internet.go` : client Crawl4AI, cache, lecture des résultats + DuckDuckGo ; +- `chat_web_images.go` : recherche d'images. + +Ce qui est porté : + +- **le cache** : + - clé : l'URL, suivie de l'empreinte MD5 des options qui changent la page + (actions, attente, bandeaux), à l'échappement HTML près du Go ; + - `web_read` et `web_grep` lisent l'ouverture la plus récente d'une URL, + toutes options confondues ; + - 10 minutes ; +- **Crawl4AI** : + - corps envoyé au Go près (clés triées, échappement HTML) ; + - clé d'accès en `Bearer` ; + - réponse `{results:[…]}` ou résultat seul, lue comme `json.Unmarshal` + (types faux, `null`, casse des clés) ; + - markdown en chaîne ou `{raw_markdown, fit_markdown}` ; + - erreurs : HTTP (fin du corps), échec du crawl, réponse vide, serveur + injoignable (texte de l'erreur du client Go) ; +- **les lignes** : `\r\n`, blancs de fin, vides et doublons consécutifs + réduits ; plan des titres ; +- **la recherche par Crawl4AI** : titres, liens `uddg` décodés deux fois + comme le Go, entités HTML, extrait tiré des liens qui suivent, doublons et + liens DuckDuckGo écartés, défi anti-bot ; +- **les images** : + - jeton `vqd` de la page, puis `i.js` ; + - mêmes en-têtes ; + - cookies gardés par hôte, comme le jar du client Go ; + - réponse lue comme `json.Unmarshal` (erreurs de syntaxe et de type au + texte du Go) ; +- **`web_grep`** : + - regex du Go (`(?i)`, classes ASCII) ; + - erreurs au texte de `regexp` pour les fautes courantes ; +- **le plafond** de 8000 caractères ; +- **les arguments** : nombres tronqués comme `int(float64)` du Go. + +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. +Ces deux étapes restent au Go, derrière deux appels sans état : + +- `POST /internal/v1/web/fetch` `{url}` → `{markdown}` ou `{error}` + (`goFetchMarkdown`) ; +- `POST /internal/v1/web/search` `{query, limit}` → `{results}` ou `{error}` + (`goSearch`). + +`ChatShared.web` porte les outils ; `GoTurnHost::exec_tool` les exécute +avant tout le reste. `web_screenshot` reste au Go (navigateur piloté). + +Le traducteur des regex du Go (`go_regex`) refuse désormais : + +- la répétition d'une répétition (`a**`, `a*??`, `a{2}*`) ; +- un compte au-delà de 1000 ou inversé. + +Un `{` qui n'ouvre pas de répétition y devient littéral, comme au Go. Les +motifs `retry_patterns` en profitent. + +Côté Go, pour le journal : + +- `webNow` : l'horloge du cache ; +- `ddgImagesBase` et `ddgHTMLBase` : les adresses de DuckDuckGo. + +## Écart connu + +reqwest pose `Accept: */*` sur une requête qui n'en a pas ; le client Go +n'en pose aucun. Seule la page de recherche d'images est concernée, sans +effet sur DuckDuckGo. + +## Parité + +`internal/loki/parity_web_tools_test.go` tire du Go un journal de 74 +appels (`tests/parity_web/outils.json`). Chaque appel porte : + +- le résultat ; +- les requêtes reçues par le faux Crawl4AI (méthode, clé, type, corps) ; +- les requêtes reçues par le faux DuckDuckGo (chemin, en-têtes, cookies) ; +- les pages et recherches demandées au moteur intégré. + +Il couvre : + +- recherche : limites, anti-bot, aucun résultat, panne ; +- lecture : plages, bornes, au-delà, page jamais ouverte ; +- grep : contexte fusionné, plafond, sans résultat, classes ASCII, sept + regex invalides ; +- cache : réouverture, forcée, options, la plus récente, péremption ; +- GitHub ; +- les réponses de Crawl4AI ; +- serveur injoignable, sans serveur, sans clé ; +- moteur intégré : ouverture, texte brut, page absente, recherche ; +- images : doublons, cookie gardé, jeton absent, pannes, réponses + illisibles ou mal typées, `null`. + +`tests/webtools.rs` le rejoue contre le port. Le faux Go sert ce que le +moteur intégré a tiré des pages. + +D'autres tests complètent le journal : + +- `tests/lot6.rs` (`outil_web_par_la_facade`) : un tour de la façade ouvre + une page sans passer par `/tool/exec` ; +- `TestInternalMoteurWebIntegre` : les deux appels internes du Go ; +- tests unitaires : MD5, clés du cache, URL, entités, nombres, erreurs de + regex, réponses de Crawl4AI. 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/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/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/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/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/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/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/backend_strata.go b/internal/loki/backend_strata.go index b25feff..ec1c145 100644 --- a/internal/loki/backend_strata.go +++ b/internal/loki/backend_strata.go @@ -52,13 +52,23 @@ import ( // autre paquet est remplacé au lancement (strataEnsureEngine). const ( strataVersion = "1.0" - strataReleaseBase = "https://github.com/nathaninline/ajean/releases/download/moe-v" + strataVersion + "/" strataSrcAsset = "ajean-moe-src-" + strataVersion + ".tar.gz" strataEngineAsset = "ajean-moe-engine-" + strataVersion + "-linux-x64-cuda12.zip" // strataEngineLocal : le nom sous lequel l'installeur attend le moteur (CUDA12_ASSET). strataEngineLocal = "strata-linux-x64-cuda12.zip" ) +// strataReleaseBase : la release figée. Une variable pour que les tests la +// pointent sur un faux serveur. +var strataReleaseBase = "https://github.com/nathaninline/ajean/releases/download/moe-v" + strataVersion + "/" + +// meminfoPath, strataDiskFree : la RAM et la place libre lues sur la machine, +// remplaçables par les tests (journal de parité de Strata). +var ( + meminfoPath = "/proc/meminfo" + strataDiskFree = diskFree +) + // strataAssetSHA : empreintes des fichiers de la release, vérifiées avant usage. var strataAssetSHA = map[string]string{ strataSrcAsset: "5747f69426973d957e4f9894a8e3333c6ef0d6ac8565e26ddb8d36f52211cb3b", @@ -154,7 +164,7 @@ const ( // memAvailableGB : la RAM réellement disponible (MemAvailable : libre + cache // récupérable), en Go. Hors Linux ou illisible : la RAM totale. func memAvailableGB() float64 { - b, err := os.ReadFile("/proc/meminfo") + b, err := os.ReadFile(meminfoPath) if err != nil { return totalRAMGB() } @@ -286,7 +296,7 @@ func strataDetect() strataEnv { env.AvailGB = min(memAvailableGB()+strataEngineRSSGB(), env.RAMGB) // strataHome() et pas LokiHome() : /data/strata peut être un disque à part // (SSD dédié monté dans le conteneur), c'est sa place libre qui compte. - if f := diskFree(strataHome()); f > 0 { + if f := strataDiskFree(strataHome()); f > 0 { env.DiskFreeGB = float64(f) / 1e9 } if runtime.GOOS != "linux" || runtime.GOARCH != "amd64" { @@ -1380,25 +1390,33 @@ func handleStrataSettings(w http.ResponseWriter, r *http.Request) { sendJSON(w, 400, map[string]any{"ok": false, "error": err.Error()}) return } + // « Actif » se décide AVANT l'écriture (comme SavePresetApplying) : c'est + // l'ancien contenu qui porte l'empreinte de la configuration courante. Lu + // après, un réglage changé faisait passer le preset en service pour + // inactif, et rien n'était appliqué. + active := false + if list, err := ListPresets(); err == nil { + for _, p := range list { + if p.ID == req.Preset && p.Active { + active = true + } + } + } path := filepath.Join(presetsDir(), req.Preset+".env") if err := os.WriteFile(path, []byte(next), 0o644); err != nil { sendJSON(w, 500, map[string]any{"ok": false, "error": err.Error()}) return } restarted := false - if list, err := ListPresets(); err == nil { - for _, p := range list { - if p.ID == req.Preset && p.Active { - if tuneDenyHTTP(w) { - return - } - if err := SwitchToPreset(path); err != nil { - sendJSON(w, 500, map[string]any{"ok": false, "error": err.Error()}) - return - } - restarted = true - } + if active { + if tuneDenyHTTP(w) { + return } + if err := SwitchToPreset(path); err != nil { + sendJSON(w, 500, map[string]any{"ok": false, "error": err.Error()}) + return + } + restarted = true } sendJSON(w, 200, map[string]any{"ok": true, "restarted": restarted}) } diff --git a/internal/loki/backend_tune_run.go b/internal/loki/backend_tune_run.go index f3b0343..5d81e71 100644 --- a/internal/loki/backend_tune_run.go +++ b/internal/loki/backend_tune_run.go @@ -552,7 +552,7 @@ func runTune(ctx context.Context, opts tuneOpts, tr *tuneTracker) (out *tuneResu res := &tuneResult{PresetID: preset.ID, PresetName: preset.Name, BaseFP: configFingerprint(cfg), PresetFP: presetFingerprint([]byte(content)), OptIn: opts.OptIn, At: time.Now().Unix(), - Weights: tuneWeightsFrom(perfLog.snapshot())} + Weights: tuneWeightsFrom(perfSnapshot())} // Référence : la ligne composée telle quelle, la sonde de la machine. tr.setPhase("référence : composition de la ligne de commande") diff --git a/internal/loki/chat_internet.go b/internal/loki/chat_internet.go index 0f4b642..92ec2a0 100644 --- a/internal/loki/chat_internet.go +++ b/internal/loki/chat_internet.go @@ -347,6 +347,9 @@ var ( const pageCacheTTL = 10 * time.Minute +// webNow : l'horloge du cache des pages (fixée par le journal de parité). +var webNow = time.Now + type fetchOptions struct { force bool actions []string @@ -371,7 +374,7 @@ func findCached(rawURL string) *cacheEntry { var best *cacheEntry for k, v := range pageCache { if k == u || strings.HasPrefix(k, u+"#") { - if time.Since(v.fetchedAt) > pageCacheTTL { + if webNow().Sub(v.fetchedAt) > pageCacheTTL { continue } if best == nil || v.fetchedAt.After(best.fetchedAt) { @@ -388,7 +391,7 @@ func getPage(rawURL string, opts fetchOptions) (*cacheEntry, error) { pageCacheMu.Lock() cached := pageCache[key] pageCacheMu.Unlock() - if !opts.force && cached != nil && time.Since(cached.fetchedAt) < pageCacheTTL { + if !opts.force && cached != nil && webNow().Sub(cached.fetchedAt) < pageCacheTTL { return cached, nil } @@ -418,7 +421,7 @@ func getPage(rawURL string, opts fetchOptions) (*cacheEntry, error) { if strings.TrimSpace(md) == "" { return nil, fmt.Errorf("page vide") } - entry := &cacheEntry{url: u, lines: normalizeLines(md), fetchedAt: time.Now()} + entry := &cacheEntry{url: u, lines: normalizeLines(md), fetchedAt: webNow()} pageCacheMu.Lock() pageCache[key] = entry pageCacheMu.Unlock() @@ -493,6 +496,10 @@ func decodeHTMLEntities(s string) string { }) } +// ddgHTMLBase : le point d'entrée HTML de DuckDuckGo lu par le moteur intégré +// (goSearch ; un faux pour les tests). +var ddgHTMLBase = "https://html.duckduckgo.com" + // decodeUddg : DDG enrobe les résultats en //duckduckgo.com/l/?uddg=URL_ENCODÉE func decodeUddg(raw string) string { s := raw diff --git a/internal/loki/chat_memory.go b/internal/loki/chat_memory.go index 30b2d0c..a5f9e5d 100644 --- a/internal/loki/chat_memory.go +++ b/internal/loki/chat_memory.go @@ -15,6 +15,10 @@ import ( // L'IA y range ce qu'elle veut retenir entre les sessions (préférences, // décisions, procédures, infos projet). Quatre outils : mem_search, mem_read, // mem_add, mem_edit. +// +// Port Rust des outils (lot 40) : rust/loki-rs/src/memory/tools.rs, employé +// par les tours de la façade quand elle tient la mémoire (LOKI_MEM=rust) ; +// journal de parité dans parity_mem_tools_test.go. type MemPage struct { Name string // nom de fichier sans le dossier (ex: "docker-notes.md") diff --git a/internal/loki/chat_prewarm.go b/internal/loki/chat_prewarm.go index 15ab32d..fc6884e 100644 --- a/internal/loki/chat_prewarm.go +++ b/internal/loki/chat_prewarm.go @@ -245,6 +245,8 @@ func prewarmNearMidnight(now time.Time) bool { // Points d'accroche remplaçables par les tests. var ( + // prewarmNow : l'heure du garde-fou de minuit. + prewarmNow = time.Now // prewarmAsync : false = le préchauffage tourne dans l'appelant (tests). prewarmAsync = true // prewarmSlots : nombre de slots du moteur local (/props total_slots). @@ -265,10 +267,16 @@ var ( ) // prewarmKick : un déclencheur. Sans la clé, rien — ni goroutine, ni requête. +// Quand la façade tient la conversation, le déclencheur lui part +// (facade_prewarm.go) : c'est elle qui a l'historique. func (c *Conversation) prewarmKick(why string) { if prewarmMode(ReadConfig()) == "" { return } + if facadeChat() { + go func() { _ = facadePost("/internal/facade/v1/chat/prewarm", map[string]any{"why": why}) }() + return + } if prewarmAsync { go c.prewarm(why) return @@ -375,7 +383,7 @@ func (c *Conversation) prewarm(why string) { return } } - if sideJobs.Load() > 0 || benchRunning() || tuneGuard() != nil || prewarmNearMidnight(time.Now()) { + if sideJobs.Load() > 0 || benchRunning() || tuneGuard() != nil || prewarmNearMidnight(prewarmNow()) { return } if !healthCheck() { diff --git a/internal/loki/chat_prewarm_test.go b/internal/loki/chat_prewarm_test.go index 9af4ae0..65cab72 100644 --- a/internal/loki/chat_prewarm_test.go +++ b/internal/loki/chat_prewarm_test.go @@ -79,6 +79,11 @@ func (m *moteurPrewarm) completions() []string { // prewarmSetup : base neuve, discussion active, faux moteur à un slot, // préchauffage synchrone. keys : réglages posés. func prewarmSetup(t *testing.T, keys map[string]string) *moteurPrewarm { + // Le préchauffage se tait dans les dix minutes avant minuit : midi ici, + // pour que les tests ne dépendent pas de l'heure à laquelle ils tournent. + oldNow := prewarmNow + prewarmNow = func() time.Time { return time.Date(2026, 1, 1, 12, 0, 0, 0, time.Local) } + t.Cleanup(func() { prewarmNow = oldNow }) t.Helper() withWorkspace(t) activeProjMu.Lock() 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/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/chat_web_images.go b/internal/loki/chat_web_images.go index f8442e5..3f297cb 100644 --- a/internal/loki/chat_web_images.go +++ b/internal/loki/chat_web_images.go @@ -46,6 +46,9 @@ type imageResult struct { var ddgVqdRe = regexp.MustCompile(`vqd=["']?([0-9-]+)`) +// ddgImagesBase : DuckDuckGo (un faux pour les tests). +var ddgImagesBase = "https://duckduckgo.com" + func ddgGet(ctx context.Context, target, referer string) ([]byte, error) { req, err := http.NewRequestWithContext(ctx, http.MethodGet, target, nil) if err != nil { @@ -75,7 +78,7 @@ func ddgGet(ctx context.Context, target, referer string) ([]byte, error) { func duckduckgoImages(query string, limit int) ([]imageResult, error) { ctx, cancel := context.WithTimeout(context.Background(), goFetchTimeout) defer cancel() - page := "https://duckduckgo.com/?q=" + url.QueryEscape(query) + "&iax=images&ia=images" + page := ddgImagesBase + "/?q=" + url.QueryEscape(query) + "&iax=images&ia=images" body, err := ddgGet(ctx, page, "") if err != nil { return nil, err @@ -84,7 +87,7 @@ func duckduckgoImages(query string, limit int) ([]imageResult, error) { if m == nil { return nil, fmt.Errorf("jeton DuckDuckGo introuvable (défi anti-bot ?)") } - api := "https://duckduckgo.com/i.js?l=fr-fr&o=json&f=,,,,,&p=1&q=" + url.QueryEscape(query) + "&vqd=" + string(m[1]) + api := ddgImagesBase + "/i.js?l=fr-fr&o=json&f=,,,,,&p=1&q=" + url.QueryEscape(query) + "&vqd=" + string(m[1]) body, err = ddgGet(ctx, api, "https://duckduckgo.com/") if err != nil { return nil, err diff --git a/internal/loki/code_criteria.go b/internal/loki/code_criteria.go index eb7830c..d1b7323 100644 --- a/internal/loki/code_criteria.go +++ b/internal/loki/code_criteria.go @@ -9,6 +9,10 @@ package loki // // Rangés par discussion dans bbolt (bucket chat, clé crit:) : ils suivent // la discussion, sa suppression les emporte. +// +// Port Rust de l'outil (lot 38) : rust/loki-rs/src/chat/crittool.rs, employé +// par les tours de la façade ; journal de parité dans +// parity_criteria_test.go. import ( "encoding/json" 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/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/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/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_backends.go b/internal/loki/facade_backends.go new file mode 100644 index 0000000..11389b5 --- /dev/null +++ b/internal/loki/facade_backends.go @@ -0,0 +1,167 @@ +package loki + +import ( + "bytes" + "encoding/json" + "errors" + "fmt" + "net/http" + "os" + "runtime" + "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, et compile elle-même llama.cpp +// depuis les sources sous Linux et macOS (install, update, install-custom ; +// lot 41, backends/build.rs). Ceux que le Go fait encore tourner — compilation +// sous Windows (chaîne MSVC), 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, +} + +func init() { + // La compilation depuis les sources part aussi à la façade (lot 41), sauf + // sous Windows : générateur Visual Studio et intégration CUDA de MSBuild + // restent au Go. + if runtime.GOOS != "windows" { + for _, p := range []string{"/api/llamacpp/install", "/api/llamacpp/update", "/api/llamacpp/install-custom"} { + facadeBackendsRoutes[p] = 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_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/facade_bench.go b/internal/loki/facade_bench.go new file mode 100644 index 0000000..318f770 --- /dev/null +++ b/internal/loki/facade_bench.go @@ -0,0 +1,95 @@ +package loki + +import ( + "net/http" + "os" + "strings" + "sync" +) + +// facade_bench.go — le banc d'essai tenu par la façade Rust (LOKI_BENCH=rust, +// lot 27 de la migration ; expérimental, le Go reste le défaut) : les routes +// /api/bench*, le job et le protocole de mesure (rust/loki-rs/src/bench). Le +// verrou de génération reste celui de la conversation du Go : la façade le +// prend par le bail du moteur, avec le motif « bench » (benching, et le refus +// errBenchBusy pour les autres). Le Go lui prête aussi l'isolation des travaux +// annexes (effacement du slot à la fin) et la télémétrie (perf_log.go). + +const facadeBenchEnv = "LOKI_BENCH" + +// facadeBench : la façade tient-elle le banc d'essai ? +func facadeBench() bool { + return strings.EqualFold(strings.TrimSpace(os.Getenv(facadeBenchEnv)), "rust") && + facadeAddr() != "" && internalToken() != "" +} + +// facadeBenchRoutes : les routes que la façade sert alors. +var facadeBenchRoutes = map[string]bool{ + "/api/bench": true, + "/api/bench/status": true, + "/api/bench/cancel": true, + "/api/bench/last": true, +} + +// facadeSideJobs : les travaux annexes ouverts par la façade (id → fin). +var facadeSideJobs = struct { + mu sync.Mutex + seq int + m map[int]func() +}{m: map[int]func(){}} + +// handleInternalSideJob : POST {} ouvre un travail annexe (engineSideJob) et +// rend son id ; POST {"end": id, "served": bool} le ferme — served : le +// moteur a servi au moins une requête du travail (engineServed), le slot +// porte donc son état et peut être effacé. +func handleInternalSideJob(w http.ResponseWriter, r *http.Request) { + var in struct { + End int `json:"end"` + Served bool `json:"served"` + } + if !readLeaseBody(w, r, &in) { + return + } + if in.End == 0 { + done := engineSideJob() + facadeSideJobs.mu.Lock() + facadeSideJobs.seq++ + id := facadeSideJobs.seq + facadeSideJobs.m[id] = done + facadeSideJobs.mu.Unlock() + sendJSON(w, http.StatusOK, map[string]any{"id": id}) + return + } + facadeSideJobs.mu.Lock() + done := facadeSideJobs.m[in.End] + delete(facadeSideJobs.m, in.End) + facadeSideJobs.mu.Unlock() + if done == nil { + sendJSON(w, http.StatusNotFound, map[string]any{"error": "travail annexe inconnu"}) + return + } + if in.Served { + engineServed() + } + done() + w.WriteHeader(http.StatusNoContent) +} + +// handleInternalBenchPerf : la trace d'un bench de la façade dans la +// télémétrie (le bench a pris le slot : la perte de cache du tour suivant lui +// revient). +func handleInternalBenchPerf(w http.ResponseWriter, r *http.Request) { + var in struct { + PromptN int `json:"prompt_n"` + PromptMs float64 `json:"prompt_ms"` + PromptPerSecond float64 `json:"prompt_per_second"` + PredictedN int `json:"predicted_n"` + PredictedPerSec float64 `json:"predicted_per_second"` + } + if !readLeaseBody(w, r, &in) { + return + } + perfRecord(perfRec{Kind: perfBench, Complete: true, Total: in.PromptN, New: in.PromptN, + PPms: in.PromptMs, PPtps: in.PromptPerSecond, Gen: in.PredictedN, TGtps: in.PredictedPerSec}, nil) + w.WriteHeader(http.StatusNoContent) +} diff --git a/internal/loki/facade_bench_test.go b/internal/loki/facade_bench_test.go new file mode 100644 index 0000000..9fa8f5e --- /dev/null +++ b/internal/loki/facade_bench_test.go @@ -0,0 +1,167 @@ +package loki + +import ( + "encoding/json" + "io" + "net/http" + "net/http/httptest" + "strconv" + "strings" + "sync" + "testing" + "time" +) + +// Le banc d'essai part à la façade avec LOKI_BENCH=rust (lot 27) ; le reste, +// non. La façade tient le verrou de génération par le bail du moteur, motif +// « bench », et le Go lui prête le travail annexe et la télémétrie. +func TestFacadeBench(t *testing.T) { + testHome(t) + var mu sync.Mutex + 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 + } + mu.Lock() + calls = append(calls, r.Method+" "+r.URL.Path) + mu.Unlock() + 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(facadeBenchEnv, "") + if facadeBench() { + t.Fatal("tenu sans LOKI_BENCH") + } + for p := range facadeBenchRoutes { + w := httptest.NewRecorder() + withFacadeChat(p, local)(w, httptest.NewRequest("POST", p, strings.NewReader("{}"))) + if w.Body.String() != "go" { + t.Fatalf("sans LOKI_BENCH, %s : %q", p, w.Body.String()) + } + } + t.Setenv(facadeBenchEnv, " RUST") + if len(facadeBenchRoutes) != 4 { + t.Fatalf("routes : %v", facadeBenchRoutes) + } + for p := range facadeBenchRoutes { + 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()) + } + } + mu.Lock() + n := len(calls) + mu.Unlock() + if n != len(facadeBenchRoutes) { + t.Fatalf("relais : %v", calls) + } + w := httptest.NewRecorder() + withFacadeChat("/api/tune", local)(w, httptest.NewRequest("POST", "/api/tune", nil)) + if w.Body.String() != "go" { + t.Fatalf("l'optimiseur reste au Go : %q", w.Body.String()) + } + + // Le bail « bench » : le verrou, le refus du bench pour les autres. + post := func(h http.HandlerFunc, path, body string) (int, map[string]any) { + rec := httptest.NewRecorder() + h(rec, httptest.NewRequest("POST", path, strings.NewReader(body))) + var out map[string]any + _ = json.Unmarshal(rec.Body.Bytes(), &out) + return rec.Code, out + } + code, out := post(handleInternalLease, "/internal/v1/engine/lease", `{"ttl_ms":60000,"why":"bench"}`) + if code != 200 { + t.Fatalf("bail : %d %v", code, out) + } + id, _ := out["lease"].(string) + if !benchRunning() || conv.busyReason() != errBenchBusy { + t.Fatalf("bail du bench : running=%v refus=%v", benchRunning(), conv.busyReason()) + } + if code, out := post(handleInternalLease, "/internal/v1/engine/lease", `{}`); code != 409 || out["error"] != errBenchBusy.Error() { + t.Fatalf("second bail : %d %v", code, out) + } + // Changer de discussion ne rompt pas le bail d'un bench. + conv.loadFrom("autre", nil) + if !benchRunning() || !conv.isGenerating() { + t.Fatal("le bail du bench a sauté à la bascule de discussion") + } + if code, _ := post(handleInternalLeaseRelease, "/internal/v1/engine/lease/release", `{"lease":"`+id+`"}`); code != 204 { + t.Fatalf("libération : %d", code) + } + if benchRunning() || conv.isGenerating() || conv.busyReason() == errBenchBusy { + t.Fatal("bail rendu : le verrou devait être libre") + } + // Un bail ordinaire n'est pas un bench ; un bail de bench expiré rend + // le verrou et le refus. + code, out = post(handleInternalLease, "/internal/v1/engine/lease", `{}`) + if code != 200 || benchRunning() { + t.Fatalf("bail ordinaire : %d %v bench=%v", code, out, benchRunning()) + } + post(handleInternalLeaseRelease, "/internal/v1/engine/lease/release", `{"lease":"`+out["lease"].(string)+`"}`) + conv.mu.Lock() + conv.extLeaseClearLocked() + conv.Generating = false + conv.mu.Unlock() + if _, err := conv.extLeaseAcquire(5*time.Second, errBenchBusy); err != nil { + t.Fatal(err) + } + conv.mu.Lock() + st := conv.extLease + conv.mu.Unlock() + conv.extLeaseExpire(st) + if benchRunning() || conv.busyReason() == errBenchBusy { + t.Fatal("bail de bench expiré : refus du bench encore là") + } + + // Le travail annexe ouvert puis fermé par la façade. + code, out = post(handleInternalSideJob, "/internal/v1/engine/sidejob", `{}`) + job, _ := out["id"].(float64) + if code != 200 || job <= 0 || sideJobs.Load() != 1 { + t.Fatalf("ouverture : %d %v (en cours : %d)", code, out, sideJobs.Load()) + } + served := engineServedCount() + if code, _ := post(handleInternalSideJob, "/internal/v1/engine/sidejob", `{"end":`+strconv.Itoa(int(job))+`,"served":true}`); code != 204 { + t.Fatalf("fermeture : %d", code) + } + if sideJobs.Load() != 0 || engineServedCount() != served+1 { + t.Fatalf("après la fermeture : en cours %d, servies %d", sideJobs.Load(), engineServedCount()-served) + } + if code, _ := post(handleInternalSideJob, "/internal/v1/engine/sidejob", `{"end":999}`); code != 404 { + t.Fatalf("travail inconnu : %d", code) + } + rec := httptest.NewRecorder() + handleInternalSideJob(rec, httptest.NewRequest("GET", "/internal/v1/engine/sidejob", nil)) + if rec.Code != 405 { + t.Fatalf("GET : %d", rec.Code) + } + + // La trace du bench dans la télémétrie. + var before uint64 + if recs := perfLog.snapshot(); len(recs) > 0 { + before = recs[len(recs)-1].Seq + } + if code, _ := post(handleInternalBenchPerf, "/internal/v1/bench/perf", + `{"prompt_n":2036,"prompt_ms":1018,"prompt_per_second":2000,"predicted_n":300,"predicted_per_second":50}`); code != 204 { + t.Fatalf("trace : %d", code) + } + recs := perfLog.snapshot() + if len(recs) == 0 || recs[len(recs)-1].Seq != before+1 { + t.Fatalf("trace absente (dernière avant : %d)", before) + } + r := recs[len(recs)-1] + if r.Kind != perfBench || !r.Complete || r.Total != 2036 || r.New != 2036 || r.Gen != 300 || r.TGtps != 50 || r.PPtps != 2000 || r.PPms != 1018 { + t.Fatalf("trace : %+v", r) + } + + t.Setenv(internalTokenEnv, "") + if facadeBench() { + t.Fatal("tenu sans jeton interne") + } +} 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/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 1bd95de..43f6a22 100644 --- a/internal/loki/facade_chat.go +++ b/internal/loki/facade_chat.go @@ -39,67 +39,14 @@ const ( ) // facadeChat : la façade tient-elle la conversation ? Il faut aussi son -// adresse et le jeton interne, sans lesquels rien ne peut lui être remis — et -// aucun réglage qu'elle ne sait pas honorer (facadeChatBlocker). +// adresse et le jeton interne, sans lesquels rien ne peut lui être remis. +// Plus aucun réglage ne garde la conversation ici : la façade honore +// NUDGE_IN_TOOL (lot 23), les motifs de relance personnalisés (lot 30), +// SIDE_SLOT et SLOT_PERSIST (lot 31), KEEP_TURN_IMAGES (lot 32), PREWARM +// (lot 33) et PROJ_SNAPSHOT (lot 34, facade_projsnap.go). func facadeChat() bool { return strings.EqualFold(strings.TrimSpace(os.Getenv(facadeChatEnv)), "rust") && - facadeAddr() != "" && internalToken() != "" && facadeChatDecision() == "" -} - -// 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). -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): - return "SLOT_PERSIST" - case prewarmMode(cfg) != "": - return "PREWARM" - case strings.TrimSpace(getStr(bkState, "retry_patterns")) != "": - return "retry_patterns" - } - 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 -// (/internal/v1/chat/holder) et s'y range. -var ( - facadeDecisionMu sync.Mutex - facadeDecisionDone bool - facadeDecisionWhy string -) - -func facadeChatDecision() string { - facadeDecisionMu.Lock() - defer facadeDecisionMu.Unlock() - if !facadeDecisionDone { - facadeDecisionWhy = facadeChatBlocker(ReadConfig()) - facadeDecisionDone = true - if facadeDecisionWhy != "" { - fmt.Fprintf(os.Stderr, "[chat] %s posé : la conversation reste tenue par le Go (LOKI_CHAT=rust ignoré)\n", facadeDecisionWhy) - } - } - return facadeDecisionWhy + facadeAddr() != "" && internalToken() != "" } // handleInternalChatHolder : qui tient la conversation ({"holder":"rust"} ou @@ -113,7 +60,7 @@ func handleInternalChatHolder(w http.ResponseWriter, r *http.Request) { sendJSON(w, http.StatusOK, map[string]any{"holder": "rust"}) return } - why := facadeChatDecision() + why := "adresse de la façade ou jeton interne manquant" if !strings.EqualFold(strings.TrimSpace(os.Getenv(facadeChatEnv)), "rust") { why = facadeChatEnv + " absent" } @@ -123,8 +70,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,34 +85,98 @@ 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, + // 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 -// 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 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), 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), la mise à +// jour (facade_update.go), les backends llama.cpp (facade_backends.go), le +// moteur de l'image (facade_engineimg.go), Strata (facade_strata.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 facadeModelRoutes[path]: + held, unavailable = facadeModels, "modèles indisponibles : " + case facadeDictateRoutes[path]: + held, unavailable = facadeDictate, "dictée indisponible : " + case facadeSettingsRoutes[path]: + held, unavailable = facadeSettings, "réglages indisponibles : " + case facadeFilesRoutes[path]: + held, unavailable = facadeFiles, "fichiers indisponibles : " + case facadePushRoutes[path]: + held, unavailable = facadePush, "notifications indisponibles : " + case facadeMemRoutes[path]: + held, unavailable = facadeMem, "mémoire indisponible : " + case facadePresetsRoutes[path]: + 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 : " + case facadeProjectsRoutes[path]: + held, unavailable = facadeProjects, "projets indisponibles : " + case facadeMCPRoutes[path]: + held, unavailable = facadeMCP, "serveurs MCP indisponibles : " + case facadeUpdateRoutes[path]: + held, unavailable = facadeUpdate, "mise à jour indisponible : " + case facadeBackendsRoutes[path]: + held, unavailable = facadeBackends, "moteurs indisponibles : " + case facadeEngineImageRoutes[path]: + held, unavailable = facadeEngineImage, "moteur de l'image indisponible : " + case facadeStrataRoutes[path]: + held, unavailable = facadeStrata, "Strata indisponible : " + case facadeTasksRoutes[path]: + held, unavailable = facadeTasks, "tâches indisponibles : " + case facadeBenchRoutes[path]: + held, unavailable = facadeBench, "banc d'essai indisponible : " + case facadeTuneRoutes[path]: + held, unavailable = facadeTune, "optimiseur indisponible : " + case facadeCoreRoutes[path]: + held, unavailable = facadeCore, "façade indisponible : " + 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()}) + // 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 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) @@ -197,6 +208,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 70282f1..184110c 100644 --- a/internal/loki/facade_chat_test.go +++ b/internal/loki/facade_chat_test.go @@ -2,12 +2,17 @@ package loki import ( "encoding/json" + "errors" "io" "net/http" "net/http/httptest" + "os" + "path/filepath" + "runtime" "strings" "sync" "testing" + "time" ) // fausseFacade : enregistre ce qui lui arrive ; /api/chat rend un flux. @@ -23,22 +28,8 @@ type fausseFacade struct { internalBody string } -// oublierDecisionFacade : la décision « qui tient la conversation » est prise -// une fois par process ; chaque test repart de zéro. -func oublierDecisionFacade(t *testing.T) { - t.Helper() - reset := func() { - facadeDecisionMu.Lock() - facadeDecisionDone, facadeDecisionWhy = false, "" - facadeDecisionMu.Unlock() - } - reset() - t.Cleanup(reset) -} - func demarrerFacade(t *testing.T) *fausseFacade { t.Helper() - oublierDecisionFacade(t) f := &fausseFacade{t: t, reply: `{"ok":true,"de":"facade"}`} f.srv = httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { b, _ := io.ReadAll(r.Body) @@ -94,17 +85,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é aussi : le navigateur est le sien (lot 44). w = httptest.NewRecorder() - withFacadeChat("/api/chat/mode", local)(w, httptest.NewRequest("POST", "/api/chat/mode", nil)) - if w.Body.String() != "go" { - t.Fatalf("mode relayé : %q", w.Body.String()) + withFacadeChat("/api/chat/cushot", local)(w, httptest.NewRequest("GET", "/api/chat/cushot", nil)) + 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()) != 1 { + if w.Body.String() != "go" || len(f.recu()) != 5 { t.Fatalf("relayé sans la variable : %q", w.Body.String()) } } @@ -254,52 +253,887 @@ func TestFacadeActivationDeProjet(t *testing.T) { } } -// Un réglage que la façade ne sait pas honorer garde la conversation ici : -// rien ne lui est remis, et elle l'apprend au démarrage. La décision tient -// jusqu'au redémarrage. -func TestFacadeReglageQuiGardeLaConversation(t *testing.T) { +// Plus aucun réglage ne garde la conversation ici (lot 34) : avec chacune des +// clés opt-in posée, la façade la tient et le trafic de chat lui est remis. +// Sans LOKI_CHAT=rust, ou sans son adresse : le Go, et pourquoi. +func TestFacadeAucunReglageNeGardeLaConversation(t *testing.T) { testHome(t) f := demarrerFacade(t) t.Setenv(internalTokenEnv, jetonTest) + for _, k := range []string{"PROJ_SNAPSHOT", "NUDGE_IN_TOOL", "SIDE_SLOT", "SLOT_PERSIST", "KEEP_TURN_IMAGES", "PREWARM"} { + if err := SetConfigKey(k, "on"); err != nil { + t.Fatal(err) + } + } + putStr(bkState, "retry_patterns", `["oups"]`) + if !facadeChat() { + t.Fatal("réglages opt-in posés : la façade doit tenir la conversation") + } if w := appelOAIInterne(t, "GET", "/internal/v1/chat/holder", ""); w.Code != 200 || !strings.Contains(w.Body.String(), `"holder":"rust"`) { t.Fatalf("détenteur : %d %s", w.Code, w.Body.String()) } - // Posé après la décision : sans effet avant le redémarrage. - if err := SetConfigKey("KEEP_TURN_IMAGES", "on"); err != nil { - t.Fatal(err) - } - if !facadeChat() { - t.Fatal("décision changée en cours de route") - } - oublierDecisionFacade(t) - if facadeChat() { - t.Fatal("KEEP_TURN_IMAGES posé : la conversation doit rester au Go") - } - w := appelOAIInterne(t, "GET", "/internal/v1/chat/holder", "") - if !strings.Contains(w.Body.String(), `"holder":"go"`) || !strings.Contains(w.Body.String(), "KEEP_TURN_IMAGES") { - t.Fatalf("détenteur : %s", w.Body.String()) - } local := func(w http.ResponseWriter, r *http.Request) { io.WriteString(w, "go") } rec := httptest.NewRecorder() withFacadeChat("/api/chat/send", local)(rec, httptest.NewRequest("POST", "/api/chat/send", strings.NewReader(`{}`))) - if rec.Body.String() != "go" || len(f.recu()) != 0 { - t.Fatalf("remis à la façade malgré le réglage : %q %v", rec.Body.String(), f.recu()) + if rec.Body.String() == "go" || len(f.recu()) != 1 { + t.Fatalf("pas remis à la façade : %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", - "SLOT_PERSIST": "SLOT_PERSIST", "PREWARM": "PREWARM", - } { - if got := facadeChatBlocker(map[string]string{k: "on"}); got != want { - t.Errorf("%s : %q", k, got) - } + putStr(bkState, "retry_patterns", "") + t.Setenv(facadeAddrEnv, "") + if w := appelOAIInterne(t, "GET", "/internal/v1/chat/holder", ""); !strings.Contains(w.Body.String(), "adresse de la façade") { + t.Fatalf("sans adresse : %s", w.Body.String()) } - if got := facadeChatBlocker(map[string]string{}); got != "" { - t.Errorf("sans réglage : %q", got) - } - // Sans LOKI_CHAT=rust : le Go, et pourquoi. - oublierDecisionFacade(t) t.Setenv(facadeChatEnv, "") if w := appelOAIInterne(t, "GET", "/internal/v1/chat/holder", ""); !strings.Contains(w.Body.String(), "LOKI_CHAT absent") { t.Fatalf("sans la variable : %s", w.Body.String()) } } + +// 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(facadeModelsEnv, "rust") + local := func(w http.ResponseWriter, r *http.Request) { io.WriteString(w, "go") } + + 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) != 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 routes voisines aussi. + for _, p := range []string{"/api/chat/send", "/api/mem/health", "/api/presets"} { + 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(), "modèles indisponibles : ") { + t.Fatalf("façade muette : %d %s", w.Code, w.Body.String()) + } + // 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" { + 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 +// 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) + 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(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(facadeModelsEnv, "") + cleanStartupPartFiles() + if _, err := os.Stat(part); !os.IsNotExist(err) { + 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) + } +} + +// 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()) + } +} + +// 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) + } +} + +// 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) + } +} + +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()) + } + } +} + +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) + } +} + +// 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) + } +} + +// 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) + } +} + +// 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) + } +} + +// 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) + } +} + +// 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()) + } + } + } +} + +// 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 depuis les sources part aussi (lot 41), sauf sous Windows. + for _, p := range []string{"/api/llamacpp/install", "/api/llamacpp/update", "/api/llamacpp/install-custom"} { + if facadeBackendsRoutes[p] != (runtime.GOOS != "windows") { + t.Fatalf("%s : tenu par la façade = %v sous %s", p, facadeBackendsRoutes[p], runtime.GOOS) + } + } + 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 +} + +// 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) + 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") + } +} + +// Le panneau « moteur de l'image » part à la façade avec LOKI_ENGINE_IMAGE=rust +// (lot 24) ; la commande du dernier lancement reste au Go. +func TestFacadeMoteurImage(t *testing.T) { + testHome(t) + var mu sync.Mutex + 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 + } + mu.Lock() + calls = append(calls, r.Method+" "+r.URL.Path) + mu.Unlock() + 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(facadeEngineImageEnv, "") + for p := range facadeEngineImageRoutes { + w := httptest.NewRecorder() + withFacadeChat(p, local)(w, httptest.NewRequest("POST", p, strings.NewReader("{}"))) + if w.Body.String() != "go" { + t.Fatalf("sans LOKI_ENGINE_IMAGE, %s : %q", p, w.Body.String()) + } + } + if len(calls) != 0 { + t.Fatalf("appels à la façade sans LOKI_ENGINE_IMAGE : %v", calls) + } + + t.Setenv(facadeEngineImageEnv, "Rust ") + if len(facadeEngineImageRoutes) != 7 { + t.Fatalf("routes : %v", facadeEngineImageRoutes) + } + for p := range facadeEngineImageRoutes { + 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()) + } + } + mu.Lock() + n := len(calls) + mu.Unlock() + if n != len(facadeEngineImageRoutes) { + t.Fatalf("relais : %v", calls) + } + w := httptest.NewRecorder() + withFacadeChat("/api/llamacpp/install", local)(w, httptest.NewRequest("POST", "/api/llamacpp/install", nil)) + if w.Body.String() != "go" { + t.Fatalf("/api/llamacpp/install doit rester au Go : %q", w.Body.String()) + } + + // Sans jeton interne, rien ne part. + t.Setenv(internalTokenEnv, "") + if facadeEngineImage() { + t.Fatal("tenu sans jeton interne") + } +} diff --git a/internal/loki/facade_core.go b/internal/loki/facade_core.go new file mode 100644 index 0000000..c5ba4ee --- /dev/null +++ b/internal/loki/facade_core.go @@ -0,0 +1,26 @@ +package loki + +// facade_core.go — les routes de base que la façade sert toujours (api.rs, +// lot 35 de la migration) : santé, état, configuration, ligne de commande du +// moteur, mémoire vive et vidéo, journal du service, chemins, couches du +// modèle. Quand elles arrivent par le tunnel du relais (accès distant, E2E), +// le Go les lui remet aussi, comme les routes des lots : ses propres +// handlers ne servent plus que sans façade (LOKI_FACADE=0). + +var facadeCoreRoutes = map[string]bool{ + "/api/ping": true, + "/api/status": true, + "/api/config": true, + "/api/engine/cmdline": true, + "/api/model/layers": true, + "/api/paths": true, + "/api/ram": true, + "/api/vram": true, + "/api/service/log": true, +} + +// facadeCore : une façade tourne devant ce processus (son adresse et le +// jeton interne sont là). +func facadeCore() bool { + return facadeAddr() != "" && internalToken() != "" +} diff --git a/internal/loki/facade_core_test.go b/internal/loki/facade_core_test.go new file mode 100644 index 0000000..5e64593 --- /dev/null +++ b/internal/loki/facade_core_test.go @@ -0,0 +1,49 @@ +package loki + +import ( + "io" + "net/http" + "net/http/httptest" + "strings" + "testing" +) + +// Lot 35 : les routes de base arrivent à la façade par le tunnel du relais +// dès qu'elle tourne, sans variable ; sans façade (LOKI_FACADE=0 : ni +// adresse ni jeton), le Go les sert lui-même. +func TestFacadeRelaieLesRoutesDeBase(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 facadeCoreRoutes { + 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 got := f.recu(); len(got) != len(facadeCoreRoutes) || !strings.HasSuffix(strings.TrimSpace(got[0]), "jeton") { + t.Fatalf("reçu par la façade : %v", got) + } + // Une route que le Go garde : ici. + w := httptest.NewRecorder() + withFacadeChat("/api/mem/health", local)(w, httptest.NewRequest("GET", "/api/mem/health", nil)) + if w.Body.String() != "go" { + t.Fatalf("route du Go relayée : %q", w.Body.String()) + } + // Façade muette : 502 qui le dit. + f.srv.Close() + w = httptest.NewRecorder() + withFacadeChat("/api/status", local)(w, httptest.NewRequest("GET", "/api/status", nil)) + if w.Code != http.StatusBadGateway || !strings.Contains(w.Body.String(), "façade indisponible : ") { + t.Fatalf("façade muette : %d %s", w.Code, w.Body.String()) + } + // Sans façade : le Go sert. + t.Setenv(facadeAddrEnv, "") + w = httptest.NewRecorder() + withFacadeChat("/api/status", local)(w, httptest.NewRequest("GET", "/api/status", nil)) + if w.Body.String() != "go" { + t.Fatalf("sans façade : %q", w.Body.String()) + } +} 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/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/facade_engineimg.go b/internal/loki/facade_engineimg.go new file mode 100644 index 0000000..da16e39 --- /dev/null +++ b/internal/loki/facade_engineimg.go @@ -0,0 +1,34 @@ +package loki + +import ( + "os" + "strings" +) + +// facade_engineimg.go — panneau « moteur de l'image » tenu par la façade Rust +// (LOKI_ENGINE_IMAGE=rust, lot 24 de la migration ; expérimental, le Go reste +// le défaut) : version qui tourne et versions installées, dernier build publié +// sur le registre, mise à jour (job des moteurs, tenu par la façade avec +// LOKI_BACKENDS=rust), bascules, suppression, version recommandée, retour à la +// version précédente et vérification du rendu du gabarit +// (rust/loki-rs/src/engineimg). loki-rs ne passe la variable qu'avec les +// backends. /api/engine/cmdline reste ici. + +const facadeEngineImageEnv = "LOKI_ENGINE_IMAGE" + +// facadeEngineImage : la façade tient-elle le panneau ? +func facadeEngineImage() bool { + return strings.EqualFold(strings.TrimSpace(os.Getenv(facadeEngineImageEnv)), "rust") && + facadeAddr() != "" && internalToken() != "" +} + +// facadeEngineImageRoutes : les routes que la façade sert alors. +var facadeEngineImageRoutes = map[string]bool{ + "/api/engine": true, + "/api/engine/check": true, + "/api/engine/update": true, + "/api/engine/use": true, + "/api/engine/remove": true, + "/api/engine/plan": true, + "/api/engine/rollback": true, +} 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/facade_keepimg.go b/internal/loki/facade_keepimg.go new file mode 100644 index 0000000..de26039 --- /dev/null +++ b/internal/loki/facade_keepimg.go @@ -0,0 +1,47 @@ +package loki + +import ( + "net/http" +) + +// facade_keepimg.go — KEEP_TURN_IMAGES pour le tour tenu par la façade (lot 32 +// de la migration). L'état du tour (images à l'essai, mesure, budget) vit dans +// la façade ; ce qui touche aux fichiers et à la vision reste ici : +// +// - /internal/v1/turn/keepimg : la vision est-elle réellement active +// (visionEnabled, engineSeesImages) et le budget des images gardées +// (keepImgBudget) ; +// - /internal/v1/turn/keepimg/store : une image relayée rangée par +// référence (refRelayImage : fichier chiffré si la mémoire l'est, date +// rafraîchie, relu à l'octet près). + +// handleInternalTurnKeepImg : POST {} → {"vision":bool,"budget":n}. +func handleInternalTurnKeepImg(w http.ResponseWriter, r *http.Request) { + var in struct{} + if !readInternalJSON(w, r, &in) { + return + } + sendJSON(w, http.StatusOK, map[string]any{ + "vision": visionEnabled() && engineSeesImages(), + "budget": keepImgBudget(), + }) +} + +// handleInternalTurnKeepImgStore : POST {"msg":Message} → +// {"ok":true,"msg":Message,"weight":octets} ou {"ok":false} (format inconnu, +// disque plein, mémoire verrouillée, relecture différente : l'image reste +// éphémère). +func handleInternalTurnKeepImgStore(w http.ResponseWriter, r *http.Request) { + var in struct { + Msg Message `json:"msg"` + } + if !readInternalJSON(w, r, &in) { + return + } + m, weight, ok := refRelayImage(in.Msg) + if !ok { + sendJSON(w, http.StatusOK, map[string]any{"ok": false}) + return + } + sendJSON(w, http.StatusOK, map[string]any{"ok": true, "msg": m, "weight": weight}) +} diff --git a/internal/loki/facade_keepimg_test.go b/internal/loki/facade_keepimg_test.go new file mode 100644 index 0000000..48d142f --- /dev/null +++ b/internal/loki/facade_keepimg_test.go @@ -0,0 +1,87 @@ +package loki + +import ( + "encoding/json" + "os" + "strings" + "testing" +) + +// L'état que la façade demande au début d'un tour : vision réellement active +// (projecteur déclaré ET vu par le moteur) et budget des images gardées. +func TestFacadeKeepImgEtat(t *testing.T) { + testHome(t) + t.Setenv(internalTokenEnv, jetonTest) + if err := SetConfigKey("CTX", "2000"); err != nil { + t.Fatal(err) + } + etat := func() (bool, float64) { + w := appelOAIInterne(t, "POST", "/internal/v1/turn/keepimg", `{}`) + var out struct { + Vision bool `json:"vision"` + Budget float64 `json:"budget"` + } + if err := json.Unmarshal(w.Body.Bytes(), &out); err != nil || w.Code != 200 { + t.Fatalf("état : %d %s", w.Code, w.Body.String()) + } + return out.Vision, out.Budget + } + if v, b := etat(); v || b != 200 { + t.Fatalf("sans projecteur : vision=%v budget=%v", v, b) + } + visionServer(t, "") + if v, _ := etat(); !v { + t.Fatal("projecteur vu par le moteur : vision attendue") + } + if w := appelOAIInterne(t, "GET", "/internal/v1/turn/keepimg", ""); w.Code != 405 { + t.Fatalf("GET : %d", w.Code) + } +} + +// Une image relayée rangée par référence, relue à l'octet près ; sinon elle +// reste éphémère. +func TestFacadeKeepImgRangement(t *testing.T) { + testHome(t) + t.Setenv(internalTokenEnv, jetonTest) + store := func(content string) map[string]any { + w := appelOAIInterne(t, "POST", "/internal/v1/turn/keepimg/store", `{"msg":{"role":"user","content":`+content+`}}`) + var out map[string]any + if err := json.Unmarshal(w.Body.Bytes(), &out); err != nil || w.Code != 200 { + t.Fatalf("rangement : %d %s", w.Code, w.Body.String()) + } + return out + } + out := store(`[{"type":"text","text":"Voici la capture demandée."},{"type":"image_url","image_url":{"url":"data:image/png;base64,iVBORw0KGgo="}}]`) + if out["ok"] != true || out["weight"] != float64(8) { + t.Fatalf("rangée : %v", out) + } + msg := out["msg"].(map[string]any) + parts := msg["content"].([]any) + if parts[0].(map[string]any)["text"] != "Voici la capture demandée." { + t.Fatalf("légende : %v", parts) + } + ref := parts[1].(map[string]any)["image_url"].(map[string]any)["url"].(string) + if !strings.HasPrefix(ref, imgRefScheme) { + t.Fatalf("pas par référence : %s", ref) + } + if back, ok := chatImageDataURL(ref); !ok || back != "data:image/png;base64,iVBORw0KGgo=" { + t.Fatalf("relecture : %v %.40s", ok, back) + } + if ents, _ := os.ReadDir(chatImgDir()); len(ents) != 1 { + t.Fatalf("%d fichier(s) rangé(s)", len(ents)) + } + for _, c := range []string{ + `"texte seul"`, + `[{"type":"image_url","image_url":{"url":"https://exemple.org/a.png"}}]`, + `[{"type":"image_url","image_url":{"url":"data:image/x-inconnu;base64,AAAA"}}]`, + `[{"type":"image_url","image_url":{"url":"data:image/png;base64,pas du base64"}}]`, + `[{"type":"image_url","image_url":{"url":"data:image/png;base64,iVBORw0KGgo="}},{"type":"image_url","image_url":{"url":"data:image/png;base64,iVBORw0KGgo="}}]`, + } { + if out := store(c); out["ok"] != false { + t.Errorf("%s : %v", c, out) + } + } + if w := appelOAIInterne(t, "POST", "/internal/v1/turn/keepimg/store", `{`); w.Code != 400 { + t.Fatalf("corps illisible : %d", w.Code) + } +} diff --git a/internal/loki/facade_mcp.go b/internal/loki/facade_mcp.go new file mode 100644 index 0000000..f451b48 --- /dev/null +++ b/internal/loki/facade_mcp.go @@ -0,0 +1,106 @@ +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 lui demande les outils MCP à +// annoncer au modèle, et leurs appels pour les tours qu'il exécute encore +// (sous-agents, tâches IA, discussion rendue au Go) via +// /internal/facade/v1/mcp/* ; son propre pool reste vide. Les tours de la +// façade appellent son pool sans passer par ici (lot 36). + +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/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/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/facade_perf.go b/internal/loki/facade_perf.go new file mode 100644 index 0000000..5067d9e --- /dev/null +++ b/internal/loki/facade_perf.go @@ -0,0 +1,74 @@ +package loki + +import ( + "bytes" + "encoding/json" + "fmt" + "net/http" + "os" +) + +// facade_perf.go — l'anneau de télémétrie tenu par la façade (lot 39 de la +// migration Rust). Quand elle tient la conversation, toutes les complétions y +// sont rangées : +// +// - celles de ses tours, directement ; +// - celles que le Go fait encore (sous-agents, tâches IA, bench, compaction +// et préchauffage de ses tours, clients externes de /v1), par +// /internal/facade/v1/perf/record. +// +// /api/perf/summary est servi par elle (facadeChatRoutes) et l'optimiseur lit +// l'anneau par perfSnapshot. Façade injoignable : la complétion est rangée +// ici, comme sans façade — la télémétrie ne fait jamais échouer un tour. + +// facadePerfJSON appelle l'API de télémétrie de la façade et décode sa +// réponse. +func facadePerfJSON(path string, body, 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 := facadeClient.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) +} + +// facadePerfRecord range une complétion dans l'anneau de la façade et la rend +// telle que rangée (numéro, perte de cache, ce qui s'est intercalé). +func facadePerfRecord(r perfRec, prefix []uint64) (perfRec, bool) { + var out struct { + Rec perfRec `json:"rec"` + } + err := facadePerfJSON("/internal/facade/v1/perf/record", map[string]any{"rec": r, "prefix": prefix}, &out) + if err != nil { + fmt.Fprintf(os.Stderr, "[perf] complétion non transmise à la façade : %v\n", err) + return perfRec{}, false + } + return out.Rec, true +} + +// perfSnapshot : l'anneau, de la plus ancienne complétion à la plus récente — +// celui de la façade quand elle le tient. +func perfSnapshot() []perfRec { + if facadeChat() { + var out struct { + Recs []perfRec `json:"recs"` + } + if err := facadePerfJSON("/internal/facade/v1/perf/snapshot", map[string]any{}, &out); err == nil { + return out.Recs + } + } + return perfLog.snapshot() +} diff --git a/internal/loki/facade_perf_test.go b/internal/loki/facade_perf_test.go new file mode 100644 index 0000000..51b580e --- /dev/null +++ b/internal/loki/facade_perf_test.go @@ -0,0 +1,47 @@ +package loki + +import ( + "strings" + "testing" +) + +// Lot 39 : quand la façade tient la conversation, les complétions du Go vont +// dans son anneau, et l'optimiseur le lit ; façade en panne, l'anneau du Go +// prend le relais. /api/perf/summary lui est remis. +func TestFacadePerfAnneau(t *testing.T) { + testHome(t) + freshPerf(t) + f := demarrerFacade(t) + f.internalStatus = 200 + f.internalBody = `{"rec":{"at":"2026-10-16T08:00:00Z","seq":7,"kind":"subagent","conv":"c1","iter":0,"complete":true,"total":10,"cached":2,"lost":3,"lost_after":"main"}}` + got := perfRecord(perfRec{Kind: perfSubagent, Conv: "c1", Complete: true, Total: 10, Cached: iptr(2)}, []uint64{1, 2}) + if got.Seq != 7 || got.Lost == nil || *got.Lost != 3 || got.LostAfter != "main" { + t.Fatalf("rangée par la façade : %+v", got) + } + if n := len(perfLog.snapshot()); n != 0 { + t.Fatalf("rangée aussi ici : %d", n) + } + vu := f.recu() + if len(vu) != 1 || !strings.HasPrefix(vu[0], "POST /internal/facade/v1/perf/record jeton ") || + !strings.Contains(vu[0], `"prefix":[1,2]`) || !strings.Contains(vu[0], `"kind":"subagent"`) { + t.Fatalf("envoi : %v", vu) + } + + f.internalBody = `{"recs":[{"at":"2026-10-16T08:00:00Z","seq":1,"kind":"main","iter":0,"complete":true,"total":40}]}` + if s := perfSnapshot(); len(s) != 1 || s[0].Total != 40 { + t.Fatalf("anneau de la façade : %+v", s) + } + + // Façade en panne : l'anneau d'ici. + f.internalStatus = 500 + got = perfRecord(perfRec{Kind: perfForeign}, nil) + if got.Seq != 1 || got.Kind != perfForeign { + t.Fatalf("repli : %+v", got) + } + if s := perfSnapshot(); len(s) != 1 || s[0].Kind != perfForeign { + t.Fatalf("repli de l'anneau : %+v", s) + } + if !facadeChatRoutes["/api/perf/summary"] { + t.Fatal("/api/perf/summary n'est pas remis à la façade") + } +} 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/facade_prewarm.go b/internal/loki/facade_prewarm.go new file mode 100644 index 0000000..307c605 --- /dev/null +++ b/internal/loki/facade_prewarm.go @@ -0,0 +1,150 @@ +package loki + +import ( + "net/http" + "sync" +) + +// facade_prewarm.go — PREWARM quand la façade tient la conversation (lot 33 de +// la migration). La façade assemble la requête du prochain tour (elle tient +// l'historique) et l'envoie ; le Go garde ce qui décide si elle peut partir +// et ce qui l'annule : +// +// - /internal/v1/prewarm/check : le moteur est-il libre d'un vrai travail +// (travail annexe, bench, optimiseur), loin de minuit, en bonne santé, et +// sur quel slot viser (un seul slot, ou celui de la discussion avec +// SIDE_SLOT) ; +// - /internal/v1/prewarm/begin : recharge l'état SLOT_PERSIST de la +// discussion, puis inscrit le préchauffage comme LA requête en vol +// (prewarmBegin). Toute requête de Loki qui part ensuite l'annule — sauf +// un tour de la façade qui le prolonge (/internal/v1/oai/begin, +// prewarm_keep) : l'annulation est relayée à la façade, qui coupe sa +// connexion au moteur ; +// - /internal/v1/prewarm/end : la fin, avec ce que le moteur a repris du +// cache (noteEnginePrompt) ; +// - après une tâche planifiée, le déclencheur part à la façade +// (prewarmKick). + +// Préchauffages de la façade inscrits ici : id → fin. +var facadePrewarms = struct { + mu sync.Mutex + next uint64 + m map[uint64]func() +}{m: map[uint64]func(){}} + +// handleInternalPrewarmCheck : POST {} → {"ok":true,"slot":-1|0} ou +// {"ok":false,"why":"…"}. +func handleInternalPrewarmCheck(w http.ResponseWriter, r *http.Request) { + var in struct{} + if !readInternalJSON(w, r, &in) { + return + } + refuse := func(why string) { + sendJSON(w, http.StatusOK, map[string]any{"ok": false, "why": why}) + } + if prewarmMode(ReadConfig()) == "" { + refuse("clé absente") + return + } + ep := resolveChatEndpoint() + if ep.External { + refuse("preset externe") + return + } + if sideJobs.Load() > 0 || benchRunning() || tuneGuard() != nil { + refuse("moteur occupé") + return + } + if prewarmNearMidnight(prewarmNow()) { + refuse("minuit proche") + return + } + if !healthCheck() { + refuse("moteur indisponible") + return + } + // Un seul slot, ou les deux de SIDE_SLOT : le préchauffage vise alors le + // slot de la discussion (id_slot 0), celui où partira le vrai tour. + slot := -1 + if n := prewarmSlots(ep); n != 1 { + if slot = engineSlotFor(ep, true); n != 2 || slot != 0 { + refuse("plusieurs slots") + return + } + } + sendJSON(w, http.StatusOK, map[string]any{"ok": true, "slot": slot}) +} + +// handleInternalPrewarmBegin : POST {"conv","n","prefix","opts"} → +// {"ok":true,"id":n} ou {"ok":false} (une requête de Loki est en vol, ou un +// autre préchauffage). n : messages du préfixe préchauffé (sans la +// sentinelle) ; prefix : son empreinte cumulée (perfPrefix) ; opts : celle +// des champs qui façonnent le rendu. +func handleInternalPrewarmBegin(w http.ResponseWriter, r *http.Request) { + var in struct { + Conv string `json:"conv"` + N int `json:"n"` + Prefix uint64 `json:"prefix"` + Opts uint64 `json:"opts"` + } + if !readInternalJSON(w, r, &in) { + return + } + // SLOT_PERSIST : l'état gardé de la discussion d'abord — le préchauffage + // le prolonge au lieu de tout recalculer. + slotPersistRestore(resolveChatEndpoint(), in.Conv) + facadePrewarms.mu.Lock() + facadePrewarms.next++ + id := facadePrewarms.next + facadePrewarms.mu.Unlock() + run := &prewarmRun{n: in.N, prefix: in.Prefix, opts: in.Opts, done: make(chan struct{})} + // Annulé par une autre requête : la façade coupe sa connexion. + run.cancel = func() { + go func() { _ = facadePost("/internal/facade/v1/chat/prewarm/cancel", map[string]any{"id": id}) }() + } + end, ok := prewarmBegin(run) + if !ok { + sendJSON(w, http.StatusOK, map[string]any{"ok": false}) + return + } + facadePrewarms.mu.Lock() + facadePrewarms.m[id] = func() { + end() + close(run.done) + } + facadePrewarms.mu.Unlock() + sendJSON(w, http.StatusOK, map[string]any{"ok": true, "id": id}) +} + +// handleInternalPrewarmEnd : POST {"id","total","cached"} → 204. total : la +// taille du prompt préchauffé (0 : inconnue) ; cached : ce que le moteur a +// repris de son cache (absent : inconnu). +func handleInternalPrewarmEnd(w http.ResponseWriter, r *http.Request) { + var in struct { + ID uint64 `json:"id"` + Total int `json:"total"` + Cached *int `json:"cached"` + } + if !readInternalJSON(w, r, &in) { + return + } + facadePrewarms.mu.Lock() + end := facadePrewarms.m[in.ID] + delete(facadePrewarms.m, in.ID) + facadePrewarms.mu.Unlock() + if end == nil { + sendJSON(w, http.StatusNotFound, map[string]any{"error": "préchauffage inconnu"}) + return + } + end() + // Après une tâche, c'est lui qui retrouve (ou non) la conversation dans le + // cache RAM : la vérification de llm_slots.go se fait ici. + if in.Total > 0 { + cached := 0 + if in.Cached != nil { + cached = *in.Cached + } + noteEnginePrompt(in.Total, cached, in.Cached != nil) + } + w.WriteHeader(http.StatusNoContent) +} diff --git a/internal/loki/facade_prewarm_test.go b/internal/loki/facade_prewarm_test.go new file mode 100644 index 0000000..b621c95 --- /dev/null +++ b/internal/loki/facade_prewarm_test.go @@ -0,0 +1,167 @@ +package loki + +import ( + "encoding/json" + "fmt" + "net/http" + "net/http/httptest" + "net/url" + "strings" + "testing" + "time" +) + +// moteurSain : un llama-server de test qui répond à /health. +func moteurSain(t *testing.T) { + t.Helper() + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusOK) + })) + t.Cleanup(srv.Close) + u, _ := url.Parse(srv.URL) + if err := SetConfigKey("PORT", u.Port()); err != nil { + t.Fatal(err) + } +} + +func prewarmPost(t *testing.T, path, body string) (int, map[string]any) { + t.Helper() + w := appelOAIInterne(t, "POST", path, body) + var out map[string]any + _ = json.Unmarshal(w.Body.Bytes(), &out) + return w.Code, out +} + +// Le moteur est-il libre d'un vrai travail, et sur quel slot viser ? +func TestFacadePrewarmCheck(t *testing.T) { + testHome(t) + t.Setenv(internalTokenEnv, jetonTest) + check := func() map[string]any { + _, out := prewarmPost(t, "/internal/v1/prewarm/check", `{}`) + return out + } + if out := check(); out["ok"] != false || out["why"] != "clé absente" { + t.Fatalf("sans la clé : %v", out) + } + if err := SetConfigKey("PREWARM", "on"); err != nil { + t.Fatal(err) + } + moteurSain(t) + prevSlots, prevNow := prewarmSlots, prewarmNow + t.Cleanup(func() { prewarmSlots, prewarmNow = prevSlots, prevNow }) + prewarmSlots = func(chatEndpoint) int { return 1 } + prewarmNow = func() time.Time { return time.Date(2026, 10, 16, 14, 0, 0, 0, time.Local) } + if out := check(); out["ok"] != true || out["slot"] != float64(-1) { + t.Fatalf("un seul slot : %v", out) + } + prewarmSlots = func(chatEndpoint) int { return 4 } + if out := check(); out["ok"] != false || out["why"] != "plusieurs slots" { + t.Fatalf("quatre slots : %v", out) + } + prewarmSlots = func(chatEndpoint) int { return 1 } + prewarmNow = func() time.Time { return time.Date(2026, 10, 16, 23, 55, 0, 0, time.Local) } + if out := check(); out["why"] != "minuit proche" { + t.Fatalf("minuit : %v", out) + } + prewarmNow = func() time.Time { return time.Date(2026, 10, 16, 14, 0, 0, 0, time.Local) } + sideJobs.Add(1) + out := check() + sideJobs.Add(-1) + if out["why"] != "moteur occupé" { + t.Fatalf("travail annexe : %v", out) + } + if err := SetConfigKey("PORT", "1"); err != nil { + t.Fatal(err) + } + if out := check(); out["why"] != "moteur indisponible" { + t.Fatalf("moteur muet : %v", out) + } +} + +// Inscrit comme LA requête en vol ; annulé par toute autre requête (la +// façade l'apprend), sauf un tour qui le prolonge ; rendu à la fin. +func TestFacadePrewarmEnVol(t *testing.T) { + testHome(t) + f := demarrerFacade(t) + begin := func() (bool, float64) { + _, out := prewarmPost(t, "/internal/v1/prewarm/begin", `{"conv":"c1","n":3,"prefix":11,"opts":22}`) + id, _ := out["id"].(float64) + return out["ok"] == true, id + } + ok, id := begin() + if !ok || id == 0 || !prewarmLive.Load() || enVol() != 1 { + t.Fatalf("inscription : ok=%v id=%v en vol=%d", ok, id, enVol()) + } + if ok, _ := begin(); ok { + t.Fatal("deux préchauffages en vol") + } + // Un tour de la façade qui le prolonge : gardé. + w := appelOAIInterne(t, "POST", "/internal/v1/oai/begin", `{"main_conv":"c1","prewarm_keep":true}`) + var tk struct { + Ticket uint64 `json:"ticket"` + } + _ = json.Unmarshal(w.Body.Bytes(), &tk) + if !prewarmLive.Load() { + t.Fatal("préchauffage annulé par le tour qui le prolonge") + } + appelOAIInterne(t, "POST", "/internal/v1/oai/end", fmt.Sprintf(`{"ticket":%d}`, tk.Ticket)) + // Toute autre requête : annulé, la façade coupe sa connexion. + w = appelOAIInterne(t, "POST", "/internal/v1/oai/begin", `{"main_conv":"c1"}`) + _ = json.Unmarshal(w.Body.Bytes(), &tk) + if prewarmLive.Load() { + t.Fatal("préchauffage gardé malgré une autre requête") + } + deadline := time.Now().Add(2 * time.Second) + want := fmt.Sprintf(`POST /internal/facade/v1/chat/prewarm/cancel jeton {"id":%d}`, int(id)) + for !recuContient(f.recu(), want) { + if time.Now().After(deadline) { + t.Fatalf("annulation non relayée : %v", f.recu()) + } + time.Sleep(10 * time.Millisecond) + } + appelOAIInterne(t, "POST", "/internal/v1/oai/end", fmt.Sprintf(`{"ticket":%d}`, tk.Ticket)) + // La fin rend la place, une seule fois. + if code, _ := prewarmPost(t, "/internal/v1/prewarm/end", fmt.Sprintf(`{"id":%d,"total":900,"cached":800}`, int(id))); code != 204 { + t.Fatalf("fin : %d", code) + } + if enVol() != 0 { + t.Fatalf("encore %d requête(s) en vol", enVol()) + } + if code, _ := prewarmPost(t, "/internal/v1/prewarm/end", fmt.Sprintf(`{"id":%d}`, int(id))); code != 404 { + t.Fatalf("fin répétée : %d", code) + } + // Une requête déjà en vol : rien ne part. + w = appelOAIInterne(t, "POST", "/internal/v1/oai/begin", `{"main_conv":"c1"}`) + _ = json.Unmarshal(w.Body.Bytes(), &tk) + if ok, _ := begin(); ok { + t.Fatal("préchauffage inscrit malgré une requête en vol") + } + appelOAIInterne(t, "POST", "/internal/v1/oai/end", fmt.Sprintf(`{"ticket":%d}`, tk.Ticket)) +} + +// Après une tâche planifiée, le déclencheur part à la façade. +func TestFacadePrewarmApresTache(t *testing.T) { + testHome(t) + f := demarrerFacade(t) + if err := SetConfigKey("PREWARM", "on"); err != nil { + t.Fatal(err) + } + conv.prewarmKick(prewarmAfterTask) + deadline := time.Now().Add(2 * time.Second) + want := `POST /internal/facade/v1/chat/prewarm jeton {"why":"après-tâche"}` + for !recuContient(f.recu(), want) { + if time.Now().After(deadline) { + t.Fatalf("déclencheur non relayé : %v", f.recu()) + } + time.Sleep(10 * time.Millisecond) + } +} + +func recuContient(l []string, s string) bool { + for _, x := range l { + if strings.TrimSpace(x) == s { + return true + } + } + return false +} 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/facade_projsnap.go b/internal/loki/facade_projsnap.go new file mode 100644 index 0000000..6a3d12a --- /dev/null +++ b/internal/loki/facade_projsnap.go @@ -0,0 +1,94 @@ +package loki + +import ( + "net/http" +) + +// facade_projsnap.go — PROJ_SNAPSHOT pour le tour tenu par la façade (lot 34 +// de la migration). La façade range l'instantané avec la discussion (il est +// persisté avec elle, champ proj_snap) et pose les dans son +// historique ; le Go, qui lit le contexte du projet (description, index +// mémoire, trackers, consignes du dépôt, date et dossier de travail), décide : +// +// - /internal/v1/turn/view, avec la clé et l'agent : la tête du tour porte +// le bloc figé (ou le vivant s'il faut rafraîchir), et projsnap dit quoi +// faire de la discussion — rafraîchir (instantané neuf, blocs retirés de +// l'historique) ou poser une mise à jour en tête du dernier message +// utilisateur. dry : la même décision pour le préchauffage, sans rien +// rendre à ranger ; +// - /internal/v1/turn/projsnap/refresh : une compaction ou une réduction +// réécrit l'historique en plein tour, le bloc vivant remplace le figé et +// devient l'instantané du tour suivant. + +// projSnapView : les pièces de turnView avec la clé (assembleTurn, chemin +// PROJ_SNAPSHOT). snap : l'instantané de la discussion (nil : aucun). +func projSnapView(caps Caps, snap *projSnapshot, dry bool) map[string]any { + cfg := ReadConfig() + // Date et dossier de travail : dans le bloc, plus dans le système. + caps.envInCtx = true + asOf := projSnapNow() + live := projSnapCapture(caps, asOf) + key := projSnapKey(caps, cfg) + tools := EnabledTools(caps) + if tools == nil { + tools = []Tool{} + } + head := []Message{} + if sp := readSysPrompt(); sp != "" { + head = append(head, Message{Role: "system", Content: sp}) + } + sysHash := projSnapSysHash(InjectSkills(append([]Message(nil), head...), caps, tools), tools) + refresh, delta, tok := projSnapDecide(snap, live, key, sysHash, asOf) + block := live.msgs + if !refresh { + block = snap.Msgs + } + out := map[string]any{"refresh": refresh, "key": key, "sys_hash": sysHash} + if !dry { + switch { + case refresh: + out["snap"] = newProjSnapshot(live, key, sysHash) + case delta != "": + // Rangé par la façade seulement si la mise à jour a trouvé un + // message utilisateur où se poser (projSnapApplyLocked). + next := *snap + next.State = live.state + next.DeltaTok += tok + out["snap"] = &next + out["delta"] = delta + default: + out["snap"] = snap + } + } + return map[string]any{ + "head": append(head, block...), + "prefix": injectPrefix(caps, tools), + "tools": tools, + "projsnap": out, + } +} + +// handleInternalTurnProjSnapRefresh : POST {"caps","key","sys_hash"} → +// {"block":[…],"snap":{…}} — le bloc vivant et l'instantané qu'il devient +// (projSnapTurn.refresh). +func handleInternalTurnProjSnapRefresh(w http.ResponseWriter, r *http.Request) { + var in struct { + Caps Caps `json:"caps"` + Key string `json:"key"` + SysHash string `json:"sys_hash"` + } + if !readInternalJSON(w, r, &in) { + return + } + caps := in.Caps + caps.envInCtx = true + live := projSnapCapture(caps, projSnapNow()) + block := live.msgs + if block == nil { + block = []Message{} + } + sendJSON(w, http.StatusOK, map[string]any{ + "block": block, + "snap": newProjSnapshot(live, in.Key, in.SysHash), + }) +} diff --git a/internal/loki/facade_projsnap_test.go b/internal/loki/facade_projsnap_test.go new file mode 100644 index 0000000..2641ea8 --- /dev/null +++ b/internal/loki/facade_projsnap_test.go @@ -0,0 +1,169 @@ +package loki + +import ( + "encoding/json" + "reflect" + "strings" + "testing" +) + +// projSnapFacade : /internal/v1/turn/view avec la clé, décodé. +type projSnapFacadeView struct { + Head []Message `json:"head"` + Prefix string `json:"prefix"` + Tools []Tool `json:"tools"` + ProjSnap struct { + Refresh bool `json:"refresh"` + Delta string `json:"delta"` + Snap *projSnapshot `json:"snap"` + Key string `json:"key"` + SysHash string `json:"sys_hash"` + } `json:"projsnap"` +} + +func projSnapFacade(t *testing.T, caps Caps, snap *projSnapshot, dry bool) projSnapFacadeView { + t.Helper() + body, _ := json.Marshal(map[string]any{"caps": caps, "snap": snap, "dry": dry}) + w := appelOAIInterne(t, "POST", "/internal/v1/turn/view", string(body)) + if w.Code != 200 { + t.Fatalf("vue : %d %s", w.Code, w.Body.String()) + } + var v projSnapFacadeView + if err := json.Unmarshal(w.Body.Bytes(), &v); err != nil { + t.Fatal(err) + } + return v +} + +// msgsJSON : la séquence telle qu'elle part (forme JSON, sans les champs +// propres au Go). +func msgsJSON(t *testing.T, msgs []Message) string { + t.Helper() + b, err := json.Marshal(msgs) + if err != nil { + t.Fatal(err) + } + return string(b) +} + +// La façade assemble ce que turnView aurait envoyé : instantané pris au +// premier tour, mise à jour posée en tête du message suivant après un +// changement, l'instantané de la façade rangé et relu. +func TestFacadeProjSnapCommeTurnView(t *testing.T) { + projSnapSetup(t, true) + t.Setenv(internalTokenEnv, jetonTest) + caps := Caps{Agent: true, Mem: MemAlways} + + // Premier tour : rien de figé, instantané neuf. + v := projSnapFacade(t, caps, nil, false) + if !v.ProjSnap.Refresh || v.ProjSnap.Snap == nil || v.ProjSnap.Delta != "" { + t.Fatalf("premier tour : %+v", v.ProjSnap) + } + c := newTestConv() + msgs := []Message{um("bonjour")} + c.Messages = append([]Message(nil), msgs...) + sent, tools, _ := c.turnView(caps, c.epoch, msgs, true) + got := withPrefix(append(append([]Message(nil), v.Head...), msgs...), v.Prefix) + if msgsJSON(t, got) != msgsJSON(t, sent) { + t.Fatalf("séquence différente de turnView :\n%s\n%s", msgsJSON(t, got), msgsJSON(t, sent)) + } + if !reflect.DeepEqual(jsonValue(t, v.Tools), jsonValue(t, tools)) { + t.Fatal("outils différents de turnView") + } + if !reflect.DeepEqual(v.ProjSnap.Snap.State, c.ProjSnap.State) || v.ProjSnap.Snap.Key != c.ProjSnap.Key || v.ProjSnap.Snap.SysHash != c.ProjSnap.SysHash { + t.Fatalf("instantané différent :\n%+v\n%+v", v.ProjSnap.Snap, c.ProjSnap) + } + if v.ProjSnap.Key != c.ProjSnap.Key || v.ProjSnap.SysHash != c.ProjSnap.SysHash { + t.Fatal("clé du tour différente") + } + + // Une page ajoutée : bloc figé gardé, mise à jour annoncée. + if err := MemAdd("nas.md", "# NAS\n"); err != nil { + t.Fatal(err) + } + snap := v.ProjSnap.Snap + v2 := projSnapFacade(t, caps, snap, false) + if v2.ProjSnap.Refresh || !strings.Contains(v2.ProjSnap.Delta, "](nas.md)") { + t.Fatalf("mise à jour attendue : %+v", v2.ProjSnap) + } + if msgsJSON(t, v2.Head) != msgsJSON(t, v.Head) { + t.Fatal("bloc figé non gardé") + } + c.Messages = append(c.Messages, am("ok."), um("et le NAS ?")) + msgs2 := append([]Message(nil), c.Messages...) + sent2, _, _ := c.turnView(caps, c.epoch, msgs2, true) + last := msgText(sent2[len(sent2)-1]) + if last != v2.ProjSnap.Delta+"et le NAS ?" { + t.Fatalf("mise à jour différente de turnView :\n%q\n%q", last, v2.ProjSnap.Delta+"et le NAS ?") + } + next := v2.ProjSnap.Snap + if next == nil || next.State.Mem["nas.md"] == "" || next.DeltaTok <= snap.DeltaTok || !reflect.DeepEqual(next.State, c.ProjSnap.State) { + t.Fatalf("état annoncé non suivi : %+v", next) + } + // Sans nouveau changement : rien à poser, instantané inchangé. + v3 := projSnapFacade(t, caps, next, false) + if v3.ProjSnap.Refresh || v3.ProjSnap.Delta != "" || !reflect.DeepEqual(v3.ProjSnap.Snap.State, next.State) { + t.Fatalf("tour sans changement : %+v", v3.ProjSnap) + } + // dry (préchauffage) : la même décision, rien à ranger. + if err := MemAdd("docker.md", "# Docker\n"); err != nil { + t.Fatal(err) + } + d := projSnapFacade(t, caps, next, true) + if d.ProjSnap.Refresh || d.ProjSnap.Snap != nil || d.ProjSnap.Delta != "" || msgsJSON(t, d.Head) != msgsJSON(t, v.Head) { + t.Fatalf("préchauffage : %+v", d.ProjSnap) + } + // Instantané d'un autre lancement (clé) : rafraîchi. + stale := *next + stale.Key = "autre" + if r := projSnapFacade(t, caps, &stale, false); !r.ProjSnap.Refresh { + t.Fatal("clé changée : rafraîchissement attendu") + } + if r := projSnapFacade(t, caps, &stale, true); !r.ProjSnap.Refresh || msgsJSON(t, r.Head) == msgsJSON(t, v.Head) { + t.Fatal("préchauffage d'un instantané périmé : bloc vivant attendu") + } +} + +// Compaction en plein tour : le bloc vivant, et l'instantané qu'il devient +// avec la clé et le système du tour. +func TestFacadeProjSnapRafraichi(t *testing.T) { + projSnapSetup(t, true) + t.Setenv(internalTokenEnv, jetonTest) + if err := MemAdd("nas.md", "# NAS\n"); err != nil { + t.Fatal(err) + } + w := appelOAIInterne(t, "POST", "/internal/v1/turn/projsnap/refresh", `{"caps":{"Agent":true,"Mem":"always"},"key":"k1","sys_hash":"s1"}`) + if w.Code != 200 { + t.Fatalf("rafraîchissement : %d %s", w.Code, w.Body.String()) + } + var out struct { + Block []Message `json:"block"` + Snap *projSnapshot `json:"snap"` + } + if err := json.Unmarshal(w.Body.Bytes(), &out); err != nil { + t.Fatal(err) + } + live := projSnapCapture(Caps{Agent: true, Mem: MemAlways, envInCtx: true}, projSnapNow()) + if msgsJSON(t, out.Block) != msgsJSON(t, live.msgs) || len(out.Block) == 0 { + t.Fatalf("bloc vivant différent : %s", msgsJSON(t, out.Block)) + } + if out.Snap == nil || out.Snap.Key != "k1" || out.Snap.SysHash != "s1" || out.Snap.State.Mem["nas.md"] == "" { + t.Fatalf("instantané : %+v", out.Snap) + } + if w := appelOAIInterne(t, "POST", "/internal/v1/turn/projsnap/refresh", `{`); w.Code != 400 { + t.Fatalf("corps illisible : %d", w.Code) + } +} + +// withPrefix : ce que la façade fait du préambule rendu (inject_prefix, +// comme InjectSkills avec ce texte). +func withPrefix(msgs []Message, prefix string) []Message { + if prefix == "" { + return msgs + } + if len(msgs) > 0 && msgs[0].Role == "system" && !isProjectSystem(msgs[0]) { + existing, _ := msgs[0].Content.(string) + return append([]Message{{Role: "system", Content: prefix + "\n\n" + existing}}, msgs[1:]...) + } + return append([]Message{{Role: "system", Content: prefix}}, msgs...) +} 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/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/facade_slots.go b/internal/loki/facade_slots.go new file mode 100644 index 0000000..2450cc2 --- /dev/null +++ b/internal/loki/facade_slots.go @@ -0,0 +1,39 @@ +package loki + +import ( + "net/http" +) + +// facade_slots.go — les slots du moteur pour le tour tenu par la façade (lot 31 +// de la migration). La machinerie reste ici, où vivent la configuration, le +// compteur des requêtes en vol et le tampon du slot de la discussion +// (llm_slots.go) ; la façade demande, avant chaque requête au moteur local, +// le slot où l'envoyer. +// +// - SIDE_SLOT (llm_sideslot.go) : le fil de la discussion sur le slot 0, le +// reste (vérification, résumé sur transcription) sur le 1, tant que le +// second slot est en service ; sinon aucun id_slot. +// - SLOT_PERSIST (llm_slotpersist.go) : l'état gardé à la dernière bascule de +// preset est rechargé avant la première requête de la discussion, si tout +// concorde. La sauvegarde, elle, se fait à la bascule (déjà ici) : le slot +// porte la discussion dès que le moteur a accepté une étape de son tour +// (/internal/v1/oai/served, engineMarkMain). + +// handleInternalTurnSlot : POST {"conv":"…","main":true} → {"slot":0|1|-1}. +// main : requête du fil de la discussion (étape d'un tour, résumé en +// continuation) ; conv : la discussion d'une étape de tour, pour le +// rechargement SLOT_PERSIST — vide pour un résumé. -1 : aucun id_slot. +func handleInternalTurnSlot(w http.ResponseWriter, r *http.Request) { + var in struct { + Conv string `json:"conv"` + Main bool `json:"main"` + } + if !readInternalJSON(w, r, &in) { + return + } + ep := resolveChatEndpoint() + if in.Main && in.Conv != "" { + slotPersistRestore(ep, in.Conv) + } + sendJSON(w, http.StatusOK, map[string]any{"slot": engineSlotFor(ep, in.Main)}) +} diff --git a/internal/loki/facade_slots_test.go b/internal/loki/facade_slots_test.go new file mode 100644 index 0000000..f79c938 --- /dev/null +++ b/internal/loki/facade_slots_test.go @@ -0,0 +1,129 @@ +package loki + +import ( + "encoding/json" + "os" + "path/filepath" + "reflect" + "testing" +) + +// Le slot d'une requête de la façade : aucun sans la clé (et /props jamais +// lu), le fil de la discussion sur le 0 et le reste sur le 1 quand le second +// slot est en service. +func TestFacadeTurnSlot(t *testing.T) { + testHome(t) + t.Setenv(internalTokenEnv, jetonTest) + slot := func(body string) (int, float64) { + w := appelOAIInterne(t, "POST", "/internal/v1/turn/slot", body) + var out struct { + Slot float64 `json:"slot"` + } + _ = json.Unmarshal(w.Body.Bytes(), &out) + return w.Code, out.Slot + } + prev := sideSlotProps + sideSlotProps = func(chatEndpoint) (int, int, bool) { t.Fatal("/props lu sans la clé"); return 0, 0, false } + for _, b := range []string{`{"conv":"c1","main":true}`, `{"main":false}`} { + if code, s := slot(b); code != 200 || s != -1 { + t.Fatalf("sans la clé %s : %d %v", b, code, s) + } + } + sideSlotProps = prev + if err := SetConfigKey("SIDE_SLOT", "on"); err != nil { + t.Fatal(err) + } + if err := SetConfigKey("CTX", "8192"); err != nil { + t.Fatal(err) + } + stubSideProps(t, 2, 8192) + if _, s := slot(`{"conv":"c1","main":true}`); s != 0 { + t.Fatalf("étape du tour : slot %v", s) + } + if _, s := slot(`{"main":false}`); s != 1 { + t.Fatalf("vérification : slot %v", s) + } + if _, s := slot(`{"main":true}`); s != 0 { + t.Fatalf("résumé en continuation : slot %v", s) + } + // Second slot hors service : plus d'id_slot. + stubSideProps(t, 1, 8192) + if _, s := slot(`{"main":false}`); s != -1 { + t.Fatalf("un seul slot : %v", s) + } + if code, _ := slot(`{`); code != 400 { + t.Fatalf("corps illisible : %d", code) + } + if w := appelOAIInterne(t, "GET", "/internal/v1/turn/slot", ""); w.Code != 405 { + t.Fatalf("GET : %d", w.Code) + } +} + +// SLOT_PERSIST : l'état gardé à la bascule est rechargé avant la première +// étape du tour de CETTE discussion, une seule fois ; jamais pour une autre +// discussion, une requête annexe ou un résumé. +func TestFacadeTurnSlotRecharge(t *testing.T) { + m, home := persistSetup(t, true) + t.Setenv(internalTokenEnv, jetonTest) + snap := slotPersistPrepare() + if snap == nil { + t.Fatal("rien à garder") + } + snap.save() + name := slotPersistName(persistTestKey, "c1") + for _, b := range []string{ + `{"conv":"autre","main":true}`, + `{"conv":"c1","main":false}`, + `{"main":true}`, + `{"conv":"c1","main":true}`, + `{"conv":"c1","main":true}`, + } { + if w := appelOAIInterne(t, "POST", "/internal/v1/turn/slot", b); w.Code != 200 { + t.Fatalf("%s : %d", b, w.Code) + } + } + want := []string{"save tmp-" + name, "restore " + name} + if got := m.got(); !reflect.DeepEqual(got, want) { + t.Fatalf("appels %v, attendu %v", got, want) + } + if _, err := os.Stat(filepath.Join(home, "slots", name)); !os.IsNotExist(err) { + t.Fatal("fichier gardé après la tentative") + } +} + +// Une étape de tour de la façade acceptée par le moteur marque le slot : +// c'est ce que la sauvegarde SLOT_PERSIST relit à la bascule. +func TestFacadeTurnSlotSauvegarde(t *testing.T) { + m, home := persistSetup(t, true) + t.Setenv(internalTokenEnv, jetonTest) + engineGate.mu.Lock() + engineGate.main = engineMainStamp{} + engineGate.mu.Unlock() + if slotPersistPrepare() != nil { + t.Fatal("slot sans discussion : rien à garder") + } + w := appelOAIInterne(t, "POST", "/internal/v1/oai/begin", `{"main_conv":"c2"}`) + var b struct { + Ticket uint64 `json:"ticket"` + } + if err := json.Unmarshal(w.Body.Bytes(), &b); err != nil || b.Ticket == 0 { + t.Fatalf("begin : %s", w.Body.String()) + } + tk := `{"ticket":` + jsonNum(b.Ticket) + `}` + appelOAIInterne(t, "POST", "/internal/v1/oai/served", tk) + appelOAIInterne(t, "POST", "/internal/v1/oai/end", tk) + snap := slotPersistPrepare() + if snap == nil { + t.Fatal("étape de la façade non vue par la sauvegarde") + } + snap.save() + name := slotPersistName(persistTestKey, "c2") + if _, err := os.Stat(filepath.Join(home, "slots", name)); err != nil { + t.Fatalf("état non gardé : %v (%v)", err, m.got()) + } +} + +func jsonNum(n uint64) string { + b, _ := json.Marshal(n) + return string(b) +} diff --git a/internal/loki/facade_strata.go b/internal/loki/facade_strata.go new file mode 100644 index 0000000..bba41f4 --- /dev/null +++ b/internal/loki/facade_strata.go @@ -0,0 +1,28 @@ +package loki + +import ( + "os" + "strings" +) + +// facade_strata.go — Strata tenu par la façade Rust (LOKI_STRATA=rust, lot 25 +// de la migration ; expérimental, le Go reste le défaut) : la machine et ce +// qui y tient, les réglages d'un modèle installé et l'installation (job des +// moteurs, tenu par la façade avec LOKI_BACKENDS=rust) — +// rust/loki-rs/src/strata. loki-rs ne passe la variable qu'avec les backends. +// Le lancement (`loki serve` d'un preset ENGINE=strata) reste ici. + +const facadeStrataEnv = "LOKI_STRATA" + +// facadeStrata : la façade tient-elle Strata ? +func facadeStrata() bool { + return strings.EqualFold(strings.TrimSpace(os.Getenv(facadeStrataEnv)), "rust") && + facadeAddr() != "" && internalToken() != "" +} + +// facadeStrataRoutes : les routes que la façade sert alors. +var facadeStrataRoutes = map[string]bool{ + "/api/strata": true, + "/api/strata/install": true, + "/api/strata/settings": true, +} diff --git a/internal/loki/facade_strata_test.go b/internal/loki/facade_strata_test.go new file mode 100644 index 0000000..09ccb8e --- /dev/null +++ b/internal/loki/facade_strata_test.go @@ -0,0 +1,67 @@ +package loki + +import ( + "io" + "net/http" + "net/http/httptest" + "strings" + "sync" + "testing" +) + +// Strata part à la façade avec LOKI_STRATA=rust (lot 25) ; le reste du +// moteur, non. +func TestFacadeStrata(t *testing.T) { + testHome(t) + var mu sync.Mutex + 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 + } + mu.Lock() + calls = append(calls, r.Method+" "+r.URL.Path) + mu.Unlock() + 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(facadeStrataEnv, "") + for p := range facadeStrataRoutes { + w := httptest.NewRecorder() + withFacadeChat(p, local)(w, httptest.NewRequest("POST", p, strings.NewReader("{}"))) + if w.Body.String() != "go" { + t.Fatalf("sans LOKI_STRATA, %s : %q", p, w.Body.String()) + } + } + t.Setenv(facadeStrataEnv, " RUST") + if len(facadeStrataRoutes) != 3 { + t.Fatalf("routes : %v", facadeStrataRoutes) + } + for p := range facadeStrataRoutes { + 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()) + } + } + mu.Lock() + n := len(calls) + mu.Unlock() + if n != len(facadeStrataRoutes) { + t.Fatalf("relais : %v", calls) + } + w := httptest.NewRecorder() + withFacadeChat("/api/llamacpp/install", local)(w, httptest.NewRequest("POST", "/api/llamacpp/install", nil)) + if w.Body.String() != "go" { + t.Fatalf("la compilation reste au Go : %q", w.Body.String()) + } + t.Setenv(internalTokenEnv, "") + if facadeStrata() { + t.Fatal("tenu sans jeton interne") + } +} diff --git a/internal/loki/facade_system.go b/internal/loki/facade_system.go new file mode 100644 index 0000000..93bcc37 --- /dev/null +++ b/internal/loki/facade_system.go @@ -0,0 +1,111 @@ +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 + +// internalServiceActionOS : serviceActionOS, variable pour les tests. +var internalServiceActionOS = func(a string) error { return serviceActionOSFn(a) } + +// 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"` + // OS : sans le refus de l'optimiseur (serviceActionOS) — pour la + // façade qui tient elle-même son verrou (LOKI_TUNE=rust). + OS bool `json:"os"` + } + 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 + } + act := internalServiceAction + if in.OS { + act = internalServiceActionOS + } + msg := "" + if err := act(in.Action); err != nil { + msg = err.Error() + } + if in.OS { + gpuMonitor.invalidate() + } + sendJSON(w, http.StatusOK, map[string]any{"ran": true, "error": msg}) +} diff --git a/internal/loki/facade_tasks.go b/internal/loki/facade_tasks.go new file mode 100644 index 0000000..93977bf --- /dev/null +++ b/internal/loki/facade_tasks.go @@ -0,0 +1,74 @@ +package loki + +import ( + "net/http" + "os" + "strings" +) + +// facade_tasks.go — tâches planifiées tenues par la façade Rust +// (LOKI_TASKS=rust, lot 26 de la migration ; expérimental, le Go reste le +// défaut) : les routes /api/tasks*, le planificateur et les tâches « script +// seul » (rust/loki-rs/src/tasks). Une tâche IA passe toujours par le verrou +// de génération et les outils de l'agent, qui sont ici : la façade la fait +// exécuter par le Go (/internal/v1/tasks/run). Le planificateur du Go ne +// démarre pas. + +const facadeTasksEnv = "LOKI_TASKS" + +// facadeTasks : la façade tient-elle les tâches ? +func facadeTasks() bool { + return strings.EqualFold(strings.TrimSpace(os.Getenv(facadeTasksEnv)), "rust") && + facadeAddr() != "" && internalToken() != "" +} + +// facadeTasksRoutes : les routes que la façade sert alors. +var facadeTasksRoutes = map[string]bool{ + "/api/tasks": true, + "/api/tasks/save": true, + "/api/tasks/delete": true, + "/api/tasks/toggle": true, + "/api/tasks/pause": true, + "/api/tasks/run": true, + "/api/tasks/stop": true, +} + +// handleInternalTaskRun (POST {id}) : exécute MAINTENANT la tâche IA d'id +// donné, comme le ferait le planificateur (preset épinglé, projet forcé, +// RunAutonomous, état persisté, notification), et rend la main à la fin. +// {busy:true} : le moteur était occupé ou le modèle pas prêt — la tâche n'a +// pas tourné et sera réessayée. +func handleInternalTaskRun(w http.ResponseWriter, r *http.Request) { + var in struct { + ID string `json:"id"` + } + if !readLeaseBody(w, r, &in) { + return + } + t, ok := getTask(in.ID) + if !ok { + sendJSON(w, http.StatusNotFound, map[string]any{"error": "tâche introuvable"}) + return + } + before := t.LastRun + runTask(t) + after, _ := getTask(in.ID) + sendJSON(w, http.StatusOK, map[string]any{"busy": after.LastRun == before}) +} + +// handleInternalPushSend (POST {title, body}) : une notification à tous les +// abonnés, envoyée en tâche de fond (les tâches script de la façade quand +// elle ne tient pas les notifications). +func handleInternalPushSend(w http.ResponseWriter, r *http.Request) { + var in struct { + Title string `json:"title"` + Body string `json:"body"` + } + if !readLeaseBody(w, r, &in) { + return + } + if hasPushSubs() { + go sendPushToAll(in.Title, in.Body) + } + w.WriteHeader(http.StatusNoContent) +} diff --git a/internal/loki/facade_tasks_test.go b/internal/loki/facade_tasks_test.go new file mode 100644 index 0000000..3c93419 --- /dev/null +++ b/internal/loki/facade_tasks_test.go @@ -0,0 +1,133 @@ +package loki + +import ( + "encoding/json" + "io" + "net/http" + "net/http/httptest" + "os" + "path/filepath" + "runtime" + "strings" + "sync" + "testing" +) + +// Les tâches partent à la façade avec LOKI_TASKS=rust (lot 26) ; le reste, +// non. Le Go exécute pour elle une tâche IA (/internal/v1/tasks/run) et +// envoie ses notifications (/internal/v1/push/send). +func TestFacadeTaches(t *testing.T) { + home := testHome(t) + var mu sync.Mutex + 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 + } + mu.Lock() + calls = append(calls, r.Method+" "+r.URL.Path) + mu.Unlock() + 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(facadeTasksEnv, "") + if facadeTasks() { + t.Fatal("tenues sans LOKI_TASKS") + } + for p := range facadeTasksRoutes { + w := httptest.NewRecorder() + withFacadeChat(p, local)(w, httptest.NewRequest("POST", p, strings.NewReader("{}"))) + if w.Body.String() != "go" { + t.Fatalf("sans LOKI_TASKS, %s : %q", p, w.Body.String()) + } + } + t.Setenv(facadeTasksEnv, " RUST") + if len(facadeTasksRoutes) != 7 { + t.Fatalf("routes : %v", facadeTasksRoutes) + } + for p := range facadeTasksRoutes { + 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()) + } + } + mu.Lock() + n := len(calls) + mu.Unlock() + if n != len(facadeTasksRoutes) { + t.Fatalf("relais : %v", calls) + } + w := httptest.NewRecorder() + withFacadeChat("/api/chat/stop", local)(w, httptest.NewRequest("POST", "/api/chat/stop", nil)) + if w.Body.String() != "go" { + t.Fatalf("l'arrêt du chat reste au Go : %q", w.Body.String()) + } + + // /internal/v1/tasks/run : la tâche IA, exécutée comme par le + // planificateur. Modèle pas prêt : elle ne tourne pas (busy). + run := func(body string) (int, map[string]any) { + rec := httptest.NewRecorder() + handleInternalTaskRun(rec, httptest.NewRequest("POST", "/internal/v1/tasks/run", strings.NewReader(body))) + var out map[string]any + _ = json.Unmarshal(rec.Body.Bytes(), &out) + return rec.Code, out + } + if code, _ := run(`{"id":"absente"}`); code != 404 { + t.Fatalf("tâche absente : %d", code) + } + rec := httptest.NewRecorder() + handleInternalTaskRun(rec, httptest.NewRequest("GET", "/internal/v1/tasks/run", nil)) + if rec.Code != 405 { + t.Fatalf("GET : %d", rec.Code) + } + if err := saveTask(Task{ID: "ia", Name: "IA", Prompt: "x", Schedule: "@every 1h", Enabled: true}); err != nil { + t.Fatal(err) + } + if code, out := run(`{"id":"ia"}`); code != 200 || out["busy"] != true { + t.Fatalf("tâche IA sans modèle : %d %v", code, out) + } + if tk, _ := getTask("ia"); tk.LastRun != 0 { + t.Fatalf("tâche IA sans modèle : passage enregistré %+v", tk) + } + // /internal/v1/push/send : sans abonné, rien ne part. + rec = httptest.NewRecorder() + handleInternalPushSend(rec, httptest.NewRequest("POST", "/internal/v1/push/send", strings.NewReader(`{"title":"t","body":"b"}`))) + if rec.Code != 204 { + t.Fatalf("push : %d", rec.Code) + } + rec = httptest.NewRecorder() + handleInternalPushSend(rec, httptest.NewRequest("POST", "/internal/v1/push/send", strings.NewReader(`{`))) + if rec.Code != 400 { + t.Fatalf("push illisible : %d", rec.Code) + } + + t.Setenv(internalTokenEnv, "") + if facadeTasks() { + t.Fatal("tenues sans jeton interne") + } + + // Une tâche script reçue tourne aussi (et n'est pas « occupée »). + if runtime.GOOS != "windows" { + if err := os.MkdirAll(filepath.Join(home, "scripts"), 0o755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(home, "scripts", "ok.sh"), []byte("#!/bin/sh\necho bonjour\n"), 0o755); err != nil { + t.Fatal(err) + } + if err := saveTask(Task{ID: "sc", Name: "Script", Kind: "script", Script: "ok.sh", Schedule: "@every 1h", Enabled: true}); err != nil { + t.Fatal(err) + } + if code, out := run(`{"id":"sc"}`); code != 200 || out["busy"] != false { + t.Fatalf("tâche script : %d %v", code, out) + } + if tk, _ := getTask("sc"); tk.LastRun == 0 || !tk.LastOK || !strings.Contains(tk.LastReport, "bonjour") { + t.Fatalf("tâche script : %+v", tk) + } + } +} diff --git a/internal/loki/facade_tune.go b/internal/loki/facade_tune.go new file mode 100644 index 0000000..14bb01e --- /dev/null +++ b/internal/loki/facade_tune.go @@ -0,0 +1,70 @@ +package loki + +import ( + "fmt" + "net/http" + "os" + "strings" +) + +// facade_tune.go — l'optimiseur tenu par la façade Rust (LOKI_TUNE=rust, lot +// 28 de la migration ; expérimental, le Go reste le défaut) : les routes +// /api/tune*, le job, les essais et l'application (rust/loki-rs/src/tune). +// +// Le verrou de l'optimiseur reste le FICHIER de LOKI_HOME, au même format +// (backend_tune_lock.go) : le Go le lit (tuneGuard), et sa reprise +// (tuneRecoverWatch) écarte celui d'une façade morte — essai orphelin arrêté, +// application défaite, moteur relancé. Le verrou de génération : le bail du +// moteur, motif « tune » (errTuneBusy pour les autres, le stop du chat +// annule). Les essais restent des « loki serve » du Go (LOKI_TUNE_TRIAL). +// +// Le Go prête ce qu'il est seul à voir (handleInternalTuneContext) et le +// démarrage et l'arrêt du moteur SANS le refus de l'optimiseur +// (handleInternalServiceAction, « os ») : c'est la façade qui tient le verrou. + +const facadeTuneEnv = "LOKI_TUNE" + +// facadeTune : la façade tient-elle l'optimiseur ? +func facadeTune() bool { + return strings.EqualFold(strings.TrimSpace(os.Getenv(facadeTuneEnv)), "rust") && + facadeAddr() != "" && internalToken() != "" +} + +// facadeTuneRoutes : les routes que la façade sert alors. +var facadeTuneRoutes = map[string]bool{ + "/api/tune": true, + "/api/tune/status": true, + "/api/tune/cancel": true, + "/api/tune/apply": true, + "/api/tune/last": true, +} + +// handleInternalTuneContext : GET → {busy, main_active, perf}. +// +// - busy : ce qui interdit d'optimiser dans le processus web +// (tunePreflight) — un benchmark, une génération, des jobs du mode code ; +// "" sinon ; +// - main_active : le vrai moteur tourne (engineNeedsStop) ; +// - perf : la télémétrie, d'où l'optimiseur tire les poids d'un tour type. +func handleInternalTuneContext(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 + } + busy := "" + switch { + case benchRunning(): + busy = errBenchBusy.Error() + case conv.isGenerating(): + busy = "génération en cours" + if err := conv.busyReason(); err != nil { + busy = err.Error() + } + case bgJobsRunning() > 0: + busy = fmt.Sprintf("%d job(s) d'arrière-plan du mode code tournent encore (bash_bg) : ils fausseraient les mesures — arrête-les d'abord", + bgJobsRunning()) + } + sendJSON(w, http.StatusOK, map[string]any{"busy": busy, "main_active": engineNeedsStop(), + "perf": perfSnapshot()}) +} diff --git a/internal/loki/facade_tune_test.go b/internal/loki/facade_tune_test.go new file mode 100644 index 0000000..95913d1 --- /dev/null +++ b/internal/loki/facade_tune_test.go @@ -0,0 +1,168 @@ +package loki + +import ( + "encoding/json" + "io" + "net/http" + "net/http/httptest" + "strings" + "sync" + "testing" +) + +// L'optimiseur part à la façade avec LOKI_TUNE=rust (lot 28) ; le reste, +// non. La façade tient le verrou de génération par le bail du moteur, motif +// « tune » ; le Go lui prête ce qu'il est seul à voir et le démarrage du +// moteur sans le refus de l'optimiseur. +func TestFacadeTune(t *testing.T) { + testHome(t) + var mu sync.Mutex + 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 + } + mu.Lock() + calls = append(calls, r.Method+" "+r.URL.Path) + mu.Unlock() + 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(facadeTuneEnv, "") + if facadeTune() { + t.Fatal("tenu sans LOKI_TUNE") + } + for p := range facadeTuneRoutes { + w := httptest.NewRecorder() + withFacadeChat(p, local)(w, httptest.NewRequest("POST", p, strings.NewReader("{}"))) + if w.Body.String() != "go" { + t.Fatalf("sans LOKI_TUNE, %s : %q", p, w.Body.String()) + } + } + t.Setenv(facadeTuneEnv, " RUST") + if len(facadeTuneRoutes) != 5 { + t.Fatalf("routes : %v", facadeTuneRoutes) + } + for p := range facadeTuneRoutes { + 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()) + } + } + mu.Lock() + n := len(calls) + mu.Unlock() + if n != len(facadeTuneRoutes) { + t.Fatalf("relais : %v", calls) + } + w := httptest.NewRecorder() + withFacadeChat("/api/bench", local)(w, httptest.NewRequest("POST", "/api/bench", nil)) + if w.Body.String() != "go" { + t.Fatalf("le banc d'essai reste au Go : %q", w.Body.String()) + } + + post := func(h http.HandlerFunc, path, body string) (int, map[string]any) { + rec := httptest.NewRecorder() + h(rec, httptest.NewRequest("POST", path, strings.NewReader(body))) + var out map[string]any + _ = json.Unmarshal(rec.Body.Bytes(), &out) + return rec.Code, out + } + tuneCtx := func() map[string]any { + rec := httptest.NewRecorder() + handleInternalTuneContext(rec, httptest.NewRequest("GET", "/internal/v1/tune/context", nil)) + if rec.Code != 200 { + t.Fatalf("contexte : %d", rec.Code) + } + var out map[string]any + if err := json.Unmarshal(rec.Body.Bytes(), &out); err != nil { + t.Fatal(err) + } + return out + } + + // Au repos : rien n'interdit, la télémétrie suit. + perfRecord(perfRec{Kind: perfMain, Complete: true, Total: 100, New: 100, Gen: 10}, nil) + c := tuneCtx() + if c["busy"] != "" || c["main_active"] != false { + t.Fatalf("contexte au repos : %v", c) + } + if perf, _ := c["perf"].([]any); len(perf) == 0 { + t.Fatalf("télémétrie absente : %v", c["perf"]) + } + rec := httptest.NewRecorder() + handleInternalTuneContext(rec, httptest.NewRequest("POST", "/internal/v1/tune/context", nil)) + if rec.Code != 405 || rec.Header().Get("Allow") != "GET" { + t.Fatalf("POST : %d", rec.Code) + } + + // Le bail « tune » : les autres reçoivent le refus de l'optimiseur ; ce + // n'est pas un benchmark. + code, out := post(handleInternalLease, "/internal/v1/engine/lease", `{"ttl_ms":60000,"why":"tune"}`) + if code != 200 { + t.Fatalf("bail : %d %v", code, out) + } + id, _ := out["lease"].(string) + if conv.busyReason() != errTuneBusy || benchRunning() || !conv.isGenerating() { + t.Fatalf("bail de l'optimiseur : refus=%v bench=%v", conv.busyReason(), benchRunning()) + } + if code, out := post(handleInternalLease, "/internal/v1/engine/lease", `{}`); code != 409 || out["error"] != errTuneBusy.Error() { + t.Fatalf("second bail : %d %v", code, out) + } + if c := tuneCtx(); c["busy"] != errTuneBusy.Error() { + t.Fatalf("contexte pendant l'optimisation : %v", c) + } + // Changer de discussion ne rompt pas le bail de l'optimiseur. + conv.loadFrom("autre", nil) + if !conv.isGenerating() || conv.busyReason() != errTuneBusy { + t.Fatal("le bail de l'optimiseur a sauté à la bascule de discussion") + } + if code, _ := post(handleInternalLeaseRelease, "/internal/v1/engine/lease/release", `{"lease":"`+id+`"}`); code != 204 { + t.Fatalf("libération : %d", code) + } + if conv.isGenerating() || conv.busyReason() == errTuneBusy { + t.Fatal("bail rendu : le verrou devait être libre") + } + // Un benchmark de la façade : le contexte le dit en premier. + code, out = post(handleInternalLease, "/internal/v1/engine/lease", `{"why":"bench"}`) + if code != 200 { + t.Fatalf("bail du bench : %d %v", code, out) + } + if c := tuneCtx(); c["busy"] != errBenchBusy.Error() { + t.Fatalf("contexte pendant un bench : %v", c) + } + post(handleInternalLeaseRelease, "/internal/v1/engine/lease/release", `{"lease":"`+out["lease"].(string)+`"}`) + + // Démarrage et arrêt sans le refus de l'optimiseur : la façade tient le + // verrou. + var guarded, osActs []string + oldA, oldOS := internalServiceAction, internalServiceActionOS + t.Cleanup(func() { internalServiceAction, internalServiceActionOS = oldA, oldOS }) + internalServiceAction = func(a string) error { guarded = append(guarded, a); return errTuneBusy } + internalServiceActionOS = func(a string) error { osActs = append(osActs, a); return nil } + for _, c := range []struct{ body, want string }{ + {`{"action":"stop","os":true}`, `{"error":"","ran":true}`}, + {`{"action":"start","os":true}`, `{"error":"","ran":true}`}, + {`{"action":"start"}`, `{"error":"` + errTuneBusy.Error() + `","ran":true}`}, + } { + 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 != 200 || got != c.want { + t.Fatalf("%s : %d %s", c.body, w.Code, got) + } + } + if strings.Join(osActs, ",") != "stop,start" || strings.Join(guarded, ",") != "start" { + t.Fatalf("actions : sans refus %v, avec %v", osActs, guarded) + } + + t.Setenv(internalTokenEnv, "") + if facadeTune() { + t.Fatal("tenu sans jeton interne") + } +} 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/llm_bench_job.go b/internal/loki/llm_bench_job.go index 09666ae..44b528f 100644 --- a/internal/loki/llm_bench_job.go +++ b/internal/loki/llm_bench_job.go @@ -81,11 +81,13 @@ var benchJob struct { canceled bool } -// benchRunning : un benchmark de ce processus est-il en cours ? +// benchRunning : un benchmark est-il en cours, de ce processus ou de la +// façade (LOKI_BENCH=rust, qui tient alors le bail du moteur) ? func benchRunning() bool { benchJob.mu.Lock() - defer benchJob.mu.Unlock() - return benchJob.running + running := benchJob.running + benchJob.mu.Unlock() + return running || benchLeased() } // benchRunner est runBench, remplaçable dans les tests. 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_backends_test.go b/internal/loki/parity_backends_test.go new file mode 100644 index 0000000..645fcf7 --- /dev/null +++ b/internal/loki/parity_backends_test.go @@ -0,0 +1,910 @@ +//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) + } +} + +// 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/parity_bench_test.go b/internal/loki/parity_bench_test.go new file mode 100644 index 0000000..1631c16 --- /dev/null +++ b/internal/loki/parity_bench_test.go @@ -0,0 +1,377 @@ +//go:build linux + +package loki + +import ( + "crypto/sha256" + "encoding/json" + "fmt" + "io" + "net" + "net/http" + "net/http/httptest" + "os" + "regexp" + "strconv" + "strings" + "sync" + "testing" + "time" +) + +// parity_bench_test.go — journal de référence du banc d'essai (lot 27 de la +// migration Rust) : les quatre routes, chaque refus au lancement, le mode +// rapide et le mode complet (profondeur, tours, reprise du cache), les +// échecs d'une mesure, le niveau de raisonnement refusé puis rejoué, la +// profondeur partielle et l'annulation, contre le faux moteur des tests du +// bench (llm_bench_test.go). Chaque requête reçue par le moteur est +// enregistrée — les longs textes (corpus) réduits à leur taille et à leur +// empreinte. Rangé dans rust/loki-rs/tests/parity_bench/bench.json ; +// tests/bench.rs le rejoue par la façade. +// +// LOKI_PARITY_UPDATE=1 go test ./internal/loki -run TestPariteBench + +const parityBenchFile = "../../rust/loki-rs/tests/parity_bench/bench.json" + +// parityBenchEngine : le réglage du faux moteur pour une étape (remis à zéro +// à chaque étape qui en donne un). +type parityBenchEngine struct { + NCtx int `json:"n_ctx"` + Busy bool `json:"busy,omitempty"` + FailAt int `json:"fail_at,omitempty"` + NoTiming bool `json:"no_timing,omitempty"` + RejectEffort string `json:"reject_effort,omitempty"` + SlowHotMs int `json:"slow_hot_ms,omitempty"` + TimeoutMs int `json:"timeout_ms,omitempty"` // benchReqTimeout +} + +type parityBenchStep struct { + Name string `json:"name"` + Config map[string]string `json:"config,omitempty"` + Files map[string]string `json:"files,omitempty"` + Engine *parityBenchEngine `json:"engine,omitempty"` + Tune bool `json:"tune,omitempty"` + Generating bool `json:"generating,omitempty"` + Method string `json:"method"` + Path string `json:"path"` + Body string `json:"body,omitempty"` + Status int `json:"status"` + Out any `json:"out"` + // Again : un second lancement pendant le bench ; CancelAt : la phase + // (préfixe) à laquelle l'annuler. + Again bool `json:"again,omitempty"` + AgainOut any `json:"again_out,omitempty"` + CancelAt string `json:"cancel_at,omitempty"` + Wait bool `json:"wait,omitempty"` + Job any `json:"job,omitempty"` // état final du job + Calls []any `json:"calls"` // requêtes reçues par le moteur + Last any `json:"last"` // state/last_bench + Presets any `json:"presets"` // state/bench_presets +} + +var ( + parityBenchIDRe = regexp.MustCompile(`"b[0-9]+-[0-9]+"`) + parityBenchTRe = regexp.MustCompile(`"(elapsed_sec|at)":[0-9.e+-]+`) + parityBenchFPRe = regexp.MustCompile(`"fingerprint":"[0-9a-f]+"`) +) + +// parityBenchDigest : une valeur JSON dont les longs textes sont réduits à +// leur taille et à leur empreinte. +func parityBenchDigest(v any) any { + switch x := v.(type) { + case map[string]any: + out := map[string]any{} + for k, e := range x { + out[k] = parityBenchDigest(e) + } + return out + case []any: + out := make([]any, len(x)) + for i, e := range x { + out[i] = parityBenchDigest(e) + } + return out + case string: + if len(x) > 64 { + sum := sha256.Sum256([]byte(x)) + return fmt.Sprintf("<%d octets %x>", len(x), sum[:6]) + } + return x + } + return v +} + +func TestPariteBench(t *testing.T) { + home := testHome(t) + oldTimeout := benchReqTimeout + // Un état laissé par un autre test du paquet : ni niveau appris, ni bench + // précédent. + resetBench := func() { + effortFallbacks.Range(func(k, _ any) bool { + effortFallbacks.Delete(k) + return true + }) + benchJob.mu.Lock() + benchJob.running, benchJob.id, benchJob.mode, benchJob.phase = false, "", "", "" + benchJob.step, benchJob.steps, benchJob.cancel, benchJob.result = 0, 0, nil, nil + benchJob.err, benchJob.canceled = "", false + benchJob.mu.Unlock() + } + resetBench() + t.Cleanup(func() { + benchReqTimeout = oldTimeout + // Les niveaux de raisonnement appris ici ne valent que pour ce test. + effortFallbacks.Range(func(k, _ any) bool { + effortFallbacks.Delete(k) + return true + }) + }) + + // Le faux moteur : celui des tests du bench, derrière un journal de + // toutes les requêtes reçues. + var mu sync.Mutex + var calls []any + fake := &fakeBenchEngine{} + inner := http.Handler(nil) + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + b, _ := io.ReadAll(r.Body) + entry := map[string]any{"method": r.Method, "path": r.URL.Path} + if len(b) > 0 { + var body any + if json.Unmarshal(b, &body) == nil { + entry["body"] = parityBenchDigest(body) + } + } + mu.Lock() + calls = append(calls, entry) + h := inner + mu.Unlock() + r.Body = io.NopCloser(strings.NewReader(string(b))) + h.ServeHTTP(w, r) + })) + defer srv.Close() + port := strings.TrimPrefix(srv.URL, "http://127.0.0.1:") + l, err := net.Listen("tcp", "127.0.0.1:0") + if err != nil { + t.Fatal(err) + } + closed := strconv.Itoa(l.Addr().(*net.TCPAddr).Port) + l.Close() + + mask := func(s string) string { + s = strings.ReplaceAll(s, home, "{{HOME}}") + s = parityBenchIDRe.ReplaceAllString(s, `"{{ID}}"`) + s = parityBenchTRe.ReplaceAllString(s, `"$1":"{{T}}"`) + return parityBenchFPRe.ReplaceAllString(s, `"fingerprint":"{{FP}}"`) + } + unmask := func(s string) string { + s = strings.ReplaceAll(s, "{{PORT}}", port) + s = strings.ReplaceAll(s, "{{CLOSED}}", closed) + return strings.ReplaceAll(s, "{{HOME}}", home) + } + decode := func(raw string) any { + var v any + if raw == "" { + return nil + } + if err := json.Unmarshal([]byte(mask(raw)), &v); err != nil { + t.Fatalf("illisible : %q", raw) + } + return v + } + handlers := map[string]http.HandlerFunc{ + "/api/bench": handleBench, + "/api/bench/status": handleBenchStatus, + "/api/bench/cancel": handleBenchCancel, + "/api/bench/last": handleBenchLast, + } + call := func(method, path, body string) (int, any) { + rec := httptest.NewRecorder() + handlers[path](rec, httptest.NewRequest(method, path, strings.NewReader(body))) + return rec.Code, decode(rec.Body.String()) + } + status := func() map[string]any { + _, v := call("GET", "/api/bench/status", "") + return v.(map[string]any) + } + wait := func(name string) { + deadline := time.Now().Add(60 * time.Second) + for status()["running"] == true { + if time.Now().After(deadline) { + t.Fatalf("%s : le bench ne finit pas", name) + } + time.Sleep(5 * time.Millisecond) + } + } + + var steps []parityBenchStep + run := func(st parityBenchStep) { + for k, v := range st.Config { + if err := SetConfigKey(k, unmask(v)); err != nil { + t.Fatal(err) + } + } + files := map[string]string{} + for k, v := range st.Files { + files[k] = unmask(v) + } + parityApplyFiles(t, home, files) + if e := st.Engine; e != nil { + f := &fakeBenchEngine{nCtx: e.NCtx, busy: e.Busy, failAt: e.FailAt, noTiming: e.NoTiming, + rejectEffort: e.RejectEffort, slowHot: time.Duration(e.SlowHotMs) * time.Millisecond} + mu.Lock() + fake = f + inner = f.handler(t) + mu.Unlock() + benchReqTimeout = oldTimeout + if e.TimeoutMs > 0 { + benchReqTimeout = time.Duration(e.TimeoutMs) * time.Millisecond + } + } + mu.Lock() + calls = nil + mu.Unlock() + var lock *tuneLock + if st.Tune { + l, err := tuneLockAcquire("web", false) + if err != nil { + t.Fatal(err) + } + lock = l + } + if st.Generating { + conv.mu.Lock() + conv.Generating = true + conv.mu.Unlock() + } + st.Status, st.Out = call(st.Method, st.Path, unmask(st.Body)) + if st.Generating { + conv.mu.Lock() + conv.Generating = false + conv.mu.Unlock() + } + if lock != nil { + lock.release() + } + if st.Again { + _, st.AgainOut = call("POST", "/api/bench", "{}") + } + if st.CancelAt != "" { + deadline := time.Now().Add(30 * time.Second) + for { + p, _ := status()["phase"].(string) + if strings.HasPrefix(p, st.CancelAt) { + break + } + if time.Now().After(deadline) { + t.Fatalf("%s : phase %q jamais atteinte", st.Name, st.CancelAt) + } + time.Sleep(2 * time.Millisecond) + } + // Le moteur a reçu la requête de cette phase avant l'annulation. + for { + mu.Lock() + n := len(fake.calls) + mu.Unlock() + if n >= 4 { + break + } + time.Sleep(2 * time.Millisecond) + } + call("POST", "/api/bench/cancel", "") + } + if st.Wait || st.CancelAt != "" { + wait(st.Name) + st.Job = status() + } + mu.Lock() + st.Calls = append([]any{}, calls...) + mu.Unlock() + st.Last = decode(string(getBytes(bkState, "last_bench"))) + st.Presets = decode(string(getBytes(bkState, "bench_presets"))) + steps = append(steps, st) + } + + preset := "MODEL=/models/qwen.gguf\nPORT={{PORT}}\nCTX=16384\nTOP_K=40\nMIN_P=0.05\nREASONING=off\nCACHE_ISOLATE=off\n" + base := map[string]string{"MODEL": "/models/qwen.gguf", "PORT": "{{PORT}}", "CTX": "16384", + "TOP_K": "40", "MIN_P": "0.05", "REASONING": "off", "CACHE_ISOLATE": "off"} + for _, st := range []parityBenchStep{ + {Name: "statut sans bench", Config: base, Files: map[string]string{"presets/qwen.env": preset}, + Engine: &parityBenchEngine{NCtx: 16384}, Method: "GET", Path: "/api/bench/status"}, + {Name: "dernier bench : aucun", Method: "GET", Path: "/api/bench/last"}, + {Name: "lancement en GET", Method: "GET", Path: "/api/bench"}, + {Name: "annulation en GET", Method: "GET", Path: "/api/bench/cancel"}, + {Name: "annulation sans bench", Method: "POST", Path: "/api/bench/cancel"}, + {Name: "preset externe", Config: map[string]string{"EXTERNAL": "1"}, Method: "POST", Path: "/api/bench", Body: `{}`}, + {Name: "optimisation en cours", Config: map[string]string{"EXTERNAL": ""}, Tune: true, Method: "POST", Path: "/api/bench", Body: `{}`}, + {Name: "modèle pas prêt", Config: map[string]string{"PORT": "{{CLOSED}}"}, Method: "POST", Path: "/api/bench", Body: `{}`}, + {Name: "génération en cours", Config: map[string]string{"PORT": "{{PORT}}"}, Generating: true, Method: "POST", Path: "/api/bench", Body: `{}`}, + {Name: "moteur occupé par un autre", Engine: &parityBenchEngine{NCtx: 16384, Busy: true}, + Method: "POST", Path: "/api/bench", Body: `{}`, Wait: true}, + {Name: "mode rapide", Engine: &parityBenchEngine{NCtx: 16384}, Method: "POST", Path: "/api/bench", Body: `{}`, Wait: true}, + {Name: "dernier bench", Method: "GET", Path: "/api/bench/last"}, + {Name: "mode complet", Engine: &parityBenchEngine{NCtx: 16384}, Method: "POST", Path: "/api/bench", Body: `{"mode":" FULL "}`, Wait: true}, + {Name: "mode complet, contexte trop petit", Engine: &parityBenchEngine{NCtx: 4096}, + Method: "POST", Path: "/api/bench", Body: `{"mode":"full"}`, Wait: true}, + {Name: "contexte inconnu du moteur : celui du preset", Engine: &parityBenchEngine{NCtx: 0}, + Method: "POST", Path: "/api/bench", Body: `{"mode":"full"}`, Wait: true}, + {Name: "paramètres bornés", Engine: &parityBenchEngine{NCtx: 65536}, + Method: "POST", Path: "/api/bench", Body: `{"prompt":99999,"n":9999,"mode":"quick"}`, Wait: true}, + {Name: "petits paramètres", Engine: &parityBenchEngine{NCtx: 16384}, + Method: "POST", Path: "/api/bench", Body: `{"prompt":100,"n":8}`, Wait: true}, + {Name: "prompt juste au-delà de la prose", Engine: &parityBenchEngine{NCtx: 16384}, + Method: "POST", Path: "/api/bench", Body: `{"prompt":900,"n":16}`, Wait: true}, + {Name: "corps illisible : défauts", Engine: &parityBenchEngine{NCtx: 16384}, + Method: "POST", Path: "/api/bench", Body: `{"prompt":"beaucoup"}`, Wait: true}, + {Name: "le préchauffage échoue", Engine: &parityBenchEngine{NCtx: 16384, FailAt: 1}, + Method: "POST", Path: "/api/bench", Body: `{}`, Wait: true}, + {Name: "la ligne courte échoue", Engine: &parityBenchEngine{NCtx: 16384, FailAt: 2}, + Method: "POST", Path: "/api/bench", Body: `{}`, Wait: true}, + {Name: "le prefill à froid échoue", Engine: &parityBenchEngine{NCtx: 16384, FailAt: 3}, + Method: "POST", Path: "/api/bench", Body: `{"mode":"full"}`, Wait: true}, + {Name: "un tour en profondeur échoue", Engine: &parityBenchEngine{NCtx: 16384, FailAt: 5}, + Method: "POST", Path: "/api/bench", Body: `{"mode":"full"}`, Wait: true}, + {Name: "sans timings", Engine: &parityBenchEngine{NCtx: 16384, NoTiming: true}, + Method: "POST", Path: "/api/bench", Body: `{}`, Wait: true}, + {Name: "niveau de raisonnement refusé puis rejoué", + Config: map[string]string{"REASONING": "", "REASONING_EFFORT": "high"}, + Engine: &parityBenchEngine{NCtx: 16384, RejectEffort: "high"}, + Method: "POST", Path: "/api/bench", Body: `{}`, Wait: true}, + {Name: "niveau appris : envoyé d'emblée", Engine: &parityBenchEngine{NCtx: 16384, RejectEffort: "high"}, + Method: "POST", Path: "/api/bench", Body: `{}`, Wait: true}, + {Name: "budget d'une requête dépassé : profondeur partielle", + Config: map[string]string{"REASONING_EFFORT": "", "REASONING": "off"}, + Engine: &parityBenchEngine{NCtx: 16384, SlowHotMs: 400, TimeoutMs: 150}, + Method: "POST", Path: "/api/bench", Body: `{"mode":"full"}`, Wait: true}, + {Name: "annulation pendant la profondeur", Engine: &parityBenchEngine{NCtx: 16384, SlowHotMs: 20000}, + Method: "POST", Path: "/api/bench", Body: `{"mode":"full"}`, Again: true, CancelAt: "tour 1/"}, + {Name: "preset Strata", Config: map[string]string{"ENGINE": "strata"}, Engine: &parityBenchEngine{NCtx: 16384}, + Method: "POST", Path: "/api/bench", Body: `{}`, Wait: true}, + {Name: "dernier bench, inchangé", Config: map[string]string{"ENGINE": ""}, Method: "GET", Path: "/api/bench/last"}, + } { + run(st) + } + + 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("../../rust/loki-rs/tests/parity_bench", 0o755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(parityBenchFile, got, 0o644); err != nil { + t.Fatal(err) + } + return + } + want, err := os.ReadFile(parityBenchFile) + 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 banc d'essai ne produit plus le journal rangé dans %s.\n"+ + "Si c'est voulu : LOKI_PARITY_UPDATE=1 go test ./internal/loki -run TestPariteBench,\n"+ + "puis cargo test --test bench dit si le port Rust suit.\n--- obtenu ---\n%s", parityBenchFile, got) + } +} 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/internal/loki/parity_build_test.go b/internal/loki/parity_build_test.go new file mode 100644 index 0000000..cdd1adb --- /dev/null +++ b/internal/loki/parity_build_test.go @@ -0,0 +1,238 @@ +//go:build linux + +package loki + +import ( + "bytes" + "encoding/json" + "os" + "os/exec" + "path/filepath" + "regexp" + "strings" + "testing" + "time" +) + +// parity_build_test.go — journal de référence de la compilation de llama.cpp +// depuis les sources (lot 41 de la migration Rust) : installation, mise à jour, +// recompilation propre, échec du build, outils manquants, backends custom. +// Les outils (git, cmake, compilateur) sont de faux scripts rangés dans le +// journal, seuls sur le PATH ; le job est rejoué jusqu'au bout et sa vue +// (lignes, phase, erreur, commits), BIN et la présence de build.old sont +// relevés après chaque étape. Rangé dans +// rust/loki-rs/tests/parity_backends/build.json ; tests/backends.rs le +// rejoue par la façade. +// +// LOKI_PARITY_UPDATE=1 go test ./internal/loki -run TestPariteCompilation + +const parityBuildFile = "../../rust/loki-rs/tests/parity_backends/build.json" + +// Les faux outils : le dépôt « distant » est un fichier à côté des scripts. +var parityBuildTools = map[string]string{ + "git": `#!/bin/sh +d=$(dirname "$0") +case "$1" in + clone) + dir="$4" + if [ -f "$(dirname "$dir")/CLONE_FAIL" ]; then echo "fatal: repository '$3' not found" >&2; exit 128; fi + mkdir -p "$dir/.git" + cat "$d/remote-sha" > "$dir/.git/HEAD_SHA" + echo "Cloning into '$dir'..." >&2 ;; + fetch) + exit 0 ;; + rev-parse) + case "$2" in + --short) + if [ "$3" = HEAD ]; then head -c 7 .git/HEAD_SHA; else head -c 7 "$d/remote-sha"; fi + echo ;; + --abbrev-ref) echo master ;; + HEAD) cat .git/HEAD_SHA ;; + *) cat "$d/remote-sha" ;; + esac ;; + pull) + cat "$d/remote-sha" > .git/HEAD_SHA + echo "Fast-forward" ;; + checkout) + echo "$2" > .git/REF + echo "HEAD is now at $2" >&2 ;; +esac +`, + "cmake": `#!/bin/sh +if [ "$1" = "-B" ]; then + mkdir -p build + echo "-- The C compiler identification is GNU 13.2.0" + echo "-- Configuring done (0.5s)" + printf 'CMAKE_HOME_DIRECTORY:INTERNAL=%s\n' "$(pwd)" > build/CMakeCache.txt + echo "-- Build files have been written to: build" + exit 0 +fi +if [ -f FAIL_BUILD ]; then + echo "[ 10%] Building CXX object common/CMakeFiles/common.dir/arg.cpp.o" + echo "common/arg.cpp:12:5: error: 'boom' was not declared in this scope" + exit 2 +fi +echo "[ 50%] Building CXX object common/CMakeFiles/common.dir/arg.cpp.o" +echo "[ 90%] Building CUDA object ggml/src/ggml-cuda/CMakeFiles/ggml-cuda.dir/fattn.cu.o" +echo "[100%] Linking CXX executable ../bin/llama-server" +mkdir -p build/bin +printf '#!/bin/sh\necho llama-server\n' > build/bin/llama-server +chmod +x build/bin/llama-server +`, + "cc": "#!/bin/sh\n", + "c++": "#!/bin/sh\n", + "make": "#!/bin/sh\n", +} + +type parityBuildStep struct { + Name string `json:"name"` + Remote string `json:"remote,omitempty"` // sha du dépôt distant + Files map[string]string `json:"files,omitempty"` // relatifs à LOKI_HOME ; "-" supprime + NoTools []string `json:"no_tools,omitempty"` // faux outils retirés pour l'étape + Action string `json:"action"` // install, update, custom + Force bool `json:"force,omitempty"` + Clean bool `json:"clean,omitempty"` + URL string `json:"url,omitempty"` + BName string `json:"bname,omitempty"` + Ref string `json:"ref,omitempty"` + Job map[string]any `json:"job"` + Bin string `json:"bin"` + BuildOld []string `json:"build_old"` // dépôts qui ont un build.old +} + +type parityBuildJournal struct { + Tools map[string]string `json:"tools"` + Steps []parityBuildStep `json:"steps"` +} + +var parityBuildJobsRe = regexp.MustCompile(`jobs=[0-9]+`) + +func TestPariteCompilation(t *testing.T) { + home := testHome(t) + fake := filepath.Join(t.TempDir(), "outils") + sys := filepath.Join(t.TempDir(), "sys") + for _, d := range []string{fake, sys} { + if err := os.MkdirAll(d, 0o755); err != nil { + t.Fatal(err) + } + } + for _, c := range []string{"mkdir", "cat", "head", "dirname", "chmod"} { + p, err := exec.LookPath(c) + if err != nil { + t.Fatal(err) + } + if err := os.Symlink(p, filepath.Join(sys, c)); err != nil { + t.Fatal(err) + } + } + writeTool := func(name string) { + if err := os.WriteFile(filepath.Join(fake, name), []byte(parityBuildTools[name]), 0o755); err != nil { + t.Fatal(err) + } + } + for name := range parityBuildTools { + writeTool(name) + } + t.Setenv("PATH", fake+string(os.PathListSeparator)+sys) + t.Setenv("LOKI_ENGINE_BIN", "") + mask := func(s string) string { + s = strings.ReplaceAll(s, home, "{{HOME}}") + return parityBuildJobsRe.ReplaceAllString(s, "jobs={{N}}") + } + var steps []parityBuildStep + run := func(st parityBuildStep) { + if st.Remote != "" { + if err := os.WriteFile(filepath.Join(fake, "remote-sha"), []byte(st.Remote+"\n"), 0o644); err != nil { + t.Fatal(err) + } + } + parityApplyFiles(t, home, st.Files) + for _, n := range st.NoTools { + _ = os.Remove(filepath.Join(fake, n)) + } + var body func() + switch st.Action { + case "install": + body = func() { lcRunInstall(st.Force) } + case "update": + body = func() { lcRunUpdate(st.Clean) } + case "custom": + body = func() { lcRunCustomInstall(st.URL, st.BName, st.Ref) } + } + if err := startLcJob(st.Action, body); err != nil { + t.Fatalf("%s : %v", st.Name, err) + } + deadline := time.Now().Add(20 * time.Second) + for { + if snap := lcJobSnapshot(0, true); snap["running"] == false { + break + } + if time.Now().After(deadline) { + t.Fatalf("%s : le job ne finit pas", st.Name) + } + time.Sleep(10 * time.Millisecond) + } + snap := lcJobSnapshot(0, true) + delete(snap, "started_at") + delete(snap, "ended_at") + b, _ := json.Marshal(snap) + var job map[string]any + _ = json.Unmarshal([]byte(mask(string(b))), &job) + st.Job = job + st.Bin = mask(ReadConfig()["BIN"]) + st.BuildOld = []string{} + entries, _ := os.ReadDir(filepath.Join(home, "backends")) + for _, e := range entries { + if isDir(filepath.Join(home, "backends", e.Name(), "build.old")) { + st.BuildOld = append(st.BuildOld, e.Name()) + } + } + for _, n := range st.NoTools { + writeTool(n) + } + steps = append(steps, st) + } + + run(parityBuildStep{Name: "mise à jour sans dépôt", Remote: "aaaaaaa1111111111111111111111111111111", Action: "update"}) + run(parityBuildStep{Name: "installation", Action: "install"}) + run(parityBuildStep{Name: "mise à jour : déjà à jour", Action: "update"}) + run(parityBuildStep{Name: "mise à jour : nouveau commit", Remote: "bbbbbbb2222222222222222222222222222222", Action: "update"}) + run(parityBuildStep{Name: "recompilation propre", Action: "update", Clean: true}) + run(parityBuildStep{Name: "installation sur un dépôt présent", Remote: "ccccccc3333333333333333333333333333333", Action: "install"}) + run(parityBuildStep{Name: "build en échec", Remote: "ddddddd4444444444444444444444444444444", + Files: map[string]string{"backends/llama.cpp/FAIL_BUILD": "1"}, Action: "update"}) + run(parityBuildStep{Name: "installation forcée", Files: map[string]string{"backends/llama.cpp/FAIL_BUILD": "-"}, + Action: "install", Force: true}) + run(parityBuildStep{Name: "cmake absent", Action: "update", NoTools: []string{"cmake"}}) + run(parityBuildStep{Name: "compilateur absent", Action: "update", NoTools: []string{"cc", "make"}}) + run(parityBuildStep{Name: "clone en échec", Files: map[string]string{"backends/llama.cpp": "-", "backends/CLONE_FAIL": "1"}, + Action: "install"}) + run(parityBuildStep{Name: "custom : URL invalide", Files: map[string]string{"backends/CLONE_FAIL": "-"}, + Action: "custom", URL: "ftp://exemple/x"}) + run(parityBuildStep{Name: "custom : nouveau fork", Action: "custom", URL: "https://github.com/prism/llama.cpp.git"}) + run(parityBuildStep{Name: "custom : nom assaini, sur un ref", Action: "custom", URL: "https://github.com/prism/llama.cpp.git", BName: "Mon Fork/../x", Ref: "v2"}) + run(parityBuildStep{Name: "custom : dossier existant sur un ref", Action: "custom", URL: "git@github.com:prism/llama.cpp.git", Ref: "v3"}) + + got, err := json.MarshalIndent(parityBuildJournal{Tools: parityBuildTools, Steps: steps}, "", " ") + if err != nil { + t.Fatal(err) + } + got = append(got, '\n') + if os.Getenv("LOKI_PARITY_UPDATE") == "1" { + if err := os.MkdirAll(filepath.Dir(parityBuildFile), 0o755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(parityBuildFile, got, 0o644); err != nil { + t.Fatal(err) + } + return + } + want, err := os.ReadFile(parityBuildFile) + 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("la compilation ne produit plus le journal rangé dans %s.\n"+ + "LOKI_PARITY_UPDATE=1 pour le régénérer si le changement est voulu.", parityBuildFile) + } +} 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/internal/loki/parity_criteria_test.go b/internal/loki/parity_criteria_test.go new file mode 100644 index 0000000..7ef54aa --- /dev/null +++ b/internal/loki/parity_criteria_test.go @@ -0,0 +1,111 @@ +//go:build linux + +package loki + +import ( + "bytes" + "encoding/json" + "os" + "path/filepath" + "testing" +) + +// parity_criteria_test.go — journal de référence de l'outil criteria du mode +// Code (lot 38 de la migration Rust) : chaque appel (arguments tels que le +// modèle les envoie, builder ou vérification), son résultat, et les critères +// rangés ensuite pour la discussion. Rangé dans +// rust/loki-rs/tests/parity_tools/criteria.json ; tests/tools.rs le rejoue +// par le port Rust. +// +// LOKI_PARITY_UPDATE=1 go test ./internal/loki -run TestPariteCriteres + +const parityCriteriaFile = "../../rust/loki-rs/tests/parity_tools/criteria.json" + +type parityCriteriaStep struct { + Name string `json:"name"` + Verifier bool `json:"verifier,omitempty"` + Put string `json:"put,omitempty"` // critères écrits tels quels avant l'appel + Args json.RawMessage `json:"args"` + Out string `json:"out"` + Stored any `json:"stored"` // la liste rangée (null : aucune) +} + +func TestPariteCriteres(t *testing.T) { + testHome(t) + conv := convEnsureActive() + var steps []parityCriteriaStep + run := func(st parityCriteriaStep) { + if st.Put != "" { + if err := putStoreBytes(bkChat, critKey(conv), []byte(st.Put)); err != nil { + t.Fatal(err) + } + } + var args map[string]any + if err := json.Unmarshal(st.Args, &args); err != nil { + t.Fatalf("%s : %v", st.Name, err) + } + if args == nil { + args = map[string]any{} + } + st.Out = toolCriteria(args, st.Verifier) + st.Stored = nil + if b, ok := getStoreBytes(bkChat, critKey(conv)); ok && len(b) > 0 { + var v any + if err := json.Unmarshal(b, &v); err != nil { + t.Fatal(err) + } + st.Stored = v + } + steps = append(steps, st) + } + a := func(s string) json.RawMessage { return json.RawMessage(s) } + + run(parityCriteriaStep{Name: "liste vide", Args: a(`{"action":"list"}`)}) + run(parityCriteriaStep{Name: "action absente : liste", Args: a(`{}`)}) + run(parityCriteriaStep{Name: "action d'un autre type", Args: a(`{"action":3}`)}) + run(parityCriteriaStep{Name: "ajout sans textes", Args: a(`{"action":"add"}`)}) + run(parityCriteriaStep{Name: "ajout de textes vides ou d'un autre type", Args: a(`{"action":"add","texts":[" ",4,null]}`)}) + run(parityCriteriaStep{Name: "ajout", Args: a(`{"action":"add","texts":[" les tests passent ","la doc <à jour> & relue",""]}`)}) + run(parityCriteriaStep{Name: "ajout à la suite", Args: a(`{"action":"add","texts":["pas de régression"]}`)}) + run(parityCriteriaStep{Name: "le builder dit fait", Args: a(`{"action":"set","id":1,"status":"completed","note":" vérifié à la main "}`)}) + run(parityCriteriaStep{Name: "le builder ne valide pas", Args: a(`{"action":"set","id":1,"status":"passed"}`)}) + run(parityCriteriaStep{Name: "id en chaîne avec #", Verifier: true, Args: a(`{"action":"set","id":" #2 ","status":"failed","note":"lien mort"}`)}) + run(parityCriteriaStep{Name: "id en chaîne illisible", Verifier: true, Args: a(`{"action":"set","id":"deux","status":"passed"}`)}) + run(parityCriteriaStep{Name: "id décimal tronqué", Verifier: true, Args: a(`{"action":"set","id":3.9,"status":"passed"}`)}) + run(parityCriteriaStep{Name: "note seule", Args: a(`{"action":"set","id":"+1","note":"revu"}`)}) + run(parityCriteriaStep{Name: "statut libre", Args: a(`{"action":"set","id":2,"status":"bloqué"}`)}) + run(parityCriteriaStep{Name: "id inconnu", Verifier: true, Args: a(`{"action":"set","id":9,"status":"passed"}`)}) + run(parityCriteriaStep{Name: "liste", Args: a(`{"action":"list"}`)}) + run(parityCriteriaStep{Name: "action inconnue", Args: a(`{"action":"reset"}`)}) + run(parityCriteriaStep{Name: "effacer", Args: a(`{"action":"clear"}`)}) + run(parityCriteriaStep{Name: "effacer à vide", Args: a(`{"action":"clear"}`)}) + // Vingt au plus ; les ids reprennent après le plus grand. + run(parityCriteriaStep{Name: "liste pleine", + Put: `[{"id":7,"text":"a","status":"passed"},{"id":3,"text":"b","status":"pending"}]`, + Args: a(`{"action":"add","texts":["c","d","e","f","g","h","i","j","k","l","m","n","o","p","q","r","s","t","u","v","w"]}`)}) + run(parityCriteriaStep{Name: "liste déjà pleine", Args: a(`{"action":"add","texts":["x"]}`)}) + run(parityCriteriaStep{Name: "liste rangée illisible", Put: `{"pas":"une liste"}`, Args: a(`{"action":"list"}`)}) + + got, err := json.MarshalIndent(steps, "", " ") + if err != nil { + t.Fatal(err) + } + got = append(got, '\n') + if os.Getenv("LOKI_PARITY_UPDATE") == "1" { + if err := os.MkdirAll(filepath.Dir(parityCriteriaFile), 0o755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(parityCriteriaFile, got, 0o644); err != nil { + t.Fatal(err) + } + return + } + want, err := os.ReadFile(parityCriteriaFile) + 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("l'outil criteria ne produit plus le journal rangé dans %s.\n"+ + "LOKI_PARITY_UPDATE=1 pour le régénérer si le changement est voulu.", parityCriteriaFile) + } +} 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/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/parity_engine_test.go b/internal/loki/parity_engine_test.go new file mode 100644 index 0000000..8a6b243 --- /dev/null +++ b/internal/loki/parity_engine_test.go @@ -0,0 +1,294 @@ +//go:build linux + +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/parity_engineimg_test.go b/internal/loki/parity_engineimg_test.go new file mode 100644 index 0000000..fa91ac0 --- /dev/null +++ b/internal/loki/parity_engineimg_test.go @@ -0,0 +1,544 @@ +//go:build linux + +package loki + +import ( + "crypto/sha256" + "encoding/hex" + "encoding/json" + "net/http" + "net/http/httptest" + "net/url" + "os" + "path/filepath" + "regexp" + "strings" + "sync" + "testing" + "time" +) + +// parity_engineimg_test.go — journal de référence du panneau « moteur de +// l'image » (lot 24 de la migration Rust) : statut (variante, build, avis, +// encart de version recommandée, versions installées, version précédente, +// vérification du rendu), dernier build publié, plan (dernière ou recommandée, +// risque de rendu du raisonnement), mise à jour depuis un faux registre OCI +// (couches du sommet, liens, couche du runtime ignorée, essai à blanc, +// REASONING_PRESERVE=off, ménage des versions, rendu comparé avant/après), +// bascules, suppression, retour à la version précédente, et les échecs. +// Rangé dans rust/loki-rs/tests/parity_engineimg/engineimg.json ; +// tests/engineimg.rs le rejoue par la façade. +// +// LOKI_PARITY_UPDATE=1 go test ./internal/loki -run TestPariteMoteurImage + +const parityEngineImgFile = "../../rust/loki-rs/tests/parity_engineimg/engineimg.json" + +// Ce que sert le faux registre : le jeton, la version annoncée par les tags, +// les tags figés qui existent, et les couches (de la base au sommet). +type parityRegistry struct { + Token int `json:"token,omitempty"` // statut de /token (0 = 200) + Blob int `json:"blob,omitempty"` // statut imposé aux couches (0 = servies) + // Corps de /token servi à la place du jeton ; statut et corps imposés + // aux manifestes ; index sans image pour cette architecture. + TokenBody string `json:"token_body,omitempty"` + Manifest int `json:"manifest,omitempty"` + ManifestBody string `json:"manifest_body,omitempty"` + NoArch bool `json:"no_arch,omitempty"` + Version string `json:"version"` + Fixed []string `json:"fixed,omitempty"` + Layers []parityRegistryLay `json:"layers"` +} + +type parityRegistryLay struct { + Size int64 `json:"size,omitempty"` // taille annoncée (0 = réelle) + Entries []parityArchEntry `json:"entries"` +} + +// Le moteur local (sonde de gabarit) : gabarit, build, ou arrêté. +type parityLocal struct { + Mode string `json:"mode"` + Build string `json:"build"` + Template string `json:"template,omitempty"` + Down bool `json:"down,omitempty"` + Apply int `json:"apply,omitempty"` // statut imposé à /apply-template (0 = rendu) +} + +type parityEngineImgStep struct { + Name string `json:"name"` + Files map[string]string `json:"files,omitempty"` + Config map[string]string `json:"config,omitempty"` + Registry *parityRegistry `json:"registry,omitempty"` + Local *parityLocal `json:"local,omitempty"` + Tune bool `json:"tune,omitempty"` + EngineEnv *string `json:"engine_env,omitempty"` // LOKI_ENGINE_BIN + Method string `json:"method"` + Path string `json:"path"` + Body string `json:"body,omitempty"` + Wait bool `json:"wait,omitempty"` + AfterJob *parityLocal `json:"after_job,omitempty"` // le moteur local une fois le job fini + WaitRender bool `json:"wait_render,omitempty"` + Status int `json:"status"` + Out any `json:"out"` + Job any `json:"job,omitempty"` + Render any `json:"render,omitempty"` + After map[string]string `json:"after"` + Tree []string `json:"tree"` +} + +// Les scripts des moteurs. +func parityEngineScript(version, devices, help string, failList bool) string { + s := "#!/bin/sh\ncase \"$1\" in\n--version) echo '" + version + "';;\n--list-devices)" + if failList { + s += " i=0; while [ $i -lt 13 ]; do echo \"trace $i\"; i=$((i+1)); done; echo 'erreur: libcudart.so.13 introuvable'; exit 1;;\n" + } else { + s += " echo 'Available devices:'" + devices + ";;\n" + } + return s + "--help) echo '" + help + "';;\nesac\n" +} + +func parityEngineLayers(version, script string, withServer bool, runtimeSize int64) []parityRegistryLay { + // Le serveur en 0700 : l'installation le rend exécutable par tous. + server := []parityArchEntry{{Name: "app/llama", Type: "file", Body: "#!/bin/true\n", Mode: 0o755}} + libs := []parityArchEntry{} + if withServer { + server = append(server, parityArchEntry{Name: "app/llama-server", Type: "file", Body: script, Mode: 0o700}) + // Masqué par la couche du dessus : jamais extrait. + libs = append(libs, parityArchEntry{Name: "app/llama-server", Type: "file", Body: "ancien", Mode: 0o755}) + } + return []parityRegistryLay{ + {Size: 2 << 30, Entries: []parityArchEntry{{Name: "usr/local/cuda/lib64/libcudart.so", Type: "file", Body: "runtime", Mode: 0o644}}}, + {Entries: []parityArchEntry{{Name: "etc/hosts", Type: "file", Body: "x", Mode: 0o644}}}, + {Entries: append(libs, []parityArchEntry{ + {Name: "app/", Type: "dir", Mode: 0o755}, + {Name: "app/libggml-base.so.0.20.0", Type: "file", Body: "elf " + version, Mode: 0o644}, + {Name: "app/libggml-base.so", Type: "symlink", Link: "libggml-base.so.0.20.0"}, + {Name: "app/libllama.so.0.1.0", Type: "file", Body: "elf", Mode: 0o644}, + {Name: "app/libllama.so", Type: "symlink", Link: "/app/libllama.so.0.1.0"}, + {Name: "app/libggml-cuda.so", Type: "file", Body: "elf", Mode: 0o644}, + {Name: "app/sous/ignoré", Type: "file", Body: "x", Mode: 0o644}, + {Name: "app/.wh.supprimé", Type: "file", Body: "", Mode: 0o644}, + {Name: "etc/passwd", Type: "file", Body: "x", Mode: 0o644}, + }...)}, + {Size: runtimeSize, Entries: server}, + } +} + +var parityAtRe = regexp.MustCompile(`"at":"[^"]*"`) + +func TestPariteMoteurImage(t *testing.T) { + home := testHome(t) + freshTplProbe(t) + resetBackendsState() + t.Cleanup(resetBackendsState) + oldPoll, oldWait := engineRenderPoll, engineRenderWait + engineRenderPoll, engineRenderWait = time.Millisecond, 10*time.Second + reset := func() { + engineRenderMu.Lock() + engineRenderLast = nil + engineRenderGen++ + engineRenderMu.Unlock() + buildMu.Lock() + buildCache = map[string]int{} + buildMu.Unlock() + } + reset() + t.Cleanup(func() { + engineRenderPoll, engineRenderWait = oldPoll, oldWait + reset() + }) + + // Le faux registre. + var mu sync.Mutex + var reg parityRegistry + type built struct { + layers map[string][]byte + manifest []byte + manDig string + index []byte + } + build := func(r parityRegistry) built { + b := built{layers: map[string][]byte{}} + var layers []map[string]any + for _, l := range r.Layers { + data := parityTarGz(t, l.Entries) + sum := sha256.Sum256(data) + d := "sha256:" + hex.EncodeToString(sum[:]) + b.layers[d] = data + size := int64(len(data)) + if l.Size > 0 { + size = l.Size + } + layers = append(layers, map[string]any{"mediaType": "application/vnd.oci.image.layer.v1.tar+gzip", "digest": d, "size": size}) + } + b.manifest, _ = json.Marshal(map[string]any{"schemaVersion": 2, "layers": layers, + "annotations": map[string]string{"org.opencontainers.image.version": r.Version}}) + sum := sha256.Sum256(b.manifest) + b.manDig = "sha256:" + hex.EncodeToString(sum[:]) + arch := ociArch() + if r.NoArch { + arch = "s390x" + } + b.index, _ = json.Marshal(map[string]any{"schemaVersion": 2, + "manifests": []map[string]any{ + {"digest": "sha256:autre", "platform": map[string]string{"os": "linux", "architecture": "ppc64le"}}, + {"digest": b.manDig, "platform": map[string]string{"os": "linux", "architecture": arch}}, + }}) + return b + } + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + mu.Lock() + cur := reg + mu.Unlock() + b := build(cur) + switch { + case r.URL.Path == "/token": + if cur.Token != 0 { + w.WriteHeader(cur.Token) + return + } + if cur.TokenBody != "" { + _, _ = w.Write([]byte(cur.TokenBody)) + return + } + _, _ = w.Write([]byte(`{"token":"jeton"}`)) + case strings.Contains(r.URL.Path, "/manifests/"): + if r.Header.Get("Authorization") != "Bearer jeton" { + w.WriteHeader(401) + return + } + if cur.Manifest != 0 { + w.WriteHeader(cur.Manifest) + return + } + if cur.ManifestBody != "" { + _, _ = w.Write([]byte(cur.ManifestBody)) + return + } + ref := r.URL.Path[strings.LastIndex(r.URL.Path, "/")+1:] + switch { + case ref == b.manDig: + _, _ = w.Write(b.manifest) + case ref == "server-cuda": + _, _ = w.Write(b.index) + default: + for _, f := range cur.Fixed { + if f == ref { + _, _ = w.Write(b.index) + return + } + } + w.WriteHeader(404) + } + case strings.Contains(r.URL.Path, "/blobs/"): + if cur.Blob != 0 { + w.WriteHeader(cur.Blob) + return + } + data, ok := b.layers[r.URL.Path[strings.LastIndex(r.URL.Path, "/")+1:]] + if !ok { + w.WriteHeader(404) + return + } + _, _ = w.Write(data) + default: + w.WriteHeader(404) + } + })) + defer srv.Close() + oldBase := ociBase + ociBase = srv.URL + defer func() { ociBase = oldBase }() + regHost := strings.TrimPrefix(srv.URL, "http://") + + // Le moteur local, pour la sonde de gabarit. + local := &fakeTpl{mode: "qwen3", build: "b10450-img"} + down := false + eng := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + local.mu.Lock() + d := down + local.mu.Unlock() + if d { + w.WriteHeader(503) + return + } + local.handler(w, r) + })) + defer eng.Close() + u, _ := url.Parse(eng.URL) + if err := SetConfigKey("PORT", u.Port()); err != nil { + t.Fatal(err) + } + setLocal := func(l *parityLocal) { + local.mu.Lock() + local.mode, local.build, local.tpl = l.Mode, l.Build, l.Template + local.applyStatus, local.applyBody = nil, "" + if l.Apply != 0 { + for range 1000 { + local.applyStatus = append(local.applyStatus, l.Apply) + } + local.applyBody = `{"error":{"code":500,"message":"Jinja Exception: gabarit cassé"}}` + } + down = l.Down + local.mu.Unlock() + } + + img := filepath.Join(home, "image") + t.Setenv("LOKI_ENGINE_BIN", filepath.Join(img, "llama-server")) + dev1 := "; echo ' CUDA0: Carte (1000 MiB, 900 MiB free)'" + mask := func(s string) string { + s = strings.ReplaceAll(s, srv.URL, "{{REG}}") + s = strings.ReplaceAll(s, regHost, "{{REGHOST}}") + s = strings.ReplaceAll(s, home, "{{HOME}}") + s = parityTSRe.ReplaceAllString(s, `"$1":"{{T}}"`) + return parityAtRe.ReplaceAllString(s, `"at":"{{T}}"`) + } + unmask := func(s string) string { + return strings.ReplaceAll(strings.ReplaceAll(s, "{{HOME}}", home), "{{PORT}}", u.Port()) + } + handlers := map[string]http.HandlerFunc{ + "/api/engine": handleEngineStatus, + "/api/engine/check": handleEngineCheck, + "/api/engine/update": handleEngineUpdate, + "/api/engine/use": handleEngineUse, + "/api/engine/remove": handleEngineRemove, + "/api/engine/plan": handleEnginePlan, + "/api/engine/rollback": handleEngineRollback, + } + call := func(method, path, body string) (int, any) { + rec := httptest.NewRecorder() + handlers[path](rec, httptest.NewRequest(method, path, strings.NewReader(body))) + 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 + } + run := func(st parityEngineImgStep) parityEngineImgStep { + 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.Registry != nil { + mu.Lock() + reg = *st.Registry + mu.Unlock() + } + if st.Local != nil { + setLocal(st.Local) + } + if st.EngineEnv != nil { + t.Setenv("LOKI_ENGINE_BIN", unmask(*st.EngineEnv)) + } + 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, 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(10 * time.Millisecond) + } + b, _ := json.Marshal(lcJobSnapshot(0, true)) + _ = json.Unmarshal([]byte(mask(string(b))), &st.Job) + if st.AfterJob != nil { + setLocal(st.AfterJob) + } + } + if st.WaitRender { + deadline := time.Now().Add(15 * time.Second) + for { + c := engineRenderSnapshot() + if c != nil && c.Status != "pending" { + break + } + if time.Now().After(deadline) { + t.Fatalf("%s : la vérification du rendu ne finit pas", st.Name) + } + time.Sleep(10 * time.Millisecond) + } + _, out := call("GET", "/api/engine", "") + st.Render = out.(map[string]any)["render_check"] + } + st.After = map[string]string{} + for _, k := range []string{"BIN", "REASONING_PRESERVE"} { + if v := ReadConfig()[k]; v != "" { + st.After["config/"+k] = mask(v) + } + } + if b, err := os.ReadFile(filepath.Join(home, "engine", "previous-bin")); err == nil { + st.After["previous-bin"] = mask(string(b)) + } + st.Tree = parityTree(t, home) + for i := range st.Tree { + st.Tree[i] = mask(st.Tree[i]) + } + return st + } + + v10900 := "version: 0.1.0-dev (build 10900, commit def5678)" + good := parityEngineScript(v10900, dev1, "--no-reasoning-preserve", false) + reg10900 := &parityRegistry{Version: "b10900", Fixed: []string{"server-cuda-b10900"}, + Layers: parityEngineLayers("b10900", good, true, 0)} + qwenTpl := "{% if preserve_thinking %}garde{% endif %}" + + noImage := "" + var steps []parityEngineImgStep + for _, st := range []parityEngineImgStep{ + {Name: "statut, moteur de l'image", Files: map[string]string{ + "image/llama-server": parityEngineScript("version: 10450 (abc1234)", dev1, "usage", false), + "image/libggml-cuda.so": "elf", + }, Registry: reg10900, Local: &parityLocal{Mode: "qwen3", Build: "b10450-img", Template: qwenTpl}, + Method: "GET", Path: "/api/engine"}, + {Name: "dernière version publiée", Method: "POST", Path: "/api/engine/check"}, + {Name: "dernière version sans numéro", Registry: &parityRegistry{Layers: parityEngineLayers("x", good, true, 0)}, + Method: "POST", Path: "/api/engine/check"}, + {Name: "jeton illisible", Registry: &parityRegistry{TokenBody: `{"token":""}`, Version: "b10900"}, Method: "POST", Path: "/api/engine/check"}, + {Name: "manifeste refusé", Registry: &parityRegistry{Manifest: 403, Version: "b10900"}, Method: "POST", Path: "/api/engine/check"}, + {Name: "manifeste illisible", Registry: &parityRegistry{ManifestBody: `{"mediaType":`, Version: "b10900"}, Method: "POST", Path: "/api/engine/check"}, + {Name: "aucune image pour cette architecture", Registry: &parityRegistry{NoArch: true, Version: "b10900", Layers: parityEngineLayers("b10900", good, true, 0)}, + Method: "POST", Path: "/api/engine/check"}, + {Name: "jeton refusé", Registry: &parityRegistry{Token: 500, Version: "b10900"}, Method: "POST", Path: "/api/engine/check"}, + {Name: "plan : dernière version", Registry: reg10900, Method: "POST", Path: "/api/engine/plan", Body: `{}`}, + {Name: "plan : version recommandée", Method: "POST", Path: "/api/engine/plan", Body: `{"recommended":true}`}, + {Name: "plan recommandé, build trop ancien", Registry: &parityRegistry{Version: "b10800", Fixed: []string{"server-cuda-b10800"}, + Layers: parityEngineLayers("b10800", good, true, 0)}, Method: "POST", Path: "/api/engine/plan", Body: `{"recommended":true}`}, + {Name: "plan recommandé, tag figé absent", Registry: &parityRegistry{Version: "b10900", Layers: parityEngineLayers("b10900", good, true, 0)}, + Method: "POST", Path: "/api/engine/plan", Body: `{"recommended":true}`}, + {Name: "plan recommandé, sans numéro", Registry: &parityRegistry{Version: "", Layers: parityEngineLayers("x", good, true, 0)}, + Method: "POST", Path: "/api/engine/plan", Body: `{"recommended":true}`}, + {Name: "plan, dernière version sans numéro", Method: "POST", Path: "/api/engine/plan", Body: `{}`}, + {Name: "plan en GET", Method: "GET", Path: "/api/engine/plan"}, + {Name: "plan, moteur local arrêté", Registry: reg10900, Local: &parityLocal{Down: true}, + Method: "POST", Path: "/api/engine/plan", Body: `{}`}, + {Name: "plan, REASONING_PRESERVE posée", Local: &parityLocal{Mode: "qwen3", Build: "b10450-img", Template: qwenTpl}, + Config: map[string]string{"REASONING_PRESERVE": "on"}, Method: "POST", Path: "/api/engine/plan", Body: `{}`}, + {Name: "plan, EXTRA_ARGS", Config: map[string]string{"REASONING_PRESERVE": "", "EXTRA_ARGS": "--reasoning-preserve"}, + Method: "POST", Path: "/api/engine/plan", Body: `{}`}, + {Name: "plan, gabarit sans les variables", Config: map[string]string{"EXTRA_ARGS": ""}, + Local: &parityLocal{Mode: "qwen3", Build: "b10450-img", Template: "simple"}, Method: "POST", Path: "/api/engine/plan", Body: `{}`}, + {Name: "plan, gabarit qui garde tout", Local: &parityLocal{Mode: "keep", Build: "b10451-img", Template: qwenTpl}, + Method: "POST", Path: "/api/engine/plan", Body: `{}`}, + {Name: "plan, gabarit vide", Local: &parityLocal{Build: "b10452-img"}, + Method: "POST", Path: "/api/engine/plan", Body: `{}`}, + {Name: "plan, build antérieur au défaut", Registry: &parityRegistry{Version: "b10700", Fixed: []string{"server-cuda-b10700"}, + Layers: parityEngineLayers("b10700", good, true, 0)}, Local: &parityLocal{Mode: "qwen3", Build: "b10450-img", Template: qwenTpl}, + Method: "POST", Path: "/api/engine/plan", Body: `{}`}, + {Name: "plan, preset externe", Registry: reg10900, Config: map[string]string{"EXTERNAL": "1"}, + Method: "POST", Path: "/api/engine/plan", Body: `{}`}, + {Name: "mise à jour pendant une optimisation", Config: map[string]string{"EXTERNAL": ""}, Tune: true, Method: "POST", Path: "/api/engine/update", Body: `{}`}, + {Name: "mise à jour, moteur local arrêté", Registry: &parityRegistry{Version: "b10920", Fixed: []string{"server-cuda-b10920"}, + Layers: parityEngineLayers("b10920", good, true, 0)}, Local: &parityLocal{Down: true}, + Method: "POST", Path: "/api/engine/update", Body: `{}`, Wait: true}, + {Name: "retour au moteur de l'image", Method: "POST", Path: "/api/engine/use", Body: `{"tag":"image"}`}, + {Name: "suppression de la version d'essai", Method: "POST", Path: "/api/engine/remove", Body: `{"tag":"server-cuda-b10920"}`}, + {Name: "mise à jour", Registry: reg10900, Local: &parityLocal{Mode: "qwen3", Build: "b10450-img", Template: qwenTpl}, + Method: "POST", Path: "/api/engine/update", Body: `{}`, Wait: true, + AfterJob: &parityLocal{Mode: "keep", Build: "b10900-new", Template: qwenTpl}, WaitRender: true}, + {Name: "statut après la mise à jour", Method: "GET", Path: "/api/engine"}, + {Name: "plan, déjà à jour", Method: "POST", Path: "/api/engine/plan", Body: `{}`}, + {Name: "déjà à jour", Method: "POST", Path: "/api/engine/update", Body: `{}`, Wait: true}, + {Name: "retour à la version précédente", Method: "POST", Path: "/api/engine/rollback"}, + {Name: "statut après le retour", Method: "GET", Path: "/api/engine"}, + {Name: "retour en GET", Method: "GET", Path: "/api/engine/rollback"}, + {Name: "retour pendant une optimisation", Tune: true, Method: "POST", Path: "/api/engine/rollback"}, + {Name: "bascule vers la version installée", Method: "POST", Path: "/api/engine/use", Body: `{"tag":" server-cuda-b10900 "}`}, + {Name: "bascule vers l'image", Method: "POST", Path: "/api/engine/use", Body: `{"tag":"image"}`}, + {Name: "bascule vers une version absente", Method: "POST", Path: "/api/engine/use", Body: `{"tag":"server-cuda-b1"}`}, + {Name: "bascule pendant une optimisation", Tune: true, Method: "POST", Path: "/api/engine/use", Body: `{"tag":"image"}`}, + {Name: "suppression d'une version", Method: "POST", Path: "/api/engine/remove", Body: `{"tag":"../server-cuda-b10900"}`}, + {Name: "suppression d'une version absente", Method: "POST", Path: "/api/engine/remove", Body: `{"tag":"server-cuda-b10900"}`}, + {Name: "mise à jour épinglée, REASONING_PRESERVE=off dans le preset actif", + Files: map[string]string{"presets/actif.env": "PORT={{PORT}}\nBIN={{HOME}}/image/llama-server\n"}, Local: &parityLocal{Mode: "qwen3", Build: "b10450-img", Template: qwenTpl}, + Method: "POST", Path: "/api/engine/update", Body: `{"tag":"server-cuda-b10900","preserve_off":true}`, Wait: true, + AfterJob: &parityLocal{Mode: "qwen3", Build: "b10900-new", Template: qwenTpl}, WaitRender: true}, + {Name: "suppression du moteur utilisé", Method: "POST", Path: "/api/engine/remove", Body: `{"tag":"server-cuda-b10900"}`}, + {Name: "REASONING_PRESERVE déjà posée", Method: "POST", Path: "/api/engine/update", + Body: `{"tag":"server-cuda-b10900","preserve_off":true}`, Wait: true, + AfterJob: &parityLocal{Mode: "drop", Build: "b10900-bis", Template: qwenTpl}, WaitRender: true}, + {Name: "REASONING_PRESERVE=off posée dans la configuration", Files: map[string]string{"presets/actif.env": "-"}, + Config: map[string]string{"REASONING_PRESERVE": ""}, Method: "POST", Path: "/api/engine/update", + Body: `{"tag":"server-cuda-b10900","preserve_off":true}`, Wait: true, + AfterJob: &parityLocal{Mode: "qwen3", Build: "b10900-ter", Template: qwenTpl, Apply: 500}, WaitRender: true}, + {Name: "nouvelle version, ménage", Files: map[string]string{"engine/server-cuda-b10000/llama-server": "#!/bin/sh\n", + "engine/server-cuda-b9999/llama-server": "#!/bin/sh\n", "engine/server-cuda-b10001": "/"}, + Config: map[string]string{"REASONING_PRESERVE": ""}, + Registry: &parityRegistry{Version: "b10950", Fixed: []string{"server-cuda-b10950"}, Layers: parityEngineLayers("b10950", parityEngineScript("version: 0.1.0-dev (build 10950, commit 0ab1234)", dev1, "", false), true, 10<<20)}, + Local: &parityLocal{Down: true}, Method: "POST", Path: "/api/engine/update", Body: `{}`, Wait: true}, + {Name: "statut, version précédente téléchargée", Method: "GET", Path: "/api/engine"}, + {Name: "REASONING_PRESERVE=off inconnue du moteur", Method: "POST", Path: "/api/engine/update", + Body: `{"tag":"server-cuda-b10950","preserve_off":true}`, Wait: true}, + {Name: "version inconnue du registre", Method: "POST", Path: "/api/engine/update", Body: `{"tag":"server-cuda-b1"}`, Wait: true}, + {Name: "image sans serveur", Registry: &parityRegistry{Version: "b10960", Fixed: []string{"server-cuda-b10960"}, + Layers: parityEngineLayers("b10960", good, false, 0)}, Method: "POST", Path: "/api/engine/update", Body: `{"tag":"server-cuda-b10960"}`, Wait: true}, + {Name: "manifeste sans couche", Registry: &parityRegistry{Version: "b10966", Fixed: []string{"server-cuda-b10966"}}, + Method: "POST", Path: "/api/engine/update", Body: `{"tag":"server-cuda-b10966"}`, Wait: true}, + {Name: "image sans version annoncée", Registry: &parityRegistry{Fixed: []string{"server-cuda-b10967"}, + Layers: parityEngineLayers("b10967", good, false, 0)}, Method: "POST", Path: "/api/engine/update", Body: `{"tag":"server-cuda-b10967"}`, Wait: true}, + {Name: "couche du runtime au sommet", Registry: &parityRegistry{Version: "b10961", Fixed: []string{"server-cuda-b10961"}, + Layers: parityEngineLayers("b10961", good, true, 700<<20)}, Method: "POST", Path: "/api/engine/update", Body: `{"tag":"server-cuda-b10961"}`, Wait: true}, + {Name: "moteur qui ne démarre pas", Registry: &parityRegistry{Version: "b10962", Fixed: []string{"server-cuda-b10962"}, + Layers: parityEngineLayers("b10962", parityEngineScript("version: 10962 (aaa)", dev1, "", true), true, 0)}, + Method: "POST", Path: "/api/engine/update", Body: `{"tag":"server-cuda-b10962"}`, Wait: true}, + {Name: "moteur qui ne voit aucune carte", Registry: &parityRegistry{Version: "b10963", Fixed: []string{"server-cuda-b10963"}, + Layers: parityEngineLayers("b10963", parityEngineScript("version: 10963 (bbb)", "", "", false), true, 0)}, + Method: "POST", Path: "/api/engine/update", Body: `{"tag":"server-cuda-b10963"}`, Wait: true}, + {Name: "jeton refusé pendant la mise à jour", Registry: &parityRegistry{Token: 401, Version: "b10964"}, + Method: "POST", Path: "/api/engine/update", Body: `{"tag":"server-cuda-b10964"}`, Wait: true}, + {Name: "couche introuvable", Registry: &parityRegistry{Blob: 404, Version: "b10965", Fixed: []string{"server-cuda-b10965"}, + Layers: parityEngineLayers("b10965", good, true, 0)}, Method: "POST", Path: "/api/engine/update", Body: `{"tag":"server-cuda-b10965"}`, Wait: true}, + {Name: "tag illisible", Method: "POST", Path: "/api/engine/update", Body: `{"tag":"."}`, Wait: true}, + {Name: "corps illisible, preset externe", Registry: reg10900, Config: map[string]string{"EXTERNAL": "1"}, + Local: &parityLocal{Mode: "qwen3", Build: "b10900-ext", Template: qwenTpl}, Method: "POST", Path: "/api/engine/update", Body: `{"tag":1}`, Wait: true}, + {Name: "statut, moteur compilé", Files: map[string]string{ + "custom/llama-server": parityEngineScript("version: 10800 (c0ffee1)", dev1, "", false), + }, Config: map[string]string{"BIN": "{{HOME}}/custom/llama-server"}, Method: "GET", Path: "/api/engine"}, + {Name: "bascule vers l'image, sans moteur d'image", EngineEnv: &noImage, Method: "POST", Path: "/api/engine/use", Body: `{"tag":"image"}`}, + {Name: "statut final", Method: "GET", Path: "/api/engine"}, + {Name: "statut sans aucun moteur", Config: map[string]string{"BIN": ""}, Method: "GET", Path: "/api/engine"}, + {Name: "statut, version précédente = moteur courant", Files: map[string]string{ + "engine/previous-bin": "{{HOME}}/engine/server-cuda-b10900/llama-server\n", + }, Config: map[string]string{"BIN": "{{HOME}}/engine/server-cuda-b10900/llama-server"}, Method: "GET", Path: "/api/engine"}, + } { + steps = append(steps, run(st)) + } + + got, err := parityJSON(map[string]any{"goarch": ociArch(), "steps": steps}) + if err != nil { + t.Fatal(err) + } + if os.Getenv("LOKI_PARITY_UPDATE") == "1" { + if err := os.MkdirAll(filepath.Dir(parityEngineImgFile), 0o755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(parityEngineImgFile, got, 0o644); err != nil { + t.Fatal(err) + } + return + } + want, err := os.ReadFile(parityEngineImgFile) + 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 panneau du moteur de l'image ne produit plus le journal rangé dans %s.\n"+ + "Si c'est voulu : LOKI_PARITY_UPDATE=1 go test ./internal/loki -run TestPariteMoteurImage,\n"+ + "puis cargo test --test engineimg dit si le port Rust suit.\n--- obtenu ---\n%s", parityEngineImgFile, got) + } +} 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/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_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/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/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/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/internal/loki/parity_mem_tools_test.go b/internal/loki/parity_mem_tools_test.go new file mode 100644 index 0000000..97a6911 --- /dev/null +++ b/internal/loki/parity_mem_tools_test.go @@ -0,0 +1,222 @@ +//go:build linux + +package loki + +import ( + "bytes" + "context" + "encoding/json" + "os" + "path/filepath" + "regexp" + "sort" + "strconv" + "strings" + "testing" + "time" +) + +// parity_mem_tools_test.go — journal de référence des outils mémoire de +// l'agent (lot 40 de la migration Rust) : mem_search, mem_read, mem_add, +// mem_edit et tracker. Chaque appel (arguments tels que le modèle les +// envoie), son résultat et son diff, puis les fichiers de la mémoire du projet +// et les trackers après coup. Heure fixée, fuseau UTC. Rangé dans +// rust/loki-rs/tests/parity_mem/outils.json ; src/memory/tools/tests.rs le +// rejoue. +// +// LOKI_PARITY_UPDATE=1 go test ./internal/loki -run TestPariteOutilsMemoire + +const parityMemToolsFile = "../../rust/loki-rs/tests/parity_mem/outils.json" + +type parityMemToolStep struct { + Name string `json:"name"` + Now string `json:"now,omitempty"` + Tool string `json:"tool"` + Args json.RawMessage `json:"args"` + Label string `json:"label"` + Out string `json:"out"` + Diff any `json:"diff"` + Added int `json:"added"` + Removed int `json:"removed"` + Files map[string]string `json:"files"` + Trackers map[string]any `json:"trackers"` +} + +var parityTrackerIDRe = regexp.MustCompile(`\b[0-9]{18,20}\b`) + +func TestPariteOutilsMemoire(t *testing.T) { + home := testHome(t) + oldLocal, oldNow := time.Local, trackerNow + time.Local = time.UTC + var now time.Time + trackerNow = func() time.Time { return now } + t.Cleanup(func() { time.Local, trackerNow = oldLocal, oldNow }) + activeProjMu.Lock() + activeProjCache = "" + activeProjMu.Unlock() + + ids := map[string]string{} + var order []string + mask := func(s string) string { + return parityTrackerIDRe.ReplaceAllStringFunc(s, func(id string) string { + if _, ok := ids[id]; !ok { + order = append(order, id) + ids[id] = "{{T" + strconv.Itoa(len(order)) + "}}" + } + return ids[id] + }) + } + unmask := func(s string) string { + for id, m := range ids { + s = strings.ReplaceAll(s, m, id) + } + return s + } + dir := projectMemoryDir(defaultProjectSlug) + files := func() map[string]string { + out := map[string]string{} + _ = filepath.WalkDir(dir, func(p string, d os.DirEntry, err error) error { + if err != nil || d.IsDir() { + return nil + } + b, _ := os.ReadFile(p) + rel, _ := filepath.Rel(dir, p) + out[rel] = string(b) + return nil + }) + return out + } + trackers := func() map[string]any { + out := map[string]any{} + kv := allKV(bkTracker) + keys := make([]string, 0, len(kv)) + for k := range kv { + keys = append(keys, k) + } + sort.Strings(keys) + for _, k := range keys { + var v any + _ = json.Unmarshal([]byte(mask(string(kv[k]))), &v) + out[k] = v + } + return out + } + var steps []parityMemToolStep + run := func(st parityMemToolStep) { + if st.Now != "" { + n, err := time.Parse(time.RFC3339, st.Now) + if err != nil { + t.Fatal(err) + } + now = n + } + var args map[string]any + if err := json.Unmarshal([]byte(unmask(string(st.Args))), &args); err != nil { + t.Fatalf("%s : %v", st.Name, err) + } + if args == nil { + args = map[string]any{} + } + st.Label = toolCallLabel(st.Tool, args) + res := execTool(context.Background(), st.Tool, args, st.Label, Caps{Agent: true}) + b, _ := json.Marshal(res.Result) + var out string + _ = json.Unmarshal(b, &out) + st.Out = mask(out) + db, _ := json.Marshal(res.Diff) + _ = json.Unmarshal(db, &st.Diff) + st.Added, st.Removed = res.Added, res.Removed + st.Files = files() + st.Trackers = trackers() + steps = append(steps, st) + } + a := func(s string) json.RawMessage { return json.RawMessage(s) } + + // Pages. + run(parityMemToolStep{Name: "recherche dans une mémoire vide", Now: "2026-07-20T10:00:00Z", Tool: "mem_search", Args: a(`{"query":"docker"}`)}) + run(parityMemToolStep{Name: "ajout", Tool: "mem_add", Args: a(`{"file":"docker-notes","content":"# Notes Docker\n\nUtiliser compose v2.\nLe port 8080 est pris.\n\n\n"}`)}) + run(parityMemToolStep{Name: "ajout d'une page existante", Tool: "mem_add", Args: a(`{"file":"docker-notes.md","content":"x"}`)}) + run(parityMemToolStep{Name: "ajout d'un nom invalide", Tool: "mem_add", Args: a(`{"file":"../x","content":"x"}`)}) + run(parityMemToolStep{Name: "ajout sans nom", Tool: "mem_add", Args: a(`{"content":"x"}`)}) + run(parityMemToolStep{Name: "ajout en majuscules", Tool: "mem_add", Args: a(`{"file":"Recettes.MD","content":"# Recettes\nTarte aux pommes\nLa copine de Nathan aime la tarte"}`)}) + run(parityMemToolStep{Name: "ajout d'une page bavarde", Tool: "mem_add", Args: a(`{"file":"nathan","content":"## Nathan\nNathan Nathan Nathan Nathan Nathan Nathan Nathan Nathan.\nNathan est né en 2019."}`)}) + long := strings.Repeat("Un texte de remplissage pour éloigner le terme. ", 4) + "Le mot RARE est là, " + strings.Repeat("puis encore du texte après lui, assez pour couper. ", 4) + lb, _ := json.Marshal(map[string]string{"file": "longue", "content": "# Longue\n" + long}) + run(parityMemToolStep{Name: "ajout d'une longue page", Tool: "mem_add", Args: lb}) + run(parityMemToolStep{Name: "couverture d'abord", Tool: "mem_search", Args: a(`{"query":"copine Nathan"}`)}) + run(parityMemToolStep{Name: "rareté et nom", Tool: "mem_search", Args: a(`{"query":"Nathan nathan TARTE"}`)}) + run(parityMemToolStep{Name: "requête vide : tout", Tool: "mem_search", Args: a(`{"query":" "}`)}) + run(parityMemToolStep{Name: "limite", Tool: "mem_search", Args: a(`{"query":"e","limit":2}`)}) + run(parityMemToolStep{Name: "limite hors bornes", Tool: "mem_search", Args: a(`{"query":"e","limit":99}`)}) + run(parityMemToolStep{Name: "extrait coupé des deux côtés", Tool: "mem_search", Args: a(`{"query":"rare"}`)}) + run(parityMemToolStep{Name: "rien", Tool: "mem_search", Args: a(`{"query":"zzz"}`)}) + run(parityMemToolStep{Name: "lecture", Tool: "mem_read", Args: a(`{"file":"docker-notes"}`)}) + run(parityMemToolStep{Name: "lecture d'une plage", Tool: "mem_read", Args: a(`{"file":"docker-notes.md","offset":3,"limit":1}`)}) + run(parityMemToolStep{Name: "lecture au-delà", Tool: "mem_read", Args: a(`{"file":"docker-notes","offset":40}`)}) + run(parityMemToolStep{Name: "lecture d'une page absente", Tool: "mem_read", Args: a(`{"file":"absente"}`)}) + run(parityMemToolStep{Name: "lecture d'un nom invalide", Tool: "mem_read", Args: a(`{"file":"a/b"}`)}) + run(parityMemToolStep{Name: "modification", Tool: "mem_edit", Args: a(`{"file":"docker-notes","old":"Le port 8080 est pris.","new":"Le port 8080 est pris.\nLe port 9090 aussi."}`)}) + run(parityMemToolStep{Name: "modification rejouée", Tool: "mem_edit", Args: a(`{"file":"docker-notes","old":"Le port 8080 est pris.\nLe port 9090 aussi.","new":"Le port 8080 est pris.\nLe port 9090 aussi."}`)}) + run(parityMemToolStep{Name: "modification déjà en place", Tool: "mem_edit", Args: a(`{"file":"docker-notes","old":"compose v1","new":"Utiliser compose v2."}`)}) + run(parityMemToolStep{Name: "ancien texte absent", Tool: "mem_edit", Args: a(`{"file":"docker-notes","old":"introuvable","new":"x"}`)}) + run(parityMemToolStep{Name: "ancien texte en double", Tool: "mem_edit", Args: a(`{"file":"nathan","old":"Nathan Nathan","new":"N"}`)}) + run(parityMemToolStep{Name: "ancien texte vide", Tool: "mem_edit", Args: a(`{"file":"nathan","old":"","new":"N"}`)}) + run(parityMemToolStep{Name: "modification d'une page absente", Tool: "mem_edit", Args: a(`{"file":"absente","old":"a","new":"b"}`)}) + run(parityMemToolStep{Name: "suppression d'un passage", Tool: "mem_edit", Args: a(`{"file":"recettes","old":"\nTarte aux pommes","new":""}`)}) + + // Trackers. + run(parityMemToolStep{Name: "aucun tracker", Tool: "tracker", Args: a(`{}`)}) + run(parityMemToolStep{Name: "ajout sans nom", Tool: "tracker", Args: a(`{"action":"add","text":"1"}`)}) + run(parityMemToolStep{Name: "ajout sans texte", Tool: "tracker", Args: a(`{"action":"add","name":"Poids","text":" "}`)}) + run(parityMemToolStep{Name: "date illisible", Tool: "tracker", Args: a(`{"action":"add","name":"Poids","when":"hier","text":"72"}`)}) + run(parityMemToolStep{Name: "ajout daté", Tool: "tracker", Args: a(`{"action":"ADD","name":"Poids","when":"2026-07-15","text":" 72.5 "}`)}) + run(parityMemToolStep{Name: "ajout à l'heure", Tool: "tracker", Args: a(`{"action":"add","name":"poids","when":"2026-07-16T08:30","text":"72.1"}`)}) + run(parityMemToolStep{Name: "ajout l'an passé", Tool: "tracker", Args: a(`{"action":"add","name":"Poids","when":"2025-12","text":"73"}`)}) + run(parityMemToolStep{Name: "ajout maintenant", Now: "2026-07-20T10:15:00Z", Tool: "tracker", Args: a(`{"action":"add","name":"Poids","text":"71.9"}`)}) + run(parityMemToolStep{Name: "un autre tracker", Tool: "tracker", Args: a(`{"action":"add","name":"Séances de sport","when":"2024","text":"course"}`)}) + run(parityMemToolStep{Name: "un nom en nombre", Tool: "tracker", Args: a(`{"action":"add","name":42,"when":"2026-01-02 03:04:05","text":7}`)}) + run(parityMemToolStep{Name: "vue d'ensemble", Tool: "tracker", Args: a(`{"action":"view"}`)}) + run(parityMemToolStep{Name: "squelette", Tool: "tracker", Args: a(`{"name":"Poids"}`)}) + run(parityMemToolStep{Name: "une année", Tool: "tracker", Args: a(`{"name":"Poids","when":"2026"}`)}) + run(parityMemToolStep{Name: "une année vide", Tool: "tracker", Args: a(`{"name":"Poids","when":"2027"}`)}) + run(parityMemToolStep{Name: "un mois", Tool: "tracker", Args: a(`{"name":"Poids","when":"2026-07"}`)}) + run(parityMemToolStep{Name: "un jour à l'heure", Tool: "tracker", Args: a(`{"name":"Poids","when":"2026-07-16T08"}`)}) + run(parityMemToolStep{Name: "tracker inconnu", Tool: "tracker", Args: a(`{"name":"Tension"}`)}) + run(parityMemToolStep{Name: "modifier sans id", Tool: "tracker", Args: a(`{"action":"edit","name":"Poids","text":"1"}`)}) + run(parityMemToolStep{Name: "modifier", Tool: "tracker", Args: a(`{"action":"edit","name":"Poids","id":"{{T1}}","text":"72.4","when":"2026-07-14"}`)}) + run(parityMemToolStep{Name: "modifier une date illisible", Tool: "tracker", Args: a(`{"action":"edit","name":"Poids","id":"{{T1}}","when":"demain"}`)}) + run(parityMemToolStep{Name: "modifier un point inconnu", Tool: "tracker", Args: a(`{"action":"edit","name":"Poids","id":"1"}`)}) + run(parityMemToolStep{Name: "modifier un tracker inconnu", Tool: "tracker", Args: a(`{"action":"edit","name":"Tension","id":"1"}`)}) + run(parityMemToolStep{Name: "supprimer sans id", Tool: "tracker", Args: a(`{"action":"delete","name":"Poids"}`)}) + run(parityMemToolStep{Name: "supprimer sans nom", Tool: "tracker", Args: a(`{"action":"delete"}`)}) + run(parityMemToolStep{Name: "supprimer", Tool: "tracker", Args: a(`{"action":"delete","name":"Poids","id":"{{T2}}"}`)}) + run(parityMemToolStep{Name: "supprimer deux fois", Tool: "tracker", Args: a(`{"action":"delete","name":"Poids","id":"{{T2}}"}`)}) + run(parityMemToolStep{Name: "action inconnue", Tool: "tracker", Args: a(`{"action":"purge","name":"Poids"}`)}) + run(parityMemToolStep{Name: "après modifications", Tool: "tracker", Args: a(`{"name":"Poids","when":"2026"}`)}) + + got, err := json.MarshalIndent(steps, "", " ") + if err != nil { + t.Fatal(err) + } + got = append(got, '\n') + if strings.Contains(string(got), home) { + t.Fatal("le journal contient le dossier du test") + } + if os.Getenv("LOKI_PARITY_UPDATE") == "1" { + if err := os.MkdirAll(filepath.Dir(parityMemToolsFile), 0o755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(parityMemToolsFile, got, 0o644); err != nil { + t.Fatal(err) + } + return + } + want, err := os.ReadFile(parityMemToolsFile) + 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 mémoire ne produisent plus le journal rangé dans %s.\n"+ + "LOKI_PARITY_UPDATE=1 pour le régénérer si le changement est voulu.", parityMemToolsFile) + } +} diff --git a/internal/loki/parity_models_test.go b/internal/loki/parity_models_test.go new file mode 100644 index 0000000..5223bed --- /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(f)) + } + 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/parity_perf_test.go b/internal/loki/parity_perf_test.go new file mode 100644 index 0000000..0a37936 --- /dev/null +++ b/internal/loki/parity_perf_test.go @@ -0,0 +1,142 @@ +//go:build linux + +package loki + +import ( + "bytes" + "encoding/json" + "os" + "path/filepath" + "strconv" + "testing" + "time" +) + +// parity_perf_test.go — journal de référence de l'anneau de télémétrie (lot 39 +// de la migration Rust) : une suite de complétions rangées (chacune avec les +// empreintes des messages envoyés), ce que l'anneau en a tiré (numéro, perte +// de cache, ce qui s'est intercalé), la ligne [perf], le résumé de +// /api/perf/summary, puis les seuils d'alerte d'une série de presets. Rangé +// dans rust/loki-rs/tests/parity_perf/perf.json ; src/perfring/tests.rs le +// rejoue. +// +// LOKI_PARITY_UPDATE=1 go test ./internal/loki -run TestPariteAnneauPerf + +const parityPerfFile = "../../rust/loki-rs/tests/parity_perf/perf.json" + +type parityPerfStep struct { + Rec json.RawMessage `json:"rec"` + Msgs []string `json:"msgs,omitempty"` // contenus des messages envoyés (rôle user) + Prefix []string `json:"prefix,omitempty"` + Out json.RawMessage `json:"out"` + Line string `json:"line"` + Alert int `json:"alert_1000"` // perfAlert au seuil 1000 : 1 vrai, 0 faux +} + +type parityPerfAlert struct { + Cfg map[string]string `json:"cfg"` + Env string `json:"env,omitempty"` // LLAMA_ARG_CHECKPOINT_MIN_SPACING_NT + Want int `json:"want"` +} + +type parityPerfJournal struct { + Steps []parityPerfStep `json:"steps"` + Summary json.RawMessage `json:"summary"` + Empty json.RawMessage `json:"empty"` + Alerts []parityPerfAlert `json:"alerts"` +} + +func TestPariteAnneauPerf(t *testing.T) { + testHome(t) + freshPerf(t) + at := time.Date(2026, 10, 16, 8, 0, 0, 0, time.UTC) + var j parityPerfJournal + rec := func(r perfRec, msgs ...string) { + var prefix []uint64 + if msgs != nil { + ms := make([]Message, len(msgs)) + for i, c := range msgs { + ms[i] = Message{Role: "user", Content: c} + } + prefix = perfPrefix(ms) + } + in, _ := json.Marshal(r) + r.At = at + at = at.Add(time.Second) + got := perfLog.record(r, prefix) + out, _ := json.Marshal(got) + st := parityPerfStep{Rec: in, Msgs: msgs, Out: out, Line: perfLine(got)} + for _, p := range prefix { + st.Prefix = append(st.Prefix, strconv.FormatUint(p, 10)) + } + if perfAlert(got, 1000) { + st.Alert = 1 + } + j.Steps = append(j.Steps, st) + } + main := func(conv string, iter, total int, cached *int) perfRec { + return perfRec{Kind: perfMain, Conv: conv, Iter: iter, Complete: true, Total: total, Cached: cached} + } + full := perfRec{Kind: perfMain, Conv: "c1", Iter: 0, Complete: true, Total: 4000, Cached: iptr(3000), + New: 1000, PPms: 2000.4, PPtps: 499.9, Gen: 50, TGtps: 40.25, DraftN: iptr(40), DraftAcc: iptr(30), TTFTms: ptr64(2100)} + rec(full, "a") + rec(main("c1", 1, 4600, iptr(4000)), "a", "b", "c") // prolonge : rien de perdu + rec(perfRec{Kind: perfSubagent, Conv: "c1", Complete: true, Total: 300, Cached: iptr(0), New: 300, PPms: 600, PPtps: 500}, "z") + rec(perfRec{Kind: perfForeign}) // client externe : vu, pas mesuré + rec(main("c1", 2, 5200, iptr(200)), "a", "b", "c", "d") // perte après le sous-agent et le client + rec(main("c1", 3, 5200, nil), "a", "b", "c", "d", "e") // cache inconnu + rec(main("c1", 4, 5300, iptr(10)), "x", "b") // diverge + rec(main("c2", 0, 20000, iptr(0)), "q") // autre discussion, gros prompt sans cache + rec(perfRec{Kind: perfMain, Conv: "c2", Iter: 1, Cached: iptr(0)}, "q", "r") // complétion coupée + rec(main("c2", 1, 21000, iptr(19000)), "q", "r") // prolonge malgré la coupure + rec(perfRec{Kind: perfCompact, Conv: "c2", Complete: true, Total: 9000, Cached: iptr(8800), New: 200, PPms: 100, PPtps: 2000, Gen: 300, TGtps: 30, TTFTms: ptr64(900)}) + rec(perfRec{Kind: perfBench, Complete: true, Total: 33000, New: 33000, PPms: 30000, PPtps: 1100, Gen: 64, TGtps: 20.5}) + rec(perfRec{Kind: perfVerify, Conv: "c1", Iter: 0, Complete: true, Total: 12000, Cached: iptr(0), New: 12000, PPms: 9000.5, PPtps: 1333.3, Gen: 2, TGtps: 9.75, DraftN: iptr(0)}) + rec(main("c1", 0, 6000, iptr(5900)), "a", "b", "c", "d", "e", "f") // prolonge la dernière terminée de c1 + + sum, _ := json.Marshal(perfSummarize(perfLog.snapshot())) + j.Summary = sum + empty, _ := json.Marshal(perfSummarize(nil)) + j.Empty = empty + + for _, c := range []parityPerfAlert{ + {Cfg: map[string]string{}}, + {Cfg: map[string]string{"CKPT_MIN_STEP": "2048"}}, + {Cfg: map[string]string{"CKPT_MIN_STEP": "4096", "UBATCH": "1024"}}, + {Cfg: map[string]string{"CKPT_MIN_STEP": "beaucoup"}}, + {Cfg: map[string]string{"CKPT_MIN_STEP": " 300 ", "UBATCH": "100"}}, + {Cfg: map[string]string{"CKPT_MIN_STEP": "2048", "EXTRA_ARGS": "--checkpoint-min-step 8192 -ub 256"}}, + {Cfg: map[string]string{"CKPT_MIN_STEP": "2048", "UBATCH": "1024", "EXTRA_ARGS": "-ub=2048"}}, + {Cfg: map[string]string{"CKPT_MIN_STEP": "-5", "UBATCH": "0"}}, + {Cfg: map[string]string{"CKPT_MIN_STEP": "2048"}, Env: "3000"}, + {Cfg: map[string]string{"CKPT_MIN_STEP": "2048"}, Env: "rien"}, + {Cfg: map[string]string{"MODEL": "absent.gguf"}}, + } { + t.Setenv("LLAMA_ARG_CHECKPOINT_MIN_SPACING_NT", c.Env) + c.Want = perfLostAlertAt(c.Cfg) + j.Alerts = append(j.Alerts, c) + } + + got, err := json.MarshalIndent(j, "", " ") + if err != nil { + t.Fatal(err) + } + got = append(got, '\n') + if os.Getenv("LOKI_PARITY_UPDATE") == "1" { + if err := os.MkdirAll(filepath.Dir(parityPerfFile), 0o755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(parityPerfFile, got, 0o644); err != nil { + t.Fatal(err) + } + return + } + want, err := os.ReadFile(parityPerfFile) + 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("l'anneau de télémétrie ne produit plus le journal rangé dans %s.\n"+ + "LOKI_PARITY_UPDATE=1 pour le régénérer si le changement est voulu.", parityPerfFile) + } +} 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/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/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/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/parity_retry_test.go b/internal/loki/parity_retry_test.go new file mode 100644 index 0000000..0455d6e --- /dev/null +++ b/internal/loki/parity_retry_test.go @@ -0,0 +1,96 @@ +package loki + +import ( + "os" + "regexp" + "testing" +) + +// parity_retry_test.go — journal de référence des motifs retry_patterns +// (lot 30 de la migration Rust) : chaque regex, compilée par le Go en +// (?mi) comme compileRetryPatterns, et sa première correspondance sur des +// textes choisis (classes et limites de mot ASCII, texte littéral, classes +// aux caractères spéciaux, Unicode). Rangé dans +// rust/loki-rs/tests/parity_retry/patterns.json ; tests/retry.rs le rejoue. +// +// LOKI_PARITY_UPDATE=1 go test ./internal/loki -run TestPariteRetryPatterns + +const parityRetryFile = "../../rust/loki-rs/tests/parity_retry/patterns.json" + +func TestPariteRetryPatterns(t *testing.T) { + pats := []string{ + `\w+`, `\W+`, `\d+`, `\D+`, `\s+`, `\S+`, `\bdo_\w+\(`, `\Bfoo`, `\bfoo\b`, + `[\w-]+`, `[^\s]+`, `[\W]+`, `[\d\s]+`, `[\D]+`, `[^\w\s]+`, + `\Qa.b\E`, `\Q(x`, `[\Q]-\E]+`, `a\Q\E+`, + `[a[]+`, `[a&&b]+`, `[!--]+`, `[a--]`, `[~~a]+`, `[]a]+`, `[^]a]+`, + `[[:alpha:]]+`, `[^[:alpha:]]+`, `[[:word:]]+`, `[[:^digit:]]+`, + `^oups`, `\Aoups`, `fin$`, `\pL+`, `\p{Greek}+`, `x{2,3}`, `(?s:a.b)`, `a.b`, + `(?U)a+`, ``, `\{"name":\s*"(bash|read)"`, `[`, `(`, `\C`, `\z`, `a\z`, + `(?Pab)`, `\x41\x{42}`, `\101`, `\1`, `(a)\1`, `\0`, `\08`, `[\101-\103]+`, `[\x41-\x43]+`, `\.\*\+`, `é+`, `ÉTÉ`, + } + inputs := []string{ + "été do_it(1) éfoo foo bar_9", + "x٣42 a  \tb\n", + "axb a.b a(x a]-]b", + "x[a[ x&ab a!#-b x~a x]a [ab", + "1abc ab12 Ωμέγα", + "Bonjour\nOUPS !\nla fin", + "oups au début, puis la FIN", + "aaaa xxx a\nb aéb", + ` {"name": "read"}`, + "AB ABC éé ÉTÉ été", + "x.*+y", + } + type match struct { + In int `json:"in"` + Match *string `json:"match"` + At int `json:"at"` + } + type entry struct { + Pattern string `json:"pattern"` + Compiles bool `json:"compiles"` + Matches []match `json:"matches"` + } + journal := map[string]any{"inputs": inputs} + var es []entry + for _, p := range pats { + e := entry{Pattern: p, Matches: []match{}} + re, err := regexp.Compile("(?mi)" + p) + if err == nil { + e.Compiles = true + for i, in := range inputs { + m := match{In: i, At: -1} + if loc := re.FindStringIndex(in); loc != nil { + s := in[loc[0]:loc[1]] + m.Match, m.At = &s, loc[0] + } + e.Matches = append(e.Matches, m) + } + } + es = append(es, e) + } + journal["patterns"] = es + + got, err := parityJSON(journal) + if err != nil { + t.Fatal(err) + } + if os.Getenv("LOKI_PARITY_UPDATE") == "1" { + if err := os.MkdirAll("../../rust/loki-rs/tests/parity_retry", 0o755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(parityRetryFile, got, 0o644); err != nil { + t.Fatal(err) + } + return + } + want, err := os.ReadFile(parityRetryFile) + 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 motifs ne produisent plus le journal rangé dans %s.\n"+ + "Si c'est voulu : LOKI_PARITY_UPDATE=1 go test ./internal/loki -run TestPariteRetryPatterns,\n"+ + "puis cargo test --test retry dit si le port Rust suit.", parityRetryFile) + } +} 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/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/parity_strata_test.go b/internal/loki/parity_strata_test.go new file mode 100644 index 0000000..e84c310 --- /dev/null +++ b/internal/loki/parity_strata_test.go @@ -0,0 +1,464 @@ +//go:build linux + +package loki + +import ( + "crypto/sha256" + "encoding/hex" + "encoding/json" + "io/fs" + "net/http" + "net/http/httptest" + "os" + "os/exec" + "path/filepath" + "runtime" + "sort" + "strings" + "sync" + "testing" + "time" +) + +// parity_strata_test.go — journal de référence de Strata (lot 25 de la +// migration Rust) : la machine vue par l'écran d'installation (cartes, +// Python, RAM, disque) et ce qui y tient, les réglages d'un modèle installé +// (et la bascule qui les applique), l'installation (paquet figé vérifié, +// installeur lancé, preset écrit puis activé) et ses échecs. +// Rangé dans rust/loki-rs/tests/parity_strata/strata.json ; tests/strata.rs +// le rejoue par la façade. +// +// LOKI_PARITY_UPDATE=1 go test ./internal/loki -run TestPariteStrata + +const parityStrataFile = "../../rust/loki-rs/tests/parity_strata/strata.json" + +// Ce que sert la fausse release : les sources (archive), le moteur (des +// octets), un statut imposé, des empreintes attendues fausses. +type parityStrataRel struct { + Status int `json:"status,omitempty"` + Src []parityArchEntry `json:"src"` + Engine string `json:"engine"` + BadSHA bool `json:"bad_sha,omitempty"` +} + +type parityStrataStep struct { + Name string `json:"name"` + Machine string `json:"machine,omitempty"` + Meminfo string `json:"meminfo,omitempty"` + DiskFree int64 `json:"disk_free,omitempty"` + Files map[string]string `json:"files,omitempty"` + Config map[string]string `json:"config,omitempty"` + State map[string]string `json:"state,omitempty"` + Release *parityStrataRel `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"` + Status int `json:"status"` + Out any `json:"out"` + Job any `json:"job,omitempty"` + Actions []string `json:"actions"` + After map[string]string `json:"after"` + Tree []string `json:"tree"` +} + +// Les machines : nvidia-smi et python3 dans le PATH (bash et mkdir toujours). +func parityStrataMachines() map[string]map[string]string { + py := func(version string, venv bool) string { + s := "#!/bin/sh\ncase \"$1\" in\n--version) echo '" + version + "';;\n-c) " + if venv { + s += "exit 0;;\n" + } else { + s += "echo 'ModuleNotFoundError: No module named ensurepip'; exit 1;;\n" + } + return s + "esac\n" + } + ok := py("Python 3.12.3", true) + smi := func(rows string) string { + return "#!/bin/sh\ncase \"$1\" in\n--query-gpu=*) printf '" + rows + "';;\nesac\n" + } + deux := smi(`0, NVIDIA GeForce RTX 3070, 8192, 100, 8.6, 00000000:01:00.0, 4, 4, 16, 16, 7001\n1, NVIDIA GeForce RTX 5060 Ti, 16311, 5, 12.0, 00000000:02:00.0, 5, 5, 16, 16, 14001\n`) + return map[string]map[string]string{ + "sans-carte": {"python3": ok}, + "smi-en-panne": {"nvidia-smi": "#!/bin/sh\nexit 9\n", "python3": ok}, + "smi-vide": {"nvidia-smi": smi(``), "python3": ok}, + "vieille-carte": {"nvidia-smi": smi(`0, NVIDIA GeForce GTX 1080 Ti, 11264, 0, 6.1, 00000000:01:00.0, 3, 3, 16, 16, 5505\n`), "python3": ok}, + "petite-carte": {"nvidia-smi": smi(`0, NVIDIA GeForce RTX 3050, 6144, 0, 8.6, 00000000:01:00.0, 4, 4, 8, 8, 7001\n`), "python3": ok}, + "sans-python": {"nvidia-smi": deux}, + "python-casse": {"nvidia-smi": deux, "python3": "#!/bin/sh\nexit 1\n"}, + "python-muet": {"nvidia-smi": deux, "python3": py("Python inconnu", true)}, + "python-ancien": {"nvidia-smi": deux, "python3": py("Python 3.8.10", true)}, + "sans-venv": {"nvidia-smi": deux, "python3": py("Python 3.11.2", false)}, + "deux-cartes": {"nvidia-smi": deux, "python3": ok}, + "jumelles": {"nvidia-smi": smi(`0, NVIDIA GeForce RTX 4060 Ti, 16311, 0, 8.9, 00000000:01:00.0, 4, 4, 8, 8, 9001\n1, NVIDIA GeForce RTX 5060 Ti, 16311, 0, 12.0, 00000000:02:00.0, 5, 5, 8, 8, 14001\n2, NVIDIA GeForce GTX 1650, 4096, 0, 7.46, 00000000:03:00.0, 3, 3, 16, 16, 4001\n`), + "python3": ok}, + "aide-ancienne": {"nvidia-smi": smi(`0, NVIDIA GeForce GTX 1070, 8192, 0, 6.1, 00000000:01:00.0, 3, 3, 16, 16, 4004\n1, NVIDIA GeForce RTX 4090, 24564, 0, 8.9, 00000000:02:00.0, 4, 4, 16, 16, 10501\n`), + "python3": ok}, + } +} + +// L'installeur factice : ses arguments, une barre de progression, puis ce +// que le modèle demandé lui fait faire. +const parityStrataSetup = `#!/bin/bash +echo "installeur : $*" +echo "cache : $XDG_CACHE_HOME" +printf 'modèle 10%%\r' +printf 'modèle 20%%\r' +echo "modèle téléchargé" +mkdir -p engine-cuda12 .venv/bin +fam=""; model=""; data="" +while [ $# -gt 0 ]; do + case "$1" in + --family) fam="$2"; shift;; + --model) model="$2"; shift;; + --data-dir) data="$2"; shift;; + esac + shift +done +case "$model" in +Q2_0) echo "erreur : téléchargement interrompu" >&2; exit 3;; +IQ2_XS) echo "rien d'écrit"; exit 0;; +esac +low=$(echo "$model" | tr 'A-Z' 'a-z') +if [ "$fam" = "swift" ]; then tag="swift-$low"; else tag="$low"; fi +mkdir -p "$data/packs/$tag" +printf '{"exe":"engine-cuda12/strata","args":["--pack","%s","--kv","int8","--spec","4","--max-context","131072","--vision","--vram-reserve-mib","700"],"vision":{"exe":"v","gpu":true},"gpu":0,"port":8080}\n' "$data/packs/$tag" > "strata-$tag.json" +echo "prêt : strata-$tag.json" +` + +func TestPariteStrata(t *testing.T) { + if runtime.GOARCH != "amd64" { + t.Skip("Strata n'est proposé que sous Linux x86_64") + } + home := testHome(t) + resetBackendsState() + t.Cleanup(resetBackendsState) + + tools := t.TempDir() + bash, err := exec.LookPath("bash") + if err != nil { + t.Skip("bash introuvable") + } + mkdir, _ := exec.LookPath("mkdir") + tr, _ := exec.LookPath("tr") + t.Setenv("PATH", tools) + machines := parityStrataMachines() + setMachine := func(name string) { + entries, _ := os.ReadDir(tools) + for _, e := range entries { + _ = os.Remove(filepath.Join(tools, e.Name())) + } + for n, p := range map[string]string{"bash": bash, "mkdir": mkdir, "tr": tr} { + if err := os.Symlink(p, filepath.Join(tools, n)); err != nil { + t.Fatal(err) + } + } + for n, body := range machines[name] { + if err := os.WriteFile(filepath.Join(tools, n), []byte(body), 0o755); err != nil { + t.Fatal(err) + } + } + } + + meminfo := filepath.Join(t.TempDir(), "meminfo") + oldMem, oldDisk, oldBase, oldSHA, oldSvc := meminfoPath, strataDiskFree, strataReleaseBase, strataAssetSHA, serviceActionOSFn + var mu sync.Mutex + var disk int64 + var actions []string + meminfoPath = meminfo + strataDiskFree = func(string) int64 { mu.Lock(); defer mu.Unlock(); return disk } + serviceActionOSFn = func(a string) error { mu.Lock(); defer mu.Unlock(); actions = append(actions, a); return nil } + t.Cleanup(func() { + meminfoPath, strataDiskFree, strataReleaseBase, strataAssetSHA, serviceActionOSFn = oldMem, oldDisk, oldBase, oldSHA, oldSvc + }) + + // La fausse release. + var rel parityStrataRel + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + mu.Lock() + cur := rel + mu.Unlock() + if cur.Status != 0 { + w.WriteHeader(cur.Status) + return + } + switch strings.TrimPrefix(r.URL.Path, "/") { + case strataSrcAsset: + _, _ = w.Write(parityTarGz(t, cur.Src)) + case strataEngineAsset: + _, _ = w.Write([]byte(cur.Engine)) + default: + w.WriteHeader(404) + } + })) + defer srv.Close() + strataReleaseBase = srv.URL + "/" + setRelease := func(r parityStrataRel) { + mu.Lock() + rel = r + mu.Unlock() + sum := func(b []byte) string { h := sha256.Sum256(b); return hex.EncodeToString(h[:]) } + strataAssetSHA = map[string]string{ + strataSrcAsset: sum(parityTarGz(t, r.Src)), + strataEngineAsset: sum([]byte(r.Engine)), + } + if r.BadSHA { + strataAssetSHA[strataEngineAsset] = strings.Repeat("0", 64) + } + } + + mask := func(s string) string { + s = strings.ReplaceAll(s, srv.URL, "{{REL}}") + s = strings.ReplaceAll(s, home, "{{HOME}}") + return parityTSRe.ReplaceAllString(s, `"$1":"{{T}}"`) + } + unmask := func(s string) string { return strings.ReplaceAll(s, "{{HOME}}", home) } + handlers := map[string]http.HandlerFunc{ + "/api/strata": handleStrata, + "/api/strata/settings": handleStrataSettings, + "/api/strata/install": handleStrataInstall, + } + // L'arborescence : presets/ et strata/ (sans le contenu des archives, qui + // diffère d'une implémentation à l'autre). + tree := func() []string { + var out []string + for _, root := range []string{"presets", "strata"} { + _ = 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) + info, err := os.Lstat(p) + if err != nil { + return nil + } + switch { + case info.IsDir(): + out = append(out, "d "+rel) + case strings.HasPrefix(rel, "strata/dl/") || strings.HasPrefix(rel, "strata/prebuilt/"): + out = append(out, "f "+rel) + default: + b, _ := os.ReadFile(p) + out = append(out, "f "+rel+" "+info.Mode().Perm().String()+" "+mask(string(b))) + } + return nil + }) + } + sort.Strings(out) + return out + } + + src := []parityArchEntry{ + {Name: "ajean-moe-1.0/", Type: "dir", Mode: 0o755}, + {Name: "ajean-moe-1.0/setup.sh", Type: "file", Body: parityStrataSetup, Mode: 0o755}, + {Name: "ajean-moe-1.0/setup.py", Type: "file", Body: "# installeur\n", Mode: 0o644}, + {Name: "ajean-moe-1.0/serve/server.py", Type: "file", Body: "# serveur\n", Mode: 0o644}, + } + goodRel := parityStrataRel{Src: src, Engine: "moteur strata 1.0"} + mem47 := "MemTotal: 49283072 kB\nMemFree: 1000000 kB\nMemAvailable: 41943040 kB\n" + mem64 := "MemTotal: 67108864 kB\nMemAvailable: 65011712 kB\n" + mem16 := "MemTotal: 16777216 kB\nMemAvailable: 12582912 kB\n" + settings := func(preset string, ctx int, extra string) string { + return `{"preset":"` + preset + `","ctx":` + jsonInt(ctx) + `,"kv":"int8","spec":4,"vision":false,"helper":true` + extra + `}` + } + + var steps []parityStrataStep + run := func(st parityStrataStep) { + if st.Machine != "" { + setMachine(st.Machine) + } + if st.Meminfo != "" { + if err := os.WriteFile(meminfo, []byte(st.Meminfo), 0o644); err != nil { + t.Fatal(err) + } + } + if st.DiskFree != 0 { + mu.Lock() + disk = st.DiskFree + mu.Unlock() + } + 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) + } + } + for k, v := range st.State { + if err := putStr(bkState, k, v); err != nil { + t.Fatal(err) + } + } + if st.Release != nil { + setRelease(*st.Release) + } + mu.Lock() + actions = nil + mu.Unlock() + var lock *tuneLock + if st.Tune { + l, err := tuneLockAcquire("web", false) + if err != nil { + t.Fatal(err) + } + lock = l + } + rec := httptest.NewRecorder() + handlers[st.Path](rec, httptest.NewRequest(st.Method, st.Path, strings.NewReader(unmask(st.Body)))) + if lock != nil { + lock.release() + } + st.Status = rec.Code + if err := json.Unmarshal([]byte(mask(rec.Body.String())), &st.Out); err != nil { + t.Fatalf("%s : corps illisible %q", st.Name, rec.Body.String()) + } + 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(10 * time.Millisecond) + } + b, _ := json.Marshal(lcJobSnapshot(0, true)) + _ = json.Unmarshal([]byte(mask(string(b))), &st.Job) + } + mu.Lock() + st.Actions = append([]string{}, actions...) + mu.Unlock() + st.After = map[string]string{} + cfg := ReadConfig() + for _, k := range []string{"ENGINE", "STRATA_CONFIG", "CTX", "STRATA_KV", "STRATA_HELPER"} { + if v := cfg[k]; v != "" { + st.After["config/"+k] = mask(v) + } + } + st.Tree = tree() + steps = append(steps, st) + } + + for _, st := range []parityStrataStep{ + {Name: "aucune carte", Machine: "sans-carte", Meminfo: mem47, DiskFree: 400e9, Method: "GET", Path: "/api/strata"}, + {Name: "nvidia-smi en panne", Machine: "smi-en-panne", Method: "GET", Path: "/api/strata"}, + {Name: "nvidia-smi sans carte", Machine: "smi-vide", Method: "GET", Path: "/api/strata"}, + {Name: "carte trop ancienne", Machine: "vieille-carte", Method: "GET", Path: "/api/strata"}, + {Name: "carte trop petite", Machine: "petite-carte", Method: "GET", Path: "/api/strata"}, + {Name: "sans Python", Machine: "sans-python", Method: "GET", Path: "/api/strata"}, + {Name: "Python en panne", Machine: "python-casse", Method: "GET", Path: "/api/strata"}, + {Name: "Python illisible", Machine: "python-muet", Method: "GET", Path: "/api/strata"}, + {Name: "Python trop ancien", Machine: "python-ancien", Method: "GET", Path: "/api/strata"}, + {Name: "sans venv", Machine: "sans-venv", Method: "GET", Path: "/api/strata"}, + {Name: "deux cartes, 47 Go", Machine: "deux-cartes", Method: "GET", Path: "/api/strata"}, + {Name: "trois cartes, la plus récente devant", Machine: "jumelles", Method: "GET", Path: "/api/strata"}, + {Name: "carte d'aide trop ancienne", Machine: "aide-ancienne", Method: "GET", Path: "/api/strata"}, + {Name: "64 Go de RAM", Machine: "deux-cartes", Meminfo: mem64, Method: "GET", Path: "/api/strata"}, + {Name: "16 Go de RAM", Meminfo: mem16, Method: "GET", Path: "/api/strata"}, + {Name: "disque presque plein", Meminfo: mem47, DiskFree: 50e9, Method: "GET", Path: "/api/strata"}, + {Name: "disque juste trop petit", DiskFree: 78e9, Method: "GET", Path: "/api/strata"}, + {Name: "données déjà là", DiskFree: 60e9, Files: map[string]string{ + "strata/data/models/swift-IQ3_XXS/a.gguf": strings.Repeat("x", 2000), + }, Method: "GET", Path: "/api/strata"}, + {Name: "meminfo sans MemAvailable", DiskFree: 400e9, Meminfo: "MemTotal: 49283072 kB\n", Method: "GET", Path: "/api/strata"}, + + // Réglages. + {Name: "réglages en GET", Meminfo: mem47, Method: "GET", Path: "/api/strata/settings"}, + {Name: "réglages illisibles", Method: "POST", Path: "/api/strata/settings", Body: `{"ctx":"x"}`}, + {Name: "réglages sans preset", Method: "POST", Path: "/api/strata/settings", Body: settings("", 65536, "")}, + {Name: "réglages, preset avec chemin", Method: "POST", Path: "/api/strata/settings", Body: settings("../x", 65536, "")}, + {Name: "réglages, preset absent", Method: "POST", Path: "/api/strata/settings", Body: settings("absent", 65536, "")}, + {Name: "réglages, preset llama.cpp", Files: map[string]string{"presets/llama.env": "MODEL=/m.gguf\n"}, + Method: "POST", Path: "/api/strata/settings", Body: settings("llama", 65536, "")}, + {Name: "réglages hors limites", Files: map[string]string{"presets/S.env": "# NAME=Strata S\nENGINE=strata\nSTRATA_HELPER_GPU=0\nCTX=131072\n"}, + Method: "POST", Path: "/api/strata/settings", Body: settings("S", 1000, "")}, + {Name: "réglages, experts inconnus", Method: "POST", Path: "/api/strata/settings", Body: settings("S", 65536, `,"experts":"nvme"`)}, + {Name: "réglages d'un preset inactif", Method: "POST", Path: "/api/strata/settings", Body: settings("S", 65536, `,"experts":"disk"`)}, + {Name: "réglages du preset actif", Config: map[string]string{"ENGINE": "strata", "STRATA_HELPER_GPU": "0", "CTX": "65536", + "STRATA_KV": "int8", "STRATA_SPEC": "4", "STRATA_VISION": "0", "STRATA_HELPER": "1", "STRATA_EXPERTS": "disk"}, + Method: "POST", Path: "/api/strata/settings", Body: settings("S", 98304, "")}, + {Name: "réglages du preset actif pendant une optimisation", Tune: true, + Method: "POST", Path: "/api/strata/settings", Body: settings("S", 98304, "")}, + + // Installation. + {Name: "installation en GET", Method: "GET", Path: "/api/strata/install"}, + {Name: "installation illisible", Method: "POST", Path: "/api/strata/install", Body: `[`}, + {Name: "installation d'un quant inconnu", Method: "POST", Path: "/api/strata/install", Body: `{"family":"swift","quant":"Q2_0"}`}, + {Name: "installation pendant une optimisation", Tune: true, Method: "POST", Path: "/api/strata/install", Body: `{"family":"swift","quant":"IQ3_XXS"}`}, + {Name: "installation sur une machine sans carte", Machine: "sans-carte", Method: "POST", Path: "/api/strata/install", + Body: `{"family":"swift","quant":"IQ3_XXS"}`, Wait: true}, + {Name: "installation qui ne tient pas", Machine: "deux-cartes", Meminfo: mem16, Method: "POST", Path: "/api/strata/install", + Body: `{"family":"qwen","quant":"IQ3_S"}`, Wait: true}, + {Name: "release introuvable", Meminfo: mem47, Release: &parityStrataRel{Status: 404, Src: src, Engine: "x"}, + Method: "POST", Path: "/api/strata/install", Body: `{"family":"swift","quant":"IQ3_XXS"}`, Wait: true}, + {Name: "moteur altéré", Release: &parityStrataRel{Src: src, Engine: "moteur strata 1.0", BadSHA: true}, + Method: "POST", Path: "/api/strata/install", Body: `{"family":"swift","quant":"IQ3_XXS"}`, Wait: true}, + {Name: "installation", Release: &goodRel, Method: "POST", Path: "/api/strata/install", + Body: `{"family":"swift","quant":"IQ3_XXS"}`, Wait: true}, + {Name: "après l'installation", Method: "GET", Path: "/api/strata"}, + {Name: "mode lancé connu, modèles inactifs", State: map[string]string{"strata_experts_mode": "drop"}, Files: map[string]string{ + "presets/STRATA FLASH NEXT CLASSIQUE IQ2_XS.env": "# NAME=Classique IQ2_XS\nENGINE=strata\nSTRATA_CONFIG={{HOME}}/strata/ajean-moe-1.0/strata-swift-iq3_xxs.json\n" + + "STRATA_MAIN_GPU=1\nSTRATA_HELPER_GPU=-1\nSTRATA_EXPERTS=disk\n", + "presets/STRATA FLASH NEXT CLASSIQUE Q2_0.env": "ENGINE=moe\nSTRATA_CONFIG={{HOME}}/strata/ajean-moe-1.0/strata-swift-iq3_xxs.json\n" + + "STRATA_MAIN_GPU=0\nSTRATA_HELPER_GPU=1\nSTRATA_HELPER=0\nSTRATA_VISION=0\nSTRATA_EXPERTS=ram\n", + }, Method: "GET", Path: "/api/strata"}, + {Name: "experts.bin et moteur du paquet", Files: map[string]string{ + "strata/data/packs/swift-iq3_xxs/experts.bin": "x", + }, State: map[string]string{"strata_experts_mode": ""}, Method: "GET", Path: "/api/strata"}, + {Name: "RAM juste sous le seuil du mode hors RAM", Meminfo: "MemTotal: 49283072 kB\nMemAvailable: 43164063 kB\n", + Method: "GET", Path: "/api/strata"}, + {Name: "RAM au-dessus du seuil du mode hors RAM", Meminfo: "MemTotal: 49283072 kB\nMemAvailable: 43457032 kB\n", + Method: "GET", Path: "/api/strata"}, + {Name: "mode hors RAM coupé dans le preset", Files: map[string]string{ + "presets/STRATA FLASH NEXT SWIFT 1.5 IQ3_XXS.env": "# NAME=Qwen3.8 Flash Next Swift 1.5 IQ3_XXS (Strata)\nENGINE=strata\n" + + "STRATA_CONFIG={{HOME}}/strata/ajean-moe-1.0/strata-swift-iq3_xxs.json\nSTRATA_MAIN_GPU=1\nSTRATA_HELPER_GPU=0\n" + + "STRATA_VISION=1\nSTRATA_EXPERTS=auto\nSTRATA_DROP=0\nSTRATA_KV=fp16\nSTRATA_KV_RESIDENT=32768\nSTRATA_SPEC=3\nCTX=131072\nREASONING=off\n", + }, Method: "GET", Path: "/api/strata"}, + {Name: "moteur d'un autre paquet", Meminfo: mem47, Files: map[string]string{ + "strata/ajean-moe-1.0/engine-cuda12/.loki-engine": "ajean-moe-engine-0.9-linux-x64-cuda12.zip\n", + }, Method: "GET", Path: "/api/strata"}, + {Name: "réinstallation, moteur téléchargé périmé", Files: map[string]string{ + "strata/dl/ajean-moe-engine-1.0-linux-x64-cuda12.zip": "ancien moteur", + }, Method: "POST", Path: "/api/strata/install", Body: `{"family":"swift","quant":"IQ3_XXS"}`, Wait: true}, + {Name: "installeur en échec", Method: "POST", Path: "/api/strata/install", Body: `{"family":"qwen","quant":"Q2_0"}`, Wait: true}, + {Name: "installeur sans configuration", Method: "POST", Path: "/api/strata/install", Body: `{"family":"qwen","quant":"IQ2_XS"}`, Wait: true}, + {Name: "configuration de lancement disparue", Files: map[string]string{ + "strata/ajean-moe-1.0/strata-swift-iq3_xxs.json": "-", + }, Method: "GET", Path: "/api/strata"}, + {Name: "configuration de lancement illisible", Files: map[string]string{ + "strata/ajean-moe-1.0/strata-swift-iq3_xxs.json": "[1]", + }, Method: "GET", Path: "/api/strata"}, + } { + run(st) + } + + got, err := parityJSON(map[string]any{"machines": machines, "setup": parityStrataSetup, "steps": steps}) + if err != nil { + t.Fatal(err) + } + if os.Getenv("LOKI_PARITY_UPDATE") == "1" { + if err := os.MkdirAll(filepath.Dir(parityStrataFile), 0o755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(parityStrataFile, got, 0o644); err != nil { + t.Fatal(err) + } + return + } + want, err := os.ReadFile(parityStrataFile) + 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("Strata ne produit plus le journal rangé dans %s.\n"+ + "Si c'est voulu : LOKI_PARITY_UPDATE=1 go test ./internal/loki -run TestPariteStrata,\n"+ + "puis cargo test --test strata dit si le port Rust suit.\n--- obtenu ---\n%s", parityStrataFile, got) + } +} + +func jsonInt(n int) string { + b, _ := json.Marshal(n) + return string(b) +} diff --git a/internal/loki/parity_subagent_test.go b/internal/loki/parity_subagent_test.go new file mode 100644 index 0000000..b3796dd --- /dev/null +++ b/internal/loki/parity_subagent_test.go @@ -0,0 +1,225 @@ +//go:build linux + +package loki + +import ( + "bytes" + "context" + "encoding/json" + "io" + "net/http" + "net/http/httptest" + "net/url" + "os" + "os/user" + "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}}")) + } + // 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) + } + 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/parity_system_test.go b/internal/loki/parity_system_test.go new file mode 100644 index 0000000..f27cffc --- /dev/null +++ b/internal/loki/parity_system_test.go @@ -0,0 +1,296 @@ +//go:build linux + +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/parity_tasks_test.go b/internal/loki/parity_tasks_test.go new file mode 100644 index 0000000..0dffdc4 --- /dev/null +++ b/internal/loki/parity_tasks_test.go @@ -0,0 +1,361 @@ +//go:build linux + +package loki + +import ( + "encoding/json" + "net/http" + "net/http/httptest" + "os" + "regexp" + "strconv" + "strings" + "testing" + "time" +) + +// parity_tasks_test.go — journal de référence des tâches planifiées (lot 26 +// de la migration Rust) : l'enregistrement (chaque forme de fréquence, ses +// erreurs, les fuseaux et les changements d'heure), la bascule, l'interrupteur +// maître, la suppression, le lancement d'une tâche script (succès, échec, +// arrêt) ou IA (moteur occupé, modèle pas prêt) et les tics du planificateur, +// l'heure des tâches fixée pas à pas. Rangé dans +// rust/loki-rs/tests/parity_tasks/tasks.json ; tests/tasks.rs le rejoue par la +// façade. +// +// LOKI_PARITY_UPDATE=1 go test ./internal/loki -run TestPariteTaches + +const parityTasksFile = "../../rust/loki-rs/tests/parity_tasks/tasks.json" + +type parityTasksStep struct { + Name string `json:"name"` + Now string `json:"now,omitempty"` // RFC 3339 : l'heure des tâches + Files map[string]string `json:"files,omitempty"` + Config map[string]string `json:"config,omitempty"` + State map[string]string `json:"state,omitempty"` + Generating bool `json:"generating,omitempty"` + Tick bool `json:"tick,omitempty"` // un tic du planificateur, avant la requête + Tune bool `json:"tune,omitempty"` // une optimisation tient le moteur pendant l'étape + PutTask string `json:"put_task,omitempty"` // une tâche écrite telle quelle en base + Method string `json:"method,omitempty"` + Path string `json:"path,omitempty"` + Body string `json:"body,omitempty"` + WaitRunning string `json:"wait_running,omitempty"` // « {{IDn}} » : attendre qu'elle tourne + WaitDone string `json:"wait_done,omitempty"` // « {{IDn}} » : attendre la fin du passage + Status int `json:"status,omitempty"` + Out any `json:"out,omitempty"` + Tasks any `json:"tasks"` +} + +var ( + parityTaskDurRe = regexp.MustCompile(`exit: (-?[0-9]+) · [^\n"\\]*`) + parityTaskIDRe = regexp.MustCompile(`\b[0-9a-f]{12}\b`) + parityTaskCreatedRe = regexp.MustCompile(`"created":[0-9]+`) +) + +func TestPariteTaches(t *testing.T) { + home := testHome(t) + oldNow := tasksNow + var now time.Time + tasksNow = func() time.Time { return now } + t.Cleanup(func() { tasksNow = oldNow }) + + // Les identifiants tirés au hasard deviennent {{ID1}}, {{ID2}}… dans + // l'ordre où ils apparaissent. + ids := map[string]string{} + var order []string + mask := func(s string) string { + s = parityTaskIDRe.ReplaceAllStringFunc(s, func(id string) string { + if _, ok := ids[id]; !ok { + order = append(order, id) + ids[id] = "{{ID" + strconv.Itoa(len(order)) + "}}" + } + return ids[id] + }) + s = strings.ReplaceAll(s, home, "{{HOME}}") + // La date du projet par défaut, posée à la lecture (heure réelle). + s = parityTaskCreatedRe.ReplaceAllString(s, `"created":"{{T}}"`) + return parityTaskDurRe.ReplaceAllString(s, "exit: $1 · {{DUR}}") + } + unmask := func(s string) string { + for id, m := range ids { + s = strings.ReplaceAll(s, m, id) + } + return strings.ReplaceAll(s, "{{HOME}}", home) + } + handlers := map[string]http.HandlerFunc{ + "/api/tasks": handleTasks, + "/api/tasks/save": handleTaskSave, + "/api/tasks/delete": handleTaskDelete, + "/api/tasks/toggle": handleTaskToggle, + "/api/tasks/pause": handleTasksPause, + "/api/tasks/run": handleTaskRun, + "/api/tasks/stop": handleTaskStop, + } + call := func(method, path, body string) (int, any) { + rec := httptest.NewRecorder() + handlers[path](rec, httptest.NewRequest(method, path, strings.NewReader(body))) + 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 + } + snapshot := func() any { + b, _ := json.Marshal(listTasks()) + var v any + _ = json.Unmarshal([]byte(mask(string(b))), &v) + return v + } + lastRun := func(id string) int64 { + tk, _ := getTask(id) + return tk.LastRun + } + + var steps []parityTasksStep + run := func(st parityTasksStep) { + if st.Now != "" { + n, err := time.Parse(time.RFC3339, st.Now) + if err != nil { + t.Fatal(err) + } + now = n + } + 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, v); err != nil { + t.Fatal(err) + } + } + for k, v := range st.State { + if err := putStr(bkState, k, v); err != nil { + t.Fatal(err) + } + } + if st.PutTask != "" { + var tk Task + if err := json.Unmarshal([]byte(st.PutTask), &tk); err != nil { + t.Fatal(err) + } + if err := putBytes(bkTasks, tk.ID, []byte(st.PutTask)); err != nil { + t.Fatal(err) + } + } + if st.Generating { + conv.mu.Lock() + conv.Generating = true + conv.mu.Unlock() + } + var lock *tuneLock + if st.Tune { + l, err := tuneLockAcquire("web", false) + if err != nil { + t.Fatal(err) + } + lock = l + } + if st.Tick { + tickTasks(tasksNow()) + } + var before int64 + if st.WaitDone != "" { + before = lastRun(unmask(st.WaitDone)) + } + if st.Path != "" { + st.Status, st.Out = call(st.Method, st.Path, unmask(st.Body)) + } + if lock != nil { + lock.release() + } + if st.Generating { + conv.mu.Lock() + conv.Generating = false + conv.mu.Unlock() + } + if st.WaitRunning != "" { + deadline := time.Now().Add(10 * time.Second) + for { + _, out := call("GET", "/api/tasks", "") + if out.(map[string]any)["running_id"] == st.WaitRunning { + break + } + if time.Now().After(deadline) { + t.Fatalf("%s : la tâche ne tourne pas", st.Name) + } + time.Sleep(10 * time.Millisecond) + } + } + if st.WaitDone != "" { + id := unmask(st.WaitDone) + deadline := time.Now().Add(20 * time.Second) + for lastRun(id) == before { + if time.Now().After(deadline) { + t.Fatalf("%s : le passage ne finit pas", st.Name) + } + time.Sleep(10 * time.Millisecond) + } + // Le registre des scripts est vidé juste après l'écriture. + for { + if sid, _ := scriptRunningAny(); sid == "" { + break + } + time.Sleep(5 * time.Millisecond) + } + } + st.Tasks = snapshot() + steps = append(steps, st) + } + + save := func(name, extra string) string { + return `{"name":"` + name + `","prompt":"Fais le point.","enabled":true` + extra + `}` + } + for _, st := range []parityTasksStep{ + {Name: "liste vide", Now: "2026-03-28T12:00:00Z", Files: map[string]string{ + "scripts/ok.sh": "#!/bin/sh\necho bonjour\n", + "scripts/echec.sh": "#!/bin/sh\necho oups >&2\nexit 2\n", + "scripts/lent.sh": "#!/bin/sh\nsleep 30\n", + "scripts/sous/rang.sh": "#!/bin/sh\necho rangé\n", + }, Method: "GET", Path: "/api/tasks"}, + {Name: "agent actif, interrupteur", State: map[string]string{"agent": "1"}, Method: "GET", Path: "/api/tasks"}, + {Name: "mémoire coupée, internet armé", Config: map[string]string{"MEM_MODE": "off"}, State: map[string]string{"internet": "1"}, Method: "GET", Path: "/api/tasks"}, + + // Enregistrement : erreurs. + {Name: "corps illisible", Method: "POST", Path: "/api/tasks/save", Body: `{"enabled":"oui"}`}, + {Name: "sans nom", Method: "POST", Path: "/api/tasks/save", Body: `{"name":" ","prompt":"x","schedule":"@every 1h"}`}, + {Name: "sans consigne", Method: "POST", Path: "/api/tasks/save", Body: `{"name":"x","schedule":"@every 1h"}`}, + {Name: "script sans nom de script", Method: "POST", Path: "/api/tasks/save", Body: `{"name":"x","kind":"script","schedule":"@every 1h"}`}, + {Name: "script absent", Method: "POST", Path: "/api/tasks/save", Body: `{"name":"x","kind":"script","script":"absent.sh","schedule":"@every 1h"}`}, + {Name: "script : dossier", Method: "POST", Path: "/api/tasks/save", Body: `{"name":"x","kind":"script","script":"sous","schedule":"@every 1h"}`}, + {Name: "script : remontée", Method: "POST", Path: "/api/tasks/save", Body: `{"name":"x","kind":"script","script":"../x.sh","schedule":"@every 1h"}`}, + {Name: "script : chemin absolu", Method: "POST", Path: "/api/tasks/save", Body: `{"name":"x","kind":"script","script":"/etc/x","schedule":"@every 1h"}`}, + {Name: "script : séparateur Windows", Method: "POST", Path: "/api/tasks/save", Body: `{"name":"x","kind":"script","script":"\\x.sh","schedule":"@every 1h"}`}, + {Name: "script : caractère interdit", Method: "POST", Path: "/api/tasks/save", Body: `{"name":"x","kind":"script","script":"a;b","schedule":"@every 1h"}`}, + {Name: "fréquence vide", Method: "POST", Path: "/api/tasks/save", Body: save("x", ``)}, + {Name: "intervalle trop court", Method: "POST", Path: "/api/tasks/save", Body: save("x", `,"schedule":"@every 30s"`)}, + {Name: "intervalle illisible", Method: "POST", Path: "/api/tasks/save", Body: save("x", `,"schedule":"@every deux heures"`)}, + {Name: "intervalle sans unité", Method: "POST", Path: "/api/tasks/save", Body: save("x", `,"schedule":"@every 5"`)}, + {Name: "jours invalides", Method: "POST", Path: "/api/tasks/save", Body: save("x", `,"schedule":"@every 0d"`)}, + {Name: "heure hors bornes", Method: "POST", Path: "/api/tasks/save", Body: save("x", `,"schedule":"@every 1d@24:00"`)}, + {Name: "heure sans deux-points", Method: "POST", Path: "/api/tasks/save", Body: save("x", `,"schedule":"@every 1d@9h"`)}, + {Name: "cron à 4 champs", Method: "POST", Path: "/api/tasks/save", Body: save("x", `,"schedule":"0 9 * *"`)}, + {Name: "cron : minute hors bornes", Method: "POST", Path: "/api/tasks/save", Body: save("x", `,"schedule":"61 9 * * *"`)}, + {Name: "cron : jour en lettres", Method: "POST", Path: "/api/tasks/save", Body: save("x", `,"schedule":"0 9 * * sun"`)}, + {Name: "cron : plage inversée", Method: "POST", Path: "/api/tasks/save", Body: save("x", `,"schedule":"0 9 5-1 * *"`)}, + {Name: "cron : pas nul", Method: "POST", Path: "/api/tasks/save", Body: save("x", `,"schedule":"*/0 * * * *"`)}, + {Name: "cron jamais satisfait", Method: "POST", Path: "/api/tasks/save", Body: save("x", `,"schedule":"0 0 30 2 *"`)}, + {Name: "une fois, date illisible", Method: "POST", Path: "/api/tasks/save", Body: save("x", `,"schedule":"@once 2026-04-31 10:00"`)}, + {Name: "une fois, déjà passé", Method: "POST", Path: "/api/tasks/save", Body: save("x", `,"schedule":"@once 2026-03-28 13:00","tz":"Europe/Paris"`)}, + + // Enregistrement : chaque forme. + {Name: "toutes les 2 h", Method: "POST", Path: "/api/tasks/save", Body: save("B toutes les 2 h", `,"schedule":"@every 2h"`)}, + {Name: "toutes les 1 h 30", Method: "POST", Path: "/api/tasks/save", Body: save("C une heure et demie", `,"schedule":"@EVERY 1.5h","enabled":false`)}, + {Name: "tous les jours à 23:00, Paris", Method: "POST", Path: "/api/tasks/save", Body: save("D chaque soir", `,"schedule":"@every 1d@23:00","tz":"Europe/Paris"`)}, + {Name: "tous les 3 jours à 9:30, New York", Method: "POST", Path: "/api/tasks/save", Body: save("E tous les trois jours", `,"schedule":"@every 3d@9:30","tz":"America/New_York"`)}, + {Name: "en semaine à 9 h, Calcutta", Method: "POST", Path: "/api/tasks/save", Body: save("F semaine", `,"schedule":"0 9 * * 1-5","tz":"Asia/Kolkata"`)}, + {Name: "chaque nuit à 2:30, Paris (heure qui saute)", Method: "POST", Path: "/api/tasks/save", Body: save("G nuit", `,"schedule":"30 2 * * *","tz":"Europe/Paris"`)}, + {Name: "toutes les 15 min, fuseau inconnu", Method: "POST", Path: "/api/tasks/save", Body: save("H quart d'heure", `,"schedule":"*/15 * * * *","tz":"Nulle/Part"`)}, + {Name: "le dimanche (7)", Method: "POST", Path: "/api/tasks/save", Body: save("I dimanche", `,"schedule":"0 8 * * 7"`)}, + {Name: "une seule fois", Method: "POST", Path: "/api/tasks/save", Body: save("J une fois", `,"schedule":"@once 2026-03-29 03:30","tz":"Europe/Paris"`)}, + {Name: "tâche script", Method: "POST", Path: "/api/tasks/save", Body: `{"name":"A script","kind":"script","script":" ok.sh ","schedule":"@every 1h","enabled":true}`}, + {Name: "tâche script en échec", Method: "POST", Path: "/api/tasks/save", Body: `{"name":"K échec","kind":"script","script":"echec.sh","schedule":"@every 1h","enabled":true}`}, + {Name: "tâche script lente", Method: "POST", Path: "/api/tasks/save", Body: `{"name":"L lente","kind":"script","script":"lent.sh","schedule":"@every 1h"}`}, + {Name: "script une seule fois", Method: "POST", Path: "/api/tasks/save", + Body: `{"name":"N script une fois","kind":"script","script":"ok.sh","schedule":"@once 2026-03-29 03:30","tz":"Europe/Paris","enabled":true}`}, + {Name: "tâche avec id inconnu", Method: "POST", Path: "/api/tasks/save", Body: `{"id":"zz","name":"M imposée","prompt":"x","schedule":"@every 1h","project":"inconnu","no_mem":true,"no_web":true}`}, + {Name: "liste", Method: "GET", Path: "/api/tasks"}, + + // Modification : la fréquence inchangée garde le prochain passage. + {Name: "renommer sans changer la fréquence", Now: "2026-03-28T12:30:00Z", Method: "POST", Path: "/api/tasks/save", + Body: `{"id":"{{ID1}}","name":"B renommée","prompt":"Autre.","schedule":"@every 2h","enabled":true}`}, + {Name: "changer la fréquence", Method: "POST", Path: "/api/tasks/save", + Body: `{"id":"{{ID1}}","name":"B renommée","prompt":"Autre.","schedule":"@every 3h","enabled":true}`}, + + // Bascule, interrupteur maître, suppression. + {Name: "bascule d'une tâche absente", Method: "POST", Path: "/api/tasks/toggle", Body: `{"id":"absente","on":true}`}, + {Name: "réactiver", Now: "2026-03-28T13:00:00Z", Method: "POST", Path: "/api/tasks/toggle", Body: `{"id":"{{ID2}}","on":true}`}, + {Name: "désactiver", Method: "POST", Path: "/api/tasks/toggle", Body: `{"id":"{{ID2}}","on":false}`}, + {Name: "suspendre tout", Method: "POST", Path: "/api/tasks/pause", Body: `{"on":true}`}, + {Name: "liste suspendue", Method: "GET", Path: "/api/tasks"}, + {Name: "tic suspendu", Now: "2026-03-28T23:00:00Z", Tick: true}, + {Name: "reprendre", Method: "POST", Path: "/api/tasks/pause", Body: `{"on":false}`}, + {Name: "interrupteur rangé à 0 : pas suspendu", State: map[string]string{"tasks_paused": "0"}, Method: "GET", Path: "/api/tasks"}, + {Name: "suppression sans id", Method: "POST", Path: "/api/tasks/delete", Body: `{}`}, + {Name: "suppression", Method: "POST", Path: "/api/tasks/delete", Body: `{"id":"zz"}`}, + + // Lancements. + {Name: "lancer une tâche absente", Method: "POST", Path: "/api/tasks/run", Body: `{"id":"absente"}`}, + {Name: "lancer une tâche IA, moteur occupé", Generating: true, Method: "POST", Path: "/api/tasks/run", Body: `{"id":"{{ID1}}"}`}, + {Name: "lancer une tâche IA, modèle pas prêt", Method: "POST", Path: "/api/tasks/run", Body: `{"id":"{{ID1}}"}`}, + {Name: "lancer le script", Method: "POST", Path: "/api/tasks/run", Body: `{"id":"{{ID10}}"}`, WaitDone: "{{ID10}}"}, + {Name: "lancer le script en échec", Method: "POST", Path: "/api/tasks/run", Body: `{"id":"{{ID11}}"}`, WaitDone: "{{ID11}}"}, + {Name: "lancer le script lent", Method: "POST", Path: "/api/tasks/run", Body: `{"id":"{{ID12}}"}`, WaitRunning: "{{ID12}}"}, + {Name: "liste pendant le script", Method: "GET", Path: "/api/tasks"}, + {Name: "arrêter le script", Method: "POST", Path: "/api/tasks/stop", Body: `{"id":"{{ID12}}"}`, WaitDone: "{{ID12}}"}, + {Name: "arrêter sans tâche script", Method: "POST", Path: "/api/tasks/stop", Body: `{}`}, + {Name: "le script qui réussissait échoue", Now: "2026-03-28T23:05:00Z", Files: map[string]string{"scripts/ok.sh": "#!/bin/sh\necho perdu\nexit 3\n"}, + Method: "POST", Path: "/api/tasks/run", Body: `{"id":"{{ID10}}"}`, WaitDone: "{{ID10}}"}, + {Name: "tâche script sans script", Now: "2026-03-28T23:01:00Z", PutTask: `{"id":"vide","name":"S vide","kind":"script","schedule":"@every 1h"}`, + Method: "POST", Path: "/api/tasks/run", Body: `{"id":"vide"}`, WaitDone: "vide"}, + {Name: "tâche script au nom refusé", PutTask: `{"id":"remonte","name":"S remonte","kind":"script","script":"../x.sh","schedule":"@every 1h"}`, + Method: "POST", Path: "/api/tasks/run", Body: `{"id":"remonte"}`, WaitDone: "remonte"}, + {Name: "tâche script au script disparu", PutTask: `{"id":"disparu","name":"S disparu","kind":"script","script":"parti.sh","schedule":"@every 1h"}`, + Method: "POST", Path: "/api/tasks/run", Body: `{"id":"disparu"}`, WaitDone: "disparu"}, + {Name: "puis réussit de nouveau", Now: "2026-03-28T23:10:00Z", Files: map[string]string{"scripts/ok.sh": "#!/bin/sh\necho bonjour\n"}, + Method: "POST", Path: "/api/tasks/run", Body: `{"id":"{{ID10}}"}`, WaitDone: "{{ID10}}"}, + + // Planificateur : les tâches IA (jamais exécutées sans modèle, elles + // bloqueraient la file) d'abord désactivées. + {Name: "tic : une tâche IA due attend le modèle", Now: "2026-03-28T16:00:00Z", Tick: true}, + } { + run(st) + } + for i := 1; i <= 9; i++ { + run(parityTasksStep{Name: "désactiver la tâche IA " + strconv.Itoa(i), Method: "POST", Path: "/api/tasks/toggle", + Body: `{"id":"{{ID` + strconv.Itoa(i) + `}}","on":false}`}) + } + for _, st := range []parityTasksStep{ + {Name: "tic pendant une optimisation : rien ne part", Now: "2026-03-29T00:30:00Z", Tune: true, Tick: true}, + {Name: "tic : la première tâche due tourne", Now: "2026-03-29T00:30:00Z", Tick: true}, + {Name: "tic : la suivante", Tick: true}, + {Name: "tic : rien de dû", Tick: true}, + {Name: "tic : de nouveau dû", Now: "2026-03-29T01:31:00Z", Tick: true}, + {Name: "tic : la suivante, encore", Tick: true}, + {Name: "tic : la tâche une fois tourne puis s'éteint", Tick: true}, + {Name: "tic : plus rien", Tick: true}, + {Name: "tâche sans prochain passage", Now: "2026-03-29T02:00:00Z", + PutTask: `{"id":"manuelle","name":"O manuelle","kind":"script","script":"ok.sh","schedule":"0 4 * * *","tz":"UTC","enabled":true}`, Tick: true}, + {Name: "tâche à la fréquence devenue invalide", PutTask: `{"id":"cassee","name":"P cassée","kind":"script","script":"ok.sh","schedule":"@every 10s","enabled":true}`, Tick: true}, + {Name: "même nom, ids dans l'ordre", PutTask: `{"id":"r2","name":"R double","kind":"script","script":"ok.sh","schedule":"@every 1h"}`}, + {Name: "même nom, second", PutTask: `{"id":"r1","name":"R double","kind":"script","script":"ok.sh","schedule":"@every 1h"}`}, + {Name: "liste finale", Method: "GET", Path: "/api/tasks"}, + } { + run(st) + } + + 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("../../rust/loki-rs/tests/parity_tasks", 0o755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(parityTasksFile, got, 0o644); err != nil { + t.Fatal(err) + } + return + } + want, err := os.ReadFile(parityTasksFile) + 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âches ne produisent plus le journal rangé dans %s.\n"+ + "Si c'est voulu : LOKI_PARITY_UPDATE=1 go test ./internal/loki -run TestPariteTaches,\n"+ + "puis cargo test --test tasks dit si le port Rust suit.\n--- obtenu ---\n%s", parityTasksFile, got) + } +} diff --git a/internal/loki/parity_tasks_tools_test.go b/internal/loki/parity_tasks_tools_test.go new file mode 100644 index 0000000..f53a6e2 --- /dev/null +++ b/internal/loki/parity_tasks_tools_test.go @@ -0,0 +1,246 @@ +//go:build linux + +package loki + +import ( + "bytes" + "context" + "encoding/json" + "os" + "path/filepath" + "strconv" + "strings" + "testing" + "time" +) + +// parity_tasks_tools_test.go — journal de référence des outils task_* de +// l'agent (lot 37 de la migration Rust) : chaque appel (arguments tels que le +// modèle les envoie) et son résultat, avec les tâches en base après coup. +// L'heure des tâches est fixée pas à pas ; le fuseau de l'utilisateur, le +// projet actif, les projets connus et les scripts du dossier protégé sont +// posés avant l'appel. Rangé dans +// rust/loki-rs/tests/parity_tasks/tools.json ; tests/tasks.rs le rejoue par +// le port Rust. +// +// LOKI_PARITY_UPDATE=1 go test ./internal/loki -run TestPariteOutilsTaches + +const parityTaskToolsFile = "../../rust/loki-rs/tests/parity_tasks/tools.json" + +type parityTaskToolStep struct { + Name string `json:"name"` + Now string `json:"now,omitempty"` // RFC 3339 : l'heure des tâches + State map[string]string `json:"state,omitempty"` + Files map[string]string `json:"files,omitempty"` + PutTask string `json:"put_task,omitempty"` // une tâche écrite telle quelle en base + Tool string `json:"tool"` + Args json.RawMessage `json:"args"` + Out string `json:"out"` + Tasks any `json:"tasks"` +} + +func TestPariteOutilsTaches(t *testing.T) { + home := testHome(t) + oldNow := tasksNow + var now time.Time + tasksNow = func() time.Time { return now } + t.Cleanup(func() { tasksNow = oldNow }) + activeProjMu.Lock() + activeProjCache = "" + activeProjMu.Unlock() + t.Cleanup(func() { + activeProjMu.Lock() + activeProjCache = "" + activeProjMu.Unlock() + }) + + // Les identifiants tirés au hasard deviennent {{ID1}}, {{ID2}}… dans + // l'ordre où ils apparaissent. + ids := map[string]string{} + var order []string + mask := func(s string) string { + return parityTaskIDRe.ReplaceAllStringFunc(s, func(id string) string { + if _, ok := ids[id]; !ok { + order = append(order, id) + ids[id] = "{{ID" + strconv.Itoa(len(order)) + "}}" + } + return ids[id] + }) + } + unmask := func(s string) string { + for id, m := range ids { + s = strings.ReplaceAll(s, m, id) + } + return s + } + snapshot := func() any { + b, _ := json.Marshal(listTasks()) + var v any + _ = json.Unmarshal([]byte(mask(string(b))), &v) + return v + } + + var steps []parityTaskToolStep + run := func(st parityTaskToolStep) { + if st.Now != "" { + n, err := time.Parse(time.RFC3339, st.Now) + if err != nil { + t.Fatal(err) + } + now = n + } + parityApplyFiles(t, home, st.Files) + for k, v := range st.State { + if err := putStr(bkState, k, v); err != nil { + t.Fatal(err) + } + if k == stActiveProjectKey { + activeProjMu.Lock() + activeProjCache = v + activeProjMu.Unlock() + } + } + if st.PutTask != "" { + raw := unmask(st.PutTask) + var tk Task + if err := json.Unmarshal([]byte(raw), &tk); err != nil { + t.Fatal(err) + } + if err := putBytes(bkTasks, tk.ID, []byte(raw)); err != nil { + t.Fatal(err) + } + mask(raw) + } + var args map[string]any + dec := json.NewDecoder(bytes.NewReader([]byte(unmask(string(st.Args))))) + if err := dec.Decode(&args); err != nil { + t.Fatalf("%s : arguments illisibles : %v", st.Name, err) + } + if args == nil { + args = map[string]any{} + } + res := execTool(context.Background(), st.Tool, args, toolCallLabel(st.Tool, args), Caps{Agent: true}) + // Le résultat tel que le modèle le reçoit : encodé en JSON (octets + // orphelins d'une coupe → U+FFFD). + b, _ := json.Marshal(res.Result) + var out string + _ = json.Unmarshal(b, &out) + st.Out = mask(out) + st.Tasks = snapshot() + steps = append(steps, st) + } + args := func(s string) json.RawMessage { return json.RawMessage(s) } + // Un compte-rendu dont la coupe à 300 octets tombe au milieu d'un « é ». + longReport := strings.Repeat("a", 299) + "é et la suite\n sur plusieurs lignes" + + run(parityTaskToolStep{Name: "liste vide", Now: "2026-03-28T12:00:00Z", + State: map[string]string{stUserTZKey: "Europe/Paris"}, + Tool: "task_list", Args: args(`{}`)}) + run(parityTaskToolStep{Name: "dans 30 minutes", Tool: "task_create", + Args: args(`{"name":"Rappel","prompt":"Bois de l'eau","in_minutes":30}`)}) + run(parityTaskToolStep{Name: "dans 1,9 minute : une seule", Tool: "task_create", + Args: args(`{"name":"Bref","prompt":"x","in_minutes":1.9}`)}) + run(parityTaskToolStep{Name: "à une heure passée : demain", Tool: "task_create", + Args: args(`{"name":"Matin","prompt":"Café","at":"08:15"}`)}) + run(parityTaskToolStep{Name: "à une heure à venir : aujourd'hui", Tool: "task_create", + Args: args(`{"name":"Soir","prompt":"Bilan","at":" 21:05 "}`)}) + run(parityTaskToolStep{Name: "à une date, dans le trou du changement d'heure", Tool: "task_create", + Args: args(`{"name":"Nuit","prompt":"x","at":"2026-03-29 02:30"}`)}) + run(parityTaskToolStep{Name: "à une date passée", Tool: "task_create", + Args: args(`{"name":"Hier","prompt":"x","at":"2026-03-27 10:00"}`)}) + run(parityTaskToolStep{Name: "at illisible", Tool: "task_create", + Args: args(`{"name":"X","prompt":"x","at":"25:00"}`)}) + run(parityTaskToolStep{Name: "at : date impossible", Tool: "task_create", + Args: args(`{"name":"X","prompt":"x","at":"2026-02-30 10:00"}`)}) + run(parityTaskToolStep{Name: "intervalle", Tool: "task_create", + Args: args(`{"name":"Veille","prompt":"Regarde les nouvelles","schedule":"@every 2h"}`)}) + run(parityTaskToolStep{Name: "cron, désactivée", Tool: "task_create", + Args: args(`{"name":"Semaine","prompt":"Point","schedule":"0 9 * * 1-5","enabled":false}`)}) + run(parityTaskToolStep{Name: "quotidienne ancrée", Tool: "task_create", + Args: args(`{"name":"Journal","prompt":"Écris","schedule":"@every 1d@09:00","in_minutes":5}`)}) + run(parityTaskToolStep{Name: "fréquence invalide", Tool: "task_create", + Args: args(`{"name":"X","prompt":"x","schedule":"@every 30s"}`)}) + run(parityTaskToolStep{Name: "sans nom", Tool: "task_create", + Args: args(`{"prompt":"x","in_minutes":5}`)}) + run(parityTaskToolStep{Name: "nom d'un autre type", Tool: "task_create", + Args: args(`{"name":42,"prompt":"x","in_minutes":5}`)}) + run(parityTaskToolStep{Name: "moins d'une minute : pas de fréquence", Tool: "task_create", + Args: args(`{"name":"X","prompt":"x","in_minutes":0.5}`)}) + run(parityTaskToolStep{Name: "ni prompt ni script", Tool: "task_create", + Args: args(`{"name":"X","in_minutes":5}`)}) + run(parityTaskToolStep{Name: "script absent", Tool: "task_create", + Args: args(`{"name":"Ménage","script":"menage.sh","schedule":"@every 1d"}`)}) + run(parityTaskToolStep{Name: "script hostile", Tool: "task_create", + Args: args(`{"name":"Ménage","script":"a;b.sh","schedule":"@every 1d"}`)}) + run(parityTaskToolStep{Name: "script dossier", Files: map[string]string{"scripts/outils/x": "#!/bin/sh\n"}, + Tool: "task_create", Args: args(`{"name":"Ménage","script":"outils","schedule":"@every 1d"}`)}) + run(parityTaskToolStep{Name: "script présent", Files: map[string]string{"scripts/menage.sh": "#!/bin/sh\necho ok\n"}, + Tool: "task_create", Args: args(`{"name":"Ménage","script":" menage.sh ","prompt":"ignoré ?","schedule":"@every 1d","enabled":"false"}`)}) + + // Les projets : une tâche d'un autre projet est invisible ; un projet + // disparu vaut le projet par défaut. + projects := `[{"slug":"generale","name":"Générale","created_at":1},{"slug":"autre","name":"Autre","created_at":2}]` + run(parityTaskToolStep{Name: "tâche d'un autre projet", State: map[string]string{stProjectsKey: projects}, + PutTask: `{"id":"aaaaaaaaaaaa","name":"Autre","prompt":"x","schedule":"@every 1h","tz":"Europe/Paris","preset":"","project":"autre","no_mem":false,"no_web":false,"enabled":true,"last_run":0,"next_run":1774699200000,"last_dur_ms":0,"last_ok":false,"last_error":"","last_report":""}`, + Tool: "task_list", Args: args(`{"id":"aaaaaaaaaaaa"}`)}) + run(parityTaskToolStep{Name: "tâche d'un projet disparu", + PutTask: `{"id":"bbbbbbbbbbbb","name":"Antique (fantôme)","prompt":"x","schedule":"@every 1h","tz":"America/New_York","preset":"","project":"fantome","no_mem":false,"no_web":false,"enabled":true,"last_run":1774692000000,"next_run":1774699200000,"last_dur_ms":1200,"last_ok":false,"last_error":"moteur occupé","last_report":""}`, + Tool: "task_list", Args: args(`{"id":"bbbbbbbbbbbb"}`)}) + rb, _ := json.Marshal(longReport) + report := string(rb) + run(parityTaskToolStep{Name: "compte-rendu long", + PutTask: `{"id":"cccccccccccc","name":"Rapport","prompt":"x","schedule":"@every 1d","tz":"Europe/Paris","preset":"","no_mem":false,"no_web":false,"enabled":false,"last_run":1774692000000,"next_run":1774699200000,"last_dur_ms":5000,"last_ok":true,"last_error":"","last_report":` + report + `}`, + Tool: "task_list", Args: args(`{}`)}) + run(parityTaskToolStep{Name: "compte-rendu complet", Tool: "task_list", Args: args(`{"id":" cccccccccccc "}`)}) + run(parityTaskToolStep{Name: "liste d'un autre projet", State: map[string]string{stActiveProjectKey: "autre"}, + Tool: "task_list", Args: args(`{}`)}) + run(parityTaskToolStep{Name: "création dans le projet actif", Tool: "task_create", + Args: args(`{"name":"Ici","prompt":"x","schedule":"@every 3h"}`)}) + run(parityTaskToolStep{Name: "retour au projet par défaut", State: map[string]string{stActiveProjectKey: "generale"}, + Tool: "task_update", Args: args(`{"id":"aaaaaaaaaaaa","name":"Volée"}`)}) + + // Les modifications. + run(parityTaskToolStep{Name: "modifier sans id", Now: "2026-03-28T15:00:00Z", Tool: "task_update", Args: args(`{"name":"x"}`)}) + run(parityTaskToolStep{Name: "modifier un id inconnu", Tool: "task_update", Args: args(`{"id":"ffffffffffff"}`)}) + run(parityTaskToolStep{Name: "renommer, nouvelle fréquence", Tool: "task_update", + Args: args(`{"id":"{{ID1}}","name":" Eau ","prompt":" ","schedule":" @every 45m "}`)}) + run(parityTaskToolStep{Name: "mettre en pause", Tool: "task_update", Args: args(`{"id":"{{ID1}}","enabled":false}`)}) + run(parityTaskToolStep{Name: "fréquence refusée", Tool: "task_update", Args: args(`{"id":"{{ID1}}","schedule":"jamais"}`)}) + run(parityTaskToolStep{Name: "reprendre une tâche sans prochaine exécution", + PutTask: `{"id":"dddddddddddd","name":"Dormante","prompt":"x","schedule":"0 8 * * *","tz":"Europe/Paris","preset":"","no_mem":false,"no_web":false,"enabled":false,"last_run":0,"next_run":0,"last_dur_ms":0,"last_ok":false,"last_error":"","last_report":""}`, + Tool: "task_update", Args: args(`{"id":"dddddddddddd","enabled":true,"prompt":"Réveil"}`)}) + run(parityTaskToolStep{Name: "liste après modifications", Tool: "task_list", Args: args(`{}`)}) + + // Les suppressions. + run(parityTaskToolStep{Name: "supprimer sans id", Tool: "task_delete", Args: args(`{"id":" "}`)}) + run(parityTaskToolStep{Name: "supprimer d'un autre projet", Tool: "task_delete", Args: args(`{"id":"aaaaaaaaaaaa"}`)}) + run(parityTaskToolStep{Name: "supprimer", Tool: "task_delete", Args: args(`{"id":"{{ID1}}"}`)}) + run(parityTaskToolStep{Name: "supprimer deux fois", Tool: "task_delete", Args: args(`{"id":"{{ID1}}"}`)}) + + // Sans fuseau du navigateur : celui de la première tâche qui en a un. + run(parityTaskToolStep{Name: "fuseau d'une tâche existante", State: map[string]string{stUserTZKey: ""}, + Tool: "task_create", Args: args(`{"name":"Ailleurs","prompt":"x","at":"23:30"}`)}) + + got, err := json.MarshalIndent(steps, "", " ") + if err != nil { + t.Fatal(err) + } + got = append(got, '\n') + if os.Getenv("LOKI_PARITY_UPDATE") == "1" { + if err := os.MkdirAll(filepath.Dir(parityTaskToolsFile), 0o755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(parityTaskToolsFile, got, 0o644); err != nil { + t.Fatal(err) + } + return + } + want, err := os.ReadFile(parityTaskToolsFile) + 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 task_* ne produisent plus le journal rangé dans %s.\n"+ + "LOKI_PARITY_UPDATE=1 pour le régénérer si le changement est voulu.", parityTaskToolsFile) + } +} 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/internal/loki/parity_tune_test.go b/internal/loki/parity_tune_test.go new file mode 100644 index 0000000..115a09b --- /dev/null +++ b/internal/loki/parity_tune_test.go @@ -0,0 +1,544 @@ +package loki + +import ( + "encoding/json" + "os" + "sort" + "strings" + "testing" +) + +// parity_tune_test.go — journal de référence de la partie PURE de +// l'optimiseur (lot 28 de la migration Rust) : découpe et réécriture +// d'EXTRA_ARGS, réglages par clé ou par drapeau, essais de chaque étape selon +// la machine et les cases cochées, garde-fous (cœur de la ligne, dénature, +// clés permises), diff et réécriture du preset, journal d'un essai, score et +// départage. Rangé dans rust/loki-rs/tests/parity_tune/core.json ; +// tests/tune_core.rs le rejoue. +// +// LOKI_PARITY_UPDATE=1 go test ./internal/loki -run TestPariteTuneCore + +const parityTuneCoreFile = "../../rust/loki-rs/tests/parity_tune/core.json" + +func parityTuneCands(c []tuneCand) []map[string]any { + out := []map[string]any{} + for _, x := range c { + out = append(out, map[string]any{"stage": x.Stage, "label": x.Label, "cfg": x.Cfg}) + } + return out +} + +func TestPariteTuneCore(t *testing.T) { + journal := map[string]any{} + + // --- EXTRA_ARGS --------------------------------------------------------- + extras := []string{ + "", " ", "-b 2048", `--chat-template-file "/mes modèles/tpl.jinja" -ub 512`, + `-ot 'blk\.(1[0-9])\.ffn_.*_exps\.=CPU' --n-cpu-moe 40`, + `-ot "blk\.([0-9]+)\.ffn_.*_exps\.=CPU,per_layer_token_embd=CPU" --cache-type-k q8_0 --n-cpu-moe 40 ` + + `--flash-attn on -ub 512 --chat-template-file "/a b/t.jinja" -ts 0.9,0.1 -ctv q8_0 -cmoe`, + "-ub=1024 -t 8 -b 4096", " -ts 0.9,0.1 --flash-attn on", "-ts=1,1 -mg 0", + "-ot exps=CPU,per_layer_token_embd=CPU -cmoe --cpu-moe -ncmoe=12", + "-ot=per_layer_token_embd=CPU", "--override-tensor exps=CPU", "-ot", "-t", "-t 8 -t 6", "-ub", + `"" -x`, `""x y`, `a""b`, `'a b'c "d"`, "--cache-type-k q8_0\t--flash-attn on\n-t 8", + `"non fermé -b 4`, `x"y z"w -ub=1024`, "-bs", "--backend-sampling -t 4", "-b 512\t-ub 256", + "-ncmoe", "--n-cpu-moe 0 --cpu-moe", + } + var ex []map[string]any + for _, s := range extras { + var spans [][]any + for _, sp := range argSpans(s) { + spans = append(spans, []any{sp.start, sp.end, sp.val}) + } + dropped, removed := tuneExtraDropExperts(s) + ex = append(ex, map[string]any{ + "in": s, + "spans": spans, + "split": splitArgs(s), + "set_ub": tuneExtraSet(s, knobUBatch.flags, "2048"), + "drop_ub": tuneExtraSet(s, knobUBatch.flags, ""), + "set_t": tuneExtraSet(s, knobThreads.flags, "7"), + "set_t_space": tuneExtraSet(s, knobThreads.flags, "a b"), + "set_b": tuneExtraSet(s, knobBatch.flags, "4096"), + "drop_ts": tuneExtraDrop(s, 1, "-ts", "--tensor-split"), + "drop_cmoe": tuneExtraDrop(s, 0, "-cmoe", "--cpu-moe"), + "add_bs": tuneExtraAddSwitch(s, "--backend-sampling"), + "drop_experts": dropped, + "removed": removed, + "core": tuneCoreArgs(splitArgs(s), false), + "core_optin": tuneCoreArgs(splitArgs(s), true), + }) + } + journal["extra"] = ex + + // --- réglages ------------------------------------------------------------ + cfgs := []map[string]string{ + {}, + {"UBATCH": "1024", "BATCH": "4096", "THREADS": "0", "FIT_TARGET": "1536", "SPEC_N_MAX": "2"}, + {"EXTRA_ARGS": "-ub 512 -t 8", "UBATCH": "1024"}, + {"EXTRA_ARGS": "-ub=256 --threads 6 -b 1024 -fitt 2048 --spec-draft-n-max 5"}, + {"EXTRA_ARGS": "-ub 512", "UBATCH": " 128 "}, + {"EXTRA_ARGS": "-t"}, + tuneRichCfg(), + } + knobs := map[string]tuneKnob{"BATCH": knobBatch, "UBATCH": knobUBatch, "THREADS": knobThreads, + "FIT_TARGET": knobFitTarget, "SPEC_N_MAX": knobSpecN} + var kn []map[string]any + for _, c := range cfgs { + get, set, unset := map[string]string{}, map[string]any{}, map[string]any{} + for name, k := range knobs { + get[name] = k.get(c) + set[name] = k.set(c, "99") + unset[name] = k.set(c, "") + } + kn = append(kn, map[string]any{"cfg": c, "get": get, "set": set, "unset": unset}) + } + journal["knobs"] = kn + + // --- essais --------------------------------------------------------------- + type envCase struct { + name string + env tuneEnv + } + rich := tuneRichEnv() + envs := []envCase{ + {"riche", rich}, + {"sans placement", func() tuneEnv { e := tuneRichEnv(); e.Placement = false; return e }()}, + {"sans --fit", func() tuneEnv { + e := tuneRichEnv() + e.Help = strings.ReplaceAll(tuneTestHelp, "--fit", "--nofit") + return e + }()}, + {"sans --fit-target", func() tuneEnv { + e := tuneRichEnv() + e.Help = strings.ReplaceAll(tuneTestHelp, "--fit-target", "--fit-x") + return e + }()}, + {"sans nvidia-smi", func() tuneEnv { e := tuneRichEnv(); e.SMI = false; return e }()}, + {"sans opt-in", func() tuneEnv { e := tuneRichEnv(); e.OptIn = false; return e }()}, + {"une carte", func() tuneEnv { e := tuneRichEnv(); e.GPUs = 1; return e }()}, + {"trois cartes", func() tuneEnv { e := tuneRichEnv(); e.GPUs = 3; return e }()}, + {"cœurs inconnus", func() tuneEnv { e := tuneRichEnv(); e.PhysCores = 0; return e }()}, + {"deux cœurs", func() tuneEnv { e := tuneRichEnv(); e.PhysCores = 2; return e }()}, + {"environnement posé", func() tuneEnv { + e := tuneRichEnv() + e.UserEnv = map[string]string{"GGML_OP_OFFLOAD_MIN_BATCH": "64", "GGML_CUDA_GRAPH_OPT": "1"} + e.QueuesEnv = true + e.ArgEnv = map[string]string{"LLAMA_ARG_BACKEND_SAMPLING": "1"} + return e + }()}, + {"dense deux cartes", tuneEnv{Help: tuneTestHelp, GPUs: 2, VRAMMiB: 28 << 10, ModelBytes: 10 << 30, SMI: true, + PhysCores: 8, Placement: true, OptIn: true}}, + {"modèle plus gros que la VRAM", tuneEnv{Help: tuneTestHelp, GPUs: 1, VRAMMiB: 8 << 10, ModelBytes: 20 << 30, SMI: true, + PhysCores: 8}}, + {"moteur ancien", tuneEnv{GPUs: 1, SMI: true, PhysCores: 4, Placement: true, OptIn: true}}, + } + candCfgs := []struct { + name string + cfg map[string]string + }{ + {"riche", tuneRichCfg()}, + {"simple", map[string]string{"MODEL": "m.gguf", "CTX": "32768"}}, + {"contexte non chiffré", map[string]string{"MODEL": "m.gguf", "EXTRA_ARGS": "-ot exps=CPU"}}, + {"split tensor", map[string]string{"MODEL": "m.gguf", "CTX": "32768", "SPLIT_MODE": "tensor", "EXTRA_ARGS": "-ts 1,1"}}, + {"lots posés", map[string]string{"MODEL": "m.gguf", "CTX": "8192", "UBATCH": "1024", "BATCH": "2048", + "OP_OFFLOAD_MIN_BATCH": "128", "CUDA_LAUNCH_QUEUES": "4X", "THREADS": "7", "FIT_TARGET": "1536"}}, + {"spéculation", map[string]string{"MODEL": "m.gguf", "CTX": "8192", "SPEC": "draft", "SPEC_N_MAX": "4", + "CUDA_GRAPH_OPT": "On", "EXTRA_ARGS": "--n-cpu-moe 10 -bs"}}, + {"ngram", map[string]string{"MODEL": "m.gguf", "CTX": "8192", "SPEC": "ngram"}}, + {"cpu-moe", map[string]string{"MODEL": "m.gguf", "CTX": "16384", "EXTRA_ARGS": "-cmoe -t 0"}}, + } + var cs []map[string]any + for _, cc := range candCfgs { + for _, ec := range envs { + stages := map[string]any{} + for _, st := range tuneStages { + c, why := st.Gen(cc.cfg, ec.env) + stages[st.Name] = map[string]any{"why": why, "cands": parityTuneCands(c)} + } + e := ec.env + cs = append(cs, map[string]any{"cfg_name": cc.name, "env_name": ec.name, "cfg": cc.cfg, + "env": map[string]any{"help": e.Help, "gpus": e.GPUs, "vram_mib": e.VRAMMiB, "model_bytes": e.ModelBytes, + "moe": e.MoE, "phys_cores": e.PhysCores, "smi": e.SMI, "arg_env": e.ArgEnv, "user_env": e.UserEnv, + "queues_env": e.QueuesEnv, "placement": e.Placement, "opt_in": e.OptIn}, + "weights_on_cpu": tuneWeightsOnCPU(cc.cfg, ec.env), + "stages": stages}) + } + } + journal["cands"] = cs + var sw []map[string]any + for _, name := range []string{"placement", "marges", "lots", "délestage", "threads", "files CUDA", "opt-in"} { + for _, only := range [][]string{nil, {"lots"}, {"Délestage"}, {"files"}, {"FILES CUDA"}, {"marges-x"}, {" threads "}, {"opt"}, {"x"}} { + sw = append(sw, map[string]any{"name": name, "only": only, "wanted": tuneStageWanted(name, only)}) + } + } + journal["stage_wanted"] = sw + + // --- garde-fous ------------------------------------------------------------ + base := tuneLaunch{Args: []string{"/bin/llama-server", "-m", "/m.gguf", "-c", "65536", "-b", "2048", "-ub", "512", + "--host", "127.0.0.1", "--port", "41000", "--parallel", "1", "-ctk", "q8_0", "-ot", "exps=CPU,per_layer_token_embd=CPU", + "--cache-ram", "8192", "--flash-attn", "on"}, + Env: map[string]string{"CUDA_VISIBLE_DEVICES": "1,0", "CUDA_SCALE_LAUNCH_QUEUES": "4x"}} + ok := tuneLaunch{Args: []string{"/bin/llama-server", "-m", "/m.gguf", "-c", "65536", "-b", "4096", "-ub", "2048", + "--host", "127.0.0.1", "--port", "41001", "--parallel", "1", "-ctk", "q8_0", "-ot", "per_layer_token_embd=CPU", + "--cache-ram", "12000", "--flash-attn", "on", "-t", "7"}, + Env: map[string]string{"CUDA_VISIBLE_DEVICES": "1,0", "GGML_OP_OFFLOAD_MIN_BATCH": "128"}} + clone := func(l tuneLaunch) tuneLaunch { + c := tuneLaunch{Args: append([]string(nil), l.Args...), Env: map[string]string{}} + for k, v := range l.Env { + c.Env[k] = v + } + return c + } + var dn []map[string]any + add := func(name string, b, c tuneLaunch) { + dn = append(dn, map[string]any{"name": name, "base": b, "cand": c, + "denature": tuneDenature(b, c, false), "denature_optin": tuneDenature(b, c, true), + "same_key": tuneLaunchKey(b) == tuneLaunchKey(c)}) + } + add("réglages permis", base, ok) + add("identique", base, clone(base)) + muts := map[string]func(l *tuneLaunch){ + "contexte": func(l *tuneLaunch) { l.Args[4] = "32768" }, + "cache kv": func(l *tuneLaunch) { l.Args[16] = "f16" }, + "ot": func(l *tuneLaunch) { l.Args[18] = "exps=CPU" }, + "fa": func(l *tuneLaunch) { l.Args[len(l.Args)-3] = "off" }, + "slots": func(l *tuneLaunch) { l.Args[14] = "2" }, + "cuda": func(l *tuneLaunch) { l.Env["CUDA_VISIBLE_DEVICES"] = "0,1" }, + "graphes": func(l *tuneLaunch) { l.Env["GGML_CUDA_GRAPH_OPT"] = "1" }, + "spéculation": func(l *tuneLaunch) { l.Args = append(l.Args, "--spec-draft-n-max", "4") }, + "modèle ébauche": func(l *tuneLaunch) { + l.Args = append(l.Args, "-md", "/d.gguf") + }, + "argument de plus": func(l *tuneLaunch) { l.Args = append(l.Args, "--jinja") }, + "argument de moins": func(l *tuneLaunch) { + l.Args = l.Args[:len(l.Args)-2] + }, + "port et clé": func(l *tuneLaunch) { + l.Args = append(l.Args, "--api-key", "s3cret", "--slot-save-path", "/tmp/x", "--port=9") + }, + "cache-ram": func(l *tuneLaunch) { l.Args = append(l.Args, "-cram", "4096") }, + } + names := make([]string, 0, len(muts)) + for n := range muts { + names = append(names, n) + } + sort.Strings(names) + for _, name := range names { + c := clone(ok) + muts[name](&c) + add(name, base, c) + } + journal["denature"] = dn + + rc := tuneRichCfg() + var cc []map[string]any + check := func(name string, b, c map[string]string) { + e1, e2 := tuneCfgCheck(b, c, false), tuneCfgCheck(b, c, true) + s1, s2 := "", "" + if e1 != nil { + s1 = e1.Error() + } + if e2 != nil { + s2 = e2.Error() + } + cc = append(cc, map[string]any{"name": name, "base": b, "cand": c, "check": s1, "check_optin": s2, + "diff": tuneDiff(b, c)}) + } + check("identique", rc, copyCfg(rc)) + check("lots", rc, setKey(setKey(rc, "UBATCH", "2048"), "BATCH", "4096")) + check("contexte", rc, setKey(rc, "CTX", "32768")) + check("spec", rc, setKey(rc, "SPEC", "auto")) + check("graphes", rc, setKey(rc, "CUDA_GRAPH_OPT", "on")) + check("retrait", rc, setKey(rc, "TEMP", "")) + check("extra permis", rc, knobThreads.set(rc, "4")) + check("extra interdit", rc, setKey(rc, "EXTRA_ARGS", strings.Replace(rc["EXTRA_ARGS"], "q8_0", "f16", 1))) + check("extra placement", rc, setKey(rc, "EXTRA_ARGS", func() string { s, _ := tuneExtraDropExperts(rc["EXTRA_ARGS"]); return s }())) + check("spéculation dans extra", rc, setKey(rc, "EXTRA_ARGS", rc["EXTRA_ARGS"]+" --spec-draft-n-max 4")) + check("extra vide", map[string]string{"EXTRA_ARGS": "-t 8"}, map[string]string{}) + journal["cfg_check"] = cc + + content := "# NAME=Gros MoE\n# commentaire\nMODEL=big.gguf\nCTX=65536\nUBATCH=512\n" + + `EXTRA_ARGS="--cache-type-k q8_0 --flash-attn on -t 8"` + "\n" + pb := parseEnv(content) + var pp []map[string]any + patch := func(name string, win map[string]string) { + out, err := tunePatchPreset(content, pb, win) + e := "" + if err != nil { + e = err.Error() + } + pp = append(pp, map[string]any{"name": name, "win": win, "out": out, "error": e}) + } + patch("threads retirés, lots", setKey(setKey(knobThreads.set(pb, ""), "UBATCH", "2048"), "BATCH", "4096")) + patch("contexte", setKey(pb, "CTX", "32768")) + patch("rien", copyCfg(pb)) + patch("opt-in", setKey(pb, "SPEC", "auto")) + patch("retrait d'EXTRA_ARGS", setKey(pb, "EXTRA_ARGS", "")) + journal["patch"] = struct { + Content string `json:"content"` + Cases []map[string]any `json:"cases"` + }{content, pp} + + // --- journal d'un essai ------------------------------------------------------ + logs := []string{ + "load_tensors: offloaded 40/49 layers to GPU\n" + + "llama_kv_cache: size = 1024.00 MiB ( 8192 cells, 32 layers, 1/1 seqs), K (q8_0): 512.00 MiB, V (q8_0): 512.00 MiB\n" + + "load_tensors: offloaded 49/49 layers to GPU\n", + "rien", + "", + "K (F16): 1 MiB, V (f16): 1 MiB\nK (q4_0): 1, V (q8_0): 2\nK (f16): x V (f16): y", + "ggml_backend_cuda_buffer_type_alloc_buffer: cudaMalloc failed: out of memory", + "common_fit: failed to fit model\n", + "llama_model_load: error loading model: bad magic\n", + "ligne 1\nderniere ligne de journal \n\n", + "\n \n", + "x\n" + strings.Repeat("é", 100), + "x\n" + strings.Repeat("a", 159) + "é" + strings.Repeat("b", 20), + } + var lf []map[string]any + for _, l := range logs { + off, total, kv := tuneLogFacts(l) + lf = append(lf, map[string]any{"log": l, "offloaded": off, "total": total, "kv": kv, "failure": tuneLoadFailure(l)}) + } + journal["logs"] = lf + + // --- score et départage ------------------------------------------------------ + intp := func(n int) *int { return &n } + var recSets []map[string]any + weights := func(name string, recs []perfRec) { + recSets = append(recSets, map[string]any{"name": name, "recs": recs, "weights": tuneWeightsFrom(recs)}) + } + weights("vide", nil) + var few []perfRec + for i := 0; i < 19; i++ { + few = append(few, perfRec{Kind: perfMain, Complete: true, New: 1000 + i, Gen: 300}) + } + weights("trop peu", few) + var many []perfRec + for i := 0; i < 30; i++ { + r := perfRec{Kind: perfMain, Complete: true, New: 500 + 37*i, Gen: 100 + 13*i, Total: 4000 + 100*i} + if i%3 == 0 { + r.Cached = intp(100) + } else { + r.Cached = intp(r.Total - 200) + } + many = append(many, r) + } + many = append(many, perfRec{Kind: perfBench, Complete: true, New: 99999, Gen: 99999}, + perfRec{Kind: perfMain, Complete: false, New: 99999, Gen: 99999}) + weights("télémétrie", many) + var bounds []perfRec + for i := 0; i < 21; i++ { + bounds = append(bounds, perfRec{Kind: perfTask, Complete: true, New: 50, Gen: 9000, Total: 100, Cached: intp(0)}) + } + weights("bornes", bounds) + var even []perfRec + for i := 0; i < 20; i++ { + even = append(even, perfRec{Kind: perfMain, Complete: true, New: 1000 + 10*i, Gen: 200 + i, Total: 2048, Cached: intp(204)}) + } + weights("pair, froid connu", even) + journal["weights"] = recSets + + w := tuneWeights{K: 2000, G: 600, FCold: 0.05} + deep := func() *benchResult { + return &benchResult{PromptPerSecond: 1000, PredictedPerSec: 50, Depth: &benchDepth{Target: 32768, + Cold: &benchTurn{PromptPerSecond: 800}, + DecodePerSec: 40, CachedPerSec: 999, + Turns: []benchTurn{{New: 2100, Cached: 30000, Expected: 30048, PromptMs: 2000}, + {New: 2100, Cached: 32300, Expected: 32348, PromptMs: 2000}}}} + } + var runs []map[string]any + score := func(name string, r *benchResult) { + run, err := tuneRunFrom(r, w) + e := "" + if err != nil { + e = err.Error() + } + runs = append(runs, map[string]any{"name": name, "bench": r, "run": run, "error": e}) + } + score("complet", deep()) + score("vide", nil) + score("sans decode", &benchResult{PromptPerSecond: 1000}) + score("rapide", &benchResult{PromptPerSecond: 1000, PredictedPerSec: 50}) + score("profondeur sautée", &benchResult{PromptPerSecond: 1000, PredictedPerSec: 50, Depth: &benchDepth{Skipped: "petit"}}) + score("partiel", func() *benchResult { r := deep(); r.Depth.Partial = "contexte plein"; return r }()) + score("sans froid", func() *benchResult { r := deep(); r.Depth.Cold = nil; return r }()) + score("froid nul", func() *benchResult { r := deep(); r.Depth.Cold.PromptPerSecond = 0; return r }()) + score("sans tours", func() *benchResult { r := deep(); r.Depth.Turns = nil; return r }()) + score("sans decode à D", func() *benchResult { r := deep(); r.Depth.DecodePerSec = 0; return r }()) + score("cache inconnu", func() *benchResult { r := deep(); r.Depth.Turns[1].Cached = -1; return r }()) + score("cache inconnu, rien d'autre", func() *benchResult { + r := deep() + r.Depth.Turns[1].Cached = -1 + r.Depth.CachedPerSec = 0 + return r + }()) + score("rien de neuf", func() *benchResult { + r := deep() + for i := range r.Depth.Turns { + r.Depth.Turns[i].New = 48 + } + return r + }()) + journal["runs"] = runs + + var wn []map[string]any + for _, c := range [][2][]float64{ + {{20, 20.4}, {19.7, 19.8}}, {{20, 20.4}, {18}}, {{20, 20.4}, {18, 18.1}}, {{18, 22}, {18.5, 18.6}}, + {{}, {1, 2}}, {{10}, {}}, {{10, 10}, {9.6, 9.6}}, {{10, 10}, {9.7, 9.7}}, {{0}, {1, 1}}, {{10, 12}, {8, 8.2, 8.1}}, + } { + gain, need, ok := tuneWins(c[0], c[1]) + wn = append(wn, map[string]any{"base": c[0], "cand": c[1], "gain": gain, "need": need, "wins": ok, + "spread_base": tuneSpread(c[0]), "mean_cand": tuneMean(c[1])}) + } + journal["wins"] = wn + var wr []map[string]any + for _, c := range []struct { + base []float64 + first float64 + }{{[]float64{20, 20.4}, 19}, {[]float64{20, 20.4}, 19.8}, {[]float64{20}, 19.39}, {[]float64{20}, 19.41}, {nil, 1}} { + wr = append(wr, map[string]any{"base": c.base, "first": c.first, "worth": tuneWorthRepeat(c.base, c.first)}) + } + journal["worth_repeat"] = wr + + var hr []map[string]any + for _, c := range []struct { + gpus []gpuStat + visible []int + }{ + {nil, nil}, + {[]gpuStat{{Total: 24576, Used: 20000}, {Total: 12288, Used: 1000}}, nil}, + {[]gpuStat{{Total: 24576, Used: 20000}, {Total: 12288, Used: 1000}}, []int{1}}, + {[]gpuStat{{Total: 0, Used: 0}, {Total: 8192, Used: 9000}}, nil}, + {[]gpuStat{{Total: 8192, Used: 100}}, []int{3}}, + } { + free, ok := tuneHeadroom(c.gpus, c.visible) + var g [][2]int + for _, x := range c.gpus { + g = append(g, [2]int{x.Total, x.Used}) + } + hr = append(hr, map[string]any{"gpus": g, "visible": c.visible, "free": free, "ok": ok}) + } + journal["headroom"] = hr + var vg []map[string]any + for _, s := range []string{"", "0", "1,0", " 2 , 3 ", "GPU-abc", "0,,1", "+1,-2"} { + vg = append(vg, map[string]any{"in": s, "out": tuneVisibleGPUs(map[string]string{"CUDA_VISIBLE_DEVICES": s})}) + } + journal["visible"] = vg + var fa []map[string]any + for _, a := range [][]string{ + {"/b", "--host", "0.0.0.0", "--port", "8080", "-m", "x"}, + {"/b", "--host=::", "--port=1", "--port", "2", "--host"}, + {"/b", "--hostname", "x", "-p", "3"}, + } { + fa = append(fa, map[string]any{"in": a, "out": tuneForceAddr(a, "127.0.0.1", "41234")}) + } + journal["force_addr"] = fa + + // --- configuration d'un essai -------------------------------------------------- + var tc []map[string]any + for _, c := range []map[string]string{ + {"MODEL": "m.gguf", "EXTRA_ARGS": `-ot "a b" -t 8`, "PORT": "41000"}, + {"MODEL": "x\ny"}, + {"NAME": "é ü", "A": " b "}, + {"A": `"q"`, "B": "c'd", "C": "#h"}, + } { + dir := t.TempDir() + err := writeTuneTrialConfig(dir, c) + e, text := "", "" + if err != nil { + e = err.Error() + } else { + b, _ := os.ReadFile(dir + "/config.env") + text = string(b) + } + tc = append(tc, map[string]any{"cfg": c, "error": e, "text": text}) + } + journal["trial_config"] = tc + + // --- Verrou : contenu du fichier, clé de prise, binaires ----------------- + owners := []tuneOwner{ + {Owner: tuneProc{PID: 42}, Via: "web", Phase: "essais", Since: 1700000000}, + {Owner: tuneProc{PID: 42, Start: "b0c1/123", Exes: []string{"/usr/bin/loki-rs"}}, Via: "apply", + Phase: "application", Since: 1700000000, Nonce: 1700000000123456789, + Trial: &tuneProc{PID: 43, Start: "b0c1/456", Exes: []string{"/usr/bin/loki", "/opt/llama-server"}}, + MainWasActive: true, Backup: "/home/l/tune/backup/Dense-1.env", PresetID: "Dense", PresetName: "Dense"}, + } + lk := []map[string]any{} + for _, o := range owners { + b, _ := json.Marshal(o) + lk = append(lk, map[string]any{"json": string(b), "key": tuneHeldKey(o)}) + } + journal["lock_owners"] = lk + se := []map[string]any{} + for _, e := range [][2]string{ + {"/usr/local/bin/loki", "/usr/local/bin/loki (deleted)"}, {"/usr/local/bin/loki", "/usr/bin/llama-server"}, + {`C:\Loki\loki.exe`, "LOKI"}, {"", "loki"}, {"/a/llama-server", "llama-server"}, {"/a/b/", "b"}, + } { + se = append(se, map[string]any{"a": e[0], "b": e[1], "same": sameExe(e[0], e[1])}) + } + journal["same_exe"] = se + + // --- Résultat tel qu'enregistré et montré -------------------------------- + run := tuneRun{TurnSec: 3.25, ColdPP: 812.5, CachedPP: 1500, TG: 42.75, ProsePP: 2000, ProseTG: 45.5, Depth: 8192} + results := []tuneResult{{}, { + PresetID: "Dense", PresetName: "Dense", BaseFP: "abc", PresetFP: "def", Engine: "b7000-x", EngineBuild: 7000, + Weights: tuneWeights{K: 2000, G: 600, FCold: 0.05, From: "défaut"}, Depth: 8192, NCtx: 16384, Slots: 1, + Baseline: tuneTrial{Stage: "référence", Label: "configuration actuelle", Status: "mesuré", + Runs: []tuneRun{run, {TurnSec: 3.5, ProsePP: 1900, ProseTG: 44}}, TurnSec: 3.375, Headroom: -1, Sec: 61.5, + Offloaded: "33/33"}, + Trials: []tuneTrial{ + {Stage: "lots", Label: "UBATCH=512 BATCH=1024", Status: "retenu", Why: "−7.3 % par tour (seuil 3.0 %)", + Runs: []tuneRun{run}, TurnSec: 3.125, Gain: 0.0734, Headroom: 512, Changes: []string{"UBATCH 2048 → 512"}, Sec: 30}, + {Stage: "lots", Label: "UBATCH=2048 BATCH=4096", Status: "écarté", Why: "dénature : x", Headroom: -1}, + }, + Best: "UBATCH=512 BATCH=1024", Gain: 0.0734, Set: map[string]string{"UBATCH": "512", "BATCH": "1024"}, + Old: map[string]string{"UBATCH": "2048", "BATCH": ""}, Changes: []string{"UBATCH 2048 → 512", "BATCH + 1024"}, + Placement: true, Partial: "annulé", Skipped: []string{"marges : une seule carte"}, Notes: []string{"n"}, + OptIn: true, At: 1700000000, + }} + rs := []string{} + for _, r := range results { + b, _ := json.Marshal(r) + rs = append(rs, string(b)) + } + journal["results"] = rs + + // --- Après application : la configuration est-elle l'essai retenu ? ------ + ab := map[string]string{"MODEL": "m.gguf", "UBATCH": "512", "EXTRA_ARGS": "-t 8"} + ares := &tuneResult{BaseFP: configFingerprint(ab), Set: map[string]string{"UBATCH": "2048", "EXTRA_ARGS": ""}, + Old: map[string]string{"UBATCH": "512", "EXTRA_ARGS": "-t 8"}} + ap := []map[string]any{} + for _, now := range []map[string]string{ + {"MODEL": "m.gguf", "UBATCH": "2048"}, {"MODEL": "m.gguf", "UBATCH": "1024"}, + {"MODEL": "autre.gguf", "UBATCH": "2048"}, {"MODEL": "m.gguf", "UBATCH": "2048", "EXTRA_ARGS": "-t 4"}, + {"MODEL": "m.gguf", "UBATCH": "2048", "PORT": "9"}, + } { + ap = append(ap, map[string]any{"now": now, "why": tuneAppliedMismatch(now, ares)}) + } + journal["applied"] = map[string]any{"base_fp": ares.BaseFP, "set": ares.Set, "old": ares.Old, "cases": ap} + + got, err := parityJSON(journal) + if err != nil { + t.Fatal(err) + } + if os.Getenv("LOKI_PARITY_UPDATE") == "1" { + if err := os.MkdirAll("../../rust/loki-rs/tests/parity_tune", 0o755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(parityTuneCoreFile, got, 0o644); err != nil { + t.Fatal(err) + } + return + } + want, err := os.ReadFile(parityTuneCoreFile) + 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'optimiseur ne produit plus le journal rangé dans %s.\n"+ + "Si c'est voulu : LOKI_PARITY_UPDATE=1 go test ./internal/loki -run TestPariteTuneCore,\n"+ + "puis cargo test --test tune_core dit si le port Rust suit.", parityTuneCoreFile) + } +} 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/internal/loki/parity_web_tools_test.go b/internal/loki/parity_web_tools_test.go new file mode 100644 index 0000000..cd9d837 --- /dev/null +++ b/internal/loki/parity_web_tools_test.go @@ -0,0 +1,542 @@ +package loki + +import ( + "bytes" + "encoding/json" + "fmt" + "io" + "net" + "net/http" + "net/http/httptest" + "os" + "path/filepath" + "strings" + "sync" + "testing" + "time" +) + +// parity_web_tools_test.go — journal de référence des outils web de l'agent +// (lot 42 de la migration Rust) : web_search, web_images, web_open, web_read +// et web_grep, sur les deux moteurs. +// +// Un faux Crawl4AI rend des réponses rangées dans le journal, URL par URL ; +// un faux DuckDuckGo sert la recherche d'images. Le moteur intégré (Go) lit +// de vraies pages HTML d'un faux site et de faux résultats DuckDuckGo : ce +// qu'il en tire (goFetchMarkdown, goSearch) est rangé tel quel, la façade le +// demandant au Go. Chaque appel porte son résultat et les requêtes reçues +// par les faux services. Rangé dans rust/loki-rs/tests/parity_web/outils.json ; +// tests/webtools.rs le rejoue contre le port. +// +// LOKI_PARITY_UPDATE=1 go test ./internal/loki -run TestPariteOutilsWeb + +const parityWebFile = "../../rust/loki-rs/tests/parity_web/outils.json" + +type parityCrawlResp struct { + Status int `json:"status"` + Body string `json:"body"` +} + +type parityImgResp struct { + PageStatus int `json:"page_status"` + Page string `json:"page"` + Cookie string `json:"cookie,omitempty"` + APIStatus int `json:"api_status"` + API string `json:"api"` +} + +type parityWebReq struct { + Path string `json:"path"` + Auth string `json:"auth,omitempty"` + CType string `json:"ctype,omitempty"` + Body string `json:"body,omitempty"` + // DuckDuckGo + Referer string `json:"referer,omitempty"` + Accept string `json:"accept,omitempty"` + Lang string `json:"lang,omitempty"` + UA string `json:"ua,omitempty"` + Cookie string `json:"cookie,omitempty"` +} + +type parityWebCall struct { + Name string `json:"name"` + Setup map[string]string `json:"setup,omitempty"` // WEB_ENGINE, CRAWL4AI_URL, crawl_key ("-" efface) + Advance int `json:"advance_s,omitempty"` + Tool string `json:"tool"` + Args map[string]any `json:"args"` + Result string `json:"result"` + Crawls []parityWebReq `json:"crawls"` + Images []parityWebReq `json:"images"` + GoFetch []string `json:"go_fetch"` + GoSearch []string `json:"go_search"` +} + +type parityGoOut struct { + Markdown string `json:"markdown,omitempty"` + Results []map[string]string `json:"results,omitempty"` + Error string `json:"error,omitempty"` +} + +type parityWebJournal struct { + Crawl map[string][]parityCrawlResp `json:"crawl"` + Images map[string]parityImgResp `json:"images"` + GoFetch map[string]parityGoOut `json:"go_fetch"` + GoSearch map[string]parityGoOut `json:"go_search"` + Calls []parityWebCall `json:"calls"` +} + +// Les réponses du faux Crawl4AI, par URL demandée (consommées dans l'ordre, +// la dernière reste). +func parityCrawlFixtures() map[string][]parityCrawlResp { + ddg := func(q string) string { + return "https://html.duckduckgo.com/html/?q=" + q + } + obj := func(v any) string { + b, _ := json.Marshal(v) + return string(b) + } + long := strings.Repeat("une ligne assez longue pour remplir la fenêtre de lecture, encore et encore. ", 3) + var longLines []string + for i := 0; i < 60; i++ { + longLines = append(longLines, fmt.Sprintf("%d %s", i, long)) + } + searchMD := strings.Join([]string{ + "# DuckDuckGo", + "", + "## [Loki & **Rust**](//duckduckgo.com/l/?uddg=https%3A%2F%2Floki.example%2Frust%3Fa%3D1%26b%3D2&rut=abc)", + "", + "[loki.example/rust](//duckduckgo.com/l/?uddg=x) [![](icon.png)](x) [Un port en **Rust** étape par étape](//duckduckgo.com/l/?uddg=y)", + "## [Publicité](https://duckduckgo.com/y.js?ad=1)", + "## [Deuxième "résultat"](https://deux.example/page)", + "", + "", + "", + "", + "", + "[trop loin pour le résumé](x)", + "## [Loki encore](//duckduckgo.com/l/?uddg=https%3A%2F%2Floki.example%2Frust%3Fa%3D1%26b%3D2)", + "## [Troisième](https://trois.example/) ", + "[!bang ignoré](x) [vraie description](y)", + "## [Quatrième](//duckduckgo.com/l/?uddg=%ZZ)", + "## [](https://vide.example/)", + }, "\n") + docMD := strings.Join([]string{ + "", + "# Documentation ", + "", + "Intro ligne 1", + "Intro ligne 1", + "", + "", + "", + "## Installation", + "Lancer `loki install`.\t", + "Erreur : moteur absent", + "### Détails", + "ligne a", + "ligne b", + "Erreur : port pris", + "ligne c", + "ligne d", + "ligne e", + "ligne f", + "Erreur : disque plein", + "####### pas un titre", + "## Fin\r", + "", + }, "\n") + return map[string][]parityCrawlResp{ + ddg("loki+rust"): {{200, obj(map[string]any{"results": []any{map[string]any{"success": true, + "markdown": map[string]any{"raw_markdown": searchMD, "fit_markdown": "ignoré"}}}})}}, + ddg("anti-bot"): {{200, obj(map[string]any{"results": []any{map[string]any{"success": true, + "markdown": "
"}}})}}, + ddg("rien"): {{200, obj(map[string]any{"results": []any{map[string]any{"success": true, "markdown": "# Rien\n\npas de résultat"}}})}}, + ddg("panne"): {{500, "Internal Server Error: " + strings.Repeat("é", 320)}}, + "https://exemple.test/doc": { + {200, obj(map[string]any{"results": []any{map[string]any{"success": true, + "markdown": map[string]any{"raw_markdown": "brut", "fit_markdown": docMD}}}})}, + {200, obj(map[string]any{"results": []any{map[string]any{"success": true, + "markdown": "# Version 2\n\nnouveau contenu"}}})}, + {200, obj(map[string]any{"results": []any{map[string]any{"success": true, + "markdown": "# Avec actions\n\nbouton cliqué"}}})}, + }, + "https://raw.githubusercontent.com/r0m1k3/loki/HEAD/README.md": {{200, obj(map[string]any{"results": []any{map[string]any{"success": true, "markdown": "# Loki\n\nLe README."}}})}}, + "https://exemple.test/echec": {{200, obj(map[string]any{"results": []any{map[string]any{"success": false, + "error_message": "net::ERR_NAME_NOT_RESOLVED " + strings.Repeat("x", 310)}}})}}, + "https://exemple.test/echec-muet": {{200, `{"results":[{"success":false}]}`}}, + "https://exemple.test/vide": {{200, `{"results":[]}`}}, + "https://exemple.test/tableau": {{200, `[]`}}, + "https://exemple.test/illisible": {{200, `pas du json`}}, + "https://exemple.test/seul": {{200, `{"success":true,"markdown":"# Seul\n\nun résultat seul"}`}}, + "https://exemple.test/mal-type": {{200, `{"results":[{"success":"oui","markdown":"x"}]}`}}, + "https://exemple.test/blanc": {{200, `{"results":[{"success":true,"markdown":" \n\t\n"}]}`}}, + "https://exemple.test/objet-faux": {{200, `{"results":[{"success":true,"markdown":{"raw_markdown":5}}]}`}}, + "https://exemple.test/longue": {{200, obj(map[string]any{"results": []any{map[string]any{"success": true, "markdown": strings.Join(longLines, "\n")}}})}}, + "https://exemple.test/brut-absent": {{200, `{"results":[{"success":true,"markdown":{"fit_markdown":"# Fit seul\n\nok"}}]}`}}, + } +} + +func parityImageFixtures() map[string]parityImgResp { + page := func(vqd string) string { + return `` + } + 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/perf_log.go b/internal/loki/perf_log.go index 36aee20..0e3e63c 100644 --- a/internal/loki/perf_log.go +++ b/internal/loki/perf_log.go @@ -343,7 +343,15 @@ func (p *perfStore) snapshot() []perfRec { // perfRecord range une complétion et, si LOKI_PERF_LOG est posée, l'écrit sur // stderr. Une ligne par complétion, c'est des dizaines par tour d'agent : pas // par défaut, les journaux Docker ont déjà celles de llama-server. +// +// Quand la façade tient la conversation, l'anneau est le sien (facade_perf.go) : +// elle range et écrit la ligne. func perfRecord(r perfRec, prefix []uint64) perfRec { + if facadeChat() { + if got, ok := facadePerfRecord(r, prefix); ok { + return got + } + } r = perfLog.record(r, prefix) if perfLogOn() { fmt.Fprintln(os.Stderr, perfLine(r)) 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/sys_platform_unix.go b/internal/loki/sys_platform_unix.go index 5ffecee..68f9b9a 100644 --- a/internal/loki/sys_platform_unix.go +++ b/internal/loki/sys_platform_unix.go @@ -85,7 +85,7 @@ func totalRAMGB() float64 { } return 0 } - b, err := os.ReadFile("/proc/meminfo") + b, err := os.ReadFile(meminfoPath) if err != nil { return 0 } diff --git a/internal/loki/sys_service.go b/internal/loki/sys_service.go index b1a093f..5a8da04 100644 --- a/internal/loki/sys_service.go +++ b/internal/loki/sys_service.go @@ -31,9 +31,13 @@ func serviceAction(action string) error { } tuneReapStale() } - return serviceActionOS(action) + return serviceActionOSFn(action) } +// serviceActionOSFn : serviceActionOS, remplaçable par les tests (une bascule +// de preset ne doit pas lancer de vrai service depuis `go test`). +var serviceActionOSFn = serviceActionOS + // preflightEngine vérifie ce sans quoi le moteur ne PEUT pas démarrer, avant de // lancer le service. Sinon llama-server sortait en erreur, systemd le relançait // toutes les 3 s, et `loki start` affichait un « activating » rassurant pendant diff --git a/internal/loki/tasks.go b/internal/loki/tasks.go index efef5e9..37ba263 100644 --- a/internal/loki/tasks.go +++ b/internal/loki/tasks.go @@ -143,6 +143,10 @@ func tasksPaused() bool { return getBool(bkState, "tasks_paused") } func setTasksPaused(on bool) error { return putBool(bkState, "tasks_paused", on) } +// tasksNow : l'heure des tâches (planification, passages), remplaçable par +// les tests (journal de parité des tâches). +var tasksNow = time.Now + // computeNextRun calcule le prochain déclenchement d'une tâche à partir de son // Schedule (dans le fuseau tz), relatif à `from`. Renvoie 0 si le schedule est // invalide (la tâche ne se déclenchera alors jamais — l'UI refuse déjà un diff --git a/internal/loki/tasks_cron.go b/internal/loki/tasks_cron.go index 29f87d1..f7b1ba3 100644 --- a/internal/loki/tasks_cron.go +++ b/internal/loki/tasks_cron.go @@ -129,7 +129,7 @@ func cutSuffix(s, suffix string) (string, bool) { // validateSchedule vérifie qu'un schedule est acceptable, sans calculer de date. func validateSchedule(schedule, tz string) error { - _, err := nextAfter(schedule, tz, time.Now()) + _, err := nextAfter(schedule, tz, tasksNow()) return err } diff --git a/internal/loki/tasks_run.go b/internal/loki/tasks_run.go index 771f780..c4fcd1b 100644 --- a/internal/loki/tasks_run.go +++ b/internal/loki/tasks_run.go @@ -155,7 +155,7 @@ const reportMax = 4000 // runTask exécute une tâche et met à jour son état persisté (dernier passage, // prochain passage, succès/échec, compte-rendu). func runTask(t Task) { - start := time.Now() + start := tasksNow() // Tâche « script seul » : aucun modèle à charger, aucun token consommé — on // lance le script du dossier protégé et sa sortie devient le compte-rendu. @@ -273,7 +273,7 @@ func ensureTaskPreset(id string) error { // tâche juste avant d'écrire pour ne pas écraser une modification concurrente // (bascule ou édition depuis l'interface pendant l'exécution). func recordTaskEnd(id string, start time.Time, report string, err error) { - now := time.Now() + now := tasksNow() cur, ok := getTask(id) if !ok { return // supprimée entre-temps : rien à écrire diff --git a/internal/loki/tasks_sched.go b/internal/loki/tasks_sched.go index e4027ac..7f2b3dc 100644 --- a/internal/loki/tasks_sched.go +++ b/internal/loki/tasks_sched.go @@ -27,7 +27,7 @@ func taskSchedLoop() { t := time.NewTicker(time.Minute) defer t.Stop() for range t.C { - tickTasks(time.Now()) + tickTasks(tasksNow()) } } @@ -35,7 +35,7 @@ func taskSchedLoop() { // relativement à maintenant — sinon une tâche à NextRun=0 serait considérée « due » // en permanence, ou jamais planifiée selon le sens de la comparaison. func primeNextRuns() { - now := time.Now() + now := tasksNow() for _, t := range listTasks() { if t.Enabled && t.NextRun == 0 { t.NextRun = computeNextRun(t.Schedule, t.TZ, now) diff --git a/internal/loki/tasks_script.go b/internal/loki/tasks_script.go index 75aa311..347cd1b 100644 --- a/internal/loki/tasks_script.go +++ b/internal/loki/tasks_script.go @@ -77,7 +77,7 @@ func scriptRunStop(id string) bool { // runScriptTask exécute le script d'une tâche et enregistre son état (comme runTask // pour une tâche IA), mais sans aucune inférence. func runScriptTask(t Task) { - start := time.Now() + start := tasksNow() name := strings.TrimSpace(t.Script) if name == "" { diff --git a/internal/loki/tasks_tools.go b/internal/loki/tasks_tools.go index f6e98e0..35e6fa0 100644 --- a/internal/loki/tasks_tools.go +++ b/internal/loki/tasks_tools.go @@ -7,6 +7,11 @@ package loki // saveTask), donc une tâche créée par l'IA apparaît et se pilote comme les // autres dans le panneau Tâches. // +// Port Rust (lot 37) : rust/loki-rs/src/tasks/tools.rs, employé par les tours +// de la façade quand elle tient les tâches (LOKI_TASKS=rust) ; journal de +// parité dans parity_tasks_tools_test.go. L'heure est celle des tâches +// (tasksNow), fixée par ce journal. +// // Gate : réservés au mode agent (EnabledTools). Une tâche n'a de sens que si // l'IA peut agir en arrière-plan ; sans agent elle n'aurait aucun outil pour // livrer son résultat. @@ -215,7 +220,7 @@ func toolTaskCreate(args map[string]any) string { // fuseau de l'utilisateur (AJEAN 0.17.5). Le modèle n'a plus à jongler avec // les fuseaux ni le cron pour un simple rappel. if schedule == "" { - now := time.Now().In(loc(tz)) + now := tasksNow().In(loc(tz)) if v, ok := args["in_minutes"].(float64); ok && v >= 1 { schedule = "@once " + now.Add(time.Duration(v)*time.Minute).Format("2006-01-02 15:04") } else if at := strings.TrimSpace(taskStrArg(args["at"])); at != "" { @@ -259,7 +264,7 @@ func toolTaskCreate(args map[string]any) string { // comme elle n'y voit et n'y pilote que ses propres tâches. Project: activeProjectSlug(), } - t.NextRun = computeNextRun(schedule, tz, time.Now()) + t.NextRun = computeNextRun(schedule, tz, tasksNow()) if err := saveTask(t); err != nil { return "[erreur] " + err.Error() } @@ -292,12 +297,12 @@ func toolTaskUpdate(args map[string]any) string { return "[erreur] fréquence invalide : " + err.Error() } t.Schedule = sched - t.NextRun = computeNextRun(sched, t.TZ, time.Now()) + t.NextRun = computeNextRun(sched, t.TZ, tasksNow()) } if v, ok := args["enabled"].(bool); ok { t.Enabled = v if v && t.NextRun == 0 { - t.NextRun = computeNextRun(t.Schedule, t.TZ, time.Now()) + t.NextRun = computeNextRun(t.Schedule, t.TZ, tasksNow()) } } if err := saveTask(t); err != nil { diff --git a/internal/loki/tracker.go b/internal/loki/tracker.go index 3911d44..a50fad2 100644 --- a/internal/loki/tracker.go +++ b/internal/loki/tracker.go @@ -273,10 +273,14 @@ func trackerMoveToProject(slug, toSlug string) error { // Vide ou « now » = maintenant. Le T de l'ISO est accepté à la place de l'espace. // dateOnly=true quand aucune heure n'a été fournie → le point n'aura pas d'heure // d'affichage, plutôt qu'un faux « minuit ». +// trackerNow : l'heure des trackers (« maintenant », année en cours), +// remplaçable par les tests (journal de parité des outils mémoire). +var trackerNow = time.Now + func parseWhen(when string) (ts int64, dateOnly bool, err error) { w := strings.TrimSpace(when) if w == "" || strings.EqualFold(w, "now") || strings.EqualFold(w, "maintenant") { - return time.Now().UnixMilli(), false, nil + return trackerNow().UnixMilli(), false, nil } w = strings.Replace(w, "T", " ", 1) for _, layout := range []string{"2006-01-02 15:04:05", "2006-01-02 15:04", "2006-01-02 15"} { @@ -402,7 +406,7 @@ func trackerSkeleton(s *Tracker) string { parts = append(parts, fmt.Sprintf("%s (%d)", y, years[y])) } fmt.Fprintf(&b, "Années : %s\n", strings.Join(parts, " · ")) - cur := time.Now().In(time.Local).Format("2006") + cur := trackerNow().In(time.Local).Format("2006") var curEv []TrackerEvent for _, e := range s.Events { if strings.HasPrefix(fmtTS(e.TS), cur) { 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 d81c80b..dc78969 100644 --- a/internal/loki/web_internal.go +++ b/internal/loki/web_internal.go @@ -71,8 +71,20 @@ func withInternalAPI(next http.Handler) http.Handler { handleInternalOAIEnd(w, r) case "/internal/v1/oai/served": handleInternalOAIServed(w, r) - case "/internal/v1/turn/perf": - handleInternalTurnPerf(w, r) + case "/internal/v1/turn/projsnap/refresh": + handleInternalTurnProjSnapRefresh(w, r) + case "/internal/v1/turn/slot": + handleInternalTurnSlot(w, r) + case "/internal/v1/prewarm/check": + handleInternalPrewarmCheck(w, r) + case "/internal/v1/prewarm/begin": + handleInternalPrewarmBegin(w, r) + case "/internal/v1/prewarm/end": + handleInternalPrewarmEnd(w, r) + case "/internal/v1/turn/keepimg": + handleInternalTurnKeepImg(w, r) + case "/internal/v1/turn/keepimg/store": + handleInternalTurnKeepImgStore(w, r) case "/internal/v1/tool/exec": handleInternalToolExec(w, r) case "/internal/v1/tool/context": @@ -95,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": @@ -103,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": @@ -111,6 +129,42 @@ 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) + case "/internal/v1/preset/apply": + 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) + 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": + handleInternalServiceAction(w, r) + case "/internal/v1/project/switch": + handleInternalProjectSwitch(w, r) + case "/internal/v1/project/drop-convs": + handleInternalProjectDropConvs(w, r) + case "/internal/v1/engine/sidejob": + handleInternalSideJob(w, r) + case "/internal/v1/bench/perf": + handleInternalBenchPerf(w, r) + case "/internal/v1/tune/context": + handleInternalTuneContext(w, r) + case "/internal/v1/tasks/run": + handleInternalTaskRun(w, r) + case "/internal/v1/push/send": + handleInternalPushSend(w, r) default: http.NotFound(w, r) } diff --git a/internal/loki/web_internal_lease.go b/internal/loki/web_internal_lease.go index ee1eb1c..1438fd1 100644 --- a/internal/loki/web_internal_lease.go +++ b/internal/loki/web_internal_lease.go @@ -6,6 +6,7 @@ import ( "io" "net/http" "os" + "sync/atomic" "time" ) @@ -43,18 +44,32 @@ type extLeaseState struct { stop bool ttl time.Duration timer *time.Timer + // why : le motif d'un bail de mesure de la façade — errBenchBusy pour un + // benchmark (LOKI_BENCH=rust), errTuneBusy pour l'optimiseur + // (LOKI_TUNE=rust). Les autres reçoivent ce refus, et changer de + // discussion ne rompt pas le bail. nil : un tour ordinaire. + why error } // extLeaseClearLocked oublie le bail de la façade. c.mu détenu. +// benchLeaseHeld : un bail « bench » de la façade est tenu. Lu sans le verrou +// de la conversation (benchRunning est appelé par des chemins qui le tiennent +// déjà, le préchauffage par exemple). +var benchLeaseHeld atomic.Bool + func (c *Conversation) extLeaseClearLocked() { if c.extLease != nil { + if c.extLease.why == errBenchBusy { + benchLeaseHeld.Store(false) + } c.extLease.timer.Stop() c.extLease = nil } } -// extLeaseAcquire prend le verrou de génération pour la façade. -func (c *Conversation) extLeaseAcquire(ttl time.Duration) (string, error) { +// extLeaseAcquire prend le verrou de génération pour la façade ; why : voir +// extLeaseState. +func (c *Conversation) extLeaseAcquire(ttl time.Duration, why error) (string, error) { if err := tuneGuard(); err != nil { return "", err } @@ -67,9 +82,13 @@ func (c *Conversation) extLeaseAcquire(ttl time.Duration) (string, error) { if err != nil { return "", err } - st := &extLeaseState{id: fmt.Sprintf("%x", b), ttl: ttl} + st := &extLeaseState{id: fmt.Sprintf("%x", b), ttl: ttl, why: why} st.timer = time.AfterFunc(ttl, func() { c.extLeaseExpire(st) }) c.Generating = true + if why != nil { + c.benching, c.leaseWhy = true, why + benchLeaseHeld.Store(why == errBenchBusy) + } c.cancel = func() { c.mu.Lock() st.stop = true @@ -88,6 +107,10 @@ func (c *Conversation) extLeaseExpire(st *extLeaseState) { } c.extLease = nil c.Generating, c.cancel = false, nil + if st.why != nil { + c.benching, c.leaseWhy = false, nil + benchLeaseHeld.Store(false) + } fmt.Fprintf(os.Stderr, "[lease] bail de la façade expiré sans renouvellement (%s) : verrou du moteur rendu\n", st.ttl) } @@ -110,10 +133,16 @@ func (c *Conversation) extLeaseRelease(id string) { if c.extLease == nil || c.extLease.id != id { return } + if c.extLease.why != nil { + c.benching, c.leaseWhy = false, nil + } c.extLeaseClearLocked() c.Generating, c.cancel = false, nil } +// benchLeased : un benchmark de la façade tient-il le verrou ? +func benchLeased() bool { return benchLeaseHeld.Load() } + func readLeaseBody(w http.ResponseWriter, r *http.Request, v any) bool { if r.Method != http.MethodPost { w.Header().Set("Allow", http.MethodPost) @@ -134,7 +163,8 @@ func readLeaseBody(w http.ResponseWriter, r *http.Request, v any) bool { func handleInternalLease(w http.ResponseWriter, r *http.Request) { var in struct { - TTLms int64 `json:"ttl_ms"` + TTLms int64 `json:"ttl_ms"` + Why string `json:"why"` // "bench" : un benchmark ; "tune" : l'optimiseur } if !readLeaseBody(w, r, &in) { return @@ -143,7 +173,14 @@ func handleInternalLease(w http.ResponseWriter, r *http.Request) { if in.TTLms > 0 { ttl = min(max(time.Duration(in.TTLms)*time.Millisecond, extLeaseMinTTL), extLeaseMaxTTL) } - id, err := conv.extLeaseAcquire(ttl) + var why error + switch in.Why { + case "bench": + why = errBenchBusy + case "tune": + why = errTuneBusy + } + id, err := conv.extLeaseAcquire(ttl, why) if err != nil { if err == ErrBusy { err = conv.busyReason() // dit QUI occupe le moteur diff --git a/internal/loki/web_internal_lease_test.go b/internal/loki/web_internal_lease_test.go index 1d0c442..31b48bf 100644 --- a/internal/loki/web_internal_lease_test.go +++ b/internal/loki/web_internal_lease_test.go @@ -86,7 +86,7 @@ func TestBailInterneVerrouPartage(t *testing.T) { func TestBailInterneNeLibereQueLeSien(t *testing.T) { testHome(t) libérerVerrou(t) - id, err := conv.extLeaseAcquire(time.Minute) + id, err := conv.extLeaseAcquire(time.Minute, nil) if err != nil { t.Fatal(err) } @@ -110,7 +110,7 @@ func TestBailInterneNeLibereQueLeSien(t *testing.T) { func TestBailInterneExpire(t *testing.T) { testHome(t) libérerVerrou(t) - id, err := conv.extLeaseAcquire(50 * time.Millisecond) + id, err := conv.extLeaseAcquire(50*time.Millisecond, nil) if err != nil { t.Fatal(err) } diff --git a/internal/loki/web_internal_oai.go b/internal/loki/web_internal_oai.go index 44bd020..086638c 100644 --- a/internal/loki/web_internal_oai.go +++ b/internal/loki/web_internal_oai.go @@ -70,6 +70,10 @@ func handleInternalOAIBegin(w http.ResponseWriter, r *http.Request) { // MainConv : étape d'un tour de cette discussion, tenue par la façade // (LOKI_CHAT=rust) — comptée comme la requête principale du Go. MainConv string `json:"main_conv"` + // PrewarmKeep : étape d'un tour de la façade qui prolonge exactement + // le préchauffage en vol (PREWARM, facade_prewarm.go) — il n'est pas + // annulé, la requête le suit dans le même slot. + PrewarmKeep bool `json:"prewarm_keep"` } if r.Method != http.MethodPost || json.NewDecoder(r.Body).Decode(&in) != nil { sendJSON(w, http.StatusBadRequest, map[string]any{"error": "POST JSON attendu"}) @@ -80,7 +84,11 @@ func handleInternalOAIBegin(w http.ResponseWriter, r *http.Request) { if in.Read { end, seq = engineRequestBeginSide(func(*prewarmRun) bool { return true }, true) } else { - end, seq = engineRequestBeginSide(nil, in.Side) + var keep func(*prewarmRun) bool + if in.PrewarmKeep { + keep = func(*prewarmRun) bool { return true } + } + end, seq = engineRequestBeginSide(keep, in.Side) } if in.PostV1 { refuse := func(retry, msg, code string) { 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/internal/loki/web_internal_turn.go b/internal/loki/web_internal_turn.go index 99bbc47..2867abd 100644 --- a/internal/loki/web_internal_turn.go +++ b/internal/loki/web_internal_turn.go @@ -80,20 +80,24 @@ func handleInternalTurnStart(w http.ResponseWriter, r *http.Request) { }) } -// handleInternalTurnView : les pièces de turnView (assembleTurn sans -// PROJ_SNAPSHOT) que la façade assemble autour de l'historique : head part -// devant les messages, puis prefix (InjectSkills) fusionné dans le premier -// système qui n'est pas un message projet, ou posé en tête. +// handleInternalTurnView : les pièces de turnView (assembleTurn) que la +// façade assemble autour de l'historique : head part devant les messages, puis +// prefix (InjectSkills) fusionné dans le premier système qui n'est pas un +// message projet, ou posé en tête. Avec PROJ_SNAPSHOT et l'agent, la tête +// porte le bloc figé de la discussion (snap) et projsnap dit quoi en faire +// (facade_projsnap.go) ; dry : pour le préchauffage, rien à ranger. func handleInternalTurnView(w http.ResponseWriter, r *http.Request) { var in struct { - Caps Caps `json:"caps"` + Caps Caps `json:"caps"` + Snap *projSnapshot `json:"snap"` + Dry bool `json:"dry"` } if !readInternalJSON(w, r, &in) { return } caps := in.Caps if caps.Agent && projSnapEnabled(ReadConfig()) { - sendJSON(w, http.StatusConflict, map[string]any{"error": "PROJ_SNAPSHOT actif : le bloc projet figé reste tenu par le Go"}) + sendJSON(w, http.StatusOK, projSnapView(caps, in.Snap, in.Dry)) return } head := []Message{} @@ -133,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/internal/loki/web_internal_turn_test.go b/internal/loki/web_internal_turn_test.go index 092d036..db67d22 100644 --- a/internal/loki/web_internal_turn_test.go +++ b/internal/loki/web_internal_turn_test.go @@ -101,7 +101,10 @@ func TestTourInterneVue(t *testing.T) { if err := SetConfigKey("PROJ_SNAPSHOT", "on"); err != nil { t.Fatal(err) } - turnJSON(t, "/internal/v1/turn/view", `{"caps":{"Agent":true}}`, 409) + // Avec la clé : le bloc figé et la décision (facade_projsnap.go). + if got := turnJSON(t, "/internal/v1/turn/view", `{"caps":{"Agent":true}}`, 200); got["projsnap"] == nil { + t.Fatalf("PROJ_SNAPSHOT sans décision : %v", got) + } turnJSON(t, "/internal/v1/turn/view", `{"caps":{"Agent":false,"Mem":"off"}}`, 200) } diff --git a/internal/loki/web_internal_turnperf.go b/internal/loki/web_internal_turnperf.go deleted file mode 100644 index b4a169d..0000000 --- a/internal/loki/web_internal_turnperf.go +++ /dev/null @@ -1,72 +0,0 @@ -package loki - -import ( - "encoding/json" - "io" - "net/http" -) - -// Télémétrie d'une complétion d'un tour tenu par la façade Rust -// (LOKI_CHAT=rust). La boucle Go range chaque complétion dans l'anneau de -// perf_log.go (perfRecord) : c'est lui qui nourrit /api/perf/summary et -// l'alerte « cache perdu » posée sur les stats finales d'une complétion. La -// boucle Rust lui envoie la même chose, ici, au même moment. -// -// POST /internal/v1/turn/perf -// {"kind":"main","conv":"c…","iter":0,"complete":true,"external":false, -// "stats":{…StatsEvent…},"prefix":[…empreintes…], -// "probe":{"tools":[…],"kwargs":{…},"effort":""}} -// → {"kind":"main","lost":N,"lost_after":"…","lost_alert":true} -// -// prefix : empreintes cumulées des messages envoyés (perfPrefix), calculées -// par l'appelant — elles n'ont à être cohérentes qu'avec ses propres envois, -// d'une complétion à la suivante de la même discussion. probe : présent quand -// la boucle Go aurait lancé la sonde du gabarit (requête de forme normale du -// fil principal, moteur local). - -const internalTurnPerfMaxBody = 8 << 20 - -func handleInternalTurnPerf(w http.ResponseWriter, r *http.Request) { - if r.Method != http.MethodPost { - w.Header().Set("Allow", http.MethodPost) - sendJSON(w, http.StatusMethodNotAllowed, map[string]any{"error": "POST attendu"}) - return - } - var in struct { - Kind string `json:"kind"` - Conv string `json:"conv"` - Iter int `json:"iter"` - Complete bool `json:"complete"` - External bool `json:"external"` - Stats StatsEvent `json:"stats"` - Prefix []uint64 `json:"prefix"` - Probe *struct { - Tools []Tool `json:"tools"` - Kwargs map[string]any `json:"kwargs"` - Effort string `json:"effort"` - } `json:"probe"` - } - raw, err := io.ReadAll(io.LimitReader(r.Body, internalTurnPerfMaxBody+1)) - if err != nil || len(raw) > internalTurnPerfMaxBody || json.Unmarshal(raw, &in) != nil { - sendJSON(w, http.StatusBadRequest, map[string]any{"error": "télémétrie illisible"}) - return - } - if in.Kind == "" { - in.Kind = perfMain - } - var prefix []uint64 - if in.Complete { - prefix = in.Prefix - } - rec := perfRecord(perfRecFromStats(perfTag{kind: in.Kind, conv: in.Conv}, in.Iter, in.Complete, in.Stats), prefix) - alert := false - if in.Complete && !in.External && ((rec.Lost != nil && *rec.Lost > 0) || (rec.Cached != nil && *rec.Cached == 0)) { - alert = perfAlert(rec, perfLostAlertAt(ReadConfig())) - } - if in.Complete && !in.External && in.Probe != nil { - tplProbeKick(in.Probe.Tools, in.Probe.Kwargs, in.Probe.Effort) - } - sendJSON(w, http.StatusOK, map[string]any{ - "kind": rec.Kind, "lost": rec.Lost, "lost_after": rec.LostAfter, "lost_alert": alert, - }) -} diff --git a/internal/loki/web_internal_turnperf_test.go b/internal/loki/web_internal_turnperf_test.go deleted file mode 100644 index 0a5548a..0000000 --- a/internal/loki/web_internal_turnperf_test.go +++ /dev/null @@ -1,67 +0,0 @@ -package loki - -import ( - "encoding/json" - "fmt" - "testing" -) - -// La complétion d'un tour tenu par Rust arrive dans l'anneau de télémétrie, -// comme celle de la boucle Go : /api/perf/summary la compte. -func TestTurnPerfDansLAnneau(t *testing.T) { - testHome(t) - t.Setenv(internalTokenEnv, jetonTest) - avant := perfSummarize(perfLog.snapshot()) - n0 := 0 - if k := avant.Kinds[perfMain]; k != nil { - n0 = k.Completions - } - body := `{"kind":"main","conv":"c-perf","iter":0,"complete":true,"external":false, - "stats":{"prompt_tokens":120,"prompt_tokens_total":120,"gen_tokens":8,"cache_tokens":0}, - "prefix":[1,2,3]}` - w := appelOAIInterne(t, "POST", "/internal/v1/turn/perf", body) - if w.Code != 200 { - t.Fatalf("code %d : %s", w.Code, w.Body.String()) - } - var got map[string]any - if err := json.Unmarshal(w.Body.Bytes(), &got); err != nil || got["kind"] != "main" { - t.Fatalf("réponse : %s", w.Body.String()) - } - apres := perfSummarize(perfLog.snapshot()) - if k := apres.Kinds[perfMain]; k == nil || k.Completions != n0+1 { - t.Fatalf("complétion non rangée : %+v", apres.Kinds[perfMain]) - } - if w := appelOAIInterne(t, "POST", "/internal/v1/turn/perf", "{"); w.Code != 400 { - t.Fatalf("corps illisible : %d", w.Code) - } -} - -// Une étape de tour acceptée par le moteur : le slot porte la discussion -// (engineSlotHolds), comme engineMarkMain dans la boucle Go. -func TestOAIServedMarqueLeSlot(t *testing.T) { - testHome(t) - t.Setenv(internalTokenEnv, jetonTest) - w := appelOAIInterne(t, "POST", "/internal/v1/oai/begin", `{"main_conv":"c-slot"}`) - var b struct { - Ticket uint64 `json:"ticket"` - } - if err := json.Unmarshal(w.Body.Bytes(), &b); err != nil || b.Ticket == 0 { - t.Fatalf("begin : %s", w.Body.String()) - } - if engineSlotHolds("c-slot") { - t.Fatal("slot marqué avant que le moteur ait accepté la requête") - } - tk := fmt.Sprintf(`{"ticket":%d}`, b.Ticket) - if w := appelOAIInterne(t, "POST", "/internal/v1/oai/served", tk); w.Code != 204 { - t.Fatalf("served : %d", w.Code) - } - if !engineSlotHolds("c-slot") { - t.Fatal("slot non marqué après served") - } - if w := appelOAIInterne(t, "POST", "/internal/v1/oai/end", tk); w.Code != 204 { - t.Fatalf("end : %d", w.Code) - } - if w := appelOAIInterne(t, "POST", "/internal/v1/oai/served", tk); w.Code != 404 { - t.Fatalf("served après end : %d", w.Code) - } -} 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/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 4609ff9..fd2a623 100644 --- a/internal/loki/web_server.go +++ b/internal/loki/web_server.go @@ -130,25 +130,32 @@ 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 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). 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. - 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. resumeMemMigration() // Planificateur des tâches : une goroutine, un tic par minute, dans le process - // qui détient la conversation et le modèle (idempotent, cf. sync.Once). - StartTaskScheduler() + // qui détient la conversation et le modèle (idempotent, cf. sync.Once) — sauf + // quand la façade tient les tâches (facade_tasks.go) : c'est elle qui planifie. + if !facadeTasks() { + StartTaskScheduler() + } // Jeton de tentative du décodage spéculatif automatique : effacé dès que le // moteur répond, sans dépendre d'une page ouverte (voir backend_serve_spec.go). startSpecAttemptWatch() @@ -218,7 +225,12 @@ 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 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), 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/internal/loki/web_tasks.go b/internal/loki/web_tasks.go index 7049275..8399682 100644 --- a/internal/loki/web_tasks.go +++ b/internal/loki/web_tasks.go @@ -6,7 +6,6 @@ import ( "encoding/json" "net/http" "strings" - "time" ) // handleTasks : GET → liste des tâches + état de l'interrupteur maître + si le @@ -134,7 +133,7 @@ func handleTaskSave(w http.ResponseWriter, r *http.Request) { t.NoMem, t.NoWeb = req.NoMem, req.NoWeb // Recalcule NextRun si la fréquence a changé (ou à la création). if t.Schedule != req.Schedule || t.NextRun == 0 { - t.NextRun = computeNextRun(req.Schedule, req.TZ, time.Now()) + t.NextRun = computeNextRun(req.Schedule, req.TZ, tasksNow()) } t.Schedule = req.Schedule if err := saveTask(t); err != nil { @@ -176,7 +175,7 @@ func handleTaskToggle(w http.ResponseWriter, r *http.Request) { } t.Enabled = req.On if req.On { - t.NextRun = computeNextRun(t.Schedule, t.TZ, time.Now()) + t.NextRun = computeNextRun(t.Schedule, t.TZ, tasksNow()) } if err := saveTask(t); err != nil { sendJSON(w, 500, map[string]any{"ok": false, "error": err.Error()}) diff --git a/rust/Cargo.lock b/rust/Cargo.lock index 9df7f87..b07e29f 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,12 +65,33 @@ 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" 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" @@ -83,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" @@ -133,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" @@ -148,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" @@ -192,6 +251,25 @@ 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" +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" @@ -205,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", ] @@ -307,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" @@ -557,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" @@ -639,6 +744,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" @@ -667,7 +778,9 @@ checksum = "f9f8bd3e56ce4dfc153cf470fffbfa98c7620958b312ca5c3a4b8d5181fd13c6" name = "loki-rs" version = "0.1.0" dependencies = [ + "base64", "bytes", + "bzip2", "flate2", "futures-util", "getrandom 0.4.3", @@ -675,17 +788,22 @@ dependencies = [ "http-body-util", "hyper", "hyper-util", + "image", "libc", "regex", "reqwest", + "ring", "rustls", "serde", "serde_json", "sha1", "sha2", + "tar", "tempfile", "tokio", "tokio-tungstenite", + "url", + "zip", ] [[package]] @@ -694,6 +812,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" @@ -715,6 +843,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" @@ -739,6 +886,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" @@ -757,6 +917,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" @@ -1197,6 +1363,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" @@ -1538,6 +1714,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" @@ -1724,6 +1906,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" @@ -1735,3 +1933,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 fcf2da2..07cf97c 100644 --- a/rust/loki-rs/Cargo.toml +++ b/rust/loki-rs/Cargo.toml @@ -4,28 +4,36 @@ version = "0.1.0" edition = "2024" [dependencies] +base64 = "0.23.1" bytes = "1.12.1" +bzip2 = "0.6.1" flate2 = "1.1.10" 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"] } +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"] } -serde_json = { version = "1.0.154", features = ["raw_value"] } +serde_json = { version = "1.0.154", features = ["raw_value", "float_roundtrip"] } 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 # 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" @@ -34,4 +42,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 020a999..121e657 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, @@ -26,11 +27,77 @@ pub struct Ctx { pub engine: engine::Paths, /// Conversation tenue par la façade (`LOKI_CHAT=rust`) ; `None` : au Go. pub chat: Option>, + /// 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 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, + /// 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>, + /// 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, + /// 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>, + /// 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>, + /// 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>, + /// Panneau « moteur de l'image » tenu par la façade + /// (`LOKI_ENGINE_IMAGE=rust`, avec les backends). + pub engineimg: Option>, + /// Strata (écran, réglages, installation) tenu par la façade + /// (`LOKI_STRATA=rust`, avec les backends). + pub strata: Option>, + /// Tâches planifiées (routes, planificateur, tâches script) tenues par la + /// façade (`LOKI_TASKS=rust`). + pub tasks: Option>, + /// Banc d'essai tenu par la façade (`LOKI_BENCH=rust`). + pub bench: Option>, + /// Optimiseur tenu par la façade (`LOKI_TUNE=rust`). + pub tune: Option>, /// 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 + || self.files.is_some() + || self.push.is_some() + || self.mem.is_some() + || self.presets + || self.engine_ctl.is_some() + || self.system.is_some() + || self.projects + || self.mcp.is_some() + || self.update + || self.backends.is_some() + || self.engineimg.is_some() + || self.strata.is_some() + || self.tasks.is_some() + || self.bench.is_some() + || self.tune.is_some() + } + /// 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 { @@ -69,11 +136,21 @@ pub fn handles(_method: &Method, path: &str) -> bool { ) } -pub async fn serve(ctx: &Ctx, req: Request) -> Response { +/// `from_go` : relayée par le Go (tunnel du relais), qui l'a déjà +/// authentifiée. +pub async fn serve(ctx: &Ctx, req: Request, from_go: bool) -> Response { let (parts, _body) = req.into_parts(); - let version = match ctx.guard(&parts.headers).await { - Ok(v) => v, - Err(refus) => return *refus, + 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, + } }; match parts.uri.path() { // Vérifie la connectivité ET la clé (200 = bonne clé, 401 = mauvaise). 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/build.rs b/rust/loki-rs/src/backends/build.rs new file mode 100644 index 0000000..73477ff --- /dev/null +++ b/rust/loki-rs/src/backends/build.rs @@ -0,0 +1,978 @@ +//! Compilation de llama.cpp depuis les sources (lot 41) — port de +//! `backend_build.go` (plan, CMake, étapes suivies), de `lcRunInstall`, +//! `lcRunUpdate`, `lcBuildAndSwitch` (`web_llamacpp.go`) et de +//! `installCustomBackend` (`backend_llamacpp.go`), pour Linux et macOS. +//! +//! Chaque étape passe par le job des moteurs : phases, sortie des commandes +//! ligne à ligne (fichiers compilés et phases tirés des lignes), journaux +//! `configure.log` et `build.log` dans le dépôt, fin des journaux remontée +//! en cas d'échec. Les outils manquants sont installés au mieux par le +//! gestionnaire de paquets de la machine, comme le Go. + +use std::path::{Path, PathBuf}; +use std::process::Stdio; +use std::time::{Duration, Instant}; + +use super::probe::{Probe, is_dir}; +use super::{Backends, Log, llama_server_bin, sanitize_backend_name, service, service_stop_if_up}; +use crate::api::Ctx; +use crate::store::BK_CONFIG; +use crate::system::go_lossy; + +/// `llamacppRepoURL`. +pub const REPO_URL: &str = "https://github.com/ggml-org/llama.cpp.git"; + +/// `sinkProgressEvery` : une barre qui se redessine (\r), montrée toutes les +/// 10 s. +const PROGRESS_EVERY: Duration = Duration::from_secs(10); + +/// Une ligne de sortie lue au plus (`bufio.Scanner`, 1 Mio). +const MAX_LINE: usize = 1 << 20; + +/// `buildPlan` complet : backend, drapeaux CMake, nvcc. +#[derive(Clone, Debug, Default, PartialEq, Eq)] +pub struct Plan { + pub backend: &'static str, + pub cuda_arch: String, + pub jobs: usize, + pub flags: Vec, + pub cuda_cxx: String, +} + +/// `cudaToolkitRoot` : `/bin/nvcc` → ``, sauf `/usr` (la +/// racine n'apprend alors rien à CMake). +pub fn cuda_toolkit_root(nvcc: &Path) -> String { + let Some(bin) = nvcc.parent() else { + return String::new(); + }; + if bin.file_name().and_then(|n| n.to_str()) != Some("bin") { + return String::new(); + } + let root = bin + .parent() + .map(|r| r.to_string_lossy().into_owned()) + .unwrap_or_default(); + match root.as_str() { + "" | "." | "/" | "/usr" => String::new(), + _ => root, + } +} + +impl Probe { + /// `detectBuildPlan`. + pub async fn build_plan(&self) -> Plan { + let base = self.detect_build_plan().await; + let mut p = Plan { + backend: base.backend, + cuda_arch: base.cuda_arch, + jobs: base.jobs, + flags: [ + "-DCMAKE_BUILD_TYPE=Release", + "-DGGML_NATIVE=ON", + // L'UI de llama-server demande npm : Loki a la sienne. + "-DLLAMA_BUILD_UI=OFF", + "-DLLAMA_USE_PREBUILT_UI=OFF", + ] + .map(String::from) + .to_vec(), + cuda_cxx: String::new(), + }; + match p.backend { + "metal" => p.flags.push("-DGGML_METAL=ON".into()), + "cuda" => { + let nvcc = self.find_nvcc().unwrap_or_default(); + p.cuda_cxx = nvcc.to_string_lossy().into_owned(); + p.flags.push("-DGGML_CUDA=ON".into()); + p.flags.push("-DGGML_CUDA_F16=ON".into()); + let root = cuda_toolkit_root(&nvcc); + if !root.is_empty() { + p.flags.push(format!("-DCUDAToolkit_ROOT={root}")); + p.flags + .push(format!("-DCMAKE_CUDA_COMPILER={}", p.cuda_cxx)); + } + if !p.cuda_arch.is_empty() { + p.flags + .push(format!("-DCMAKE_CUDA_ARCHITECTURES={}", p.cuda_arch)); + } + } + "hip" => p.flags.push("-DGGML_HIP=ON".into()), + "vulkan" => p.flags.push("-DGGML_VULKAN=ON".into()), + _ => {} + } + p + } +} + +/// `cacheStale` : un `build/` configuré pour un autre dossier source (dépôt +/// déplacé) ne se reconfigure pas en place. +pub fn cache_stale(build: &Path, repo: &Path) -> bool { + let Ok(b) = std::fs::read_to_string(build.join("CMakeCache.txt")) else { + return false; + }; + let abs = std::path::absolute(repo).unwrap_or_else(|_| repo.to_path_buf()); + let abs = abs.to_string_lossy(); + for line in b.split('\n') { + if line.starts_with("CMAKE_HOME_DIRECTORY:") + && let Some(i) = line.find('=') + { + let home = line[i + 1..].trim(); + return !home.is_empty() && home != abs; + } + } + false +} + +/// L'erreur d'une commande, au format d'`exec` du Go. +fn exit_err(status: std::process::ExitStatus) -> Result<(), String> { + if status.success() { + return Ok(()); + } + match status.code() { + Some(c) => Err(format!("exit status {c}")), + None => { + #[cfg(unix)] + { + use std::os::unix::process::ExitStatusExt as _; + if let Some(s) = status.signal() { + return Err(format!("signal: {}", signal_name(s))); + } + } + Err(status.to_string()) + } + } +} + +#[cfg(unix)] +fn signal_name(s: i32) -> &'static str { + match s { + libc::SIGKILL => "killed", + libc::SIGTERM => "terminated", + libc::SIGINT => "interrupt", + libc::SIGSEGV => "segmentation fault", + libc::SIGABRT => "aborted", + _ => "signal", + } +} + +/// Une commande lancée par une étape : sorties standard et d'erreur dans le +/// même tube (leur ordre gardé), lues ligne à ligne (voir [`Splitter`]) et +/// copiées dans `log_file`, créé avant le lancement comme au Go. +struct Running { + rx: tokio::sync::mpsc::UnboundedReceiver, + done: tokio::task::JoinHandle>, +} + +fn spawn_lines( + probe: &Probe, + dir: Option<&Path>, + env: &[(String, String)], + name: &str, + args: &[String], + progress: bool, + log_file: Option<&Path>, +) -> Result { + let mut file = log_file.and_then(|p| std::fs::File::create(p).ok()); + let Some(bin) = probe.look_path(name) else { + return Err(format!( + "exec: \"{name}\": executable file not found in $PATH" + )); + }; + let (mut reader, writer) = std::io::pipe().map_err(|e| e.to_string())?; + let mut cmd = std::process::Command::new(&bin); + cmd.args(args) + .stdin(Stdio::null()) + .stdout(writer.try_clone().map_err(|e| e.to_string())?) + .stderr(writer); + if let Some(d) = dir { + cmd.current_dir(d); + } + for (k, v) in env { + cmd.env(k, v); + } + let spawned = cmd.spawn(); + // Les extrémités d'écriture n'appartiennent plus qu'à l'enfant : le tube + // se ferme à sa fin. + drop(cmd); + let mut child = + spawned.map_err(|e| crate::paths::os_err("fork/exec", &bin.to_string_lossy(), &e))?; + let (tx, rx) = tokio::sync::mpsc::unbounded_channel::(); + let done = tokio::task::spawn_blocking(move || { + split_lines(&mut reader, progress, |l| { + if let Some(f) = file.as_mut() { + use std::io::Write as _; + let _ = writeln!(f, "{l}"); + } + let _ = tx.send(l); + }); + drop(reader); + exit_err(child.wait().map_err(|e| e.to_string())?) + }); + Ok(Running { rx, done }) +} + +/// [`spawn_lines`], chaque ligne versée au job. +#[allow(clippy::too_many_arguments)] +async fn run_lines( + probe: &Probe, + log: &Log<'_>, + dir: Option<&Path>, + env: &[(String, String)], + name: &str, + args: &[String], + progress: bool, + log_file: Option<&Path>, +) -> Result<(), String> { + let mut r = spawn_lines(probe, dir, env, name, args, progress, log_file)?; + while let Some(line) = r.rx.recv().await { + log.line(&line).await; + } + r.done.await.map_err(|e| e.to_string())? +} + +/// [`spawn_lines`], chaque ligne passée à `f` (tests). +#[cfg(test)] +#[allow(clippy::too_many_arguments)] +async fn run_lines_with( + probe: &Probe, + dir: Option<&Path>, + env: &[(String, String)], + name: &str, + args: &[String], + progress: bool, + log_file: Option<&Path>, + mut f: impl FnMut(String), +) -> Result<(), String> { + let mut r = spawn_lines(probe, dir, env, name, args, progress, log_file)?; + while let Some(line) = r.rx.recv().await { + f(line); + } + r.done.await.map_err(|e| e.to_string())? +} + +/// Le découpage en lignes de `run_lines` : `sinkWriter` (`progress`) ou +/// `bufio.Scanner`. +pub struct Splitter { + progress: bool, + buf: Vec, + /// Dernière barre de progression montrée. + pub last_prog: Option, + /// Ligne démesurée : le scanner s'est arrêté. + pub stopped: bool, +} + +impl Splitter { + pub fn new(progress: bool) -> Self { + Splitter { + progress, + buf: Vec::new(), + last_prog: None, + stopped: false, + } + } + + /// Une écriture de la commande. + pub fn feed(&mut self, p: &[u8], emit: &mut impl FnMut(String)) { + if self.stopped { + return; + } + self.buf.extend_from_slice(p); + let progress = self.progress; + while let Some(i) = self + .buf + .iter() + .position(|&c| c == b'\n' || (progress && c == b'\r')) + { + if self.buf[i] == b'\n' { + let mut line = &self.buf[..i]; + if line.last() == Some(&b'\r') { + line = &line[..line.len() - 1]; + } + if !progress && line.len() > MAX_LINE { + self.stopped = true; + return; + } + emit(go_lossy(line)); + self.buf.drain(..=i); + continue; + } + // \r : fin de ligne Windows ou barre qui se redessine ; le \n + // peut arriver dans l'écriture suivante. + if i + 1 >= self.buf.len() { + break; + } + if self.buf[i + 1] == b'\n' { + emit(go_lossy(&self.buf[..i])); + self.buf.drain(..i + 2); + continue; + } + let prog = go_lossy(&self.buf[..i]).trim().to_string(); + if !prog.is_empty() && self.last_prog.is_none_or(|t| t.elapsed() >= PROGRESS_EVERY) { + self.last_prog = Some(Instant::now()); + emit(prog); + } + self.buf.drain(..=i); + } + if !progress && self.buf.len() > MAX_LINE { + self.stopped = true; + } + } + + /// Fin du flux : le scanner rend le dernier morceau, le `sinkWriter` le + /// perd. + pub fn finish(&mut self, emit: &mut impl FnMut(String)) { + if self.progress || self.stopped || self.buf.is_empty() { + return; + } + let mut line = &self.buf[..]; + if line.last() == Some(&b'\r') { + line = &line[..line.len() - 1]; + } + emit(go_lossy(line)); + self.buf.clear(); + } +} + +fn split_lines(r: &mut impl std::io::Read, progress: bool, mut emit: impl FnMut(String)) { + let mut sp = Splitter::new(progress); + let mut chunk = [0u8; 8192]; + loop { + match r.read(&mut chunk) { + Ok(0) | Err(_) => break, + Ok(n) => sp.feed(&chunk[..n], &mut emit), + } + if sp.stopped { + return; + } + } + sp.finish(&mut emit); +} + +/// `runStep` : une commande dont chaque ligne va au job. +pub async fn run_step( + probe: &Probe, + log: &Log<'_>, + dir: Option<&Path>, + name: &str, + args: &[&str], +) -> Result<(), String> { + let args: Vec = args.iter().map(|s| s.to_string()).collect(); + run_lines(probe, log, dir, &[], name, &args, true, None).await +} + +/// `buildLlamacpp` : configure puis compile la cible llama-server. Un +/// `build/` configuré ailleurs (ou `clean`) repart de zéro (`build.old`). +pub async fn build_llamacpp( + probe: &Probe, + log: &Log<'_>, + repo: &Path, + p: &Plan, + clean: bool, +) -> Result<(), String> { + let build = repo.join("build"); + if (clean || cache_stale(&build, repo)) && is_dir(&build) { + let old = repo.join("build.old"); + let _ = std::fs::remove_dir_all(&old); + if std::fs::rename(&build, &old).is_err() { + let _ = std::fs::remove_dir_all(&build); + } + } + // nvcc dans le PATH et exposé par CUDACXX pour la configuration CMake. + let mut env: Vec<(String, String)> = Vec::new(); + if p.backend == "cuda" && !p.cuda_cxx.is_empty() { + let cuda_bin = Path::new(&p.cuda_cxx) + .parent() + .map(|d| d.to_string_lossy().into_owned()) + .unwrap_or_default(); + env.push(("CUDACXX".into(), p.cuda_cxx.clone())); + let path = std::env::var("PATH").unwrap_or_default(); + env.push(("PATH".into(), format!("{cuda_bin}:{path}"))); + } + let mut cfg_args: Vec = ["-B", "build", "-S", "."].map(String::from).to_vec(); + cfg_args.extend(p.flags.iter().cloned()); + let cfg_log = repo.join("configure.log"); + if let Err(e) = run_lines( + probe, + log, + Some(repo), + &env, + "cmake", + &cfg_args, + false, + Some(&cfg_log), + ) + .await + { + hint_missing_dep(probe, p, &cfg_log); + return Err(format!("configuration CMake échouée: {e}")); + } + let build_args: Vec = [ + "--build", + "build", + "--config", + "Release", + "-j", + &p.jobs.to_string(), + "--target", + "llama-server", + ] + .map(String::from) + .to_vec(); + run_lines( + probe, + log, + Some(repo), + &env, + "cmake", + &build_args, + false, + Some(&repo.join("build.log")), + ) + .await + .map_err(|e| format!("compilation échouée: {e}")) +} + +/// `hintMissingBuildDep` : une dépendance connue manquante, dite sur la +/// sortie d'erreur de la façade (le Go l'écrit sur son terminal, pas dans +/// le job). +fn hint_missing_dep(probe: &Probe, p: &Plan, cfg_log: &Path) { + let Ok(log) = std::fs::read_to_string(cfg_log) else { + return; + }; + if p.backend == "cuda" && log.contains("CUDA Toolkit not found") { + eprintln!( + "[dépendance] nvcc est présent mais le CUDA Toolkit complet (en-têtes + cudart) est introuvable." + ); + } + if p.backend == "vulkan" && log.contains("SPIRV-Headers") { + let hint = pkg_install_hint(probe, "spirv-headers"); + eprintln!( + "[dépendance] en-têtes SPIR-V (paquet « SPIRV-Headers ») introuvables pour le backend Vulkan{}", + if hint.is_empty() { + String::new() + } else { + format!(" : {hint}") + } + ); + } +} + +/// `pkgInstallHint`. +fn pkg_install_hint(probe: &Probe, pkg: &str) -> String { + for (bin, cmd) in [ + ("pacman", "sudo pacman -S "), + ("apt-get", "sudo apt-get install -y "), + ("dnf", "sudo dnf install -y "), + ("zypper", "sudo zypper install -y "), + ("brew", "brew install "), + ] { + if probe.has_tool(bin) { + return format!("{cmd}{pkg}"); + } + } + String::new() +} + +/// `autoInstallTool` : le paquet par le gestionnaire de la machine (sudo +/// hors root), sorties sur celles de la façade. +async fn auto_install_tool(probe: &Probe, name: &str) -> Result<(), String> { + for (m, install) in [ + ("apt-get", &["install", "-y"][..]), + ("dnf", &["install", "-y"][..]), + ("pacman", &["-S", "--noconfirm"][..]), + ("brew", &["install"][..]), + ] { + let Some(bin) = probe.look_path(m) else { + continue; + }; + let mut argv: Vec = vec![bin.to_string_lossy().into_owned()]; + argv.extend(install.iter().map(|s| s.to_string())); + argv.push(name.to_string()); + #[cfg(unix)] + let root = unsafe { libc::geteuid() } == 0; + #[cfg(not(unix))] + let root = true; + if m != "brew" && !root { + let Some(sudo) = probe.look_path("sudo") else { + return Err(format!("{m} requiert root (ni root ni sudo disponibles)")); + }; + argv.insert(0, sudo.to_string_lossy().into_owned()); + } + let status = tokio::process::Command::new(&argv[0]) + .args(&argv[1..]) + .stdin(Stdio::null()) + .status() + .await + .map_err(|e| e.to_string())?; + return exit_err(status); + } + Err(format!( + "aucun gestionnaire de paquets connu — installe {name} manuellement" + )) +} + +/// `requireTools` : les outils manquants installés au mieux, puis revus. +pub async fn require_tools(probe: &Probe, tools: &[&str]) -> Result<(), String> { + let missing: Vec<&str> = tools + .iter() + .copied() + .filter(|t| !probe.has_tool(t)) + .collect(); + if missing.is_empty() { + return Ok(()); + } + eprintln!( + "[info] outils manquants: {} — installation automatique…", + missing.join(", ") + ); + for t in &missing { + if let Err(e) = auto_install_tool(probe, t).await { + eprintln!(" • {t}: {e}"); + } + } + let still: Vec<&str> = tools + .iter() + .copied() + .filter(|t| !probe.has_tool(t)) + .collect(); + if !still.is_empty() { + return Err(format!( + "outils toujours manquants après tentative d'install: {} — installe-les à la main puis réessaie", + still.join(", ") + )); + } + Ok(()) +} + +/// `ensureCompiler` (Unix) : cc, c++ et make, installés au mieux. +pub async fn ensure_compiler(probe: &Probe) -> Result<(), String> { + let any = |names: &[&str]| names.iter().any(|n| probe.has_tool(n)); + if any(&["cc", "gcc", "clang"]) && any(&["c++", "g++", "clang++"]) && probe.has_tool("make") { + return Ok(()); + } + let candidates: &[&str] = if probe.has_tool("apt-get") { + &["build-essential", "gcc", "g++", "make"] + } else { + &["gcc", "gcc-c++", "make"] + }; + eprintln!("[info] compilateur C/C++ absent — installation via le gestionnaire de paquets…"); + for pkg in candidates { + let _ = auto_install_tool(probe, pkg).await; + } + if any(&["cc", "gcc"]) && any(&["c++", "g++"]) && probe.has_tool("make") { + return Ok(()); + } + Err( + "compilateur C/C++ introuvable — installe gcc/g++/make (ou build-essential) manuellement" + .into(), + ) +} + +/// `lcAppendLogTail` : la fin des journaux de build, dans le job. +async fn append_log_tail(log: &Log<'_>, paths: &[PathBuf]) { + for p in paths { + let Ok(b) = std::fs::read(p) else { continue }; + let text = String::from_utf8_lossy(&b); + let lines: Vec<&str> = text.trim_end_matches('\n').split('\n').collect(); + let tail = &lines[lines.len().saturating_sub(30)..]; + let base = p + .file_name() + .map(|n| n.to_string_lossy().into_owned()) + .unwrap_or_default(); + log.line(&format!("--- fin de {base} ---")).await; + for l in tail { + log.line(l).await; + } + } +} + +/// `lcBuildAndSwitch` : compile llama-server et y pointe BIN. Faux : le job +/// a échoué. +async fn build_and_switch( + ctx: &Ctx, + b: &Backends, + log: &Log<'_>, + repo: &Path, + clean: bool, +) -> bool { + let plan = b.probe.build_plan().await; + log.line(&format!( + "plan de build : backend={} arch={} jobs={}", + plan.backend, plan.cuda_arch, plan.jobs + )) + .await; + log.phase("configuration CMake…").await; + if let Err(e) = build_llamacpp(&b.probe, log, repo, &plan, clean).await { + append_log_tail(log, &[repo.join("configure.log"), repo.join("build.log")]).await; + b.jobs.fail(&ctx.store, &e).await; + return false; + } + let bin = llama_server_bin(repo); + if bin.is_empty() { + b.jobs + .fail( + &ctx.store, + &format!( + "build terminé mais binaire introuvable sous {}", + repo.join("build").display() + ), + ) + .await; + return false; + } + log.line(&format!("binaire compilé : {bin}")).await; + if let Err(e) = ctx.store.put(BK_CONFIG, "BIN", bin.into_bytes()).await { + b.jobs + .fail( + &ctx.store, + &format!("build ok mais échec d'écriture de BIN : {e}"), + ) + .await; + return false; + } + log.line("BIN mis à jour").await; + true +} + +/// La vérification commune des outils ; l'erreur est rangée dans le job. +async fn tools_ready(ctx: &Ctx, b: &Backends) -> bool { + let res = match require_tools(&b.probe, &["git", "cmake"]).await { + Ok(()) => ensure_compiler(&b.probe).await, + Err(e) => Err(e), + }; + match res { + Ok(()) => true, + Err(e) => { + b.jobs.fail(&ctx.store, &e).await; + false + } + } +} + +/// `lcRunInstall` : clone, compilation, BIN. Dépôt déjà là : une mise à +/// jour (`force` : il est supprimé et recloné). +pub async fn run_install(ctx: &Ctx, b: &Backends, force: bool) { + let log = Log { + jobs: &b.jobs, + store: &ctx.store, + }; + let repo = b.default_repo_dir(); + log.phase("vérification des outils (git, cmake, compilateur)…") + .await; + if !tools_ready(ctx, b).await { + return; + } + if is_dir(&repo.join(".git")) { + if !force { + log.phase("dépôt déjà présent — bascule en mise à jour") + .await; + Box::pin(run_update(ctx, b, false)).await; + return; + } + log.phase("suppression du dépôt existant (--force)…").await; + if let Err(e) = std::fs::remove_dir_all(&repo) { + b.jobs + .fail( + &ctx.store, + &crate::paths::os_err("unlinkat", &repo.to_string_lossy(), &e), + ) + .await; + return; + } + } + if let Some(parent) = repo.parent() + && let Err(e) = std::fs::create_dir_all(parent) + { + b.jobs + .fail( + &ctx.store, + &crate::paths::os_err("mkdir", &parent.to_string_lossy(), &e), + ) + .await; + return; + } + log.phase("clone de llama.cpp…").await; + let repo_s = repo.to_string_lossy().into_owned(); + if let Err(e) = run_step( + &b.probe, + &log, + None, + "git", + &["clone", "--depth=1", REPO_URL, &repo_s], + ) + .await + { + b.jobs + .fail(&ctx.store, &format!("git clone a échoué : {e}")) + .await; + return; + } + if !build_and_switch(ctx, b, &log, &repo, true).await { + return; + } + b.jobs.done(&ctx.store, "installation terminée").await; +} + +/// `lcRunUpdate` : fetch, pull au besoin, recompilation (service arrêté le +/// temps du build), BIN. +pub async fn run_update(ctx: &Ctx, b: &Backends, clean: bool) { + let log = Log { + jobs: &b.jobs, + store: &ctx.store, + }; + log.phase("vérification des outils (git, cmake, compilateur)…") + .await; + if !tools_ready(ctx, b).await { + return; + } + let cfg_bin = ctx + .store + .config() + .await + .get("BIN") + .cloned() + .unwrap_or_default(); + let repo = b.repo_dir(&cfg_bin); + if !is_dir(&repo.join(".git")) { + b.jobs + .fail( + &ctx.store, + &format!( + "aucun dépôt llama.cpp ({}) — lance d'abord l'installation", + repo.display() + ), + ) + .await; + return; + } + let old = b.git(&repo, &["rev-parse", "--short", "HEAD"]).await; + b.jobs.set_commits(Some(&old), None).await; + let mut branch = b.git(&repo, &["rev-parse", "--abbrev-ref", "HEAD"]).await; + if branch.is_empty() || branch == "HEAD" { + branch = "master".into(); + } + log.phase("git fetch origin…").await; + if let Err(e) = run_step( + &b.probe, + &log, + Some(&repo), + "git", + &["fetch", "origin", "--quiet"], + ) + .await + { + b.jobs + .fail(&ctx.store, &format!("git fetch a échoué : {e}")) + .await; + return; + } + let local = b.git(&repo, &["rev-parse", "HEAD"]).await; + let remote = b + .git(&repo, &["rev-parse", &format!("origin/{branch}")]) + .await; + if !local.is_empty() && local == remote && !clean && !llama_server_bin(&repo).is_empty() { + b.jobs + .done(&ctx.store, &format!("déjà à jour ({old}) — rien à faire")) + .await; + return; + } + if local != remote { + log.phase(&format!("git pull origin/{branch}…")).await; + if let Err(e) = run_step( + &b.probe, + &log, + Some(&repo), + "git", + &["pull", "--ff-only", "origin", &branch], + ) + .await + { + b.jobs + .fail( + &ctx.store, + &format!("git pull a échoué (modifs locales ?) : {e}"), + ) + .await; + return; + } + } + let new = b.git(&repo, &["rev-parse", "--short", "HEAD"]).await; + b.jobs.set_commits(None, Some(&new)).await; + // Le binaire en service ne se réécrit pas : le moteur s'arrête le temps + // du build, et repart après, même en échec. + let (svc_was_up, err) = service_stop_if_up(ctx).await; + if svc_was_up { + log.phase("arrêt du service le temps du build…").await; + if !err.is_empty() { + log.line(&format!("[warn] impossible d'arrêter le service : {err}")) + .await; + } + } + let ok = build_and_switch(ctx, b, &log, &repo, clean).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; + } + } + if !ok { + return; + } + let msg = if old == new { + format!("recompilé ({new})") + } else { + format!("mis à jour : {old} → {new}") + }; + b.jobs.done(&ctx.store, &msg).await; +} + +/// `looksLikeGitURL`. +pub fn looks_like_git_url(u: &str) -> bool { + ["https://", "http://", "git@", "ssh://", "git://"] + .iter() + .any(|p| u.starts_with(p)) +} + +/// `deriveBackendName` : le nom du dépôt, suffixé du propriétaire pour un +/// fork nommé « llama.cpp ». +pub fn derive_backend_name(url: &str) -> String { + let s = url.trim(); + let s = s.strip_suffix(".git").unwrap_or(s); + let s = s.trim_end_matches('/'); + let parts: Vec<&str> = s.split(['/', ':']).filter(|p| !p.is_empty()).collect(); + let (repo, owner) = match parts.len() { + 0 => ("", ""), + 1 => (parts[0], ""), + n => (parts[n - 1], parts[n - 2]), + }; + let mut name = if repo.is_empty() { + "backend".to_string() + } else { + repo.to_string() + }; + if name.eq_ignore_ascii_case("llama.cpp") && !owner.is_empty() { + name = format!("{name}-{owner}"); + } + sanitize_backend_name(&name) +} + +/// `installCustomBackend` : clone (ou met à jour) un fork dans +/// `backends/` et le compile, sans toucher à BIN ; rend le binaire. +pub async fn install_custom( + b: &Backends, + log: &Log<'_>, + url: &str, + name: &str, + git_ref: &str, +) -> Result { + let url = url.trim(); + if url.is_empty() { + return Err("URL du dépôt vide".into()); + } + if !looks_like_git_url(url) { + return Err(format!( + "URL de dépôt invalide (attendu https://…, git@… ou ssh://…) : {url}" + )); + } + let name = if name.trim().is_empty() { + derive_backend_name(url) + } else { + sanitize_backend_name(name) + }; + // `backendDir` : le nom assaini ne sort jamais de backends/. + let dir = std::path::absolute(b.backends_dir()) + .map_err(|e| e.to_string())? + .join(&name); + log.phase("vérification des outils (git, cmake, compilateur)…") + .await; + require_tools(&b.probe, &["git", "cmake"]).await?; + ensure_compiler(&b.probe).await?; + let probe = &b.probe; + if is_dir(&dir.join(".git")) { + log.phase("dépôt déjà présent — mise à jour…").await; + let _ = run_step( + probe, + log, + Some(&dir), + "git", + &["fetch", "origin", "--quiet"], + ) + .await; + if !git_ref.is_empty() { + run_step(probe, log, Some(&dir), "git", &["checkout", git_ref]).await?; + } else { + let branch = b.git(&dir, &["rev-parse", "--abbrev-ref", "HEAD"]).await; + if !branch.is_empty() && branch != "HEAD" { + let _ = run_step( + probe, + log, + Some(&dir), + "git", + &["pull", "--ff-only", "origin", &branch], + ) + .await; + } + } + } else { + if let Some(parent) = dir.parent() { + std::fs::create_dir_all(parent) + .map_err(|e| crate::paths::os_err("mkdir", &parent.to_string_lossy(), &e))?; + } + log.phase(&format!("clone de {url}…")).await; + let dir_s = dir.to_string_lossy().into_owned(); + run_step( + probe, + log, + None, + "git", + &["clone", "--depth=1", url, &dir_s], + ) + .await + .map_err(|e| format!("git clone a échoué : {e}"))?; + if !git_ref.is_empty() { + let _ = run_step( + probe, + log, + Some(&dir), + "git", + &["fetch", "--unshallow", "origin"], + ) + .await; + run_step(probe, log, Some(&dir), "git", &["checkout", git_ref]).await?; + } + } + let plan = probe.build_plan().await; + log.phase(&format!("compilation (backend={})…", plan.backend)) + .await; + build_llamacpp(probe, log, &dir, &plan, false).await?; + let bin = llama_server_bin(&dir); + if bin.is_empty() { + return Err(format!( + "build terminé mais binaire introuvable sous {}", + dir.join("build").display() + )); + } + Ok(bin) +} + +/// `lcRunCustomInstall` : un backend custom, sans bascule de BIN (il se +/// choisit par preset). +pub async fn run_custom(ctx: &Ctx, b: &Backends, url: &str, name: &str, git_ref: &str) { + let log = Log { + jobs: &b.jobs, + store: &ctx.store, + }; + match install_custom(b, &log, url, name, git_ref).await { + Err(e) => b.jobs.fail(&ctx.store, &e).await, + Ok(bin) => { + log.line(&format!("binaire compilé : {bin}")).await; + log.line("→ pour l'utiliser : édite un modèle → section Moteur → « backend détecté » et choisis-le.") + .await; + b.jobs.done(&ctx.store, "backend custom installé").await; + } + } +} + +#[cfg(test)] +mod tests; diff --git a/rust/loki-rs/src/backends/build/tests.rs b/rust/loki-rs/src/backends/build/tests.rs new file mode 100644 index 0000000..2be2bf4 --- /dev/null +++ b/rust/loki-rs/src/backends/build/tests.rs @@ -0,0 +1,285 @@ +use super::*; + +fn feed_all(sp: &mut Splitter, writes: &[&[u8]]) -> Vec { + let mut got = Vec::new(); + for w in writes { + sp.feed(w, &mut |l| got.push(l)); + } + sp.finish(&mut |l| got.push(l)); + got +} + +/// `TestSinkWriterProgression` : une barre (\r sans \n) apparaît sans noyer +/// le journal ; \r\n reste une fin de ligne, même coupé entre deux écritures. +#[test] +fn barre_de_progression() { + let mut sp = Splitter::new(true); + let mut got = Vec::new(); + sp.feed(b"Step 5\r", &mut |l| got.push(l)); + sp.feed( + "\n modèle : 1 / 76 GB\r modèle : 2 / 76 GB\r".as_bytes(), + &mut |l| got.push(l), + ); + sp.feed(" modèle : 3 / 76 GB\r".as_bytes(), &mut |l| got.push(l)); + sp.last_prog = Some(Instant::now() - Duration::from_secs(60)); + sp.feed(" modèle : 40 / 76 GB\r[ok] fini\n".as_bytes(), &mut |l| { + got.push(l) + }); + // Un reste sans fin de ligne est perdu, comme au Go. + sp.feed(b"reste", &mut |l| got.push(l)); + sp.finish(&mut |l| got.push(l)); + assert_eq!( + got, + [ + "Step 5", + "modèle : 1 / 76 GB", + "modèle : 3 / 76 GB", + "[ok] fini" + ] + ); +} + +/// Le scanner : \n seul coupe, \r final retiré, dernier morceau rendu, +/// octets invalides en U+FFFD un à un. +#[test] +fn scanner_de_lignes() { + let mut sp = Splitter::new(false); + let got = feed_all(&mut sp, &[b"a\r\nb\rc\n", b"d", b"\xff\xfee\n", b"fin\r"]); + assert_eq!(got, ["a", "b\rc", "d\u{fffd}\u{fffd}e", "fin"]); + let mut sp = Splitter::new(false); + let long = vec![b'x'; MAX_LINE + 1]; + let got = feed_all(&mut sp, &[b"ok\n", &long, b"\napres\n"]); + assert_eq!(got, ["ok"]); +} + +/// `TestCudaToolkitRoot`. +#[test] +fn racine_du_toolkit() { + for (nvcc, want) in [ + ("/usr/local/cuda/bin/nvcc", "/usr/local/cuda"), + ("/usr/local/cuda-12.8/bin/nvcc", "/usr/local/cuda-12.8"), + ("/usr/bin/nvcc", ""), + ("/opt/nvcc", ""), + ("/bin/nvcc", ""), + ("bin/nvcc", ""), + ("", ""), + ] { + assert_eq!(cuda_toolkit_root(Path::new(nvcc)), want, "{nvcc}"); + } +} + +#[test] +fn cache_deplace() { + let d = tempfile::tempdir().unwrap(); + let repo = d.path().join("llama.cpp"); + let build = repo.join("build"); + std::fs::create_dir_all(&build).unwrap(); + assert!(!cache_stale(&build, &repo), "sans cache"); + let cache = |home: &str| { + std::fs::write( + build.join("CMakeCache.txt"), + format!("# x\nCMAKE_HOME_DIRECTORY:INTERNAL={home} \nAUTRE=1\n"), + ) + .unwrap() + }; + cache(&repo.to_string_lossy()); + assert!(!cache_stale(&build, &repo), "même dossier"); + cache("/ailleurs/llama.cpp"); + assert!(cache_stale(&build, &repo), "dépôt déplacé"); + cache(""); + assert!(!cache_stale(&build, &repo), "chemin vide"); + std::fs::write( + build.join("CMakeCache.txt"), + "CMAKE_HOME_DIRECTORY:INTERNAL\n", + ) + .unwrap(); + assert!(!cache_stale(&build, &repo), "sans ="); +} + +#[test] +fn noms_et_urls() { + for (url, want) in [ + ("https://github.com/prism/llama.cpp.git", "llama.cpp-prism"), + ("git@github.com:prism/llama.cpp.git", "llama.cpp-prism"), + ("https://github.com/ikawrakow/ik_llama.cpp/", "ik_llama.cpp"), + (" https://github.com/Owner/LLAMA.CPP ", "llama.cpp-owner"), + ("llama.cpp", "llama.cpp"), + ("https://", "https"), + ("", "backend"), + ("/", "backend"), + ] { + assert_eq!(derive_backend_name(url), want, "{url}"); + } + for (url, ok) in [ + ("https://x", true), + ("http://x", true), + ("git@x:y", true), + ("ssh://x", true), + ("git://x", true), + ("ftp://x", false), + ("/chemin/local", false), + (" https://x", false), + ] { + assert_eq!(looks_like_git_url(url), ok, "{url}"); + } +} + +/// Le plan complet par machine (faux outils, fausse racine). +#[cfg(target_os = "linux")] +#[tokio::test] +async fn plan_par_machine() { + let tools = tempfile::tempdir().unwrap(); + let root = tempfile::tempdir().unwrap(); + let probe = Probe { + path: Some(vec![tools.path().to_path_buf()]), + root: root.path().to_path_buf(), + }; + let script = |name: &str, body: &str| { + write_script(&tools.path().join(name), &format!("#!/bin/sh\n{body}\n")); + }; + let base = [ + "-DCMAKE_BUILD_TYPE=Release", + "-DGGML_NATIVE=ON", + "-DLLAMA_BUILD_UI=OFF", + "-DLLAMA_USE_PREBUILT_UI=OFF", + ]; + let p = probe.build_plan().await; + assert_eq!( + (p.backend, p.flags.clone(), p.cuda_cxx.as_str()), + ("cpu", base.map(String::from).to_vec(), "") + ); + assert!(p.jobs >= 1); + + // nvcc dans le PATH seulement (racine /usr : rien à apprendre à CMake). + script("nvcc", ""); + script( + "nvidia-smi", + r#"case "$1" in -L) echo "GPU 0: RTX";; --query-gpu=compute_cap) printf '8.6\n8.9\n8.6\n';; esac"#, + ); + let p = probe.build_plan().await; + let nvcc = tools.path().join("nvcc").to_string_lossy().into_owned(); + let mut want: Vec = base.map(String::from).to_vec(); + want.extend(["-DGGML_CUDA=ON".into(), "-DGGML_CUDA_F16=ON".into()]); + want.push("-DCMAKE_CUDA_ARCHITECTURES=86;89".into()); + assert_eq!((p.backend, p.cuda_arch.as_str()), ("cuda", "86;89")); + // Le dossier des outils ne s'appelle pas bin : pas de racine. + assert_eq!(p.flags, want); + assert_eq!(p.cuda_cxx, nvcc); + + // Un vrai toolkit l'emporte, racine explicite. + let tk = root.path().join("usr/local/cuda-12.8/bin"); + std::fs::create_dir_all(&tk).unwrap(); + std::fs::write(tk.join("nvcc"), "").unwrap(); + let p = probe.build_plan().await; + let tk_nvcc = tk.join("nvcc").to_string_lossy().into_owned(); + let tk_root = root + .path() + .join("usr/local/cuda-12.8") + .to_string_lossy() + .into_owned(); + let mut want: Vec = base.map(String::from).to_vec(); + want.extend([ + "-DGGML_CUDA=ON".into(), + "-DGGML_CUDA_F16=ON".into(), + format!("-DCUDAToolkit_ROOT={tk_root}"), + format!("-DCMAKE_CUDA_COMPILER={tk_nvcc}"), + "-DCMAKE_CUDA_ARCHITECTURES=86;89".into(), + ]); + assert_eq!(p.flags, want); + assert_eq!(p.cuda_cxx, tk_nvcc); + + // Sans compute_cap : pas d'architecture imposée. + script("nvidia-smi", r#"[ "$1" = -L ] && echo "GPU 0: RTX""#); + let p = probe.build_plan().await; + assert!( + !p.flags + .iter() + .any(|f| f.starts_with("-DCMAKE_CUDA_ARCHITECTURES")) + ); + assert_eq!(p.cuda_arch, ""); + + // HIP, puis Vulkan. + std::fs::remove_file(tools.path().join("nvidia-smi")).unwrap(); + script("hipcc", ""); + let p = probe.build_plan().await; + assert_eq!( + (p.backend, p.flags.last().unwrap().as_str()), + ("hip", "-DGGML_HIP=ON") + ); + std::fs::remove_file(tools.path().join("hipcc")).unwrap(); + script("glslc", ""); + script("vulkaninfo", ""); + let p = probe.build_plan().await; + assert_eq!( + (p.backend, p.flags.last().unwrap().as_str()), + ("vulkan", "-DGGML_VULKAN=ON") + ); +} + +/// Les étapes suivies : sortie des deux flux dans l'ordre, journal écrit, +/// erreurs au format d'`exec`. +#[cfg(unix)] +#[tokio::test] +async fn erreurs_des_commandes() { + let tools = tempfile::tempdir().unwrap(); + let probe = Probe { + path: Some(vec![tools.path().to_path_buf()]), + root: tools.path().to_path_buf(), + }; + let p = tools.path().join("outil"); + 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"); + let r = run_lines_with( + &probe, + Some(tools.path()), + &[("X".into(), "1".into())], + "outil", + &args(&["3"]), + false, + Some(&log), + |l| got.push(l), + ) + .await; + assert_eq!(r, Err("exit status 3".into())); + assert_eq!(got, ["un", "deux", "troisX=1"]); + assert_eq!( + std::fs::read_to_string(&log).unwrap(), + "un\ndeux\ntroisX=1\n" + ); + let mut got = Vec::new(); + let r = run_lines_with(&probe, None, &[], "outil", &args(&["0"]), true, None, |l| { + got.push(l) + }) + .await; + assert_eq!(r, Ok(())); + assert_eq!(got, ["un", "deux"]); + let r = run_lines_with(&probe, None, &[], "absent", &[], true, None, |_| {}).await; + assert_eq!( + r, + Err("exec: \"absent\": executable file not found in $PATH".into()) + ); + 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/src/backends/devices.rs b/rust/loki-rs/src/backends/devices.rs new file mode 100644 index 0000000..7061427 --- /dev/null +++ b/rust/loki-rs/src/backends/devices.rs @@ -0,0 +1,338 @@ +//! `/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"); + } + c.stdin(Stdio::null()).kill_on_drop(true); + combined(c, bin, Some(LIST_TIMEOUT)).await +} + +/// `cmd.CombinedOutput()` : sorties standard et d'erreur dans le même tube ; +/// l'erreur au format d'`exec` (`exit status N`, `signal: killed`, +/// `fork/exec …`). `limit` : délai au-delà duquel le processus est tué. +pub async fn combined( + mut c: tokio::process::Command, + bin: &str, + limit: Option, +) -> (String, Option) { + // 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.stdout(Stdio::from(writer)).stderr(Stdio::from(writer2)); + 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 = match limit { + None => child.wait().await, + Some(d) => match tokio::time::timeout(d, child.wait()).await { + 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..4a2212b --- /dev/null +++ b/rust/loki-rs/src/backends/mod.rs @@ -0,0 +1,1024 @@ +//! 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`]) : les siens +//! (binaires précompilés ; compilation depuis les sources — install, update, +//! install-custom — sous Linux et macOS depuis le lot 41, [`build`]) et ceux +//! que le Go fait encore tourner — compilation sous Windows, moteur de +//! l'image, Strata — qui lui rapportent chaque étape par l'API interne +//! (`/internal/facade/v1/build-job/*`). + +pub mod build; +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/"; + +const INSTALL_REQ_TYPE: &str = r#"struct { Force bool "json:\"force\"" }"#; +const UPDATE_REQ_TYPE: &str = r#"struct { Clean bool "json:\"clean\"" }"#; +const CUSTOM_REQ_TYPE: &str = r#"struct { Repo string "json:\"repo\""; Name string "json:\"name\""; Ref string "json:\"ref\"" }"#; +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`. + pub(crate) 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. +pub(crate) 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). +pub(crate) 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), + } +} + +/// Arrête le moteur s'il tourne : (il tournait, erreur de l'arrêt). +pub(crate) async fn service_stop_if_up(ctx: &Ctx) -> (bool, String) { + if ctx.engine_ctl.is_some() { + if crate::enginectl::service_is_active(ctx) { + return service(ctx, "stop", false).await; + } + return (false, String::new()); + } + // Côté Go, « arrête-le s'il tourne » dit aussi s'il tournait. + service(ctx, "stop", true).await +} + +/// 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" + ) || (cfg!(unix) && is_build_route(path)) +} + +/// Les routes de compilation depuis les sources (lot 41 ; Unix seulement, +/// la chaîne MSVC de Windows reste au Go). +fn is_build_route(path: &str) -> bool { + matches!( + path, + "/api/llamacpp/install" | "/api/llamacpp/update" | "/api/llamacpp/install-custom" + ) +} + +/// 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, + p if cfg!(unix) && is_build_route(p) => start_build(ctx, b, p, &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"), + } +} + +/// `handleLlamacppInstall`, `handleLlamacppUpdate`, +/// `handleLlamacppInstallCustom` : le job de compilation lancé. +async fn start_build(ctx: &Ctx, b: &Arc, path: &str, raw: &[u8]) -> Response { + let provided = b.provided_engine_bin(); + if !provided.is_empty() { + return fail( + StatusCode::CONFLICT, + format!( + "Le moteur est fourni par l'image Docker ({provided}) : il n'y a rien à compiler ici, \ + et l'image ne contient ni cmake ni compilateur. Pour le mettre à jour, utilise \ + « mettre à jour le moteur » dans Réglages → Moteur." + ), + ); + } + enum Run { + Install(bool), + Update(bool), + Custom(String, String, String), + } + let (action, run) = match path { + "/api/llamacpp/install" => { + let d = gojson::decode_struct(raw, INSTALL_REQ_TYPE, &[("force", Kind::Bool)]); + ("install", Run::Install(d.bool("force"))) + } + "/api/llamacpp/update" => { + let d = gojson::decode_struct(raw, UPDATE_REQ_TYPE, &[("clean", Kind::Bool)]); + ("update", Run::Update(d.bool("clean"))) + } + _ => { + let d = gojson::decode_struct( + raw, + CUSTOM_REQ_TYPE, + &[("repo", Kind::Str), ("name", Kind::Str), ("ref", Kind::Str)], + ); + if d.str("repo").trim().is_empty() { + return fail(StatusCode::BAD_REQUEST, "URL du dépôt requise"); + } + ( + "custom", + Run::Custom(d.str("repo"), d.str("name"), d.str("ref")), + ) + } + }; + if let Some(e) = tune_busy(&ctx.store).await { + return fail(StatusCode::CONFLICT, e); + } + if let Err(e) = b.jobs.start(&ctx.store, action).await { + return fail(StatusCode::CONFLICT, e); + } + let (b2, ctx2) = (b.clone(), ctx.clone()); + tokio::spawn(async move { + match run { + Run::Install(force) => build::run_install(&ctx2, &b2, force).await, + Run::Update(clean) => build::run_update(&ctx2, &b2, clean).await, + Run::Custom(url, name, r) => build::run_custom(&ctx2, &b2, &url, &name, &r).await, + } + b2.jobs.end(&ctx2.store).await; + }); + send_json(StatusCode::OK, &json!({ "ok": true })) +} + +/// 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) = service_stop_if_up(ctx).await; + if svc_was_up { + log.phase("arrêt du service le temps de l'installation…") + .await; + } + 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. +async fn download( + b: &Backends, + a: &prebuilt::Asset, + dest: &Path, + log: &Log<'_>, +) -> Result<(), String> { + let rq = b + .client + .get(&a.url) + .header("User-Agent", "Go-http-client/1.1"); + log_download(log, rq, dest, a.size).await +} + +/// Un téléchargement dont la progression part au journal du job au fil de +/// l'eau, dans l'ordre. +pub async fn log_download( + log: &Log<'_>, + rq: reqwest::RequestBuilder, + dest: &Path, + total: i64, +) -> 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_request(rq, dest, total, &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..4786d40 --- /dev/null +++ b/rust/loki-rs/src/backends/prebuilt.rs @@ -0,0 +1,566 @@ +//! 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 rq = client.get(url).header("User-Agent", "Go-http-client/1.1"); + download_request(rq, dest, total, logf).await +} + +/// `downloadRequest` : la même chose pour une requête déjà construite — un +/// registre OCI exige un en-tête d'autorisation, même en lecture anonyme. +pub async fn download_request( + rq: reqwest::RequestBuilder, + dest: &Path, + total: i64, + logf: &mut (dyn FnMut(String) + Send), +) -> Result<(), String> { + let resp = rq.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. +pub(crate) 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/bench/corpus.txt b/rust/loki-rs/src/bench/corpus.txt new file mode 100644 index 0000000..8a61c6f --- /dev/null +++ b/rust/loki-rs/src/bench/corpus.txt @@ -0,0 +1,10 @@ +In the early hours of an October morning, Camille walked along the canal, watching the cargo barges slip past the iron bridge that spanned the water. She thought about the meeting she had skipped, the unanswered messages on her phone, the way the city always seemed to forget her name after summer ended. Three streets away, a pâtisserie opened its shutters and the smell of warm butter mixed with diesel exhaust from the waiting bus. +Pendant ce temps, à Marseille, un chercheur en biologie marine prépare son matériel pour une plongée. Il étudie les herbiers de posidonie, ces prairies sous-marines vieilles de plusieurs milliers d'années qui stockent autant de carbone qu'une forêt amazonienne. Le bateau quitte le port à six heures vingt-trois. +Quantum computers, properly engineered, can solve certain classes of problems exponentially faster than classical machines. The catch is that decoherence ruins everything. Engineers use dilution refrigerators to drop superconducting qubits to fifteen millikelvin, colder than deep space. The wires connecting the chip to room-temperature electronics must dissipate almost no heat, or the qubit state collapses before any useful computation finishes. +Le boulanger lève la pâte à quatre heures. Il regarde la balance numérique en plissant les yeux : six cent vingt-trois grammes, presque le compte. Son chien dort sur le tapis de farine près du four. Dehors, deux chats se disputent un poisson abandonné par le pêcheur de nuit. +Consider a recursive descent parser written in Go. The lexer emits tokens; the parser consumes them and produces an abstract syntax tree. Error recovery is hard: after a syntax error, the parser must resynchronize at a known boundary—a semicolon, a closing brace—without losing track of subsequent diagnostics. Tree-sitter solves this with incremental parsing and a glr-like algorithm. +Le philosophe stoïcien disait : "Ce qui nous trouble, ce n'est pas ce qui nous arrive, mais l'opinion que nous nous en faisons." Vingt siècles plus tard, la phrase apparaît dans un livre de poche au rayon développement personnel d'une librairie d'aéroport, à côté d'un roman policier suédois. +Mitochondria descended from ancient bacteria engulfed by archaeal cells roughly two billion years ago. They still keep their own ring of DNA, separate from the nuclear genome. Mutations in mitochondrial DNA accumulate with age and have been implicated in everything from Parkinson's disease to ordinary muscle fatigue. Yet they remain stubbornly difficult to repair therapeutically because each cell contains hundreds. +La marée descend lentement, exposant des rochers couverts d'huîtres et d'algues vertes. Un héron immobile surveille les flaques laissées par l'eau. Plus loin, deux enfants courent avec un cerf-volant rouge qui refuse de monter à cause de l'humidité dans la voile. +Compilers translate high-level languages into machine code through several intermediate representations. LLVM IR sits in the middle: typed, mostly static-single-assignment, suitable for both aggressive optimization and direct lowering to x86 or ARM. The optimizer runs dozens of passes—dead code elimination, loop-invariant code motion, induction variable simplification—each touching the IR in carefully ordered ways. +Le cuisinier ferme les yeux pour goûter la sauce. Trop salée. Il ajoute une pomme de terre crue coupée en quartiers, sachant qu'elle absorbera l'excès en mijotant vingt minutes. Sa grand-mère lui a appris ce geste un dimanche de novembre il y a très longtemps. diff --git a/rust/loki-rs/src/bench/job.rs b/rust/loki-rs/src/bench/job.rs new file mode 100644 index 0000000..8f0dc39 --- /dev/null +++ b/rust/loki-rs/src/bench/job.rs @@ -0,0 +1,684 @@ +//! Le banc d'essai en tâche de fond (`llm_bench_job.go`), ses quatre routes +//! et l'enregistrement des mesures (`llm_bench.go`, « enregistrement ») : +//! +//! - `POST /api/bench` lance (202 + id) ; refus clairs avant de lancer ; +//! - `GET /api/bench/status` : la phase en cours, puis le résultat ou l'erreur ; +//! - `POST /api/bench/cancel` annule ; +//! - `GET /api/bench/last` : la dernière mesure enregistrée. +//! +//! Pendant la mesure, le bench tient le verrou de génération du Go par le +//! bail du moteur, motif « bench » : un message, une tâche ou un client /v1 +//! reçoivent le refus du bench. Un stop du chat (ou un Reset) côté Go +//! interrompt le bail : le bench est annulé. + +use std::collections::{BTreeMap, HashMap}; +use std::path::PathBuf; +use std::sync::atomic::{AtomicU64, Ordering}; +use std::sync::{Arc, Mutex}; +use std::time::{Duration, Instant}; + +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::sync::watch; + +use super::{BenchResult, Corpora, Engine, Opts, Setup}; +use crate::api::Ctx; +use crate::auth::json as send_json; +use crate::chat::lease::EngineLease; +use crate::chat::wire::{EffortFallbacks, log_effort_fallback}; +use crate::gojson::{self, Kind}; +use crate::proxy::Body; +use crate::store::BK_STATE; + +type Cfg = HashMap; + +const LAST_KEY: &str = "last_bench"; +const PRESETS_KEY: &str = "bench_presets"; +const BENCH_REQ: &str = + r#"struct { Mode string "json:\"mode\""; Prompt int "json:\"prompt\""; N int "json:\"n\"" }"#; + +/// Le job : le bench en cours ou le dernier terminé (un seul à la fois). +#[derive(Default)] +struct JobState { + seq: i64, + running: bool, + /// Lancement en cours (le bail se prend hors du verrou). + starting: bool, + id: String, + mode: String, + phase: String, + step: i64, + steps: i64, + started: Option, + finished: Option, + cancel: Option>, + result: Option, + err: String, + canceled: bool, +} + +/// Le banc d'essai tenu par la façade. +pub struct Bench { + pub home: PathBuf, + state: Mutex, + /// Budget d'une requête de chat (`benchReqTimeout`). + pub req_timeout: Mutex, + /// Durée du bail du moteur (renouvelé au tiers). + pub lease_ttl: Duration, + /// Niveaux de raisonnement appris quand la façade ne tient pas la + /// conversation (sinon, ceux de sa conversation). + effort: EffortFallbacks, + client: reqwest::Client, + /// Le service du moteur (PID, pour l'indication « possible thrash »). + engine: crate::engine::Paths, +} + +impl Bench { + pub fn new(engine: crate::engine::Paths) -> Self { + Bench { + home: engine.home.clone(), + state: Mutex::new(JobState::default()), + req_timeout: Mutex::new(super::REQ_TIMEOUT), + lease_ttl: Duration::from_secs(30), + effort: EffortFallbacks::new(), + client: { + crate::chat::engine::install_crypto(); + reqwest::Client::builder().build().unwrap_or_default() + }, + engine, + } + } + + fn lock(&self) -> std::sync::MutexGuard<'_, JobState> { + self.state.lock().unwrap_or_else(|e| e.into_inner()) + } + + /// `benchRunning`. + pub fn running(&self) -> bool { + self.lock().running + } + + /// `benchCancel` : false si aucun ne tournait. + pub fn cancel(&self) -> bool { + let st = self.lock(); + match (&st.cancel, st.running) { + (Some(c), true) => { + let _ = c.send(true); + true + } + _ => false, + } + } + + /// `benchStatus`. + pub fn status(&self) -> Value { + let st = self.lock(); + let mut out = json!({ "ok": true, "running": st.running, "id": st.id, "mode": st.mode }); + if st.id.is_empty() { + return out; + } + let o = out.as_object_mut().expect("objet"); + o.insert("phase".into(), json!(st.phase)); + o.insert("step".into(), json!(st.step)); + o.insert("steps".into(), json!(st.steps)); + let end = if st.running { + Some(Instant::now()) + } else { + st.finished + }; + let elapsed = match (st.started, end) { + (Some(s), Some(e)) => e.saturating_duration_since(s).as_secs_f64(), + _ => 0.0, + }; + o.insert("elapsed_sec".into(), json!(elapsed)); + if !st.running { + if let Some(r) = &st.result { + o.insert( + "result".into(), + serde_json::to_value(r).unwrap_or(Value::Null), + ); + o.insert("saved".into(), json!(savable(r))); + } + if !st.err.is_empty() { + o.insert("error".into(), json!(st.err)); + o.insert("canceled".into(), json!(st.canceled)); + } + } + out + } + + /// `benchStart` : le statut HTTP et la réponse. + pub async fn start(self: &Arc, ctx: &Ctx, opts: Opts) -> (StatusCode, Value) { + let store = &ctx.store; + let fail = |code: StatusCode, e: &str| (code, json!({ "ok": false, "error": e })); + let cfg = store.config().await; + if crate::engstatus::is_external(&cfg) { + return fail( + StatusCode::BAD_REQUEST, + "benchmark indisponible : le preset actif est une API externe", + ); + } + if let Some(e) = crate::backends::tune_busy(store).await { + return fail(StatusCode::CONFLICT, &e); + } + if !crate::engstatus::health_check(&cfg).await { + return fail( + StatusCode::SERVICE_UNAVAILABLE, + crate::tasks::ERR_MODEL_LOADING, + ); + } + { + let mut st = self.lock(); + if st.running || st.starting { + return fail(StatusCode::CONFLICT, "un benchmark tourne déjà"); + } + st.starting = true; + } + let lease = match EngineLease::acquire_for(store.clone(), self.lease_ttl, "bench").await { + Ok(l) => l, + Err(e) => { + self.lock().starting = false; + return fail(StatusCode::CONFLICT, &e.to_string()); + } + }; + let (tx, rx) = watch::channel(false); + let id = { + let mut st = self.lock(); + st.seq += 1; + let id = format!("b{}-{}", unix_secs(), st.seq); + *st = JobState { + seq: st.seq, + running: true, + starting: false, + id: id.clone(), + mode: opts.mode.clone(), + phase: "démarrage".into(), + step: 0, + steps: 0, + started: Some(Instant::now()), + finished: None, + cancel: Some(tx), + result: None, + err: String::new(), + canceled: false, + }; + id + }; + let (me, ctx2, id2) = (self.clone(), ctx.clone(), id.clone()); + tokio::spawn(async move { me.job(ctx2, id2, opts, rx, lease).await }); + (StatusCode::ACCEPTED, json!({ "ok": true, "id": id })) + } + + /// Le bench en tâche de fond : le verrou est rendu AVANT d'annoncer la + /// fin — l'interface qui lit « terminé » et envoie aussitôt un message ne + /// doit pas se voir refuser « benchmark en cours ». + async fn job( + self: Arc, + ctx: Ctx, + id: String, + opts: Opts, + rx: watch::Receiver, + mut lease: EngineLease, + ) { + let me = self.clone(); + let id2 = id.clone(); + let progress = move |phase: &str, step: i64, steps: i64| { + let mut st = me.lock(); + if st.id == id2 { + st.phase = phase.to_string(); + st.step = step; + st.steps = steps; + } + }; + let run = self.run_bench(&ctx, opts, rx.clone(), &progress); + tokio::pin!(run); + let mut interrupted = false; + let res = loop { + tokio::select! { + r = &mut run => break r, + _ = lease.interrupted(), if !interrupted => { + // Stop du chat, Reset ou bail perdu côté Go : le bench + // s'arrête (le verrou n'est plus, ou ne sera plus, à lui). + interrupted = true; + if let Some(c) = &self.lock().cancel { + let _ = c.send(true); + } + } + } + }; + let canceled = *rx.borrow(); + lease.release().await; + let mut st = self.lock(); + if st.id != id { + return; + } + st.running = false; + st.finished = Some(Instant::now()); + st.cancel = None; + match res { + Ok(r) => st.result = Some(r), + Err(e) => { + st.result = None; + st.canceled = canceled; + st.err = if canceled { + "benchmark annulé".into() + } else { + e + }; + } + } + } + + /// `runBench` : mesure le moteur local et enregistre le résultat s'il + /// est complet. + async fn run_bench( + &self, + ctx: &Ctx, + opts: Opts, + cancel: watch::Receiver, + progress: &(dyn Fn(&str, i64, i64) + Send + Sync), + ) -> Result { + let store = &ctx.store; + let cfg = store.config().await; + if crate::engstatus::is_external(&cfg) { + return Err("benchmark indisponible : le preset actif est une API externe".into()); + } + if crate::strata::is_strata(&cfg) { + return Err("benchmark indisponible : le preset actif tourne sur Strata (le débit s'affiche sous chaque réponse)".into()); + } + if let Some(e) = crate::backends::tune_busy(store).await { + return Err(e); + } + let port = crate::engstatus::llm_port_raw(&cfg); + if !crate::engstatus::health_check(&cfg).await { + return Err(format!("serveur injoignable sur :{port}")); + } + let arg_env: HashMap = super::ARG_ENV + .iter() + .filter_map(|k| { + std::env::var(k) + .ok() + .filter(|v| !v.is_empty()) + .map(|v| (k.to_string(), v)) + }) + .collect(); + let effort: &EffortFallbacks = match &ctx.chat { + Some(c) => &c.shared.effort, + None => &self.effort, + }; + let mut setup: Setup = super::setup_from(&cfg, &arg_env, effort); + setup.hybrid = model_hybrid(ctx, &cfg).await; + let route = store + .internal_json( + Method::GET, + "/internal/v1/oai/route?method=POST&path=%2Fv1%2Fchat%2Fcompletions", + None, + ) + .await + .unwrap_or(Value::Null); + setup.side_slot = route.get("side_live").and_then(Value::as_bool) == Some(true); + let served = Arc::new(AtomicU64::new(0)); + let engine = Engine { + base: format!("http://localhost:{port}"), + key: route + .get("key") + .and_then(Value::as_str) + .unwrap_or("") + .to_string(), + client: self.client.clone(), + cancel, + req_timeout: *self.req_timeout.lock().unwrap_or_else(|e| e.into_inner()), + served: served.clone(), + }; + let cfg2 = cfg.clone(); + let learn = move |want: &str, got: &str| { + effort.remember(&cfg2, want, got); + log_effort_fallback(want, got); + }; + let paths = self.engine.clone(); + let io = move || engine_io_sample(&paths); + // Le bench prend le slot de la conversation : une fois fini, le Go + // l'efface si c'est sans risque (engineSideJob). + let side = store + .internal_post("/internal/v1/engine/sidejob", &json!({})) + .await + .ok() + .and_then(|(_, v)| v.get("id").and_then(Value::as_i64)); + let res = super::run( + &engine, + opts, + &mut setup, + &Corpora::default_set(), + progress, + &learn, + &io, + ) + .await; + if let Some(id) = side { + let _ = store + .internal_post( + "/internal/v1/engine/sidejob", + &json!({ "end": id, "served": served.load(Ordering::SeqCst) > 0 }), + ) + .await; + } + let res = res?; + let _ = store + .internal_post( + "/internal/v1/bench/perf", + &json!({ + "prompt_n": res.prompt_n, + "prompt_ms": res.prompt_ms, + "prompt_per_second": res.prompt_per_second, + "predicted_n": res.predicted_n, + "predicted_per_second": res.predicted_per_second, + }), + ) + .await; + if savable(&res) { + save_last(ctx, &res, &cfg).await; + self.save_for_active_preset(ctx, &res, &cfg).await; + } + Ok(res) + } + + /// `saveBenchForActivePreset` : sous l'id du preset actif ; sans effet si + /// aucun ne correspond. + async fn save_for_active_preset(&self, ctx: &Ctx, res: &BenchResult, cfg: &Cfg) { + let store = &ctx.store; + let order = crate::presets::parse_preset_order( + store + .get(BK_STATE, crate::presets::PRESET_ORDER_KEY) + .await + .ok() + .flatten() + .as_deref(), + ); + let Ok(list) = crate::presets::list_presets(&self.home, cfg, &order) else { + return; + }; + let Some(id) = list.into_iter().find(|p| p.active).map(|p| p.id) else { + return; + }; + let mut m = load_bench_store(ctx).await; + m.insert(id, saved_bench(res, cfg)); + let _ = store + .put(BK_STATE, PRESETS_KEY, gojson::marshal(&m).into_bytes()) + .await; + } +} + +fn unix_secs() -> i64 { + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .map_or(0, |d| d.as_secs() as i64) +} + +/// `benchSavable` : seul un résultat COMPLET est enregistré. +pub fn savable(res: &BenchResult) -> bool { + res.depth.as_ref().is_none_or(|d| d.partial.is_empty()) +} + +/// `savedBench`. +#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)] +pub struct Saved { + #[serde(default)] + pub result: BenchResult, + #[serde(default)] + pub model: String, + #[serde(default)] + pub at: i64, + #[serde(default, skip_serializing_if = "String::is_empty")] + pub fingerprint: String, +} + +/// `newSavedBench` : l'indication du moment (thrash) n'est pas enregistrée. +pub fn saved_bench(res: &BenchResult, cfg: &Cfg) -> Saved { + let mut r = res.clone(); + if let Some(d) = &mut r.depth { + d.hint.clear(); + } + Saved { + result: r, + model: crate::paths::go_base(cfg.get("MODEL").map(String::as_str).unwrap_or("")), + at: unix_secs(), + fingerprint: crate::presets_api::bench_fingerprint(cfg, cfg), + } +} + +/// `saveLastBench`. +async fn save_last(ctx: &Ctx, res: &BenchResult, cfg: &Cfg) { + let _ = ctx + .store + .put( + BK_STATE, + LAST_KEY, + gojson::marshal(&saved_bench(res, cfg)).into_bytes(), + ) + .await; +} + +/// `loadBenchStore` : les mesures par preset (vide s'il n'y en a pas). +async fn load_bench_store(ctx: &Ctx) -> BTreeMap { + ctx.store + .get(BK_STATE, PRESETS_KEY) + .await + .ok() + .flatten() + .and_then(|b| serde_json::from_slice::>>(&b).ok()) + .flatten() + .unwrap_or_default() +} + +/// `loadLastBench`. +async fn load_last(ctx: &Ctx) -> Option { + let b = ctx.store.get(BK_STATE, LAST_KEY).await.ok().flatten()?; + serde_json::from_slice(&b).ok() +} + +/// Le modèle du preset est-il hybride (reprise du cache aux points de +/// contrôle) ? +pub(crate) async fn model_hybrid(ctx: &Ctx, cfg: &Cfg) -> bool { + let m = cfg.get("MODEL").map(|s| s.trim()).unwrap_or(""); + if m.is_empty() { + return false; + } + let stored = crate::gguf::parse_stored_model_dirs( + ctx.store + .get(BK_STATE, crate::gguf::STATE_MODEL_DIRS_KEY) + .await + .ok() + .flatten() + .as_deref(), + ); + let env = std::env::var(crate::gguf::MODEL_DIRS_ENV).ok(); + let Ok(p) = crate::gguf::resolve_serve_model_path(m, &ctx.engine.home, &stored, env.as_deref()) + else { + return false; + }; + match crate::gguf::gguf_meta(&p) { + Ok(g) => crate::serve::ckpt::gguf_hybrid(Some(&g)), + Err(_) => false, + } +} + +/// `engineIOSample` : octets lus du disque et RSS du moteur ; `None` si le +/// PID ou /proc est illisible. +fn engine_io_sample(paths: &crate::engine::Paths) -> Option<(i64, i64)> { + let pid = crate::engine::read_pid(&paths.pid()); + if pid <= 0 { + return None; + } + let io = std::fs::read_to_string(format!("/proc/{pid}/io")).ok()?; + for line in io.split('\n') { + if let Some(v) = line.strip_prefix("read_bytes:") { + let n = v.trim().parse::().ok()?; + let rss = std::fs::read_to_string(format!("/proc/{pid}/status")) + .map(|s| crate::engstatus::parse_vm_rss(&s)) + .unwrap_or(0); + return Some((n, rss)); + } + } + None +} + +// --------------------------------------------------------------------------- +// Routes +// --------------------------------------------------------------------------- + +/// Les routes servies par ce module. +pub fn handles(path: &str) -> bool { + matches!( + path, + "/api/bench" | "/api/bench/status" | "/api/bench/cancel" | "/api/bench/last" + ) +} + +/// `postOnly`. +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( + hyper::header::ALLOW, + hyper::header::HeaderValue::from_static("POST"), + ); + Some(r) +} + +/// `from_go` : remise par le Go (tunnel), déjà authentifiée par lui. +pub async fn serve( + ctx: &Ctx, + bench: &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; + } + match parts.uri.path() { + "/api/bench" => { + 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(); + // Les erreurs de décodage sont ignorées (champs facultatifs). + let d = gojson::decode_struct( + &raw, + BENCH_REQ, + &[("mode", Kind::Str), ("prompt", Kind::Int), ("n", Kind::Int)], + ); + let mut opts = Opts { + mode: super::MODE_QUICK.into(), + prompt: 2000, + predict: 300, + depth: 0, + }; + if d.str("mode").trim().eq_ignore_ascii_case(super::MODE_FULL) { + opts.mode = super::MODE_FULL.into(); + } + if d.int("prompt") > 0 { + opts.prompt = d.int("prompt").min(32768); + } + if d.int("n") > 0 { + opts.predict = d.int("n").min(4096); + } + let (code, out) = bench.start(ctx, opts).await; + send_json(code, &out) + } + "/api/bench/status" => send_json(StatusCode::OK, &bench.status()), + "/api/bench/cancel" => { + if let Some(r) = post_only(&parts.method) { + return r; + } + send_json(StatusCode::OK, &json!({ "ok": bench.cancel() })) + } + "/api/bench/last" => match load_last(ctx).await { + None => send_json(StatusCode::OK, &json!({ "ok": false })), + Some(sb) => send_json( + StatusCode::OK, + &json!({ "ok": true, "result": sb.result, "model": sb.model, "at": sb.at }), + ), + }, + _ => send_json( + StatusCode::NOT_FOUND, + &json!({ "ok": false, "error": "route inconnue" }), + ), + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[cfg(target_os = "linux")] + #[test] + fn lecture_du_moteur() { + let tmp = tempfile::tempdir().unwrap(); + let paths = crate::engine::Paths { + home: tmp.path().to_path_buf(), + service: "loki-engine".into(), + }; + assert_eq!(engine_io_sample(&paths), None, "sans PID"); + std::fs::create_dir_all(paths.pid().parent().unwrap()).unwrap(); + std::fs::write(paths.pid(), "0\n").unwrap(); + assert_eq!(engine_io_sample(&paths), None, "PID nul"); + std::fs::write(paths.pid(), format!("{}\n", std::process::id())).unwrap(); + let (read, rss) = engine_io_sample(&paths).expect("ce processus"); + assert!(read >= 0 && rss > 0, "{read} {rss}"); + } + + #[test] + fn mesure_enregistree() { + let cfg: Cfg = [("MODEL", "/models/Q.gguf"), ("CTX", "32768")] + .iter() + .map(|(k, v)| (k.to_string(), v.to_string())) + .collect(); + let res = BenchResult { + prompt_per_second: 1.0, + depth: Some(super::super::Depth { + hint: "possible thrash".into(), + ..Default::default() + }), + ..Default::default() + }; + let sb = saved_bench(&res, &cfg); + assert_eq!(sb.model, "Q.gguf"); + assert!(!sb.fingerprint.is_empty()); + assert_eq!( + sb.fingerprint, + crate::presets_api::bench_fingerprint(&cfg, &cfg) + ); + assert_eq!( + sb.result.depth.as_ref().unwrap().hint, + "", + "l'indication ne s'enregistre pas" + ); + assert_eq!(res.depth.as_ref().unwrap().hint, "possible thrash"); + assert!(sb.at > 0); + assert_eq!(saved_bench(&res, &Cfg::new()).model, "."); + // Seul un résultat complet s'enregistre. + assert!(savable(&BenchResult::default())); + assert!(savable(&res)); + let partial = BenchResult { + depth: Some(super::super::Depth { + partial: "tour 1 interrompu".into(), + ..Default::default() + }), + ..Default::default() + }; + assert!(!savable(&partial)); + } +} diff --git a/rust/loki-rs/src/bench/mod.rs b/rust/loki-rs/src/bench/mod.rs new file mode 100644 index 0000000..956a042 --- /dev/null +++ b/rust/loki-rs/src/bench/mod.rs @@ -0,0 +1,1120 @@ +//! Banc d'essai tenu par la façade (lot 27 de la migration, `LOKI_BENCH=rust` +//! ; expérimental, le Go reste le défaut) — port de `llm_bench.go` et +//! `llm_bench_job.go`. +//! +//! Le bench mesure le moteur LOCAL tel que le chat s'en sert, sur +//! `/v1/chat/completions`, avec le gabarit, le raisonnement et +//! l'échantillonnage du preset : +//! +//! - « rapide » : un préchauffage jeté, puis la ligne courte (~2000 jetons de +//! prompt, ~300 générés) ; +//! - « complet » : la ligne courte, puis une discussion à profondeur réelle — +//! un prefill à froid de D jetons et trois tours qui y ajoutent chacun +//! ~2048 jetons neufs. La reprise du cache est LUE dans la réponse. +//! +//! Une réponse non-200 ou sans timings fait échouer la phase : rien n'est +//! inventé, rien n'est enregistré. Le job, les routes et l'enregistrement +//! sont dans [`job`]. + +pub mod job; + +use std::collections::HashMap; +use std::time::Duration; + +use serde::{Deserialize, Serialize}; +use serde_json::{Map, Value, json}; +use tokio::sync::watch; + +use crate::chat::wire::{ + EffortFallbacks, apply_sampling_from, effort_from_rejection, reasoning_effort_value, + reasoning_explicitly_off, reasoning_template_kwargs, +}; + +type Cfg = HashMap; + +/// `benchProtocol`. +pub const PROTOCOL: i64 = 2; +pub const MODE_QUICK: &str = "quick"; +pub const MODE_FULL: &str = "full"; + +const TURNS: i64 = 3; +const APPEND_TOKENS: i64 = 2048; +const GEN_TOKENS: i64 = 256; +const DEPTH_MAX: i64 = 32768; +const DEPTH_CPU: i64 = 16384; +const DEPTH_MIN: i64 = 4096; +const MARGIN: i64 = 512; +pub(crate) const WARM_GEN: i64 = 32; +const SEED: i64 = 1234; +const REUSE_SLACK: i64 = 16; +pub(crate) const SAMPLE_BYTES: usize = 160 << 10; +const FALLBACK_RATIO: f64 = 2.5; +const THRASH_BYTES: i64 = 512 << 20; + +/// `benchReqTimeout` : budget d'une requête. +pub const REQ_TIMEOUT: Duration = Duration::from_secs(6 * 60); + +/// `errBenchNoTimings`. +pub const ERR_NO_TIMINGS: &str = + "le moteur n'a pas renvoyé de mesures (timings) : rien à enregistrer"; + +/// `benchCorpus` : la prose de la ligne courte (le texte même du Go). +pub const CORPUS: &str = include_str!("corpus.txt"); + +const ASK_PROSE: &str = "\n\nContinue this passage with another 1000+ words of original varied prose, mixing French and English narrative paragraphs on different topics."; +pub(crate) const ASK_FIRST: &str = "Résume en quelques phrases le rôle de ce fichier."; +const ASK_NEXT: &str = + "Écris en JavaScript une fonction courte inspirée de cette partie, sans explication."; + +/// `benchArgEnv` : les LLAMA_ARG_* qui décident des types de cache et du +/// placement des poids. +pub const ARG_ENV: [&str; 6] = [ + "LLAMA_ARG_CACHE_TYPE_K", + "LLAMA_ARG_CACHE_TYPE_V", + "LLAMA_ARG_OVERRIDE_TENSOR", + "LLAMA_ARG_CPU_MOE", + "LLAMA_ARG_N_CPU_MOE", + "LLAMA_ARG_N_CPU_FFN", +]; + +/// Un flottant écrit comme le `json.Marshal` du Go : entier sans « .0 ». +pub fn ser_go_f64(v: &f64, s: S) -> Result { + if v.is_finite() && v.fract() == 0.0 && v.abs() < 1e15 { + s.serialize_i64(*v as i64) + } else { + s.serialize_f64(*v) + } +} + +fn is_zero_f(v: &f64) -> bool { + *v == 0.0 +} + +fn is_zero_i(v: &i64) -> bool { + *v == 0 +} + +fn is_false(v: &bool) -> bool { + !*v +} + +/// `benchDraft` : acceptation du brouillon. +#[derive(Clone, Copy, Debug, Default, PartialEq, Serialize, Deserialize)] +pub struct Draft { + #[serde(default)] + pub n: i64, + #[serde(default)] + pub accepted: i64, +} + +/// `(*benchDraft).add`. +pub fn draft_add(d: Option, o: Option) -> Option { + let Some(o) = o else { return d }; + let mut d = d.unwrap_or_default(); + d.n += o.n; + d.accepted += o.accepted; + Some(d) +} + +/// `benchTurn`. +#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)] +pub struct Turn { + #[serde(default)] + pub new: i64, + #[serde(default)] + pub cached: i64, + #[serde(default)] + pub expected: i64, + #[serde(default, serialize_with = "ser_go_f64")] + pub prompt_ms: f64, + #[serde(default, serialize_with = "ser_go_f64")] + pub prompt_per_second: f64, + #[serde(default)] + pub predicted_n: i64, + #[serde(default, serialize_with = "ser_go_f64")] + pub predicted_ms: f64, + #[serde(default, serialize_with = "ser_go_f64")] + pub predicted_per_second: f64, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub draft: Option, +} + +/// `benchDepth`. +#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)] +pub struct Depth { + #[serde(default)] + pub ctx: i64, + #[serde(default)] + pub target: i64, + #[serde(default, skip_serializing_if = "String::is_empty")] + pub skipped: String, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub cold: Option, + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub turns: Vec, + #[serde( + default, + skip_serializing_if = "is_zero_f", + serialize_with = "ser_go_f64" + )] + pub cached_per_second: f64, + #[serde( + default, + skip_serializing_if = "is_zero_f", + serialize_with = "ser_go_f64" + )] + pub decode_per_second: f64, + #[serde(default, serialize_with = "ser_go_f64")] + pub reuse: f64, + #[serde(default, skip_serializing_if = "String::is_empty")] + pub reuse_note: String, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub draft: Option, + #[serde(default, skip_serializing_if = "String::is_empty")] + pub partial: String, + #[serde(default, skip_serializing_if = "String::is_empty")] + pub hint: String, +} + +/// `benchResult`. +#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)] +pub struct BenchResult { + #[serde(default)] + pub prompt_n: i64, + #[serde(default, serialize_with = "ser_go_f64")] + pub prompt_ms: f64, + #[serde(default, serialize_with = "ser_go_f64")] + pub prompt_per_second: f64, + #[serde(default)] + pub predicted_n: i64, + #[serde(default, serialize_with = "ser_go_f64")] + pub predicted_ms: f64, + #[serde(default, serialize_with = "ser_go_f64")] + pub predicted_per_second: f64, + #[serde(default, rename = "elapsed_sec", serialize_with = "ser_go_f64")] + pub elapsed: f64, + #[serde(default, skip_serializing_if = "String::is_empty")] + pub mode: String, + #[serde(default, skip_serializing_if = "is_zero_i")] + pub protocol: i64, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub draft: Option, + #[serde(default, skip_serializing_if = "String::is_empty")] + pub kv: String, + #[serde(default, skip_serializing_if = "is_false")] + pub kv_quantized: bool, + #[serde(default, skip_serializing_if = "String::is_empty")] + pub engine: String, + #[serde(default, skip_serializing_if = "String::is_empty")] + pub gpus: String, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub depth: Option, +} + +/// `benchOpts`. +#[derive(Clone, Debug, Default, PartialEq)] +pub struct Opts { + pub mode: String, + pub prompt: i64, + pub predict: i64, + /// Profondeur imposée (0 : choisie selon le contexte). + pub depth: i64, +} + +impl Opts { + pub fn full(&self) -> bool { + self.mode == MODE_FULL + } +} + +/// `benchCorpora` : prose pour la ligne courte, code pour la profondeur. +pub struct Corpora { + pub prose: String, + pub code: String, +} + +impl Corpora { + /// `defaultBenchCorpora` : le code est l'interface web embarquée. + pub fn default_set() -> Self { + Corpora { + prose: CORPUS.to_string(), + code: String::from_utf8_lossy(crate::ui::index_bytes()).into_owned(), + } + } +} + +// --------------------------------------------------------------------------- +// Le moteur, vu du bench +// --------------------------------------------------------------------------- + +/// Erreur d'une requête au moteur. +#[derive(Clone, Debug, PartialEq)] +pub enum ReqError { + /// Réponse non-200 (`benchHTTPError`), corps gardé en entier. + Http { + method: String, + path: String, + status: String, + body: String, + }, + /// Le budget de la requête est dépassé (`context.DeadlineExceeded`). + Timeout(String), + /// Le bench est annulé. + Canceled(String), + /// Autre (réseau, réponse sans timings…). + Other(String), +} + +impl std::fmt::Display for ReqError { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + ReqError::Http { + method, + path, + status, + body, + } => { + let snip = body.trim(); + let snip = if snip.len() > 300 { + let mut cut = 300; + while cut > 0 && !snip.is_char_boundary(cut) { + cut -= 1; + } + &snip[..cut] + } else { + snip + }; + write!(f, "{method} {path} : {status} {snip}") + } + ReqError::Timeout(e) | ReqError::Canceled(e) | ReqError::Other(e) => f.write_str(e), + } + } +} + +/// `benchEngine` : le moteur local. +#[derive(Clone)] +pub struct Engine { + pub base: String, + pub key: String, + pub client: reqwest::Client, + /// Annulation du bench. + pub cancel: watch::Receiver, + /// Budget d'une requête de chat. + pub req_timeout: Duration, + /// Requêtes de chat servies (le slot porte alors l'état du bench). + pub served: std::sync::Arc, +} + +/// Le nom de méthode d'une `url.Error` du Go (« Post », « Get »). +fn go_op(method: &str) -> String { + let m = method.to_ascii_lowercase(); + let mut c = m.chars(); + match c.next() { + Some(f) => f.to_uppercase().collect::() + c.as_str(), + None => String::new(), + } +} + +impl Engine { + /// `do` : le corps d'une réponse 200 (lu jusqu'à 8 Mo) ; toute autre + /// réponse est une erreur. + async fn send( + &self, + method: &str, + path: &str, + payload: Option<&Value>, + limit: Duration, + ) -> Result, ReqError> { + let url = format!("{}{path}", self.base); + let m = reqwest::Method::from_bytes(method.as_bytes()).unwrap_or(reqwest::Method::GET); + let mut rq = self.client.request(m, &url); + if let Some(p) = payload { + rq = rq + .header("Content-Type", "application/json") + .body(serde_json::to_vec(p).unwrap_or_default()); + } + if !self.key.is_empty() { + rq = rq.header("Authorization", format!("Bearer {}", self.key)); + } + let op = go_op(method); + let fut = async { + let resp = rq + .send() + .await + .map_err(|e| ReqError::Other(format!("{op} \"{url}\": {e}")))?; + let status = resp.status(); + let mut data = Vec::new(); + let mut resp = resp; + while let Some(chunk) = resp + .chunk() + .await + .map_err(|e| ReqError::Other(format!("{op} \"{url}\": {e}")))? + { + let room = (8usize << 20).saturating_sub(data.len()); + data.extend_from_slice(&chunk[..chunk.len().min(room)]); + if room <= chunk.len() { + break; + } + } + if status != reqwest::StatusCode::OK { + return Err(ReqError::Http { + method: method.to_string(), + path: path.to_string(), + status: format!( + "{} {}", + status.as_u16(), + status.canonical_reason().unwrap_or("") + ), + body: String::from_utf8_lossy(&data).into_owned(), + }); + } + Ok(data) + }; + let mut cancel = self.cancel.clone(); + if *cancel.borrow() { + return Err(ReqError::Canceled(format!( + "{op} \"{url}\": context canceled" + ))); + } + tokio::select! { + r = tokio::time::timeout(limit, fut) => match r { + Ok(r) => r, + Err(_) => Err(ReqError::Timeout(format!( + "{op} \"{url}\": context deadline exceeded" + ))), + }, + _ = async { + // Émetteur disparu : plus personne pour annuler. + if cancel.wait_for(|c| *c).await.is_err() { + std::future::pending::<()>().await; + } + } => { + Err(ReqError::Canceled(format!("{op} \"{url}\": context canceled"))) + } + } + } + + /// `idle` : un slot qui travaille, c'est la requête d'un autre. `/slots` + /// coupé ou illisible : on ne sait pas, on n'empêche rien. + pub async fn idle(&self) -> Result<(), String> { + let Ok(data) = self + .send("GET", "/slots", None, Duration::from_secs(3)) + .await + else { + return Ok(()); + }; + let Ok(Value::Array(slots)) = serde_json::from_slice::(&data) else { + return Ok(()); + }; + let busy = slots + .iter() + .any(|s| s.get("is_processing").and_then(Value::as_bool) == Some(true)); + if busy { + return Err( + "le moteur traite déjà une requête : relance le bench une fois libre".into(), + ); + } + Ok(()) + } + + /// `props` : la taille de contexte et le build. Absents : 0 et "". + pub async fn props(&self) -> (i64, String) { + let Ok(data) = self + .send("GET", "/props", None, Duration::from_secs(5)) + .await + else { + return (0, String::new()); + }; + let Ok(Value::Object(p)) = serde_json::from_slice::(&data) else { + return (0, String::new()); + }; + let int = |v: Option<&Value>| v.and_then(Value::as_i64).unwrap_or(0); + let mut n = int(p + .get("default_generation_settings") + .and_then(|d| d.get("n_ctx"))); + if n <= 0 { + n = int(p.get("n_ctx")); + } + let build = p + .get("build_info") + .and_then(Value::as_str) + .unwrap_or("") + .to_string(); + (n, build) + } + + /// `bytesPerToken` : octets par jeton d'un échantillon (`/tokenize`). + pub async fn bytes_per_token(&self, sample: &str) -> f64 { + if sample.is_empty() { + return FALLBACK_RATIO; + } + let Ok(data) = self + .send( + "POST", + "/tokenize", + Some(&json!({ "content": sample })), + Duration::from_secs(30), + ) + .await + else { + return FALLBACK_RATIO; + }; + match serde_json::from_slice::(&data) { + Ok(v) => match v.get("tokens").and_then(Value::as_array) { + Some(t) if !t.is_empty() => sample.len() as f64 / t.len() as f64, + _ => FALLBACK_RATIO, + }, + Err(_) => FALLBACK_RATIO, + } + } + + /// `chat` : une complétion et ses timings ; sans timings, pas de mesure. + pub async fn chat(&self, payload: &Value) -> Result { + let data = self + .send( + "POST", + "/v1/chat/completions", + Some(payload), + self.req_timeout, + ) + .await?; + self.served + .fetch_add(1, std::sync::atomic::Ordering::SeqCst); + parse_reply(&data) + } +} + +/// `perfNum` : un nombre (ou une chaîne qui en contient un), fini, positif et +/// < 1e12 ; sinon inconnu. +fn perf_num(v: Option<&Value>) -> Option { + let f = match v? { + Value::Number(n) => n.as_f64()?, + Value::String(s) => s.parse::().ok()?, + _ => return None, + }; + (f.is_finite() && (0.0..1e12).contains(&f)).then_some(f) +} + +/// `benchReply` : une réponse du chat. `total` : usage.prompt_tokens (0 = +/// inconnu). +#[derive(Clone, Debug, Default, PartialEq)] +pub struct Reply { + pub turn: Turn, + pub total: i64, + pub content: String, +} + +impl Reply { + /// `promptTotal`. + pub fn prompt_total(&self) -> i64 { + if self.total > 0 { + return self.total; + } + self.turn.new + self.turn.cached.max(0) + } +} + +/// La réponse du moteur en mesure (`decodePerfWire` et le contenu). +pub fn parse_reply(data: &[u8]) -> Result { + let first = serde_json::Deserializer::from_slice(data) + .into_iter::() + .next(); + let o = match first { + Some(Ok(Value::Object(o))) => o, + _ => Map::new(), + }; + let t = o.get("timings").and_then(Value::as_object); + let num = |k: &str| t.and_then(|t| perf_num(t.get(k))); + let (Some(pn), Some(pms), Some(gn), Some(gms)) = ( + num("prompt_n"), + num("prompt_ms"), + num("predicted_n"), + num("predicted_ms"), + ) else { + return Err(ReqError::Other(ERR_NO_TIMINGS.into())); + }; + let rate = |n: f64, ms: f64, given: Option| match given { + Some(g) if g > 0.0 => g, + _ if ms > 0.0 => n / (ms / 1000.0), + _ => 0.0, + }; + let mut turn = Turn { + new: pn as i64, + cached: -1, + prompt_ms: pms, + prompt_per_second: rate(pn, pms, num("prompt_per_second")), + predicted_n: gn as i64, + predicted_ms: gms, + predicted_per_second: rate(gn, gms, num("predicted_per_second")), + ..Default::default() + }; + let usage = o.get("usage").and_then(Value::as_object); + let cached_tokens = usage + .and_then(|u| u.get("prompt_tokens_details")) + .and_then(Value::as_object) + .and_then(|d| perf_num(d.get("cached_tokens"))); + if let Some(c) = num("cache_n") { + turn.cached = c as i64; + } else if let Some(c) = cached_tokens { + turn.cached = c as i64; + } + if let (Some(n), Some(a)) = (num("draft_n"), num("draft_n_accepted")) + && n as i64 > 0 + { + turn.draft = Some(Draft { + n: n as i64, + accepted: a as i64, + }); + } + let total = usage + .and_then(|u| perf_num(u.get("prompt_tokens"))) + .map_or(0, |f| f as i64); + // json.Unmarshal du message entier : un octet de trop, pas de contenu. + let content = serde_json::from_slice::(data) + .ok() + .and_then(|v| { + v.get("choices")? + .get(0)? + .get("message")? + .get("content")? + .as_str() + .map(str::to_string) + }) + .unwrap_or_default(); + Ok(Reply { + turn, + total, + content, + }) +} + +// --------------------------------------------------------------------------- +// Réglages du preset +// --------------------------------------------------------------------------- + +/// `benchSetup` : ce que le bench tire du preset, une fois. +pub struct Setup { + pub cfg: Cfg, + pub want: String, + pub off: bool, + pub effort: String, + pub kwargs: Option>, + pub retried: bool, + pub ub: i64, + pub cpu_placed: bool, + pub hybrid: bool, + pub kv: String, + pub kv_quant: bool, + pub gpus: String, + pub side_slot: bool, +} + +/// `benchSetupFrom`. +pub fn setup_from( + cfg: &Cfg, + arg_env: &HashMap, + fallbacks: &EffortFallbacks, +) -> Setup { + let get = |k: &str| cfg.get(k).map(String::as_str).unwrap_or(""); + let extra = crate::models::split_args(get("EXTRA_ARGS")); + let want = reasoning_effort_value(get("REASONING_EFFORT")); + let effort = fallbacks.resolve(cfg, &want); + let off = reasoning_explicitly_off(get("REASONING")) || want == "none"; + let kwargs = reasoning_template_kwargs(off, &effort); + let mut ub = 512; + for v in [ + crate::serve::flag_value(&extra, &["-ub", "--ubatch-size"]), + get("UBATCH").to_string(), + ] { + if let Some(n) = go_atoi(v.trim()) + && n > 0 + { + ub = n; + break; + } + } + let cpu_placed = !crate::serve::queues::tensor_override(&extra, arg_env).is_empty(); + let (k, v, _) = crate::serve::fidelity::effective_kv_types(cfg, &extra, arg_env); + let (kv, kv_quant) = kv_label(&k, &v); + Setup { + cfg: cfg.clone(), + want, + off, + effort, + kwargs, + retried: false, + ub, + cpu_placed, + // Le modèle hybride se lit dans le GGUF : posé par l'appelant, qui sait + // où sont les modèles. + hybrid: false, + kv, + kv_quant, + gpus: get("CUDA_VISIBLE_DEVICES").trim().to_string(), + side_slot: false, + } +} + +/// `strconv.Atoi`. +fn go_atoi(s: &str) -> Option { + let digits = s.strip_prefix(['+', '-']).unwrap_or(s); + if digits.is_empty() || !digits.bytes().all(|c| c.is_ascii_digit()) { + return None; + } + s.parse().ok() +} + +/// `benchKVLabel` : les types de cache (« f16 », « q8_0/f16 »…), quantifiés +/// ou non (rang 2 et plus de kvFidelity). +pub fn kv_label(k: &str, v: &str) -> (String, bool) { + let norm = |s: &str| match s.trim().to_lowercase() { + s if s.is_empty() => "f16".to_string(), + s => s, + }; + let (k, v) = (norm(k), norm(v)); + let (rk, _) = crate::serve::fidelity::kv_fidelity(&k); + let (rv, _) = crate::serve::fidelity::kv_fidelity(&v); + let label = if k == v { k } else { format!("{k}/{v}") }; + (label, rk.max(rv) >= 2) +} + +impl Setup { + /// `payload` : une requête de chat du bench. + pub fn payload(&self, msgs: &[(String, String)], max_tokens: i64, cache: bool) -> Value { + let messages: Vec = msgs + .iter() + .map(|(r, c)| json!({ "role": r, "content": c })) + .collect(); + let mut p = Map::new(); + p.insert("model".into(), json!("loki")); + p.insert("messages".into(), Value::Array(messages)); + p.insert("max_tokens".into(), json!(max_tokens)); + p.insert("stream".into(), json!(false)); + p.insert("temperature".into(), json!(0.7)); + p.insert("seed".into(), json!(SEED)); + p.insert("cache_prompt".into(), json!(cache)); + apply_sampling_from(&mut p, &self.cfg); + if !self.effort.is_empty() { + p.insert("reasoning_effort".into(), json!(self.effort)); + } + if let Some(k) = &self.kwargs { + p.insert("chat_template_kwargs".into(), Value::Object(k.clone())); + } + if self.side_slot { + p.insert("id_slot".into(), json!(1)); + } + Value::Object(p) + } + + /// `ask` : un gabarit qui refuse le niveau de raisonnement est rejoué une + /// fois avec celui qu'il accepte (ou sans le champ), traduction retenue. + pub async fn ask( + &mut self, + e: &Engine, + msgs: &[(String, String)], + max_tokens: i64, + cache: bool, + learn: &(dyn Fn(&str, &str) + Send + Sync), + ) -> Result { + let r = e.chat(&self.payload(msgs, max_tokens, cache)).await; + let body = match &r { + Err(ReqError::Http { body, .. }) if !self.retried && !self.want.is_empty() => { + body.clone() + } + _ => return r, + }; + let Some(fixed) = effort_from_rejection(&body, &self.want) else { + return r; + }; + self.retried = true; + learn(&self.want, &fixed); + self.kwargs = reasoning_template_kwargs(self.off, &fixed); + self.effort = fixed; + e.chat(&self.payload(msgs, max_tokens, cache)).await + } +} + +// --------------------------------------------------------------------------- +// Le protocole +// --------------------------------------------------------------------------- + +/// `benchDepthFor` : la moitié du contexte, plafonnée, et assez basse pour +/// que les tours y tiennent. Trop petit : la phase est sautée. +pub fn depth_for(n_ctx: i64, cpu_placed: bool, warm: i64) -> (i64, String) { + if n_ctx <= 0 { + return (0, "taille de contexte inconnue".into()); + } + let mut d = (n_ctx / 2).min(DEPTH_MAX); + if cpu_placed { + d = d.min(DEPTH_CPU); + } + d = d.min(n_ctx - TURNS * (APPEND_TOKENS + GEN_TOKENS) - GEN_TOKENS - warm - MARGIN); + if d < DEPTH_MIN { + return ( + 0, + format!("contexte de {n_ctx} jetons trop petit pour la mesure en profondeur"), + ); + } + (d, String::new()) +} + +/// `benchSlice` : à partir de `off`, environ `n` octets arrêtés à une fin de +/// ligne — jamais au milieu d'un caractère. "" : corpus épuisé. +pub fn slice(text: &str, off: usize, n: i64) -> (&str, usize) { + if off >= text.len() || n <= 0 { + return ("", off); + } + let n = n as usize; + let mut end = off.saturating_add(n); + if end >= text.len() { + return (&text[off..], text.len()); + } + if let Some(i) = text.as_bytes()[off..end].iter().rposition(|&b| b == b'\n') + && i > n / 2 + { + end = off + i + 1; + } + while end > off && !text.is_char_boundary(end) { + end -= 1; + } + (&text[off..end], end) +} + +/// `benchLineStart` : le début de la ligne qui suit `off`. +pub fn line_start(text: &str, off: i64) -> usize { + if off <= 0 { + return 0; + } + let off = off as usize; + if off >= text.len() { + return text.len(); + } + let b = text.as_bytes(); + if b[off - 1] == b'\n' { + return off; + } + match b[off..].iter().position(|&c| c == b'\n') { + Some(i) => off + i + 1, + None => text.len(), + } +} + +/// `benchReuse` : la part du prompt précédent reprise du cache, et ce +/// qu'elle veut dire. +pub fn reuse(turns: &[Turn], hybrid: bool) -> (f64, String) { + let (mut cached, mut expected) = (0i64, 0i64); + let mut full = true; + for t in turns { + if t.cached < 0 || t.expected <= 0 { + continue; + } + cached += t.cached.min(t.expected); + expected += t.expected; + if t.cached < t.expected - REUSE_SLACK { + full = false; + } + } + if expected == 0 { + return ( + -1.0, + "le moteur ne dit pas combien de jetons il reprend du cache".into(), + ); + } + let ratio = cached as f64 / expected as f64; + if full { + (ratio, String::new()) + } else if hybrid { + ( + ratio, + "reprise partielle : modèle hybride, le cache ne reprend qu'aux points de contrôle" + .into(), + ) + } else { + ( + ratio, + "reprise partielle : le gabarit réécrit sans doute la fin de l'historique (raisonnement retiré)" + .into(), + ) + } +} + +/// `benchThrashHint` : un moteur qui relit beaucoup le disque pendant les +/// tours n'a pas le modèle en RAM (indication seulement). +pub fn thrash_hint(read: i64, generated: i64, rss: i64) -> String { + if read <= 0 || generated <= 0 { + return String::new(); + } + let per = read * 256 / generated; + if per <= THRASH_BYTES { + return String::new(); + } + format!( + "possible thrash : {:.1} Go relus du disque par 256 jetons générés (RSS {:.1} Go) — le modèle ne tient sans doute pas en RAM", + per as f64 / (1u64 << 30) as f64, + rss as f64 / (1u64 << 30) as f64 + ) +} + +/// `benchDraftText`. +pub fn draft_text(d: Option<&Draft>) -> String { + match d { + Some(d) if d.n != 0 => format!( + " brouillon accepté {:.0} % ({}/{})", + d.accepted as f64 * 100.0 / d.n as f64, + d.accepted, + d.n + ), + _ => String::new(), + } +} + +/// `time.Duration.String` du Go, exact (« 150ms », « 1.5µs », « 6m0s »). +pub fn go_dur(d: Duration) -> String { + let u = d.as_nanos(); + if u == 0 { + return "0s".into(); + } + // La fraction de v / 10^prec, zéros de fin omis (sans point si nulle). + let frac = |v: u128, prec: u32| -> (u128, String) { + let p = 10u128.pow(prec); + let f = v % p; + if f == 0 { + return (v / p, String::new()); + } + let digits = format!("{f:0width$}", width = prec as usize); + (v / p, format!(".{}", digits.trim_end_matches('0'))) + }; + if u < 1_000_000_000 { + let (unit, prec) = match u { + u if u < 1_000 => ("ns", 0), + u if u < 1_000_000 => ("µs", 3), + _ => ("ms", 6), + }; + let (i, f) = frac(u, prec); + return format!("{i}{f}{unit}"); + } + let (secs, f) = frac(u, 9); + let (h, m, s) = (secs / 3600, secs / 60 % 60, secs % 60); + match (h, m) { + (0, 0) => format!("{s}{f}s"), + (0, m) => format!("{m}m{s}{f}s"), + (h, m) => format!("{h}h{m}m{s}{f}s"), + } +} + +/// La lecture disque et la RSS du moteur (`engineIOSample`) ; `None` hors +/// Linux ou sans moteur. +pub type IoSample<'a> = &'a (dyn Fn() -> Option<(i64, i64)> + Send + Sync); + +/// `benchRun` : le protocole contre un moteur. `Err` = une phase de mesure a +/// échoué, rien à enregistrer ; une phase en profondeur interrompue par son +/// budget ou par la taille du contexte rend un résultat PARTIEL. +pub async fn run( + e: &Engine, + mut opts: Opts, + s: &mut Setup, + corp: &Corpora, + progress: &(dyn Fn(&str, i64, i64) + Send + Sync), + learn: &(dyn Fn(&str, &str) + Send + Sync), + io: IoSample<'_>, +) -> Result { + if opts.prompt <= 0 { + opts.prompt = 2000; + } + if opts.predict <= 0 { + opts.predict = 300; + } + e.idle().await?; + let steps = if opts.full() { 2 + 1 + TURNS } else { 2 }; + let step = std::sync::atomic::AtomicI64::new(0); + let next = |phase: &str| { + let n = step.fetch_add(1, std::sync::atomic::Ordering::SeqCst) + 1; + progress(phase, n, steps); + }; + let t0 = std::time::Instant::now(); + let (mut n_ctx, build) = e.props().await; + if n_ctx <= 0 { + n_ctx = go_atoi(s.cfg.get("CTX").map(String::as_str).unwrap_or("").trim()).unwrap_or(0); + } + let mode = if opts.full() { MODE_FULL } else { MODE_QUICK }; + let mut res = BenchResult { + mode: mode.into(), + protocol: PROTOCOL, + kv: s.kv.clone(), + kv_quantized: s.kv_quant, + engine: build, + gpus: s.gpus.clone(), + ..Default::default() + }; + let code = corp.code.as_str(); + let (sample, _) = slice(code, 0, SAMPLE_BYTES as i64); + let code_ratio = e.bytes_per_token(sample).await; + let prose_ratio = e.bytes_per_token(&corp.prose).await; + + // 1. Préchauffage, jeté. + next("préchauffage"); + let mut warm_tok = 2 * s.ub; + if n_ctx > 0 { + warm_tok = warm_tok.min(n_ctx / 4).max(64); + } + let warm = warm_tok + WARM_GEN; + let from = line_start( + code, + code.len() as i64 - (warm_tok as f64 * code_ratio) as i64, + ); + let (warm_text, _) = slice(code, from, code.len() as i64); + let msgs = vec![("user".to_string(), format!("{warm_text}\n\n{ASK_FIRST}"))]; + if let Err(err) = s.ask(e, &msgs, WARM_GEN, false, learn).await { + return Err(format!("préchauffage : {err}")); + } + + // 2. La ligne courte : de la prose, puis du code inédit. + next(&format!( + "ligne courte ({} + {} jetons)", + opts.prompt, opts.predict + )); + let mut prompt = corp.prose.clone(); + let want = (opts.prompt as f64 * prose_ratio) as i64; + if want < prompt.len() as i64 { + prompt = slice(&corp.prose, 0, want).0.to_string(); + } else { + let rest = opts.prompt - (prompt.len() as f64 / prose_ratio) as i64; + if rest > 0 { + let (chunk, _) = slice( + code, + line_start(code, (code.len() / 2) as i64), + (rest as f64 * code_ratio) as i64, + ); + prompt = format!("{prompt}\n\n{chunk}"); + } + } + let msgs = vec![("user".to_string(), format!("{}{ASK_PROSE}", prompt.trim()))]; + let quick = s + .ask(e, &msgs, opts.predict, false, learn) + .await + .map_err(|err| format!("ligne courte : {err}"))?; + let q = quick.turn; + res.prompt_n = q.new; + res.prompt_ms = q.prompt_ms; + res.prompt_per_second = q.prompt_per_second; + res.predicted_n = q.predicted_n; + res.predicted_ms = q.predicted_ms; + res.predicted_per_second = q.predicted_per_second; + res.draft = q.draft; + if !opts.full() { + res.elapsed = t0.elapsed().as_secs_f64(); + return Ok(res); + } + + // 3. La profondeur. + let (mut d, mut why) = depth_for(n_ctx, s.cpu_placed, warm); + if opts.depth > 0 { + d = opts.depth; + why = String::new(); + } + let mut depth = Depth { + ctx: n_ctx, + target: d, + reuse: -1.0, + ..Default::default() + }; + if !why.is_empty() { + depth.skipped = why; + res.depth = Some(depth); + res.elapsed = t0.elapsed().as_secs_f64(); + return Ok(res); + } + depth_run(e, s, code, code_ratio, &mut depth, &next, learn, io).await?; + res.depth = Some(depth); + res.elapsed = t0.elapsed().as_secs_f64(); + Ok(res) +} + +/// `benchDepthRun` : prefill à froid à D, puis trois tours qui prolongent la +/// même discussion avec du code jamais vu. +#[allow(clippy::too_many_arguments)] +async fn depth_run( + e: &Engine, + s: &mut Setup, + code: &str, + ratio: f64, + depth: &mut Depth, + next: &(dyn Fn(&str) + Send + Sync), + learn: &(dyn Fn(&str, &str) + Send + Sync), + io: IoSample<'_>, +) -> Result<(), String> { + next(&format!("prefill à froid ({} jetons)", depth.target)); + let (prefix, mut off) = slice(code, 0, (depth.target as f64 * ratio) as i64); + let mut msgs = vec![( + "user".to_string(), + format!( + "Voici un fichier source de l'interface web de Loki :\n\n```html\n{prefix}\n```\n\n{ASK_FIRST}" + ), + )]; + let cold = match s.ask(e, &msgs, GEN_TOKENS, false, learn).await { + Err(ReqError::Timeout(_)) => { + depth.partial = format!("prefill à froid interrompu après {}", go_dur(e.req_timeout)); + return Ok(()); + } + Err(err) => return Err(format!("prefill à froid : {err}")), + Ok(r) => r, + }; + depth.cold = Some(cold.turn.clone()); + let (mut prev, mut answer) = (cold.prompt_total(), cold.content.clone()); + // Relectures du disque comptées sur les TOURS seulement. + let before = io(); + let (mut new_n, mut generated) = (0i64, 0i64); + let (mut prompt_ms, mut gen_ms) = (0f64, 0f64); + for r in 1..=TURNS { + if prev + 2 * GEN_TOKENS + APPEND_TOKENS * 5 / 4 + MARGIN > depth.ctx { + depth.partial = format!("contexte plein après {} tour(s)", r - 1); + break; + } + let (chunk, next_off) = slice(code, off, (APPEND_TOKENS as f64 * ratio) as i64); + if chunk.is_empty() { + depth.partial = format!("corpus épuisé après {} tour(s)", r - 1); + break; + } + off = next_off; + next(&format!("tour {r}/{TURNS} en profondeur")); + msgs.push(("assistant".to_string(), answer.clone())); + msgs.push(( + "user".to_string(), + format!("Suite du fichier :\n\n```html\n{chunk}\n```\n\n{ASK_NEXT}"), + )); + let rep = match s.ask(e, &msgs, GEN_TOKENS, true, learn).await { + Err(ReqError::Timeout(_)) => { + depth.partial = format!("tour {r} interrompu après {}", go_dur(e.req_timeout)); + break; + } + Err(err) => return Err(format!("tour {r} en profondeur : {err}")), + Ok(rep) => rep, + }; + let mut t = rep.turn.clone(); + t.expected = prev; + depth.draft = draft_add(depth.draft, t.draft); + new_n += t.new; + prompt_ms += t.prompt_ms; + generated += t.predicted_n; + gen_ms += t.predicted_ms; + depth.turns.push(t); + prev = rep.prompt_total(); + answer = rep.content; + } + if prompt_ms > 0.0 { + depth.cached_per_second = new_n as f64 / (prompt_ms / 1000.0); + } + if gen_ms > 0.0 { + depth.decode_per_second = generated as f64 / (gen_ms / 1000.0); + } + (depth.reuse, depth.reuse_note) = reuse(&depth.turns, s.hybrid); + if let (Some((io0, rss0)), Some((io1, rss1))) = (before, io()) + && generated > 0 + { + depth.hint = thrash_hint(io1 - io0, generated, rss0.max(rss1)); + } + Ok(()) +} + +#[cfg(test)] +mod tests; diff --git a/rust/loki-rs/src/bench/tests.rs b/rust/loki-rs/src/bench/tests.rs new file mode 100644 index 0000000..c35e936 --- /dev/null +++ b/rust/loki-rs/src/bench/tests.rs @@ -0,0 +1,684 @@ +//! Tests du bench : ceux du Go (`llm_bench_test.go`), portés, et les bornes. + +use super::*; + +fn cfg(pairs: &[(&str, &str)]) -> Cfg { + pairs + .iter() + .map(|(k, v)| (k.to_string(), v.to_string())) + .collect() +} + +#[test] +fn profondeur_choisie() { + let warm = 2 * 512 + WARM_GEN; + let turns = TURNS * (APPEND_TOKENS + GEN_TOKENS); + for (ctx, cpu, want, skipped, what) in [ + (131072, false, DEPTH_MAX, false, "grand contexte : plafond"), + ( + 131072, + true, + DEPTH_CPU, + false, + "poids sur CPU : plafond réduit", + ), + (65536, false, 32768, false, "moitié du contexte"), + ( + 16384, + false, + 16384 - turns - GEN_TOKENS - warm - MARGIN, + false, + "les tours doivent tenir", + ), + (8192, false, 0, true, "trop petit"), + (0, false, 0, true, "inconnu"), + ] { + let (got, why) = depth_for(ctx, cpu, warm); + assert_eq!((got, !why.is_empty()), (want, skipped), "{what}"); + if got > 0 { + assert!(got + turns + GEN_TOKENS <= ctx, "{what} : déborde"); + } + } + assert_eq!(depth_for(0, false, 0).1, "taille de contexte inconnue"); + assert_eq!( + depth_for(8192, false, warm).1, + "contexte de 8192 jetons trop petit pour la mesure en profondeur" + ); + // Juste au seuil : 4096 tient, 4095 non. + let at = |d: i64| d + turns + GEN_TOKENS + warm + MARGIN; + assert_eq!(depth_for(at(4096), false, warm).0, 4096); + assert_eq!(depth_for(at(4095), false, warm).0, 0); +} + +#[test] +fn types_de_cache() { + for (k, v, label, quant) in [ + ("", "", "f16", false), + ("f16", "", "f16", false), + ("bf16", "bf16", "bf16", false), + ("q8_0", "q8_0", "q8_0", true), + ("q8_0", "", "q8_0/f16", true), + ("", "q4_0", "f16/q4_0", true), + (" Q8_0 ", "q8_0", "q8_0", true), + ] { + assert_eq!(kv_label(k, v), (label.to_string(), quant), "{k:?} {v:?}"); + } + // Le -ctk d'EXTRA_ARGS l'emporte, comme au lancement. + let s = setup_from( + &cfg(&[ + ("KV_TYPE", "f16"), + ("EXTRA_ARGS", "-ctk q8_0 -ctv q8_0 --n-cpu-moe 20"), + ]), + &HashMap::new(), + &EffortFallbacks::new(), + ); + assert_eq!( + (s.kv.as_str(), s.kv_quant, s.cpu_placed), + ("q8_0", true, true) + ); + let s = setup_from(&cfg(&[]), &HashMap::new(), &EffortFallbacks::new()); + assert!(!s.cpu_placed); + // Placement par l'environnement de llama-server. + let env = HashMap::from([("LLAMA_ARG_CPU_MOE".to_string(), "1".to_string())]); + let s = setup_from(&cfg(&[]), &env, &EffortFallbacks::new()); + assert!(s.cpu_placed); +} + +#[test] +fn micro_lot_du_preset() { + let ub = |pairs: &[(&str, &str)]| { + setup_from(&cfg(pairs), &HashMap::new(), &EffortFallbacks::new()).ub + }; + assert_eq!(ub(&[]), 512); + assert_eq!(ub(&[("UBATCH", "1024")]), 1024); + assert_eq!(ub(&[("UBATCH", " 2048 ")]), 2048); + assert_eq!(ub(&[("UBATCH", "0")]), 512); + assert_eq!(ub(&[("UBATCH", "x")]), 512); + assert_eq!(ub(&[("EXTRA_ARGS", "-ub 256"), ("UBATCH", "1024")]), 256); + assert_eq!(ub(&[("EXTRA_ARGS", "--ubatch-size 128")]), 128); + assert_eq!(ub(&[("EXTRA_ARGS", "-ub -4"), ("UBATCH", "64")]), 64); + let gpus = setup_from( + &cfg(&[("CUDA_VISIBLE_DEVICES", " 0,1 ")]), + &HashMap::new(), + &EffortFallbacks::new(), + ) + .gpus; + assert_eq!(gpus, "0,1"); +} + +#[test] +fn requete_du_bench() { + let msgs = vec![("user".to_string(), "x".to_string())]; + let fb = EffortFallbacks::new(); + let p = setup_from( + &cfg(&[("TEMP", "0.6"), ("TOP_P", "0.95"), ("TOP_K", "20")]), + &HashMap::new(), + &fb, + ) + .payload(&msgs, 10, true); + assert_eq!(p["temperature"], json!(0.6)); + assert_eq!(p["top_p"], json!(0.95)); + assert_eq!(p["top_k"], json!(20)); + assert_eq!(p["seed"], json!(SEED)); + assert_eq!(p["max_tokens"], json!(10)); + assert_eq!(p["model"], json!("loki")); + assert_eq!(p["messages"], json!([{ "role": "user", "content": "x" }])); + assert_eq!( + (p["cache_prompt"].clone(), p["stream"].clone()), + (json!(true), json!(false)) + ); + for k in [ + "chat_template_kwargs", + "reasoning_effort", + "ignore_eos", + "id_slot", + ] { + assert!(p.get(k).is_none(), "{k}"); + } + let s = setup_from(&cfg(&[]), &HashMap::new(), &fb); + let p = s.payload(&msgs, 10, false); + assert_eq!(p["temperature"], json!(0.7)); + assert_eq!(p["cache_prompt"], json!(false)); + assert!(p.get("top_k").is_none()); + let mut s = setup_from(&cfg(&[("REASONING_EFFORT", "high")]), &HashMap::new(), &fb); + assert_eq!(s.payload(&msgs, 1, true)["reasoning_effort"], json!("high")); + s.side_slot = true; + assert_eq!(s.payload(&msgs, 1, true)["id_slot"], json!(1)); + // Raisonnement coupé : le gabarit le dit. + let s = setup_from(&cfg(&[("REASONING", "off")]), &HashMap::new(), &fb); + assert_eq!( + s.payload(&msgs, 1, true)["chat_template_kwargs"], + json!({ "enable_thinking": false }) + ); + let s = setup_from(&cfg(&[("REASONING_EFFORT", "none")]), &HashMap::new(), &fb); + assert!(s.off); + // Une traduction apprise est envoyée d'emblée. + let c = cfg(&[("REASONING_EFFORT", "high"), ("MODEL", "/m/Q.gguf")]); + fb.remember(&c, "high", "xhigh"); + let s = setup_from(&c, &HashMap::new(), &fb); + assert_eq!((s.want.as_str(), s.effort.as_str()), ("high", "xhigh")); +} + +#[test] +fn morceaux_du_corpus() { + let text = "ééé ligne\n".repeat(100); + let mut off = 0; + while off < text.len() { + let (chunk, next) = slice(&text, off, 37); + assert!(!chunk.is_empty(), "à {off}"); + if next < text.len() { + assert!(chunk.ends_with('\n'), "coupé hors fin de ligne : {chunk:?}"); + } + off = next; + } + assert_eq!(slice(&text, text.len(), 10).0, ""); + assert_eq!(slice(&text, 0, 0).0, ""); + assert_eq!(slice("abc", 0, 99), ("abc", 3)); + // Sans fin de ligne assez loin : coupé à la taille, jamais dans un + // caractère. + assert_eq!(slice("éééééé", 0, 3), ("é", 2)); + assert_eq!(slice("ab\ncdefgh", 0, 8), ("ab\ncdefg", 8)); + assert_eq!(slice("abcde\nfgh", 0, 8), ("abcde\n", 6)); + assert_eq!(slice("abcd\nfgh", 0, 8), ("abcd\nfgh", 8)); + // Pile à la fin du texte : tout, sans chercher de fin de ligne. + assert_eq!(slice("abcdefg\nh", 0, 9), ("abcdefg\nh", 9)); + assert_eq!(line_start("ab\ncd", 1), 3); + assert_eq!(line_start("ab\ncd", 3), 3); + assert_eq!(line_start("ab\ncd", 0), 0); + assert_eq!(line_start("ab\ncd", -5), 0); + assert_eq!(line_start("ab\ncd", 4), 5); + assert_eq!(line_start("ab\ncd", 9), 5); +} + +#[test] +fn reprise_du_cache() { + let t = |cached, expected| Turn { + cached, + expected, + ..Default::default() + }; + let (r, note) = reuse(&[t(1000, 1004), t(3000, 3000)], false); + assert!(r > 0.99 && note.is_empty(), "{r} {note}"); + assert_eq!(reuse(&[t(1000, 1016)], false).1, ""); + assert!(!reuse(&[t(1000, 1017)], false).1.is_empty()); + let part = [t(512, 3000)]; + assert!(reuse(&part, true).1.contains("hybride")); + assert!(reuse(&part, false).1.contains("gabarit")); + assert_eq!(reuse(&part, false).0, 512.0 / 3000.0); + let (r, note) = reuse(&[t(-1, 3000)], false); + assert_eq!(r, -1.0); + assert!(!note.is_empty()); + assert_eq!(reuse(&[t(10, 0)], false).0, -1.0); + // Un tour sans attendu ne compte pas, même à côté d'un tour mesuré. + assert_eq!(reuse(&[t(50, 100), t(5, -10)], false).0, 0.5); + // Un cache plus grand que l'attendu compte pour l'attendu. + assert_eq!(reuse(&[t(5000, 3000)], false).0, 1.0); +} + +#[test] +fn relectures_du_disque() { + assert_eq!(thrash_hint(100 << 20, 256, 0), ""); + assert_eq!(thrash_hint(0, 256, 0), ""); + assert_eq!(thrash_hint(1 << 40, 0, 0), ""); + assert_eq!(thrash_hint(512 << 20, 256, 0), ""); + let h = thrash_hint(3 << 30, 768, 60 << 30); + assert_eq!( + h, + "possible thrash : 1.0 Go relus du disque par 256 jetons générés (RSS 60.0 Go) — le modèle ne tient sans doute pas en RAM" + ); + assert!(!thrash_hint((512 << 20) + 1, 256, 0).is_empty()); +} + +#[test] +fn brouillon() { + assert_eq!(draft_text(None), ""); + assert_eq!(draft_text(Some(&Draft { n: 0, accepted: 0 })), ""); + assert_eq!( + draft_text(Some(&Draft { + n: 30, + accepted: 21 + })), + " brouillon accepté 70 % (21/30)" + ); + let d = draft_add(None, Some(Draft { n: 10, accepted: 7 })); + let d = draft_add(d, None); + let d = draft_add(d, Some(Draft { n: 5, accepted: 1 })); + assert_eq!(d, Some(Draft { n: 15, accepted: 8 })); +} + +#[test] +fn durees_du_go() { + let ms = Duration::from_millis; + assert_eq!(go_dur(Duration::ZERO), "0s"); + assert_eq!(go_dur(Duration::from_nanos(7)), "7ns"); + assert_eq!(go_dur(Duration::from_nanos(1500)), "1.5µs"); + assert_eq!(go_dur(ms(150)), "150ms"); + assert_eq!(go_dur(Duration::from_micros(1500)), "1.5ms"); + assert_eq!(go_dur(ms(1000)), "1s"); + assert_eq!(go_dur(ms(1500)), "1.5s"); + assert_eq!(go_dur(Duration::from_secs(6 * 60)), "6m0s"); + assert_eq!(go_dur(Duration::from_secs(3600)), "1h0m0s"); + assert_eq!(go_dur(Duration::from_secs(3661)), "1h1m1s"); + assert_eq!( + go_dur(Duration::from_nanos(61_000_000_001)), + "1m1.000000001s" + ); +} + +#[test] +fn reponse_du_moteur() { + let full = br#"{"choices":[{"message":{"content":"ok"}}],"usage":{"prompt_tokens":120,"prompt_tokens_details":{"cached_tokens":30}},"timings":{"prompt_n":100,"prompt_ms":50,"predicted_n":10,"predicted_ms":200,"draft_n":4,"draft_n_accepted":3}}"#; + let r = parse_reply(full).unwrap(); + assert_eq!(r.turn.new, 100); + assert_eq!(r.turn.prompt_per_second, 2000.0); + assert_eq!(r.turn.predicted_per_second, 50.0); + assert_eq!(r.turn.cached, 30); + assert_eq!(r.turn.draft, Some(Draft { n: 4, accepted: 3 })); + assert_eq!((r.total, r.content.as_str()), (120, "ok")); + assert_eq!(r.prompt_total(), 120); + // cache_n l'emporte ; débits donnés par le moteur gardés. + let r = parse_reply(br#"{"timings":{"prompt_n":"8","prompt_ms":4,"predicted_n":2,"predicted_ms":0,"cache_n":5,"prompt_per_second":7,"draft_n":0,"draft_n_accepted":0}}"#).unwrap(); + assert_eq!((r.turn.cached, r.turn.prompt_per_second), (5, 7.0)); + assert_eq!(r.turn.predicted_per_second, 0.0); + assert_eq!(r.turn.draft, None); + assert_eq!(r.prompt_total(), 13); + // Débits nuls donnés par le moteur : recalculés. + let r = parse_reply(br#"{"timings":{"prompt_n":100,"prompt_ms":50,"predicted_n":10,"predicted_ms":200,"prompt_per_second":0,"predicted_per_second":0}}"#).unwrap(); + assert_eq!( + (r.turn.prompt_per_second, r.turn.predicted_per_second), + (2000.0, 50.0) + ); + // Cache inconnu : -1, et le total sans lui. + let r = parse_reply( + br#"{"timings":{"prompt_n":8,"prompt_ms":4,"predicted_n":2,"predicted_ms":1}} trailing"#, + ) + .unwrap(); + assert_eq!( + (r.turn.cached, r.prompt_total(), r.content.as_str()), + (-1, 8, "") + ); + for bad in [ + &br#"{"timings":{"prompt_n":8,"prompt_ms":4,"predicted_n":2}}"#[..], + br#"{"timings":{"prompt_n":-1,"prompt_ms":4,"predicted_n":2,"predicted_ms":1}}"#, + br#"{"timings":"x"}"#, + b"pas du json", + br#"{"timings":{"prompt_n":1e13,"prompt_ms":4,"predicted_n":2,"predicted_ms":1}}"#, + ] { + assert_eq!( + parse_reply(bad), + Err(ReqError::Other(ERR_NO_TIMINGS.into())), + "{}", + String::from_utf8_lossy(bad) + ); + } +} + +#[test] +fn erreur_http() { + let e = ReqError::Http { + method: "POST".into(), + path: "/v1/chat/completions".into(), + status: "500 Internal Server Error".into(), + body: format!(" {}é{} ", "x".repeat(299), "y".repeat(10)), + }; + // 300 octets au plus, jamais au milieu d'un caractère. + assert_eq!( + e.to_string(), + format!( + "POST /v1/chat/completions : 500 Internal Server Error {}", + "x".repeat(299) + ) + ); + assert_eq!(go_op("POST"), "Post"); + assert_eq!(go_op("get"), "Get"); +} + +#[test] +fn flottants_du_go() { + let t = Turn { + prompt_ms: 3836.0, + prompt_per_second: 1037.5, + ..Default::default() + }; + let v = serde_json::to_string(&t).unwrap(); + assert!(v.contains(r#""prompt_ms":3836,"#), "{v}"); + assert!(v.contains(r#""prompt_per_second":1037.5"#), "{v}"); + let d = Depth { + reuse: -1.0, + ..Default::default() + }; + assert_eq!( + serde_json::to_value(&d).unwrap(), + json!({ "ctx": 0, "target": 0, "reuse": -1 }) + ); +} + +/// Un mini moteur : chaque chemin rend (statut, corps) ; compte les +/// requêtes reçues. +async fn mini_engine( + routes: Vec<(&'static str, u16, &'static str)>, +) -> ( + Engine, + Arc, + watch::Sender, +) { + let (e, _, hits, tx) = rec_engine(routes).await; + (e, hits, tx) +} + +/// Requêtes reçues par le mini moteur : (chemin, corps). +type Reqs = Arc>>; + +/// `mini_engine`, qui garde aussi les corps des requêtes. +async fn rec_engine( + routes: Vec<(&'static str, u16, &'static str)>, +) -> ( + Engine, + Reqs, + Arc, + watch::Sender, +) { + use std::sync::atomic::AtomicUsize; + use tokio::io::{AsyncReadExt, AsyncWriteExt}; + let l = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap(); + let addr = l.local_addr().unwrap(); + let hits = Arc::new(AtomicUsize::new(0)); + let reqs: Reqs = Arc::default(); + let (h, rq) = (hits.clone(), reqs.clone()); + tokio::spawn(async move { + while let Ok((mut st, _)) = l.accept().await { + let routes = routes.clone(); + let (h, rq) = (h.clone(), rq.clone()); + tokio::spawn(async move { + let mut buf = vec![0u8; 65536]; + let mut got = Vec::new(); + let head = loop { + if let Some(i) = got.windows(4).position(|w| w == b"\r\n\r\n") { + break i + 4; + } + let n = st.read(&mut buf).await.unwrap_or(0); + if n == 0 { + return; + } + got.extend_from_slice(&buf[..n]); + }; + let text = String::from_utf8_lossy(&got[..head]).to_string(); + let len = text + .lines() + .filter_map(|l| l.split_once(':')) + .find(|(k, _)| k.eq_ignore_ascii_case("content-length")) + .and_then(|(_, v)| v.trim().parse::().ok()) + .unwrap_or(0); + while got.len() < head + len { + let n = st.read(&mut buf).await.unwrap_or(0); + if n == 0 { + break; + } + got.extend_from_slice(&buf[..n]); + } + let n = h.fetch_add(1, std::sync::atomic::Ordering::SeqCst) + 1; + let line = text.lines().next().unwrap_or("").to_string(); + let path = line.split(' ').nth(1).unwrap_or("").to_string(); + let end = got.len().min(head + len); + let body = String::from_utf8_lossy(&got[head..end]).to_string(); + rq.lock().unwrap().push((path.clone(), body)); + let (code, body) = routes + .iter() + .find(|(p, _, _)| *p == path) + .map_or((404, "{}"), |(_, c, b)| (*c, *b)); + // « {{N}} » : le rang de la requête. + let body = body.replace("{{N}}", &n.to_string()); + let reason = reqwest::StatusCode::from_u16(code) + .ok() + .and_then(|s| s.canonical_reason()) + .unwrap_or(""); + let resp = format!( + "HTTP/1.1 {code} {reason}\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{body}", + body.len() + ); + let _ = st.write_all(resp.as_bytes()).await; + }); + } + }); + crate::chat::engine::install_crypto(); + let (tx, rx) = watch::channel(false); + let e = Engine { + base: format!("http://{addr}"), + key: String::new(), + client: reqwest::Client::builder().build().unwrap(), + cancel: rx, + req_timeout: Duration::from_secs(5), + served: Arc::new(std::sync::atomic::AtomicU64::new(0)), + }; + (e, reqs, hits, tx) +} + +use std::sync::Arc; + +#[tokio::test] +async fn reponses_du_moteur() { + let ok = r#"{"timings":{"prompt_n":8,"prompt_ms":4,"predicted_n":2,"predicted_ms":1}}"#; + let (e, hits, cancel) = mini_engine(vec![ + ("/v1/chat/completions", 201, ok), + ( + "/props", + 200, + r#"{"n_ctx":8192,"build_info":"b1","default_generation_settings":{"n_ctx":0}}"#, + ), + ("/tokenize", 200, r#"{"tokens":[]}"#), + ]) + .await; + // Seul un 200 vaut réponse. + match e.chat(&json!({})).await { + Err(ReqError::Http { status, .. }) => assert_eq!(status, "201 Created"), + r => panic!("201 : {r:?}"), + } + assert_eq!(e.served.load(std::sync::atomic::Ordering::SeqCst), 0); + // n_ctx du slot nul : celui du serveur. + assert_eq!(e.props().await, (8192, "b1".to_string())); + // /tokenize sans jetons : le repli ; échantillon vide : rien n'est envoyé. + assert_eq!(e.bytes_per_token("abcd").await, FALLBACK_RATIO); + let sent = hits.load(std::sync::atomic::Ordering::SeqCst); + assert_eq!(e.bytes_per_token("").await, FALLBACK_RATIO); + tokio::time::sleep(Duration::from_millis(50)).await; + assert_eq!(hits.load(std::sync::atomic::Ordering::SeqCst), sent); + // Annulé avant d'envoyer : rien ne part. + let before = hits.load(std::sync::atomic::Ordering::SeqCst); + cancel.send(true).unwrap(); + match e.chat(&json!({})).await { + Err(ReqError::Canceled(m)) => assert!(m.ends_with("context canceled"), "{m}"), + r => panic!("annulé : {r:?}"), + } + tokio::time::sleep(Duration::from_millis(50)).await; + assert_eq!(hits.load(std::sync::atomic::Ordering::SeqCst), before); +} + +/// Le refus du gabarit Qwen (`chat/wire.rs`). +const QWEN_REFUS: &str = r#"{"error":{"code":500,"message":"\n------------\nWhile executing CallExpression at line 49, column 28 in source:\n...', 'low') %}\n {{- raise_exception('Unexpected reasoning effort ' ~ reason...\n ^\nError: Jinja Exception: Unexpected reasoning effort high. Supported types are xhigh (default), medium, and low.","type":"server_error"}}"#; + +const CHAT: &str = "/v1/chat/completions"; + +fn chats(reqs: &Reqs) -> Vec { + reqs.lock() + .unwrap() + .iter() + .filter(|(p, _)| p == CHAT) + .map(|(_, b)| serde_json::from_str(b).unwrap()) + .collect() +} + +fn content(v: &Value) -> String { + v["messages"][v["messages"].as_array().unwrap().len() - 1]["content"] + .as_str() + .unwrap() + .to_string() +} + +#[tokio::test] +async fn niveau_rejoue_une_seule_fois() { + let (e, reqs, _, _c) = rec_engine(vec![(CHAT, 500, QWEN_REFUS)]).await; + let mut s = setup_from( + &cfg(&[("REASONING_EFFORT", "high")]), + &HashMap::new(), + &EffortFallbacks::new(), + ); + let learned = std::sync::Mutex::new(Vec::new()); + let learn = |w: &str, f: &str| learned.lock().unwrap().push(format!("{w}>{f}")); + let msgs = vec![("user".to_string(), "x".to_string())]; + assert!(s.ask(&e, &msgs, 8, false, &learn).await.is_err()); + assert!(s.ask(&e, &msgs, 8, false, &learn).await.is_err()); + let sent: Vec = chats(&reqs) + .iter() + .map(|b| b["reasoning_effort"].as_str().unwrap_or("").to_string()) + .collect(); + // Rejoué une fois, avec le niveau accepté, puis plus jamais. + assert_eq!(sent, ["high", "xhigh", "xhigh"]); + assert_eq!(*learned.lock().unwrap(), ["high>xhigh"]); +} + +fn tokens(n: usize) -> &'static str { + Box::leak(format!(r#"{{"tokens":[{}]}}"#, vec!["1"; n].join(",")).into_boxed_str()) +} + +const REPLY: &str = r#"{"choices":[{"message":{"content":"ok"}}],"timings":{"prompt_n":1000,"prompt_ms":5,"predicted_n":2,"predicted_ms":1}}"#; + +#[tokio::test] +async fn prechauffage_et_ligne_courte() { + // 100 jetons : la prose fait 2 octets par jeton, le code 4. + let (e, reqs, _, _c) = rec_engine(vec![ + ("/props", 200, r#"{"n_ctx":100,"build_info":"b9"}"#), + ("/tokenize", 200, tokens(100)), + (CHAT, 200, REPLY), + ]) + .await; + let corp = Corpora { + prose: "mot ".repeat(50), + code: "abc\n".repeat(100), + }; + let mut s = setup_from(&cfg(&[]), &HashMap::new(), &EffortFallbacks::new()); + let phases = std::sync::Mutex::new(Vec::new()); + let progress = |p: &str, n: i64, of: i64| phases.lock().unwrap().push(format!("{p} {n}/{of}")); + let opts = Opts { + mode: String::new(), + prompt: 0, + predict: 0, + depth: 0, + }; + let res = run(&e, opts, &mut s, &corp, &progress, &|_, _| {}, &|| None) + .await + .unwrap(); + assert_eq!( + *phases.lock().unwrap(), + ["préchauffage 1/2", "ligne courte (2000 + 300 jetons) 2/2"] + ); + assert_eq!((res.engine.as_str(), res.prompt_n), ("b9", 1000)); + let c = chats(&reqs); + assert_eq!(c.len(), 2); + // Le préchauffage : 64 jetons au moins (le quart du contexte en vaut + // 25), pris en fin de code au ratio du code. + assert_eq!(c[0]["max_tokens"], 32); + assert_eq!( + content(&c[0]), + format!("{}\n\n{ASK_FIRST}", "abc\n".repeat(64)) + ); + // La ligne courte : toute la prose, puis le code du milieu. + assert_eq!(c[1]["max_tokens"], 300); + assert_eq!( + content(&c[1]), + format!( + "{}\n\n{}abc{ASK_PROSE}", + "mot ".repeat(50), + "abc\n".repeat(49) + ) + ); + + // Juste au-delà de la prose : 50 jetons de code encore. + let opts = Opts { + mode: String::new(), + prompt: 150, + predict: 7, + depth: 0, + }; + run(&e, opts, &mut s, &corp, &|_, _, _| {}, &|_, _| {}, &|| None) + .await + .unwrap(); + let c = chats(&reqs); + assert_eq!(c[3]["max_tokens"], 7); + assert_eq!( + content(&c[3]), + format!( + "{}\n\n{}abc{ASK_PROSE}", + "mot ".repeat(50), + "abc\n".repeat(49) + ) + ); +} + +#[tokio::test] +async fn profondeur_arretee() { + // Une réponse par requête ; 10 jetons repris sur 1010. + let (e, reqs, _, _c) = rec_engine(vec![( + CHAT, + 200, + r#"{"choices":[{"message":{"content":"r{{N}}"}}],"timings":{"prompt_n":1000,"cache_n":10,"prompt_ms":5,"predicted_n":2,"predicted_ms":1}}"#, + )]) + .await; + let mut s = setup_from(&cfg(&[]), &HashMap::new(), &EffortFallbacks::new()); + let go = async |code: &str, ctx: i64, target: i64, s: &mut Setup| { + let mut d = Depth { + ctx, + target, + reuse: -1.0, + ..Default::default() + }; + depth_run(&e, s, code, 4.0, &mut d, &|_| {}, &|_, _| {}, &|| None) + .await + .unwrap(); + d + }; + let short = "abc\n".repeat(1000); + // 1010 jetons déjà là : deux réponses, un tour et la marge ne tiennent + // plus dans 4000. + let d = go(&short, 4000, 100, &mut s).await; + assert_eq!(d.partial, "contexte plein après 0 tour(s)"); + assert!(d.turns.is_empty()); + assert_eq!(chats(&reqs).len(), 1); + // Tout le code lu au prefill : rien à ajouter. + let d = go(&short, 1 << 20, 1000, &mut s).await; + assert_eq!(d.partial, "corpus épuisé après 0 tour(s)"); + assert_eq!(chats(&reqs).len(), 2); + // Deux tours, puis le code manque. + let d = go(&"abc\n".repeat(3000), 1 << 20, 100, &mut s).await; + assert_eq!(d.partial, "corpus épuisé après 2 tour(s)"); + assert_eq!(d.turns.len(), 2); + assert_eq!(d.turns[0].expected, 1010); + // Chaque tour reprend la réponse du précédent. + let c = chats(&reqs); + assert_eq!(c.len(), 5); + let roles = |v: &Value| -> Vec { + v["messages"] + .as_array() + .unwrap() + .iter() + .map(|m| { + format!( + "{}:{}", + m["role"].as_str().unwrap(), + m["content"].as_str().unwrap().len().min(2) + ) + }) + .collect() + }; + assert_eq!( + roles(&c[4]), + ["user:2", "assistant:2", "user:2", "assistant:2", "user:2"] + ); + assert_eq!(c[4]["messages"][1]["content"], "r3"); + assert_eq!(c[4]["messages"][3]["content"], "r4"); + // Reprise partielle hors modèle hybride : le gabarit en cause. + assert!((d.reuse - 10.0 / 1010.0).abs() < 1e-12, "{}", d.reuse); + assert!(d.reuse_note.contains("gabarit"), "{}", d.reuse_note); + s.hybrid = true; + let d = go(&"abc\n".repeat(3000), 1 << 20, 100, &mut s).await; + assert!(d.reuse_note.contains("hybride"), "{}", d.reuse_note); +} 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/compact.rs b/rust/loki-rs/src/chat/compact.rs index 6c354fd..8aea243 100644 --- a/rust/loki-rs/src/chat/compact.rs +++ b/rust/loki-rs/src/chat/compact.rs @@ -416,7 +416,7 @@ pub(crate) fn is_loki_injected(m: &Message) -> bool { /// `dropKeptImages` : copie où chaque image gardée devient sa légende suivie /// d'[`IMAGE_LOST_MARKER`], et coût libéré. `None` : aucune image gardée. -fn drop_kept_images(msgs: &[Message]) -> Option<(Vec, i64)> { +pub fn drop_kept_images(msgs: &[Message]) -> Option<(Vec, i64)> { if !msgs.iter().any(|m| m.img_tokens > 0) { return None; } @@ -441,7 +441,7 @@ fn drop_kept_images(msgs: &[Message]) -> Option<(Vec, i64)> { Some((out, freed)) } -fn log_kept_images_dropped(phase: &str, freed: i64) { +pub fn log_kept_images_dropped(phase: &str, freed: i64) { eprintln!("[images] {phase} : images gardées retirées ({freed} jetons)"); } @@ -510,6 +510,57 @@ pub(crate) fn strip_context_updates(msgs: Vec) -> Vec { .collect() } +/// `prependCtxUpdate` : la mise à jour du bloc projet (PROJ_SNAPSHOT) posée en +/// tête d'un message `user`, marqué `ctx_upd` ; `None` : pas un message où la +/// poser. +pub(crate) fn prepend_ctx_update(m: &Message, block: &str) -> Option { + if m.role != "user" { + return None; + } + let mut out = m.clone(); + match &m.content { + Some(Value::String(c)) => out.content = Some(Value::String(format!("{block}{c}"))), + Some(Value::Array(parts)) => { + let mut v = Vec::with_capacity(parts.len() + 1); + v.push(serde_json::json!({ "type": "text", "text": block })); + v.extend(parts.iter().cloned()); + out.content = Some(Value::Array(v)); + } + _ => return None, + } + out.ctx_upd = true; + Some(out) +} + +/// `replaceProjectHead` : le bloc projet de tête (hors rappel des pages lues, +/// qui appartient à l'historique) remplacé par `block`, à la même place. +pub(crate) fn replace_project_head(msgs: Vec, block: &[Message]) -> Vec { + let end = msgs.iter().take_while(|m| m.role == "system").count(); + let mut out = Vec::with_capacity(msgs.len() + block.len()); + let mut inserted = false; + let mut rest = msgs.into_iter(); + for m in rest.by_ref().take(end) { + let reminder = matches!(&m.content, Some(Value::String(s)) if s.starts_with(super::wire::MEM_REMINDER_PREFIX)); + if super::wire::is_project_system(&m) && !reminder { + if !inserted { + out.extend(block.iter().cloned()); + inserted = true; + } + continue; + } + if reminder && !inserted { + out.extend(block.iter().cloned()); + inserted = true; + } + out.push(m); + } + if !inserted { + out.extend(block.iter().cloned()); + } + out.extend(rest); + out +} + // ---- bornes et assemblage -------------------------------------------------- /// `compactBounds` : frontières tête/queue. Tête = messages système de tête ; @@ -853,6 +904,9 @@ struct WireMsg { tool_calls: Option>, } +/// Réponse lue mais sans choix. +pub const ERR_EMPTY_SUMMARY: &str = "résumé: réponse vide"; + /// Décodage d'une réponse non streamée de /v1/chat/completions, comme /// `postSummary` : seule la PREMIÈRE valeur JSON compte (un octet parasite /// après elle est ignoré), aucun choix → « résumé: réponse vide ». @@ -866,7 +920,7 @@ pub fn parse_summary_response(raw: &[u8]) -> Result Some(Ok(r)) => r, }; let Some(ch) = resp.choices.and_then(|c| c.into_iter().next()) else { - return Err(SummaryError::Other("résumé: réponse vide".into())); + return Err(SummaryError::Other(ERR_EMPTY_SUMMARY.into())); }; Ok(SummaryChoice { content: ch.message.content.unwrap_or_default(), @@ -1841,6 +1895,77 @@ impl Compactor<'_, S, R> { #[cfg(test)] mod tests { use super::*; + + // ── PROJ_SNAPSHOT (lot 34, cas de chat_projsnap_test.go) ── + + #[test] + fn mise_a_jour_en_tete_du_message() { + let block = format!("{CTX_UPD_OPEN} at=\"x\">\n+ a\n{CTX_UPD_CLOSE}\n\n"); + let m = prepend_ctx_update(&um("suite"), &block).expect("message user"); + assert!(m.ctx_upd); + assert_eq!(m.content, Some(Value::String(format!("{block}suite")))); + // Retirée à l'identique. + assert_eq!(without_ctx_update(&m).unwrap().content, um("suite").content); + let parts = Message { + role: "user".into(), + content: Some( + serde_json::json!([{"type": "image_url", "image_url": {"url": "loki-img:a.png"}}]), + ), + ..Default::default() + }; + let m = prepend_ctx_update(&parts, &block).unwrap(); + assert_eq!( + m.content.as_ref().unwrap()[0], + serde_json::json!({"type": "text", "text": block}) + ); + assert_eq!(m.content.as_ref().unwrap()[1]["type"], "image_url"); + assert!(prepend_ctx_update(&tm("r"), &block).is_none()); + let empty = Message { + role: "user".into(), + ..Default::default() + }; + assert!(prepend_ctx_update(&empty, &block).is_none()); + } + + #[test] + fn bloc_projet_remplace_a_sa_place() { + use super::super::wire::{MEM_INDEX_PREFIX, MEM_REMINDER_PREFIX, PROJECT_CONTEXT_PREFIX}; + let sys = |c: String| Message { + role: "system".into(), + content: Some(Value::String(c)), + ..Default::default() + }; + let msgs = vec![ + sys("base".into()), + sys(format!("{PROJECT_CONTEXT_PREFIX} — vieux")), + sys(format!("{MEM_INDEX_PREFIX} vieux")), + sys(format!("{MEM_REMINDER_PREFIX} a.md")), + um("q"), + ]; + let block = vec![sys(format!("{MEM_INDEX_PREFIX} neuf"))]; + let out = replace_project_head(msgs, &block); + let got: Vec = out.iter().map(msg_text).collect(); + assert_eq!( + got, + [ + "base".to_string(), + format!("{MEM_INDEX_PREFIX} neuf"), + format!("{MEM_REMINDER_PREFIX} a.md"), + "q".into() + ] + ); + // Bloc vide au départ : inséré après le système commun. + let out = replace_project_head(vec![sys("base".into()), um("q")], &block); + assert_eq!(msg_text(&out[1]), format!("{MEM_INDEX_PREFIX} neuf")); + assert_eq!(msg_text(&out[2]), "q"); + // Rappel des pages lues avant tout bloc : le bloc se pose devant lui. + let out = replace_project_head( + vec![sys(format!("{MEM_REMINDER_PREFIX} a.md")), um("q")], + &block, + ); + assert_eq!(msg_text(&out[0]), format!("{MEM_INDEX_PREFIX} neuf")); + assert_eq!(msg_text(&out[1]), format!("{MEM_REMINDER_PREFIX} a.md")); + } use crate::chat::types::{ToolCall, ToolCallFunc, ToolFunction}; use serde_json::json; use std::sync::atomic::{AtomicBool, Ordering}; diff --git a/rust/loki-rs/src/chat/crittool.rs b/rust/loki-rs/src/chat/crittool.rs new file mode 100644 index 0000000..01c9696 --- /dev/null +++ b/rust/loki-rs/src/chat/crittool.rs @@ -0,0 +1,176 @@ +//! L'outil `criteria` du mode Code (lot 38) — port de `toolCriteria` +//! (`code_criteria.go`) : le modèle pose, met à jour, liste ou efface les +//! critères d'acceptation de la discussion (`chat/crit:`, scellés comme +//! le contenu des discussions). Seule la passe de vérification peut marquer +//! un critère `passed`. Chaque changement part aussi à l'écran (delta +//! `criteria`, `critNotify`). + +use serde_json::{Map, Value, json}; + +use super::persist::BK_CHAT; +use super::verify::{Criterion, crit_list, crit_render}; +use crate::store::Store; + +/// `critMaxCount`. +pub const CRIT_MAX_COUNT: usize = 20; + +/// L'issue d'un appel : le texte rendu au modèle, et la liste à pousser à +/// l'écran quand elle a changé (vide : effacée). +#[derive(Debug, Default, PartialEq)] +pub struct CritOut { + pub result: String, + pub notify: Option>, +} + +impl CritOut { + fn text(s: impl Into) -> Self { + CritOut { + result: s.into(), + notify: None, + } + } +} + +/// `strconv.Atoi` (signe accepté) ; 0 sinon, comme l'id ignoré du Go. +fn go_atoi(s: &str) -> i64 { + let digits = s.strip_prefix(['+', '-']).unwrap_or(s); + if digits.is_empty() || !digits.bytes().all(|c| c.is_ascii_digit()) { + return 0; + } + s.parse().unwrap_or(0) +} + +/// `strings.TrimSpace`. +fn go_trim(s: &str) -> &str { + s.trim_matches(char::is_whitespace) +} + +/// `critSave` : une liste vide supprime la clé ; une erreur est ignorée, +/// comme dans le Go. +async fn crit_save(store: &Store, conv: &str, list: &[Criterion]) { + let key = format!("crit:{conv}"); + if list.is_empty() { + let _ = store.del(BK_CHAT, &key).await; + return; + } + let _ = store + .put_seal( + BK_CHAT, + &key, + crate::gojson::marshal(&list.to_vec()).into_bytes(), + ) + .await; +} + +/// `toolCriteria` : `allow_pass`, la passe de vérification seule. +pub async fn exec( + store: &Store, + conv: &str, + args: &Map, + allow_pass: bool, +) -> CritOut { + let s = |k: &str| args.get(k).and_then(Value::as_str).unwrap_or(""); + let action = s("action"); + let mut list = crit_list(store, conv).await; + match action { + "list" | "" => { + if list.is_empty() { + return CritOut::text("[aucun critère]"); + } + CritOut::text(crit_render(&list)) + } + "add" => { + let raw: &[Value] = args + .get("texts") + .and_then(Value::as_array) + .map_or(&[], Vec::as_slice); + let mut next = 1; + for c in &list { + if c.id >= next { + next = c.id + 1; + } + } + let mut added = 0; + for r in raw { + let Some(t) = r.as_str().map(go_trim).filter(|t| !t.is_empty()) else { + continue; + }; + if list.len() >= CRIT_MAX_COUNT { + break; + } + list.push(Criterion { + id: next, + text: t.to_string(), + status: "pending".into(), + note: String::new(), + }); + next += 1; + added += 1; + } + if added == 0 { + return CritOut::text("[erreur] aucun critère fourni (texts)"); + } + crit_save(store, conv, &list).await; + CritOut { + result: format!("[ok] {added} critère(s) ajouté(s)\n{}", crit_render(&list)), + notify: Some(list), + } + } + "set" => { + // id numérique ou chaîne (« "2" », « "#2" »). + let id = match args.get("id") { + Some(Value::Number(n)) => n.as_f64().map_or(0, |v| v as i64), + Some(Value::String(v)) => { + let t = go_trim(v); + go_atoi(t.strip_prefix('#').unwrap_or(t)) + } + _ => 0, + }; + let status = s("status"); + let note = s("note"); + if status == "passed" && !allow_pass { + return CritOut::text( + "[refusé] Seule la passe de vérification peut marquer un critère passed. Termine ton implémentation ; la vérification suivra.", + ); + } + let Some(i) = list.iter().position(|c| c.id == id) else { + return CritOut::text(format!("[erreur] critère #{id} inconnu")); + }; + if !status.is_empty() { + list[i].status = status.to_string(); + } + if !note.is_empty() { + list[i].note = go_trim(note).to_string(); + } + crit_save(store, conv, &list).await; + let result = format!("[ok] critère #{id} → {}", list[i].status); + CritOut { + result, + notify: Some(list), + } + } + "clear" => { + crit_save(store, conv, &[]).await; + CritOut { + result: "[ok] critères effacés".into(), + notify: Some(Vec::new()), + } + } + a => CritOut::text(format!("[erreur] action inconnue : {a}")), + } +} + +/// Le delta `critNotify` : la liste (vide : `[]`) et l'heure (ms). +pub fn notify_delta(list: &[Criterion], ts_ms: i64) -> Map { + let mut d = Map::new(); + d.insert("criteria".into(), json!(list)); + d.insert("ts".into(), json!(ts_ms)); + d +} + +/// Maintenant, en ms Unix. +pub fn now_ms() -> i64 { + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .map_or(0, |d| d.as_millis() as i64) +} 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/chat/generate.rs b/rust/loki-rs/src/chat/generate.rs index a63f0ef..a0f1cbe 100644 --- a/rust/loki-rs/src/chat/generate.rs +++ b/rust/loki-rs/src/chat/generate.rs @@ -22,11 +22,20 @@ //! corrections du builder) suit le tour, avant la compaction de fin //! ([`super::verify`]) ; toute compaction rend l'état des critères au résumé. //! -//! Écarts assumés (restent au Go) : PROJ_SNAPSHOT et KEEP_TURN_IMAGES -//! (refusés par `/turn/view`), préchauffage, télémétrie, motifs -//! `retry_patterns` personnalisés. +//! Le bloc projet figé (PROJ_SNAPSHOT, lot 34) : le Go décide avec la vue du +//! tour, la façade range l'instantané et pose les mises à jour +//! ([`ChatService::assemble_turn`]). Le préchauffage (PREWARM, lot 33) part en +//! fin de tour ([`super::prewarm`]). +//! Les images gardées (KEEP_TURN_IMAGES, lot 32) +//! partent en début et en fin de tour quand il le faut +//! ([`ChatService::kept_images_turn_start`]). La télémétrie des complétions +//! (tours, vérifications, résumés) rejoint l'anneau tenu ici +//! ([`crate::perfring`], lot 39) ; les motifs `retry_patterns` personnalisés +//! sont lus au démarrage ([`retry_patterns`]). Chaque requête au moteur local +//! part sur le slot que rend le Go (SIDE_SLOT, rechargement SLOT_PERSIST, +//! lot 31). -use std::collections::VecDeque; +use std::collections::{HashMap, VecDeque}; use std::future::Future; use std::pin::Pin; use std::sync::{Arc, Mutex}; @@ -38,15 +47,20 @@ use tokio::sync::watch; use super::compact::{ CompactCaps, CompactConfig, CompactOpts, Compactor, TurnView, compact_needed, compact_opts_for, - ctx_pending, estimate_tokens, log_compact, log_ctx_estimate, strip_context_updates, + ctx_pending, drop_kept_images, estimate_tokens, log_compact, log_ctx_estimate, + log_kept_images_dropped, prepend_ctx_update, strip_context_updates, }; use super::engine::EngineHttp; use super::history::{ drop_stray_nudges, history_from_view, inject_prefix, remind_read_mem_pages, tool_used_delta, }; -use super::host::{ChatShared, EngineSummarizer, GoTurnHost, StoreRecall, TurnCompact}; +use super::host::{ + ChatShared, EngineSummarizer, GoTurnHost, SnapTurn, StoreRecall, TurnCompact, keep_images_state, +}; use super::journal::Delta; +use super::keepimg::{has_kept_images, keep_turn_images_on}; use super::lease::{EngineLease, LEASE_TTL, LeaseError, LeaseSignal, engine_stop}; +use super::persist::ProjSnapshot; use super::sessions::{GoSessionHooks, Sessions}; use super::turn::{Cancel, TurnConfig, run_turn}; use super::types::{Message, ReasoningEcho, StreamEvent, Tool}; @@ -166,6 +180,42 @@ pub(super) struct TurnEnv { pub(super) summarizer: EngineSummarizer, } +/// La décision PROJ_SNAPSHOT rendue avec la vue du tour (`projsnap`). +#[derive(Debug, Clone, Default, Deserialize)] +pub(super) struct SnapDecision { + /// Instantané neuf : blocs `` retirés de l'historique. + #[serde(default)] + pub(super) refresh: bool, + /// Mise à jour à poser en tête du dernier message utilisateur. + #[serde(default)] + pub(super) delta: String, + /// L'instantané à ranger (absent en préchauffage). + #[serde(default)] + pub(super) snap: Option, + #[serde(default)] + pub(super) key: String, + #[serde(default)] + pub(super) sys_hash: String, +} + +/// Les pièces de la vue d'envoi. +pub(super) struct ViewParts { + pub(super) head: Vec, + pub(super) prefix: String, + pub(super) tools: Vec, + pub(super) snap: Option, +} + +/// Une étape assemblée : ce qui part, les outils, l'historique d'où part la +/// vue (`historyFromView`), et de quoi rafraîchir le bloc projet en plein +/// tour (PROJ_SNAPSHOT). +pub(super) struct Assembled { + pub(super) sent: Vec, + pub(super) tools: Vec, + pub(super) hist: Vec, + pub(super) snap_turn: Option, +} + /// Le chat tenu par la façade : la discussion ouverte, ses tours et sa file. pub struct ChatService { pub sessions: Arc>, @@ -406,6 +456,55 @@ impl ChatService { } } + /// `keptImagesTurnStart` : début (ou fin) d'un tour. Les images gardées + /// (KEEP_TURN_IMAGES) sont retirées d'abord — avant toute compaction, qui + /// ne résume alors que si c'est encore nécessaire — quand le contexte la + /// réclame, ou quand elles ne doivent plus partir : clé retirée, vision + /// absente (un moteur sans projecteur refuserait la requête). Rend + /// l'historique et le contexte à jour. + async fn kept_images_turn_start( + &self, + epoch: i64, + cfg: &HashMap, + ccfg: &CompactConfig, + msgs: Vec, + ctx_used: i64, + peak: i64, + ) -> (Vec, i64) { + if !has_kept_images(&msgs) { + return (msgs, ctx_used); + } + if keep_turn_images_on(cfg) + && matches!(keep_images_state(&self.shared.store).await, Some((true, _))) + && !compact_needed(ccfg, &msgs, ctx_used, peak) + { + return (msgs, ctx_used); + } + let Some((out, freed)) = drop_kept_images(&msgs) else { + return (msgs, ctx_used); + }; + log_kept_images_dropped("début-tour", freed); + let ctx_used = if ctx_used > 0 { + (ctx_used - freed).max(1) + } else { + ctx_used + }; + let conv = self.sessions.conv(); + let cur = conv + .with_state_if(epoch, |st| { + st.messages = out.clone(); + if st.ctx_used > 0 { + st.ctx_used = (st.ctx_used - freed).max(1); + } + st.ctx_used + }) + .unwrap_or(0); + if cur > 0 { + conv.append_delta(epoch, obj("ctx_used", cur)); + } + (out, ctx_used) + } + /// `compactAndPublish` : bannière, journal, installation du résultat, /// jauge de contexte. Rend l'historique compacté, `None` s'il n'a pas /// changé. @@ -463,23 +562,34 @@ impl ChatService { Some(compacted) } - /// `/internal/v1/turn/view` : tête (prompt du preset, consignes, - /// projet), préambule et outils du tour ; `Err` : le message à journaliser. + /// Les pièces de la vue d'envoi (`/internal/v1/turn/view`) : tête du Go + /// (prompt du preset, consignes, projet), préambule, outils ; avec + /// PROJ_SNAPSHOT et l'agent, la décision sur le bloc figé (`snap` : + /// l'instantané de la discussion ; `dry` : préchauffage, rien à ranger). pub(super) async fn turn_view( &self, caps: &Value, - ) -> Result<(Vec, String, Vec), String> { + snap: Option<&ProjSnapshot>, + dry: bool, + ) -> Result { match self .shared .store - .internal_post("/internal/v1/turn/view", &json!({ "caps": caps })) + .internal_post( + "/internal/v1/turn/view", + &json!({ "caps": caps, "snap": snap, "dry": dry }), + ) .await { - Ok((s, v)) if s.as_u16() == 200 => Ok(( - serde_json::from_value::>(v["head"].clone()).unwrap_or_default(), - v["prefix"].as_str().unwrap_or("").to_string(), - serde_json::from_value::>(v["tools"].clone()).unwrap_or_default(), - )), + Ok((s, v)) if s.as_u16() == 200 => Ok(ViewParts { + head: serde_json::from_value::>(v["head"].clone()).unwrap_or_default(), + prefix: v["prefix"].as_str().unwrap_or("").to_string(), + tools: serde_json::from_value::>(v["tools"].clone()).unwrap_or_default(), + snap: v + .get("projsnap") + .filter(|p| p.is_object()) + .and_then(|p| serde_json::from_value(p.clone()).ok()), + }), Ok((_, v)) => Err(v["error"] .as_str() .unwrap_or("vue du tour refusée") @@ -488,13 +598,102 @@ impl ChatService { } } + /// `turnView` : la séquence envoyée pour une étape de la discussion (tour, + /// relance du builder) — tête du Go, historique, préambule. Avec + /// PROJ_SNAPSHOT : l'instantané de la discussion rangé, rafraîchi (blocs + /// `` retirés de l'historique), ou complété d'une mise à + /// jour posée en tête du dernier message utilisateur, dans la discussion + /// et dans `msgs`. Sans la clé, une discussion qui porte encore des blocs + /// d'une époque où elle servait en est nettoyée. + pub(super) async fn assemble_turn( + &self, + epoch: i64, + caps: &Value, + mut msgs: Vec, + ) -> Result { + let conv = self.sessions.conv(); + let snap = conv.with_state(|st| st.proj_snap.clone()); + let v = self.turn_view(caps, snap.as_ref(), false).await?; + let agent = caps.get("Agent").and_then(Value::as_bool).unwrap_or(false); + let mut snap_turn = None; + let hist = match v.snap { + Some(d) => { + let applied = conv.with_state_if(epoch, |st| { + if d.refresh { + st.messages = strip_context_updates(std::mem::take(&mut st.messages)); + st.proj_snap = d.snap.clone(); + return (true, false); + } + if d.delta.is_empty() { + return (false, false); + } + // Pas de message utilisateur où la poser : l'état annoncé + // ne bouge pas, elle partira au tour suivant. + let Some(m) = st + .messages + .last() + .and_then(|m| prepend_ctx_update(m, &d.delta)) + else { + return (false, false); + }; + if let Some(last) = st.messages.last_mut() { + *last = m; + } + st.proj_snap = d.snap.clone(); + (false, true) + }); + if let Some((refresh, updated)) = applied { + if refresh { + msgs = strip_context_updates(msgs); + } + if updated + && let Some(m) = msgs.last().and_then(|m| prepend_ctx_update(m, &d.delta)) + && let Some(last) = msgs.last_mut() + { + *last = m; + } + } + snap_turn = Some(SnapTurn { + caps: caps.clone(), + key: d.key, + sys_hash: d.sys_hash, + }); + msgs + } + None => { + if agent { + conv.with_state_if(epoch, |st| { + if st.proj_snap.is_some() || st.messages.iter().any(|m| m.ctx_upd) { + st.proj_snap = None; + st.messages = strip_context_updates(std::mem::take(&mut st.messages)); + } + }); + } + strip_context_updates(msgs) + } + }; + let mut sent = v.head; + sent.extend(hist.iter().cloned()); + Ok(Assembled { + sent: inject_prefix(sent, &v.prefix), + tools: v.tools, + hist, + snap_turn, + }) + } + /// Configuration, point de chat et résumeur d'un tour (lus une fois). pub(super) async fn turn_env(&self) -> TurnEnv { let store = &self.shared.store; 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 { @@ -503,6 +702,11 @@ impl ChatService { cfg: cfg.clone(), patterns: self.shared.patterns.clone(), port, + go: Some(( + store.clone(), + self.sessions.conv().id(), + self.shared.perf.clone(), + )), }; TurnEnv { cfg, @@ -619,7 +823,7 @@ impl ChatService { /// Changement de projet (`/api/projects`, action switch), remis par le /// Go : refusé pendant un tour. - pub async fn project_switch(&self, slug: &str) -> Result<(), Refused> { + pub async fn project_switch(self: &Arc, slug: &str) -> Result<(), Refused> { if self.sessions.conv().is_generating() { return Err(Refused { status: 409, @@ -632,7 +836,9 @@ impl ChatService { .map_err(|e| Refused { status: 400, error: e.to_string(), - }) + })?; + self.prewarm_after_switch().await; + Ok(()) } /// Suppression d'un projet (`deleteProject`) : ses discussions d'abord, @@ -687,6 +893,9 @@ impl ChatService { ); let local = !ep.external; let conv_id = conv.id(); + // Capacités demandées pour ce tour, retenues en mémoire (jamais + // persistées) pour préchauffer le suivant (PREWARM). + self.shared.prewarm.note_caps(&conv_id, prep.caps.clone()); let agent = prep.cap("Agent"); let mem_off = prep.turn_caps.get("Mem").and_then(Value::as_str) == Some("off"); let ccaps = CompactCaps { @@ -708,10 +917,15 @@ impl ChatService { let peak = std::mem::take(&mut st.gen_peak); (msgs, used, peak) }); - let Some((mut msgs, ctx_used, peak)) = snap else { + let Some((msgs, ctx_used, peak)) = snap else { lease.release().await; return; }; + // KEEP_TURN_IMAGES : images gardées retirées d'abord si le contexte le + // demande ou si elles ne doivent plus partir. + let (mut msgs, ctx_used) = self + .kept_images_turn_start(epoch, &cfg, &ccfg, msgs, ctx_used, peak) + .await; // Compaction de début de tour. let mut start_est = ctx_used; @@ -751,30 +965,21 @@ impl ChatService { } } - // Vue d'envoi : tête du Go (prompt du preset, consignes, projet), - // l'historique sans bloc , le préambule. - let (head, prefix, tools) = match self.turn_view(&prep.turn_caps).await { - Ok(v) => v, + // Vue d'envoi : tête du Go (prompt du preset, consignes, projet ou + // bloc figé), l'historique, le préambule. + let Assembled { + sent, + tools, + hist, + snap_turn, + } = match self.assemble_turn(epoch, &prep.turn_caps, msgs).await { + Ok(a) => a, Err(e) => { conv.append_delta(epoch, obj("error", e)); self.finish(epoch, &cancel, lease, turn_start).await; return; } }; - if agent { - // Une discussion qui porte encore des blocs d'une époque où - // PROJ_SNAPSHOT servait en est nettoyée (comme assembleTurn). - conv.with_state_if(epoch, |st| { - if st.proj_snap.is_some() || st.messages.iter().any(|m| m.ctx_upd) { - st.proj_snap = None; - st.messages = strip_context_updates(std::mem::take(&mut st.messages)); - } - }); - } - let hist = strip_context_updates(msgs); - let mut sent = head; - sent.extend(hist.iter().cloned()); - let sent = inject_prefix(sent, &prefix); let temperature = if prep.temperature == 0.0 { 0.7 @@ -811,7 +1016,21 @@ impl ChatService { kwargs: reasoning.kwargs.clone().unwrap_or_default(), }, queued: Box::new(move || me.drain_queued(epoch)), - perf_kind: "main", + deltas: { + let me = self.clone(); + 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. @@ -890,6 +1109,9 @@ impl ChatService { } } }; + // Bloc projet rafraîchi par une compaction en cours de tour + // (PROJ_SNAPSHOT) : c'est lui que le tour suivant renvoie. + let refreshed = host.take_refreshed_snap(); drop(host); // Vue modèle rangée : outils puis réponse finale — sauf si un reset @@ -898,6 +1120,9 @@ impl ChatService { if let Some(base) = new_base.take() { st.messages = base; } + if let Some(s) = refreshed { + st.proj_snap = Some(s); + } st.messages.extend(res.extra.iter().cloned()); if !content.trim().is_empty() { let a = Message { @@ -961,7 +1186,13 @@ impl ChatService { } // Compaction de fin de tour : la jauge retombe tout de suite, le tour - // suivant démarre avec de la marge. Pas après un stop. + // suivant démarre avec de la marge. Pas après un stop. Les images + // gardées partent d'abord, comme en début de tour. + if !cancel.is_cancelled() { + (msgs, ctx_used) = self + .kept_images_turn_start(epoch, &cfg, &ccfg, msgs, ctx_used, peak) + .await; + } if !cancel.is_cancelled() && compact_needed(&ccfg, &msgs, ctx_used, peak) && !self @@ -1028,6 +1259,9 @@ impl ChatService { { eprintln!("[chat] fin de tour non signalée : {e}"); } + // Plus rien ne suit : le prochain tour peut être préparé pendant + // que l'utilisateur lit (PREWARM, off par défaut). + self.prewarm_kick(super::prewarm::TURN_END).await; } } } @@ -1046,6 +1280,25 @@ pub async fn go_keeps_chat(store: &crate::store::Store) -> Result } } +/// `retryPatterns()` : la surcharge `retry_patterns` de la base si elle se +/// lit, sinon les motifs embarqués. Lue une fois, au démarrage, comme le Go +/// la compile une fois par processus. +pub async fn retry_patterns( + store: &crate::store::Store, +) -> std::sync::Arc { + let raw = store.get_str(BK_STATE, "retry_patterns").await; + match super::stream::RetryPatterns::from_override(&raw) { + Some(p) => { + eprintln!( + "[chat] motifs de relance personnalisés (retry_patterns) : {} motif(s)", + p.len() + ); + std::sync::Arc::new(p) + } + None => super::stream::RetryPatterns::builtin(), + } +} + impl ChatShared { /// L'état partagé des tours, autour de l'écrivain des discussions. /// `img_dir` : `LOKI_HOME/chatimg`. @@ -1054,6 +1307,13 @@ impl ChatShared { store: crate::store::Store, img_dir: std::path::PathBuf, ) -> Self { + // LOKI_HOME : le dossier des images en est un sous-dossier. + let perf = std::sync::Arc::new(crate::perfring::PerfRing::new( + img_dir + .parent() + .map(|p| p.to_path_buf()) + .unwrap_or_default(), + )); ChatShared { http: EngineHttp::new(), store, @@ -1063,6 +1323,17 @@ 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()), + prewarm: Default::default(), + perf, + mcp: None, + tasks: None, + memory: None, + 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 f66f163..51ce101 100644 --- a/rust/loki-rs/src/chat/host.rs +++ b/rust/loki-rs/src/chat/host.rs @@ -3,7 +3,11 @@ //! - envoi au moteur par [`EngineHttp`] (clé de CET endpoint), pannes //! classées et formulées comme `friendlyLLMError` ; //! - outils fichiers et shell exécutés ici ([`crate::tools`], état partagé -//! tenu par le Go) ; les autres par le Go (`/internal/v1/tool/exec`), avec +//! tenu par le Go), outils MCP par le pool de la façade quand elle le tient +//! (lot 36), outils task_* quand elle tient les tâches (lot 37), critères +//! du mode Code (lot 38), outils mémoire quand elle tient la mémoire +//! (lot 40) ; les autres +//! par le Go (`/internal/v1/tool/exec`), avec //! ce qu'il tire du résultat (capture de la bulle, aperçu du navigateur, //! image relayée) ; //! - résultats complets (« voir plus ») confiés à l'écrivain des discussions ; @@ -11,7 +15,11 @@ //! ([`EngineSummarizer`]), blocs archivés pour recall par le Go //! ([`StoreRecall`]) ; //! - images jointes (`loki-img:…`) lues sur disque et ouvertes par le Go -//! (déchiffrées), gardées en cache comme `chatImageDataURL`. +//! (déchiffrées), gardées en cache comme `chatImageDataURL` ; +//! - slots du moteur (lot 31) : avant chaque requête au moteur local, le Go +//! rend le slot où l'envoyer (SIDE_SLOT) et recharge au besoin l'état +//! gardé de la discussion (SLOT_PERSIST) ; la requête est comptée en vol, +//! sur ce slot, jusqu'à la fin de sa lecture. //! //! Écarts assumés : le résumé en continuation (COMPACT_CONTINUATION) demande //! l'état du slot du moteur, tenu par le Go — `slot_holds` rend faux et la @@ -19,8 +27,10 @@ //! 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; use futures_util::StreamExt; use serde_json::{Map, Value, json}; @@ -31,7 +41,7 @@ use super::compact::{ parse_summary_response, }; use super::engine::{EngineHttp, classify, friendly_error}; -use super::persist::{Persister, StoreSink, ToolResJob}; +use super::persist::{Persister, ProjSnapshot, StoreSink, ToolResJob}; use super::stream::{CacheHintFn, PreflightFn, RetryPatterns}; use super::turn::{CompactPhase, PerfMarks, PerfRec, Reply, SendError, ToolOutcome, TurnHost}; use super::types::{AskEvent, DiffLine, Message, Tool}; @@ -39,7 +49,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`). @@ -106,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 { @@ -170,10 +185,29 @@ pub struct EngineSummarizer { pub cfg: HashMap, pub patterns: Arc, pub port: u16, + /// Le Go et la discussion des résumés (requêtes en vol, slot du + /// moteur), et l'anneau de télémétrie (kind=compact). `None` sans base + /// (tests). + pub go: Option<(Store, String, Arc)>, } impl EngineSummarizer { - async fn post(&self, payload: Value) -> Result { + /// `postSummary`. `main` : le résumé prolonge le prompt du slot de la + /// discussion (continuation) ; sinon c'est un prompt neuf, sur le second + /// slot quand il est en service (SIDE_SLOT). + async fn post(&self, mut payload: Value, main: bool) -> Result { + let mut _gate = None; + if !self.ep.external + && let Some((store, _, _)) = &self.go + { + let slot = engine_slot_for(store, "", main).await; + if let Some(n) = slot + && let Some(o) = payload.as_object_mut() + { + o.insert("id_slot".into(), Value::from(n)); + } + _gate = engine_begin(store, "", slot == Some(1), false).await; + } let body = serde_json::to_vec(&payload).unwrap_or_default(); let resp = self .http @@ -200,7 +234,25 @@ impl EngineSummarizer { body: String::from_utf8_lossy(b).trim().to_string(), }); } - parse_summary_response(&raw) + let out = parse_summary_response(&raw); + // Télémétrie de la compaction : réponse non streamée, timings et usage + // à la racine, lus sans échec possible — dès que la réponse se lit. + let decoded = match &out { + Ok(_) => true, + Err(SummaryError::Other(e)) => e == super::compact::ERR_EMPTY_SUMMARY, + Err(_) => false, + }; + if decoded && let Some((store, conv, ring)) = &self.go { + let (complete, stats) = super::perf::wire_stats(&raw); + let rec = PerfRec { + complete, + external: self.ep.external, + stats, + ..Default::default() + }; + super::perf::record(store, ring, super::perf::KIND_COMPACT, conv, &rec).await; + } + out } } @@ -226,7 +278,7 @@ impl Summarizer for EngineSummarizer { { o.remove("chat_template_kwargs"); } - self.post(p).await + self.post(p, false).await } async fn summarize_continuation( @@ -253,7 +305,7 @@ impl Summarizer for EngineSummarizer { o.remove("stream_options"); o.insert("max_tokens".into(), json!(req.max_tokens)); } - self.post(p).await + self.post(p, true).await } fn slot_holds(&self, _conv: &str) -> bool { @@ -284,6 +336,53 @@ 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, + /// Préchauffage (PREWARM) : celui en vol, capacités des discussions. + pub prewarm: super::prewarm::Prewarm, + /// L'anneau de télémétrie des complétions (lot 39) : celles de la + /// façade, et celles que le Go lui confie. + pub perf: Arc, + /// Le pool MCP, quand la façade le tient (LOKI_MCP=rust) : les appels + /// `mcp__*` du tour y vont directement (lot 36). + pub mcp: Option>, + /// Les tâches, quand la façade les tient (LOKI_TASKS=rust) : les outils + /// `task_*` du tour s'exécutent ici (lot 37). + pub tasks: Option>, + /// 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, + /// Les jobs d'arrière-plan du mode Code (lot 43). + 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, +} + +impl ChatShared { + /// [`Self::go_version`] : `/internal/v1/auth`, retenue une fois lue. + pub async fn go_version(&self) -> String { + if let Some(v) = self.go_version.get() { + return v.clone(); + } + let v = self + .store + .internal_json(hyper::Method::GET, "/internal/v1/auth", None) + .await + .ok() + .and_then(|v| v.get("version").and_then(Value::as_str).map(str::to_string)) + .unwrap_or_default(); + if !v.is_empty() { + let _ = self.go_version.set(v.clone()); + } + v + } } /// Ce que la compaction sait du tour. @@ -314,14 +413,123 @@ pub struct GoTurnHost { pub compact: TurnCompact, /// Messages mis en file par l'utilisateur pendant le tour. 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: 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, + /// La requête qui part va sur le second slot (posé par `engine_slot`, + /// lu par `send` pour le ticket « en vol »). + pub side_slot: AtomicBool, + /// PROJ_SNAPSHOT : la clé et le système du tour (`None` sans la clé), et + /// 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 +/// le bloc projet quand une compaction réécrit l'historique en plein tour +/// (`projSnapTurn`). +#[derive(Debug, Clone)] +pub struct SnapTurn { + pub caps: Value, + pub key: String, + pub sys_hash: String, +} + +impl GoTurnHost { + /// L'instantané posé en cours de tour (`takeRefreshed`), s'il y en a un. + pub fn take_refreshed_snap(&self) -> Option { + self.snap_refreshed + .lock() + .unwrap_or_else(|e| e.into_inner()) + .take() + } +} + +/// Délai d'une demande de slot : le rechargement SLOT_PERSIST relit jusqu'à +/// 8 Gio (`slotPersistRestoreTimeout`, deux minutes). +const SLOT_ASK_TIMEOUT: Duration = Duration::from_secs(180); + +/// `engineSlotFor` demandé au Go (`/internal/v1/turn/slot`). `main` : fil de +/// la discussion ; `conv` (étape d'un tour seulement) : le Go recharge +/// d'abord l'état SLOT_PERSIST de cette discussion. Go injoignable : aucun +/// `id_slot`, la requête d'avant la clé. +pub async fn engine_slot_for(store: &Store, conv: &str, main: bool) -> Option { + match store + .internal_post_within( + "/internal/v1/turn/slot", + &json!({ "conv": conv, "main": main }), + SLOT_ASK_TIMEOUT, + ) + .await + { + Ok((code, v)) if code.is_success() => v["slot"].as_i64().and_then(|n| u8::try_from(n).ok()), + Ok((code, v)) => { + eprintln!("[loki-rs] moteur : slot non demandé ({code} {v})"); + None + } + Err(e) => { + eprintln!("[loki-rs] moteur : slot non demandé ({e})"); + None + } + } +} + +/// Ouvre un ticket « en vol » auprès du Go (`engineRequestBeginSide`) : le +/// slot de la discussion n'est pas effacé pendant la requête, et un +/// préchauffage est annulé — sauf `keep_prewarm`, une étape de tour qui le +/// prolonge. `main_conv` : étape d'un tour de cette discussion ; `side` : +/// requête sur le second slot. `None` si le Go ne répond pas — la requête +/// part quand même. +pub async fn engine_begin( + store: &Store, + main_conv: &str, + side: bool, + keep_prewarm: bool, +) -> Option { + match store + .internal_json( + hyper::Method::POST, + "/internal/v1/oai/begin", + Some(&json!({ "main_conv": main_conv, "side": side, "prewarm_keep": keep_prewarm })), + ) + .await + { + Ok(v) => v["ticket"].as_u64().map(|ticket| GateTicket { + store: store.clone(), + ticket, + }), + Err(e) => { + eprintln!("[loki-rs] moteur : requête non comptée en vol ({e})"); + None + } + } +} + +/// KEEP_TURN_IMAGES (lot 32) : la vision est-elle réellement active +/// (projecteur déclaré et vu par le moteur) et le budget des images gardées. +/// Go injoignable : `None` (images éphémères). +pub async fn keep_images_state(store: &Store) -> Option<(bool, i64)> { + match store + .internal_post("/internal/v1/turn/keepimg", &json!({})) + .await + { + Ok((code, v)) if code.is_success() => Some(( + v["vision"].as_bool().unwrap_or(false), + v["budget"].as_i64().unwrap_or(0), + )), + _ => None, + } } /// Rend le ticket « en vol » au Go quand la réponse du moteur est lue ou /// abandonnée (`engineRequestBeginSide` … `end`). -struct GateTicket { +pub struct GateTicket { store: Store, ticket: u64, } @@ -344,34 +552,14 @@ impl Drop for GateTicket { } impl GoTurnHost { - /// Ouvre un ticket « en vol » auprès du Go : le slot de la discussion - /// n'est pas effacé pendant la requête, et un préchauffage est annulé. - /// `None` si le Go ne répond pas — la requête part quand même. - async fn gate_begin(&self) -> Option { - let main_conv = if self.perf_kind == "main" { + async fn gate_begin(&self, keep_prewarm: bool) -> Option { + let main_conv = if self.perf_kind == super::perf::KIND_MAIN { self.conv_id.as_str() } else { "" }; - match self - .shared - .store - .internal_json( - hyper::Method::POST, - "/internal/v1/oai/begin", - Some(&json!({ "main_conv": main_conv })), - ) - .await - { - Ok(v) => v["ticket"].as_u64().map(|ticket| GateTicket { - store: self.shared.store.clone(), - ticket, - }), - Err(e) => { - eprintln!("[loki-rs] moteur : requête non comptée en vol ({e})"); - None - } - } + let side = self.side_slot.load(Ordering::SeqCst); + engine_begin(&self.shared.store, main_conv, side, keep_prewarm).await } } @@ -429,14 +617,271 @@ pub fn see_image_message(label: &str, img: Value) -> Message { } } +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, + 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. + // Critères d'acceptation du mode Code (lot 38) : ceux de la + // discussion du tour ; seule la vérification marque `passed`. + if name == "criteria" { + let verifier = self.caps.get("Role").and_then(Value::as_str) == Some("verifier"); + let out = + super::crittool::exec(&self.shared.store, &self.compact.main_conv, args, verifier) + .await; + if let Some(list) = &out.notify { + (self.deltas)(super::crittool::notify_delta( + list, + super::crittool::now_ms(), + )); + } + return ToolOutcome { + result: out.result, + ..Default::default() + }; + } + // Outils mémoire, quand la façade tient la mémoire (lot 40). + if crate::memory::tools::is_tool(name) + && let Some(mem) = &self.shared.memory + { + let env = crate::memory::tools::MemEnv { + store: &self.shared.store, + mem, + now_ms: crate::memory::now_ms(), + }; + if let Some(o) = crate::memory::tools::exec(&env, name, args, label).await { + return ToolOutcome { + result: o.result, + diff: o.diff, + added: o.added, + removed: o.removed, + ..Default::default() + }; + } + } + // 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() + }; + } + // 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. + 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. + 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" { + 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 + { + let dir = tasks.scripts_dir(); + let env = crate::tasks::tools::ToolEnv { + store: &self.shared.store, + scripts_dir: &dir, + now_ns: tasks.now(), + }; + if let Some(result) = crate::tasks::tools::exec(&env, name, args).await { + return ToolOutcome { + result, + ..Default::default() + }; + } + } + if crate::mcp::config::is_mcp_tool(name) + && let Some(mcp) = &self.shared.mcp + { + let version = self.shared.go_version().await; + return ToolOutcome { + result: mcp.call(&self.shared.store, name, args, &version).await, + ..Default::default() + }; + } + // Outils fichiers et shell : ici (lot 7), l'état partagé restant au Go. + if crate::tools::is_local(name) { + let st = crate::tools::GoToolState { + store: self.shared.store.clone(), + caps: self.caps.clone(), + }; + let code = self + .caps + .get("Code") + .and_then(Value::as_bool) + .unwrap_or(false); + if let Some(out) = crate::tools::exec_local(&st, code, name, args, label).await { + return out; + } + } + match self + .shared + .store + .tool_exec(name, &Value::Object(args.clone()), &self.caps) + .await + { + Ok(v) => tool_outcome(&v, label), + Err(e) => ToolOutcome { + result: format!("[erreur] exécution de l'outil impossible : {e}"), + ..Default::default() + }, + } + } +} + impl TurnHost for GoTurnHost { - async fn send(&self, ep: &ChatEndpoint, body: Vec) -> Result { + 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 { - self.gate_begin().await + 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) => { @@ -476,33 +921,17 @@ impl TurnHost for GoTurnHost { } async fn exec_tool(&self, name: &str, args: &Map, label: &str) -> ToolOutcome { - // Outils fichiers et shell : ici (lot 7), l'état partagé restant au Go. - if crate::tools::is_local(name) { - let st = crate::tools::GoToolState { - store: self.shared.store.clone(), - caps: self.caps.clone(), - }; - let code = self - .caps - .get("Code") - .and_then(Value::as_bool) - .unwrap_or(false); - if let Some(out) = crate::tools::exec_local(&st, code, name, args, label).await { - return out; - } - } - match self - .shared - .store - .tool_exec(name, &Value::Object(args.clone()), &self.caps) - .await + // 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 { - Ok(v) => tool_outcome(&v, label), - Err(e) => ToolOutcome { - result: format!("[erreur] exécution de l'outil impossible : {e}"), + 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 { @@ -574,6 +1003,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; } @@ -590,42 +1052,105 @@ impl TurnHost for GoTurnHost { self.shared.echo_refusals.refuse(model); } + async fn project_refresh(&self, msgs: Vec) -> Vec { + let Some(t) = &self.projsnap else { + return msgs; + }; + let got = self + .shared + .store + .internal_post( + "/internal/v1/turn/projsnap/refresh", + &json!({ "caps": t.caps, "key": t.key, "sys_hash": t.sys_hash }), + ) + .await; + let (block, snap) = match got { + Ok((code, v)) if code.is_success() => ( + serde_json::from_value::>(v["block"].clone()).ok(), + serde_json::from_value::(v["snap"].clone()).ok(), + ), + _ => (None, None), + }; + // Go injoignable : le bloc figé reste, sans les mises à jour (le début + // du prompt change de toute façon) ; l'instantané n'est pas remplacé. + let msgs = super::compact::strip_context_updates(msgs); + match (block, snap) { + (Some(block), Some(snap)) => { + *self + .snap_refreshed + .lock() + .unwrap_or_else(|e| e.into_inner()) = Some(snap); + super::compact::replace_project_head(msgs, &block) + } + _ => msgs, + } + } + + fn prewarm_keeps(&self, prefix: &[u64], payload: &Value) -> bool { + self.perf_kind == super::perf::KIND_MAIN + && self.shared.prewarm.live() + && self + .shared + .prewarm + .keeps(prefix, super::prewarm::opts_hash(payload)) + } + + async fn keep_images(&self) -> Option { + // Fil principal d'une discussion seulement : la vérification ne + // persiste pas son historique. + if !super::keepimg::keep_turn_images_on(&self.cfg) + || self.perf_kind != super::perf::KIND_MAIN + || self.conv_id.is_empty() + { + return None; + } + match keep_images_state(&self.shared.store).await { + Some((true, budget)) => Some(budget), + _ => None, + } + } + + async fn keep_image(&self, m: &Message) -> Option<(Message, i64)> { + let (code, v) = self + .shared + .store + .internal_post("/internal/v1/turn/keepimg/store", &json!({ "msg": m })) + .await + .ok()?; + if !code.is_success() || v["ok"] != true { + return None; + } + let msg: Message = serde_json::from_value(v["msg"].clone()).ok()?; + Some((msg, v["weight"].as_i64().unwrap_or(0))) + } + + async fn engine_slot(&self, ep: &ChatEndpoint) -> Option { + if ep.external { + self.side_slot.store(false, Ordering::SeqCst); + return None; + } + // Étape d'un tour de la discussion : le slot 0, après rechargement de + // l'état gardé (SLOT_PERSIST). Vérification : le second slot. + let main = self.perf_kind == super::perf::KIND_MAIN; + let conv = if main { self.conv_id.as_str() } else { "" }; + let slot = engine_slot_for(&self.shared.store, conv, main).await; + self.side_slot.store(slot == Some(1), Ordering::SeqCst); + slot + } + fn cache_hint(&self) -> Option { None } async fn perf_record(&self, rec: PerfRec) -> PerfMarks { - let probe = rec - .probe - .as_ref() - .map(|p| json!({ "tools": p.tools, "kwargs": p.kwargs, "effort": p.effort })); - let body = json!({ - "kind": self.perf_kind, - "conv": self.conv_id, - "iter": rec.iter, - "complete": rec.complete, - "external": rec.external, - "stats": rec.stats, - "prefix": rec.prefix, - "probe": probe, - }); - match self - .shared - .store - .internal_json(hyper::Method::POST, "/internal/v1/turn/perf", Some(&body)) - .await - { - Ok(v) => PerfMarks { - kind: v["kind"].as_str().unwrap_or("").to_string(), - lost: v["lost"].as_i64(), - lost_after: v["lost_after"].as_str().unwrap_or("").to_string(), - lost_alert: v["lost_alert"].as_bool().unwrap_or(false), - }, - Err(e) => { - eprintln!("[loki-rs] télémétrie : {e}"); - PerfMarks::default() - } - } + super::perf::record( + &self.shared.store, + &self.shared.perf, + self.perf_kind, + &self.conv_id, + &rec, + ) + .await } fn preflight(&self) -> Option { diff --git a/rust/loki-rs/src/chat/keepimg.rs b/rust/loki-rs/src/chat/keepimg.rs new file mode 100644 index 0000000..9deea5a --- /dev/null +++ b/rust/loki-rs/src/chat/keepimg.rs @@ -0,0 +1,272 @@ +//! KEEP_TURN_IMAGES pour le tour tenu par la façade (lot 32) — port de +//! l'état du tour de `chat_keep_images.go` (`keepImages`). +//! +//! Sans la clé, une image montrée par un outil (capture, `see_image`) est +//! ÉPHÉMÈRE : vue pendant le tour, absente de l'historique. Avec la clé, elle +//! est gardée telle qu'envoyée, sous conditions : +//! - fil principal d'une discussion, vision réellement active (demandé au Go +//! à chaque tour) ; +//! - rangée par référence par le Go et relue à l'octet près ; +//! - coût MESURÉ par le moteur (écart de `usage.prompt_tokens` entre deux +//! requêtes, moins l'estimation du texte ajouté), jamais estimé ; +//! - toutes les images gardées tiennent dans 10 % de la fenêtre ; la +//! première qui ne tient pas reste éphémère, et toutes celles qui la +//! suivent dans le tour aussi (le préfixe divergera de toute façon à elle). +//! +//! Le retrait prioritaire par une compaction ou une réduction est dans +//! [`super::compact`] ; celui du début de tour, de la boucle, ici et dans +//! [`super::turn`]. + +use std::collections::{HashMap, HashSet}; + +use super::compact::msg_tokens; +use super::types::{Message, StatsEvent}; +use super::wire::content_parts; + +/// En dessous, la mesure ne vaut rien (écart noyé dans l'erreur +/// d'estimation du texte) : l'image reste éphémère. +pub const KEEP_IMG_MIN_TOKENS: i64 = 16; + +/// `keepTurnImagesOn`. +pub fn keep_turn_images_on(cfg: &HashMap) -> bool { + matches!( + cfg.get("KEEP_TURN_IMAGES") + .map(|v| v.trim().to_lowercase()) + .as_deref(), + Some("on" | "1" | "true" | "yes" | "oui") + ) +} + +/// `keptImageTokens` : coût total des images gardées. +pub fn kept_image_tokens(msgs: &[Message]) -> i64 { + msgs.iter().map(|m| m.img_tokens).sum() +} + +/// `hasKeptImages`. +pub fn has_kept_images(msgs: &[Message]) -> bool { + msgs.iter().any(|m| m.img_tokens > 0) +} + +fn has_image_part(m: &Message) -> bool { + content_parts(m.content.as_ref()) + .iter() + .any(|p| p.get("type").and_then(|t| t.as_str()) == Some("image_url")) +} + +#[derive(Debug, Clone, Copy, PartialEq)] +struct Pend { + msg_idx: usize, + extra_idx: usize, + weight: i64, +} + +/// L'état de KEEP_TURN_IMAGES pendant UN tour ; absent (`None` dans la +/// boucle) sans la clé, hors fil principal ou sans vision. +#[derive(Debug, Default)] +pub struct KeepImages { + /// `keepImgBudget` : part de la fenêtre que les images gardées occupent. + budget: i64, + /// Une image du tour n'a pas été gardée : les suivantes non plus. + stopped: bool, + /// Images de l'étape courante, à l'essai jusqu'à la mesure de la requête + /// qui les montre la première. + pend: Vec, + /// Numéro de cette requête, contexte que la complétion précédente laisse + /// (`ctxAfter`), premier message ajouté depuis. + pend_req: i64, + pend_base: i64, + pend_from: usize, + /// Requêtes envoyées ; messages de la dernière. + req_n: i64, + sent_len: usize, + /// `ctxAfter` de la dernière complétion complète (0 : inconnu). + last_after: i64, +} + +impl KeepImages { + pub fn new(budget: i64) -> Self { + KeepImages { + budget, + ..Default::default() + } + } + + /// Faut-il ranger l'image (ni tour arrêté, ni budget déjà plein) ? + pub fn wants(&self, messages: &[Message]) -> bool { + !self.stopped && kept_image_tokens(messages) < self.budget + } + + /// `relay` : ajoute le message qui relaie une image d'outil. `stored` : + /// l'image rangée par référence et son poids (octets), `None` si elle ne + /// l'a pas été — elle reste alors éphémère, et le tour s'arrête là pour + /// les suivantes. `from` : début des messages de l'étape. + pub fn relay( + &mut self, + messages: &mut Vec, + extra: &mut Vec, + mut m: Message, + from: usize, + stored: Option<(Message, i64)>, + ) { + m.img_relay = true; + if self.stopped { + messages.push(m); + return; + } + let Some((mut r, weight)) = stored.filter(|(_, w)| *w > 0) else { + self.stopped = true; + messages.push(m); + return; + }; + r.img_relay = true; + if self.pend.is_empty() { + self.pend_req = self.req_n + 1; + self.pend_base = self.last_after; + self.pend_from = from; + } + messages.push(r.clone()); + extra.push(r); + self.pend.push(Pend { + msg_idx: messages.len() - 1, + extra_idx: extra.len() - 1, + weight, + }); + } + + /// `sending` : une requête part avec `n` messages. + pub fn sending(&mut self, n: usize) { + self.req_n += 1; + self.sent_len = n; + } + + /// `observe` : une complétion est lue. Mesure le coût des images à + /// l'essai si c'est la requête qui les a montrées la première, et les + /// garde si le budget le permet ; sinon elles redeviennent éphémères. + pub fn observe( + &mut self, + complete: bool, + st: &StatsEvent, + messages: &mut [Message], + extra: &mut Vec, + ) { + let after = if complete && st.prompt_tokens_total > 0 { + st.ctx_after() + } else { + 0 + }; + if !self.pend.is_empty() { + if !self.measure(after, st.prompt_tokens_total, messages, extra) { + self.reject(extra); + } + self.pend.clear(); + } + self.last_after = after; + } + + /// `measure` : répartit le coût mesuré entre les images à l'essai, au + /// prorata de leur poids, et les garde si tout tient dans le budget. + fn measure( + &self, + after: i64, + prompt: i64, + messages: &mut [Message], + extra: &mut [Message], + ) -> bool { + if after <= 0 + || self.pend_base <= 0 + || self.pend_req != self.req_n + || self.pend_from > self.sent_len + || self.sent_len > messages.len() + { + return false; + } + // Ce que la requête a ajouté depuis la précédente, hors images. + let text: i64 = messages[self.pend_from..self.sent_len] + .iter() + .filter(|m| !m.img_relay) + .map(msg_tokens) + .sum(); + let img = prompt - self.pend_base - text; + let weight: i64 = self.pend.iter().map(|p| p.weight).sum(); + let n = self.pend.len() as i64; + if weight <= 0 || img < KEEP_IMG_MIN_TOKENS * n { + return false; + } + if kept_image_tokens(messages) + img > self.budget { + return false; + } + let mut shares = Vec::with_capacity(self.pend.len()); + let mut left = img; + for (i, p) in self.pend.iter().enumerate() { + let share = if i == self.pend.len() - 1 { + left + } else { + img * p.weight / weight + }; + if share < KEEP_IMG_MIN_TOKENS { + return false; + } + left -= share; + shares.push(share); + } + if self.pend.iter().any(|p| { + !messages.get(p.msg_idx).is_some_and(|m| m.img_relay) + || !extra.get(p.extra_idx).is_some_and(|m| m.img_relay) + }) { + return false; + } + for (p, share) in self.pend.iter().zip(shares) { + messages[p.msg_idx].img_tokens = share; + extra[p.extra_idx].img_tokens = share; + } + true + } + + /// `reject` : retire d'`extra` les images à l'essai — éphémères, comme + /// sans la clé — et arrête le tour pour les suivantes. + fn reject(&mut self, extra: &mut Vec) { + if self.pend.is_empty() { + return; + } + self.stopped = true; + let drop: HashSet = self.pend.iter().map(|p| p.extra_idx).collect(); + let mut i = 0; + extra.retain(|_| { + let keep = !drop.contains(&i); + i += 1; + keep + }); + self.pend.clear(); + } + + /// `rewritten` : l'historique vient d'être réécrit en cours de tour et + /// `extra` repart de zéro. Les images à l'essai ne se mesurent plus : + /// éphémères, et le tour s'arrête là pour les suivantes. + pub fn rewritten(&mut self) { + if !self.pend.is_empty() { + self.stopped = true; + } + self.pend.clear(); + } + + /// `finish` : fin du tour. Une image encore à l'essai n'a pas été + /// mesurée : éphémère. + pub fn finish(&mut self, extra: &mut Vec) { + self.reject(extra); + } +} + +/// `publishable` : l'historique publié par une réécriture en cours de tour, +/// sans les relais dont l'image n'est pas gardée. Sans l'état : tel quel. +pub fn publishable(kt: Option<&KeepImages>, msgs: &[Message]) -> Vec { + if kt.is_none() { + return msgs.to_vec(); + } + msgs.iter() + .filter(|m| !(m.img_relay && m.img_tokens == 0 && has_image_part(m))) + .cloned() + .collect() +} + +#[cfg(test)] +mod tests; diff --git a/rust/loki-rs/src/chat/keepimg/tests.rs b/rust/loki-rs/src/chat/keepimg/tests.rs new file mode 100644 index 0000000..9a17d2c --- /dev/null +++ b/rust/loki-rs/src/chat/keepimg/tests.rs @@ -0,0 +1,298 @@ +//! Tests de l'état KEEP_TURN_IMAGES d'un tour (cas de +//! `chat_keep_images_test.go`). + +use super::*; +use crate::chat::compact::IMAGE_LOST_MARKER; +use serde_json::{Value, json}; + +fn um(s: &str) -> Message { + Message { + role: "user".into(), + content: Some(json!(s)), + ..Default::default() + } +} + +fn tm(s: &str) -> Message { + Message { + role: "tool".into(), + tool_call_id: "v1".into(), + content: Some(json!(s)), + ..Default::default() + } +} + +fn img(url: &str) -> Message { + Message { + role: "user".into(), + content: Some(json!([ + {"type": "text", "text": "Voici la capture demandée."}, + {"type": "image_url", "image_url": {"url": url}} + ])), + ..Default::default() + } +} + +fn stats(prompt: i64, gen_tokens: i64) -> StatsEvent { + StatsEvent { + prompt_tokens_total: prompt, + gen_tokens, + ..Default::default() + } +} + +fn kept(tokens: i64) -> Message { + let mut m = img("loki-img:aa.png"); + m.img_relay = true; + m.img_tokens = tokens; + m +} + +/// Une étape `see_image` : première complétion (prompt 1000, 20 générés), +/// résultat d'outil, relais rangé ; rend (état, messages, extra, base du +/// texte ajouté). +fn step(k: &mut KeepImages, stored: bool) -> (Vec, Vec, i64) { + let mut messages = vec![um("regarde img.png")]; + let mut extra = Vec::new(); + k.sending(messages.len()); + k.observe(true, &stats(1000, 20), &mut messages, &mut extra); + let from = messages.len(); + let t = tm("[ok] image chargée : img.png"); + let text = msg_tokens(&t); + messages.push(t.clone()); + extra.push(t); + let s = stored.then(|| (img("loki-img:aa.png"), 1000)); + k.relay( + &mut messages, + &mut extra, + img("data:image/png;base64,AA=="), + from, + s, + ); + k.sending(messages.len()); + (messages, extra, text) +} + +#[test] +fn cle() { + for (v, want) in [ + ("on", true), + (" OUI ", true), + ("1", true), + ("true", true), + ("yes", true), + ("off", false), + ("", false), + ("2", false), + ] { + let cfg = HashMap::from([("KEEP_TURN_IMAGES".to_string(), v.to_string())]); + assert_eq!(keep_turn_images_on(&cfg), want, "{v:?}"); + } + assert!(!keep_turn_images_on(&HashMap::new())); +} + +/// Coût mesuré par le moteur : écart de prompt moins le texte ajouté ; gardée +/// par référence, dans `messages` comme dans `extra`. +#[test] +fn gardee_et_mesuree() { + let mut k = KeepImages::new(3200); + let (mut messages, mut extra, text) = step(&mut k, true); + assert_eq!(extra.len(), 2); + k.observe( + true, + &stats(1020 + text + 300, 5), + &mut messages, + &mut extra, + ); + assert_eq!(extra.len(), 2); + let r = &extra[1]; + assert!(r.img_relay && r.img_tokens == 300, "{r:?}"); + assert_eq!( + r.content.as_ref().unwrap()[1]["image_url"]["url"], + "loki-img:aa.png" + ); + assert_eq!(messages[2].img_tokens, 300); + assert_eq!(kept_image_tokens(&messages), 300); +} + +/// Au-delà du budget, l'image reste éphémère : retirée d'`extra`, gardée +/// dans `messages` (le modèle l'a vue), et le tour s'arrête là. +#[test] +fn budget() { + let mut k = KeepImages::new(200); + let (mut messages, mut extra, text) = step(&mut k, true); + k.observe( + true, + &stats(1020 + text + 300, 5), + &mut messages, + &mut extra, + ); + assert_eq!(extra.len(), 1); + assert_eq!(messages.len(), 3); + assert!(messages[2].img_relay && messages[2].img_tokens == 0); + assert!(k.stopped); + // Une image suivante n'est même pas rangée. + assert!(!k.wants(&messages)); +} + +/// Moteur sans usage, ou complétion coupée : rien à mesurer. +#[test] +fn sans_mesure() { + for (complete, prompt) in [(true, 0), (false, 2000)] { + let mut k = KeepImages::new(3200); + let (mut messages, mut extra, _) = step(&mut k, true); + k.observe(complete, &stats(prompt, 5), &mut messages, &mut extra); + assert_eq!(extra.len(), 1, "complète={complete} prompt={prompt}"); + assert_eq!(kept_image_tokens(&messages), 0); + } + // Première complétion sans usage : pas de base. + let mut k = KeepImages::new(3200); + let mut messages = vec![um("q")]; + let mut extra = Vec::new(); + k.sending(1); + k.observe(true, &stats(0, 0), &mut messages, &mut extra); + let t = tm("[ok]"); + messages.push(t.clone()); + extra.push(t); + k.relay( + &mut messages, + &mut extra, + img("data:x"), + 1, + Some((img("loki-img:b.png"), 9)), + ); + k.sending(messages.len()); + k.observe(true, &stats(5000, 5), &mut messages, &mut extra); + assert_eq!(extra.len(), 1); +} + +/// Requête relancée entre-temps (reprise réseau) : la mesure ne vaut plus. +#[test] +fn requete_relancee() { + let mut k = KeepImages::new(3200); + let (mut messages, mut extra, text) = step(&mut k, true); + k.sending(messages.len()); + k.observe( + true, + &stats(1020 + text + 300, 5), + &mut messages, + &mut extra, + ); + assert_eq!(extra.len(), 1); +} + +/// Rangement refusé : éphémère, marqué relais, et les suivantes aussi. +#[test] +fn rangement_refuse() { + let mut k = KeepImages::new(3200); + let (mut messages, extra, _) = step(&mut k, false); + assert_eq!(extra.len(), 1); + assert!(messages[2].img_relay); + assert!(k.stopped); + let mut extra = extra; + k.relay( + &mut messages, + &mut extra, + img("data:y"), + 1, + Some((img("loki-img:c.png"), 5)), + ); + assert_eq!(extra.len(), 1, "tour arrêté : plus rien de gardé"); + assert!(messages[3].img_relay); +} + +/// Deux images dans l'étape : le coût réparti au prorata des octets, la +/// dernière prend le reste ; une part sous le seuil, tout reste éphémère. +#[test] +fn partage_entre_images() { + let mut k = KeepImages::new(3200); + let mut messages = vec![um("q")]; + let mut extra = Vec::new(); + k.sending(1); + k.observe(true, &stats(1000, 20), &mut messages, &mut extra); + k.relay( + &mut messages, + &mut extra, + img("data:a"), + 1, + Some((img("loki-img:a.png"), 3000)), + ); + k.relay( + &mut messages, + &mut extra, + img("data:b"), + 1, + Some((img("loki-img:b.png"), 1000)), + ); + k.sending(messages.len()); + k.observe(true, &stats(1020 + 401, 5), &mut messages, &mut extra); + assert_eq!( + (extra[0].img_tokens, extra[1].img_tokens), + (300, 101), + "{extra:?}" + ); + let mut k = KeepImages::new(3200); + let mut messages = vec![um("q")]; + let mut extra = Vec::new(); + k.sending(1); + k.observe(true, &stats(1000, 20), &mut messages, &mut extra); + k.relay( + &mut messages, + &mut extra, + img("data:a"), + 1, + Some((img("loki-img:a.png"), 3000)), + ); + k.relay( + &mut messages, + &mut extra, + img("data:b"), + 1, + Some((img("loki-img:b.png"), 10)), + ); + k.sending(messages.len()); + k.observe(true, &stats(1020 + 400, 5), &mut messages, &mut extra); + assert!(extra.is_empty(), "part de 1 jeton : {extra:?}"); +} + +/// Réécriture en cours de tour : une image non gardée n'entre pas dans +/// l'historique publié ; gardée ou déjà retirée, si. Les images à l'essai ne +/// sont plus suivies. +#[test] +fn publication() { + let mut eph = img("data:image/png;base64,AA=="); + eph.img_relay = true; + let mut stub = um(&format!("Image :{IMAGE_LOST_MARKER}")); + stub.img_relay = true; + let msgs = vec![um("q"), tm("a"), eph, tm("b"), kept(50), stub]; + let k = KeepImages::new(3200); + let got = publishable(Some(&k), &msgs); + assert_eq!(got.len(), 5); + assert_eq!(got[2].content, Some(Value::from("b"))); + assert_eq!(publishable(None, &msgs).len(), 6, "sans la clé : tel quel"); + let mut k = KeepImages::new(3200); + let (_, _, _) = step(&mut k, true); + k.rewritten(); + assert!(k.stopped && k.pend.is_empty()); + let mut k = KeepImages::new(3200); + k.rewritten(); + assert!(!k.stopped, "rien à l'essai : le tour continue"); +} + +/// Fin du tour avec une image à l'essai : éphémère. +#[test] +fn fin_de_tour() { + let mut k = KeepImages::new(3200); + let (_, mut extra, _) = step(&mut k, true); + k.finish(&mut extra); + assert_eq!(extra.len(), 1); + assert_eq!(extra[0].role, "tool"); +} + +/// Budget déjà plein : rien à ranger. +#[test] +fn budget_plein_avant_rangement() { + let k = KeepImages::new(200); + assert!(!k.wants(&[um("q"), kept(200)])); + assert!(k.wants(&[um("q"), kept(199)])); +} diff --git a/rust/loki-rs/src/chat/lease.rs b/rust/loki-rs/src/chat/lease.rs index f17fe44..52b89a4 100644 --- a/rust/loki-rs/src/chat/lease.rs +++ b/rust/loki-rs/src/chat/lease.rs @@ -76,7 +76,17 @@ pub struct EngineLease { impl EngineLease { /// Prend le verrou de génération pour `ttl` (borné par le Go à 5 s – 5 min). pub async fn acquire(store: Store, ttl: Duration) -> Result { - let body = json!({ "ttl_ms": ttl.as_millis() as u64 }); + Self::acquire_for(store, ttl, "").await + } + + /// Comme [`Self::acquire`], avec un motif : « bench » — le Go refuse alors + /// les autres avec le message du benchmark, et changer de discussion ne + /// rompt pas le bail. + pub async fn acquire_for(store: Store, ttl: Duration, why: &str) -> Result { + let mut body = json!({ "ttl_ms": ttl.as_millis() as u64 }); + if !why.is_empty() { + body["why"] = json!(why); + } let (status, v) = store .internal_post(PATH_LEASE, &body) .await diff --git a/rust/loki-rs/src/chat/mod.rs b/rust/loki-rs/src/chat/mod.rs index 3a6725c..658ad93 100644 --- a/rust/loki-rs/src/chat/mod.rs +++ b/rust/loki-rs/src/chat/mod.rs @@ -17,20 +17,28 @@ //! - [`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`) ; +//! - [`perf`] : télémétrie des complétions (relevés rangés par le Go). pub mod compact; pub mod conv; +pub mod crittool; pub mod engine; pub mod generate; pub mod history; pub mod host; pub mod journal; +pub mod keepimg; pub mod lease; pub mod nudges; +pub mod perf; pub mod persist; +pub mod prewarm; pub mod sessions; pub mod stream; +pub mod subagent; +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/perf.rs b/rust/loki-rs/src/chat/perf.rs new file mode 100644 index 0000000..f11342d --- /dev/null +++ b/rust/loki-rs/src/chat/perf.rs @@ -0,0 +1,208 @@ +//! Télémétrie des complétions de la façade (lots 29 et 39). Toutes +//! rejoignent l'anneau tenu ici ([`crate::perfring`]) : il les range, +//! calcule la perte de cache contre la précédente de la même discussion et +//! l'alerte au seuil du preset actif ; il nourrit `/api/perf/summary`. +//! +//! Ce module porte ce que la façade relève elle-même : la réponse entière +//! d'un résumé de compaction (`perfRecFromWire`), et le passage des stats +//! d'une complétion à l'anneau (`perfRecFromStats`). Des identifiants et +//! des compteurs, jamais de texte. Rien ne retourne au moteur. + +use serde_json::Value; + +use super::turn::{PerfMarks, PerfRec}; +use super::types::StatsEvent; +use crate::perfring::{PerfRing, perf_alert}; +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"; + +/// `perfRecFromWire` : une réponse NON streamée (compaction), lue d'un +/// bloc, au format des stats d'une complétion streamée. Jamais d'échec : un +/// compteur illisible reste inconnu. Rend aussi si la complétion est +/// complète (un usage, ou des timings). +pub fn wire_stats(raw: &[u8]) -> (bool, StatsEvent) { + let mut s = StatsEvent::default(); + // Seule la première valeur JSON compte (`json.Decoder`). + let v = serde_json::Deserializer::from_slice(raw) + .into_iter::() + .next() + .and_then(Result::ok) + .unwrap_or(Value::Null); + // Une valeur non nulle d'un autre type (chaîne, nombre) alloue quand + // même la structure côté Go avant l'erreur de type : présente, vide. + let has_timings = v.get("timings").is_some_and(|t| !t.is_null()); + if let Some(t) = v.get("timings").filter(|t| t.is_object()) { + let n = |k: &str| perf_num(t.get(k)); + s.prompt_tokens = n("prompt_n").unwrap_or(0.0) as i64; + s.prompt_ms = n("prompt_ms").unwrap_or(0.0); + s.prompt_per_second = n("prompt_per_second").unwrap_or(0.0); + s.gen_tokens = n("predicted_n").unwrap_or(0.0) as i64; + s.gen_per_second = n("predicted_per_second").unwrap_or(0.0); + s.cache_tokens = n("cache_n").map(|f| f as i64); + s.draft_n = n("draft_n").map(|f| f as i64); + s.draft_accepted = n("draft_n_accepted").map(|f| f as i64); + } + let usage = v.get("usage").filter(|u| u.is_object()); + if let Some(u) = usage { + s.prompt_tokens_total = perf_num(u.get("prompt_tokens")).unwrap_or(0.0) as i64; + if s.gen_tokens == 0 { + s.gen_tokens = perf_num(u.get("completion_tokens")).unwrap_or(0.0) as i64; + } + } + if s.cache_tokens.is_none() { + s.cache_tokens = usage + .and_then(|u| u.get("prompt_tokens_details")) + .filter(|d| d.is_object()) + .and_then(|d| perf_num(d.get("cached_tokens"))) + .map(|f| f as i64); + } + (s.prompt_tokens_total > 0 || has_timings, s) +} + +/// `perfNum` : un nombre (ou une chaîne qui en contient un), fini, positif +/// et < 1e12 ; sinon inconnu. +fn perf_num(v: Option<&Value>) -> Option { + let f = match v? { + Value::Number(n) => n.as_f64()?, + Value::String(s) => s.parse::().ok()?, + _ => return None, + }; + (f.is_finite() && (0.0..1e12).contains(&f)).then_some(f) +} + +/// Range la complétion dans l'anneau et rend ce qu'il en tire : la perte +/// de cache, ce qui s'est intercalé, et l'alerte au seuil du preset actif +/// (jamais sur une API externe). La télémétrie ne fait jamais échouer un +/// tour. +pub async fn record( + store: &Store, + ring: &PerfRing, + kind: &str, + conv: &str, + rec: &PerfRec, +) -> PerfMarks { + let kind = if kind.is_empty() { KIND_MAIN } else { kind }; + let s = &rec.stats; + let r = crate::perfring::PerfRec { + kind: kind.to_string(), + conv: conv.to_string(), + iter: rec.iter, + complete: rec.complete, + total: s.prompt_tokens_total, + cached: s.cache_tokens, + new: s.prompt_tokens, + pp_ms: s.prompt_ms, + pp_tps: s.prompt_per_second, + gen_tokens: s.gen_tokens, + tg_tps: s.gen_per_second, + draft_n: s.draft_n, + draft_accepted: s.draft_accepted, + ttft_ms: s.ttft_ms, + ..Default::default() + }; + let prefix: &[u64] = if rec.complete { &rec.prefix } else { &[] }; + let got = ring.record(r, prefix); + let mut alert = false; + if rec.complete && !rec.external && (got.lost.is_some_and(|l| l > 0) || got.cached == Some(0)) { + let cfg = store.config().await; + let stored = crate::gguf::parse_stored_model_dirs( + store + .get(BK_STATE, crate::gguf::STATE_MODEL_DIRS_KEY) + .await + .ok() + .flatten() + .as_deref(), + ); + alert = perf_alert(&got, ring.lost_alert_at(&cfg, &stored)); + } + PerfMarks { + kind: got.kind, + lost: got.lost, + lost_after: got.lost_after, + lost_alert: alert, + } +} + +#[cfg(test)] +mod tests { + use super::super::turn::perf_prefix; + use super::super::types::Message; + use super::*; + use serde_json::json; + + #[test] + fn releve_d_une_reponse_entiere() { + let (complete, s) = wire_stats( + br#"{"timings":{"prompt_n":"120","prompt_ms":50.5,"prompt_per_second":2376,"predicted_n":30,"predicted_per_second":40,"cache_n":900,"draft_n":-1},"usage":{"prompt_tokens":1020,"completion_tokens":31}} parasite"#, + ); + assert!(complete); + assert_eq!( + s, + StatsEvent { + prompt_tokens_total: 1020, + cache_tokens: Some(900), + prompt_tokens: 120, + prompt_ms: 50.5, + prompt_per_second: 2376.0, + gen_tokens: 30, + gen_per_second: 40.0, + ..Default::default() + } + ); + // Sans timings : le cache d'OpenAI, les jetons générés de l'usage. + let (complete, s) = wire_stats( + br#"{"usage":{"prompt_tokens":10,"completion_tokens":3,"prompt_tokens_details":{"cached_tokens":4}}}"#, + ); + assert_eq!( + ( + complete, + s.prompt_tokens_total, + s.gen_tokens, + s.cache_tokens + ), + (true, 10, 3, Some(4)) + ); + // Rien de lisible : incomplète, tout inconnu. + let (complete, s) = wire_stats(b"pas du json"); + assert!(!complete); + assert_eq!(s.cache_tokens, None); + let (complete, s) = wire_stats(br#"{"timings":null,"usage":{"prompt_tokens":1e13}}"#); + assert_eq!((complete, s.prompt_tokens_total), (false, 0)); + // Des timings d'un autre type : présents mais vides, comme le Go. + let (complete, s) = wire_stats(br#"{"timings":"x"}"#); + assert_eq!((complete, s.prompt_tokens), (true, 0)); + } + + #[test] + fn empreintes_cumulees() { + let m = |c: &str| Message { + role: "user".into(), + content: Some(json!(c)), + ..Default::default() + }; + let a = perf_prefix(&[m("a"), m("b")]); + let b = perf_prefix(&[m("a"), m("b"), m("c")]); + assert_eq!(a.len(), 2); + assert_eq!(b[..2], a[..]); + assert_ne!(perf_prefix(&[m("x"), m("b")])[1], a[1]); + assert!(perf_prefix(&[]).is_empty()); + // FNV-1a de référence : le JSON du message, puis l'octet nul. + let mut h = 0xcbf2_9ce4_8422_2325u64; + for c in serde_json::to_vec(&m("a")).unwrap().into_iter().chain([0]) { + h = (h ^ u64::from(c)).wrapping_mul(0x0000_0100_0000_01b3); + } + assert_eq!(perf_prefix(&[m("a")]), vec![h]); + let mut h = 0xcbf2_9ce4_8422_2325u64; + for c in *b"a" { + h = (h ^ u64::from(c)).wrapping_mul(0x0000_0100_0000_01b3); + } + assert_eq!(h, 0xaf63_dc4c_8601_ec8c); + } +} diff --git a/rust/loki-rs/src/chat/prewarm.rs b/rust/loki-rs/src/chat/prewarm.rs new file mode 100644 index 0000000..b0cec26 --- /dev/null +++ b/rust/loki-rs/src/chat/prewarm.rs @@ -0,0 +1,521 @@ +//! PREWARM tenu par la façade (lot 33) — port de `chat_prewarm.go`. +//! +//! Préparer le prochain tour pendant que l'utilisateur lit. Certains +//! recalculs sont inévitables (prompt réécrit par une compaction, dernier +//! message assistant rendu autrement, discussion reprise après une tâche qui +//! a pris le slot) ; sans la clé, ils tombent sur le premier jeton du message +//! suivant. +//! +//! Avec la clé, la façade envoie au moteur local, dès qu'il est libre, la +//! requête du prochain tour telle qu'elle partira — assemblée par les MÊMES +//! fonctions que le tour — suivie d'un message utilisateur sentinelle « . », +//! avec `max_tokens` 1. Le moteur calcule tout le préfixe ; le vrai tour ne +//! calcule plus que son propre message. Rien n'est persisté. +//! +//! Le Go garde ce qui décide si le préchauffage peut partir et ce qui +//! l'annule (`facade_prewarm.go`) : il l'inscrit comme LA requête en vol, et +//! toute requête de Loki qui part ensuite l'annule — sauf un tour de la +//! façade dont la requête le PROLONGE exactement (empreintes des messages, +//! des outils et des arguments du gabarit), qui le suit dans le même slot. +//! Aucune erreur ne remonte. + +use std::collections::{HashMap, HashSet}; +use std::sync::{Arc, Mutex}; +use std::time::Duration; + +use serde_json::{Value, json}; +use tokio::sync::watch; + +use super::compact::{compact_needed, ctx_pending, strip_context_updates}; +use super::generate::ChatService; +use super::history::inject_prefix; +use super::perf::{self, wire_stats}; +use super::turn::{PerfRec, perf_prefix}; +use super::types::Message; +use super::wire::{ + ChatPayloadOpts, build_chat_payload, turn_echo_policy, turn_reasoning, wire_messages, +}; +use crate::engstatus::is_external; + +/// Le message utilisateur qui tient la place du vrai. +pub const SENTINEL: &str = "."; + +/// Déclencheurs (pour le journal). +pub const TURN_END: &str = "fin-tour"; +pub const AFTER_TASK: &str = "après-tâche"; +pub const SWITCH: &str = "discussion"; + +/// PREWARM=full : préparer la discussion ouverte si l'utilisateur y reste. +pub const SWITCH_DELAY: Duration = Duration::from_secs(3); +/// Borne d'un préchauffage (un MoE aux experts en RAM met des minutes sur 20k +/// jetons). Toute vraie requête l'annule bien avant. +pub const TIMEOUT: Duration = Duration::from_secs(15 * 60); +/// Attente de la fin d'un préchauffage remplacé. +const STOP_WAIT: Duration = Duration::from_secs(5); +/// Capacités gardées, une par discussion. +const CAPS_MAX: usize = 64; + +/// `prewarmMode` : « on », « full » ou "" (off). Jamais pour un preset externe. +pub fn prewarm_mode(cfg: &HashMap) -> &'static str { + if is_external(cfg) { + return ""; + } + match cfg + .get("PREWARM") + .map(|v| v.trim().to_lowercase()) + .as_deref() + { + Some("on" | "1" | "true" | "yes" | "oui") => "on", + Some("full") => "full", + _ => "", + } +} + +/// `prewarmOptsKeys` : les champs du corps, hors messages, qui changent le +/// prompt rendu par le gabarit (l'échantillonnage n'y touche pas). +const OPTS_KEYS: [&str; 5] = [ + "tools", + "parallel_tool_calls", + "tool_choice", + "chat_template_kwargs", + "reasoning_effort", +]; + +/// `prewarmOptsHash` : FNV-1a 64 de ces champs. Comparée seulement aux +/// empreintes calculées ici, d'un corps à l'autre. +pub fn opts_hash(payload: &Value) -> u64 { + const OFFSET: u64 = 0xcbf2_9ce4_8422_2325; + const PRIME: u64 = 0x0000_0100_0000_01b3; + let mut h = OFFSET; + let mut eat = |b: &[u8]| { + for &c in b { + h = (h ^ u64::from(c)).wrapping_mul(PRIME); + } + }; + for k in OPTS_KEYS { + eat(k.as_bytes()); + eat(&[0]); + if let Some(v) = payload.get(k) { + eat(&serde_json::to_vec(v).unwrap_or_default()); + } + eat(&[0]); + } + h +} + +/// `withTurnRole(caps, ".")` : en mode Code, le rôle d'un message ordinaire. +pub fn sentinel_caps(caps: &Value) -> Value { + let mut c = caps.clone(); + let code = c.get("Code").and_then(Value::as_bool).unwrap_or(false); + let role_empty = c + .get("Role") + .and_then(Value::as_str) + .unwrap_or("") + .is_empty(); + if code + && role_empty + && let Some(o) = c.as_object_mut() + { + o.insert("Role".into(), Value::from("builder")); + } + c +} + +/// Un préchauffage inscrit au Go : `prefix` couvre ses `n` premiers messages +/// (sans la sentinelle), `opts` les champs qui façonnent le rendu. +#[derive(Debug, Clone, Copy, PartialEq)] +pub struct Run { + pub id: u64, + pub n: usize, + pub prefix: u64, + pub opts: u64, +} + +impl Run { + /// `matches` : la requête dont `prefix` est l'empreinte cumulée des + /// messages prolonge-t-elle exactement ce préchauffage ? + pub fn matches(&self, prefix: &[u64], opts: u64) -> bool { + self.n > 0 + && prefix.len() > self.n + && prefix[self.n - 1] == self.prefix + && opts == self.opts + } +} + +struct Current { + /// Rempli une fois inscrit au Go. + run: Option, + cancel: watch::Sender, + done: watch::Receiver, +} + +/// L'état partagé du préchauffage (un exemplaire par processus). +#[derive(Default)] +pub struct Prewarm { + cur: Mutex>, + /// Capacités du dernier tour de chaque discussion, en mémoire seulement. + caps: Mutex>, + /// Raisons d'échec déjà journalisées. + logged: Mutex>, +} + +impl Prewarm { + fn cur(&self) -> std::sync::MutexGuard<'_, Option> { + self.cur.lock().unwrap_or_else(|e| e.into_inner()) + } + + /// Un préchauffage est-il en vol (ou en train de partir) ? + pub fn live(&self) -> bool { + self.cur().is_some() + } + + /// La requête d'un tour le prolonge-t-elle exactement ? + pub fn keeps(&self, prefix: &[u64], opts: u64) -> bool { + self.cur() + .as_ref() + .and_then(|c| c.run) + .is_some_and(|r| r.matches(prefix, opts)) + } + + /// `prewarmStop` : annule le préchauffage en vol et rend de quoi attendre + /// sa fin (`None` : rien en vol). + pub fn stop(&self) -> Option> { + let c = self.cur().take()?; + let _ = c.cancel.send(true); + Some(c.done) + } + + /// Annulation relayée par le Go (une autre requête est partie). Un + /// préchauffage pas encore inscrit est annulé aussi : il n'y en a qu'un. + pub fn cancel_id(&self, id: u64) -> bool { + let g = self.cur(); + match g.as_ref() { + Some(c) if c.run.is_none_or(|r| r.id == id) => { + let _ = c.cancel.send(true); + true + } + _ => false, + } + } + + /// `prewarmNoteCaps`. + pub fn note_caps(&self, conv: &str, caps: Value) { + if conv.is_empty() { + return; + } + let mut m = self.caps.lock().unwrap_or_else(|e| e.into_inner()); + if !m.contains_key(conv) + && m.len() >= CAPS_MAX + && let Some(k) = m.keys().next().cloned() + { + m.remove(&k); + } + m.insert(conv.to_string(), caps); + } + + pub fn caps_for(&self, conv: &str) -> Option { + self.caps + .lock() + .unwrap_or_else(|e| e.into_inner()) + .get(conv) + .cloned() + } + + fn log_once(&self, key: &str, msg: &str) { + if self + .logged + .lock() + .unwrap_or_else(|e| e.into_inner()) + .insert(key.to_string()) + { + eprintln!("[prewarm] {msg}"); + } + } + + /// Inscrit le préchauffage qui part ; `None` si un autre est déjà là. + fn register(&self) -> Option<(watch::Receiver, watch::Sender)> { + let mut g = self.cur(); + if g.is_some() { + return None; + } + let (cancel, cancelled) = watch::channel(false); + let (done_tx, done) = watch::channel(false); + *g = Some(Current { + run: None, + cancel, + done, + }); + Some((cancelled, done_tx)) + } + + fn set_run(&self, run: Run) { + if let Some(c) = self.cur().as_mut() { + c.run = Some(run); + } + } + + /// Fin du préchauffage `run` (ou de celui qui partait) : la place est + /// rendue s'il est toujours le courant. + fn release(&self, id: Option) { + let mut g = self.cur(); + if g.as_ref().is_some_and(|c| c.run.map(|r| r.id) == id) { + *g = None; + } + } +} + +/// Ce que l'assemblage rend : le corps, et l'empreinte du préfixe préchauffé. +#[derive(Debug, Clone, PartialEq)] +pub struct Body { + pub payload: Value, + pub n: usize, + pub prefix: u64, + pub opts: u64, +} + +impl ChatService { + /// `prewarmKick` : un déclencheur. Sans la clé, rien — ni tâche, ni + /// requête. + pub async fn prewarm_kick(self: &Arc, why: &'static str) { + let cfg = self.shared.store.config().await; + if prewarm_mode(&cfg).is_empty() { + return; + } + let me = self.clone(); + tokio::spawn(async move { me.prewarm(why).await }); + } + + /// `prewarmAfterSwitch` : changement de discussion. Le préchauffage de + /// l'ancienne est annulé ; avec PREWARM=full, la nouvelle est préparée si + /// l'utilisateur y reste 3 s. + pub async fn prewarm_after_switch(self: &Arc) { + self.shared.prewarm.stop(); // sa fin n'est pas attendue + let cfg = self.shared.store.config().await; + if prewarm_mode(&cfg) != "full" { + return; + } + let id = self.sessions.conv().id(); + let me = self.clone(); + tokio::spawn(async move { + tokio::time::sleep(SWITCH_DELAY).await; + if me.sessions.conv().id() == id { + me.prewarm_kick(SWITCH).await; + } + }); + } + + /// `prewarmBodyFor` : le corps du préchauffage de la discussion ouverte, + /// ou la raison de s'abstenir. Lecture seule. + pub async fn prewarm_body(&self) -> Result { + let env = self.turn_env().await; + let conv_id = self.sessions.conv().id(); + let caps = self + .shared + .prewarm + .caps_for(&conv_id) + .ok_or("capacités du dernier tour inconnues")?; + let turn_caps = sentinel_caps(&caps); + let conv = self.sessions.conv(); + if conv.is_generating() { + return Err("moteur occupé"); + } + let (mut msgs, ctx_now, peak, snap) = conv.with_state(|st| { + let msgs = st.messages.clone(); + let used = ctx_pending(st.ctx_used, st.ctx_used_len, &msgs, &env.echo); + (msgs, used, st.gen_peak, st.proj_snap.clone()) + }); + // Sans message utilisateur, le contexte projet irait en tête de la + // sentinelle : rien à préparer. + if !msgs.iter().any(|m| m.role == "user") { + return Err("discussion vide"); + } + msgs.push(Message { + role: "user".into(), + content: Some(Value::from(SENTINEL)), + ..Default::default() + }); + // Le prochain tour compactera d'abord : son prompt ne sera pas celui-ci. + if compact_needed(&env.ccfg, &msgs, ctx_now, peak) { + return Err("compaction attendue au prochain tour"); + } + // Mêmes décisions que le vrai tour (PROJ_SNAPSHOT compris), sans rien + // ranger : bloc figé gardé avec ses mises à jour, ou rafraîchi. + let v = self + .turn_view(&turn_caps, snap.as_ref(), true) + .await + .map_err(|_| "vue du tour refusée")?; + let tools = v.tools; + let hist = match &v.snap { + Some(d) if !d.refresh => msgs, + _ => strip_context_updates(msgs), + }; + let mut sent = v.head; + sent.extend(hist); + let sent = inject_prefix(sent, &v.prefix); + let reasoning = turn_reasoning(&env.cfg, &self.shared.effort); + let pol = turn_echo_policy(false, || env.echo.clone()); + self.shared.images.prefetch(&self.shared.store, &sent).await; + let (wire, _) = wire_messages(&sent, false, &pol); + let n = wire.len(); + let last_ok = wire.last().is_some_and(|m| { + m.role == "user" && m.content.as_ref().and_then(Value::as_str) == Some(SENTINEL) + }); + if n < 2 || !last_ok { + return Err("assemblage inattendu"); + } + let mut payload = build_chat_payload( + &env.ep, + &wire, + 0.7, + &ChatPayloadOpts { + tools: &tools, + disable_tools: false, + tool_choice_none: false, + effort: &reasoning.effort, + kwargs: reasoning.kwargs.as_ref(), + no_sampling: false, + }, + &env.cfg, + &|r| self.shared.images.get(r), + ); + // Seuls les champs de réponse changent : un seul jeton, non streamé. + if let Some(o) = payload.as_object_mut() { + o.insert("stream".into(), Value::Bool(false)); + o.remove("stream_options"); + o.insert("max_tokens".into(), json!(1)); + } + let opts = opts_hash(&payload); + Ok(Body { + payload, + n: n - 1, + prefix: perf_prefix(&wire)[n - 2], + opts, + }) + } + + /// `prewarm` : un préchauffage complet, ou rien. Jamais d'erreur rendue. + pub async fn prewarm(self: &Arc, why: &str) { + let cfg = self.shared.store.config().await; + if prewarm_mode(&cfg).is_empty() { + return; + } + let env = self.turn_env().await; + if env.ep.external { + return; + } + // Un seul en vol : le nouveau déclencheur remplace l'ancien. + if let Some(mut done) = self.shared.prewarm.stop() { + let ended = tokio::time::timeout(STOP_WAIT, done.wait_for(|d| *d)).await; + if !matches!(ended, Ok(Ok(_))) { + return; + } + } + let store = &self.shared.store; + let slot = match store + .internal_post("/internal/v1/prewarm/check", &json!({})) + .await + { + Ok((code, v)) if code.is_success() && v["ok"] == true => { + v["slot"].as_i64().unwrap_or(-1) + } + _ => return, + }; + let Ok(mut body) = self.prewarm_body().await else { + return; + }; + if slot >= 0 + && let Some(o) = body.payload.as_object_mut() + { + o.insert("id_slot".into(), json!(slot)); + } + let Ok(raw_body) = serde_json::to_vec(&body.payload) else { + return; + }; + let Some((mut cancelled, done)) = self.shared.prewarm.register() else { + return; + }; + let conv_id = self.sessions.conv().id(); + let begun = store + .internal_post( + "/internal/v1/prewarm/begin", + &json!({ "conv": conv_id, "n": body.n, "prefix": body.prefix, "opts": body.opts }), + ) + .await; + let id = match begun { + Ok((code, v)) if code.is_success() && v["ok"] == true => v["id"].as_u64(), + _ => None, + }; + let Some(id) = id else { + // Une requête de Loki est partie entre-temps. + self.shared.prewarm.release(None); + let _ = done.send(true); + return; + }; + self.shared.prewarm.set_run(Run { + id, + n: body.n, + prefix: body.prefix, + opts: body.opts, + }); + let (mut total, mut cached) = (0i64, None::); + if !*cancelled.borrow() { + let send = async { + let resp = self + .shared + .http + .post(&env.ep.url, &env.ep.key, raw_body) + .await; + match resp { + Err(e) => Err(format!("moteur injoignable ({why}) : {e}")), + Ok(r) => { + let status = r.status(); + let raw = r.bytes().await.map_err(|e| e.to_string())?; + let raw = &raw[..raw.len().min(1 << 20)]; + Ok((status, raw.to_vec())) + } + } + }; + let out: Option), String>> = tokio::select! { + o = tokio::time::timeout(TIMEOUT, send) => o.ok(), + _ = cancelled.wait_for(|c| *c) => None, + }; + match out { + Some(Err(e)) => self.shared.prewarm.log_once("net", &e), + Some(Ok((status, raw))) if status.as_u16() != 200 => { + let txt = String::from_utf8_lossy(&raw[..raw.len().min(200)]) + .trim() + .to_string(); + self.shared.prewarm.log_once( + status.as_str(), + &format!("refusé par le moteur ({why}) : {status} {txt}"), + ); + } + Some(Ok((_, raw))) => { + // Télémétrie seulement (kind=prewarm) : la réponse est jetée. + let (complete, stats) = wire_stats(&raw); + total = stats.prompt_tokens_total; + cached = stats.cache_tokens; + let rec = PerfRec { + complete, + stats, + ..Default::default() + }; + perf::record(store, &self.shared.perf, perf::KIND_PREWARM, &conv_id, &rec) + .await; + } + None => {} + } + } + let _ = store + .internal_post( + "/internal/v1/prewarm/end", + &json!({ "id": id, "total": total, "cached": cached }), + ) + .await; + self.shared.prewarm.release(Some(id)); + let _ = done.send(true); + } +} + +#[cfg(test)] +mod tests; diff --git a/rust/loki-rs/src/chat/prewarm/tests.rs b/rust/loki-rs/src/chat/prewarm/tests.rs new file mode 100644 index 0000000..258a190 --- /dev/null +++ b/rust/loki-rs/src/chat/prewarm/tests.rs @@ -0,0 +1,167 @@ +//! Tests du préchauffage (cas de `chat_prewarm_test.go`). + +use super::*; +use serde_json::json; + +fn cfg(kv: &[(&str, &str)]) -> HashMap { + kv.iter() + .map(|(k, v)| (k.to_string(), v.to_string())) + .collect() +} + +fn um(s: &str) -> Message { + Message { + role: "user".into(), + content: Some(json!(s)), + ..Default::default() + } +} + +#[test] +fn mode() { + for (v, want) in [ + ("on", "on"), + (" OUI ", "on"), + ("1", "on"), + ("full", "full"), + ("FULL", "full"), + ("off", ""), + ("", ""), + ] { + assert_eq!(prewarm_mode(&cfg(&[("PREWARM", v)])), want, "{v:?}"); + } + assert_eq!(prewarm_mode(&cfg(&[])), ""); + // Jamais pour un preset externe. + assert_eq!( + prewarm_mode(&cfg(&[ + ("PREWARM", "full"), + (crate::engstatus::EXT_KEY_FLAG, "1") + ])), + "" + ); +} + +/// Seuls les champs qui changent le prompt rendu comptent ; l'échantillonnage +/// et les champs de réponse, non. +#[test] +fn empreinte_des_options() { + let base = json!({ + "model": "m", "messages": [], "stream": true, "temperature": 0.7, + "tools": [{"type": "function", "function": {"name": "bash"}}], + "tool_choice": "auto", "chat_template_kwargs": {"enable_thinking": true} + }); + let h = opts_hash(&base); + let mut same = base.clone(); + same["temperature"] = json!(0.2); + same["stream"] = json!(false); + same["max_tokens"] = json!(1); + same["id_slot"] = json!(0); + same["messages"] = json!([{"role": "user", "content": "x"}]); + assert_eq!(opts_hash(&same), h); + for (k, v) in [ + ("tools", json!([])), + ("tool_choice", json!("none")), + ("chat_template_kwargs", json!({"enable_thinking": false})), + ("reasoning_effort", json!("low")), + ("parallel_tool_calls", json!(true)), + ] { + let mut other = base.clone(); + other[k] = v; + assert_ne!(opts_hash(&other), h, "{k}"); + } + // Un champ absent n'est pas un champ vide. + let mut absent = base.clone(); + absent.as_object_mut().unwrap().remove("tool_choice"); + let mut empty = base.clone(); + empty["tool_choice"] = json!(""); + assert_ne!(opts_hash(&absent), opts_hash(&empty)); +} + +#[test] +fn role_de_la_sentinelle() { + assert_eq!( + sentinel_caps(&json!({"Code": true, "Role": ""}))["Role"], + "builder" + ); + assert_eq!(sentinel_caps(&json!({"Code": true}))["Role"], "builder"); + assert_eq!( + sentinel_caps(&json!({"Code": true, "Role": "verifier"}))["Role"], + "verifier" + ); + assert!(sentinel_caps(&json!({"Agent": true})).get("Role").is_none()); +} + +/// Une requête prolonge le préchauffage quand ses n premiers messages ont la +/// même empreinte, qu'elle en a au moins un de plus, et mêmes options. +#[test] +fn prolongement() { + let wire = vec![um("a"), um("b"), um(SENTINEL)]; + let pre = perf_prefix(&wire); + let run = Run { + id: 1, + n: 2, + prefix: pre[1], + opts: 7, + }; + let real = perf_prefix(&[um("a"), um("b"), um("vraie question")]); + assert!(run.matches(&real, 7)); + assert!(!run.matches(&real, 8), "autres options"); + assert!(!run.matches(&real[..2], 7), "rien de plus que le préfixe"); + let other = perf_prefix(&[um("a"), um("c"), um("vraie question")]); + assert!(!run.matches(&other, 7), "préfixe différent"); + let longer = perf_prefix(&[um("a"), um("b"), um("x"), um("y")]); + assert!(run.matches(&longer, 7)); + assert!(!Run { n: 0, ..run }.matches(&real, 7)); +} + +/// Un seul en vol ; gardé par la requête qui le prolonge ; annulé (par le +/// Go ou par une autre requête) ; la place rendue à sa fin seulement. +#[test] +fn etat_partage() { + let p = Prewarm::default(); + assert!(!p.live()); + let (cancelled, done) = p.register().expect("libre"); + assert!(p.live()); + assert!(p.register().is_none(), "deux en vol"); + // Pas encore inscrit au Go : rien à garder, mais annulable. + assert!(!p.keeps(&[1, 2, 3], 7)); + p.set_run(Run { + id: 4, + n: 2, + prefix: 2, + opts: 7, + }); + assert!(p.keeps(&[1, 2, 3], 7)); + assert!(!p.keeps(&[1, 2, 3], 8)); + assert!(!p.cancel_id(9), "annulation d'un autre préchauffage"); + assert!(!*cancelled.borrow()); + assert!(p.cancel_id(4)); + assert!(*cancelled.borrow()); + assert!(p.live(), "la place reste prise jusqu'à la fin"); + p.release(Some(9)); + assert!(p.live(), "fin d'un autre préchauffage"); + p.release(Some(4)); + assert!(!p.live()); + drop(done); + // stop : annule et rend de quoi attendre la fin. + let (cancelled, done) = p.register().unwrap(); + let mut wait = p.stop().expect("en vol"); + assert!(*cancelled.borrow() && !p.live()); + let _ = done.send(true); + assert!(*wait.borrow_and_update()); + assert!(p.stop().is_none()); +} + +#[test] +fn capacites_par_discussion() { + let p = Prewarm::default(); + p.note_caps("", json!({"Agent": true})); + assert!(p.caps_for("").is_none()); + p.note_caps("c1", json!({"Agent": true})); + p.note_caps("c1", json!({"Agent": false})); + assert_eq!(p.caps_for("c1"), Some(json!({"Agent": false}))); + for i in 0..CAPS_MAX + 10 { + p.note_caps(&format!("x{i}"), json!({})); + } + assert_eq!(p.caps.lock().unwrap().len(), CAPS_MAX); +} 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/chat/stream.rs b/rust/loki-rs/src/chat/stream.rs index 3d1bd80..69614fb 100644 --- a/rust/loki-rs/src/chat/stream.rs +++ b/rust/loki-rs/src/chat/stream.rs @@ -909,6 +909,236 @@ impl RetryPatterns { } } +impl RetryPatterns { + /// `compileRetryPatterns` : la surcharge `retry_patterns` de la base — + /// un tableau JSON de regex du Go, chacune en `(?mi)`. `None` : absente, + /// illisible ou vide (les motifs embarqués s'appliquent). Une regex qui + /// ne se compile pas est sautée, comme le Go le fait. + pub fn from_override(raw: &str) -> Option { + if raw.is_empty() { + return None; + } + let over: Vec = serde_json::from_str(raw).ok()?; + if over.is_empty() { + return None; + } + let pats = over + .iter() + .filter_map(|p| compile_go_regex(&format!("(?mi){p}"))) + .map(|r| Box::new(GoRegex(r)) as Box) + .collect(); + Some(RetryPatterns::custom(pats)) + } + + /// Nombre de motifs. + pub fn len(&self) -> usize { + self.pats.len() + } + + pub fn is_empty(&self) -> bool { + self.pats.is_empty() + } +} + +/// `regexp.Compile` du Go : la regex traduite ([`go_regex`]), octal permis +/// (`\\101`) ; `None` si l'un ou l'autre la refuse. +pub fn compile_go_regex(p: &str) -> Option { + regex::RegexBuilder::new(&go_regex(p)?) + .octal(true) + .build() + .ok() +} + +/// Un motif de la surcharge, compilé. +struct GoRegex(regex::Regex); + +impl RetryPattern for GoRegex { + fn find(&self, haystack: &str) -> Option<(usize, usize)> { + self.0.find(haystack).map(|m| (m.start(), m.end())) + } +} + +/// Une regex du Go (RE2, paquet `regexp`) réécrite pour `regex` : les +/// classes Perl et les limites de mot du Go sont ASCII (`\w`, `\d`, `\s`, +/// `\b`), celles de `regex` Unicode ; `\Q…\E` (texte littéral) n'existe +/// pas en Rust ; dans une classe, `[`, `&&`, `--` et `~~` y ont un sens que +/// le Go ne leur donne pas. `None` : une regex que le Go refuse et que +/// `regex` accepterait (`\\Q` dans une classe). +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; + if n == 'Q' { + // Texte littéral jusqu'à \E, ou jusqu'au bout. + let mut lit = String::new(); + while i < cs.len() && !(cs[i] == '\\' && cs.get(i + 1) == Some(&'E')) { + lit.push(cs[i]); + i += 1; + } + if i < cs.len() { + i += 2; + } + if class { + return None; + } + out.push_str(®ex::escape(&lit)); + 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)) { + return None; + } + let rep: &str = match (class, n) { + (false, 'w') => "[[:word:]]", + (false, 'W') => "[[:^word:]]", + (false, 'd') => "[0-9]", + (false, 'D') => "[^0-9]", + (false, 's') => r"[\t\n\f\r ]", + (false, 'S') => r"[^\t\n\f\r ]", + (false, 'b') => r"(?-u:\b)", + (false, 'B') => r"(?-u:\B)", + (true, 'w') => "[:word:]", + (true, 'W') => "[:^word:]", + (true, 'd') => "0-9", + (true, 'D') => "[:^digit:]", + (true, 's') => r"\t\n\f\r ", + (true, 'S') => "[:^space:]", + _ => { + out.push('\\'); + out.push(n); + class_start = false; + continue; + } + }; + out.push_str(rep); + class_start = false; + continue; + } + if class { + match c { + ']' if !class_start => { + class = false; + out.push(']'); + } + '[' if cs.get(i + 1) == Some(&':') => { + // Classe POSIX : recopiée jusqu'à « :] ». + let rest: String = cs[i..].iter().collect(); + match rest.find(":]") { + Some(end) => { + out.push_str(&rest[..end + 2]); + i += rest[..end + 2].chars().count(); + class_start = false; + continue; + } + None => out.push_str(r"\["), + } + } + '[' | '&' | '~' => { + out.push('\\'); + out.push(c); + } + '-' if cs.get(i + 1) == Some(&'-') => { + out.push_str(r"-\-"); + i += 1; + } + _ => out.push(c), + } + class_start = false; + i += 1; + continue; + } + out.push(c); + i += 1; + if c == '[' { + class = true; + class_start = true; + if cs.get(i) == Some(&'^') { + out.push('^'); + i += 1; + } + } + } + Some(out) +} + /// Marge relue avant la fin du balayage précédent. const RETRY_SCAN_MARGIN: usize = 512; /// Recul au-delà duquel on retombe sur le balayage complet. @@ -2673,6 +2903,75 @@ mod tests { assert_eq!(retry_scan_start(&"a b ".repeat(20000), 80000), 0); } + /// La surcharge `retry_patterns` : tableau JSON de regex du Go, chacune + /// en `(?mi)`, sémantique du Go (classes et limites de mot ASCII). + #[test] + fn surcharge_retry_patterns() { + for raw in ["", "null", "[]", "[\"a\", 1]", "\"x\"", "[\"a\"] x"] { + assert!(RetryPatterns::from_override(raw).is_none(), "{raw}"); + } + let p = RetryPatterns::from_override(r#"["^oups", "[", "\\bdo_\\w+\\("]"#).unwrap(); + assert!(p.is_custom()); + assert_eq!(p.len(), 2, "la regex illisible est sautée"); + assert!(p.textual_tool_call("Bonjour\nOUPS !")); + assert!(p.textual_tool_call("éxécute do_it(1)")); + assert_eq!( + p.snippet("puis do_it(1) et la suite"), + "do_it(1) et la suite" + ); + assert!(!p.textual_tool_call("ado_it(1)")); + // Classes du Go : ASCII. + let m = |pat: &str, s: &str| { + compile_go_regex(&format!("(?mi){pat}")) + .unwrap() + .find(s) + .map(|m| m.as_str().to_string()) + }; + assert_eq!(m(r"\w+", "été"), Some("t".into())); + assert_eq!(m(r"\bfoo\b", "éfoo"), Some("foo".into())); + assert_eq!(m(r"\Bfoo", "éfoo"), None); + assert_eq!(m(r"\d+", "x٣42"), Some("42".into())); + assert_eq!(m(r"\s+", "a\u{a0} \tb"), Some(" \t".into())); + assert_eq!(m(r"\S+", "\u{a0}a b"), Some("\u{a0}a".into())); + assert_eq!(m(r"[\w-]+", "é-a_b c"), Some("-a_b".into())); + assert_eq!(m(r"[^\s]+", " ab "), Some("ab".into())); + assert_eq!(m(r"[\W]", "aé"), Some("é".into())); + assert_eq!(m(r"[\d\s]+", "a1 2b"), Some("1 2".into())); + assert_eq!(m(r"[\D]", "1x"), Some("x".into())); + // Texte littéral, dans et hors d'une classe. + assert_eq!(m(r"\Qa.b\E", "axb a.b"), Some("a.b".into())); + 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())); + // « !-- » : l'intervalle de « ! » à « - » pour le Go. + assert_eq!(m(r"[!--]+", "a!#-b"), Some("!#-".into())); + assert_eq!(m(r"[~~a]+", "x~a"), Some("~a".into())); + assert_eq!(m(r"[]a]+", "x]a"), Some("]a".into())); + assert_eq!(m(r"[[:alpha:]]+", "1abc"), Some("abc".into())); + assert_eq!(m(r"[^[:alpha:]]+", "ab12"), Some("12".into())); + // Le reste passe tel quel. + assert_eq!( + go_regex(r"\pL\.x{2}(?s:.)").as_deref(), + Some(r"\pL\.x{2}(?s:.)") + ); + } + /// `(?mi)\Aoups` : motif de surcharge ancré en tête. struct Oups; 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/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 3d18b75..5a9b6e2 100644 --- a/rust/loki-rs/src/chat/turn.rs +++ b/rust/loki-rs/src/chat/turn.rs @@ -23,13 +23,28 @@ //! `cb` (comme le `ChatCallback` Go) ; le tour rend ses messages à ajouter à //! 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. Avec l'une de -//! ces clés, le Go garde la conversation (`facadeChatBlocker`, décidé au -//! démarrage). La télémétrie (`perfRecord`, sonde du gabarit) et le comptage -//! des requêtes en vol du moteur passent par le Go ([`TurnHost::perf_record`], -//! et l'hôte autour de [`TurnHost::send`]). +//! Télémétrie : chaque complétion lue est rangée par le Go (`perfRecord`, par +//! [`TurnHost::perf_record`]) ; la dernière statistique d'une complétion +//! complète porte la nature et la perte de cache qu'il a calculées. +//! +//! Slots du moteur (lot 31) : chaque requête part sur le slot que rend +//! [`TurnHost::engine_slot`] (SIDE_SLOT : 0 pour le fil de la discussion, 1 +//! pour une vérification ; aucun sans second slot en service), et un cache KV +//! à court de place en plein calcul est rejoué tel quel. Le rechargement +//! SLOT_PERSIST et le comptage des requêtes en vol passent par le Go (l'hôte, +//! autour de [`TurnHost::engine_slot`] et de [`TurnHost::send`]). +//! +//! Images gardées (KEEP_TURN_IMAGES, lot 32) : une image relayée par un outil +//! est gardée à l'essai jusqu'à la mesure de son coût par le moteur +//! ([`super::keepimg`]) ; les images gardées partent avant tout résumé. +//! +//! Préchauffage (PREWARM, lot 33) : une requête qui prolonge exactement le +//! préchauffage en vol le garde ([`TurnHost::prewarm_keeps`]) ; toute autre +//! l'annule ([`super::prewarm`]). +//! +//! Bloc projet figé (PROJ_SNAPSHOT, lot 34) : une compaction ou une réduction +//! en plein tour fait remplacer le bloc figé par le vivant +//! ([`TurnHost::project_refresh`]). use std::collections::HashMap; use std::future::Future; @@ -44,13 +59,15 @@ use tokio::sync::watch; use super::compact::cap_tool_result; use super::compact::{ - CompactConfig, compact_needed, compact_would_trigger, context_overflow, estimate_tokens, - log_compact, log_ctx, log_ctx_estimate, overflow_tokens, shrink_to_fit, + CompactConfig, compact_needed, compact_would_trigger, context_overflow, drop_kept_images, + estimate_tokens, log_compact, log_ctx, log_ctx_estimate, log_kept_images_dropped, + overflow_tokens, shrink_to_fit, }; use super::engine::{ LineError, Lines, NET_RETRIES, NetKind, net_delay, retryable, retryable_status, stream_cut_error, }; +use super::keepimg::{KeepImages, has_kept_images, publishable}; use super::nudges::{ MAX_NUDGES, THINK_NUDGE_FIRST, THINK_NUDGE_STUCK, append_nudge, budget_nudge, dedupable_tool, log_budget, repeated_call_result, steer_system, tool_result_preview, @@ -125,12 +142,27 @@ pub enum CompactPhase { /// Les effets d'un tour, fournis par l'appelant. pub trait TurnHost: Send + Sync { - /// POST du corps vers l'endpoint (sa clé à lui). + /// POST du corps vers l'endpoint (sa clé à lui). `keep_prewarm` : la + /// requête prolonge exactement le préchauffage en vol, qui n'est pas + /// annulé ([`Self::prewarm_keeps`]). fn send( &self, ep: &ChatEndpoint, body: Vec, + keep_prewarm: bool, ) -> impl Future> + Send; + /// PROJ_SNAPSHOT : une compaction ou une réduction vient de réécrire + /// l'historique en plein tour — le début du prompt change de toute façon, + /// le bloc projet figé est remplacé par le vivant et les mises à jour + /// retirées (`projSnapTurn.refresh`). Sans la clé : tel quel. + fn project_refresh(&self, msgs: Vec) -> impl Future> + Send { + async { msgs } + } + /// PREWARM : la requête qui part (empreintes cumulées de ses messages, + /// corps) prolonge-t-elle exactement le préchauffage en vol ? + fn prewarm_keeps(&self, _prefix: &[u64], _payload: &Value) -> bool { + false + } /// Exécute un appel d'outil (`execTool`). fn exec_tool( &self, @@ -153,6 +185,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 { @@ -172,6 +218,23 @@ pub trait TurnHost: Send + Sync { fn preflight(&self) -> Option { None } + /// KEEP_TURN_IMAGES : le budget des images gardées si la clé vaut pour + /// ce tour (fil principal, vision active) ; `None` : images éphémères. + fn keep_images(&self) -> impl Future> + Send { + async { None } + } + /// Range par référence l'image d'un relais (`refRelayImage`) : le + /// message qui la porte ainsi et son poids en octets ; `None` : elle + /// reste éphémère. + fn keep_image(&self, _m: &Message) -> impl Future> + Send { + async { None } + } + /// Slot du moteur où envoyer la requête qui part (`engineSlotFor`) ; + /// `None` : aucun `id_slot`. Appelé avant chaque requête, moteur local ou + /// non : l'hôte y recharge aussi l'état SLOT_PERSIST de la discussion. + fn engine_slot(&self, _ep: &ChatEndpoint) -> impl Future> + Send { + async { None } + } /// Télémétrie d'une complétion (`perfRecord`) : la rend au Go, qui la /// range dans l'anneau de `/api/perf/summary` et dit quoi poser sur la /// copie finale des stats (nature, jetons perdus, alerte). @@ -190,16 +253,6 @@ pub struct PerfRec { pub stats: StatsEvent, /// Empreintes cumulées des messages envoyés ([`perf_prefix`]), si complète. pub prefix: Vec, - /// Requête de forme normale du moteur local : de quoi lancer la sonde du - /// gabarit (`tplProbeKick`). - pub probe: Option, -} - -#[derive(Debug, Clone, PartialEq, Default)] -pub struct PerfProbe { - pub tools: Vec, - pub kwargs: Option>, - pub effort: String, } /// Ce que la télémétrie pose sur la copie finale des stats. @@ -317,6 +370,20 @@ fn log_stream_retry(attempt: u32, cause: &str) { ); } +/// `sideSlotPoolError` : « Context size has been exceeded. » sans le nombre +/// de jetons de la requête n'est pas un prompt trop long, c'est le cache KV +/// qui a manqué de place pendant le calcul — avec deux slots, la faute du +/// voisin, pas de la conversation. +pub fn side_slot_pool_error(msg: &str) -> bool { + static REQ_EXCEEDS: std::sync::LazyLock = std::sync::LazyLock::new(|| { + regex::Regex::new(r"(?i)exceeds the available context size|request \([0-9]+ tokens\)") + .expect("motif fixe") + }); + msg.to_lowercase() + .contains("context size has been exceeded") + && !REQ_EXCEEDS.is_match(msg) +} + fn text_msg(role: &str, text: impl Into) -> Message { Message { role: role.into(), @@ -393,15 +460,50 @@ struct Loop { budget_nudges: i64, /// `perfIter` : complétions de ce tour, complètes ou non. perf_iter: i64, + /// Rejeux « cache KV plein » (SIDE_SLOT). + pool_retries: u32, } -/// `runChatLoop` : le tour, de la première requête à la réponse finale. +/// `runChatTools` : le tour, de la première requête à la réponse finale. +/// KEEP_TURN_IMAGES : en fin de tour, une image gardée à l'essai mais jamais +/// mesurée redevient éphémère, quelle que soit la sortie de la boucle. pub async fn run_turn( + host: &H, + tc: &TurnConfig, + messages: Vec, + cancel: &Cancel, + cb: &mut (dyn FnMut(StreamEvent) -> bool + Send), +) -> TurnResult { + let mut kt = host.keep_images().await.map(KeepImages::new); + let mut r = run_turn_loop(host, tc, messages, cancel, cb, &mut kt).await; + if let Some(k) = kt.as_mut() { + k.finish(&mut r.extra); + } + r +} + +/// `publishHistory` : publie l'historique réécrit en cours de tour +/// (compaction, réduction, retrait des images gardées). KEEP_TURN_IMAGES : +/// une image non gardée n'y entre pas ; sans la clé, tel quel. +fn publish_history( + cb: &mut (dyn FnMut(StreamEvent) -> bool + Send), + kt: &mut Option, + m: &[Message], +) { + if let Some(k) = kt.as_mut() { + k.rewritten(); + } + cb(StreamEvent::NewHistory(publishable(kt.as_ref(), m))); +} + +/// `runChatLoop` : la boucle de [`run_turn`]. +async fn run_turn_loop( host: &H, tc: &TurnConfig, mut messages: Vec, cancel: &Cancel, cb: &mut (dyn FnMut(StreamEvent) -> bool + Send), + kt: &mut Option, ) -> TurnResult { let mut extra: Vec = Vec::new(); let mut s = Loop::default(); @@ -418,9 +520,6 @@ pub async fn run_turn( if tc.code { budget *= super::nudges::CODE_BUDGET_FACTOR; } - let new_history = |cb: &mut (dyn FnMut(StreamEvent) -> bool + Send), m: &[Message]| { - cb(StreamEvent::NewHistory(m.to_vec())); - }; let fail = |cb: &mut (dyn FnMut(StreamEvent) -> bool + Send), extra: Vec, err: String| { cb(StreamEvent::Err(err.clone())); @@ -443,14 +542,27 @@ 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()); host.prepare_images(&messages).await; - let (body, echo_sent, prefix) = { + let slot = host.engine_slot(ep).await; + let (body, echo_sent, prefix, keep_prewarm) = { let (sent, echo_sent) = wire_messages(&messages, s.tool_choice_none, &pol); - let payload = build_chat_payload( + let mut payload = build_chat_payload( ep, &sent, tc.temperature, @@ -465,14 +577,27 @@ pub async fn run_turn( &tc.cfg, &|r| host.image_data_url(r), ); + // SIDE_SLOT : le slot rendu par l'hôte ; aucun, la requête d'avant. + if let Some(n) = slot + && let Some(o) = payload.as_object_mut() + { + o.insert("id_slot".into(), Value::from(n)); + } + let prefix = perf_prefix(&sent); + let keep = !ep.external && host.prewarm_keeps(&prefix, &payload); ( serde_json::to_vec(&payload).unwrap_or_default(), echo_sent, - perf_prefix(&sent), + prefix, + keep, ) }; let t_req = Instant::now(); - let reply = match cancel.or_stop(host.send(ep, body)).await { + // KEEP_TURN_IMAGES : rien sans la clé. + if let Some(k) = kt.as_mut() { + k.sending(messages.len()); + } + let reply = match cancel.or_stop(host.send(ep, body, keep_prewarm)).await { None => return TurnResult { extra, err: None }, Some(Ok(r)) => r, Some(Err(e)) => { @@ -513,6 +638,26 @@ pub async fn run_turn( effort = fixed; continue; } + // SIDE_SLOT : un cache KV à court de place en plein calcul n'est + // pas un prompt trop long. Rejouée telle quelle, sans compaction ni + // réduction — deux fois au plus, le temps que l'autre slot finisse, + // puis l'erreur telle quelle. Sans second slot en service, rien. + if !ep.external && slot.is_some() && side_slot_pool_error(&msg) { + if s.pool_retries < 2 { + s.pool_retries += 1; + log_ctx( + "cache KV plein pendant le calcul (deux slots) : requête rejouée telle quelle, sans compaction", + ); + if cancel.backoff(s.pool_retries - 1).await { + continue; + } + } + return fail( + cb, + extra, + format!("llama-server a renvoyé {status} : {msg}"), + ); + } if echo_sent > 0 && echo_template_error(status, &msg) { s.echo_off_turn = true; s.echo_tpl_retry = true; @@ -531,6 +676,18 @@ pub async fn run_turn( if s.echo_off_turn { messages = strip_reasoning(&messages).into_owned(); } + // KEEP_TURN_IMAGES : les images gardées partent d'abord, et la + // requête est rejouée sans elles ; le résumé (avec perte) ne vient + // qu'ensuite, s'il le faut encore. + if context_overflow(ccfg, &msg, &messages) + && let Some((c, freed)) = drop_kept_images(&messages) + { + log_kept_images_dropped("réactif", freed); + messages = c; + extra.clear(); + publish_history(cb, kt, &messages); + continue; + } let overflow = context_overflow(ccfg, &msg, &messages); if ccfg.enabled && !s.compacted_retry && overflow { let Some(c) = cancel @@ -542,9 +699,9 @@ pub async fn run_turn( if let Some(c) = c { s.compacted_retry = true; log_compact(ccfg, "réactif", 0, &messages, &c, true); - messages = c; + messages = host.project_refresh(c).await; extra.clear(); - new_history(cb, &messages); + publish_history(cb, kt, &messages); continue; } } @@ -554,9 +711,9 @@ pub async fn run_turn( if changed { s.shrink_retries += 1; log_compact(ccfg, "réduction", actual, &messages, &c, true); - messages = c; + messages = host.project_refresh(c).await; extra.clear(); - new_history(cb, &messages); + publish_history(cb, kt, &messages); continue; } } @@ -640,15 +797,21 @@ pub async fn run_turn( } } // Arrêt pendant la lecture : le tour s'arrête là, sans erreur, et rien - // de plus ne part vers l'écran. + // de plus ne part vers l'écran. La complétion coupée reste dans la + // télémétrie, incomplète. if stopped { + host.perf_record(PerfRec { + iter: s.perf_iter, + external: ep.external, + ..Default::default() + }) + .await; return TurnResult { extra, err: None }; } let out = parser.finish(&mut emit); let complete = out.is_complete(read_err.is_some()); // Télémétrie (perf_log.go) : une complétion coupée avant son chunk - // final est rangée comme incomplète, sans calcul de perte. Sonde du - // gabarit : fil d'une requête de forme normale, moteur local. + // final est rangée comme incomplète, sans calcul de perte. let marks = host .perf_record(PerfRec { iter: s.perf_iter, @@ -656,13 +819,6 @@ pub async fn run_turn( external: ep.external, stats: out.stats.clone(), prefix: if complete { prefix } else { Vec::new() }, - probe: (complete && !ep.external && !s.disable_tools && !s.tool_choice_none).then( - || PerfProbe { - tools: tc.tools.clone(), - kwargs: kwargs.clone(), - effort: effort.clone(), - }, - ), }) .await; s.perf_iter += 1; @@ -677,6 +833,11 @@ pub async fn run_turn( st.lost_after = marks.lost_after; st.lost_alert = marks.lost_alert; cb(StreamEvent::Stats(st)); + // 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 { @@ -685,6 +846,11 @@ pub async fn run_turn( } s.last_est = 0; } + // KEEP_TURN_IMAGES : coût des images de l'étape précédente, mesuré sur + // cette complétion ; gardées ou rendues éphémères. + if let Some(k) = kt.as_mut() { + k.observe(complete, &out.stats, &mut messages, &mut extra); + } if out.finish_reason == "length" { log_ctx(&format!( "finish=length prompt={} généré={} raisonnement={} fenêtre={}", @@ -904,7 +1070,20 @@ pub async fn run_turn( // Plafond pour le MODÈLE seulement : l'UI a reçu le résultat. push_result(&mut messages, &mut extra, cap_tool_result(&ex.result)); if let Some(img) = ex.relay { - messages.push(img); // éphémère : jamais dans l'historique + match kt.as_mut() { + // Éphémère : jamais dans l'historique. + None => messages.push(img), + // KEEP_TURN_IMAGES : gardée par référence, à l'essai + // jusqu'à la mesure de son coût. + Some(k) => { + let stored = if k.wants(&messages) { + host.keep_image(&img).await + } else { + None + }; + k.relay(&mut messages, &mut extra, img, step_start, stored); + } + } } } // Compaction EN COURS DE TOUR, sur le contexte réel du dernier appel @@ -915,6 +1094,21 @@ pub async fn run_turn( used = base + estimate_tokens(&messages[step_start..], &ccfg.echo); s.last_est = used; } + // KEEP_TURN_IMAGES : les images gardées partent AVANT tout résumé ; + // la compaction ne suit que si elle reste nécessaire. + if has_kept_images(&messages) + && compact_needed(ccfg, &messages, used, s.peak_gen) + && let Some((c, freed)) = drop_kept_images(&messages) + { + log_kept_images_dropped("en-tour", freed); + messages = c; + if used > 0 { + used = (used - freed).max(1); + s.last_est = used; + } + extra.clear(); + publish_history(cb, kt, &messages); + } if compact_needed(ccfg, &messages, used, s.peak_gen) && !host.compact_refused(used, &messages) { @@ -938,9 +1132,9 @@ pub async fn run_turn( ); if let Some(c) = c { s.last_est = 0; - messages = c; + messages = host.project_refresh(c).await; extra.clear(); - new_history(cb, &messages); + publish_history(cb, kt, &messages); } } continue; @@ -967,6 +1161,18 @@ pub async fn run_turn( && compact_would_trigger(ccfg, &messages, known) { s.length_replays += 1; + // KEEP_TURN_IMAGES : les images gardées d'abord ; le résumé ne + // suit que s'il reste nécessaire. + if let Some((c, freed)) = drop_kept_images(&messages) { + log_kept_images_dropped("fenêtre-pleine", freed); + messages = c; + extra.clear(); + publish_history(cb, kt, &messages); + if !compact_would_trigger(ccfg, &messages, (known - freed).max(1)) { + cb(StreamEvent::DropReasoning); + continue; + } + } cb(StreamEvent::Compacting(true)); let c = cancel .or_stop(host.compact(CompactPhase::FullWindow, &messages)) @@ -985,9 +1191,9 @@ pub async fn run_turn( ); if let Some(c) = c { cb(StreamEvent::DropReasoning); - messages = c; + messages = host.project_refresh(c).await; extra.clear(); - new_history(cb, &messages); + publish_history(cb, kt, &messages); continue; } } diff --git a/rust/loki-rs/src/chat/turn/tests.rs b/rust/loki-rs/src/chat/turn/tests.rs index 50d6505..78cb478 100644 --- a/rust/loki-rs/src/chat/turn/tests.rs +++ b/rust/loki-rs/src/chat/turn/tests.rs @@ -2,7 +2,7 @@ use super::*; use crate::chat::compact::CompactConfig; -use crate::chat::types::ToolFunction; +use crate::chat::types::{StatsEvent, ToolFunction}; use crate::chat::wire::resolve_chat_endpoint; use serde_json::json; use std::collections::VecDeque; @@ -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,29 @@ 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>, + /// Télémétrie rangée ; et ce que le Go en tire. + perfs: Mutex>, + marks: Mutex, + /// Slot rendu par l'hôte (SIDE_SLOT) ; demandes reçues. + slot: Mutex>, + slot_asks: Mutex, + /// KEEP_TURN_IMAGES : budget (clé valable pour le tour) ; image relayée + /// par chaque outil ; rangements demandés (refusés si `keep_refuse`). + keep: Mutex>, + relay: Mutex>, + keep_stores: Mutex, + keep_refuse: Mutex, + /// PREWARM : la requête prolonge-t-elle le préchauffage ? ce que `send` + /// a reçu. + prewarm_match: Mutex, + sent_keeps: Mutex>, + /// PROJ_SNAPSHOT : clé posée pour ce tour ; rafraîchissements demandés. + snap_on: Mutex, + refreshes: Mutex, } impl Fake { @@ -58,7 +84,13 @@ fn stream_of(lines: Vec, cut: bool) -> BodyStream { } impl TurnHost for Fake { - async fn send(&self, _ep: &ChatEndpoint, body: Vec) -> Result { + async fn send( + &self, + _ep: &ChatEndpoint, + body: Vec, + keep: bool, + ) -> Result { + self.sent_keeps.lock().unwrap().push(keep); self.bodies .lock() .unwrap() @@ -107,6 +139,7 @@ impl TurnHost for Fake { .unwrap_or_else(|| format!("résultat de {name}")); ToolOutcome { result, + relay: self.relay.lock().unwrap().clone(), ..Default::default() } } @@ -120,13 +153,114 @@ impl TurnHost for Fake { fn drain_queued(&self) -> Vec { std::mem::take(&mut *self.queued.lock().unwrap()) } - fn image_data_url(&self, _r: &str) -> Option { - None + fn image_data_url(&self, r: &str) -> Option { + (r == "loki-img:aa.png").then(|| IMG_URL.to_string()) } fn effort_remember(&self, want: &str, got: &str) { 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() + } + async fn perf_record(&self, rec: PerfRec) -> PerfMarks { + self.perfs.lock().unwrap().push(rec); + self.marks.lock().unwrap().clone() + } + async fn keep_images(&self) -> Option { + *self.keep.lock().unwrap() + } + async fn project_refresh(&self, mut msgs: Vec) -> Vec { + if *self.snap_on.lock().unwrap() { + *self.refreshes.lock().unwrap() += 1; + msgs.insert(0, text_msg("system", "BLOC VIVANT")); + } + msgs + } + fn prewarm_keeps(&self, prefix: &[u64], payload: &Value) -> bool { + // Ce que l'hôte reçoit : les empreintes des messages envoyés et le + // corps de la requête. + assert_eq!(prefix.len(), payload["messages"].as_array().unwrap().len()); + *self.prewarm_match.lock().unwrap() + } + async fn keep_image(&self, m: &Message) -> Option<(Message, i64)> { + *self.keep_stores.lock().unwrap() += 1; + if *self.keep_refuse.lock().unwrap() { + return None; + } + let mut r = m.clone(); + r.content = Some(json!([ + m.content.as_ref().unwrap()[0].clone(), + {"type": "image_url", "image_url": {"url": "loki-img:aa.png"}} + ])); + Some((r, 1000)) + } + async fn engine_slot(&self, _ep: &ChatEndpoint) -> Option { + *self.slot_asks.lock().unwrap() += 1; + *self.slot.lock().unwrap() + } +} + +/// L'image d'un relais : la même data-URL que rend la référence rangée. +const IMG_URL: &str = "data:image/png;base64,AA=="; + +fn relay_msg() -> Message { + Message { + role: "user".into(), + content: Some(json!([ + {"type": "text", "text": "Voici la capture demandée."}, + {"type": "image_url", "image_url": {"url": IMG_URL}} + ])), + ..Default::default() + } +} + +/// Fin de complétion avec cet usage ; `prompt` 0 : aucun usage. +fn finish_u(reason: &str, prompt: i64, completion: i64) -> Vec { + let mut v = vec![format!( + "data: {}", + json!({"choices":[{"delta":{},"finish_reason": reason}]}) + )]; + if prompt > 0 { + v.push(format!( + "data: {}", + json!({"choices":[],"usage":{"prompt_tokens":prompt,"completion_tokens":completion}}) + )); + } + v +} + +fn see_image_step(prompt: i64) -> Script { + let mut l = vec![call("v1", "see_image", r#"{"file":"img.png"}"#)]; + l.extend(finish_u("tool_calls", prompt, 20)); + Script::Ok(l) +} + +fn answer_u(s: &str, prompt: i64) -> Script { + let mut l = vec![content(s)]; + l.extend(finish_u("stop", prompt, 5)); + Script::Ok(l) +} + +/// Lance un tour sur cet historique et rend (résultat, événements). +async fn run_hist( + host: &Fake, + tc: &TurnConfig, + hist: Vec, +) -> (TurnResult, Vec) { + let mut evs = Vec::new(); + let mut cb = |ev: StreamEvent| { + evs.push(ev); + true + }; + let r = run_turn(host, tc, hist, &Cancel::never(), &mut cb).await; + (r, evs) } fn content(s: &str) -> String { @@ -339,6 +473,106 @@ 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()); +} + +/// SIDE_SLOT : chaque requête porte le slot que rend l'hôte, demandé avant +/// chaque envoi ; sans slot, aucun `id_slot`. +#[tokio::test] +async fn slot_du_moteur_dans_chaque_requete() { + let host = Fake::new(vec![ + tool_step("c1", "glob", r#"{"pattern":"*.go"}"#), + answer("Trouvé"), + ]); + *host.slot.lock().unwrap() = Some(1); + let (r, _) = run(&host, &config(&["glob"]), &Cancel::never()).await; + assert_eq!(r.err, None); + assert_eq!( + ( + host.body(0)["id_slot"].clone(), + host.body(1)["id_slot"].clone() + ), + (json!(1), json!(1)) + ); + assert_eq!(*host.slot_asks.lock().unwrap(), 2); + let host = Fake::new(vec![answer("ok")]); + let (r, _) = run(&host, &config(&[]), &Cancel::never()).await; + assert_eq!(r.err, None); + assert!(host.body(0).get("id_slot").is_none()); +} + +/// Cache KV plein en plein calcul avec deux slots : rejouée telle quelle, +/// deux fois au plus, sans compaction ; au-delà, l'erreur. Sans second slot, +/// c'est un débordement comme un autre. +#[tokio::test(start_paused = true)] +async fn cache_kv_plein_rejoue_sans_compaction() { + let plein = r#"{"error":{"code":500,"message":"Context size has been exceeded.","type":"server_error"}}"#; + let host = Fake::new(vec![ + Script::Status(500, plein.into()), + Script::Status(500, plein.into()), + answer("ok"), + ]); + *host.slot.lock().unwrap() = Some(0); + *host.compacted.lock().unwrap() = Some(vec![um("résumé")]); + let (r, evs) = run(&host, &config(&[]), &Cancel::never()).await; + assert_eq!(r.err, None); + assert_eq!(text(&evs), "ok"); + assert_eq!(host.sent(), 3); + assert!(host.phases.lock().unwrap().is_empty(), "pas de compaction"); + assert_eq!(host.body(2)["messages"], host.body(0)["messages"]); + // Trois fois de suite : l'erreur telle quelle, sans compaction. + let host = Fake::new((0..3).map(|_| Script::Status(500, plein.into())).collect()); + *host.slot.lock().unwrap() = Some(0); + *host.compacted.lock().unwrap() = Some(vec![um("résumé")]); + let (r, _) = run(&host, &config(&[]), &Cancel::never()).await; + // Le corps du refus tel quel, comme le Go. + assert_eq!(r.err, Some(format!("llama-server a renvoyé 500 : {plein}"))); + assert_eq!(host.sent(), 3); + assert!(host.phases.lock().unwrap().is_empty()); + // Sans second slot : compaction réactive. + let host = Fake::new(vec![Script::Status(500, plein.into()), answer("ok")]); + *host.compacted.lock().unwrap() = Some(vec![um("résumé")]); + let (r, _) = run(&host, &config(&[]), &Cancel::never()).await; + assert_eq!(r.err, None); + assert_eq!(*host.phases.lock().unwrap(), [CompactPhase::Reactive]); + // Moteur externe : jamais (le slot vient d'un moteur local). + let host = Fake::new(vec![Script::Status(500, plein.into()), answer("ok")]); + *host.slot.lock().unwrap() = Some(0); + *host.compacted.lock().unwrap() = Some(vec![um("résumé")]); + let mut tc = config(&[]); + tc.ep.external = true; + let (r, _) = run(&host, &tc, &Cancel::never()).await; + assert_eq!(r.err, None); + assert_eq!(*host.phases.lock().unwrap(), [CompactPhase::Reactive]); +} + +#[test] +fn cache_kv_plein_ou_prompt_trop_long() { + for (msg, want) in [ + ( + r#"{"error":{"code":500,"message":"Context size has been exceeded.","type":"server_error"}}"#, + true, + ), + ( + r#"{"error":{"message":"request (40000 tokens) exceeds the available context size (32768 tokens), try increasing it","type":"exceed_context_size_error"}}"#, + false, + ), + ("the request exceeds the available context size", false), + ("CONTEXT SIZE HAS BEEN EXCEEDED, Request (12 TOKENS)", false), + ("failed to parse tool call", false), + ] { + assert_eq!(side_slot_pool_error(msg), want, "{msg}"); + } } #[tokio::test] @@ -471,6 +705,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", + "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/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/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 575f6ca..a5aad00 100644 --- a/rust/loki-rs/tests/proxy.rs +++ b/rust/loki-rs/tests/proxy.rs @@ -100,6 +100,24 @@ async fn facade(backend: SocketAddr) -> (SocketAddr, oneshot::Sender<()>) { service: "loki-engine".into(), }, chat: None, + models: None, + dictate: None, + settings: false, + files: None, + push: None, + mem: None, + presets: false, + engine_ctl: None, + system: None, + projects: false, + mcp: None, + update: false, + backends: None, + engineimg: None, + strata: None, + tasks: None, + bench: None, + tune: 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); +} 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") + ); +} diff --git a/rust/loki-rs/tests/retry.rs b/rust/loki-rs/tests/retry.rs new file mode 100644 index 0000000..a2d2c03 --- /dev/null +++ b/rust/loki-rs/tests/retry.rs @@ -0,0 +1,68 @@ +//! Lot 30 : les motifs `retry_patterns` d'une surcharge, rejoués sur le +//! journal tiré du Go (`internal/loki/parity_retry_test.go` → +//! `tests/parity_retry/patterns.json`) : même compilation, même première +//! correspondance. + +mod common; + +use common::fakego; +use loki_rs::chat::stream::compile_go_regex; +use serde_json::Value; + +#[test] +fn motifs_comme_le_go() { + let fx: Value = + serde_json::from_str(include_str!("parity_retry/patterns.json")).expect("journal"); + let inputs: Vec<&str> = fx["inputs"] + .as_array() + .unwrap() + .iter() + .map(|v| v.as_str().unwrap()) + .collect(); + for e in fx["patterns"].as_array().unwrap() { + let pat = e["pattern"].as_str().unwrap(); + let re = compile_go_regex(&format!("(?mi){pat}")); + assert_eq!(re.is_some(), e["compiles"].as_bool().unwrap(), "{pat}"); + let Some(re) = re else { continue }; + for m in e["matches"].as_array().unwrap() { + let input = inputs[m["in"].as_u64().unwrap() as usize]; + let got = re + .find(input) + .map(|x| (x.as_str().to_string(), x.start() as i64)); + let want = m["match"] + .as_str() + .map(|s| (s.to_string(), m["at"].as_i64().unwrap())); + assert_eq!(got, want, "{pat} sur {input:?}"); + } + } +} + +/// La surcharge est lue au démarrage de la conversation de la façade ; +/// absente ou illisible, les motifs embarqués s'appliquent. +#[tokio::test] +async fn surcharge_lue_au_demarrage() { + for (seed, custom, n) in [ + (Some(r#"["^oups", "[", "\\bdo_\\w+\\("]"#), true, 2), + (Some("pas du json"), false, 0), + (None, false, 0), + ] { + let tmp = tempfile::tempdir().unwrap(); + let (_f, _go, _stop, chat) = fakego::start_with( + tmp.path().to_path_buf(), + fakego::Opts { + chat: true, + seed: seed + .map(|s| vec![("state".into(), "retry_patterns".into(), s.into())]) + .unwrap_or_default(), + ..Default::default() + }, + ) + .await; + let p = &chat.unwrap().shared.patterns; + assert_eq!(p.is_custom(), custom, "{seed:?}"); + if custom { + assert_eq!(p.len(), n); + assert!(p.textual_tool_call("OUPS, raté")); + } + } +} 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" + ); +} 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()); + } +} diff --git a/rust/loki-rs/tests/strata.rs b/rust/loki-rs/tests/strata.rs new file mode 100644 index 0000000..a7291ae --- /dev/null +++ b/rust/loki-rs/tests/strata.rs @@ -0,0 +1,356 @@ +//! Lot 25 : Strata tenu par la façade (`LOKI_STRATA=rust`, avec +//! `LOKI_BACKENDS=rust`). +//! +//! Le journal tiré du Go (`internal/loki/parity_strata_test.go` → +//! `tests/parity_strata/strata.json`) est rejoué par une façade : fausses +//! machines dans le PATH des sondes (nvidia-smi, python3), faux /proc/meminfo, +//! place libre imposée, fausse release (sources, moteur, empreintes) et +//! installeur factice ; la bascule finale part au faux Go, qui applique le +//! preset comme `applyPresetFile`. + +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 common::parity::{apply_files, tar_gz, write_file}; +use http_body_util::Full; +use hyper::service::service_fn; +use hyper::{Request, Response}; +use hyper_util::rt::TokioIo; +use loki_rs::backends::Backends; +use loki_rs::backends::probe::Probe; +use loki_rs::strata::api::Strata; +use regex::Regex; +use serde_json::{Value, json}; +use sha2::Digest; +use tokio::net::TcpListener; + +fn fixture() -> Value { + serde_json::from_str(include_str!("parity_strata/strata.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"; + +/// La fausse release : `{status, src, engine}`. +async fn fake_release(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 path = req.uri().path().trim_start_matches('/').to_string(); + let (code, body) = match cur["status"].as_u64() { + Some(c) if c != 0 => (c as u16, Vec::new()), + _ if path == loki_rs::strata::SRC_ASSET => (200, src_bytes(&cur)), + _ if path == loki_rs::strata::ENGINE_ASSET => { + (200, s(&cur["engine"]).as_bytes().to_vec()) + } + _ => (404, Vec::new()), + }; + 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}") +} + +fn src_bytes(rel: &Value) -> Vec { + tar_gz(rel["src"].as_array().map_or(&[][..], Vec::as_slice)) +} + +fn sha(b: &[u8]) -> String { + hex::encode(sha2::Sha256::digest(b)) +} + +struct Bench { + facade: SocketAddr, + go: Arc, + _stop: tokio::sync::oneshot::Sender<()>, + home: PathBuf, + tools: PathBuf, + root: PathBuf, + strata: Arc, + rel: Arc>, + rel_base: 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 probe = || Probe { + path: Some(vec![tools.path().to_path_buf()]), + root: root.path().to_path_buf(), + }; + std::fs::create_dir_all(root.path().join("proc")).unwrap(); + let rel = Arc::new(Mutex::new(json!({}))); + let rel_base = fake_release(rel.clone()).await; + let b = Arc::new(Backends::with( + home.clone(), + String::new(), + probe(), + String::new(), + )); + let mut st = Strata::new(home.clone(), probe()); + st.release_base = format!("{rel_base}/"); + st.platform_ok = true; + let strata = Arc::new(st); + let (facade, go, stop, _) = fakego::start_with( + home.clone(), + fakego::Opts { + backends: Some(b), + strata: Some(strata.clone()), + ..Default::default() + }, + ) + .await; + *go.apply_presets.lock().unwrap() = true; + Bench { + facade, + go, + _stop: stop, + home, + tools: tools.path().to_path_buf(), + root: root.path().to_path_buf(), + strata, + rel, + rel_base, + _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 v = v + .replace(&self.rel_base, "{{REL}}") + .replace(&self.home.to_string_lossy().into_owned(), "{{HOME}}"); + 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"); + } + + /// Les outils d'une machine (bash, mkdir et tr toujours). + fn set_machine(&self, machines: &Value, name: &str) { + for e in std::fs::read_dir(&self.tools).unwrap().flatten() { + std::fs::remove_file(e.path()).unwrap(); + } + for tool in ["bash", "mkdir", "tr"] { + let p = loki_rs::paths::look_path(tool).expect(tool); + std::os::unix::fs::symlink(p, self.tools.join(tool)).unwrap(); + } + for (tool, script) in machines[name].as_object().into_iter().flatten() { + write_file(&self.tools.join(tool), s(script), 0o755); + } + } + + fn set_release(&self, r: &Value) { + *self.rel.lock().unwrap() = r.clone(); + let mut want = std::collections::HashMap::from([ + (loki_rs::strata::SRC_ASSET.to_string(), sha(&src_bytes(r))), + ( + loki_rs::strata::ENGINE_ASSET.to_string(), + sha(s(&r["engine"]).as_bytes()), + ), + ]); + if r["bad_sha"] == json!(true) { + want.insert(loki_rs::strata::ENGINE_ASSET.to_string(), "0".repeat(64)); + } + *self.strata.asset_sha.lock().unwrap() = want; + } + + /// presets/ et strata/, sans le contenu des archives. + fn tree(&self) -> Vec { + use std::os::unix::fs::PermissionsExt; + fn walk(b: &Bench, p: &Path, out: &mut Vec) { + let Ok(m) = std::fs::symlink_metadata(p) else { + return; + }; + let rel = p + .strip_prefix(&b.home) + .unwrap() + .to_string_lossy() + .into_owned(); + if m.is_dir() { + out.push(format!("d {rel}")); + for e in std::fs::read_dir(p).unwrap().flatten() { + walk(b, &e.path(), out); + } + } else if rel.starts_with("strata/dl/") || rel.starts_with("strata/prebuilt/") { + out.push(format!("f {rel}")); + } else { + let c = std::fs::read(p).unwrap(); + out.push(format!( + "f {rel} {} {}", + common::parity::perm_string(m.permissions().mode() & 0o777), + b.mask(&String::from_utf8_lossy(&c)) + )); + } + } + let mut out = Vec::new(); + for root in ["presets", "strata"] { + walk(self, &self.home.join(root), &mut out); + } + out.sort(); + out + } +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 4)] +async fn parite_de_strata() { + if !cfg!(target_arch = "x86_64") { + eprintln!("Strata n'est proposé que sous Linux x86_64 : rejeu sauté"); + return; + } + let fx = fixture(); + let bn = bench().await; + let machines = &fx["machines"]; + for st in fx["steps"].as_array().unwrap() { + let name = s(&st["name"]); + if let Some(m) = st["machine"].as_str() { + bn.set_machine(machines, m); + } + if let Some(m) = st["meminfo"].as_str() { + std::fs::write(bn.root.join("proc/meminfo"), m).unwrap(); + } + if let Some(d) = st["disk_free"].as_i64() { + *bn.strata.disk_free.lock().unwrap() = Some(d); + } + 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); + } + } + for (k, v) in st["state"].as_object().into_iter().flatten() { + if s(v).is_empty() { + bn.go.del("state", k); + } else { + bn.go.set("state", k, s(v)); + } + } + if !st["release"].is_null() { + bn.set_release(&st["release"]); + } + bn.go.preset_applies.lock().unwrap().clear(); + *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"); + } + // Une bascule faite par le Go : application du preset puis + // redémarrage du service. + let actions: Vec = bn + .go + .preset_applies + .lock() + .unwrap() + .iter() + .filter(|(_, mode)| mode == "switch") + .map(|_| json!("restart")) + .collect(); + assert_eq!(Value::Array(actions), st["actions"], "{name} : service"); + let mut after = BTreeMap::new(); + for k in [ + "ENGINE", + "STRATA_CONFIG", + "CTX", + "STRATA_KV", + "STRATA_HELPER", + ] { + if let Some(v) = bn.go.get("config", k).filter(|v| !v.is_empty()) { + after.insert(format!("config/{k}"), Value::String(bn.mask(&v))); + } + } + assert_eq!( + Value::Object(after.into_iter().collect()), + st["after"], + "{name} : après" + ); + let t: Vec = bn.tree().into_iter().map(Value::String).collect(); + let t = if t.is_empty() { + Value::Null + } else { + Value::Array(t) + }; + assert_eq!(t, st["tree"], "{name} : arborescence"); + } +} + +/// Sans les backends dans la façade (pas de job), Strata répond 503. +#[tokio::test] +async fn sans_backends() { + let tmp = tempfile::tempdir().unwrap(); + let st = Strata::new(tmp.path().to_path_buf(), Probe::system()); + let (facade, _go, _stop, _) = fakego::start_with( + tmp.path().to_path_buf(), + fakego::Opts { + strata: Some(Arc::new(st)), + ..Default::default() + }, + ) + .await; + let (c, b) = fakego::send(facade, "GET", "/api/strata", &[], "").await; + assert_eq!(c.as_u16(), 503, "{b:?}"); +} 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()); +} 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()); + } +} diff --git a/rust/loki-rs/tests/tasks.rs b/rust/loki-rs/tests/tasks.rs new file mode 100644 index 0000000..594c8d2 --- /dev/null +++ b/rust/loki-rs/tests/tasks.rs @@ -0,0 +1,555 @@ +//! Lot 26 : tâches planifiées tenues par la façade (`LOKI_TASKS=rust`). +//! +//! Le journal tiré du Go (`internal/loki/parity_tasks_test.go` → +//! `tests/parity_tasks/tasks.json`) est rejoué par une façade dont l'heure +//! des tâches est fixée pas à pas et dont les tics du planificateur sont +//! déclenchés à la demande. Le faux Go tient la base, le moteur occupé et la +//! discussion ouverte ; une tâche IA qu'il reçoit ne tourne pas (le modèle +//! n'est jamais prêt ici, comme dans le journal). + +#![cfg(unix)] + +mod common; + +use std::collections::HashMap; +use std::net::SocketAddr; +use std::path::PathBuf; +use std::sync::Arc; +use std::sync::atomic::{AtomicI64, Ordering}; +use std::time::Duration; + +use common::fakego; +use common::parity::apply_files; +use loki_rs::store::Store; +use loki_rs::tasks::{self, Tasks}; +use regex::Regex; +use serde_json::{Value, json}; + +fn fixture() -> Value { + serde_json::from_str(include_str!("parity_tasks/tasks.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"; + +/// L'heure des tâches du rejeu (ns Unix). +static NOW: AtomicI64 = AtomicI64::new(0); + +fn clock() -> i64 { + NOW.load(Ordering::SeqCst) +} + +fn rfc3339_ns(v: &str) -> i64 { + let d = &v[..10]; + let t = &v[11..19]; + let p: Vec = d.split('-').map(|x| x.parse().unwrap()).collect(); + let h: Vec = t.split(':').map(|x| x.parse().unwrap()).collect(); + let utc = loki_rs::tz::Location::utc(); + utc.date(p[0], p[1], p[2], h[0], h[1], h[2]) * 1_000_000_000 +} + +struct Bench { + facade: SocketAddr, + go: Arc, + _stop: tokio::sync::oneshot::Sender<()>, + home: PathBuf, + tasks: Arc, + store: Store, + ids: HashMap, + order: Vec, + _tmp: tempfile::TempDir, +} + +async fn bench(clock: fn() -> i64) -> Bench { + let tmp = tempfile::tempdir().unwrap(); + let home = tmp.path().to_path_buf(); + let tasks = Arc::new(Tasks::with(home.clone(), Duration::from_secs(3600), clock)); + let (facade, go, stop, _) = fakego::start_with( + home.clone(), + fakego::Opts { + tasks: Some(tasks.clone()), + ..Default::default() + }, + ) + .await; + // Pas de modèle prêt : le port du moteur ne répond pas. + let port = std::net::TcpListener::bind("127.0.0.1:0") + .unwrap() + .local_addr() + .unwrap() + .port(); + go.set("config", "PORT", &port.to_string()); + let ws = home.join("workspace").join("actif"); + std::fs::create_dir_all(&ws).unwrap(); + *go.conv_workspace.lock().unwrap() = Some(json!({ + "dir": ws, "root": home.join("workspace"), "id": "actif", "title": "" + })); + *go.conv_ensure.lock().unwrap() = true; + let backend = go.backend.lock().unwrap().unwrap(); + let store = Store::new(backend, fakego::JETON.to_string()); + // Le premier passage du planificateur. + for _ in 0..500 { + if tasks.ticks() > 0 { + break; + } + tokio::time::sleep(Duration::from_millis(5)).await; + } + Bench { + facade, + go, + _stop: stop, + home, + tasks, + store, + ids: HashMap::new(), + order: Vec::new(), + _tmp: tmp, + } +} + +impl Bench { + /// Les identifiants tirés au hasard deviennent {{ID1}}, {{ID2}}… dans + /// l'ordre où ils apparaissent. + fn mask(&mut self, v: &str) -> String { + let id = Regex::new(r"\b[0-9a-f]{12}\b").unwrap(); + let dur = Regex::new(r#"exit: (-?[0-9]+) · [^\n"\\]*"#).unwrap(); + let created = Regex::new(r#""created":[0-9]+"#).unwrap(); + let mut out = String::new(); + let mut last = 0; + for m in id.find_iter(v) { + out.push_str(&v[last..m.start()]); + let k = m.as_str().to_string(); + if !self.ids.contains_key(&k) { + self.order.push(k.clone()); + self.ids + .insert(k.clone(), format!("{{{{ID{}}}}}", self.order.len())); + } + out.push_str(&self.ids[&k]); + last = m.end(); + } + out.push_str(&v[last..]); + let out = out.replace(&self.home.to_string_lossy().into_owned(), "{{HOME}}"); + let out = created.replace_all(&out, r#""created":"{{T}}""#); + dur.replace_all(&out, "exit: $1 · {{DUR}}").into_owned() + } + + fn unmask(&self, v: &str) -> String { + let mut v = v.to_string(); + // Les plus longs d'abord : {{ID10}} avant {{ID1}}. + let mut ids: Vec<_> = self.ids.iter().collect(); + ids.sort_by_key(|(_, m)| std::cmp::Reverse(m.len())); + for (id, m) in ids { + v = v.replace(m, id); + } + v.replace("{{HOME}}", &self.home.to_string_lossy()) + } + + async fn call(&mut self, method: &str, path: &str, body: &str) -> (u16, Value) { + let (code, b) = fakego::send(self.facade, method, path, &[], body).await; + let raw = String::from_utf8_lossy(&b).into_owned(); + let out = serde_json::from_str(&self.mask(&raw)) + .unwrap_or_else(|_| panic!("{path} : corps illisible {raw:?}")); + (code.as_u16(), out) + } + + async fn snapshot(&mut self) -> Value { + let list = tasks::list_tasks(&self.store).await; + if list.is_empty() { + return Value::Null; + } + let raw = serde_json::to_string(&list).unwrap(); + serde_json::from_str(&self.mask(&raw)).unwrap() + } + + async fn last_run(&self, id: &str) -> i64 { + tasks::get_task(&self.store, id) + .await + .map_or(0, |t| t.last_run) + } + + async fn tick(&self) { + let before = self.tasks.ticks(); + self.tasks.kick(); + for _ in 0..2000 { + if self.tasks.ticks() > before { + return; + } + tokio::time::sleep(Duration::from_millis(5)).await; + } + panic!("le tic ne finit pas"); + } +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 4)] +async fn parite_des_taches() { + let fx = fixture(); + let steps = fx["steps"].as_array().unwrap(); + NOW.store(rfc3339_ns(s(&steps[0]["now"])), Ordering::SeqCst); + let mut bn = bench(clock).await; + let mut ia_runs = 0; + for st in steps { + let name = s(&st["name"]); + if let Some(n) = st["now"].as_str() { + NOW.store(rfc3339_ns(n), Ordering::SeqCst); + } + 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() { + bn.go.set("config", k, s(v)); + } + for (k, v) in st["state"].as_object().into_iter().flatten() { + bn.go.set("state", k, s(v)); + } + if let Some(t) = st["put_task"].as_str() { + let v: Value = serde_json::from_str(t).unwrap(); + bn.go.set("tasks", s(&v["id"]), t); + } + *bn.go.tune_busy.lock().unwrap() = + (st["tune"].as_bool() == Some(true)).then(|| TUNE_BUSY.to_string()); + let generating = st["generating"].as_bool() == Some(true); + *bn.go.generating.lock().unwrap() = generating; + if st["tick"].as_bool() == Some(true) { + bn.tick().await; + } + let before = match st["wait_done"].as_str() { + Some(id) => bn.last_run(&bn.unmask(id)).await, + None => 0, + }; + if let Some(path) = st["path"].as_str() { + let body = bn.unmask(s(&st["body"])); + let (code, out) = bn.call(s(&st["method"]), path, &body).await; + assert_eq!(code as i64, st["status"].as_i64().unwrap(), "{name}"); + assert_eq!(out, st["out"], "{name}"); + } + *bn.go.generating.lock().unwrap() = false; + *bn.go.tune_busy.lock().unwrap() = None; + if let Some(want) = st["wait_running"].as_str() { + let mut ok = false; + for _ in 0..1000 { + let (_, out) = bn.call("GET", "/api/tasks", "").await; + if out["running_id"] == json!(want) { + ok = true; + break; + } + tokio::time::sleep(Duration::from_millis(10)).await; + } + assert!(ok, "{name} : la tâche ne tourne pas"); + } + if let Some(id) = st["wait_done"].as_str() { + let id = bn.unmask(id); + let mut ok = false; + for _ in 0..2000 { + if bn.last_run(&id).await != before { + ok = true; + break; + } + tokio::time::sleep(Duration::from_millis(10)).await; + } + assert!(ok, "{name} : le passage ne finit pas"); + // Le registre des scripts est vidé juste après l'écriture. + while bn.tasks.script_running() { + tokio::time::sleep(Duration::from_millis(5)).await; + } + } + let snap = bn.snapshot().await; + assert_eq!(snap, st["tasks"], "{name} : tâches"); + // Une tâche IA due part au Go, qui ne la fait pas tourner (modèle pas + // prêt) : son état ne bouge pas. + let runs = bn.go.task_runs.lock().unwrap().clone(); + if runs.len() != ia_runs { + assert_eq!(name, "tic : une tâche IA due attend le modèle"); + assert_eq!(runs.len(), ia_runs + 1, "{name}"); + let masked = bn.mask(runs.last().unwrap()); + assert_eq!(masked, "{{ID1}}", "{name}"); + ia_runs = runs.len(); + } + } + assert_eq!(ia_runs, 1, "la tâche IA due n'est jamais partie au Go"); + // Les notifications des scripts partent au Go (la façade ne les tient + // pas) : succès, échec, puis les passages du planificateur. + let sends = bn.go.push_sends.lock().unwrap().clone(); + let titles: Vec<&str> = sends.iter().map(|(t, _)| t.as_str()).collect(); + assert_eq!( + titles.first().copied(), + Some("Loki · tâche terminée"), + "{sends:?}" + ); + assert!( + sends + .iter() + .any(|(t, b)| t == "Loki · tâche en échec" && b == "K échec"), + "{sends:?}" + ); + assert!( + !sends.iter().any(|(_, b)| b == "L lente"), + "un arrêt manuel ne notifie pas : {sends:?}" + ); +} + +/// Une tâche IA lancée à la main part au Go, qui l'exécute ; le moteur prêt +/// (preset externe), la façade ne la refuse pas. +#[tokio::test] +async fn tache_ia_executee_par_le_go() { + let mut bn = bench(tasks::now_ns).await; + bn.go.set("config", loki_rs::engstatus::EXT_KEY_FLAG, "1"); + let (code, out) = bn + .call( + "POST", + "/api/tasks/save", + r#"{"name":"IA","prompt":"Fais le point.","schedule":"@every 1h","enabled":true}"#, + ) + .await; + assert_eq!(code, 200, "{out}"); + let id = bn.unmask(s(&out["id"])); + // Le Go tient la tâche un moment : l'écran la montre en cours. + *bn.go.task_run_delay.lock().unwrap() = Some(Duration::from_millis(400)); + let (code, out) = bn + .call("POST", "/api/tasks/run", &json!({ "id": id }).to_string()) + .await; + assert_eq!((code, out), (200, json!({ "ok": true }))); + let masked = bn.mask(&id); + let mut seen = false; + for _ in 0..200 { + let (_, v) = bn.call("GET", "/api/tasks", "").await; + if v["running_id"] == json!(masked) { + seen = true; + break; + } + tokio::time::sleep(Duration::from_millis(5)).await; + } + assert!(seen, "la tâche IA n'est pas montrée en cours"); + for _ in 0..500 { + let (_, v) = bn.call("GET", "/api/tasks", "").await; + if v["running_id"] == json!("") { + break; + } + tokio::time::sleep(Duration::from_millis(5)).await; + } + let (_, v) = bn.call("GET", "/api/tasks", "").await; + assert_eq!(v["running_id"], json!("")); + assert_eq!(*bn.go.task_runs.lock().unwrap(), vec![id.clone()]); + // Le Go ne l'a pas fait tourner (occupé) : rien n'est enregistré ici. + let t = tasks::get_task(&bn.store, &id).await.unwrap(); + assert_eq!( + (t.last_run, t.last_ok, t.last_report.as_str()), + (0, false, "") + ); + // Arrêt d'une tâche IA : la génération du Go. + let (code, _) = bn + .call("POST", "/api/tasks/stop", &json!({ "id": id }).to_string()) + .await; + assert_eq!(code, 200); + assert_eq!(*bn.go.lease_log.lock().unwrap(), vec!["stop".to_string()]); + let (code, _) = bn + .call("POST", "/api/tasks/run", r#"{"id":"absente"}"#) + .await; + assert_eq!(code, 404); +} + +/// Sans demande, le planificateur tourne à son pas. +#[tokio::test] +async fn tics_au_pas_du_planificateur() { + let tmp = tempfile::tempdir().unwrap(); + let t = Arc::new(Tasks::with( + tmp.path().to_path_buf(), + Duration::from_millis(20), + tasks::now_ns, + )); + let (_facade, _go, _stop, _) = fakego::start_with( + tmp.path().to_path_buf(), + fakego::Opts { + tasks: Some(t.clone()), + ..Default::default() + }, + ) + .await; + for _ in 0..500 { + if t.ticks() >= 4 { + return; + } + tokio::time::sleep(Duration::from_millis(5)).await; + } + panic!("le planificateur ne tourne pas : {} tics", t.ticks()); +} + +/// Sans la façade pour les tâches, leurs routes partent au Go. +#[tokio::test] +async fn sans_la_facade() { + let tmp = tempfile::tempdir().unwrap(); + let (facade, go, _stop) = fakego::start(tmp.path().to_path_buf()).await; + let (c, b) = fakego::send(facade, "GET", "/api/tasks", &[], "").await; + assert_eq!(c.as_u16(), 200); + assert_eq!(String::from_utf8_lossy(&b), "go:/api/tasks"); + assert!( + go.relayed + .lock() + .unwrap() + .contains(&"/api/tasks".to_string()) + ); +} + +/// Une horloge qui avance d'une seconde et demie à chaque lecture. +static TICKING: AtomicI64 = AtomicI64::new(1_774_738_800_000_000_000); + +fn ticking() -> i64 { + TICKING.fetch_add(1_500_000_000, Ordering::SeqCst) +} + +/// La durée d'un passage : de son début à sa fin, à l'horloge des tâches. +#[tokio::test] +async fn duree_du_passage() { + let mut bn = bench(ticking).await; + apply_files( + &bn.home, + &json!({ "scripts/ok.sh": "#!/bin/sh\necho bonjour\n" }), + ); + let (code, out) = bn + .call( + "POST", + "/api/tasks/save", + r#"{"name":"S","kind":"script","script":"ok.sh","schedule":"@every 1h","enabled":true}"#, + ) + .await; + assert_eq!(code, 200, "{out}"); + let id = bn.unmask(s(&out["id"])); + let (code, _) = bn + .call("POST", "/api/tasks/run", &json!({ "id": id }).to_string()) + .await; + assert_eq!(code, 200); + let mut t = None; + for _ in 0..1000 { + t = tasks::get_task(&bn.store, &id) + .await + .filter(|t| t.last_run > 0); + if t.is_some() { + break; + } + tokio::time::sleep(Duration::from_millis(10)).await; + } + let t = t.expect("le passage ne finit pas"); + assert!(t.last_ok, "{t:?}"); + assert!( + t.last_dur_ms > 0 && t.last_dur_ms % 1500 == 0, + "durée : {}", + t.last_dur_ms + ); +} + +/// Au démarrage, le prochain passage des tâches actives qui n'en ont pas +/// (`primeNextRuns`) ; les autres ne bougent pas. +#[tokio::test] +async fn amorce_au_demarrage() { + let tmp = tempfile::tempdir().unwrap(); + let t = Arc::new(Tasks::with( + tmp.path().to_path_buf(), + Duration::from_secs(3600), + tasks::now_ns, + )); + let task = |id: &str, enabled: bool, next: i64| { + ( + "tasks".to_string(), + id.to_string(), + json!({ "id": id, "name": id, "kind": "script", "script": "ok.sh", + "schedule": "@every 1h", "enabled": enabled, "next_run": next }) + .to_string(), + ) + }; + let (_facade, go, _stop, _) = fakego::start_with( + tmp.path().to_path_buf(), + fakego::Opts { + tasks: Some(t.clone()), + seed: vec![ + task("active", true, 0), + task("eteinte", false, 0), + task("posee", true, 42), + ], + ..Default::default() + }, + ) + .await; + for _ in 0..500 { + if t.ticks() > 0 { + break; + } + tokio::time::sleep(Duration::from_millis(5)).await; + } + assert!(t.ticks() > 0, "pas de premier passage"); + let next = |id: &str| -> i64 { + let v: Value = serde_json::from_str(&go.get("tasks", id).unwrap()).unwrap(); + v["next_run"].as_i64().unwrap() + }; + let now_ms = tasks::now_ns() / 1_000_000; + let a = next("active"); + assert!( + (now_ms + 3_590_000..=now_ms + 3_600_000).contains(&a), + "active : {a}" + ); + assert_eq!(next("eteinte"), 0); + assert_eq!(next("posee"), 42); +} + +fn zero_clock() -> i64 { + 0 +} + +/// Lot 37 : les outils task_* de l'agent, rejoués contre le journal du Go +/// (`internal/loki/parity_tasks_tools_test.go` → `parity_tasks/tools.json`) : +/// chaque appel, son résultat tel que le modèle le reçoit, les tâches en base +/// après coup. +#[tokio::test(flavor = "multi_thread", worker_threads = 4)] +async fn parite_des_outils() { + use loki_rs::tasks::tools::{ToolEnv, exec, is_tool}; + let fx: Value = serde_json::from_str(include_str!("parity_tasks/tools.json")).expect("journal"); + let mut bn = bench(zero_clock).await; + let scripts = bn.home.join("scripts"); + let mut now = 0; + for st in fx.as_array().unwrap() { + let name = s(&st["name"]); + if let Some(n) = st["now"].as_str() { + now = rfc3339_ns(n); + } + 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["state"].as_object().into_iter().flatten() { + bn.go.set("state", k, s(v)); + } + if let Some(t) = st["put_task"].as_str() { + let raw = bn.unmask(t); + let v: Value = serde_json::from_str(&raw).unwrap(); + bn.go.set("tasks", s(&v["id"]), &raw); + bn.mask(&raw); + } + let tool = s(&st["tool"]); + assert!(is_tool(tool), "{name}"); + let args: serde_json::Map = + serde_json::from_str(&bn.unmask(&st["args"].to_string())).unwrap(); + let env = ToolEnv { + store: &bn.store, + scripts_dir: &scripts, + now_ns: now, + }; + let out = exec(&env, tool, &args).await.expect("outil task_*"); + assert_eq!(bn.mask(&out), s(&st["out"]), "{name}"); + assert_eq!(bn.snapshot().await, st["tasks"], "{name}"); + } + let env = ToolEnv { + store: &bn.store, + scripts_dir: &scripts, + now_ns: now, + }; + assert_eq!(exec(&env, "bash", &serde_json::Map::new()).await, None); + assert!(!is_tool("task_run")); +} diff --git a/rust/loki-rs/tests/tools.rs b/rust/loki-rs/tests/tools.rs index 0664cdc..09637ac 100644 --- a/rust/loki-rs/tests/tools.rs +++ b/rust/loki-rs/tests/tools.rs @@ -67,3 +67,63 @@ async fn le_reste_va_au_go() { let args = a(json!({"pattern":"*"})); assert!(exec_local(&st, false, "glob", &args, "*").await.is_some()); } + +/// Lot 38 : l'outil criteria du mode Code, rejoué contre le journal du Go +/// (`internal/loki/parity_criteria_test.go` → `parity_tools/criteria.json`) : +/// résultat de chaque appel, critères rangés après coup (scellés), et la +/// liste poussée à l'écran quand elle change. +#[tokio::test] +async fn criteria_comme_le_go() { + use loki_rs::chat::crittool::{CRIT_MAX_COUNT, exec}; + let fx: Value = + serde_json::from_str(include_str!("parity_tools/criteria.json")).expect("journal"); + let t = tempfile::tempdir().unwrap(); + let (_f, go, _stop) = common::fakego::start(t.path().into()).await; + let backend = go.backend.lock().unwrap().unwrap(); + let store = loki_rs::store::Store::new(backend, common::fakego::JETON.into()); + assert_eq!(CRIT_MAX_COUNT, 20); + let conv = "c1"; + for st in fx.as_array().unwrap() { + let name = st["name"].as_str().unwrap(); + if let Some(put) = st["put"].as_str() { + store + .put_seal("chat", &format!("crit:{conv}"), put.as_bytes().to_vec()) + .await + .unwrap(); + } + let args = st["args"].as_object().cloned().unwrap_or_default(); + let verifier = st["verifier"].as_bool() == Some(true); + let before = store + .get_open("chat", &format!("crit:{conv}")) + .await + .unwrap(); + let out = exec(&store, conv, &args, verifier).await; + assert_eq!(out.result, st["out"].as_str().unwrap(), "{name}"); + let raw = store + .get_open("chat", &format!("crit:{conv}")) + .await + .unwrap(); + let stored = raw + .as_deref() + .filter(|b| !b.is_empty()) + .map(|b| serde_json::from_slice::(b).unwrap()) + .unwrap_or(Value::Null); + assert_eq!(stored, st["stored"], "{name}"); + // Scellés comme le contenu des discussions. + if let Some(sealed) = go.get("chat", &format!("crit:{conv}")) { + assert!(sealed.starts_with("SCELLE:"), "{name} : {sealed}"); + } + // L'écran reçoit la liste quand elle change (vide : effacée). + match &out.notify { + Some(list) => { + let want = if stored.is_null() { + json!([]) + } else { + stored.clone() + }; + assert_eq!(json!(list), want, "{name}"); + } + None => assert_eq!(raw, before, "{name} : rien ne change sans notification"), + } + } +} diff --git a/rust/loki-rs/tests/tplprobe.rs b/rust/loki-rs/tests/tplprobe.rs new file mode 100644 index 0000000..421d589 --- /dev/null +++ b/rust/loki-rs/tests/tplprobe.rs @@ -0,0 +1,264 @@ +//! Lot 23 : sonde de gabarit portée en Rust (`chat/tplprobe.rs`). +//! +//! Le journal tiré du Go (`internal/loki/parity_tplprobe_test.go` → +//! `tests/parity_tplprobe/tplprobe.json`) est rejoué échange par échange : un +//! faux moteur rend les réponses enregistrées dans l'ordre et vérifie que le +//! port envoie, octet pour octet, les mêmes requêtes (méthode, chemin, clé, +//! corps). Puis verdicts, lignes de journal, derniers verdicts (courant et par +//! forme), cache, relevés du rendu, comparaisons et empreintes des formes. + +use std::collections::VecDeque; +use std::convert::Infallible; +use std::sync::{Arc, Mutex}; +use std::time::Duration; + +use bytes::Bytes; +use http_body_util::{BodyExt, Full}; +use hyper::service::service_fn; +use hyper::{Request, Response}; +use hyper_util::rt::TokioIo; +use loki_rs::chat::tplprobe::{self as tp, Env, Prober, Shape, TplProbe}; +use loki_rs::chat::types::Tool; +use serde_json::{Map, Value}; +use tokio::net::TcpListener; + +fn fixture() -> Value { + serde_json::from_str(include_str!("parity_tplprobe/tplprobe.json")).expect("journal") +} + +fn s(v: &Value) -> &str { + v.as_str().unwrap_or("") +} + +/// Le faux moteur : les échanges attendus, et les écarts constatés. +#[derive(Default)] +struct Script { + expected: VecDeque, + errors: Vec, +} + +async fn fake_engine(script: 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 script = script.clone(); + tokio::spawn(async move { + let svc = service_fn(move |req: Request| { + let script = script.clone(); + async move { + let method = req.method().to_string(); + let path = req.uri().path().to_string(); + let auth = req + .headers() + .get("authorization") + .map(|v| String::from_utf8_lossy(v.as_bytes()).into_owned()) + .unwrap_or_default(); + let body = req.into_body().collect().await.unwrap().to_bytes(); + let body = String::from_utf8_lossy(&body).into_owned(); + let mut sc = script.lock().unwrap(); + let (code, reply) = match sc.expected.pop_front() { + None => { + sc.errors + .push(format!("requête en trop : {method} {path} {body}")); + (599, String::new()) + } + Some(want) => { + let got = + (method.as_str(), path.as_str(), auth.as_str(), body.as_str()); + let exp = ( + s(&want["method"]), + s(&want["path"]), + s(&want["auth"]), + s(&want["body"]), + ); + if got != exp { + sc.errors.push(format!("attendu {exp:?}\n reçu {got:?}")); + } + ( + want["status"].as_u64().unwrap() as u16, + s(&want["reply"]).to_string(), + ) + } + }; + let mut r = Response::new(Full::new(Bytes::from(reply))); + *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; + }); + } + }); + port +} + +fn shape(fx: &Value, name: &str) -> Shape { + let sh = &fx["shapes"][name]; + let tools: Vec = if s(&sh["tools"]).is_empty() { + Vec::new() + } else { + serde_json::from_str(s(&sh["tools"])).unwrap() + }; + let kwargs: Option> = sh["kwargs"].as_object().cloned(); + Shape::new(&tools, kwargs.as_ref(), s(&sh["effort"])) +} + +/// Le verdict comme le journal le range : sans l'instant, avec `cacheable`. +fn result_json(r: &tp::ProbeResult) -> Value { + let mut v = r.to_json(); + let m = v.as_object_mut().unwrap(); + m.remove("at"); + m.insert("cacheable".into(), Value::Bool(r.cacheable)); + v +} + +fn opt_json(r: Option) -> Value { + r.map(|r| result_json(&r)).unwrap_or(Value::Null) +} + +fn take(script: &Arc>, ctx: &str) { + let mut sc = script.lock().unwrap(); + assert!(sc.errors.is_empty(), "{ctx} :\n{}", sc.errors.join("\n")); + assert!( + sc.expected.is_empty(), + "{ctx} : échanges non faits {:?}", + sc.expected + ); + sc.errors.clear(); +} + +#[tokio::test] +async fn parite_des_verdicts() { + let fx = fixture(); + for sc in fx["scenarios"].as_array().unwrap() { + let name = s(&sc["name"]); + let script = Arc::new(Mutex::new(Script::default())); + let port = fake_engine(script.clone()).await; + let mut probe = TplProbe::new(); + probe.auto = false; + probe.backoff = Duration::from_millis(1); + // Preset externe : décidé sur la configuration, comme le Go. + let cfg: std::collections::HashMap = sc["config"] + .as_object() + .into_iter() + .flatten() + .map(|(k, v)| (k.clone(), s(v).to_string())) + .collect(); + let env = Env { + external: loki_rs::engstatus::is_external(&cfg), + port: port.into(), + key: s(&sc["api_key"]).to_string(), + }; + assert_eq!( + env.external, + sc["external"].as_bool().unwrap_or(false), + "{name}" + ); + for (i, st) in sc["steps"].as_array().unwrap().iter().enumerate() { + let ctx = format!("{name} #{i}"); + script.lock().unwrap().expected = st["exchanges"] + .as_array() + .into_iter() + .flatten() + .cloned() + .collect(); + let sh = shape(&fx, s(&st["shape"])); + let r = probe.ensure(&env, &sh).await; + take(&script, &ctx); + assert_eq!(result_json(&r), st["result"], "{ctx} : verdict"); + assert_eq!(tp::probe_line(&r), s(&st["line"]), "{ctx} : journal"); + assert_eq!( + opt_json(probe.caps_current(env.external)), + st["current"], + "{ctx} : courant" + ); + assert_eq!( + opt_json(probe.caps_for(env.external, &sh)), + st["for"], + "{ctx} : forme" + ); + // Un preset externe n'affiche jamais le verdict du moteur local. + assert!(probe.caps_current(true).is_none(), "{ctx}"); + assert!(probe.caps_for(true, &sh).is_none(), "{ctx}"); + } + } +} + +#[tokio::test] +async fn parite_des_releves() { + let fx = fixture(); + let script = Arc::new(Mutex::new(Script::default())); + let port = fake_engine(script.clone()).await; + let p = Prober::local(port.into(), "", Duration::from_millis(1)); + for (i, pc) in fx["prints"].as_array().unwrap().iter().enumerate() { + script.lock().unwrap().expected = pc["exchanges"] + .as_array() + .into_iter() + .flatten() + .cloned() + .collect(); + let got = p.render_print(&shape(&fx, s(&pc["shape"]))).await; + take(&script, &format!("relevé #{i}")); + match got { + Ok(out) => { + assert_eq!(out, s(&pc["out"]), "relevé #{i}"); + assert_eq!(s(&pc["err"]), "", "relevé #{i}"); + } + Err(e) => { + assert_eq!(e, s(&pc["err"]), "relevé #{i}"); + assert_eq!(s(&pc["out"]), "", "relevé #{i}"); + } + } + } + for d in fx["diffs"].as_array().unwrap() { + let (same, detail) = tp::render_diff(s(&d["before"]), s(&d["after"])); + assert_eq!(same, d["same"].as_bool().unwrap(), "{d}"); + assert_eq!(detail, s(&d["detail"]), "{d}"); + } + for (name, h) in fx["hashes"].as_object().unwrap() { + assert_eq!(shape(&fx, name).hash(), s(h), "empreinte {name}"); + } +} + +/// La sonde automatique : une à la fois, au plus une par intervalle, et +/// jamais quand elle est coupée. +#[tokio::test] +async fn declenchement_borne() { + let script = Arc::new(Mutex::new(Script::default())); + let port = fake_engine(script.clone()).await; + let env = Env { + external: false, + port: port.into(), + key: String::new(), + }; + let health = + || serde_json::json!({"method": "GET", "path": "/health", "status": 503, "reply": ""}); + let mut off = TplProbe::new(); + off.auto = false; + let off = Arc::new(off); + off.kick(env.clone(), Shape::default()); + tokio::time::sleep(Duration::from_millis(100)).await; + take(&script, "coupée"); + + let mut p = TplProbe::new(); + p.every = Duration::from_millis(300); + let p = Arc::new(p); + script.lock().unwrap().expected = [health()].into(); + p.kick(env.clone(), Shape::default()); + p.kick(env.clone(), Shape::default()); // encore en vol : rien + tokio::time::sleep(Duration::from_millis(100)).await; + take(&script, "première"); + p.kick(env.clone(), Shape::default()); // finie, mais trop tôt : rien + tokio::time::sleep(Duration::from_millis(50)).await; + take(&script, "trop tôt"); + tokio::time::sleep(Duration::from_millis(300)).await; + script.lock().unwrap().expected = [health()].into(); + p.kick(env.clone(), Shape::default()); + tokio::time::sleep(Duration::from_millis(100)).await; + take(&script, "après l'intervalle"); + // La forme sondée en dernier est retenue pour le relevé du rendu. + assert_eq!(p.last_shape(), Shape::default()); +} diff --git a/rust/loki-rs/tests/tune.rs b/rust/loki-rs/tests/tune.rs new file mode 100644 index 0000000..76600e2 --- /dev/null +++ b/rust/loki-rs/tests/tune.rs @@ -0,0 +1,1465 @@ +//! Lot 28 : l'optimiseur tenu par la façade (`LOKI_TUNE=rust`). +//! +//! Les essais passent par de vrais processus : ce binaire de test joue +//! « loki serve » (la seule fonction dont le nom contient « serve », +//! sélectionnée par l'argument que la façade passe). À blanc, il écrit la +//! ligne de commande de l'essai ; pour de vrai, il sert un faux llama-server +//! dont le prefill est d'autant plus rapide que le micro-lot est petit. + +mod common; + +use std::collections::HashMap; +use std::io::{BufRead, BufReader, Read, Write}; +use std::net::TcpListener; +use std::path::PathBuf; +use std::sync::{Arc, Mutex}; +use std::time::Duration; + +use common::fakego; +use loki_rs::tune::job::Tune; +use serde_json::{Value, json}; + +const FAUX: &str = "LOKI_TUNE_FAUX"; +const VRAM_NOTE: &str = "VRAM libre non mesurable (nvidia-smi absent) : ni micro-lot plus grand ni marges --fit essayés"; +const BUSY: &str = "optimisation en cours (loki tune) : le moteur est réservé aux essais — attends la fin ou annule-la"; + +// --------------------------------------------------------------------------- +// Le faux moteur +// --------------------------------------------------------------------------- + +/// Ce que le faux moteur annonce : son contexte, s'il travaille déjà, et ses +/// débits (0 : prefill selon le micro-lot, decode à 50 jetons/s) ; `down` : +/// /health en 503 ; `flat_props` : /props sans `default_generation_settings` +/// (le contexte au premier niveau seulement). +#[derive(Default)] +struct Knobs { + n_ctx: Mutex, + busy: Mutex, + pps: Mutex, + tg: Mutex, + down: Mutex, + flat_props: Mutex, +} + +/// Un faux llama-server (fil à part, jusqu'à la fin du processus). +fn fake_llama(listener: TcpListener, ubatch: i64, knobs: Arc) { + let last = Arc::new(Mutex::new(0i64)); + std::thread::spawn(move || { + for st in listener.incoming().flatten() { + let (last, knobs) = (last.clone(), knobs.clone()); + std::thread::spawn(move || { + let _ = answer(st, ubatch, &knobs, &last); + }); + } + }); +} + +fn answer( + st: std::net::TcpStream, + ubatch: i64, + knobs: &Knobs, + last: &Mutex, +) -> std::io::Result<()> { + let n_ctx = *knobs.n_ctx.lock().unwrap(); + let mut r = BufReader::new(st.try_clone()?); + let mut line = String::new(); + r.read_line(&mut line)?; + let path = line.split(' ').nth(1).unwrap_or("").to_string(); + let mut len = 0usize; + loop { + let mut h = String::new(); + r.read_line(&mut h)?; + if h.trim().is_empty() { + break; + } + if let Some((k, v)) = h.split_once(':') + && k.eq_ignore_ascii_case("content-length") + { + len = v.trim().parse().unwrap_or(0); + } + } + let mut body = vec![0u8; len]; + r.read_exact(&mut body)?; + let req: Value = serde_json::from_slice(&body).unwrap_or(Value::Null); + // Un seul slot : comme llama-server, un id_slot qu'il n'a pas est refusé. + if path == "/v1/chat/completions" && req["id_slot"].as_i64().is_some_and(|i| i >= 1) { + let b = r#"{"error":{"code":400,"message":"slot id invalide"}}"#; + let mut w = st; + return write!( + w, + "HTTP/1.1 400 Bad Request\r\nContent-Type: application/json\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{b}", + b.len() + ); + } + if path == "/health" && *knobs.down.lock().unwrap() { + let mut w = st; + return w.write_all( + b"HTTP/1.1 503 Service Unavailable\r\nContent-Length: 0\r\nConnection: close\r\n\r\n", + ); + } + let out = match path.as_str() { + "/health" => json!({ "status": "ok" }), + "/props" if *knobs.flat_props.lock().unwrap() => json!({ + "n_ctx": n_ctx, "total_slots": 1, "build_info": "b7000-faux", + }), + "/props" => json!({ + "n_ctx": n_ctx, "total_slots": 1, "build_info": "b7000-faux", + "default_generation_settings": { "n_ctx": n_ctx }, + }), + "/slots" => json!([{ "id": 0, "is_processing": *knobs.busy.lock().unwrap() }]), + "/tokenize" => { + let n = req["content"].as_str().unwrap_or("").len() / 4; + json!({ "tokens": vec![0; n.max(1)] }) + } + "/v1/chat/completions" => { + let chars: usize = req["messages"] + .as_array() + .map(|m| { + m.iter() + .map(|x| x["content"].as_str().unwrap_or("").len()) + .sum() + }) + .unwrap_or(0); + let total = (chars / 4).max(1) as i64; + let mut l = last.lock().unwrap(); + let cached = if req["cache_prompt"] == json!(true) && *l <= total { + *l + } else { + 0 + }; + *l = total; + let new = total - cached; + // Le prefill ralentit avec le micro-lot ; le decode, non. + let pps = match *knobs.pps.lock().unwrap() { + 0.0 => 4_000_000.0 / ubatch as f64, + v => v, + }; + let tg = match *knobs.tg.lock().unwrap() { + 0.0 => 50.0, + v => v, + }; + let n_gen = req["max_tokens"].as_i64().unwrap_or(16); + json!({ + "choices": [{ "message": { "content": "ok" } }], + "usage": { "prompt_tokens": total }, + "timings": { + "prompt_n": new, "prompt_ms": (new as f64 / pps * 1000.0).max(1.0), + "predicted_n": n_gen, "predicted_ms": n_gen as f64 / tg * 1000.0, + "cache_n": cached, + }, + }) + } + _ => { + let mut w = st; + return w.write_all( + b"HTTP/1.1 404 Not Found\r\nContent-Length: 0\r\nConnection: close\r\n\r\n", + ); + } + }; + let b = out.to_string(); + let mut w = st; + write!( + w, + "HTTP/1.1 200 OK\r\nContent-Type: application/json\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{b}", + b.len() + ) +} + +/// « loki serve » pour l'optimiseur : ce binaire relancé par la façade avec +/// `LOKI_TUNE_FAUX`. Ailleurs : rien. +#[test] +fn serve() { + if std::env::var(FAUX).is_err() { + return; + } + let dir = PathBuf::from(std::env::var("LOKI_TUNE_TRIAL").unwrap()); + let cfg = + loki_rs::presets::parse_env(&std::fs::read_to_string(dir.join("config.env")).unwrap()); + let get = |k: &str| cfg.get(k).cloned().unwrap_or_default(); + let ub = get("UBATCH"); + let die = |msg: &str| -> ! { + let _ = std::io::stderr().write_all(format!("{msg}\n").as_bytes()); + std::process::exit(1); + }; + if std::env::var("LOKI_TUNE_DRYRUN").as_deref() == Ok("1") { + if get("REFUS") == ub { + die(&format!("\x1b[31m[err]\x1b[0m UBATCH={ub} refusé")); + } + let mut args = vec!["-m".to_string(), get("MODEL"), "-c".into(), get("CTX")]; + for (k, f) in [("UBATCH", "-ub"), ("BATCH", "-b")] { + if !get(k).is_empty() && !(k == "BATCH" && !get("SANS_BATCH").is_empty()) { + args.extend([f.to_string(), get(k)]); + } + } + // Une ligne qui change ce que le modèle calcule. + if get("DENATURE") == ub { + args.push("--no-mmap".into()); + } + args.extend(["--host".into(), get("HOST"), "--port".into(), get("PORT")]); + let launch = json!({ + "bin": "/opt/faux/llama-server", "args": args, + "probe": { "help": "", "gpus": 0, "vram_mib": 0, + "model_bytes": get("MODEL_BYTES").parse::().unwrap_or(1000), "moe": false, + "cpu_threads": get("CPU_THREADS").parse::().unwrap_or(8) }, + }); + std::fs::write(dir.join("launch.json"), launch.to_string()).unwrap(); + return; + } + if get("ECHEC") == ub { + die("llama_model_load: error loading model: out of memory"); + } + let port: u16 = get("PORT").parse().unwrap(); + let l = TcpListener::bind(("127.0.0.1", port)).unwrap(); + // Un essai qui ne répond jamais : il tient son port (sinon un autre test + // lancé en parallèle peut le prendre et répondre à sa place) sans jamais + // accepter. + if get("MUET") == ub { + std::thread::sleep(Duration::from_secs(600)); + drop(l); + return; + } + let _ = std::io::stdout().write_all(b"load_tensors: offloaded 33/33 layers to GPU\n"); + let knobs = Knobs::default(); + // Un essai qui ne garde pas le contexte de la référence. + let ctx: i64 = get("CTX").parse().unwrap(); + *knobs.n_ctx.lock().unwrap() = if get("CTX_FAUX") == ub { ctx / 2 } else { ctx }; + *knobs.flat_props.lock().unwrap() = get("PROPS_PLAT") == "1"; + // Débits imposés par micro-lot : « 1024:2000,512:1953 ». + let speed = |k: &str| { + get(k) + .split(',') + .filter_map(|p| p.split_once(':')) + .find(|(u, _)| *u == ub) + .and_then(|(_, v)| v.parse::().ok()) + .unwrap_or(0.0) + }; + *knobs.pps.lock().unwrap() = speed("VITESSE"); + *knobs.tg.lock().unwrap() = speed("DECODE"); + fake_llama(l, ub.parse().unwrap_or(2048), Arc::new(knobs)); + // Arrêté par la façade ; un essai oublié (test interrompu) s'en va seul. + std::thread::sleep(Duration::from_secs(600)); +} + +// --------------------------------------------------------------------------- +// Le banc +// --------------------------------------------------------------------------- + +struct Tn { + facade: std::net::SocketAddr, + go: Arc, + _stop: tokio::sync::oneshot::Sender<()>, + home: PathBuf, + port: u16, + main: Arc, + _tmp: tempfile::TempDir, +} + +const PRESET: &str = "# NAME=Dense\nCTX=32768\nUBATCH=2048\nREASONING=off\nCACHE_ISOLATE=off\n"; + +async fn start() -> Tn { + start_with(|_, _| {}, |_, _| {}).await +} + +/// [`start`], l'optimiseur réglé par `ft` et la façade par `fo` (le +/// répertoire en second). +async fn start_with( + ft: impl FnOnce(&mut Tune, &std::path::Path), + fo: impl FnOnce(&mut fakego::Opts, &std::path::Path), +) -> Tn { + let tmp = tempfile::tempdir().unwrap(); + let home = tmp.path().to_path_buf(); + let mut t = Tune::new(home.clone(), std::env::current_exe().unwrap()); + t.trial_env = vec![(FAUX.into(), "1".into())]; + t.poll = Duration::from_millis(20); + t.probe_poll = Duration::from_millis(20); + t.settle = Duration::ZERO; + t.load_timeout = Duration::from_secs(20); + t.lease_ttl = Duration::from_millis(300); + t.gpu_pause = Duration::ZERO; + ft(&mut t, &home); + let tune = Arc::new(t); + // Moteur et modèle présents : la relance du vrai moteur passe ses + // vérifications. + std::fs::write(home.join("llama-server"), "x").unwrap(); + std::fs::write(home.join("m.gguf"), "x").unwrap(); + let mut opts = fakego::Opts { + tune: Some(tune), + ..Default::default() + }; + fo(&mut opts, &home); + let (facade, go, stop, _) = fakego::start_with(home.clone(), opts).await; + // Le vrai moteur, en service. + let l = TcpListener::bind("127.0.0.1:0").unwrap(); + let port = l.local_addr().unwrap().port(); + let main: Arc = Arc::default(); + *main.n_ctx.lock().unwrap() = 32768; + fake_llama(l, 2048, main.clone()); + let tn = Tn { + facade, + go, + _stop: stop, + home, + port, + main, + _tmp: tmp, + }; + tn.preset(PRESET); + *tn.go.service_active.lock().unwrap() = true; + *tn.go.apply_presets.lock().unwrap() = true; + tn +} + +impl Tn { + /// Le preset « dense », et la configuration qui lui correspond (elle + /// seule). + fn preset(&self, content: &str) { + let dir = self.home.join("presets"); + std::fs::create_dir_all(&dir).unwrap(); + let h = self.home.display(); + let content = format!( + "{content}MODEL={h}/m.gguf\nBIN={h}/llama-server\nPORT={}\n", + self.port + ); + std::fs::write(dir.join("dense.env"), &content).unwrap(); + for k in self.go.config_map().keys() { + self.go.del("config", k); + } + for (k, v) in loki_rs::presets::parse_env(&content) { + self.go.set("config", &k, &v); + } + } + + async fn call(&self, method: &str, path: &str, body: &str) -> (u16, Value) { + let (code, b) = fakego::send(self.facade, method, path, &[], body).await; + let v = if b.is_empty() { + Value::Null + } else { + serde_json::from_slice(&b) + .unwrap_or_else(|_| panic!("illisible : {}", String::from_utf8_lossy(&b))) + }; + (code.as_u16(), v) + } + + async fn status(&self) -> Value { + self.call("GET", "/api/tune/status", "").await.1 + } + + async fn wait(&self, what: &str, key: &str) -> Value { + for _ in 0..6000 { + let st = self.status().await; + // Notre propre verrou n'est jamais « une optimisation ailleurs ». + assert_eq!(st.get("elsewhere"), None, "{st}"); + if st[key] != json!(true) { + return st; + } + tokio::time::sleep(Duration::from_millis(10)).await; + } + panic!("{what} : ne finit pas"); + } + + /// Application dans le preset ; son issue (`apply` de l'état). + async fn apply_preset(&self) -> Value { + let (code, out) = self + .call("POST", "/api/tune/apply", r#"{"target":"preset"}"#) + .await; + assert_eq!((code, out), (202, json!({ "ok": true }))); + self.wait("application", "applying").await["apply"].clone() + } + + fn config(&self) -> HashMap { + ["MODEL", "CTX", "UBATCH", "BATCH", "PORT"] + .iter() + .filter_map(|k| self.go.get("config", k).map(|v| (k.to_string(), v))) + .collect() + } +} + +fn trials(res: &Value) -> Vec<(String, String, String)> { + res["trials"] + .as_array() + .map(|a| { + a.iter() + .map(|t| { + ( + t["label"].as_str().unwrap_or("").to_string(), + t["status"].as_str().unwrap_or("").to_string(), + t["why"].as_str().unwrap_or("").to_string(), + ) + }) + .collect() + }) + .unwrap_or_default() +} + +// --------------------------------------------------------------------------- +// Les tests +// --------------------------------------------------------------------------- + +/// Une optimisation entière : vrai moteur arrêté puis relancé, essais +/// composés à blanc puis lancés, mesurés, retenus ; puis l'application dans +/// une copie, dans le preset, et le refus d'un preset changé depuis. +#[tokio::test(flavor = "multi_thread", worker_threads = 4)] +async fn optimisation_complete() { + let tn = start().await; + let (code, out) = tn + .call("POST", "/api/tune", r#"{"stages":["lots"],"budget_min":5}"#) + .await; + assert_eq!((code, out), (202, json!({ "ok": true }))); + assert_eq!(*tn.go.lease_whys.lock().unwrap(), vec!["tune".to_string()]); + // Pendant la mesure : le verrou de LOKI_HOME, au nom de la façade. + let mut seen_lock = false; + for _ in 0..2000 { + if let Some(o) = loki_rs::tune::lock::read_owner(&tn.home.join("tune.lock")) { + assert_eq!(o.owner.pid, i64::from(std::process::id())); + assert_eq!(o.via, "web"); + assert!(o.main_was_active); + seen_lock = true; + break; + } + tokio::time::sleep(Duration::from_millis(5)).await; + } + assert!(seen_lock, "verrou jamais posé"); + // Chaque essai lancé est noté dans le verrou : un Loki tué pendant la + // mesure l'arrête à son redémarrage. + let mut trial = None; + for _ in 0..4000 { + if let Some(t) = + loki_rs::tune::lock::read_owner(&tn.home.join("tune.lock")).and_then(|o| o.trial) + { + trial = Some(t); + break; + } + tokio::time::sleep(Duration::from_millis(5)).await; + } + let trial = trial.expect("essai jamais noté dans le verrou"); + assert!(trial.pid > 0 && trial.pid != i64::from(std::process::id())); + assert_eq!( + trial.exes[0], + std::env::current_exe() + .unwrap() + .to_string_lossy() + .into_owned() + ); + let (code, out) = tn.call("POST", "/api/tune", "{}").await; + assert_eq!(code, 409); + assert_eq!(out["error"], json!(BUSY)); + // Rien ne s'applique pendant la mesure. + assert_eq!( + tn.call("POST", "/api/tune/apply", r#"{"target":"preset"}"#) + .await, + (409, json!({ "ok": false, "error": BUSY })) + ); + let st = tn.wait("optimisation", "running").await; + assert_eq!(st["error"], Value::Null, "{st}"); + // La progression : la référence puis chaque essai, une fois chacun. + let shown: Vec<(&str, &str)> = st["trials"] + .as_array() + .unwrap() + .iter() + .map(|t| (t["label"].as_str().unwrap(), t["status"].as_str().unwrap())) + .collect(); + assert_eq!(shown.len(), 5, "{shown:?}"); + assert_eq!(shown[0], ("configuration actuelle", "mesuré")); + assert!(shown.iter().all(|t| t.1 != "en cours"), "{shown:?}"); + assert_eq!(st["eta_sec"], json!(0)); + let res = &st["result"]; + assert_eq!(res["preset_id"], json!("dense")); + assert_eq!(res["preset_name"], json!("Dense")); + assert_eq!(res["engine"], json!("b7000-faux")); + assert_eq!(res["engine_build"], json!(7000)); + assert_eq!( + (res["n_ctx"].clone(), res["slots"].clone()), + (json!(32768), json!(1)) + ); + assert_eq!(res["depth"], json!(16384)); + assert_eq!(res["baseline"]["status"], json!("mesuré")); + assert_eq!(res["baseline"]["runs"].as_array().unwrap().len(), 2); + assert_eq!(res["baseline"]["offloaded"], json!("33/33")); + let tr = trials(res); + assert_eq!( + tr.iter().map(|t| t.0.as_str()).collect::>(), + [ + "UBATCH=512 BATCH=2048", + "UBATCH=512 BATCH=1024", + "UBATCH=1024 BATCH=2048", + "UBATCH=2048 BATCH=4096" + ] + ); + assert_eq!(tr[0].1, "retenu", "{tr:?}"); + // Confirmé seulement s'il promet : le premier, pas les suivants. + let runs = |i: usize| res["trials"][i]["runs"].as_array().unwrap().len(); + assert_eq!((runs(0), runs(1), runs(2), runs(3)), (2, 1, 1, 1)); + assert!( + tr[0].2.starts_with('−') && tr[0].2.contains("par tour (seuil"), + "{tr:?}" + ); + assert_eq!( + res["best"], + json!(tr.iter().rfind(|t| t.1 == "retenu").unwrap().0) + ); + assert_eq!(res["set"], json!({ "BATCH": "2048", "UBATCH": "512" })); + assert_eq!(res["old"], json!({ "BATCH": "", "UBATCH": "2048" })); + let (base_turn, best_turn) = ( + res["baseline"]["turn_sec"].as_f64().unwrap(), + res["trials"][0]["turn_sec"].as_f64().unwrap(), + ); + let gain = res["gain"].as_f64().unwrap(); + assert!( + (gain - (base_turn - best_turn) / base_turn).abs() < 1e-12, + "{gain} {base_turn} {best_turn}" + ); + // Aussi rapide que le meilleur : « ≈ 0 % ». + assert_eq!(tr[1].2, "≈ 0 % (seuil 3.0 %)"); + let skipped: Vec<&str> = res["skipped"] + .as_array() + .unwrap() + .iter() + .map(|s| s.as_str().unwrap()) + .collect(); + assert!(skipped.contains(&"marges : non demandé"), "{skipped:?}"); + assert_eq!(res["notes"], json!([VRAM_NOTE])); + // Le vrai moteur : arrêté, puis relancé, sans le refus de l'optimiseur ; + // la dictée coupée ; le verrou rendu ; le bail aussi. + assert_eq!(*tn.go.service_os.lock().unwrap(), ["stop", "start"]); + assert_eq!(*tn.go.dictate_stops.lock().unwrap(), 1); + assert!(!tn.home.join("tune.lock").exists(), "verrou non rendu"); + assert!(tn.go.lease.lock().unwrap().is_none(), "bail non rendu"); + // Chaque essai a sa copie de configuration, sur une adresse privée ; + // config.env n'est jamais touché. + let e1 = loki_rs::presets::parse_env( + &std::fs::read_to_string(tn.home.join("tune/run/e01/config.env")).unwrap(), + ); + assert_eq!(e1.get("HOST").map(String::as_str), Some("127.0.0.1")); + assert_ne!(e1.get("PORT"), Some(&tn.port.to_string())); + assert_eq!(tn.config()["UBATCH"], "2048"); + // Enregistré par preset. + let saved: Value = serde_json::from_str(&tn.go.get("state", "tune_results").unwrap()).unwrap(); + assert_eq!(saved["dense"]["best"], res["best"]); + let (_, last) = tn.call("GET", "/api/tune/last?id=dense", "").await; + assert_eq!(last["ok"], json!(true)); + assert_eq!(last["preset_changed"], json!(false)); + assert_eq!(last["result"], *res); + // Le build du moteur actuel est inconnu (binaire illisible) : rien à + // signaler. + assert_eq!(last.get("engine_changed"), None, "{last}"); + assert_eq!( + tn.call("POST", "/api/tune/apply", r#"{"target":"ailleurs"}"#) + .await, + (400, json!({ "ok": false, "error": "cible inconnue" })) + ); + + // Copie : rien d'autre ne bouge. + let (code, out) = tn + .call("POST", "/api/tune/apply", r#"{"target":"copy"}"#) + .await; + assert_eq!(code, 200, "{out}"); + let msg = out["message"].as_str().unwrap(); + assert!( + msg.starts_with("copie « Dense (optimisé) » créée ("), + "{msg}" + ); + assert!(msg.ends_with(") — bascule dessus pour l'utiliser"), "{msg}"); + assert!( + std::fs::read_to_string(tn.home.join("presets/dense.env")) + .unwrap() + .contains("UBATCH=2048") + ); + assert_eq!(*tn.go.service_os.lock().unwrap(), ["stop", "start"]); + + // Le preset lui-même : réécrit, appliqué, moteur redémarré et sondé. + let (code, out) = tn + .call("POST", "/api/tune/apply", r#"{"target":"preset"}"#) + .await; + assert_eq!((code, out), (202, json!({ "ok": true }))); + let st = tn.wait("application", "applying").await; + assert_eq!(st["apply"]["error"], json!(""), "{st}"); + assert_eq!( + st["apply"]["message"], + json!("preset « Dense » optimisé et vérifié") + ); + let log: Vec<&str> = st["apply"]["log"] + .as_array() + .unwrap() + .iter() + .map(|s| s.as_str().unwrap()) + .collect(); + assert!( + log[0].starts_with("écriture du preset (sauvegarde : "), + "{log:?}" + ); + assert_eq!( + &log[1..], + [ + "redémarrage du moteur", + "attente du moteur", + "sonde : prompt de 16384 jetons puis decode" + ] + ); + let preset = std::fs::read_to_string(tn.home.join("presets/dense.env")).unwrap(); + assert!( + preset.contains("UBATCH=512") && preset.contains("# NAME=Dense"), + "{preset}" + ); + assert_eq!(tn.config()["UBATCH"], "512"); + assert_eq!( + *tn.go.service_os.lock().unwrap(), + ["stop", "start", "restart"] + ); + assert!(!tn.home.join("tune.lock").exists(), "verrou non rendu"); + // Le preset a changé depuis la mesure : plus rien ne s'applique. + let (code, out) = tn + .call("POST", "/api/tune/apply", r#"{"target":"preset"}"#) + .await; + assert_eq!( + (code, out), + ( + 200, + json!({ "ok": false, "error": "le preset a changé depuis la mesure : relance l'optimisation" }) + ) + ); + let (_, last) = tn.call("GET", "/api/tune/last?id=dense", "").await; + assert_eq!(last["preset_changed"], json!(true)); +} + +/// Une sonde qui échoue après application : l'ancienne version revient — +/// preset ET configuration — et le moteur est redémarré dessus. +#[tokio::test(flavor = "multi_thread", worker_threads = 4)] +async fn application_defaite() { + let tn = start().await; + tn.call("POST", "/api/tune", r#"{"stages":["lots"]}"#).await; + let st = tn.wait("optimisation", "running").await; + assert_eq!(st["result"]["set"]["UBATCH"], json!("512"), "{st}"); + // Le moteur relancé ne garde pas son contexte. + *tn.main.n_ctx.lock().unwrap() = 4096; + let (code, _) = tn + .call("POST", "/api/tune/apply", r#"{"target":"preset"}"#) + .await; + assert_eq!(code, 202); + let st = tn.wait("application", "applying").await; + assert_eq!( + st["apply"]["error"], + json!("contexte 4096 × 1 slot(s) au lieu de 32768 × 1 : ancienne version rétablie") + ); + assert_eq!(st["apply"]["message"], json!("")); + assert_eq!( + st["apply"]["log"].as_array().unwrap().last().unwrap(), + "échec (contexte 4096 × 1 slot(s) au lieu de 32768 × 1) : retour à l'ancienne version" + ); + let preset = std::fs::read_to_string(tn.home.join("presets/dense.env")).unwrap(); + assert!( + preset.contains("UBATCH=2048"), + "preset non rétabli : {preset}" + ); + assert_eq!(tn.config()["UBATCH"], "2048", "configuration non rétablie"); + assert_eq!( + *tn.go.service_os.lock().unwrap(), + ["stop", "start", "restart", "restart"] + ); + assert!(!tn.home.join("tune.lock").exists()); + // Une sauvegarde a été faite avant d'écrire. + let backups: Vec<_> = std::fs::read_dir(tn.home.join("tune/backup")) + .unwrap() + .flatten() + .collect(); + assert_eq!(backups.len(), 1); +} + +/// Essais refusés à la composition, essais qui ne chargent pas, budget. +#[tokio::test(flavor = "multi_thread", worker_threads = 4)] +async fn essais_refuses() { + let tn = start().await; + tn.preset(&format!("{PRESET}REFUS=1024\nECHEC=512\n")); + tn.call("POST", "/api/tune", r#"{"stages":["lots"]}"#).await; + let st = tn.wait("optimisation", "running").await; + let tr = trials(&st["result"]); + assert_eq!( + tr, + [ + ( + "UBATCH=512 BATCH=2048".to_string(), + "échec".to_string(), + "mémoire insuffisante (OOM)".to_string() + ), + ( + "UBATCH=512 BATCH=1024".into(), + "échec".into(), + "mémoire insuffisante (OOM)".into() + ), + ( + "UBATCH=1024 BATCH=2048".into(), + "écarté".into(), + "refusé au lancement : [err] UBATCH=1024 refusé".into() + ), + ( + "UBATCH=2048 BATCH=4096".into(), + "mesuré".into(), + tr[3].2.clone() + ), + ], + "{tr:?}" + ); + assert!(tr[3].2.ends_with(" (seuil 3.0 %)"), "{tr:?}"); + assert_eq!(st["result"]["best"], Value::Null); + assert_eq!(st["result"]["set"], Value::Null); + // Rien à appliquer. + let (code, out) = tn + .call("POST", "/api/tune/apply", r#"{"target":"preset"}"#) + .await; + assert_eq!( + (code, out), + ( + 200, + json!({ "ok": false, "error": "rien à appliquer : la configuration actuelle reste la meilleure" }) + ) + ); + + // La référence ne se compose pas : l'optimisation échoue, le moteur est + // relancé, le verrou rendu. + tn.preset(&format!("{PRESET}REFUS=2048\n")); + tn.go.service_os.lock().unwrap().clear(); + tn.call("POST", "/api/tune", "{}").await; + let st = tn.wait("référence", "running").await; + assert_eq!( + st["error"], + json!("la configuration active ne se lance pas : [err] UBATCH=2048 refusé") + ); + assert_eq!(st["result"], Value::Null); + assert_eq!(*tn.go.service_os.lock().unwrap(), ["stop", "start"]); + // La relance échoue elle aussi : dit dans l'erreur. + *tn.go.service_os_fail.lock().unwrap() = Some(("start".into(), "boum".into())); + tn.call("POST", "/api/tune", "{}").await; + let st = tn.wait("relance", "running").await; + assert_eq!( + st["error"], + json!( + "la configuration active ne se lance pas : [err] UBATCH=2048 refusé — relance du moteur principal : boum" + ) + ); + *tn.go.service_os_fail.lock().unwrap() = None; + assert!(!tn.home.join("tune.lock").exists()); +} + +/// Annulation pendant les essais : l'essai arrêté, le résultat partiel +/// gardé. +#[tokio::test(flavor = "multi_thread", worker_threads = 4)] +async fn annulation() { + let tn = start().await; + tn.call("POST", "/api/tune", r#"{"stages":["lots"]}"#).await; + // Attendre le premier essai, puis annuler. + for _ in 0..4000 { + let st = tn.status().await; + if st["phase"] + .as_str() + .is_some_and(|p| p.starts_with("lots — ")) + { + break; + } + tokio::time::sleep(Duration::from_millis(5)).await; + } + let (code, out) = tn.call("POST", "/api/tune/cancel", "").await; + assert_eq!((code, out), (200, json!({ "ok": true }))); + let st = tn.wait("annulation", "running").await; + assert_eq!(st["result"]["partial"], json!("annulé"), "{st}"); + assert!(!tn.home.join("tune.lock").exists()); + let (_, out) = tn.call("POST", "/api/tune/cancel", "").await; + assert_eq!(out, json!({ "ok": false })); + // Le stop du chat (signalé au renouvellement du bail) annule aussi. + *tn.go.lease_stop.lock().unwrap() = true; + tn.call("POST", "/api/tune", r#"{"stages":["lots"]}"#).await; + let st = tn.wait("stop", "running").await; + *tn.go.lease_stop.lock().unwrap() = false; + let annule = + st["result"]["partial"] == json!("annulé") || st["error"] == json!("optimisation annulée"); + assert!(annule, "{st}"); +} + +/// Les refus d'entrée et les routes au repos, avec les messages du Go. +#[tokio::test(flavor = "multi_thread", worker_threads = 4)] +async fn refus_d_entree() { + let tn = start().await; + assert_eq!( + tn.status().await, + json!({ "ok": true, "running": false, "applying": false }) + ); + assert_eq!( + tn.call("GET", "/api/tune", "").await, + ( + 405, + json!({ "ok": false, "error": "méthode GET refusée : POST attendu" }) + ) + ); + assert_eq!(tn.call("GET", "/api/tune/cancel", "").await.0, 405); + assert_eq!( + tn.call("POST", "/api/tune/cancel", "").await, + (200, json!({ "ok": false })) + ); + assert_eq!( + tn.call("POST", "/api/tune/apply", r#"{"target":"copy"}"#) + .await, + ( + 400, + json!({ "ok": false, "error": "aucun résultat à appliquer" }) + ) + ); + assert_eq!( + tn.call("GET", "/api/tune/last?id=dense", "").await, + (200, json!({ "ok": false })) + ); + let refus = |code: u16, e: &str| (code, json!({ "ok": false, "error": e })); + tn.go.set("config", "EXTERNAL", "1"); + assert_eq!( + tn.call("POST", "/api/tune", "{}").await, + refus( + 400, + "optimisation indisponible : le preset actif est une API externe" + ) + ); + tn.go.del("config", "EXTERNAL"); + tn.go.set("config", "ENGINE", "strata"); + assert_eq!( + tn.call("POST", "/api/tune", "{}").await, + refus( + 409, + "optimisation indisponible : le preset actif tourne sur Strata, réglé par sa propre fenêtre" + ) + ); + tn.go.del("config", "ENGINE"); + tn.go.set("config", "CTX", "4096"); + assert_eq!( + tn.call("POST", "/api/tune", "{}").await, + refus( + 409, + "aucun preset ne correspond à la configuration active : enregistre-la comme preset (ou bascule sur l'un d'eux) avant d'optimiser" + ) + ); + tn.go.set("config", "CTX", "32768"); + *tn.go.tune_ctx_busy.lock().unwrap() = + "2 job(s) d'arrière-plan du mode code tournent encore".into(); + assert_eq!( + tn.call("POST", "/api/tune", "{}").await, + refus(409, "2 job(s) d'arrière-plan du mode code tournent encore") + ); + tn.go.tune_ctx_busy.lock().unwrap().clear(); + *tn.main.busy.lock().unwrap() = true; + assert_eq!( + tn.call("POST", "/api/tune", "{}").await, + refus( + 409, + "le moteur traite une requête (discussion, tâche ou client /v1) : réessaie une fois libre" + ) + ); + *tn.main.busy.lock().unwrap() = false; + *tn.go.go_busy.lock().unwrap() = Some("génération en cours".into()); + assert_eq!( + tn.call("POST", "/api/tune", "{}").await, + refus(409, "génération en cours") + ); + *tn.go.go_busy.lock().unwrap() = None; + // Une optimisation tenue par un autre processus vivant (« loki tune ») : + // refus, et l'interface le voit. + let other = json!({ "owner": { "pid": 1 }, "via": "cli", "phase": "essais", "since": 1700000000, "main_was_active": false }); + std::fs::write(tn.home.join("tune.lock"), other.to_string()).unwrap(); + assert_eq!(tn.call("POST", "/api/tune", "{}").await, refus(409, BUSY)); + assert_eq!(tn.status().await["elsewhere"], json!("cli")); + std::fs::remove_file(tn.home.join("tune.lock")).unwrap(); + assert_eq!(tn.status().await.get("elsewhere"), None); + assert!(tn.go.lease_whys.lock().unwrap().iter().all(|w| w == "tune")); +} + +/// Un verrou périmé (Loki tué en plein essai) : l'essai orphelin est arrêté +/// — identité complète vérifiée — avant que l'optimisation ne reprenne la +/// place ; une application interrompue est défaite. +#[cfg(target_os = "linux")] +#[tokio::test(flavor = "multi_thread", worker_threads = 4)] +async fn orphelin_et_application_interrompue() { + use std::os::unix::process::CommandExt; + let tn = start().await; + let mut child = std::process::Command::new("sleep") + .arg("60") + .process_group(0) + .spawn() + .unwrap(); + let pid = i64::from(child.id()); + // L'identité une fois l'exec visible dans /proc : lue trop tôt, elle + // porterait l'exécutable du test, et l'essai ne serait plus reconnu. + let mut ident = None; + for _ in 0..200 { + if let Some((start, exe)) = loki_rs::tune::lock::proc_identity(pid) + && exe.ends_with("sleep") + { + ident = Some((start, exe)); + break; + } + tokio::time::sleep(Duration::from_millis(10)).await; + } + let (start, exe) = ident.expect("exec de sleep jamais visible"); + let dead = json!({ "pid": 999999, "start": "x" }); + let write = |trial: Value, extra: Value| { + let mut o = json!({ "owner": dead, "via": "cli", "phase": "essais", "since": 1, "trial": trial, "main_was_active": false }); + for (k, v) in extra.as_object().unwrap() { + o[k] = v.clone(); + } + std::fs::write(tn.home.join("tune.lock"), o.to_string()).unwrap(); + }; + // Identité qui ne concorde pas (PID recyclé) : on ne tue rien. + write( + json!({ "pid": pid, "start": format!("{start}x"), "exes": [exe] }), + json!({}), + ); + tn.call("POST", "/api/tune", r#"{"stages":["aucune"]}"#) + .await; + tn.wait("essais", "running").await; + assert!( + child.try_wait().unwrap().is_none(), + "processus tué sur la foi du seul PID" + ); + // Identité complète : arrêté. + write( + json!({ "pid": pid, "start": start, "exes": [exe] }), + json!({}), + ); + tn.call("POST", "/api/tune", r#"{"stages":["aucune"]}"#) + .await; + tn.wait("orphelin", "running").await; + let mut gone = false; + for _ in 0..300 { + if child.try_wait().unwrap().is_some() { + gone = true; + break; + } + tokio::time::sleep(Duration::from_millis(20)).await; + } + assert!(gone, "essai orphelin toujours en vie"); + + // Application interrompue : la sauvegarde revient, preset et + // configuration. + let old = std::fs::read_to_string(tn.home.join("presets/dense.env")).unwrap(); + let backup = tn.home.join("vieux.env"); + std::fs::write(&backup, &old).unwrap(); + tn.preset(&PRESET.replace("UBATCH=2048", "UBATCH=999")); + write( + Value::Null, + json!({ "phase": "application", "backup": backup, "preset_id": "dense", "preset_name": "Dense" }), + ); + tn.call("POST", "/api/tune", r#"{"stages":["aucune"]}"#) + .await; + tn.wait("application interrompue", "running").await; + assert_eq!( + std::fs::read_to_string(tn.home.join("presets/dense.env")).unwrap(), + old + ); + assert_eq!(tn.config()["UBATCH"], "2048"); + // Hors phase d'application, rien n'est défait. + tn.preset(&PRESET.replace("UBATCH=2048", "UBATCH=999")); + write( + Value::Null, + json!({ "phase": "essais", "backup": backup, "preset_id": "dense", "preset_name": "Dense" }), + ); + tn.call("POST", "/api/tune", r#"{"stages":["aucune"]}"#) + .await; + tn.wait("essais interrompus", "running").await; + assert_eq!(tn.config()["UBATCH"], "999"); +} + +/// Sans la façade pour l'optimiseur, ses routes partent au Go. +#[tokio::test] +async fn sans_la_facade() { + let tmp = tempfile::tempdir().unwrap(); + let (facade, _go, _stop) = fakego::start(tmp.path().to_path_buf()).await; + for p in [ + "/api/tune", + "/api/tune/status", + "/api/tune/cancel", + "/api/tune/apply", + "/api/tune/last", + ] { + let (c, b) = fakego::send(facade, "GET", p, &[], "").await; + assert_eq!(c.as_u16(), 200); + assert_eq!(String::from_utf8_lossy(&b), format!("go:{p}")); + } +} + +/// Pendant la prise du bail, un second lancement est refusé. +#[tokio::test(flavor = "multi_thread", worker_threads = 4)] +async fn second_lancement_pendant_le_bail() { + let tn = Arc::new(start().await); + *tn.go.lease_delay.lock().unwrap() = Some(Duration::from_millis(400)); + let first = { + let tn = tn.clone(); + tokio::spawn(async move { + tn.call("POST", "/api/tune", r#"{"stages":["aucune"]}"#) + .await + }) + }; + tokio::time::sleep(Duration::from_millis(150)).await; + // Avant la prise du bail : aucune estimation encore. + assert_eq!(tn.status().await["running"], json!(false)); + assert_eq!( + tn.call("POST", "/api/tune", "{}").await, + (409, json!({ "ok": false, "error": BUSY })) + ); + assert_eq!(first.await.unwrap().0, 202); + let st = tn.status().await; + if st["running"] == json!(true) { + assert_eq!( + st["eta_sec"], + json!(-1), + "aucun essai mesuré : ETA inconnue" + ); + } + tn.wait("optimisation", "running").await; + assert_eq!(*tn.go.lease_whys.lock().unwrap(), vec!["tune".to_string()]); +} + +/// Une application qui échoue avant d'écrire quoi que ce soit (une autre +/// optimisation tient le moteur) : l'erreur, sans journal. +#[tokio::test(flavor = "multi_thread", worker_threads = 4)] +async fn application_refusee_sans_journal() { + let tn = start().await; + tn.call("POST", "/api/tune", r#"{"stages":["lots"]}"#).await; + let st = tn.wait("optimisation", "running").await; + assert_eq!(st["result"]["set"]["UBATCH"], json!("512"), "{st}"); + let other = json!({ "owner": { "pid": 1 }, "via": "cli", "phase": "essais", "since": 1700000000, "main_was_active": false }); + std::fs::write(tn.home.join("tune.lock"), other.to_string()).unwrap(); + let (code, _) = tn + .call("POST", "/api/tune/apply", r#"{"target":"preset"}"#) + .await; + assert_eq!(code, 202); + let mut st = tn.status().await; + for _ in 0..500 { + if st["applying"] == json!(false) { + break; + } + tokio::time::sleep(Duration::from_millis(10)).await; + st = tn.status().await; + } + let apply = &st["apply"]; + assert_eq!(apply["log"], Value::Null, "{st}"); + assert_eq!(apply["message"], json!("")); + assert!( + apply["error"] + .as_str() + .unwrap() + .starts_with("une optimisation tourne déjà (cli, PID 1, depuis "), + "{st}" + ); + assert_eq!(st["elsewhere"], json!("cli")); + assert!( + std::fs::read_to_string(tn.home.join("presets/dense.env")) + .unwrap() + .contains("UBATCH=2048"), + "rien n'est écrit" + ); + std::fs::remove_file(tn.home.join("tune.lock")).unwrap(); +} + +/// La façade pilote elle-même le moteur (LOKI_ENGINE=rust) : arrêt et +/// relance par son superviseur, pas par le Go. +#[cfg(unix)] +#[tokio::test(flavor = "multi_thread", worker_threads = 4)] +async fn moteur_pilote_par_la_facade() { + use std::os::unix::fs::PermissionsExt; + let tn = start_with( + |_, _| {}, + |o, home| { + let script = home.join("superviseur.sh"); + std::fs::write( + &script, + "#!/bin/sh\necho \"$1 $2\" >> \"$SUPERVISEUR_LOG\"\n", + ) + .unwrap(); + std::fs::set_permissions(&script, std::fs::Permissions::from_mode(0o755)).unwrap(); + let ctl = loki_rs::enginectl::EngineCtl::new( + script, + vec![( + "SUPERVISEUR_LOG".into(), + home.join("superviseur.log").to_string_lossy().into_owned(), + )], + ); + o.engine_ctl = Some(Arc::new(ctl)); + // Le moteur tourne : son PID, ce processus. + let pid = loki_rs::engine::Paths { + home: home.to_path_buf(), + service: "loki-engine".into(), + } + .pid(); + std::fs::create_dir_all(pid.parent().unwrap()).unwrap(); + std::fs::write(pid, format!("{}\n", std::process::id())).unwrap(); + }, + ) + .await; + *tn.go.service_active.lock().unwrap() = false; + tn.call("POST", "/api/tune", r#"{"stages":["aucune"]}"#) + .await; + let st = tn.wait("optimisation", "running").await; + assert_eq!(st["error"], Value::Null, "{st}"); + assert_eq!( + std::fs::read_to_string(tn.home.join("superviseur.log")).unwrap(), + "engine stop\nengine start\n" + ); + assert!( + tn.go.service_os.lock().unwrap().is_empty(), + "rien par le Go" + ); +} + +/// Les essais écartés après composition : ligne dénaturée, ligne déjà +/// mesurée (doublon), contexte perdu au chargement. +#[tokio::test(flavor = "multi_thread", worker_threads = 4)] +async fn essais_ecartes() { + let tn = start().await; + tn.preset(&format!( + "{PRESET}DENATURE=1024\nCTX_FAUX=512\nSANS_BATCH=1\n" + )); + tn.call("POST", "/api/tune", r#"{"stages":["lots"]}"#).await; + let st = tn.wait("optimisation", "running").await; + let tr = trials(&st["result"]); + assert_eq!(tr.len(), 4, "{tr:?}"); + assert_eq!( + tr[0], + ( + "UBATCH=512 BATCH=2048".into(), + "écarté".into(), + "dénature : contexte 16384 × 1 slot(s) au lieu de 32768 × 1".into() + ) + ); + assert_eq!( + tr[1], + ( + "UBATCH=512 BATCH=1024".into(), + "doublon".into(), + "même ligne de commande que « UBATCH=512 BATCH=2048 »".into() + ) + ); + assert_eq!(tr[2].1, "écarté"); + assert!( + tr[2].2.starts_with("dénature : ") && tr[2].2.contains("--no-mmap"), + "{tr:?}" + ); + assert_eq!( + tr[3], + ( + "UBATCH=2048 BATCH=4096".into(), + "doublon".into(), + "même ligne de commande que « configuration actuelle »".into() + ) + ); + assert_eq!(st["result"]["best"], Value::Null); +} + +/// La VRAM mesurée (faux nvidia-smi) : marge libre après chaque mesure, et +/// les essais trop justes écartés. +#[cfg(unix)] +#[tokio::test(flavor = "multi_thread", worker_threads = 4)] +async fn vram_mesuree() { + use std::os::unix::fs::PermissionsExt; + let tn = start_with( + |t, home| { + let smi = home.join("nvidia-smi"); + std::fs::write( + &smi, + format!("#!/bin/sh\ncat '{}'\n", home.join("smi.txt").display()), + ) + .unwrap(); + std::fs::set_permissions(&smi, std::fs::Permissions::from_mode(0o755)).unwrap(); + std::fs::write(home.join("smi.txt"), "Fausse, 1000, 24000, 0, 40\n").unwrap(); + t.nvidia_smi = smi.to_string_lossy().into_owned(); + }, + |_, _| {}, + ) + .await; + tn.call("POST", "/api/tune", r#"{"stages":["lots"]}"#).await; + let st = tn.wait("optimisation", "running").await; + let res = &st["result"]; + assert_eq!(res["baseline"]["headroom_mib"], json!(23000), "{res}"); + assert_eq!(res["trials"][0]["headroom_mib"], json!(23000)); + assert_eq!(res["trials"][0]["status"], json!("retenu")); + assert_eq!(res["notes"], Value::Null, "VRAM mesurable : aucune note"); + // Une carte presque pleine : tout essai est trop juste. + std::fs::write(tn.home.join("smi.txt"), "Fausse, 23500, 24000, 0, 40\n").unwrap(); + tn.preset(&format!("{PRESET}MMPROJ=/models/vision.gguf\n")); + tn.call("POST", "/api/tune", r#"{"stages":["lots"]}"#).await; + let st = tn.wait("trop juste", "running").await; + let res = &st["result"]; + assert_eq!( + res["notes"], + json!(["la configuration actuelle laisse déjà moins de 1280 Mio libres sur une carte"]) + ); + let tr = trials(res); + assert!( + tr.iter() + .all(|t| t.1 == "écarté" && t.2 == "trop juste : 500 Mio libres sur une carte (< 1280)"), + "{tr:?}" + ); + assert_eq!(res["best"], Value::Null); +} + +/// Le budget atteint : les essais restants ne sont pas faits. +#[tokio::test(flavor = "multi_thread", worker_threads = 4)] +async fn budget_atteint() { + let tn = start_with(|t, _| t.budget_unit = Duration::from_millis(100), |_, _| {}).await; + tn.call("POST", "/api/tune", r#"{"stages":["lots"],"budget_min":1}"#) + .await; + let st = tn.wait("budget", "running").await; + assert_eq!( + st["result"]["partial"], + json!("budget de 0s atteint : 4 essai(s) non faits") + ); + assert_eq!(st["result"]["trials"], Value::Null); +} + +/// La profondeur : celle que la référence tient avec le plus gros +/// préchauffage essayé. +#[tokio::test(flavor = "multi_thread", worker_threads = 4)] +async fn profondeur_commune() { + let tn = start().await; + // Un moteur qui ne donne le contexte qu'au premier niveau de /props. + tn.preset(&format!( + "{}PROPS_PLAT=1\n", + PRESET.replace("CTX=32768", "CTX=20000") + )); + tn.call("POST", "/api/tune", r#"{"stages":["aucune"]}"#) + .await; + let st = tn.wait("optimisation", "running").await; + assert_eq!(st["result"]["depth"], json!(7288), "{st}"); + assert_eq!(st["result"]["n_ctx"], json!(20000)); +} + +/// Les notes : modèle plus gros que la RAM ; relance du vrai moteur qui +/// échoue après une optimisation réussie ; poids tirés de la télémétrie ; +/// essais d'une optimisation précédente effacés. +#[tokio::test(flavor = "multi_thread", worker_threads = 4)] +async fn notes_et_poids() { + let tn = start().await; + let (_, total_mb) = loki_rs::sysinfo::ram_usage_mb(); + assert!(total_mb > 0); + // La moitié de la RAM : pas de note (des octets, comparés à des Mio). + tn.preset(&format!("{PRESET}MODEL_BYTES={}\n", total_mb << 19)); + let stale = tn.home.join("tune/run/e09"); + std::fs::create_dir_all(&stale).unwrap(); + let recs: Vec = (0..20) + .map(|_| json!({ "kind": "main", "complete": true, "total": 4000, "cached": 0, "new": 1000, "gen": 100 })) + .collect(); + *tn.go.tune_perf.lock().unwrap() = Value::Array(recs); + *tn.go.service_os_fail.lock().unwrap() = Some(("start".into(), "boum".into())); + tn.call("POST", "/api/tune", r#"{"stages":["aucune"]}"#) + .await; + let st = tn.wait("optimisation", "running").await; + let res = &st["result"]; + assert_eq!( + res["notes"], + json!([VRAM_NOTE, "⚠️ relance du moteur principal : boum"]), + "{res}" + ); + assert_eq!( + res["weights"], + json!({ "k": 1000, "g": 100, "f_cold": 1, "from": "télémétrie" }) + ); + assert!(!stale.exists(), "essais précédents effacés"); + // Enregistré AVANT la relance : sans sa note. + let saved: Value = serde_json::from_str(&tn.go.get("state", "tune_results").unwrap()).unwrap(); + assert_eq!(saved["dense"]["notes"], json!([VRAM_NOTE])); + *tn.go.service_os_fail.lock().unwrap() = None; + // Deux fois la RAM : la note. + tn.preset(&format!("{PRESET}MODEL_BYTES={}\n", total_mb << 21)); + tn.call("POST", "/api/tune", r#"{"stages":["aucune"]}"#) + .await; + let st = tn.wait("optimisation", "running").await; + assert_eq!( + st["result"]["notes"], + json!([ + VRAM_NOTE, + "modèle plus gros que la RAM : chaque essai le relit depuis le disque — prévoir un budget plus long, ou ne garder que quelques étapes" + ]) + ); +} + +/// Ce que la mesure dit d'un essai : un gain sous le seuil, un écart nul, +/// un decode en prose plus lent. +#[tokio::test(flavor = "multi_thread", worker_threads = 4)] +async fn ecarts_de_mesure() { + let tn = start().await; + // 512 refusé : la référence reste la meilleure ; 1024 à peine plus + // rapide ; 2048/4096 identique. + tn.preset(&format!("{PRESET}REFUS=512\nVITESSE=1024:1990\n")); + tn.call("POST", "/api/tune", r#"{"stages":["lots"]}"#).await; + let st = tn.wait("optimisation", "running").await; + let tr = trials(&st["result"]); + assert_eq!(tr.len(), 4, "{tr:?}"); + assert_eq!(tr[2].0, "UBATCH=1024 BATCH=2048"); + assert_eq!(tr[2].1, "mesuré"); + assert!( + tr[2].2.starts_with('−') && tr[2].2.ends_with(" %, sous le seuil de 3 %"), + "{tr:?}" + ); + assert_eq!( + st["result"]["trials"][2]["runs"].as_array().unwrap().len(), + 1 + ); + assert_eq!( + tr[3], + ( + "UBATCH=2048 BATCH=4096".into(), + "mesuré".into(), + "≈ 0 % (seuil 3.0 %)".into() + ) + ); + // Un prefill bien plus rapide, mais un decode en prose plus lent de 20 %. + tn.preset(&format!("{PRESET}REFUS=1024\nDECODE=512:40\n")); + tn.call("POST", "/api/tune", r#"{"stages":["lots"]}"#).await; + let st = tn.wait("decode", "running").await; + let tr = trials(&st["result"]); + assert_eq!( + tr[0], + ( + "UBATCH=512 BATCH=2048".into(), + "mesuré".into(), + "decode en prose plus lent de 20 %".into() + ), + "{tr:?}" + ); + assert_eq!(st["result"]["best"], Value::Null); +} + +/// L'application dans le preset, gardée : il doit être celui en service, et +/// pas une API externe ; une configuration changée ailleurs pendant +/// l'application la défait ; la sauvegarde est notée dans le verrou avant +/// toute écriture. +#[tokio::test(flavor = "multi_thread", worker_threads = 4)] +async fn application_gardee() { + let tn = start().await; + tn.call("POST", "/api/tune", r#"{"stages":["lots"]}"#).await; + let st = tn.wait("optimisation", "running").await; + assert_eq!(st["result"]["set"]["UBATCH"], json!("512"), "{st}"); + let old = std::fs::read_to_string(tn.home.join("presets/dense.env")).unwrap(); + + // La configuration active n'est plus ce preset. + tn.go.set("config", "UBATCH", "1024"); + let apply = tn.apply_preset().await; + assert_eq!( + apply["error"], + json!( + "ce preset n'est plus celui en service : bascule dessus, ou enregistre le résultat dans une copie" + ), + "{apply}" + ); + assert_eq!(apply["log"], Value::Null); + tn.go.set("config", "UBATCH", "2048"); + + // Une API externe. + tn.go.set("config", "EXTERNAL", "1"); + let apply = tn.apply_preset().await; + assert_eq!( + apply["error"], + json!("le preset actif est une API externe"), + "{apply}" + ); + tn.go.del("config", "EXTERNAL"); + + // Une clé changée ailleurs pendant l'application : l'ancienne version + // revient, sans relance sur la nouvelle. + tn.go + .apply_extra + .lock() + .unwrap() + .push(("THREADS".into(), "7".into())); + let apply = tn.apply_preset().await; + assert_eq!( + apply["error"], + json!( + "la configuration a changé ailleurs que sur les clés réglées depuis la mesure : ancienne version rétablie" + ), + "{apply}" + ); + assert_eq!( + std::fs::read_to_string(tn.home.join("presets/dense.env")).unwrap(), + old + ); + assert_eq!( + *tn.go.service_os.lock().unwrap(), + ["stop", "start", "restart"] + ); + tn.go.apply_extra.lock().unwrap().clear(); + tn.preset(PRESET); + + // Le moteur relancé tarde : pendant l'attente, le verrou désigne la + // sauvegarde (un Loki tué la remet au redémarrage). + *tn.main.down.lock().unwrap() = true; + let (code, _) = tn + .call("POST", "/api/tune/apply", r#"{"target":"preset"}"#) + .await; + assert_eq!(code, 202); + let mut noted = None; + for _ in 0..1000 { + if let Some(o) = loki_rs::tune::lock::read_owner(&tn.home.join("tune.lock")) + && !o.backup.is_empty() + { + noted = Some(o); + break; + } + tokio::time::sleep(Duration::from_millis(5)).await; + } + let o = noted.expect("sauvegarde jamais notée dans le verrou"); + assert_eq!( + ( + o.phase.as_str(), + o.preset_id.as_str(), + o.preset_name.as_str() + ), + ("application", "dense", "Dense") + ); + assert_eq!(std::fs::read_to_string(&o.backup).unwrap(), old); + *tn.main.down.lock().unwrap() = false; + let apply = tn.wait("application", "applying").await["apply"].clone(); + assert_eq!( + (apply["error"].clone(), apply["message"].clone()), + (json!(""), json!("preset « Dense » optimisé et vérifié")), + "{apply}" + ); + assert!(!tn.home.join("tune.lock").exists(), "verrou non rendu"); +} + +/// Un essai qui ne répond jamais : abandonné au bout du délai de chargement. +#[tokio::test(flavor = "multi_thread", worker_threads = 4)] +async fn essai_muet() { + let tn = start_with(|t, _| t.load_timeout = Duration::from_secs(2), |_, _| {}).await; + tn.preset(&format!("{PRESET}MUET=512\n")); + tn.call("POST", "/api/tune", r#"{"stages":["lots"]}"#).await; + let st = tn.wait("optimisation", "running").await; + let tr = trials(&st["result"]); + assert_eq!( + tr[0], + ( + "UBATCH=512 BATCH=2048".into(), + "échec".into(), + "pas prêt après 2s".into() + ), + "{tr:?}" + ); +} + +/// SIDE_SLOT sur un moteur à un seul slot : les mesures restent sur le +/// slot 0. +#[tokio::test(flavor = "multi_thread", worker_threads = 4)] +async fn second_slot_absent() { + let tn = start().await; + tn.preset(&format!("{PRESET}SIDE_SLOT=on\n")); + tn.call("POST", "/api/tune", r#"{"stages":["aucune"]}"#) + .await; + let st = tn.wait("optimisation", "running").await; + assert_eq!(st["error"], Value::Null, "{st}"); + assert_eq!(st["result"]["baseline"]["status"], json!("mesuré"), "{st}"); +} + +/// Les threads essayés : comptés sur les cœurs physiques que la sonde du +/// moteur annonce (des poids sur CPU). +#[tokio::test(flavor = "multi_thread", worker_threads = 4)] +async fn threads_de_la_sonde() { + let tn = start().await; + tn.preset(&format!("{PRESET}EXTRA_ARGS=--cpu-moe\nCPU_THREADS=13\n")); + tn.call("POST", "/api/tune", r#"{"stages":["threads"]}"#) + .await; + let st = tn.wait("optimisation", "running").await; + let labels: Vec = trials(&st["result"]).into_iter().map(|t| t.0).collect(); + assert_eq!(labels, ["THREADS=12", "THREADS=6"], "{st}"); +} diff --git a/rust/loki-rs/tests/tune_core.rs b/rust/loki-rs/tests/tune_core.rs new file mode 100644 index 0000000..39642f9 --- /dev/null +++ b/rust/loki-rs/tests/tune_core.rs @@ -0,0 +1,404 @@ +//! Lot 28 : la partie pure de l'optimiseur, rejouée sur le journal tiré du Go +//! (`internal/loki/parity_tune_test.go` → `tests/parity_tune/core.json`). + +use std::collections::HashMap; + +use loki_rs::bench::BenchResult; +use loki_rs::tune::core::{self as tc, Cfg, Env, Knob, Launch, PerfRec}; +use serde_json::{Value, json}; + +fn fixture() -> Value { + serde_json::from_str(include_str!("parity_tune/core.json")).expect("journal") +} + +fn s(v: &Value) -> &str { + v.as_str().unwrap_or("") +} + +fn cfg(v: &Value) -> Cfg { + serde_json::from_value(if v.is_null() { json!({}) } else { v.clone() }).unwrap() +} + +fn strs(v: &Value) -> Vec { + serde_json::from_value(if v.is_null() { json!([]) } else { v.clone() }).unwrap() +} + +/// Une liste Go : `null` et `[]` se valent ici (tranche nil ou vide). +fn list(v: Vec) -> Value { + json!(v) +} + +fn same_list(got: Vec, want: &Value, what: &str) { + assert_eq!(list(got), list(strs(want)), "{what}"); +} + +const KNOBS: [(&str, Knob); 5] = [ + ("BATCH", tc::KNOB_BATCH), + ("UBATCH", tc::KNOB_UBATCH), + ("THREADS", tc::KNOB_THREADS), + ("FIT_TARGET", tc::KNOB_FIT_TARGET), + ("SPEC_N_MAX", tc::KNOB_SPEC_N), +]; + +#[test] +fn extra_args_jeton_par_jeton() { + let fx = fixture(); + for c in fx["extra"].as_array().unwrap() { + let input = s(&c["in"]); + let spans: Vec = tc::arg_spans(input) + .into_iter() + .map(|sp| json!([sp.start, sp.end, sp.val])) + .collect(); + let want = if c["spans"].is_null() { + json!([]) + } else { + c["spans"].clone() + }; + assert_eq!(Value::Array(spans), want, "spans {input:?}"); + same_list( + loki_rs::models::split_args(input), + &c["split"], + &format!("split {input:?}"), + ); + let ub = tc::KNOB_UBATCH.flags; + let t = tc::KNOB_THREADS.flags; + for (key, got) in [ + ("set_ub", tc::extra_set(input, ub, "2048")), + ("drop_ub", tc::extra_set(input, ub, "")), + ("set_t", tc::extra_set(input, t, "7")), + ("set_t_space", tc::extra_set(input, t, "a b")), + ("set_b", tc::extra_set(input, tc::KNOB_BATCH.flags, "4096")), + ( + "drop_ts", + tc::extra_drop(input, 1, &["-ts", "--tensor-split"]), + ), + ( + "drop_cmoe", + tc::extra_drop(input, 0, &["-cmoe", "--cpu-moe"]), + ), + ("add_bs", tc::extra_add_switch(input, "--backend-sampling")), + ] { + assert_eq!(got, s(&c[key]), "{key} {input:?}"); + } + let (dropped, removed) = tc::extra_drop_experts(input); + assert_eq!(dropped, s(&c["drop_experts"]), "drop_experts {input:?}"); + same_list(removed, &c["removed"], &format!("removed {input:?}")); + let split = loki_rs::models::split_args(input); + same_list( + tc::core_args(&split, false), + &c["core"], + &format!("core {input:?}"), + ); + same_list( + tc::core_args(&split, true), + &c["core_optin"], + &format!("core opt-in {input:?}"), + ); + } +} + +#[test] +fn reglages_par_cle_ou_par_drapeau() { + let fx = fixture(); + for c in fx["knobs"].as_array().unwrap() { + let base = cfg(&c["cfg"]); + for (name, k) in KNOBS { + assert_eq!(k.get(&base), s(&c["get"][name]), "get {name} {base:?}"); + assert_eq!( + k.set(&base, "99"), + cfg(&c["set"][name]), + "set {name} {base:?}" + ); + assert_eq!( + k.set(&base, ""), + cfg(&c["unset"][name]), + "unset {name} {base:?}" + ); + } + } +} + +fn env(v: &Value) -> Env { + let map = |x: &Value| -> HashMap { + serde_json::from_value(if x.is_null() { json!({}) } else { x.clone() }).unwrap() + }; + Env { + help: s(&v["help"]).to_string(), + gpus: v["gpus"].as_i64().unwrap(), + vram_mib: v["vram_mib"].as_i64().unwrap(), + model_bytes: v["model_bytes"].as_i64().unwrap(), + moe: v["moe"].as_bool().unwrap(), + phys_cores: v["phys_cores"].as_i64().unwrap(), + smi: v["smi"].as_bool().unwrap(), + arg_env: map(&v["arg_env"]), + user_env: map(&v["user_env"]), + queues_env: v["queues_env"].as_bool().unwrap(), + placement: v["placement"].as_bool().unwrap(), + opt_in: v["opt_in"].as_bool().unwrap(), + } +} + +#[test] +fn essais_de_chaque_etape() { + let fx = fixture(); + let mut n = 0; + for c in fx["cands"].as_array().unwrap() { + let (base, e) = (cfg(&c["cfg"]), env(&c["env"])); + let what = format!("{} / {}", s(&c["cfg_name"]), s(&c["env_name"])); + assert_eq!( + json!(tc::weights_on_cpu(&base, &e)), + c["weights_on_cpu"], + "{what} : poids sur CPU" + ); + for (name, generate) in tc::STAGES { + let (cands, why) = generate(&base, &e); + let want = &c["stages"][name]; + assert_eq!(why, s(&want["why"]), "{what} / {name} : pourquoi"); + let got: Vec = cands + .iter() + .map(|x| json!({ "stage": x.stage, "label": x.label, "cfg": x.cfg })) + .collect(); + assert_eq!(Value::Array(got), want["cands"], "{what} / {name} : essais"); + n += cands.len(); + } + } + assert!(n > 100, "{n} essais seulement"); + for c in fx["stage_wanted"].as_array().unwrap() { + let only = strs(&c["only"]); + assert_eq!( + json!(tc::stage_wanted(s(&c["name"]), &only)), + c["wanted"], + "{} {only:?}", + c["name"] + ); + } +} + +#[test] +fn garde_fous() { + let fx = fixture(); + for c in fx["denature"].as_array().unwrap() { + let name = s(&c["name"]); + let b: Launch = serde_json::from_value(c["base"].clone()).unwrap(); + let k: Launch = serde_json::from_value(c["cand"].clone()).unwrap(); + assert_eq!(tc::denature(&b, &k, false), s(&c["denature"]), "{name}"); + assert_eq!( + tc::denature(&b, &k, true), + s(&c["denature_optin"]), + "{name} opt-in" + ); + assert_eq!( + json!(tc::launch_key(&b) == tc::launch_key(&k)), + c["same_key"], + "{name} : même lancement" + ); + } + for c in fx["cfg_check"].as_array().unwrap() { + let name = s(&c["name"]); + let (b, k) = (cfg(&c["base"]), cfg(&c["cand"])); + let err = |r: Result<(), String>| r.err().unwrap_or_default(); + assert_eq!(err(tc::cfg_check(&b, &k, false)), s(&c["check"]), "{name}"); + assert_eq!( + err(tc::cfg_check(&b, &k, true)), + s(&c["check_optin"]), + "{name} opt-in" + ); + same_list(tc::diff(&b, &k), &c["diff"], &format!("{name} : diff")); + } + let content = s(&fx["patch"]["content"]); + let base = loki_rs::presets::parse_env(content); + for c in fx["patch"]["cases"].as_array().unwrap() { + let name = s(&c["name"]); + let (out, err) = match tc::patch_preset(content, &base, &cfg(&c["win"])) { + Ok(o) => (o, String::new()), + Err(e) => (String::new(), e), + }; + assert_eq!( + (out.as_str(), err.as_str()), + (s(&c["out"]), s(&c["error"])), + "{name}" + ); + } +} + +#[test] +fn journal_d_un_essai() { + let fx = fixture(); + for c in fx["logs"].as_array().unwrap() { + let log = s(&c["log"]); + let (off, total, kv) = tc::log_facts(log); + assert_eq!( + json!([off, total, kv]), + json!([c["offloaded"], c["total"], c["kv"]]), + "{log:?}" + ); + assert_eq!(tc::load_failure(log), s(&c["failure"]), "{log:?}"); + } +} + +#[test] +fn score_et_departage() { + let fx = fixture(); + for c in fx["weights"].as_array().unwrap() { + let recs: Vec = serde_json::from_value(if c["recs"].is_null() { + json!([]) + } else { + c["recs"].clone() + }) + .unwrap(); + assert_eq!( + serde_json::to_value(tc::weights_from(&recs)).unwrap(), + c["weights"], + "{}", + c["name"] + ); + } + let w = tc::Weights { + k: 2000.0, + g: 600.0, + f_cold: 0.05, + from: String::new(), + }; + for c in fx["runs"].as_array().unwrap() { + let bench: Option = serde_json::from_value(c["bench"].clone()).unwrap(); + let (run, err) = match tc::run_from(bench.as_ref(), &w) { + Ok(r) => (r, String::new()), + Err(e) => (tc::Run::default(), e), + }; + assert_eq!( + serde_json::to_value(&run).unwrap(), + c["run"], + "{}", + c["name"] + ); + assert_eq!(err, s(&c["error"]), "{}", c["name"]); + } + let f = |v: &Value| -> Vec { + serde_json::from_value(if v.is_null() { json!([]) } else { v.clone() }).unwrap() + }; + for c in fx["wins"].as_array().unwrap() { + let (b, k) = (f(&c["base"]), f(&c["cand"])); + let (gain, need, ok) = tc::wins(&b, &k); + let n = |k: &str| c[k].as_f64().unwrap(); + assert_eq!( + (gain, need, ok, tc::spread(&b), tc::mean(&k)), + ( + n("gain"), + n("need"), + c["wins"].as_bool().unwrap(), + n("spread_base"), + n("mean_cand") + ), + "{b:?} {k:?}" + ); + } + for c in fx["worth_repeat"].as_array().unwrap() { + assert_eq!( + json!(tc::worth_repeat( + &f(&c["base"]), + c["first"].as_f64().unwrap() + )), + c["worth"], + "{c}" + ); + } + for c in fx["headroom"].as_array().unwrap() { + let gpus: Vec<(i64, i64)> = serde_json::from_value(if c["gpus"].is_null() { + json!([]) + } else { + c["gpus"].clone() + }) + .unwrap(); + let visible: Vec = serde_json::from_value(if c["visible"].is_null() { + json!([]) + } else { + c["visible"].clone() + }) + .unwrap(); + let got = tc::headroom(&gpus, &visible); + assert_eq!( + json!([got.unwrap_or(0), got.is_some()]), + json!([c["free"], c["ok"]]), + "{c}" + ); + } + for c in fx["visible"].as_array().unwrap() { + let got = tc::visible_gpus(&HashMap::from([( + "CUDA_VISIBLE_DEVICES".to_string(), + s(&c["in"]).to_string(), + )])); + let want: Vec = serde_json::from_value(if c["out"].is_null() { + json!([]) + } else { + c["out"].clone() + }) + .unwrap(); + assert_eq!(got, want, "{c}"); + } + for c in fx["force_addr"].as_array().unwrap() { + assert_eq!( + list(tc::force_addr(&strs(&c["in"]), "127.0.0.1", "41234")), + c["out"], + "{c}" + ); + } + for c in fx["trial_config"].as_array().unwrap() { + let (text, err) = match tc::trial_config_text(&cfg(&c["cfg"])) { + Ok(t) => (t, String::new()), + Err(e) => (String::new(), e), + }; + assert_eq!( + (text.as_str(), err.as_str()), + (s(&c["text"]), s(&c["error"])), + "{c}" + ); + } +} + +/// Le verrou : le fichier écrit octet pour octet comme le Go, la clé de +/// prise, les binaires comparés par leur nom. +#[test] +fn verrou_au_format_du_go() { + use loki_rs::tune::lock::{Owner, held_key, same_exe}; + let fx = fixture(); + for c in fx["lock_owners"].as_array().unwrap() { + let o: Owner = serde_json::from_str(s(&c["json"])).unwrap(); + assert_eq!(serde_json::to_string(&o).unwrap(), s(&c["json"])); + assert_eq!(held_key(&o), s(&c["key"])); + } + for c in fx["same_exe"].as_array().unwrap() { + assert_eq!( + same_exe(s(&c["a"]), s(&c["b"])), + c["same"].as_bool().unwrap(), + "{c}" + ); + } +} + +/// Le résultat : enregistré et montré comme le Go l'écrit. +#[test] +fn resultat_au_format_du_go() { + use loki_rs::tune::job::TuneResult; + let fx = fixture(); + for r in fx["results"].as_array().unwrap() { + let res: TuneResult = serde_json::from_str(s(r)).unwrap(); + assert_eq!(serde_json::to_string(&res).unwrap(), s(r)); + } +} + +/// Après application : la configuration est-elle l'essai retenu ? +#[test] +fn application_conforme() { + use loki_rs::tune::job::{TuneResult, applied_mismatch}; + let fx = fixture(); + let a = &fx["applied"]; + let res = TuneResult { + base_fp: s(&a["base_fp"]).to_string(), + set: serde_json::from_value(a["set"].clone()).unwrap(), + old: serde_json::from_value(a["old"].clone()).unwrap(), + ..Default::default() + }; + for c in a["cases"].as_array().unwrap() { + assert_eq!(applied_mismatch(&cfg(&c["now"]), &res), s(&c["why"]), "{c}"); + } +} 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); +}