Compare commits

..
Author SHA1 Message Date
Claude 96ef1ac95b Fusion de main : la suppression des modèles y a atterri, le sélecteur reste
main a livré entre-temps « un .gguf gardé se réutilise et s'efface »
(9bdaf00), qui couvre la suppression des modèles depuis l'éditeur avec un
index des références plus complet que celui de cette branche :
buildModelRefIndex lit aussi le --mmproj resté dans EXTRA_ARGS et le
construit en une passe, et deleteModelFile porte lui-même le refus « en
service ». Cette branche cède donc tout ce qui doublonnait.

Abandonné (repris par main) :
- modelSizeOnDisk, modelIsLoaded, modelPresetRefs, presetsUsingModel →
  modelFamilySize, modelUsers, buildModelRefIndex ;
- la liste « Modèles installés » (#m-mdl-*) et son CSS → celle de main
  (#m-inst-*), qui sélectionne aussi un modèle déjà téléchargé ;
- deletePresetModel → main supprime le .gguf partagé mais NOMME les autres
  presets concernés, plutôt que de conserver le fichier ;
- backend_models_delete_test.go → backend_models_refs_test.go couvre les
  mêmes cas, y compris les octets libérés d'une famille de tranches.

Conservé, c'est ce qui reste propre à cette branche : le sélecteur de
l'en-tête ne liste QUE des presets. Charger un .gguf n'écrivait que MODEL=,
laissant en place le contexte et l'échantillonnage du modèle précédent — un
27B par-dessus les réglages d'un 4B mourait en OOM sans explication.
/api/models/use disparaît avec le groupe qu'elle servait.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RoKfYRvVHTf8GyKhgY5WxT
2026-09-12 22:26:34 +00:00
Claude 06f0e74c77 Modèles : les supprimer depuis l'interface, et ne charger que des presets
Un .gguf ne pouvait s'effacer qu'en cochant « supprimer aussi le fichier »
au moment de supprimer son preset. Tout ce qui avait été téléchargé sans
preset — une quantization essayée puis abandonnée, un projecteur du mauvais
dépôt — restait donc sur le disque pour des dizaines de gigaoctets, sans
aucun moyen de partir depuis Loki.

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

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

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

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

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

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RoKfYRvVHTf8GyKhgY5WxT
2026-09-09 08:53:36 +00:00
676 changed files with 1748 additions and 368204 deletions

No files matched your search

-1
View File
@@ -8,4 +8,3 @@ dist/
!README.md
.env
.env.*
rust/target/
-18
View File
@@ -31,24 +31,6 @@ jobs:
GOOS=${t%/*} GOARCH=${t#*/} CGO_ENABLED=0 go build -o /dev/null ./cmd/loki
done
# Façade Rust (rust/loki-rs) : même exigence que le Go, zéro avertissement.
rust:
runs-on: ubuntu-latest
defaults:
run:
working-directory: rust
steps:
- uses: actions/checkout@v4
- uses: dtolnay/rust-toolchain@stable
with:
components: clippy, rustfmt
- uses: Swatinem/rust-cache@v2
with:
workspaces: rust
- run: cargo fmt --all --check
- run: cargo clippy --all-targets --locked -- -D warnings
- run: cargo test --locked
macos:
runs-on: macos-latest
steps:
-54
View File
@@ -54,7 +54,6 @@ jobs:
push: true
build-args: |
LOKI_VERSION=${{ github.sha }}
LOKI_BUILD=${{ github.run_number }}
tags: ${{ steps.meta.outputs.tags }}
labels: ${{ steps.meta.outputs.labels }}
# Le cache de couches réutilise l'étape asrfetch (téléchargement du
@@ -62,56 +61,3 @@ jobs:
# pas : seul le code Go et l'assemblage de l'image se refont.
cache-from: type=gha
cache-to: type=gha,mode=max
# Release « rolling » : les binaires que le conteneur installe lui-même au
# démarrage (loki-rs self-update, voir docker-entrypoint.sh). Même numéro de
# run que l'image ci-dessus : c'est lui qui ordonne les versions.
# manifest.json part EN DERNIER — tant qu'il annonce l'ancien build, un
# conteneur qui démarre pendant l'envoi garde ce qu'il a ; s'il lit des
# binaires à moitié remplacés, l'empreinte ne correspond pas et il n'installe
# rien. Pré-release : `loki update` (hors conteneur, /releases/latest) ne la
# voit pas.
rolling:
if: github.ref == 'refs/heads/main'
runs-on: ubuntu-latest
permissions:
contents: write
concurrency:
group: rolling-release
cancel-in-progress: false
steps:
- uses: actions/checkout@v4
- uses: actions/setup-go@v5
with:
go-version-file: go.mod
- uses: dtolnay/rust-toolchain@stable
with:
targets: x86_64-unknown-linux-musl
- uses: Swatinem/rust-cache@v2
with:
workspaces: rust
- name: Outils musl
run: sudo apt-get update && sudo apt-get install -y --no-install-recommends musl-tools
- name: Binaires
run: |
mkdir -p dist
CGO_ENABLED=0 GOOS=linux GOARCH=amd64 \
go build -trimpath -ldflags "-s -w" -o dist/loki-linux ./cmd/loki
(cd rust && cargo build --release --locked --target x86_64-unknown-linux-musl)
cp rust/target/x86_64-unknown-linux-musl/release/loki-rs dist/loki-rs-linux
file dist/loki-rs-linux
(cd dist && sha256sum loki-linux loki-rs-linux > SHA256SUMS)
abi=$(tr -d '[:space:]' < IMAGE_ABI)
printf '{"build":%s,"abi":%s,"commit":"%s"}\n' \
"${{ github.run_number }}" "$abi" "$GITHUB_SHA" > dist/manifest.json
cat dist/manifest.json dist/SHA256SUMS
- name: Publier
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: |
gh release view rolling >/dev/null 2>&1 || \
gh release create rolling --prerelease --target "$GITHUB_SHA" \
--title "rolling (mise à jour au démarrage)" \
--notes "Binaires installés automatiquement par le conteneur à son démarrage. Ne pas télécharger à la main."
gh release upload rolling dist/loki-linux dist/loki-rs-linux dist/SHA256SUMS --clobber
gh release upload rolling dist/manifest.json --clobber
-3
View File
@@ -22,6 +22,3 @@ Thumbs.db
.pytest_cache/
__pycache__/
node_modules/
# Rust (façade loki-rs)
rust/target/
+1 -32
View File
@@ -42,23 +42,6 @@ RUN CGO_ENABLED=0 go build -trimpath -ldflags "-s -w" -o /out/loki ./cmd/loki
# limité à cette étape de build.
RUN GOBIN=/out GOTOOLCHAIN=auto go install golang.org/x/tools/gopls@latest
# ── Étape 1 ter : façade Rust loki-rs ───────────────────────────────────
# Premier lot de la migration Go → Rust (rust/, voir docs/superpowers/specs/).
# Alpine = musl : binaire STATIQUE, indépendant de la glibc de l'image runtime —
# le même que celui publié dans la release « rolling » et installé à chaud
# dans /data/bin par `loki-rs self-update`.
FROM rust:1-alpine AS rustbuild
RUN apk add --no-cache musl-dev gcc
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
# ── Étape 1 bis : serveur de dictée (Parakeet via sherpa-onnx) ──────────
# On TÉLÉCHARGE un binaire préconstruit, on ne compile plus rien. C'est le
# changement de fond par rapport à whisper.cpp, qu'il remplace : deux étapes de
@@ -104,12 +87,8 @@ LABEL org.opencontainers.image.revision="${LOKI_VERSION}" \
# via npx ; tini en PID 1. curl et libgomp1 sont déjà dans l'image amont.
# Node 22 depuis NodeSource et non depuis Ubuntu (qui livre Node 18) : Playwright
# et la plupart des serveurs MCP réclament Node ≥ 20.
# python3 + python3-venv : l'installeur et le serveur du moteur Strata
# (backend_strata.go) sont en Python ≥ 3.10 et créent leur propre .venv dans
# /data/strata. Le moteur et le modèle ne sont PAS dans l'image : téléchargés
# à la demande depuis Paramètres → Moteur.
RUN apt-get update && apt-get install -y --no-install-recommends \
git tini ca-certificates curl gnupg python3 python3-venv \
git tini ca-certificates curl gnupg \
&& curl -fsSL https://deb.nodesource.com/setup_22.x | bash - \
&& apt-get install -y --no-install-recommends nodejs \
&& rm -rf /var/lib/apt/lists/*
@@ -148,7 +127,6 @@ RUN if [ "$PLAYWRIGHT" = "1" ]; then \
COPY --from=gobuild /out/loki /usr/local/bin/loki
COPY --from=gobuild /out/gopls /usr/local/bin/gopls
COPY --from=rustbuild /out/loki-rs /usr/local/bin/loki-rs
# Dictée vocale (POST /api/transcribe). Le modèle (~500 Mo) n'est PAS dans
# l'image : téléchargé à la demande dans /data/asr/.
COPY --from=asrfetch /out/sherpa-onnx-offline-websocket-server /usr/local/bin/sherpa-onnx-offline-websocket-server
@@ -165,15 +143,6 @@ ENV LOKI_CONTAINER=1 \
NVIDIA_VISIBLE_DEVICES=all \
NVIDIA_DRIVER_CAPABILITIES=compute,utility
# Mise à jour au démarrage (loki-rs self-update) : IMAGE_ABI dit quelles
# dépendances d'image les binaires exigent — un binaire publié pour une autre
# ABI n'est jamais installé. LOKI_BUILD = numéro de run CI de cette image : un
# binaire de /data/bin n'est pris que s'il est PLUS RÉCENT. Posés ici, en fin
# d'image, pour ne pas invalider le cache des couches lourdes à chaque build.
COPY IMAGE_ABI /etc/loki/IMAGE_ABI
ARG LOKI_BUILD=0
ENV LOKI_BUILD=${LOKI_BUILD}
# 8090 : interface web (seule à exposer). Le moteur (8080) reste interne au
# conteneur — il n'est pas authentifié par défaut.
EXPOSE 8090
-1
View File
@@ -1 +0,0 @@
1
+1 -37
View File
@@ -26,43 +26,7 @@ La licence d'origine est conservée à l'identique dans [`LICENSE`](LICENSE).
(contrat builder/verifier, tracker « lu avant d'écrire », auto-retry
patterns, table languages.json) est reprise
d'**[OpenFox](https://github.com/co-l/openfox)** (MIT) et réécrite en Go
pour ce fork ; les prompts de rôles sont des réécritures originales. S'y
ajoutent depuis d'autres idées reprises d'OpenFox et réimplémentées ici :
les **sous-agents** (`code_subagent.go` — un rôle délégué qui travaille dans
son propre contexte), la **vérification déclenchée quand le builder a
fini** (statut `completed`, relance sur critères ouverts), le **pré-vol**
qui refuse une écriture dès son chemin (`tool_schema_order.go`), la
**réparation des noms d'outils hallucinés** (`code_alias.go`), les
**consignes du dépôt** AGENTS.md / CLAUDE.md (`code_instructions.go`),
l'**hygiène du terminal** (`chat_shell_hygiene.go`) et un **compactage**
qui garde l'état du mode Code.
## Reprises depuis l'amont après le fork
Le fork continue de suivre AJEAN et y reprend des fonctionnalités, adaptées au
conteneur : le **contrôle du navigateur** (`computer_use.go`, `computer_cdp.go`,
`browser_grid.go` — pilotage d'un Chromium en CDP, ici celui de Playwright déjà
présent dans l'image) et la **préparation des images** envoyées au modèle
(`web_upload_orient.go` — orientation EXIF et redimensionnement), les
**notifications Web Push** (`push.go`, `web_push.go`), les **tâches script** et
le dossier de scripts (`chat_scripts.go`, `tasks_script.go`, `tasks_tools.go`),
et le **chiffrement de la mémoire** (`mem_crypto.go`, `mem_vault.go`,
`mem_store.go`, `mem_io.go`, `mem_migrate.go`, `mem_snapshots.go`,
`backup_bundle.go`). La sauvegarde vers le relais (`relay_backup.go`) n'est
**pas** reprise : elle vise ajean.link, le service de l'auteur amont. Loki
expose à la place un export/import de paquet chiffré en fichier local.
## Moteur Strata
Le second moteur de Loki (`internal/loki/backend_strata.go`) est
**[Strata](https://github.com/Niko1221/Strata)** (MIT), spécialisé dans
Qwen3.8-Flash-Next. Son intégration est reprise d'AJEAN (« AJEAN MoE 1.1 »,
`backend_moe.go`) : Loki télécharge le paquet figé publié par AJEAN sur sa
release `moe-v1.1` (sources de Strata et binaire Linux CUDA 12 compilé, avec
les modifications d'AJEAN), vérifié par SHA-256 avec les empreintes publiées
par AJEAN. La reprise automatique d'une installation 1.0 (`strataMigrate`) est
reprise d'AJEAN (`moeMigrate`). Ni Strata ni le modèle ne sont inclus dans
l'image : ils sont téléchargés à la demande.
pour ce fork ; les prompts de rôles sont des réécritures originales.
## Services externes
+7 -806
View File
@@ -16,9 +16,7 @@ seule image.
```
┌────────────────── conteneur loki ──────────────────┐
│ loki-rs serve (façade Rust, port 8090) │
│ │ relaie HTTP/SSE/WebSocket │
│ loki web (UI + API Go, 127.0.0.1:8091) │
│ loki web (UI + API, port 8090, premier plan) │
│ │ pilote (fichier PID — pas de systemd) │
│ loki serve ──exec──► llama-server (CUDA, :8080) │
│ ▲ modèles .gguf │
@@ -37,13 +35,6 @@ seule image.
redémarre le moteur normalement.
- Le moteur (port 8080, non authentifié par défaut) **n'est pas exposé** ;
seule l'UI (8090) l'est.
- **Migration vers Rust en cours**, par lots : la façade `loki-rs`
(`rust/loki-rs`) prend le port public et relaie au serveur Go ; chaque lot
lui fait reprendre des routes. Déjà servis par Rust : l'interface (HTML,
JS, polices) et `/api/ping`, avec l'authentification de pilotage portée à
l'identique (la clé est demandée au Go par une API interne locale,
protégée par un jeton tiré à chaque démarrage). Plan et feuille de route :
`docs/superpowers/specs/2026-10-13-rust-migration-lot1-design.md`.
## Démarrage rapide (Docker, GPU NVIDIA)
@@ -102,89 +93,6 @@ la recherche qu'au téléchargement. À défaut, la variable d'environnement
priorité. Un jeton en **lecture** suffit (huggingface.co/settings/tokens), et il
n'est envoyé qu'aux adresses Hugging Face.
## Choisir son moteur : llama.cpp ou Strata
Loki a deux moteurs, et le choix se fait **par preset** (clé `ENGINE`) :
- **llama.cpp** (par défaut) — n'importe quel GGUF, réglages complets dans
l'éditeur de preset.
- **[Strata](https://github.com/Niko1221/Strata)** — spécialisé dans
**Qwen3.8 Flash Next** (MoE de 125 B) : les experts sont répartis entre
VRAM, RAM et disque pour faire tourner ce modèle sur une carte de joueur.
Intégration reprise d'AJEAN (paquet figé « AJEAN MoE 1.1 », vérifié par
SHA-256).
**Paramètres → Moteur → Strata** : la fenêtre lit la machine (cartes, RAM,
disque), propose la version (Classique ou Swift 1.5) et la qualité qui tient,
puis installe et active le modèle en un clic (téléchargement de 66 à 84 Go dans
`/data/strata`). Le modèle installé devient un preset comme les autres :
basculer dessus lance Strata, basculer sur un autre preset revient à llama.cpp.
Son engrenage ouvre les réglages propres à Strata (contexte jusqu'à 262 144,
cache KV fp16/8 bits, prédiction MTP, lecture des images, seconde carte).
La seconde carte a trois réglages : **non utilisée**, **aide aux experts** (le
défaut : elle garde et calcule une part des experts) ou **partage des couches**
(les deux cartes se répartissent les couches du modèle). Le partage est souvent
plus rapide avec deux cartes identiques (+50 % en génération mesuré par un
utilisateur d'AJEAN sur deux RTX 3060) ; sur deux cartes très différentes,
l'aide aux experts reste le meilleur choix. En partage des couches, le mode
« hors RAM » n'est jamais choisi : la seconde carte n'y garde pas d'experts.
Depuis le paquet 1.1, la carte d'appoint calcule elle-même ses experts à la
lecture des prompts (au lieu de les recopier vers la principale) : les ajouts
de 100 à 200 jetons se lisent ~30 à 40 % plus vite (mesures d'AJEAN). Un modèle
installé avec la 1.0 passe à la 1.1 **à son premier lancement**, sans
réinstallation : Loki télécharge le nouveau paquet (~340 Mo), reprend
l'environnement Python et la configuration de la 1.0 (le dossier
`/data/strata/ajean-moe-1.0` doit donc rester en place) et réécrit le preset.
En cas d'échec, la 1.0 continue de tourner et la raison est dans le journal du
moteur. En mode « hors RAM », le contexte est plafonné à 131 072 jetons (au-delà,
le cache KV verrouillé en RAM ne tient plus).
Pré-requis : Linux + NVIDIA RTX 20 ou plus récente (8 Go de VRAM minimum, une
deuxième carte est utilisée comme carte d'appoint), environ 46 Go de RAM pour
la plus petite qualité, pilote compatible CUDA 12.8. Le conteneur doit pouvoir
verrouiller la mémoire : `ulimits: memlock: -1` (déjà présent dans les
fichiers compose fournis). Le benchmark et l'optimiseur ne s'appliquent qu'à
llama.cpp.
### Strata sur un SSD dédié (Unraid)
Strata range **tout** dans `/data/strata` : moteur, environnement Python (~10
Go), modèle (66 à 84 Go), et, quand la RAM ne suffit pas à garder les experts
(mode « lus depuis le disque »), il les relit en continu depuis ce dossier. Un
SSD de 250 Go dédié lui suffit largement (quelle que soit la qualité choisie)
et c'est lui qui fait la vitesse en mode disque : un NVMe vaut mieux qu'un SATA,
et un disque dur de l'array est à proscrire.
1. **Le SSD en pool Unraid.** Array arrêté : *Main → Add Pool*, nom `strata`,
1 emplacement, assigne le SSD. Démarre l'array, clique le pool, *File
system type* = `xfs` (ou `btrfs`), puis *Format* en bas de la page Main.
Le SSD est alors monté en `/mnt/strata`.
(Avec le plugin *Unassigned Devices* à la place : monte-le, il apparaît en
`/mnt/disks/<nom>` — même suite en remplaçant le chemin.)
2. **Le dossier** (terminal Unraid) : `mkdir -p /mnt/strata/loki`.
Vérifie avec `df -h /mnt/strata` que c'est bien le SSD (~230 Go) et pas
`rootfs` : un `/mnt/<pool>` qui n'existe pas est dans la RAM d'Unraid.
3. **Ce qui existe déjà.** Si une installation de Strata avait commencé dans
`/mnt/user/appdata/loki/data/strata`, *Compose Down* puis, au choix :
`rsync -a /mnt/user/appdata/loki/data/strata/ /mnt/strata/loki/` (on garde,
les presets restent valides : le chemin vu par le conteneur ne change pas),
ou supprime ce dossier pour repartir de zéro. Sinon il reste caché sous le
montage et occupe de la place pour rien.
4. **Le compose** : décommente dans `docker-compose.unraid.yml` la ligne
```yaml
- /mnt/strata/loki:/data/strata
```
(sous les deux volumes existants), puis *Compose Up*.
5. **Installer** : *Paramètres → Moteur → Strata*. La fenêtre affiche
maintenant l'espace libre **du SSD** ; choisis la version et la qualité
proposées, puis installe.
Pas de partage `/mnt/user/…` pour ce montage : la couche FUSE d'Unraid
ralentit fortement la lecture des experts en mmap. Et rien à faire côté mover
ou cache : le pool `strata` n'est lié à aucun partage.
## Installation sur Unraid
L'image est construite et publiée par GitHub Actions sur GHCR
@@ -200,32 +108,6 @@ Unraid. Compose prêt à l'emploi : [`docker-compose.unraid.yml`](docker-compose
`docker-compose.unraid.yml` → **Compose Up**.
4. Interface : `http://<ip-unraid>:8090`.
## Mise à jour au démarrage
Un **simple redémarrage du conteneur** (ou du serveur Unraid) suffit à passer
à la dernière version de Loki : pas besoin de `docker compose pull` pour les
changements de code.
- À chaque push sur `main`, la CI publie la release GitHub
[`rolling`](https://github.com/R0m1k3/Loki/releases/tag/rolling) : binaires
`loki` (Go) et `loki-rs` (Rust), `SHA256SUMS`, `manifest.json`.
- Au démarrage, `loki-rs self-update` les télécharge, vérifie leur empreinte
SHA-256 et les installe dans `/data/bin/<build>/`. Le journal du conteneur
dit ce qui s'est passé (`[loki-rs update] …`, puis
`[entrypoint] binaires Loki : …`).
- **Jamais bloquant** : réseau coupé, empreinte fausse, GitHub injoignable →
les binaires déjà en place servent, le conteneur démarre normalement.
- **Quand une nouvelle image est nécessaire** (nouvelle dépendance système,
Python, Node, llama.cpp…), le fichier `IMAGE_ABI` est incrémenté : les
conteneurs plus anciens n'installent pas ces binaires et le journal demande
un `docker compose pull`.
- La mise à jour est faite par le `loki-rs` **de l'image**, jamais par celui
téléchargé : une version publiée défectueuse ne peut pas empêcher le
correctif suivant d'arriver au redémarrage d'après.
- `docker exec loki loki …` utilise aussi la version installée.
- Couper : `LOKI_AUTO_UPDATE=0`. Revenir aux binaires de l'image : supprimer
`/data/bin` et redémarrer avec `LOKI_AUTO_UPDATE=0`.
## Configuration
Tout se règle **dans l'UI** (modèle, contexte, presets…) et survit aux
@@ -240,43 +122,9 @@ redémarrages (volume `/data`). Variables d'environnement du conteneur :
| `LOKI_HOME` | données (volume) | `/data` |
| `LOKI_MODEL_DIRS` | dossiers .gguf additionnels | `/models` |
| `HF_TOKEN` | jeton Hugging Face, pour les dépôts à accès restreint (repli : le jeton réglé dans l'UI prime) | — |
| `LOKI_CHROME` | binaire du navigateur piloté (contrôle du navigateur) ; par défaut le Chromium de Playwright de l'image | — |
| `LOKI_CU_HEADFUL` | `1` : ouvre une vraie fenêtre au lieu du mode headless (machine avec écran) | — |
| `LOKI_TRUSTED_HOSTS` | noms de domaine autorisés à servir l'UI **sans** clé de pilotage (reverse proxy), séparés par des virgules | — |
| `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, 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
toute requête venue d'un autre site : une page web ouverte dans un navigateur
du réseau ne peut plus piloter Loki en douce (ni par DNS rebinding). Derrière
un reverse proxy (`loki.mondomaine.fr`), définir une clé
(`docker exec -it loki loki set-web-key`) ou lister le nom dans
`LOKI_TRUSTED_HOSTS`.
En CLI dans le conteneur : `docker exec -it loki loki status` (aussi :
`logs`, `restart`, `config`, `bench`, `tune`, `test`…).
`logs`, `restart`, `config`, `bench`, `test`…).
### Données et persistance
@@ -289,9 +137,7 @@ et modèles compris.
| `/data/loki.db` | base bbolt : préférences, discussions, mémoire des réglages |
| `/data/presets/` | un `.env` par preset (modèle, contexte, NGL, vision…) |
| `/data/models/` | modèles téléchargés depuis l'interface |
| `/data/memory/` | pages de mémoire persistante (`.md`) — joignables **uniquement** par les outils `mem_*`, pas au shell ; chiffrées si le chiffrement est actif |
| `/data/backups/memory/` | snapshots de la mémoire pris avant chaque opération qui touche à tout |
| `/data/scripts/` | scripts durables de l'agent, hors du workspace jetable (planifiables sans modèle) |
| `/data/memory/` | pages de mémoire persistante (`.md`) |
| `/data/workspace/` | racine du dossier de travail de l'agent |
| `/data/workspace/discussions/<id>/` | fichiers d'UNE discussion : dépôts, captures, ce que l'agent y écrit |
| `/data/loki-engine.log` | journal de `llama-server` (aussi via `loki logs`) |
@@ -308,27 +154,6 @@ Sur Unraid, garde le **même** chemin hôte d'un lancement à l'autre : `/mnt/us
emplacements différents dès que le partage n'est pas en cache-only ou que le
*mover* est passé. Le compose fourni utilise `/mnt/user/appdata/loki/…`.
**Performance sur Unraid.** `/mnt/user/…` passe par `shfs`, la couche FUSE des
partages : chaque écriture de `loki.db` et chaque page d'un gros modèle relue
depuis le disque (MoE plus gros que la RAM, mmappé) traverse un démon en espace
utilisateur, ce qui ralentit le décodage. Loki le détecte au démarrage
(`fuse.shfs` dans `/proc/self/mountinfo`) et l'affiche en encart d'information —
rien n'est perdu ni altéré. Pour l'éviter :
- **recommandé** — partages en *Exclusive access* (Unraid 6.12+). Ce n'est
pas une case mais un état : *Global Share Settings* → *Permit exclusive
shares* = Yes, puis le partage `appdata` (et celui des modèles) entièrement
sur **un** pool — mover d'abord vers le pool, ensuite *Secondary storage* =
None — jusqu'à lire *Exclusive access : Yes* sur la page du partage.
Redémarre le conteneur : le chemin `/mnt/user/…` ne change pas, FUSE est
court-circuité ;
- **avancé** — mappe `/mnt/<ton-pool>/appdata/loki/…` (ex. `/mnt/cache` si ton
pool s'appelle `cache`), avec une migration manuelle : `Compose Down`,
`rsync -a` de l'ancien dossier vers le nouveau, puis modification des deux
lignes `volumes`. Un `/mnt/<pool>` inexistant atterrit dans la RAM d'Unraid
(données perdues au redémarrage, serveur saturé par un modèle de 80 Go), et
changer le chemin sans migrer donne un `/data` vide.
**Tu perds modèles, discussions et fichiers à chaque redémarrage ?** C'est le
signe que `/data` n'est **pas monté** : le conteneur écrit alors dans sa couche
éphémère, détruite à chaque recréation (mise à jour d'image, `compose down`,
@@ -350,29 +175,11 @@ Héritées d'AJEAN :
- **Tchat** avec streaming, raisonnement visible, pièces jointes, export de
conversations. La **vision** demande un modèle multimodal *et* son projecteur
`mmproj` — voir [Installer un modèle](#installer-un-modèle). Toute image
envoyée au modèle (pièce jointe, `see_image`, capture) est d'abord
**redressée** — l'orientation EXIF d'une photo de téléphone est cuite dans
les pixels, sinon le projecteur, qui ignore l'EXIF, la voyait couchée — et
**ramenée sous 1568 px** de grand côté, taille au-delà de laquelle le base64
grossit sans rien apprendre de plus au modèle.
`mmproj` — voir [Installer un modèle](#installer-un-modèle).
- **Mémoire persistante** (`memory off|ondemand|always`).
- **Accès internet** : recherche + lecture de pages, moteur Go intégré ou
[Crawl4AI](https://github.com/unclecode/crawl4ai) pour les pages JS.
- **Agent** : shell, fichiers, workspace (`agent on`).
- **Contrôle du navigateur** (`computer on`, ou *Réglages → Contrôle du
navigateur*) : l'IA **pilote** un Chromium — celui de Playwright, déjà dans
l'image — par le protocole DevTools. Elle ouvre une page, en reçoit les
éléments interactifs **numérotés** (`[12] bouton « Se connecter »`) et agit
par numéro : `browser_open`, `browser_snapshot`, `browser_find`,
`browser_click`, `browser_type`, `browser_key`, `browser_scroll`. Aucune
vision requise — ça marche avec de petits modèles texte. Avec un projecteur
`mmproj` chargé s'ajoutent `browser_screenshot` (image quadrillée tous les
100 px) et `browser_click_xy`, pour ce que l'arbre d'accessibilité ne montre
pas (canvas, bandeau en iframe). Ce sont des **actions réelles** sur le web :
l'interrupteur est distinct de l'accès internet et n'agit qu'en **mode
agent**, au même niveau de confiance que `bash`. La session navigateur est
unique et réutilisée entre les appels ; couper l'interrupteur la ferme.
- **Serveurs MCP** : Node.js (`npx`) et uv (`uvx`) sont inclus dans l'image, pour
les serveurs écrits en JavaScript comme en Python. Au **premier** lancement,
`npx`/`uvx` téléchargent le paquet du serveur — Loki attend jusqu'à 3 minutes
@@ -388,48 +195,7 @@ Héritées d'AJEAN :
activer avant l'exécution, accès mémoire et web. Un **interrupteur maître**
suspend tout d'un coup. Sans **mode agent**, une tâche s'exécute mais n'a aucun
outil pour agir — l'interface le dit. Une seule inférence tourne à la fois : une
tâche attend son tour, et le bouton stop du chat l'interrompt. Deux types de
tâche : une **consigne IA**, ou un **script seul** — un fichier du dossier
`/data/scripts` lancé directement, **sans charger le modèle ni consommer un
token** (sauvegarde, synchro, nettoyage n'ont rien à demander à un LLM). Ce
dossier vit **hors du workspace** : vider une discussion, ou la supprimer,
n'y touche pas. L'IA elle-même dispose des outils `task_create`, `task_list`,
`task_update` et `task_delete` — elle peut donc se poser ses propres rappels
et veilles — cloisonnés par projet : dans un projet, elle ne voit et ne
pilote que les tâches de ce projet.
- **Notifications** (Web Push) : le serveur prévient le navigateur **à la fin
d'une réponse et à la fin d'une tâche planifiée**, même l'app fermée ou le
téléphone verrouillé — c'est le serveur qui pousse, pas la page (un onglet
caché relâche son flux). L'interrupteur est dans *Réglages → Mode agent*, à
armer **sur chaque appareil** (l'abonnement appartient au navigateur). Exige
**HTTPS** ou localhost ; sur iPhone, il faut d'abord ajouter Loki à l'écran
d'accueil. Les clés VAPID sont générées à la première demande et rangées avec
le reste sous `/data` ; le corps de la notification reste générique (aucun
extrait de réponse), puisqu'elle transite par Apple ou Google.
- **Chiffrement de la mémoire** (*Réglages → Mémoire*) : pages mémoire,
discussions, blocs archivés au compactage et trackers chiffrés en
**AES-256-GCM** sur le disque. Chiffrement à enveloppe : une clé de données
(DEK) tirée une fois, enfermée dans un coffre par une clé dérivée en
**Argon2id**. Ce qui ouvre le coffre : la **clé de pilotage de l'appareil**
(le serveur n'en garde qu'une empreinte — il ne peut pas ouvrir le coffre
seul) ou une **clé de récupération** affichée une seule fois à l'activation.
La DEK ne vit qu'en RAM : après un redémarrage à froid, la mémoire est
verrouillée jusqu'à ce qu'un navigateur se reconnecte — verrouillée, Loki
n'écrit jamais de clair par-dessus du chiffré, il refuse d'écrire. Règle
tenue partout : **rien n'est supprimé avant que son remplaçant ait été relu
et vérifié**, un **snapshot** est pris avant chaque bascule, et une migration
interrompue reprend au démarrage.
- **Sauvegarde chiffrée en fichier** : *exporter* télécharge un paquet scellé
(mémoire, presets, réglages) que *importer* rejoue sur un autre serveur avec
la seule clé — de quoi remonter le conteneur ailleurs. C'est la sauvegarde de
l'amont sans son relais : ici rien ne part sur un service tiers, le fichier
reste chez toi.
- **Interface en anglais** (*Réglages → Apparence → Langue*) : la source reste
française et « English » applique un dictionnaire sur la coque — navigation,
intitulés, boutons, interrupteurs. Ce qui n'y figure pas **reste en
français** plutôt que d'afficher une clé technique, et le fil de discussion
n'est jamais touché : c'est ton contenu. Le dictionnaire s'enrichit sans
toucher au reste de l'interface (`ui/src/js/28-i18n.js`).
tâche attend son tour, et le bouton stop du chat l'interrompt.
- **Presets** de configuration par modèle, bench, auto-détection GPU.
- **Échantillonnage réglable par preset** : température, `top_p`, `top_k`,
`min_p`, pénalités de présence et de répétition, dans l'éditeur de preset. Ces
@@ -462,15 +228,7 @@ Ajoutées par ce fork :
de code fait apparaître une puce « passer en mode Code ? » — suggestion,
jamais bascule automatique. Conception reprise
d'[OpenFox](https://github.com/co-l/openfox) (MIT), réécrite en Go — voir
`NOTICE.md`. **Sous-agents** : l'outil `subagent` délègue une recherche
(`explorer`), une relecture (`code-reviewer`) ou un découpage (`planner`) à
un rôle qui travaille dans **son propre contexte** et ne rend que sa réponse.
Sur un modèle local, c'est ce qui sauve la fenêtre : « trouve où est géré le
cache » coûte dix lectures de fichiers, qui resteraient sinon dans
l'historique jusqu'à la compaction alors que seule la réponse comptait. Tous
les rôles délégués sont en **lecture seule** — ce qui modifie le dépôt reste
dans le fil principal, sous tes yeux — et un sous-agent ne peut pas en
appeler un autre.
`NOTICE.md`.
- **Catalogue MCP** : le panneau *Serveurs MCP* offre un bouton **catalogue** —
une vingtaine de serveurs connus (filesystem, git, fetch, memory, sqlite,
playwright, context7, github…) avec leur commande déjà renseignée, classés par
@@ -497,9 +255,6 @@ Ajoutées par ce fork :
→ maximale) et rejoue le message sans rien perdre de l'historique — une fois,
puis la traduction est retenue pour ce modèle. La liste est grisée quand le
raisonnement est coupé pour ce modèle.
- **Performance** : cache de prompts, slots, décodage spéculatif, placement
MoE et multi-GPU, optimiseur — tout est rassemblé dans la section
[Performance](#performance), avec la table des clés et leur défaut.
- **Discussions multiples** : historique complet dans la barre latérale, titre
repris du premier message (renommable), suppression. **Chaque discussion a son
dossier de fichiers** (`workspace/discussions/<id>/`) : les pièces jointes
@@ -605,526 +360,6 @@ Retirées par ce fork :
sa route n'avait aucun consommateur et son repli embarqué datait de 2024. La
recherche Hugging Face le remplace.
## Performance
Le but : des tours plus courts **sans dénaturer le modèle**. Rien ici ne
touche à la quantification des poids, à l'échantillonnage, à la température, au
budget de raisonnement ni au contenu du contexte. Tout ce qui changerait ce
qu'envoie Loki au modèle, le placement des poids ou les slots du moteur est
**désactivé par défaut**, derrière une clé ; sans clé, la requête et la ligne de
commande restent celles d'avant. Les clés se posent dans le preset (éditeur) ou
par `loki config set CLÉ valeur` ; celles marquées « moteur relancé » demandent
un redémarrage du moteur.
### Actif par défaut, sans perte
- **Cache de prompts dimensionné** (`--cache-ram`) : agrandi pour un modèle
tout-GPU d'après l'état mesuré dans le GGUF, jamais sous le défaut du moteur ;
et le slot est effacé après un sous-agent, une vérification, une tâche ou un
bench, pour que la conversation revienne du cache RAM au lieu d'être
recalculée.
- **Préfixe stable** : rappels de Loki gardés dans l'historique tels
qu'envoyés, relance après un 500 sans toucher aux outils ni au système, ligne
MCP présente dès le premier tour, ordre des trackers fixe.
- **Comptage du contexte** sans le raisonnement jamais renvoyé : la compaction
(avec perte) part moins tôt, et son résumé n'est plus amputé de l'état
d'avancement.
- **Threads CPU** : sans `THREADS`, le moteur prend ses cœurs physiques (plus
tous les threads logiques) ; conteneur à l'étroit (cpuset, quota) borné.
- **Deux GPU** : `CUDA_SCALE_LAUNCH_QUEUES=4x` quand le pipeline entre cartes
est possible (même calcul, seul le prompt peut gagner).
- **Points de reprise des hybrides** sur un moteur officiel antérieur à
`b10864` : `--checkpoint-min-step 2048` d'office si la RAM le permet, et un
avis « moteur trop ancien » ; l'encart « version recommandée » propose la mise
à jour (sur clic seulement, voir [Mettre à jour llama.cpp](#mettre-à-jour-llamacpp-sans-reconstruire-limage)).
- **Côté Go** : écriture d'un gros fichier en temps linéaire, persistance de la
discussion hors du chemin du premier jeton, un seul `nvidia-smi` partagé
(moniteur et éditeur de preset), stockage Unraid servi par FUSE signalé.
- **Mesure** : télémétrie par complétion (cache repris, recalculé, brouillon
accepté) dans l'interface et `GET /api/perf/summary`, sonde du gabarit de
chat (lecture seule, `/apply-template`), bench honnête en tâche de fond.
- **Garde-fous** : un cache KV quantifié, `--context-shift` ou `--cache-reuse`
posés à la main sont signalés ; un mode de chargement résident voué à
l'échec est refusé (`LOAD_GUARD`) ; avis de placement MoE dans l'éditeur.
### Les clés
| Clé | Défaut | Effet | Prix, zone grise |
|---|---|---|---|
| `CACHE_RAM` | auto | cache de prompts en RAM hôte (`--cache-ram`, Mio), copie exacte des conversations quittées | `-1` = RAM non bornée (déconseillé avec un MoE mmappé), `0` = coupé |
| `CACHE_ISOLATE` | actif | efface le slot après un travail annexe (sous-agent, vérification, tâche, bench) | `off` pour couper ; sans objet avec `SIDE_SLOT` |
| `THREADS` / `THREADS_BATCH` | vide = cœurs physiques | `-t` / `-tb` | — |
| `CUDA_LAUNCH_QUEUES` | auto (4x sur ≥ 2 GPU si pipeline) | file de lancements CUDA | `off`, ou `0.25x`…`4x` imposé ; réglage de machine |
| `CTX_CHECKPOINTS` | moteur (32) | points de reprise par slot d'un hybride | 70 à 200 Mio de RAM hôte chacun |
| `CKPT_MIN_STEP` | moteur (2048 d'office : hybride, moteur officiel < `b10864`) | espacement minimal des points de reprise | — |
| `LOAD_GUARD` | actif | refuse un `--load-mode` résident qui ne tient pas en RAM | `off` = lancer quand même |
| `REASONING_ECHO` | off | renvoie au moteur local la réflexion du modèle, au format entraîné | plus de contexte par tour, compaction plus tôt |
| `REASONING_PRESERVE` | vide = défaut du moteur | `--reasoning-preserve` / `--no-reasoning-preserve` | change le rendu : `off` rend l'ancien historique à Qwen3.6, mais change celui de Qwen3.8 |
| `PROJ_SNAPSHOT` | off | bloc projet figé par discussion, changements en `<context_update>` ; date et dossier sortent du système | le modèle lit les changements en tête du message suivant |
| `PREWARM` | off | `on` : prépare le prochain tour pendant que tu lis ; `full` : aussi la discussion qu'on ouvre | une requête de plus au moteur au repos (1 jeton, jeté) |
| `COMPACT_CONTINUATION` | off | le résumé de compaction prolonge le prompt en cache au lieu d'une transcription à froid | repli sur l'ancien chemin au moindre écart |
| `KEEP_TURN_IMAGES` | off | garde dans l'historique les images montrées par les outils | **zone grise** : plus de contexte, compaction plus tôt ; une API externe refacture les images |
| `NUDGE_IN_TOOL` | off | le rappel de budget d'outils part au bout du dernier résultat d'outil | **zone grise** : un modèle peut moins bien suivre une consigne lue dans un outil |
| `SIDE_SLOT` | off (moteur relancé) | second slot pour les travaux annexes, la discussion garde le sien | cache KV et état récurrent en double en VRAM |
| `SLOT_PERSIST` | off | état du slot gardé sur disque à la bascule de preset, rechargé au retour | 2 fichiers de 8 Gio au plus ; refusé avec `SPEC` |
| `SPEC` | off (moteur relancé) | décodage spéculatif : `auto`, `mtp`, `ngram`, `mtp+ngram` | même distribution, pas le même texte au bit près ; MTP : 1 à 2 Go de VRAM |
| `MODEL_DRAFT` | — | tête MTP publiée à part ou petit modèle brouillon | VRAM |
| `SPEC_N_MAX` | moteur (3) | jetons anticipés par étape | — |
| `SPEC_SAMPLING` | greedy (exact) | `probabilistic` seulement avec `SPEC=mtp` ou `mtp+ngram` | — |
| `SPLIT_MODE` | couches | `tensor` : parallélisme de tenseurs entre cartes CUDA | expérimental : décodage plus rapide, prefill plus lent |
| `FIT_TARGET` | 1024 Mio par carte | marge laissée libre par le placement auto (`--fit-target`) | — |
| `OP_OFFLOAD_MIN_BATCH` | moteur (32) | lot à partir duquel les experts MoE sur CPU sont recopiés vers le GPU | à mesurer |
| `CUDA_GRAPH_OPT` | off | branches Q/K/V en parallèle au décodage | expérimental ; `off` puis redémarrage s'il plante |
| `KV_TYPE` (`_K`, `_V`) | f16 | quantification du cache KV | **zone grise** : `q8_0` modifie légèrement les sorties, `q4_0` perte mesurable — jamais posé par Loki |
| `BATCH` / `UBATCH` | 2048 / 512 | lots du prefill | MoE aux experts en RAM : `UBATCH` 2048+ |
| `LOKI_PERF_LOG` (environnement) | absent | une ligne `[perf]` par complétion sur stderr | — |
Outils sans clé, qui ne font rien d'eux-mêmes :
| Outil | Ce qu'il fait |
|---|---|
| **Bench** (bouton, `loki bench`, `--full`) | mesure en tâche de fond, à profondeur réelle avec le mode complet (prefill à froid, tours qui reprennent le cache, decode) |
| **Optimiseur** (`loki tune`, bouton « Optimiser… ») | cherche lots, threads, marges, placement (`--placement`) et options opt-in (`--opt-in`) sur un moteur d'essai ; rien n'est écrit sans un clic |
| **Dupliquer en placement auto…** (éditeur, MoE) | copie du preset où `--fit` place les experts au lieu de `-ot` / `--n-cpu-moe` |
| **Version recommandée** (panneau Moteur) | met à jour llama.cpp vers un build ≥ `b10864`, sur clic, avec retour à la version précédente |
### Ordre de test conseillé
Une étape à la fois, mesurée avant la suivante :
1. **Bench complet** du preset tel quel : la référence.
2. **`PREWARM=on` et `COMPACT_CONTINUATION=on`**, puis une vraie session de
travail ; `GET /api/perf/summary` dit, par nature de requête
(`kinds.main`…), combien de fois et de jetons le cache a été perdu
(`lost_events`, `lost_tokens`) et le prefill par tour.
3. **Optimiseur** (« Optimiser… ») sur le preset en service : lots, threads,
marges.
4. **MoE aux experts en RAM** : « Dupliquer en placement auto… », bascule sur
la copie, `successfully fit params` au journal, bench complet des deux.
5. **Mise à jour du moteur** vers la version recommandée si l'encart s'affiche
(points de reprise des hybrides, MTP rapide) ; vérifier le rendu du gabarit
dans le panneau.
6. **`SPEC=auto`** (ou `mtp`) si le modèle a une tête MTP : bench, puis
`draft_rate` dans `/api/perf/summary`.
### Ce qui a été écarté, et pourquoi
- **`--cache-reuse`** : réutilise des morceaux de cache calculés sous un autre
contexte, donc change les sorties ; llama.cpp le coupe d'ailleurs pour les
hybrides et le multimodal. Signalé s'il est posé à la main.
- **`--context-shift`** à la place de la compaction : retire des jetons du
contexte (perte), et ne marche pas sur les hybrides. Signalé lui aussi.
- **Cache KV quantifié par défaut** : `q8_0` n'est pas exact, `q4_0` perd
mesurablement, pour un gain d'environ 2 Gio sur un 27B hybride (16 couches
sur 64 portent du KV). Reste un choix explicite et étiqueté (`KV_TYPE`).
- Aussi : `-sm tensor` par défaut (prefill plus lent, expérimental), plus de
slots par défaut (OOM : états récurrents par slot), `--cache-ram -1` (RAM
non bornée), et tout réglage d'échantillonnage ou de quantification « pour
aller plus vite ».
### Détail des clés et des outils
- **Bench** (bouton du preset, `loki bench`, `--full` pour le mode complet) :
en tâche de fond (`POST /api/bench`, progression et annulation), chat,
tâches et compaction refusés le temps de la mesure. Le mode rapide mesure une
ligne courte avec le gabarit, le raisonnement et l'échantillonnage du preset ;
le mode complet ajoute un prefill à froid à profondeur réelle (jusqu'à 32 k
jetons, 16 k si des poids tournent sur CPU) et trois tours qui reprennent le
cache — reprise lue dans `cache_n`, jamais supposée. Rien n'est enregistré
sans réponses valides et timings réels ; l'empreinte du preset accompagne
chaque mesure.
- **Raisonnement renvoyé au modèle** (clé `REASONING_ECHO`, **off** par défaut,
`loki config set REASONING_ECHO on`) : le raisonnement que le moteur local a
séparé (`reasoning_content`) est gardé avec chaque message et renvoyé au même
modèle. Les gabarits Qwen3.5/3.6 relisent alors les étapes d'une boucle
d'outils avec leur réflexion — le format entraîné — au lieu de blocs vides, et
le moteur ne recalcule plus le dernier message. Avec `REASONING_PRESERVE=on`
(gabarits qui ont ce réglage, Qwen3.6), le préfixe reste stable d'un message
utilisateur à l'autre ; Qwen3.5 n'en a pas, le gain y reste interne au tour.
Contrepartie : plus de contexte par tour, donc compaction plus tôt. Jamais
vers une API externe ni vers un autre modèle ; un prompt trop long est d'abord
rejoué sans raisonnement, et un gabarit qui le refuse le suspend pour ce
modèle jusqu'au redémarrage.
- **Bloc projet figé** (clé `PROJ_SNAPSHOT`, **off** par défaut,
`loki config set PROJ_SNAPSHOT on`) : le contexte du projet (description,
index mémoire, trackers, `AGENTS.md`) part en tête du premier message ; sans
la clé il est reconstruit à chaque tour, et une page créée ou une valeur de
tracker notée fait recalculer toute la conversation derrière lui. Avec la clé,
chaque discussion garde une copie datée du bloc, envoyée à l'identique, et les
changements arrivent en tête du message suivant dans un bloc
`<context_update>` : pages ajoutées, modifiées, retirées, nouvelle ligne d'un
tracker, et le texte **complet** d'une description ou d'un `AGENTS.md` modifié
— rien n'est perdu, seul ce petit bloc est à calculer. Une ligne du prompt
système (présente seulement avec la clé) dit au modèle que ces blocs viennent
de Loki et que le plus récent l'emporte. La date du jour et le dossier de
travail de la discussion (ou, pour un poste distant ciblé, son nom, son
dossier et s'il est hors ligne) quittent aussi le prompt système pour ce
bloc, les consignes restant dans le système : système et outils deviennent
les mêmes d'une discussion à l'autre, et passer minuit n'ajoute qu'une ligne
de mise à jour au lieu de tout recalculer. Le bloc est repris tout neuf, et les
anciens `<context_update>` retirés, quand le début du prompt change de toute
façon : compaction, système ou outils modifiés (réglages, mode), redémarrage de
Loki, changement de modèle, de preset, de projet, de mode mémoire ou de mode
Code — et dès que les mises à jour accumulées deviennent trop longues. Les
tâches planifiées gardent le bloc à jour à chaque tour et, comme les
sous-agents, la vérification et le terminal, leur système d'avant, date et
dossier compris ; sans agent, rien du
projet n'est envoyé ; un preset externe (API) n'est jamais concerné. Le titre, l'export JSON, le résumé de compaction et la
passe de vérification ne voient pas ces blocs.
- **Préchauffage du prochain tour** (clé `PREWARM`, **off** par défaut,
`loki config set PREWARM on`) : certains recalculs sont inévitables — prompt
réécrit par une compaction, dernier message rendu autrement au tour suivant,
discussion reprise après une tâche planifiée qui a pris le slot — et sans la
clé ils retardent le premier mot du message suivant. Avec `on`, dès que le
moteur local est libre après un tour ou une tâche, Loki lui envoie la requête
du prochain tour, assemblée par les mêmes fonctions que la vraie (contenu
vivant, rien de figé), suivie d'un message `.` et limitée à 1 jeton : le
moteur calcule le préfixe pendant que tu lis, et le vrai message ne calcule
plus que lui-même. Le jeton et le `.` sont jetés, rien n'est enregistré. Toute
autre requête de Loki l'annule aussitôt, sauf un message dont la requête
prolonge exactement le préfixe préparé (même historique, mêmes outils, mêmes
réglages du gabarit). Jamais avec plus d'un slot (`PARALLEL` ou `-np` dans
`EXTRA_ARGS`) — sauf les deux de `SIDE_SLOT`, où il prépare le slot de la
discussion —, pendant un tour, une tâche, un bench, ni vers un preset externe.
`full` prépare aussi la discussion qu'on ouvre, si on y reste 3 s. Visible dans
la télémétrie sous `prewarm`. Ce que voit le modèle ne change pas : au pire, le
préchauffage ne sert à rien (moteur sans points de reprise aux messages
utilisateur, image juste avant, agent ou web changé avant d'envoyer).
- **Compaction en continuation** (clé `COMPACT_CONTINUATION`, **off** par
défaut, `loki config set COMPACT_CONTINUATION on`) : sans la clé, le résumé
d'une compaction part dans une requête à part — un prompt de résumeur et une
transcription des anciens tours — que le moteur local calcule à froid, des
dizaines de secondes sur un 27B, des minutes sur un MoE. Avec la clé, quand le
slot porte encore le prompt de la discussion, Loki renvoie la requête du tour
telle qu'elle est partie (mêmes messages, outils, arguments du gabarit et
intensité de raisonnement) suivie d'une seule demande de résumé : le moteur ne
calcule que celle-ci. La demande désigne le premier message gardé tel quel,
demande de tout résumer avant lui et de dater l'état d'avancement à cet
endroit ; mêmes règles de résumé qu'avant (mode Code compris), même budget,
température 0.2 sans l'échantillonnage du preset, réflexion coupée,
`tool_choice` à `none`. Ce qui est compacté, archivé et rangé ne change pas :
la requête sert seulement à obtenir le résumé, et rien du prompt système ou du
projet n'entre dans l'historique. Repli sur la transcription au moindre
écart : autre requête passée par le slot depuis le tour (vérification du mode Code,
sous-agent, tâche, préchauffage, bench, client `/v1`, autre discussion),
moteur relancé sur un autre modèle ou une autre fenêtre, marge insuffisante
dans la fenêtre, refus du moteur, appel d'outil émis, résumé vide ou fait de
seul raisonnement ; un refus du gabarit ou un appel d'outil la suspend pour ce
modèle jusqu'au redémarrage (gpt-oss à intensité haute peut épuiser le budget
en réflexion : repli). La clé évite aussi un résumé voué au refus (même vide,
il ne réduirait pas le contexte de 20 %) et, après un refus faute de réduction,
n'en redemande pas avant que le contexte ait grossi de 10 % sur le même fil —
sauf à 90 % de la fenêtre ou après une édition, une régénération ou un fil
vidé. Le filet réactif (prompt refusé), le bouton « compacter », les tâches,
les sous-agents et un preset externe gardent le chemin d'avant. Visible dans la
télémétrie sous `compact`.
- **Images d'outils gardées** (clé `KEEP_TURN_IMAGES`, **off** par défaut,
**zone grise**, `loki config set KEEP_TURN_IMAGES on`) : sans la clé, une
image montrée par un outil (`web_screenshot`, `browser_screenshot`,
`see_image`) n'est vue que pendant le tour ; au tour suivant elle manque à
l'historique, et toute la boucle d'outils qui la suivait — souvent des
dizaines d'étapes de navigation — est recalculée. Avec `on`, elle reste dans
l'historique telle qu'envoyée, rangée par référence (`chatimg/`, chiffrée si
la mémoire l'est), sous conditions : vision réellement active (projecteur
chargé, revérifié à chaque tour — un preset sans vision n'en reçoit jamais) ;
coût mesuré par le moteur (écart du nombre de jetons du prompt entre deux
requêtes), et une image qu'on ne sait pas mesurer reste éphémère ; toutes les
images gardées tiennent dans 10 % de la fenêtre, la première qui dépasse et
celles qui la suivent dans le tour restent éphémères. Plus d'images gardées,
c'est plus de contexte, donc une compaction (avec perte) plus tôt : toute
compaction ou réduction forcée retire ces images **d'abord** (leur légende et
une mention « image non gardée » restent) et ne résume ensuite que si c'est
encore nécessaire ; rouvrir la discussion les retire aussi, comme les pièces
jointes. Discussion seulement (ni tâche, ni sous-agent, ni vérification). Un
preset externe n'est concerné que si sa vision est déclarée, et l'API
refacture alors ces images à chaque tour. Le gain suppose que le moteur
reprenne son cache au-delà d'une image ; sur un modèle hybride (Qwen3.5/3.6),
dont les points de reprise ne suivent pas toujours une image, à vérifier dans
la télémétrie (`lost` du tour suivant) avant de l'adopter. Sans la clé, la
requête est identique à l'octet près.
- **Rappel de budget dans le résultat d'outil** (clé `NUDGE_IN_TOOL`, **off**
par défaut, **zone grise**, `loki config set NUDGE_IN_TOOL on`) : après un
grand nombre d'appels d'outils dans un même tour, Loki rappelle au modèle de
conclure, dans un message à part. Sur un gabarit dont le rendu change dès
qu'un message s'ajoute (Qwen3.5 : ce message devient la « dernière question »
et tout le tour est rendu autrement), ce rappel fait recalculer toute la
boucle d'outils du tour. Avec `on`, le rappel part au bout du dernier résultat
d'outil, et seul ce bout est à calculer — sur le moteur local, et seulement
si la sonde de gabarit a conclu que le rendu de ce modèle bouge pour la forme
de cette requête — mêmes outils, `chat_template_kwargs` et niveau de
raisonnement (« inconnu » ou forme jamais sondée : message à part, comme
sans la clé). Le texte du rappel ne change pas, sa
place si : un modèle entraîné à se méfier des consignes lues dans une sortie
d'outil peut moins bien le suivre. À comparer (tours qui concluent après le
rappel) avant de l'adopter.
- **Second slot pour les travaux annexes** (clé `SIDE_SLOT`, **off** par
défaut, `loki config set SIDE_SLOT on`, moteur relancé) : avec un seul slot,
une vérification du mode Code, un sous-agent, une tâche planifiée ou un bench
prennent la place de la discussion ; son état part dans le cache RAM et en
revient — ou est recalculé s'il n'y tient plus. Avec `on`, le moteur ouvre
deux slots de `CTX` jetons **chacun** (`-c` 2×`CTX`, `--parallel 2`,
`--no-kv-unified`) : deux flux de cache KV séparés, si bien qu'un slot ne
peut jamais manquer de place à cause de l'autre, et que les travaux annexes
gardent toute la fenêtre. Chaque requête de Loki porte alors son `id_slot` :
le tour, ses étapes, le préchauffage et le résumé en continuation sur le
slot 0 ; vérification, sous-agents, tâches, bench, résumé sur transcription
et clients `/v1` (forcés, quel que soit l'`id_slot` demandé) sur le slot 1.
L'état de la discussion ne bouge plus ; l'effacement de slot après un
travail annexe (`CACHE_ISOLATE`) n'a plus lieu d'être et s'abstient. Le prix :
le cache KV et l'état récurrent d'un hybride en double en VRAM (le
brouillon MTP et les points de reprise en RAM hôte aussi) — l'éditeur de
preset affiche ce surcoût sous le cache de prompts, et le lancement refuse la
clé (un slot, comme sans elle, avec une note dans le journal) si modèle et
deux états dépassent 90 % de la VRAM, si la VRAM est inconnue (macOS, AMD),
si des poids sont sur CPU (`--n-cpu-moe`, `-ot …=CPU`, `NGL` partiel), si le
moteur ne connaît pas `--no-kv-unified`, ou si `PARALLEL`≠2, `-c`, `-np`,
`-kvu`, `--kv-unified-per-slot`, `--cache-idle-slots` (ou leurs
`LLAMA_ARG_*`) sont réglés à la main. Le routage ne s'active que si le moteur
annonce bien deux slots d'au moins `CTX` jetons (`/props`) ; sinon les
requêtes partent sans `id_slot`, comme avant. Ce que voit le modèle ne
change pas : deux slots qui calculent en même temps donnent des logits égaux
au bruit de virgule flottante près, comme un découpage de lots différent.
Un « cache KV plein » sans nombre de jetons (le pool, pas le prompt) est
rejoué tel quel et ne déclenche jamais de compaction.
- **État du slot gardé à la bascule de preset** (clé `SLOT_PERSIST`, **off**
par défaut, `loki config set SLOT_PERSIST on`) : passer d'un preset A à B
puis revenir à A relance le moteur deux fois, et la conversation de A est
recalculée en entier au retour. Avec `on`, juste avant une bascule faite
depuis l'interface ou par une tâche, Loki demande au moteur d'écrire l'état
du slot de la discussion (`/slots/0?action=save`, sous `LOKI_HOME/slots`),
et le recharge au retour, avant le premier message de cette discussion.
Conditions strictes, sinon rien n'est écrit ou rechargé et le calcul est
normal : la dernière requête passée par le slot était bien un tour de
cette discussion (pas une vérification, une tâche, un préchauffage ou un
client `/v1`), au moins 4096 jetons, au plus 8 Gio estimés et le double
de place libre ; au retour, le moteur doit avoir **exactement** la même
empreinte — ligne de commande complète (modèle, `CTX`, types KV, `NGL`,
lots, gabarit, `EXTRA_ARGS`…), variables `LLAMA_ARG_*`/`GGML_*`/`CUDA_*`,
taille et date du modèle, du projecteur, du binaire et de ses bibliothèques
— et son slot 0 ne doit encore avoir servi aucune tâche. Une mise à jour du
moteur change l'empreinte : jamais de reprise d'un build à l'autre. Une
seule tentative par fichier, retiré ensuite ; deux fichiers au plus,
supprimés dès que la clé est retirée. Réglage de la machine : il survit aux
bascules comme `HOST` (un preset qui le pose l'emporte). Refusé au lancement (note au journal)
avec le décodage spéculatif (`SPEC`, brouillon dans `EXTRA_ARGS` : la
sauvegarde du moteur ne garde pas l'état du brouillon, et un brouillon MTP
détecté sur le slot bloque aussi), des poids sur CPU (MoE déporté), un
`--slot-save-path` déjà réglé, ou un moteur joignable par d'autres sans clé
d'API (`HOST` autre que `127.0.0.1`). `loki switch` en ligne de commande ne
garde rien (seul le process web sait ce que porte le slot). Le gain est
réel surtout sur un modèle dense ; sur un hybride (Qwen3.5/3.6), le fichier
n'a pas de points de reprise et ne sert que si le message suivant prolonge
exactement les jetons gardés (gabarit qui rend le dernier tour à
l'identique), sinon recalcul comme avant. Ce que voit le modèle ne change
pas : llama.cpp ne reprend un état que sur un préfixe de jetons identique.
Avec `PREWARM`, le préchauffage passe par le slot après chaque tour : une
bascule faite ensuite ne garde rien (les deux clés se recouvrent peu). Une
simple lecture d'un client (`/v1/models`, `/health`) ne compte pas.
- **Spéculation par n-grammes** (clé `SPEC`, valeurs `ngram` et `mtp+ngram`,
**off** par défaut, `loki config set SPEC ngram`, moteur relancé) : en mode
Code, le modèle recopie sans cesse ce qui est déjà dans le contexte (chemins,
diffs, arguments JSON, fichiers réécrits). `ngram` y cherche la suite des
24 derniers jetons et propose d'un coup les 48 à 64 suivants
(`--spec-type ngram-mod --spec-ngram-mod-n-match 24 --spec-ngram-mod-n-min 48
--spec-ngram-mod-n-max 64`, toujours en clair, jamais `--spec-default` dont
le contenu peut changer d'une version à l'autre) ; `mtp+ngram` ajoute la tête
MTP (`--spec-type draft-mtp,ngram-mod`), les n-grammes passant d'abord quand
ils trouvent. Chaque jeton reste tiré par le modèle et un jeton proposé n'est
gardé que s'il coïncide : même distribution, mais pas le même texte au bit
près (la vérification par lots n'emprunte pas les noyaux du décodage jeton
par jeton) — on compare des sessions rejouées, pas des diffs. Rien n'est posé
si le moteur ne connaît pas `--spec-ngram-mod-n-match`, si `EXTRA_ARGS` ou
`LLAMA_ARG_SPEC_TYPE` règlent déjà la spéculation (`--spec-type`, qui
s'additionne au lieu de remplacer, `--spec-default`, `--spec-ngram-*`, `-md`…),
ou, pour `ngram`, si un `--spec-draft-*` y figure. `mtp+ngram` sans tête MTP
(ni dans le fichier ni en `MODEL_DRAFT`) ou sans `draft-mtp` dans le moteur
retombe sur les n-grammes seuls, avec une note. Avec `ngram`, une tête MTP
présente reste inutilisée (le type explicite coupe le choix automatique) : la
note le dit. Poids sur CPU (`--n-cpu-moe`, `-ot …=CPU`, `NGL` partiel) :
refusé tant que `GGML_OP_OFFLOAD_MIN_BATCH` (clé `OP_OFFLOAD_MIN_BATCH`) ne
dépasse pas le lot de vérification de 65 jetons — sinon chaque brouillon
recopierait les experts vers le GPU ; `OP_OFFLOAD_MIN_BATCH=128` pour
l'essayer, en mesurant aussi les lectures disque si le modèle dépasse la RAM.
Sur un hybride, chaque brouillon copie l'état récurrent (point de reprise)
et le recharge s'il est rejeté. `/api/perf/summary` donne l'acceptation par
nature de requête (`kinds.<nature>.draft_rate`) : à garder là où elle paie.
Les drapeaux d'acceptation synthétique (`--spec-synth-*`, « benchmarking
only »), que Loki ne pose jamais, déclenchent un avertissement au lancement.
- **Parallélisme de tenseurs** (clé `SPLIT_MODE=tensor`, **off** par défaut,
expérimental, moteur relancé) : au lieu de donner à chaque carte des couches
entières (`-sm layer`, défaut), chaque couche est coupée entre les cartes,
qui additionnent ensuite leurs morceaux. L'amont mesure un décodage plus
rapide mais un prefill nettement plus lent : sur deux GeForce en PCIe et une
boucle d'agent qui relit beaucoup, à mesurer par tour avant de l'adopter.
Loki n'active le mode que si l'aide du moteur liste `{none,layer,row,tensor}`
(le mot « tensor » seul est partout), et ajoute alors `-ngl all`, `-ts` au
prorata de la VRAM totale des cartes dans l'ordre du moteur (`--device`
compris), `-fa on` — chacun seulement s'il n'est pas déjà dans `EXTRA_ARGS`
ou une `LLAMA_ARG_*` — et `GGML_CUDA_ALLREDUCE=internal` si l'environnement
ne le fixe pas : le chemin NCCL compresse en BF16 les grosses réductions du
prefill, une perte de précision ; la réduction interne garde le type natif.
« NCCL not compiled in » au journal est alors sans effet ; `GGML_CUDA_P2P=1`
reste à poser soi-même si le pilote le permet. Pas de `--fit` en mode
tensor : Loki estime poids + cache de `CTX` jetons et refuse au-delà de 90 %
de la VRAM — jamais en réduisant `CTX`. Refusé aussi (découpe par couches,
note au journal) avec un cache KV autre que f16/bf16/f32 (jamais réécrit
d'office), des poids ou le cache KV sur CPU (`--n-cpu-moe`, `-ot`, `NGL`
partiel, `-nkvo`), `--backend-sampling`, `-fa off`, un `-sm` déjà réglé,
`SPEC`, `SIDE_SLOT`, `CTX` non chiffré, une seule carte ou une carte non
CUDA, une architecture que llama.cpp exclut (et `qwen4exp`), un preset
externe ; `SLOT_PERSIST` est refusé avec la clé.
- **Guide de placement entre cartes inégales** (éditeur de preset, groupe
« Cartes graphiques », dès deux cartes ; un conseil, rien n'est appliqué
d'office) : chaque carte CUDA affiche sa liaison PCIe maximale (génération,
largeur), l'actuelle et l'horloge mémoire en info-bulle — lues par
`nvidia-smi` en une requête, jointes par nom de carte (rien pour deux
cartes homonymes ni pour un moteur Vulkan), avec repli sur la requête
historique si un vieux pilote refuse un champ. `loki gpu` les affiche aussi.
Le guide rappelle les deux règles du moteur, dans l'ordre qu'il voit
(`--device` compris) : en placement auto, `--fit` remplit d'abord la
**dernière** carte, qui porte la couche de sortie (modèle dense : la plus
rapide en dernier, ou une marge `FIT_TARGET` plus large sur la lente) ; les
experts MoE en RAM sont recopiés au prefill vers la **première** (à elle la
liaison la plus large). Les marges `FIT_TARGET` du preset sont montrées carte
par carte ; un `--tensor-split` (qui désactive `--fit`) ou un
`CUDA_VISIBLE_DEVICES` propre au preset (ordre réel différent) est signalé.
Un lien « inverser l'ordre » écrit `--device` (et retourne `--tensor-split`)
dans le preset édité. Pas de bouton de mesure automatique : comparer deux
ordres demande deux rechargements du moteur et un ordre inversé peut manquer
de VRAM sur la petite carte — dupliquer le preset, inverser l'ordre dans la
copie, « bench complet » sur chacun, et vérifier `offloaded N/N layers`.
- **MoE aux experts sur CPU : avis et copie en placement auto** (jamais de
réécriture du preset). Au lancement (journal) et dans l'éditeur, sous
« Experts MoE sur CPU », pour un modèle dont le GGUF annonce des experts :
experts placés à la main (`-ot` visant `…exps…` vers le CPU, `--n-cpu-moe`,
`--cpu-moe` — un `-ot` de la seule couche d'entrée `per_layer_token_embd` ne
compte pas) → `--fit` ne les place pas, et monter `UBATCH` seul grossirait
le tampon de calcul de chaque carte sans personne pour rééquilibrer (monter
`--n-cpu-moe` d'abord) ; placement auto avec un modèle plus gros que la VRAM
et `UBATCH` ≤ 512 → « UBATCH 2048+ recommandé » (les experts restés en RAM
sont recopiés vers le GPU à chaque micro-lot du prefill) ; moteur sans
`--fit` → « garde `--n-cpu-moe` » ; `--fit` coupé par un autre `-ot` → rien
ne sort les experts du GPU. Le bouton **« Dupliquer en placement auto… »**
crée une **copie** du preset affiché, sous « (placement auto) », sans la
bascule : `-ot` des experts (et celui de `per_layer_token_embd`, redondant :
llama.cpp garde toujours la couche d'entrée au CPU), `--n-cpu-moe`,
`--cpu-moe`, `-ngl`, `--tensor-split`, `--fit off`, `-b`/`-ub` et drapeaux
de chargement retirés ; `NGL` retiré (`-ngl auto`), `UBATCH=2048`,
`BATCH=4096`, `--load-mode mmap` ; `CTX`, cache KV (quantifié ou non : à
garder identique pour comparer), échantillonnage intacts. Refusé si la copie
ne tournerait pas en `--fit` (autre `-ot`, `-sm row/tensor`, `SPLIT_MODE`,
`--n-cpu-ffn`) ou si `CTX` vaut 0. Après bascule : `successfully fit params`
au journal (`failed to fit` = tous les experts sur GPU, revenir à
l'original), puis bench complet des deux.
- **Garde du mode de chargement** (clé d'échappement `LOAD_GUARD=off`, dans
le preset) : `--load-mode none`, `mlock`, `dio`, `mmap+mlock` (ou
`--no-mmap`, `--mlock` sur un moteur ancien, ou leurs `LLAMA_ARG_*`) gardent
en RAM tous les poids laissés au CPU. Loki en estime la part — modèle
(toutes tranches) moins VRAM totale, tout le modèle sur un Mac Apple Silicon — face à la
RAM effective (limite du conteneur cgroup comprise) : avertissement au-delà
de 80 % (llama.cpp #26110 : swap, décodage de 25 à 7,5 t/s ; mlock : processus
tué) ; **refus** au lancement seulement quand la borne basse dépasse 90 %
(échec certain), avec la raison en clair, aussi affichée par l'interface.
Le refus sort avec le code 78, que l'unité systemd écrite par
`loki install` ne relance pas (`RestartPreventExitStatus=78`) ; une unité
d'une version précédente (ou launchd) reçoit une sortie sans erreur pour ne
pas boucler — `sudo systemctl edit loki-engine` (section `[Service]`,
`RestartPreventExitStatus=78`) met l'unité à jour.
La VRAM qui fonde un refus est celle des cartes que le moteur liste
lui-même (`--list-devices`, lu seulement quand un refus se profile sur la
VRAM NVIDIA) : un moteur Vulkan sur cartes mixtes NVIDIA + AMD compte les
deux. Liste illisible, VRAM inconnue (Mac Intel, AMD seule, `--device`) ou
serveurs `--rpc` : avertissement, jamais de refus. `mmap` et `auto` ne sont jamais concernés ;
`LLAMA_ARG_NO_MMAP`/`LLAMA_ARG_MLOCK` ne comptent que sur un moteur ancien
(un moteur à `--load-mode` les ignore). Rien ne change dans la ligne de
commande.
- **Optimiseur sans perte** (`loki tune`, bouton **« Optimiser… »** dans
l'éditeur du preset en service ; rien ne tourne de soi-même, aucune clé de
config). Cherche, pour cette machine et ce build du moteur, les réglages
d'ordonnancement qui raccourcissent un tour de conversation, sans rien changer
à ce que calcule le modèle (sortie équivalente en distribution : les sommes
flottantes ne sont pas identiques au bit près d'un lot ou d'un placement à
l'autre ; la spéculation vérifie chaque jeton). **Isolation** : le vrai moteur
est arrêté (comme « décharger la VRAM »), chaque essai est une **copie** de la
configuration avec ses surcharges dans `LOKI_HOME/tune/run/` — `config.env`
n'est jamais écrit — lancée par `loki serve` sur `127.0.0.1` et un port libre,
mêmes `CUDA_VISIBLE_DEVICES` ; son groupe de processus est tué en sortie
quoi qu'il arrive, puis le vrai moteur est relancé. **Verrou** exclusif entre
processus (`LOKI_HOME/tune.lock`, PID + instant de démarrage + binaire) :
pendant la mesure, tout démarrage du moteur (`serviceAction`, toutes
plateformes), bascule ou enregistrement de preset, choix des GPU, clé d'API,
mise à jour ou recompilation du moteur, rechargement de la VRAM, bench, chat,
compaction, `bash_bg` et clients `/v1` (503) sont refusés avec une phrase
claire ; les tâches planifiées attendent. Un verrou dont le propriétaire est
mort (Loki tué en plein essai) est écarté avant tout démarrage du moteur :
l'essai orphelin n'est arrêté que si PID, instant de démarrage ET binaire
concordent (`taskkill /T` sous Windows), et le moteur est relancé s'il
tournait avant — au démarrage de l'interface, puis toutes les 30 s par le
processus web (un `stat` tant qu'aucun verrou n'existe) : un `loki tune` tué
par `kill -9` ne laisse plus le moteur arrêté. **Refus d'entrée** : preset
externe, aucun preset actif, génération, tâche, bench ou job `bash_bg` en
cours, moteur occupé (`/slots`) — en ligne de commande, seul ce dernier
contrôle voit le processus web : préférer le bouton. En ligne de commande
aussi, un moteur qui ne serait pas celui de ce `LOKI_HOME` (il répond alors
que ce dossier le dit arrêté, refuse sa clé d'API ou sert un autre `MODEL` :
typiquement `sudo loki tune`, qui perd un `LOKI_HOME` exporté) fait
refuser, avec la marche à suivre. **Essais** (descente étape par étape depuis
la meilleure configuration du moment, chaque axe seulement si l'aide du
moteur et la machine le permettent) : placement — seulement avec « inclure
le placement » / `--placement` : `--fit` à la place des experts placés à la
main (`-ot …exps…`, `--n-cpu-moe`, `--cpu-moe`) ou d'un `--tensor-split` ;
marges `FIT_TARGET` (2 cartes ou plus, `--fit-target` dans l'aide) ;
`UBATCH` ∈ {512, 1024, 2048, + 4096 pour un MoE} × `BATCH` ∈ {max(2048, ub),
2 × ub ≤ 8192}, lot > micro-lot sur 2 cartes tout GPU, jamais de micro-lot
plus grand sans `nvidia-smi` ; `OP_OFFLOAD_MIN_BATCH` ∈ {32, 128, 512} et
threads (omis = cœurs physiques, physiques − 1, moitié ; Linux) seulement
avec des poids sur CPU ; `CUDA_LAUNCH_QUEUES` off/4x sur 2 cartes ou plus ;
avec « inclure les options opt-in » / `--opt-in` : `SPEC=auto`,
`SPEC_N_MAX` ∈ {2, 3, 4}, `CUDA_GRAPH_OPT=on`, `--backend-sampling`. Un
réglage qu'`EXTRA_ARGS` écraserait (`-ub`, `-t`, `-fitt`…) est réglé **dans
EXTRA_ARGS, sur place** ; EXTRA_ARGS est traité jeton par jeton et seuls les
drapeaux de la liste blanche (`-ot` des experts, `-ncmoe`, `-cmoe`, `-sm`,
`-ts`, `-mg`, `-fit`, `-fitt`, `-b`, `-ub`, `-t`, `-tb`, + `--spec-draft-n-max`
et `-bs` en opt-in) bougent ; tous les autres jetons (`--cache-type-k`,
`--flash-attn`, `--chat-template-file`…) ressortent à l'octet près. **Jamais
touchés** : `MODEL`, `CTX`, `KV_TYPE*` (un cache déjà quantifié est signalé
zone grise, jamais modifié), `MMPROJ`, `REASONING*`, `TEMP` et autres clés
d'échantillonnage, `PARALLEL`, `NGL`, `SPLIT_MODE`, `CUDA_VISIBLE_DEVICES`.
**Garde-fous** : chaque essai est d'abord composé à blanc (`loki serve` sans
rien charger) et sa ligne de commande comparée à celle de référence hors
réglages permis — toute autre différence (contexte, cache KV, flash-attn,
slots, variable du moteur…) l'écarte comme « dénature » ; une fois chargé,
`n_ctx`, `total_slots` (`/props`) et les types du cache KV (journal) sont
recomparés ; moins de couches déportées que la référence = « fit en recul »
(performance), écarté ; moins de 768 Mio de VRAM libre sur une carte après la
mesure (+ 512 avec `MMPROJ`) = « trop juste », jamais retenu ; échec de
chargement ou OOM = essai ignoré ; deux essais à la même ligne de commande ne
sont mesurés qu'une fois. **Mesure** : le bench complet (prefill à froid à une
profondeur D fixe pour tous les essais, trois tours qui reprennent le cache —
points de reprise réels d'un hybride compris —, decode à D, ligne courte en
prose), avec l'échantillonnage et le raisonnement du preset. **Score** : durée
d'un tour type = K / prefill des tours + G / decode à D + f × D / prefill à
froid, K, G et f tirés des médianes de la télémétrie (`/api/perf/summary`)
quand elle en a vu assez, sinon 2000, 600 et 5 %. La référence est mesurée
deux fois ; un essai ne gagne que s'il bat la meilleure configuration du
moment de plus de max(3 %, écart entre passages) sur **deux** passages, sans
ralentir de plus de 5 % le decode en prose. Budget par défaut 30 min
(`--budget`), ETA et annulation ; au-delà, résultat partiel dit tel ; étapes de
base à la carte (`--stages lots,threads…`, cases de la fenêtre). **Résultat**
: les mesures de chaque essai et le diff des clés, enregistrés par preset
avec l'empreinte et le build du moteur (l'éditeur propose de relancer après
une mise à jour du moteur). **Rien n'est écrit sans un clic** : « enregistrer
dans une copie » (preset « (optimisé) », non activé) ou « appliquer à ce
preset » après confirmation du diff — et une confirmation de plus si
EXTRA_ARGS est réécrit par le placement. Le preset est sauvegardé
(`LOKI_HOME/tune/backup/`), réécrit clé par clé (commentaires et ordre
intacts), réappliqué ; la configuration active doit alors être exactement la
référence plus les clés réglées, le moteur redémarre et doit répondre avec le
même contexte et les mêmes slots puis tenir une sonde (prompt de D jetons et
decode) — sinon l'ancienne version est rétablie et le moteur relancé. Pendant
la mesure, le chat est indisponible : prévoir 10 à 40 minutes selon la taille
du modèle (un modèle relu depuis un disque lent recharge à chaque essai).
## Différences avec l'amont
| | AJEAN (amont) | Loki (ce fork) |
@@ -1139,7 +374,7 @@ Une étape à la fois, mesurée avant la suivante :
| Agent de code | — | mode Code : critères d'acceptation, passe de vérification, LSP, outils git |
| Accès distant | relais chiffré ajean.link | retiré de l'interface |
| Endpoint OpenAI | `:8080/v1` du moteur, ouvert par `network on` | `/v1` servi par Loki sur le port de l'interface, protégé par la clé API |
| Mise à jour | `ajean update` (binaire GitHub) | au redémarrage du conteneur (release `rolling`) ; `docker compose pull` si l'image change |
| Mise à jour | `ajean update` (binaire GitHub) | `docker compose pull` |
Le reste — mémoire, outils, protocole, moteur d'inférence — est celui d'AJEAN.
Pour récupérer les évolutions de l'amont :
@@ -1200,40 +435,6 @@ courant n'est pas touché. Le moteur livré par l'image reste par ailleurs intac
Quand cette limite est atteinte pour de bon (CUDA majeur trop ancien), la
solution reste le rebuild avec un `LLAMACPP_IMAGE` récent, ci-dessus.
**Version recommandée.** Quand le moteur officiel qui tourne est antérieur à
`b10864`, le panneau affiche un encart qui dit ce que la mise à jour apporte :
les points de reprise des modèles hybrides (Qwen3.5/3.6, Qwen3-Next) ne sont plus
évincés trop tôt — jusqu'à ~8 k jetons recalculés à chaque reprise —, le MTP
rapide (graphes CUDA) arrive à `b11009` et le MTP de qwen4exp à `b11331`. Le
décodage spéculatif reste coupé tant que `SPEC` n'est pas posé. L'encart se
décide sans réseau, sur le build du moteur (jamais pour un llama.cpp compilé ou
un fork, dont le numéro ne prouve rien). Le bouton **Mettre à jour vers la
version recommandée** n'interroge le registre qu'au clic : il prend le dernier
build publié, vérifie qu'il atteint `b10864` et que son tag figé
(`server-cuda-bNNNNN`) existe bien, puis demande confirmation. Sans réseau, il
le dit et n'installe rien. Rien n'est jamais mis à jour d'office, ni au
démarrage.
**Revenir à la version précédente.** Après une mise à jour, la version
téléchargée qui tournait juste avant est gardée (les plus anciennes partent,
~450 Mo chacune) ; le bouton **Revenir à la version précédente** y ramène sans
réseau, et la version quittée devient à son tour « la précédente ».
**Le rendu du raisonnement.** Depuis `b10763`, llama-server garde par défaut la
réflexion des tours passés dans le gabarit (`preserve_reasoning`). Un gabarit
qui la retirait de lui-même (Qwen3.6, gabarits à `clear_thinking`) la rend alors
— vide, puisque Loki ne la renvoie pas sans `REASONING_ECHO` : le modèle
relirait un historique où il n'a jamais réfléchi. Avant de basculer vers un tel
moteur, Loki lit le gabarit chargé et le verdict de la sonde de gabarit ; si le
prompt rendu va changer et que `REASONING_PRESERVE` n'est pas posé, la
confirmation l'explique et propose de poser `REASONING_PRESERVE=off` dans le
preset actif (case cochée seulement quand le changement est établi :
sur un gabarit qui garde déjà la réflexion, comme Qwen3.8, `off` changerait à
son tour le rendu). L'utilisateur choisit. Après la bascule, le rendu de
quelques conversations synthétiques est comparé, via `/apply-template`, entre
l'ancien et le nouveau moteur une fois le modèle chargé : le panneau dit s'il
est identique, ou montre le premier écart.
Derrière un miroir de registre ou un réseau qui n'atteint pas ghcr.io :
`LOKI_OCI_REGISTRY=https://mon-miroir.interne` dans l'environnement du conteneur.
+34 -130
View File
@@ -1,149 +1,53 @@
# Loki 0.15.0
# Loki 0.12.0
Une version entièrement consacrée à la vitesse, avec une règle tenue de bout en
bout : **ne jamais dénaturer le modèle**. Rien ne touche à la quantification
des poids, à l'échantillonnage, à la température, au budget de raisonnement ni
au contenu du contexte. Ce qui est sans perte est actif d'office ; tout ce qui
changerait ce que voit le modèle, le placement des poids ou les slots du moteur
attend une clé. Sans clé, la requête et la ligne de commande restent celles
d'avant.
Le sélecteur de modèle devient un vrai sélecteur, la dictée vocale arrive, et
les cartes de raisonnement suivent l'écriture au lieu d'envahir la page.
La section **Performance** du README rassemble toutes les clés, leur défaut et
leur prix.
## Le sélecteur de l'en-tête charge vraiment un modèle
## Actif d'office, sans perte
Deux groupes : « Presets » et « Modèles (.gguf) » — tous les fichiers de
`/models` et `/data/models`. Choisir un modèle le charge (seul `MODEL` change,
contexte/NGL/échantillonnage conservés) et redémarre le moteur. Sans preset
créé, le sélecteur n'offrait aucun choix.
**La conversation n'est plus recalculée pour rien.**
## Dictée vocale
- **Cache de prompts dimensionné** : le moteur garde en RAM une copie exacte des
conversations qu'il quitte. Son défaut ne tenait pas une conversation de 30 à
65 k jetons à côté d'un vérificateur ou d'un sous-agent : il l'évinçait, et
tout était recalculé (30 à 80 s sur un 27B). Il est maintenant agrandi pour un
modèle tout-GPU, et le slot est effacé après chaque travail annexe pour que la
conversation revienne du cache.
- **Préfixe stable** : les rappels de Loki entrent dans l'historique tels
qu'envoyés, la relance après un 500 ne change plus les outils, la ligne MCP ne
manque plus au premier tour après un démarrage, l'ordre des trackers ne bouge
plus. Chacun de ces écarts faisait recalculer toute la boucle d'outils.
- **Compaction moins tôt** : le raisonnement que Loki ne renvoie jamais ne
compte plus dans le contexte. La compaction (qui perd de l'information)
partait vers 44-47 k au lieu de 49 k sur une fenêtre de 65 k. Son résumé n'est
plus amputé de l'état d'avancement.
- **Hybrides (Qwen3.5/3.6) sur un moteur ancien** : `--checkpoint-min-step 2048`
d'office quand la RAM le permet, pour ne plus retomber jusqu'à ~8 k jetons en
arrière à chaque reprise.
Un bouton micro dans la carte de saisie : tu parles, le texte s'écrit. La
transcription est 100 % locale — whisper.cpp compilé dans l'image, modèle
multilingue téléchargé au premier usage (~190 Mo, dans `/data/whisper/`).
Le navigateur exige HTTPS (ou localhost) pour donner accès au micro.
**Le matériel est mieux employé.**
## Cartes raisonnement/outils : taille fixe, défilement qui suit
- **Threads CPU** : « auto » veut dire les cœurs physiques, plus tous les
threads logiques — c'est le décodage des experts MoE sur CPU qui payait.
- **Deux GPU** : file de lancements CUDA élargie quand le pipeline entre cartes
est possible (même calcul ; seul le prompt peut gagner).
Hauteur bornée (280 px) avec défilement interne collé au texte pendant la
génération — remonter lire le début est respecté. Les cartes d'outil suivent
la ligne en cours d'écriture puis remontent en tête une fois l'appel terminé.
Sur un raisonnement géant, seul le bas du bloc est re-rendu en direct (le
texte complet est posé à la fin) : l'affichage ne se fige plus.
**Loki lui-même pèse moins.**
## Mesures
- Écrire un gros fichier ne coûte plus un temps quadratique côté Go (jusqu'à
8 s de CPU volées au décodage sur un MoE).
- L'enregistrement de la discussion ne retarde plus le premier jeton.
- Un seul `nvidia-smi` pour tous les onglets, aucun pour un onglet caché, un
seul à l'ouverture de l'éditeur de preset.
- Unraid : un `/data` ou `/models` servi par la couche FUSE est signalé, avec
le remède.
- La carte du temps total affiche la **vitesse moyenne de la conversation**
(« en cours — 12 s · moy 21.3 tok/s »), rejouée au rechargement.
- Le **pourcentage de chargement** du modèle bouge aussi sur un redémarrage à
chaud (modèle déjà dans le cache disque : on suit la mémoire résidente, plus
seulement les octets lus).
**On peut enfin mesurer.**
## Interface
- Chaque réponse dit ce qu'elle a repris du cache, recalculé et accepté du
brouillon ; `GET /api/perf/summary` en fait la synthèse par nature de requête
(`lost_events`, `lost_tokens` : le cache perdu, et après quoi).
- **Bench** honnête, en tâche de fond : mode complet à profondeur réelle, tours
qui reprennent le cache, rien d'enregistré sans timings réels.
- Une sonde du gabarit de chat dit, sans rien envoyer au modèle, si le rendu
reste stable d'un message à l'autre.
- Discussions triées par date de **création** (récentes en tête) : une
discussion garde sa place, écrire dans un vieux fil ne le fait plus remonter.
- Bouton **Réglages** ancré en pied de barre latérale, au-dessus du moniteur
Performance.
**Garde-fous.** Un cache KV quantifié, `--context-shift` ou `--cache-reuse`
posés à la main sont signalés. Un mode de chargement résident voué à l'échec
(poids restés en RAM au-delà de 90 %) est refusé avec la raison en clair —
`LOAD_GUARD=off` pour passer outre ; le refus n'est plus relancé en boucle par
systemd, et un moteur Vulkan sur cartes mixtes n'est plus refusé à tort.
## Rappel 0.11.0
## En opt-in : à essayer, à mesurer
| Clé | Ce qu'elle fait |
|---|---|
| `PREWARM=on` | prépare le prochain tour pendant que tu lis (`full` : aussi la discussion qu'on ouvre) |
| `COMPACT_CONTINUATION=on` | le résumé de compaction prolonge le prompt en cache au lieu d'être calculé à froid |
| `PROJ_SNAPSHOT=on` | bloc projet figé par discussion, changements livrés à part ; date et dossier sortent du système |
| `REASONING_ECHO=on` | renvoie au moteur local la réflexion du modèle, au format entraîné |
| `SPEC=auto` / `mtp` | décodage spéculatif MTP (tête détectée dans le fichier, garde-fous VRAM) |
| `SPEC=ngram` / `mtp+ngram` | n-grammes du contexte, utiles en mode Code |
| `SIDE_SLOT=on` | second slot pour les travaux annexes (VRAM en double) |
| `SLOT_PERSIST=on` | état du slot gardé à la bascule de preset |
| `SPLIT_MODE=tensor` | parallélisme de tenseurs entre cartes (expérimental) |
| `FIT_TARGET`, `OP_OFFLOAD_MIN_BATCH`, `CUDA_GRAPH_OPT` | réglages d'expert pour cartes inégales et MoE |
| `KEEP_TURN_IMAGES=on` | **zone grise** : garde les images des outils dans l'historique |
| `NUDGE_IN_TOOL=on` | **zone grise** : rappel de budget dans le résultat d'outil |
Et deux outils qui ne font rien d'eux-mêmes :
- **Optimiseur** (`loki tune`, bouton « Optimiser… ») : cherche lots, threads,
marges et placement sur un moteur d'essai isolé ; rien n'est écrit sans un
clic, et l'application est vérifiée puis défaite si le moteur ne tient pas.
- **« Dupliquer en placement auto… »** pour un MoE aux experts placés à la
main : une copie du preset où `--fit` les répartit.
## Ordre de test conseillé
Une étape à la fois, mesurée avant la suivante :
1. **Bench complet** du preset tel quel : la référence.
2. **`PREWARM` + `COMPACT_CONTINUATION`**, une vraie session de travail, puis
`/api/perf/summary` : le cache perdu (`lost`) doit baisser.
3. **Optimiseur** sur le preset en service.
4. **MoE** : « Dupliquer en placement auto… », bench complet des deux.
5. **Mise à jour du moteur** vers la version recommandée (encart du panneau
Moteur).
6. **`SPEC=auto`** (ou `mtp`) si le modèle a une tête MTP.
## Moteur : version recommandée et retour arrière
Quand le moteur officiel est antérieur à `b10864`, le panneau Moteur dit ce
qu'une mise à jour apporte (points de reprise des hybrides, MTP rapide) et la
propose — sur clic seulement, version vérifiée sur le registre. La version
quittée est gardée : **Revenir à la version précédente** y ramène sans réseau.
Depuis `b10763`, llama-server garde par défaut la réflexion des tours passés :
un gabarit comme Qwen3.6 la rendrait vide. Avant de basculer, Loki le détecte et
propose `REASONING_PRESERVE=off` ; après, il compare le rendu de l'ancien et du
nouveau moteur et signale le premier écart.
## Ce qui a été écarté
- **`--cache-reuse`** : réutilise du cache calculé sous un autre contexte, donc
change les sorties.
- **`--context-shift`** : retire des jetons du contexte, et ne marche pas sur
les hybrides.
- **Cache KV quantifié par défaut** : `q8_0` n'est pas exact, `q4_0` perd
mesurablement, pour ~2 Gio gagnés sur un 27B hybride. Reste un choix
explicite (`KV_TYPE`).
## Autres corrections
- Une compaction en plein tour ne fait plus disparaître le rappel des pages
mémoire lues.
- Un `loki tune` tué net ne laisse plus le moteur arrêté ; lancé avec un autre
`LOKI_HOME` que l'interface (`sudo`), il refuse au lieu de charger un essai à
côté d'un moteur vivant.
- L'ancienne option « n-grammes (mod) » de l'éditeur rejoint `SPEC=ngram`, avec
une migration proposée (jamais faite d'office).
- Accès refusé : l'interface dit pourquoi et comment le lever.
Réglages en modale à deux volets ; nom du modèle sur chaque réponse
(journalisé, il survit au rechargement) ; jeton Hugging Face réglable dans
l'interface ; tâches planifiées reprises de l'amont.
## Mise à jour
```
docker compose pull && docker compose up -d
```
Installation systemd (`loki install`) : l'unité du moteur gagne
`RestartPreventExitStatus=78`. `loki update` ne réécrit pas les unités ;
`sudo loki install`, ou `sudo systemctl edit loki-engine` avec cette ligne dans
`[Service]`, la met à jour.
Binary file not shown.
Binary file not shown.
+6 -6
View File
@@ -2,14 +2,14 @@
"FixedFileInfo": {
"FileVersion": {
"Major": 0,
"Minor": 15,
"Patch": 0,
"Minor": 12,
"Patch": 3,
"Build": 0
},
"ProductVersion": {
"Major": 0,
"Minor": 15,
"Patch": 0,
"Minor": 12,
"Patch": 3,
"Build": 0
},
"FileFlagsMask": "3f",
@@ -25,7 +25,7 @@
"LegalCopyright": "Copyright (c) 2026 Loki contributors. MIT License.",
"OriginalFilename": "loki.exe",
"ProductName": "Loki",
"ProductVersion": "0.15.0",
"ProductVersion": "0.12.3",
"Comments": "https://github.com/R0m1k3/Loki — projet open source (MIT)"
},
"VarFileInfo": {
@@ -34,4 +34,4 @@
"CharsetID": "04B0"
}
}
}
}
+2 -45
View File
@@ -16,10 +16,8 @@
# Les modèles se téléchargent directement depuis l'UI (catalogue intégré),
# ou dépose tes .gguf dans /mnt/user/appdata/loki/models.
#
# Mise à jour : AUTOMATIQUE au redémarrage du conteneur (binaires Loki de
# la release « rolling », vérifiés SHA-256 ; LOKI_AUTO_UPDATE=0 pour couper).
# Si le journal demande une nouvelle image : Compose Down puis Up avec
# « pull image », ou « docker compose pull » avant Up.
# Mise à jour : Compose Down puis Up avec « pull image », ou
# « docker compose pull » avant Up.
# ───────────────────────────────────────────────────────────────────────────
services:
loki:
@@ -36,50 +34,9 @@ services:
# lancement). Ex. LOKI_MODEL=Qwen3-14B-Q4_K_M.gguf après dépôt du
# fichier dans /mnt/user/appdata/loki/models.
- LOKI_MODEL=
# Accès par NOM DE DOMAINE (reverse proxy, ex. loki.mondomaine.fr) sans
# clé de pilotage : liste ici le(s) nom(s), séparés par des virgules —
# sinon l'API répond 403 (protection contre les sites tiers / DNS
# rebinding). Inutile pour un accès par IP ou nom local.
- LOKI_TRUSTED_HOSTS=
# Conversation tenue par la façade Rust (défaut). « go » la rend au Go si
# quelque chose ne va pas — puis Compose Down / Up.
# - LOKI_CHAT=go
volumes:
# PERFORMANCE — /mnt/user/… passe par la couche FUSE des partages
# Unraid (shfs) : chaque écriture de la base et chaque page d'un gros
# modèle relue depuis le disque (modèle plus gros que la RAM) traverse un
# démon en espace utilisateur. Loki le signale au démarrage (encart
# d'information, rien n'est perdu). Deux remèdes, au choix :
# - Recommandé (Unraid 6.12+) : partages en « Exclusive access ».
# Ce n'est pas une case mais un état : Settings -> Global Share
# Settings -> « Permit exclusive shares » = Yes, puis le partage
# appdata (et celui des modèles) entièrement sur UN pool — mover
# d'abord vers le pool, ensuite Secondary storage = None. La page du
# partage affiche alors « Exclusive access : Yes ». Redémarre le
# conteneur : le chemin /mnt/user reste le même, FUSE est
# court-circuité.
# - Avancé : mapper /mnt/<ton-pool>/appdata/loki/… (ex. /mnt/cache si
# ton pool s'appelle « cache »). Migration MANUELLE obligatoire :
# Compose Down, rsync -a de l'ancien dossier vers le nouveau, puis
# changer les deux lignes ci-dessous. Un /mnt/<pool> qui n'existe
# pas atterrit dans la RAM d'Unraid : données perdues au redémarrage,
# et un modèle de 80 Go peut saturer la mémoire du serveur.
# Ne change JAMAIS ce chemin sans migrer : /data apparaîtrait vide.
# /data : config, base, mémoire, workspace, modèles téléchargés par l'UI
- /mnt/user/appdata/loki/data:/data
# /models : dossier de .gguf déposés à la main (gros fichiers)
- /mnt/user/appdata/loki/models:/models
# Strata sur un SSD dédié (optionnel) : tout ce que Strata installe
# (moteur, Python, modèle de 66 à 84 Go, experts lus en mmap) va dans
# /data/strata. Monter un SSD à cet endroit l'y envoie entier. Le SSD
# doit être un pool Unraid (Main -> Add Pool, ex. « strata ») : chemin
# /mnt/<pool>/…, PAS /mnt/user/… (FUSE ralentit le mmap des experts).
# Vérifie que /mnt/strata existe avant Compose Up : sinon Docker crée
# le dossier dans la RAM d'Unraid. Détails : README, « Strata sur un
# SSD dédié ».
# - /mnt/strata/loki:/data/strata
# Moteur Strata (Qwen3.8 Flash Next) : il verrouille ses experts en RAM
# (mlock, 30 à 50 Go). La limite Docker par défaut (64 Ko) l'en empêche.
ulimits:
memlock: -1
restart: unless-stopped
-7
View File
@@ -22,9 +22,6 @@ services:
- LOKI_MODEL=${LOKI_MODEL:-}
- LOKI_CTX=${LOKI_CTX:-}
- LOKI_NGL=${LOKI_NGL:-}
# Accès par nom de domaine (reverse proxy) sans clé de pilotage : nom(s)
# autorisé(s), séparés par des virgules — sinon l'API répond 403.
- LOKI_TRUSTED_HOSTS=${LOKI_TRUSTED_HOSTS:-}
volumes:
# /data = LOKI_HOME : config, base bbolt, mémoire, workspace, modèles téléchargés
- ./data:/data
@@ -37,8 +34,4 @@ services:
- driver: nvidia
count: all
capabilities: ["gpu"]
# Moteur Strata (Qwen3.8 Flash Next) : il verrouille ses experts en RAM
# (mlock, 30 à 50 Go). La limite Docker par défaut (64 Ko) l'en empêche.
ulimits:
memlock: -1
restart: unless-stopped
+3 -39
View File
@@ -1,7 +1,6 @@
#!/bin/sh
# Entrypoint du conteneur Loki : met à jour les binaires Loki, sème la
# configuration au premier démarrage, lance le moteur (supervision PID, sans
# systemd), puis l'UI au premier plan derrière la façade Rust (loki-rs).
# Entrypoint du conteneur Loki : sème la configuration au premier démarrage,
# lance le moteur (supervision PID, sans systemd), puis l'UI au premier plan.
set -eu
: "${LOKI_HOME:=/data}"
@@ -16,33 +15,6 @@ if ! grep -qs " $LOKI_HOME " /proc/self/mountinfo; then
echo "[entrypoint] Ajoute « -v /chemin/hote/data:/data » (docker run) ou le mapping volumes du docker-compose."
fi
# ── Mise à jour au démarrage ───────────────────────────────────────────────
# Le loki-rs DE L'IMAGE télécharge la dernière release « rolling » (binaires
# vérifiés par SHA-256) dans $LOKI_HOME/bin/<build>/ : un simple redémarrage
# du conteneur suffit à passer à la dernière version. C'est toujours celui de
# l'image qui s'en charge — un binaire publié défectueux ne peut donc pas
# empêcher le correctif suivant d'arriver. Jamais bloquant (réseau coupé,
# empreinte fausse : l'état précédent reste). LOKI_AUTO_UPDATE=0 le coupe.
/usr/local/bin/loki-rs self-update || true
# loki et loki-rs viennent TOUJOURS du même dossier : /data/bin/<build> s'il
# est plus récent que l'image et de même ABI, sinon l'image.
BIN_DIR=$(/usr/local/bin/loki-rs which-bin 2>/dev/null || true)
if [ -z "$BIN_DIR" ] || [ ! -x "$BIN_DIR/loki" ] || [ ! -x "$BIN_DIR/loki-rs" ]; then
BIN_DIR=/usr/local/bin
fi
echo "[entrypoint] binaires Loki : $BIN_DIR"
# Tous les appels « loki » ci-dessous visent le binaire choisi.
loki() { "$BIN_DIR/loki" "$@"; }
# Et `docker exec loki loki …` aussi : /usr/local/sbin passe AVANT
# /usr/local/bin dans le PATH, un lien y suffit sans toucher aux binaires de
# l'image (celui de loki-rs doit rester intact, c'est lui qui met à jour).
if [ "$BIN_DIR" != /usr/local/bin ]; then
ln -sf "$BIN_DIR/loki" /usr/local/sbin/loki 2>/dev/null || true
else
rm -f /usr/local/sbin/loki 2>/dev/null || true
fi
# Le chemin du moteur est imposé par l'image (llama-server précompilé de
# l'image officielle llama.cpp, dans /app) : on le (re)pose à chaque boot,
# une mise à jour de l'image ne doit pas laisser un BIN obsolète en base.
@@ -87,12 +59,4 @@ else
echo "[entrypoint] aucun MODEL configuré — télécharge un modèle depuis l'UI pour démarrer le moteur"
fi
# Façade Rust (loki-rs) sur le port public ; elle lance `loki web` sur
# 127.0.0.1:${LOKI_GO_PORT:-8091} et lui relaie tout. LOKI_FACADE=0 revient à
# l'ancien schéma (Go seul, directement sur le port public) — porte de sortie
# si la façade pose problème.
export LOKI_WEB_PORT="${LOKI_WEB_PORT:-8090}"
if [ "${LOKI_FACADE:-1}" = "0" ]; then
exec "$BIN_DIR/loki" web "$LOKI_WEB_PORT"
fi
exec "$BIN_DIR/loki-rs" serve
exec loki web "${LOKI_WEB_PORT:-8090}"
@@ -1,122 +0,0 @@
# Migration Rust lot 1 — façade + mise à jour au démarrage — Implementation Plan
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** Mettre un binaire Rust `loki-rs` en frontal du conteneur (proxy vers le Go) et faire que le conteneur prenne les derniers binaires publiés à chaque démarrage.
**Architecture:** `loki-rs serve` écoute sur `LOKI_WEB_PORT` (8090), lance `loki web 8091` (Go, `LOKI_WEB_BIND=127.0.0.1`) en enfant supervisé et lui proxifie tout (HTTP, SSE, WebSocket). `loki-rs self-update` télécharge la release GitHub `rolling` dans `/data/bin/<build>/`, `loki-rs which-bin` dit à l'entrypoint quel dossier de binaires utiliser.
**Tech Stack:** Rust 2021 (tokio, hyper 1, hyper-util, http-body-util, reqwest rustls, sha2, serde_json), cible `x86_64-unknown-linux-musl` ; Go 1.25 existant ; GitHub Actions.
Spec : `docs/superpowers/specs/2026-10-13-rust-migration-lot1-design.md`.
## Global Constraints
- Port public inchangé : 8090 (`LOKI_WEB_PORT`). Go interne : `127.0.0.1:8091` (`LOKI_GO_PORT`).
- Release : tag `rolling`, pré-release, assets `loki-linux`, `loki-rs-linux`, `SHA256SUMS`, `manifest.json` (uploadé en dernier).
- `manifest.json` = `{"build": <github.run_number>, "abi": <IMAGE_ABI>, "commit": "<sha>"}`.
- Source unique de l'ABI : fichier `IMAGE_ABI` à la racine (contenu `1`), copié dans l'image en `/etc/loki/IMAGE_ABI`. Build de l'image : `ENV LOKI_BUILD=<run_number>` (0 en build local).
- Les binaires de `/data/bin/<build>/` ne sont utilisés que si `abi == IMAGE_ABI` **et** `build > LOKI_BUILD`.
- `LOKI_AUTO_UPDATE=0` : aucune requête réseau. Le boot n'est jamais bloqué (code 0, timeouts bornés).
- La façade efface `X-Loki-Via` entrant ; transmet `Host`, `Authorization`, `Cookie`, `Origin`, `Sec-Fetch-*`, `X-Forwarded-Proto` intacts ; retire les hop-by-hop ; ne bufferise rien.
- Go : `gofmt -l .` vide + `staticcheck ./...` propre. Rust : `cargo fmt --check`, `cargo clippy --all-targets -- -D warnings`, `cargo test`.
- Git : jamais `git add -A` ni `git stash` ; commits en français, style narratif.
## File Structure
```
IMAGE_ABI ABI d'image (entier)
rust/Cargo.toml workspace
rust/loki-rs/Cargo.toml
rust/loki-rs/src/main.rs CLI : serve | self-update | which-bin | version
rust/loki-rs/src/proxy.rs proxy HTTP/SSE/WS vers le Go
rust/loki-rs/src/child.rs supervision de `loki web`
rust/loki-rs/src/update/mod.rs orchestration self-update + which-bin
rust/loki-rs/src/update/sums.rs parse SHA256SUMS
rust/loki-rs/src/update/state.rs release.json, décision, sélection du dossier
rust/loki-rs/tests/proxy.rs intégration façade
rust/loki-rs/tests/update.rs intégration self-update (faux serveur)
internal/loki/web_server.go LOKI_WEB_BIND
Dockerfile étape rustbuild, IMAGE_ABI, LOKI_BUILD
docker-entrypoint.sh self-update, which-bin, exec loki-rs serve
.github/workflows/ci.yml job rust
.github/workflows/docker-publish.yml LOKI_BUILD + job rolling
README.md section mise à jour au démarrage
```
---
### Task 1: Go — adresse d'écoute configurable
**Files:** Modify `internal/loki/web_server.go` (cmdWeb), Test `internal/loki/web_bind_test.go`
**Interfaces:** Produces `webBindAddr(port int) string` — `LOKI_WEB_BIND` (défaut `0.0.0.0`) + `:port`.
- [ ] Test : `t.Setenv("LOKI_WEB_BIND","")` → `"0.0.0.0:8090"` ; `"127.0.0.1"` → `"127.0.0.1:8091"`.
- [ ] `go test ./internal/loki -run TestWebBindAddr` → FAIL (indéfini).
- [ ] Implémenter, l'utiliser dans `cmdWeb` à la place de `fmt.Sprintf("0.0.0.0:%d", port)`.
- [ ] Test PASS, `gofmt -l .` vide, staticcheck propre. Commit.
### Task 2: Workspace Rust + parse SHA256SUMS + état d'installation
**Files:** Create `rust/Cargo.toml`, `rust/loki-rs/Cargo.toml`, `src/main.rs` (squelette CLI), `src/update/{mod,sums,state}.rs`, `.gitignore` (`rust/target/`).
**Interfaces (produits) :**
- `sums::parse(text:&str) -> HashMap<String,String>` (nom → hex minuscule ; accepte `*nom`, ignore lignes vides/invalides).
- `state::Manifest { build:u64, abi:u32, commit:String }` (serde).
- `state::Installed` = Manifest écrit dans `<dir>/release.json`.
- `state::read_installed(dir:&Path) -> Option<Manifest>`.
- `state::select_dir(data_bin:&Path, image_dir:&Path, image_build:u64, image_abi:u32) -> PathBuf` : `<data_bin>/<build>` si release.json valide, abi égal, build > image_build, et `loki` + `loki-rs` présents dedans ; sinon `image_dir`.
- `state::needs_update(m:&Manifest, image_build:u64, image_abi:u32, installed:Option<&Manifest>) -> Decision` avec `enum Decision { Install, UpToDate, AbiMismatch }`.
- [ ] Tests unitaires : parse (2 espaces, `*`, majuscules → minuscules, ligne cassée ignorée) ; `needs_update` (abi ≠ → AbiMismatch ; build ≤ image → UpToDate ; build ≤ installé → UpToDate ; sinon Install) ; `select_dir` (aucun fichier → image ; abi ≠ → image ; build ≤ image → image ; binaire manquant → image ; ok → data dir).
- [ ] `cargo test` FAIL puis implémentation, PASS, fmt + clippy. Commit.
### Task 3: self-update (téléchargement vérifié, installation atomique)
**Files:** `src/update/mod.rs`, `tests/update.rs`
**Interfaces :** `update::run(cfg:&UpdateConfig) -> Outcome` (jamais d'erreur remontée au CLI : journalise et rend `Outcome::{Installed(u64), UpToDate, AbiMismatch, Failed(String), Disabled}`) ; `UpdateConfig { base_url, data_bin, image_build, image_abi, enabled, timeout }` construit par `UpdateConfig::from_env()` (`LOKI_UPDATE_URL` défaut `https://github.com/R0m1k3/Loki/releases/download/rolling`, `LOKI_HOME/bin`, `LOKI_BUILD`, `/etc/loki/IMAGE_ABI` puis `LOKI_IMAGE_ABI`, `LOKI_AUTO_UPDATE`).
Algorithme : manifeste → décision → `SHA256SUMS` → télécharge `loki-linux`, `loki-rs-linux` dans `<data_bin>/.tmp-<build>/` → vérifie chaque hash → chmod 755 (unix) → renomme en `loki`, `loki-rs` → écrit `release.json` → renomme le dossier en `<data_bin>/<build>` → écrit `<data_bin>/release.json` (tmp + rename) → supprime les autres `<n>/` et `.tmp-*`. Toute erreur : supprime `.tmp-<build>`, `Failed`.
- [ ] Tests d'intégration (faux serveur hyper sur 127.0.0.1:0 servant des fichiers en mémoire) : installation OK (fichiers présents, release.json, select_dir pointe dessus) ; hash faux → rien installé, ancien état intact ; abi ≠ → rien téléchargé hormis le manifeste ; serveur absent → Failed, pas de panic ; `enabled=false` → Disabled sans requête ; seconde exécution → UpToDate et nettoyage des anciens builds.
- [ ] FAIL → implémentation → PASS, fmt + clippy. Commit.
### Task 4: Façade proxy
**Files:** `src/proxy.rs`, `tests/proxy.rs`
**Interfaces :** `proxy::serve(listener: tokio::net::TcpListener, backend: SocketAddr, shutdown: impl Future) -> io::Result<()>`.
Comportement : hyper http1 + `with_upgrades` ; chaque requête → client hyper-util vers `backend` (URI `http://backend{path_and_query}`), Host d'origine conservé ; retire hop-by-hop (`connection`, `keep-alive`, `proxy-connection`, `te`, `trailer`, `transfer-encoding`, `upgrade`, + noms listés dans `Connection`) et `x-loki-via` ; si requête d'upgrade (`Connection: upgrade` + `Upgrade`), garde `Connection: upgrade` + `Upgrade`, attend 101, puis `copy_bidirectional` entre les deux côtés upgradés ; corps en streaming dans les deux sens ; erreur de connexion → 502 `text/plain` « Loki (Go) redémarre — réessaie dans un instant ».
- [ ] Tests (faux backend hyper) : GET relayé avec Host/Authorization/Origin/X-Forwarded-Proto intacts et `X-Loki-Via` absent côté backend ; POST corps relayé ; SSE : backend envoie 1er événement puis attend 500 ms — le client reçoit le 1er avant 300 ms ; WebSocket écho via tokio-tungstenite ; backend arrêté → 502.
- [ ] FAIL → implémentation → PASS, fmt + clippy. Commit.
### Task 5: Supervision du Go + commande `serve`
**Files:** `src/child.rs`, `src/main.rs`
**Interfaces :** `child::Supervisor::spawn(go_bin:PathBuf, port:u16) -> Supervisor` (relance avec backoff 1 s → 30 s, remis à 1 s après 60 s de vie ; stdin null, stdout/stderr hérités ; env `LOKI_WEB_BIND=127.0.0.1`) ; `Supervisor::shutdown(self, grace:Duration)` (SIGTERM unix / kill windows, puis kill après `grace`).
`main serve` : go_bin = `LOKI_GO_BIN` sinon `loki` à côté de l'exe courant ; attend jusqu'à 30 s que `127.0.0.1:LOKI_GO_PORT` accepte (sinon ouvre quand même, 502 en attendant) ; `proxy::serve` sur `0.0.0.0:LOKI_WEB_PORT` ; SIGTERM/SIGINT → arrêt du proxy + `shutdown(10 s)`.
`main which-bin` imprime `select_dir(...)` ; `main version` imprime version crate + `LOKI_BUILD`.
- [ ] Test unitaire `go_bin` par défaut ; test du backoff (fonction pure `next_backoff(prev, lived) -> Duration`).
- [ ] Test manuel local : `loki-rs serve` avec un `loki.exe` construit → `curl localhost:8090/` renvoie l'UI.
- [ ] Commit.
### Task 6: Image, entrypoint, CI, release rolling, doc
**Files:** `IMAGE_ABI`, `Dockerfile`, `docker-entrypoint.sh`, `.github/workflows/ci.yml`, `.github/workflows/docker-publish.yml`, `README.md`.
- Dockerfile : étape `FROM rust:1-alpine AS rustbuild` (`apk add musl-dev`, `cargo build --release --locked`), copie `/usr/local/bin/loki-rs` ; `ARG LOKI_BUILD=0` → `ENV LOKI_BUILD` ; `COPY IMAGE_ABI /etc/loki/IMAGE_ABI`.
- Entrypoint : `loki-rs self-update` (best-effort) ; `BIN_DIR=$(loki-rs which-bin)` ; `LOKI="$BIN_DIR/loki"` pour tous les appels config/start ; `exec "$BIN_DIR/loki-rs" serve`.
- CI : job `rust` (fmt, clippy, test sur ubuntu).
- docker-publish : `LOKI_BUILD=${{ github.run_number }}` en build-arg ; job `rolling` (contents: write, concurrency `rolling`) : build Go linux CGO=0, build Rust musl (`rustup target add x86_64-unknown-linux-musl`, `musl-tools`), SHA256SUMS, manifest, `gh release create rolling --prerelease` si absent, upload `--clobber` binaires+sums puis manifest.
- README : section « Mise à jour au démarrage » (comportement, `LOKI_AUTO_UPDATE=0`, ABI → pull).
- [ ] `sh -n docker-entrypoint.sh`, YAML relu, commit.
## Self-review
Spec couvert : façade (T4/T5), X-Loki-Via (T4), bind Go (T1), rolling + manifeste + ABI (T2/T3/T6), repli et boot non bloquant (T3/T6), désactivation (T3), tests (T2–T4), CI (T6). Écart assumé vs spec : ordre des versions par `build` (run_number) et non par commit, et installation dans `/data/bin/<build>/` (bascule atomique par `release.json`).
@@ -1,150 +0,0 @@
# Migration de Loki vers Rust — lot 1 : façade Rust + mise à jour au démarrage
Date : 2026-10-13
Statut : validé (design), à planifier
## Contexte
Loki est aujourd'hui un monobinaire Go (`internal/loki`, ~65 000 lignes hors
tests) qui sert une UI embarquée (~26 000 lignes JS/CSS/HTML) et pilote
llama.cpp, Strata, MCP, le mode Code, etc. L'objectif à terme est de le
réécrire en Rust. Une réécriture d'un bloc ne livrerait rien pendant des
semaines : la migration est **progressive**, par lots, avec Rust en production
dès le premier lot.
Second besoin : aujourd'hui, un `docker restart` relance la même image. Le
conteneur doit prendre la dernière version de Loki **au prochain démarrage**,
sans `docker compose pull` manuel.
## Architecture de cohabitation (« strangler fig »)
```
:8090 ──► loki-rs (Rust, axum/tokio)
├─ routes portées → traitées en Rust
└─ tout le reste → proxy HTTP / SSE / WebSocket
→ loki web (Go) sur 127.0.0.1:8091
```
- Nouveau workspace Cargo dans `rust/`, binaire `loki-rs`, compilé en statique
(`x86_64-unknown-linux-musl`) : indépendant de la glibc de l'image.
- Go écoute uniquement en local sur 8091 (nouvelle option d'adresse d'écoute
de `loki web`). Le port public reste 8090 : compose et reverse proxy
inchangés.
- Au lot 1, **aucune route n'est portée** : la façade proxifie tout. Elle sert
de socle aux lots suivants.
- Authentification (clé de pilotage, `LOKI_TRUSTED_HOSTS`) : reste à Go tant
qu'il sert la route. Go ne fonde aucune décision d'accès sur l'IP source
(vérifié : ni `RemoteAddr` ni `X-Forwarded-For` dans `internal/loki`), donc
passer par 127.0.0.1 n'élargit rien. La façade transmet `Host`,
`Authorization`, `Cookie`, `Origin`, `Sec-Fetch-*`, `X-Forwarded-Proto`
**sans les altérer**, et retire seulement les en-têtes hop-by-hop.
- **Sécurité** : la façade **efface `X-Loki-Via`** de toute requête entrante.
Cet en-tête, posé par le tunnel du relais, dispense du contrôle d'hôte
anti-DNS-rebinding (`crossSiteReject`). Aujourd'hui l'écoute HTTP directe de
Go ne l'efface pas : sans clé de pilotage, une page en DNS rebinding peut le
forger. La façade ferme ce trou dans le conteneur ; un correctif Go séparé
le ferme pour `loki web` hors conteneur. Test dédié.
- Flux : pas de timeout d'écriture, pas de bufferisation des réponses
`text/event-stream` (chat SSE, `/v1` en streaming), upgrade WebSocket relayé
(`/api/node/ws` et autres). Pas de recompression : Go gère déjà gzip.
- Le relais `loki link` reste entièrement côté Go jusqu'à son propre lot.
### Démarrage et arrêt
`loki-rs serve` lance `loki web 8091` (Go) comme processus enfant, attend
qu'il réponde, puis ouvre 8090. Si l'enfant meurt, la façade le relance
(backoff borné) et répond 502 avec un message clair entre-temps. Sur SIGTERM,
la façade transmet le signal à l'enfant et attend sa fin (moteur llama-server
arrêté proprement par Go, comme aujourd'hui).
## Mise à jour au démarrage
### Publication (CI)
À chaque push sur `main`, en plus de l'image GHCR, un workflow publie la
release GitHub **`rolling`** (pré-release, assets écrasés) :
- `loki-linux` — binaire Go (déjà `CGO_ENABLED=0`)
- `loki-rs-linux` — binaire Rust musl
- `SHA256SUMS` — format `sha256sum` (celui que `loki update` sait déjà lire)
- `manifest.json` — `{"commit": "<sha>", "image_abi": <entier>}`
La release `rolling` n'est jamais marquée `latest` : `loki update` (machines
non conteneurisées) continue de suivre les tags `vX.Y.Z`.
### ABI d'image
Le Dockerfile déclare `ENV LOKI_IMAGE_ABI=1`. On l'incrémente (Dockerfile et
source du manifeste dans le même commit) quand le code exige un changement de
l'image : nouvelle dépendance système, Python/Node, chemin du moteur, etc.
Un binaire publié avec une ABI différente de celle de l'image n'est **jamais**
installé : il ne pourrait pas tourner correctement.
### Au boot (`loki-rs self-update`)
Appelé par `docker-entrypoint.sh`, avec le `loki-rs` de l'image :
1. `LOKI_AUTO_UPDATE=0` → ne fait rien.
2. Télécharge `manifest.json` (timeout global 20 s). Erreur réseau → étape 6.
3. `image_abi` ≠ `LOKI_IMAGE_ABI` → journal :
« nouvelle version nécessitant une nouvelle image : docker compose pull »,
étape 6.
4. `commit` identique à `/data/bin/VERSION` → rien à faire, étape 6.
5. Télécharge les deux binaires en `/data/bin/*.part`, vérifie chacun contre
`SHA256SUMS`, `chmod +x`, renomme atomiquement, écrit `VERSION` en dernier.
Toute erreur → `.part` supprimés, rien remplacé.
6. Fin. Code de sortie 0 dans tous les cas : le boot n'est jamais bloqué.
L'entrypoint choisit ensuite les binaires : `/data/bin/loki` et
`/data/bin/loki-rs` s'ils existent, sont exécutables **et** que
`/data/bin/ABI` égale `LOKI_IMAGE_ABI` ; sinon ceux de l'image. Les deux
binaires viennent toujours de la même source (jamais un Go de l'image avec un
Rust de `/data/bin`). Le chemin Go choisi est passé à la façade
(`LOKI_GO_BIN`) et utilisé pour les appels `loki config` de l'entrypoint.
Limite assumée : seuls les binaires Loki se mettent à jour ainsi ; llama.cpp
se met à jour via l'UI (existant), le reste de l'image via un pull signalé par
l'ABI.
## Organisation du code Rust
```
rust/
Cargo.toml workspace
loki-rs/
src/main.rs CLI : serve | self-update | version
src/proxy.rs façade HTTP/SSE/WebSocket
src/child.rs supervision du processus Go
src/update/ manifeste, téléchargement, SHA256SUMS, installation
```
Dépendances : `tokio`, `axum`, `hyper`/`hyper-util`, `reqwest` (rustls,
sans OpenSSL — musl), `sha2`, `serde`/`serde_json`, `clap`.
## Tests
- Unitaires : parse de `SHA256SUMS` (entrées `*nom`, nom absent), décision
d'installation (ABI différente, même commit, `LOKI_AUTO_UPDATE=0`),
installation atomique (échec au milieu → rien remplacé).
- Intégration : façade devant un faux backend axum —
SSE reçu morceau par morceau (pas en bloc), WebSocket écho,
en-têtes `Host`/`Authorization`/`Origin` transmis intacts, `X-Loki-Via`
entrant effacé, 502 si backend absent.
- Mise à jour : faux serveur de release local (URL de base injectable par
variable d'environnement de test).
- CI : `cargo fmt --check`, `cargo clippy -D warnings`, `cargo test` dans un
job dédié ; la CI Go (gofmt + staticcheck + tests) reste inchangée.
## Feuille de route des lots suivants (hors périmètre de ce spec)
| Lot | Contenu |
|---|---|
| 2 | Fichiers statiques de l'UI ; lecture de la config via une API interne Go |
| 3 | Supervision de llama-server |
| 4 | Client LLM, `/v1` OpenAI, flux chat SSE |
| 5 | Outils de l'agent, MCP, mode Code/LSP |
| 6 | Modèles et téléchargements, Strata, optimiseur, bench |
| 7 | Mémoire, tâches, dictée, relais, CDP |
| 8 | Store bbolt → redb, retrait de Go |
Chaque lot aura son propre spec, plan et PR.
@@ -1,74 +0,0 @@
# Migration Rust — lot 2 : UI statique, authentification, `/api/ping`
Date : 2026-10-13 · Statut : validé · Prérequis : lot 1 (façade `loki-rs`).
## But
Faire passer en Rust les premières routes réelles, et surtout poser le socle
dont toutes les routes `/api/*` suivantes auront besoin : l'authentification.
## Périmètre
1. **UI statique servie par Rust**, embarquée dans le binaire à la
compilation depuis `internal/loki/ui/` (même source que le `go:embed`) :
| Route | Content-Type | Cache-Control | Autre |
|---|---|---|---|
| `/`, `/index.html` | `text/html; charset=utf-8` | `no-store, max-age=0` | |
| `/marked.min.js` | `application/javascript` | `public, max-age=86400` | |
| `/sw.js` | `application/javascript` | `no-store, max-age=0` | `Service-Worker-Allowed: /` |
| `/manifest.webmanifest` | `application/manifest+json` | `public, max-age=3600` | |
| `/fonts/<nom>.woff2` | `font/woff2` | `public, max-age=31536000, immutable` | |
GET et HEAD seulement (les autres méthodes restent relayées au Go).
Compression gzip si le client l'accepte, sauf polices (comme
`web_gzip.go`) ; `Vary: Accept-Encoding` sur les types compressibles.
Version compressée calculée une fois, à la première demande.
2. **API interne Go** `GET /internal/v1/auth` → `{"web_key": "…", "version": "…"}`.
- Servie **uniquement sur l'écoute TCP directe** de `loki web`
(enveloppe autour du mux dans `cmdWeb`) : le tunnel du relais sert le mux
nu et ne la voit jamais.
- Exige l'en-tête `X-Loki-Internal` égal à `LOKI_INTERNAL_TOKEN` (≥ 32
caractères, comparaison en temps constant). Jeton absent, trop court ou
faux → 404 (rien ne révèle l'existence de la route).
- Base illisible → 503 : la façade fermera l'accès (jamais ouvert par
défaut sur une panne d'E/S, même règle que `requireWebAuth`).
- La façade tire un jeton aléatoire de 32 octets à chaque démarrage et le
passe au Go par l'environnement.
3. **Authentification de pilotage en Rust** (`auth.rs`), port à l'identique de
`requireWebAuth` + `crossSiteReject` + `localHostName` + `checkBearer` :
- clé relue à **chaque requête** (API interne) : un changement de clé prend
effet immédiatement ;
- API interne en erreur (Go absent, 503, réponse illisible) → 503
`{"error":"configuration illisible — réessaie dans un instant"}` ;
- refus site tiers / hôte non local sans clé → 403 `{"error":"<raison>"}`
(mêmes messages que le Go) ;
- Bearer absent ou faux → 401 `{"error":"non autorisé"}` +
`WWW-Authenticate: Bearer realm="loki"` ;
- `X-Loki-Via` n'est jamais pris en compte côté Rust (effacé à l'entrée,
le trafic du tunnel ne traverse pas la façade).
4. **`/api/ping` en Rust** : `{"ok":true,"service":"loki","version":"<version Go>"}`
après authentification — première route `/api/*` servie par Rust.
5. **Durcissement de la façade** : `/internal/*` venant de l'extérieur → 404 ;
en-tête `X-Loki-Internal` entrant effacé avant relais.
## Hors périmètre
Toutes les autres routes `/api/*`, `/v1`, le flux chat : lots suivants.
## Tests
- Go : route interne (sans jeton → 404, mauvais jeton → 404, bon jeton → 200
avec clé et version, autres chemins relayés au mux).
- Rust : cas de `TestCrossSiteReject` repris un pour un ; `local_host_name`
(IP, IPv6 entre crochets, nom sans point, suffixes locaux, hôte de
confiance) ; Bearer ; statiques (types, cache, gzip, HEAD, police inconnue
→ relayée) ; intégration façade + faux backend interne : ping 200 sans clé,
401 avec clé sans Bearer, 200 avec bon Bearer, 503 si l'API interne tombe,
403 rebinding, `/internal/v1/auth` depuis l'extérieur → 404.
- Bout en bout local : vrai `loki` Go derrière la façade, `loki set-web-key`,
ping avec et sans clé.
@@ -1,49 +0,0 @@
# Migration Rust — lot 3 : supervision du moteur
Date : 2026-10-14 · Statut : livré · Prérequis : lots 1-2.
## Découpage
Le « moteur » en conteneur, c'est deux couches de taille très différente :
1. **La supervision du processus** (~350 lignes Go : `sys_service_container.go`,
`sys_proctree_linux.go`) — lancer `loki serve` détaché, fichier PID,
journal, arrêter tout l'arbre (Strata compris) et attendre que la VRAM
soit rendue.
2. **La ligne de commande** de llama-server / Strata (~9 000 lignes :
`backend_serve*.go`, presets, MoE, spéculatif, threads, cache…), là où
s'applique la règle « ne jamais dénaturer les modèles ».
Le lot 3 porte la couche 1. La couche 2 reste au Go (`loki serve`) et fera
l'objet d'un lot dédié, parce que chaque option y a été réglée contre un
défaut mesuré.
## Ce qui change
- `loki-rs engine start|stop|restart|status` : port à l'identique de la
supervision Go — chef de session (`setsid`), fichier PID et journal aux
**mêmes chemins** (`$LOKI_HOME/<service>.pid|.log`), contrôle à 2 s avec
les 20 dernières lignes du journal en cas d'échec, arrêt de l'arbre
(session, groupe, filiation ; SIGTERM, 4 s, SIGKILL, 20 s au plus), mêmes
messages.
- Le Go garde ses garde-fous (optimisation en cours via `serviceAction`,
preset externe) puis délègue au `loki-rs` voisin de son binaire.
`loki logs`, `enable`/`disable` restent au Go.
- Porte de sortie : `LOKI_ENGINE_SUPERVISOR=go`, ou absence de `loki-rs`.
- Différence assumée : le contrôle « démarré ? » regarde si l'enfant est
sorti (`try_wait`), pas le signal 0 — un enfant mort reste un zombie, qui
répond encore au signal 0.
- L'arrêt exclut de l'arbre `loki-rs` lui-même ET son parent (le Go qui l'a
appelé), comme le Go excluait son propre PID.
## Tests
- Rust (toutes plateformes) : `/proc/<pid>/stat` avec parenthèses dans le
nom, arbre par session/groupe/filiation avec exclusion de l'appelant.
- Rust (Linux, CI) : faux `loki serve` qui lance un enfant dans sa session et
un « moteur » dans SA PROPRE session (comme Strata) — démarrage, « déjà
démarré », arrêt de tout l'arbre, redémarrage, échec avec journal, PID
nettoyé.
- Go (Linux, CI) : choix du `loki-rs` voisin (absent, non exécutable,
`LOKI_ENGINE_SUPERVISOR=go`), appel réel avec l'environnement attendu,
erreur rendue = dernière ligne d'erreur du superviseur.
@@ -1,93 +0,0 @@
# Migration Rust — lot 6 : le cœur chat
Date : 2026-10-14 · Statut : **livré** (étapes 1 à 6 ; défaut Rust depuis le 2026-10-15, `LOKI_CHAT=go` pour revenir au Go) ·
Prérequis : lots 1-5 (façade, auth, store via API interne, `/v1`).
## Pourquoi c'est un lot à part
Le chat est le cœur de Loki et le seul chemin qu'on ne peut pas casser un
soir de déploiement. ~17 700 lignes Go (`chat_*.go` 11 491, `llm_*.go`
6 245), dont une fonction de 1 469 lignes (`runChatLoop`,
`llm_client.go:1279`) qui mêle protocole du moteur, reprises sur erreur,
compactage en cours de tour et exécution des outils.
## Carte (HEAD b98ee5c)
### Surface HTTP
Toutes derrière `requireWebAuth` (déjà portée).
| Route | Handler | Rôle |
|---|---|---|
| `/api/chat` | `handleChat` web_chat.go:316 | **SSE** : rejoue le journal puis le suit en direct |
| `/api/chat/send` | `handleChatSend` :178 | démarre un tour ou le met en file (409 occupé, 503 pas prêt) |
| `/api/chat/stop`, `/reset`, `/state`, `/compact`, `/tool-result` | web_chat.go | contrôle et lecture |
| `/api/chat/mode`, `/criteria` | code_mode.go | mode Code |
| `/api/chat/export`, `/upload`, `/files*`, `/file*`, `/image`, `/cushot` | divers | pièces jointes, espace de travail, aperçu navigateur |
| `/api/conversations{,/new,/switch,/rename,/delete}` | web_chat.go | index des discussions |
| `/api/e2e/chat` | relay_e2e.go:143 | même flux, chiffré AES-GCM par événement (tunnel) |
Trame SSE : `data: {"choices":[{"delta":…}]}`, battement `: ping` toutes les
4 s, regroupement à 128 Kio ou 256 événements.
### État en mémoire
- `conv *Conversation` (chat_conversation.go:114) : `Messages` (vue du
modèle), `Log []LogEvent{Seq,TS,Delta}` (journal d'affichage, **seule voie
vers l'UI**, via `appendDelta`), `Seq`, `CtxUsed`, `Generating`, `cancel`,
`epoch`, file d'attente, tâche en cours. Un `mu` + `cond`.
- `Generating` est un **verrou global du moteur** partagé avec les tâches
planifiées (`RunAutonomous`), le bench et l'optimiseur (`measureLease`) et
quatre sondes `isGenerating`.
- Persistance : `snapshot()` sérialise Messages ET Log dans un seul JSON,
**chiffré** (`memEncoderNow`, clé en mémoire du Go après déverrouillage),
écrit par un écrivain unique (`persistQ`) en une transaction bbolt —
bucket `chat`, clés `conv:<id>`, `index`, `active`.
### Boucle d'un tour
`handleChatSend` → `EnqueueOrStart` → `StartTurn` → `go generate` →
`turnView` (prompt système, outils, MCP, mémoire, instantané projet) →
`runChatTools` → `runChatLoop` (payload, slot, requête au moteur, analyse du
flux, reprises, compactage réactif, **switch des outils inline**
llm_client.go:2363-2549, compactage en cours de tour) → retour dans
`generate` (validation mode Code `codeVerifyLoop`, compactage de fin de
tour, persistance, file suivante, préchauffage).
## Coupures possibles
| | Coupure | Ce qui traverse | Risque |
|---|---|---|---|
| A | Journal + SSE en Rust, boucle au Go | flux de deltas Go→Rust, epoch, reset | Log persisté DANS le même blob chiffré que Messages |
| B | Client LLM en Rust (protocole, flux, reprises) | appel d'outil Go en RPC, porte du moteur, compactage | fonction de 1 469 lignes, appels récursifs (sous-agents, vérificateur) |
| C | Machine d'état de la conversation en Rust | turnView, compactage, outils, persistance en RPC | verrou `Generating` partagé avec tâches/bench/optimiseur |
| D | Sessions + persistance | — | écrivain unique bbolt, chiffrement, ordre des résultats d'outils |
## Stratégie proposée
Pas de bascule partielle de la discussion de l'utilisateur : le cœur chat
est réécrit **en entier** côté Rust, derrière `LOKI_CHAT=rust` (Go par
défaut), puis comparé au Go sur des conversations rejouées avant de devenir
le défaut. Étapes, chacune livrée et testée :
1. **Go, sans changement de comportement** : extraire le switch des outils en
`execTool(ctx, name, args, caps) → résultat` et l'exposer en API interne
(`/internal/v1/tool/exec`), ainsi que le chiffrement des blobs
(`/internal/v1/crypt/seal|open`, la clé reste au Go) et le bail du moteur
(`Generating` → `/internal/v1/engine/lease`).
2. **Rust, bibliothèque** : protocole du moteur (payload OpenAI, analyse SSE
llama.cpp/Strata, reasoning, appels d'outils natifs et textuels, effort,
reprises réseau), testé contre des flux enregistrés.
3. **Rust, conversation** : Messages + Log + epoch + file + SSE `/api/chat`
(rejeu, pagination `historyCut`, `coalesceReplay`, `compactLog`),
persistance au même format (`conv:<id>`, chiffré via l'étape 1).
4. **Rust, boucle** : `runChatLoop` réécrit en machine d'état explicite,
outils exécutés par le Go (étape 1), compactage porté.
5. **Parité** : rejeu de conversations réelles exportées, comparaison des
journaux produits (Go vs Rust) avec un faux moteur déterministe.
6. **Bascule** `LOKI_CHAT=rust` par défaut ; outils, MCP, mémoire, mode Code
portés ensuite (lot 7) en retirant les appels au Go un par un.
## Questions ouvertes
- Chiffrement : port du format (`memEncoderNow`) en Rust à l'étape 3, ou
RPC au Go jusqu'au lot 9 ?
- Tunnel du relais (`/api/e2e/chat`) : reste au Go jusqu'au portage du
relais ; tant que `LOKI_CHAT=rust`, le flux chiffré doit lire le journal
Rust (API interne inverse) — à concevoir.
@@ -1,44 +0,0 @@
# 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:<id>`), 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.
@@ -1,63 +0,0 @@
# 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=<px>` 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/<id>/…`, `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.
@@ -1,47 +0,0 @@
# 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é.
@@ -1,117 +0,0 @@
# 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/<projet>/<nom>.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é
`<projet>/<slug>`. 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.
@@ -1,97 +0,0 @@
# 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).
@@ -1,86 +0,0 @@
# 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é.
@@ -1,90 +0,0 @@
# 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 <action>` (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 <action>`, 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.
@@ -1,92 +0,0 @@
# 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.
@@ -1,78 +0,0 @@
# 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/<slug>` 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/<slug>`.
## 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.
@@ -1,65 +0,0 @@
# 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:<id>`, 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:<id>`), sur le fil (`{criteria, ts}`) |
L'export suit `chat_export.go` :
- Markdown : un bloc par tour, le raisonnement replié dans un `<details>`,
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.
@@ -1,93 +0,0 @@
# 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__<serveur>__<outil>` 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.
@@ -1,26 +0,0 @@
# 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.
@@ -1,111 +0,0 @@
# 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.
@@ -1,85 +0,0 @@
# 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.
@@ -1,130 +0,0 @@
# 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.
@@ -1,121 +0,0 @@
# 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.
@@ -1,193 +0,0 @@
# 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é.
@@ -1,151 +0,0 @@
# 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.
@@ -1,93 +0,0 @@
# Lot 7 — outils de l'agent exécutés par la façade
## Contexte
Lot 6 : quand la façade tient la conversation (`LOKI_CHAT=rust`), la boucle du
tour est en Rust mais chaque appel d'outil repart au Go
(`/internal/v1/tool/exec`). Lot 7 : porter les outils eux-mêmes, en retirant
les appels au Go un par un. Rien ne change sans `LOKI_CHAT=rust` : la boucle
Go exécute ses outils comme avant.
## Étape 1 — fichiers et shell (fait)
Exécutés par la façade (`rust/loki-rs/src/tools/`) : `read`, `grep`, `glob`,
`write`, `edit`, `bash`, `ask`, avec les mêmes garde-fous dans le même ordre
(commande catastrophique, `&` final en mode Code, bac à sable du mode Code,
dossier mémoire interdit, « lu avant d'écrire »), le même diff pour l'UI et le
même nettoyage de la sortie du shell (`| tail -N`, ANSI, fin bornée).
L'état partagé reste au Go, en un seul exemplaire, derrière trois routes
internes (`web_internal_toolstate.go`) :
| Route | Rôle |
|---|---|
| `POST /internal/v1/tool/context` | dossier de travail (`agentCwd`), dossier mémoire, cible distante |
| `POST /internal/v1/tool/tracker` | `read` : lecture notée ; `check` : refus éventuel avant écriture |
| `POST /internal/v1/tool/wrote` | après une écriture : note du suivi, bloc des diagnostics LSP |
Le suivi reste au Go parce que les sous-agents et les tâches planifiées, qui y
tournent, le lisent et l'écrivent aussi ; les serveurs LSP y vivent.
Repli sur le Go : outil non porté ; `write`/`edit`/`bash` quand l'agent vise
un poste distant (une commande catastrophique est refusée avant, ici) ;
contexte indisponible.
Le jeton de l'API interne est retiré de l'environnement du shell de l'agent.
### Parité
`internal/loki/parity_tools_test.go` passe 58 appels à `execTool` sur une
arborescence de test et range le journal (libellés, résultats, diffs,
questions, arborescence finale) dans `rust/loki-rs/tests/parity_tools/` ;
`rust/loki-rs/tests/tools.rs` rejoue les mêmes appels dans `exec_local` et
retrouve le même journal. Le test Go échoue si le comportement Go change
(`LOKI_PARITY_UPDATE=1` pour régénérer, puis le test Rust dit si le port suit).
### Écarts assumés
- Motif `grep` invalide : le message d'erreur est celui du moteur d'expressions
de Rust (crate `regex`), pas celui de Go.
- Classes `\w`, `\b`, `\s`, `\d` d'un motif `grep` : Unicode en Rust, ASCII en
Go (un mot accentué y est un mot entier).
- Octets hors UTF-8 dans un fichier lu ou cherché : remplacés par U+FFFD par
séquence invalide (Go : par octet, à l'encodage JSON). `edit` travaille sur
les octets et laisse intact ce qu'il ne remplace pas.
- `mkdir` en échec : le chemin cité est le dossier parent visé, pas le
composant qui a échoué.
## Étape 2 — boucle du contrat du mode Code (fait)
`codeVerifyLoop` porté (`rust/loki-rs/src/chat/verify.rs`) : à la fin d'un
tour de build en mode Code, relances du builder sur les critères encore
ouverts, passe de vérification sur un contexte isolé (`POST
/internal/v1/turn/verifier` : prompt du rôle et faits de la machine, outils
fermés, rôle `verifier` qui seul peut marquer `passed`), corrections, abandon
dit dans le fil après deux corrections. Mêmes deltas que le Go (`role`,
`verify_done`, mesures `ctx_isolated`). Les critères sont lus en base
(`chat/crit:<id>`) ; l'outil `criteria` reste au Go. Toute compaction en mode
Code rend leur état au résumé, comme `compactMessages`.
Écart assumé : l'isolation du slot du moteur après la vérification
(`engineSideJob`) reste au Go, qui ne voit pas passer les requêtes de la
façade.
## Étape 3 — git (fait)
`git_status`, `git_diff`, `git_clone` (`rust/loki-rs/src/tools/git.rs`) :
dépôt deviné comme le Go (sous-dossier demandé, racine, unique sous-dossier
dépôt), sorties mêlées et bornées, jamais d'invite, clone limité à https:// et
git@hôte: et rangé dans le dossier de la discussion. Toujours sur cette
machine, cible distante ou non (comme le Go). Journal de référence
`tests/parity_tools/git.json` (`TestPariteOutilsGit`, deux dépôts, aucun appel
réseau), rejoué par `tests/tools_git.rs`.
## Étapes suivantes
1. Web (`web_search`, `web_open`, `web_read`, `web_grep`, `web_images`) : le
cache de pages et la conversion HTML → Markdown (bibliothèques Go) ne se
reproduisent pas à l'octet ; à porter avec un journal de référence
structurel plutôt qu'exact.
2. Shell d'arrière-plan (`bash_bg`, `bash_tail`) : ses travaux sont lus par
l'optimiseur et la suppression d'une discussion, au Go.
3. Mémoire (`mem_*`, `recall*`, `tracker`) : avec le portage du chiffrement.
4. Tâches, navigateur (CDP), MCP, sous-agents, critères.
@@ -1,101 +0,0 @@
# 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.
@@ -1,96 +0,0 @@
# 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.
@@ -1,263 +0,0 @@
# 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/<essai>/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/<pid>/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.
@@ -1,82 +0,0 @@
# 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.
@@ -1,54 +0,0 @@
# 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.
@@ -1,88 +0,0 @@
# 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.
@@ -1,97 +0,0 @@
# 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é.
@@ -1,112 +0,0 @@
# 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 `<context_update>`, 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.
@@ -1,92 +0,0 @@
# 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 `<context_update>` 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 `<context_update>` 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`.
@@ -1,48 +0,0 @@
# 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`.
@@ -1,56 +0,0 @@
# 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.
@@ -1,82 +0,0 @@
# 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.
@@ -1,77 +0,0 @@
# 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:<id>` 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.
@@ -1,92 +0,0 @@
# 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.
@@ -1,87 +0,0 @@
# 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.
@@ -1,133 +0,0 @@
# 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).
@@ -1,121 +0,0 @@
# 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.
@@ -1,82 +0,0 @@
# 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/<id>.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.
@@ -1,138 +0,0 @@
# 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.
@@ -1,116 +0,0 @@
# 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.
@@ -1,97 +0,0 @@
# 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.
@@ -1,115 +0,0 @@
# 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 <rôle> : <erreur> ».
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.
+1 -3
View File
@@ -5,7 +5,6 @@ go 1.25.0
require (
codeberg.org/readeck/go-readability/v2 v2.1.2
github.com/JohannesKaufmann/html-to-markdown/v2 v2.5.2
github.com/SherClockHolmes/webpush-go v1.4.0
github.com/caddyserver/certmagic v0.25.4
github.com/coder/websocket v1.8.15
github.com/getlantern/systray v1.2.2
@@ -14,7 +13,6 @@ require (
go.etcd.io/bbolt v1.5.0
golang.org/x/mod v0.35.0
golang.org/x/net v0.55.0
golang.org/x/sys v0.45.0
)
require (
@@ -30,7 +28,6 @@ require (
github.com/go-shiori/dom v0.0.0-20230515143342-73569d674e1c // indirect
github.com/go-stack/stack v1.8.0 // indirect
github.com/gogs/chardet v0.0.0-20211120154057-b7413eaefb8f // indirect
github.com/golang-jwt/jwt/v5 v5.3.1 // indirect
github.com/google/jsonschema-go v0.4.3 // indirect
github.com/itlightning/dateparse v0.2.1 // indirect
github.com/klauspost/cpuid/v2 v2.3.0 // indirect
@@ -48,6 +45,7 @@ require (
golang.org/x/crypto v0.51.0 // indirect
golang.org/x/oauth2 v0.35.0 // indirect
golang.org/x/sync v0.20.0 // indirect
golang.org/x/sys v0.45.0 // indirect
golang.org/x/text v0.37.0 // indirect
golang.org/x/tools v0.44.0 // indirect
)
-67
View File
@@ -6,8 +6,6 @@ github.com/JohannesKaufmann/dom v0.3.1 h1:J16l9JAHWgkFPR3VIPbQ1gvS0cWab6laK1q7PF
github.com/JohannesKaufmann/dom v0.3.1/go.mod h1:BZPkf8ZeYrBgABjwJn9iiKt8aiCtkxpHkevms+Yp2DE=
github.com/JohannesKaufmann/html-to-markdown/v2 v2.5.2 h1:XFJZFWESIWlUEHHjzBuv8RvrtCWnSGlimEX17ysSDb8=
github.com/JohannesKaufmann/html-to-markdown/v2 v2.5.2/go.mod h1:BHWO8lJzttJLqwuV8Rb1B3OG2OSzLbssZDI1FRg2eAA=
github.com/SherClockHolmes/webpush-go v1.4.0 h1:ocnzNKWN23T9nvHi6IfyrQjkIc0oJWv1B1pULsf9i3s=
github.com/SherClockHolmes/webpush-go v1.4.0/go.mod h1:XSq8pKX11vNV8MJEMwjrlTkxhAj1zKfxmyhdV7Pd6UA=
github.com/andybalholm/cascadia v1.3.4 h1:vM2lgh0Vru9Vwyfm4cQqWP2HHMW0u0+2PAW7Q38Qufg=
github.com/andybalholm/cascadia v1.3.4/go.mod h1:BLRmbRjpEtNKieZOCCvYj4RqN+KRA41GBe/5O+G93kM=
github.com/caddyserver/certmagic v0.25.4 h1:8eIXh0HC3MsGnNo8One+BCxMGTbe5zb/oz+2KsxBFQg=
@@ -41,10 +39,8 @@ github.com/go-stack/stack v1.8.0 h1:5SgMzNM5HxrEjV0ww2lTmX6E2Izsfxas4+YHWRs3Lsk=
github.com/go-stack/stack v1.8.0/go.mod h1:v0f6uXyyMGvRgIKkXu+yp6POWl0qKG85gN/melR3HDY=
github.com/gogs/chardet v0.0.0-20211120154057-b7413eaefb8f h1:3BSP1Tbs2djlpprl7wCLuiqMaUh5SJkkzI2gDs+FgLs=
github.com/gogs/chardet v0.0.0-20211120154057-b7413eaefb8f/go.mod h1:Pcatq5tYkCW2Q6yrR2VRHlbHpZ/R4/7qyL1TCF7vl14=
github.com/golang-jwt/jwt/v5 v5.2.1/go.mod h1:pqrtFR0X4osieyHYxtmOUWsAWrfe1Q5UVIyoH402zdk=
github.com/golang-jwt/jwt/v5 v5.3.1 h1:kYf81DTWFe7t+1VvL7eS+jKFVWaUnK9cB1qbwn63YCY=
github.com/golang-jwt/jwt/v5 v5.3.1/go.mod h1:fxCRLWMO43lRc8nhHWY6LGqRcf+1gQWArsqaEUEa5bE=
github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=
github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=
github.com/google/jsonschema-go v0.4.3 h1:/DBOLZTfDow7pe2GmaJNhltueGTtDKICi8V8p+DQPd0=
@@ -88,7 +84,6 @@ github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
github.com/yosida95/uritemplate/v3 v3.0.2 h1:Ed3Oyj9yrmi9087+NczuL5BwkIc4wvTb5zIM+UJPGz4=
github.com/yosida95/uritemplate/v3 v3.0.2/go.mod h1:ILOh0sOhIJR3+L/8afwt/kE++YT040gmv5BQTMR2HP4=
github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY=
github.com/yuin/goldmark v1.8.2 h1:kEGpgqJXdgbkhcOgBxkC0X0PmoPG1ZyoZ117rDVp4zE=
github.com/yuin/goldmark v1.8.2/go.mod h1:ip/1k0VRfGynBgxOz0yCqHrbZXhcjxyuS66Brc7iBKg=
github.com/zeebo/assert v1.1.0 h1:hU1L1vLTHsnO8x8c9KAR5GmM5QscxHg5RNU5z5qbUWY=
@@ -107,86 +102,24 @@ go.uber.org/zap v1.27.1 h1:08RqriUEv8+ArZRYSTXy1LeBScaMpVSTBhCeaZYfMYc=
go.uber.org/zap v1.27.1/go.mod h1:GB2qFLM7cTU87MWRP2mPIjqfIDnGu+VIO4V/SdhGo2E=
go.uber.org/zap/exp v0.3.0 h1:6JYzdifzYkGmTdRR59oYH+Ng7k49H9qVpWwNSsGJj3U=
go.uber.org/zap/exp v0.3.0/go.mod h1:5I384qq7XGxYyByIhHm6jg5CHkGY0nsTfbDLgDDlgJQ=
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc=
golang.org/x/crypto v0.13.0/go.mod h1:y6Z2r+Rw4iayiXXAIxJIDAJ1zMW4yaTpebo8fPOliYc=
golang.org/x/crypto v0.19.0/go.mod h1:Iy9bg/ha4yyC70EfRS8jz+B6ybOBKMaSxLj6P6oBDfU=
golang.org/x/crypto v0.23.0/go.mod h1:CKFgDieR+mRhux2Lsu27y0fO304Db0wZe70UKqHu0v8=
golang.org/x/crypto v0.31.0/go.mod h1:kDsLvtWBEx7MV9tJOj9bnXsPbxwJQ6csT/x4KIN4Ssk=
golang.org/x/crypto v0.51.0 h1:IBPXwPfKxY7cWQZ38ZCIRPI50YLeevDLlLnyC5wRGTI=
golang.org/x/crypto v0.51.0/go.mod h1:8AdwkbraGNABw2kOX6YFPs3WM22XqI4EXEd8g+x7Oc8=
golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4=
golang.org/x/mod v0.8.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs=
golang.org/x/mod v0.12.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs=
golang.org/x/mod v0.15.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c=
golang.org/x/mod v0.17.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c=
golang.org/x/mod v0.35.0 h1:Ww1D637e6Pg+Zb2KrWfHQUnH2dQRLBQyAtpr/haaJeM=
golang.org/x/mod v0.35.0/go.mod h1:+GwiRhIInF8wPm+4AoT6L0FA1QWAad3OMdTRx4tFYlU=
golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg=
golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c=
golang.org/x/net v0.6.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs=
golang.org/x/net v0.10.0/go.mod h1:0qNGK6F8kojg2nk9dLZ2mShWaEBan6FAoqfSigmmuDg=
golang.org/x/net v0.15.0/go.mod h1:idbUs1IY1+zTqbi8yxTbhexhEEk5ur9LInksu6HrEpk=
golang.org/x/net v0.21.0/go.mod h1:bIjVDfnllIU7BJ2DNgfnXvpSvtn8VRwhlsaeUTyUS44=
golang.org/x/net v0.25.0/go.mod h1:JkAGAh7GEvH74S6FOH42FLoXpXbE/aqXSrIQjXgsiwM=
golang.org/x/net v0.55.0 h1:bcvxaJn3e1U6InsFWt1JUq1aSjnRxLzT2rtD2KfkDF8=
golang.org/x/net v0.55.0/go.mod h1:L5U2KuzuOe1lY7Z+aWVIKK6qEeJXnXV9yzGA+WCHJww=
golang.org/x/oauth2 v0.35.0 h1:Mv2mzuHuZuY2+bkyWXIHMfhNdJAdwW3FuWeCPYN5GVQ=
golang.org/x/oauth2 v0.35.0/go.mod h1:lzm5WQJQwKZ3nwavOZ3IS5Aulzxi68dUSgRHujetwEA=
golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.3.0/go.mod h1:FU7BRWz2tNW+3quACPkgCx/L+uEAv1htQ0V83Z9Rj+Y=
golang.org/x/sync v0.6.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
golang.org/x/sync v0.7.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
golang.org/x/sync v0.10.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
golang.org/x/sync v0.20.0 h1:e0PTpb7pjO8GAtTs2dQ6jYa5BWYlMuX047Dco/pItO4=
golang.org/x/sync v0.20.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0=
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
golang.org/x/sys v0.0.0-20201018230417-eeed37f84f13/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.1.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.8.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.12.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.17.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
golang.org/x/sys v0.20.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
golang.org/x/sys v0.28.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
golang.org/x/sys v0.45.0 h1:dO4czNzziLiiXplLQgBCEpCvXQ3dnkn0SdaZSYdQ+FY=
golang.org/x/sys v0.45.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
golang.org/x/telemetry v0.0.0-20240228155512-f48c80bd79b2/go.mod h1:TeRTkGYfJXctD9OcfyVLyj2J3IxLnKwHJR8f4D8a3YE=
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8=
golang.org/x/term v0.5.0/go.mod h1:jMB1sMXY+tzblOD4FWmEbocvup2/aLOaQEp7JmGp78k=
golang.org/x/term v0.8.0/go.mod h1:xPskH00ivmX89bAKVGSKKtLOWNx2+17Eiy94tnKShWo=
golang.org/x/term v0.12.0/go.mod h1:owVbMEjm3cBLCHdkQu9b1opXd4ETQWc3BhuQGKgXgvU=
golang.org/x/term v0.17.0/go.mod h1:lLRBjIVuehSbZlaOtGMbcMncT+aqLLLmKrsjNrUguwk=
golang.org/x/term v0.20.0/go.mod h1:8UkIAJTvZgivsXaD6/pH6U9ecQzZ45awqEOzuCvwpFY=
golang.org/x/term v0.27.0/go.mod h1:iMsnZpn0cago0GOrHO2+Y7u7JPn5AylBrcoWkElMTSM=
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ=
golang.org/x/text v0.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8=
golang.org/x/text v0.9.0/go.mod h1:e1OnstbJyHTd6l/uOt8jFFHp6TRDWZR/bV3emEE/zU8=
golang.org/x/text v0.13.0/go.mod h1:TvPlkZtksWOMsz7fbANvkp4WM8x/WCo/om8BMLbz+aE=
golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU=
golang.org/x/text v0.15.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU=
golang.org/x/text v0.21.0/go.mod h1:4IBbMaMmOPCJ8SecivzSH54+73PCFmPWxNTLm+vZkEQ=
golang.org/x/text v0.37.0 h1:Cqjiwd9eSg8e0QAkyCaQTNHFIIzWtidPahFWR83rTrc=
golang.org/x/text v0.37.0/go.mod h1:a5sjxXGs9hsn/AJVwuElvCAo9v8QYLzvavO5z2PiM38=
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc=
golang.org/x/tools v0.6.0/go.mod h1:Xwgl3UAJ/d3gWutnCtw505GrjyAbvKui8lOU390QaIU=
golang.org/x/tools v0.13.0/go.mod h1:HvlwmtVNQAhOuCjW7xxvovg8wbNq7LwfXh/k7wXUl58=
golang.org/x/tools v0.21.1-0.20240508182429-e35e4ccd0d2d/go.mod h1:aiJjzUbINMkxbQROHiO6hDPo2LHcIPhhQsa9DLh0yGk=
golang.org/x/tools v0.44.0 h1:UP4ajHPIcuMjT1GqzDWRlalUEoY+uzoZKnhOjbIPD2c=
golang.org/x/tools v0.44.0/go.mod h1:KA0AfVErSdxRZIsOVipbv3rQhVXTnlU6UhKxHd1seDI=
golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
gopkg.in/Knetic/govaluate.v3 v3.0.0/go.mod h1:csKLBORsPbafmSCGTEh3U7Ozmsuq8ZSIlKk1bcqph0E=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
+1 -2
View File
@@ -10,7 +10,6 @@ Method — in this order:
Rules:
- PLAN ONCE. If the task needs a plan, write it to PLAN.md in ONE short write, then follow it. Between tool calls, think one or two sentences at most — NEVER restate or re-derive the plan: it is in PLAN.md and in your criteria, read them instead.
- Write each file COMPLETE in a single write call. Many small writes waste turns.
- When a criterion is done AND checked, mark it completed (criteria set). Verification starts once none is left pending; only it may mark passed.
- You may NOT mark a criterion passed — the verification pass does that.
- If a command fails, read the error and fix the cause; do not retry the same command unchanged.
- Ask the user (ask tool) only for decisions that are genuinely theirs; decide the rest yourself.
- Making the change is your job; shipping it is not. Without an explicit request, never commit, push, reset or rebase, never deploy, restart a service or replace a running binary. Read-only inspection (git_status, git_diff) is fine and encouraged.
+4 -29
View File
@@ -44,45 +44,20 @@ func emitBuildLine(line string) {
// Sert à téer la sortie des commandes de runStep quand un sink est actif.
// (mutex : stdout et stderr d'une même commande peuvent écrire en parallèle)
type sinkWriter struct {
mu sync.Mutex
buf []byte
lastProg time.Time // dernière ligne de progression (\r) émise
mu sync.Mutex
buf []byte
}
// sinkProgressEvery : une barre de progression (tqdm, téléchargements de
// l'installeur de Strata) se redessine sur place avec \r, sans jamais finir sa
// ligne. On en montre une version toutes les 10 s : sans elle, un téléchargement
// de 76 Go laissait le journal figé pendant une heure ; toutes, elles le noieraient.
const sinkProgressEvery = 10 * time.Second
func (s *sinkWriter) Write(p []byte) (int, error) {
s.mu.Lock()
defer s.mu.Unlock()
s.buf = append(s.buf, p...)
for {
i := strings.IndexAny(string(s.buf), "\r\n")
i := strings.IndexByte(string(s.buf), '\n')
if i < 0 {
break
}
if s.buf[i] == '\n' {
emitBuildLine(strings.TrimRight(string(s.buf[:i]), "\r"))
s.buf = s.buf[i+1:]
continue
}
// \r : fin de ligne Windows (\r\n) ou barre qui se redessine. Le \n peut
// arriver dans l'écriture suivante : on attend de le savoir.
if i+1 >= len(s.buf) {
break
}
if s.buf[i+1] == '\n' {
emitBuildLine(string(s.buf[:i]))
s.buf = s.buf[i+2:]
continue
}
if prog := strings.TrimSpace(string(s.buf[:i])); prog != "" && time.Since(s.lastProg) >= sinkProgressEvery {
s.lastProg = time.Now()
emitBuildLine(prog)
}
emitBuildLine(strings.TrimRight(string(s.buf[:i]), "\r"))
s.buf = s.buf[i+1:]
}
return len(p), nil
-29
View File
@@ -1,29 +0,0 @@
package loki
import (
"reflect"
"testing"
"time"
)
// Une barre de progression (\r sans \n) doit apparaître dans le journal, sans
// le noyer ; \r\n reste une fin de ligne ordinaire, même coupé entre deux écritures.
func TestSinkWriterProgression(t *testing.T) {
var got []string
setBuildSink(func(l string) { got = append(got, l) })
defer setBuildSink(nil)
s := &sinkWriter{}
s.Write([]byte("Step 5\r"))
s.Write([]byte("\n modèle : 1 / 76 GB\r modèle : 2 / 76 GB\r"))
s.Write([]byte(" modèle : 3 / 76 GB\r"))
s.lastProg = time.Now().Add(-time.Minute) // 10 s écoulées
s.Write([]byte(" modèle : 40 / 76 GB\r[ok] fini\n"))
// « 3 » finissait sa propre écriture : il attendait de savoir si un saut de
// ligne suivait, et part donc avec l'écriture suivante.
want := []string{"Step 5", "modèle : 1 / 76 GB", "modèle : 3 / 76 GB", "[ok] fini"}
if !reflect.DeepEqual(got, want) {
t.Fatalf("lignes %q, attendu %q", got, want)
}
}
+1 -9
View File
@@ -73,16 +73,11 @@ 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. 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.
// chars, elide the middle. Empty in → empty out.
func maskAPIKey(k string) string {
if k == "" {
return ""
}
if len(k) <= 2 {
return "…"
}
if len(k) <= 13 {
return "…" + k[len(k)-2:]
}
@@ -97,9 +92,6 @@ func maskAPIKey(k string) string {
// loki set-api-key génère une clé aléatoire
// loki set-api-key "" supprime la protection
func cmdSetAPIKey(args []string) error {
if err := tuneGuard(); err != nil {
return err
}
var key string
switch {
case len(args) == 0:
+1 -10
View File
@@ -26,7 +26,6 @@ package loki
import (
"archive/tar"
"compress/gzip"
"context"
"encoding/json"
"fmt"
"io"
@@ -282,19 +281,11 @@ func parseEngineBuild(out string) (build int, commit string) {
// engineBuildOf interroge un llama-server pour son numéro de build. Renvoie
// (0, "") si le binaire ne répond pas — un moteur cassé ne doit pas empêcher
// d'en installer un autre.
//
// Borné dans le temps comme binHelp : « loki serve » le lance avant le moteur
// (points de reprise d'un hybride, voir ckptArgs), et un binaire qui reste
// coincé à l'initialisation de ses backends ne doit pas bloquer le lancement —
// passé le délai, build inconnu, donc ni avis ni drapeau.
func engineBuildOf(bin string) (int, string) {
if bin == "" || !isFile(bin) {
return 0, ""
}
ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second)
defer cancel()
cmd := hideCmd(exec.CommandContext(ctx, bin, "--version"))
cmd.WaitDelay = 2 * time.Second // un descendant qui garde la sortie ouverte ne retient pas Wait
cmd := hideCmd(exec.Command(bin, "--version"))
cmd.Env = libraryPathEnv(filepath.Dir(bin))
out, _ := cmd.CombinedOutput() // --version sort parfois en code non nul
return parseEngineBuild(string(out))
+6 -19
View File
@@ -24,11 +24,10 @@ import (
)
const (
extKeyFlag = "EXTERNAL" // marqueur : "1" = preset externe
extKeyURL = "EXTERNAL_URL" // base ou URL complète des complétions
extKeyModel = "EXTERNAL_MODEL" // nom du modèle envoyé dans le payload
extKeyToken = "EXTERNAL_KEY" // clé API (Bearer), peut être vide
extKeyVision = "EXTERNAL_VISION" // "1" = le modèle distant accepte les images
extKeyFlag = "EXTERNAL" // marqueur : "1" = preset externe
extKeyURL = "EXTERNAL_URL" // base ou URL complète des complétions
extKeyModel = "EXTERNAL_MODEL" // nom du modèle envoyé dans le payload
extKeyToken = "EXTERNAL_KEY" // clé API (Bearer), peut être vide
)
// chatEndpoint : où partent les appels /v1/chat/completions de ce tour.
@@ -47,14 +46,6 @@ func isExternalConfig(cfg map[string]string) bool {
// externalActive : le preset actif est-il un endpoint externe ?
func externalActive() bool { return isExternalConfig(ReadConfig()) }
// externalVisionActive : le preset actif est externe ET déclaré multimodal. Pas
// de MMPROJ ni de sonde /props à consulter côté distant : seule la case du
// preset dit si le modèle accepte les images.
func externalVisionActive() bool {
cfg := ReadConfig()
return isExternalConfig(cfg) && strings.TrimSpace(cfg[extKeyVision]) == "1"
}
// completionsURL normalise l'URL saisie en une URL de complétions complète.
// Accepte les trois formes qu'on colle en pratique :
//
@@ -111,9 +102,8 @@ func (e chatEndpoint) auth(set func(k, v string)) {
// externalPresetContent construit le corps .env d'un preset externe (hors ligne
// « # NAME= », ajoutée par SavePreset). Une clé vide n'est pas écrite. `ctx` va
// dans la clé CTX standard — elle pilote la jauge de contexte et le seuil de
// compaction, exactement comme pour un preset local. vision = le modèle distant
// accepte les images (EXTERNAL_VISION=1).
func externalPresetContent(url, model, key, ctx string, vision bool) string {
// compaction, exactement comme pour un preset local.
func externalPresetContent(url, model, key, ctx string) string {
m := map[string]string{
extKeyFlag: "1",
extKeyURL: strings.TrimSpace(url),
@@ -122,9 +112,6 @@ func externalPresetContent(url, model, key, ctx string, vision bool) string {
if k := strings.TrimSpace(key); k != "" {
m[extKeyToken] = k
}
if vision {
m[extKeyVision] = "1"
}
if c := strings.TrimSpace(ctx); c != "" {
m["CTX"] = c
}
@@ -1,43 +0,0 @@
package loki
import "testing"
// Preset externe actif : `loki serve` n'a aucun moteur local à lancer. Il
// sortait en erreur « MODEL non défini », que systemd (Restart=on-failure)
// relançait en boucle (amont #95).
func TestServeExitsCleanlyForExternalPreset(t *testing.T) {
testHome(t)
if err := WriteConfig(map[string]string{extKeyFlag: "1", "PORT": "8080"}); err != nil {
t.Fatal(err)
}
if err := cmdServe(nil); err != nil {
t.Fatalf("loki serve sur un preset externe : %v", err)
}
}
// Le pré-vol refusait `loki start` / `loki restart` sur un preset externe,
// en invitant à réinstaller llama.cpp.
func TestPreflightAcceptsExternalPreset(t *testing.T) {
testHome(t)
if err := WriteConfig(map[string]string{extKeyFlag: "1"}); err != nil {
t.Fatal(err)
}
if err := preflightEngine(); err != nil {
t.Fatalf("pré-vol refusé sur un preset externe : %v", err)
}
}
// Strata : le pré-vol vérifie sa configuration à lui, pas BIN/MODEL.
func TestPreflightStrataWithoutConfig(t *testing.T) {
testHome(t)
if err := WriteConfig(map[string]string{"ENGINE": "strata"}); err != nil {
t.Fatal(err)
}
err := preflightEngine()
if err == nil {
t.Fatal("un preset Strata sans STRATA_CONFIG devrait être refusé")
}
if want := "STRATA_CONFIG"; !contains(err.Error(), want) {
t.Fatalf("message attendu sur %s : %v", want, err)
}
}
+4 -53
View File
@@ -1,7 +1,6 @@
package loki
import (
"context"
"encoding/json"
"net/http"
"net/http/httptest"
@@ -47,7 +46,7 @@ func TestEndpointSuitLePresetActif(t *testing.T) {
}
if err := WriteConfig(parseEnv(externalPresetContent(
"https://api.groq.com/openai/v1", "llama-3.3-70b", "sk-secret", "65536", false))); err != nil {
"https://api.groq.com/openai/v1", "llama-3.3-70b", "sk-secret", "65536"))); err != nil {
t.Fatal(err)
}
ep = resolveChatEndpoint()
@@ -72,7 +71,7 @@ func TestEndpointSuitLePresetActif(t *testing.T) {
func TestLaCleLocaleNePartJamaisChezUnTiers(t *testing.T) {
testHome(t)
if err := WriteConfig(parseEnv(externalPresetContent(
"https://api.openai.com/v1", "gpt-4o-mini", "", "", false))); err != nil {
"https://api.openai.com/v1", "gpt-4o-mini", "", ""))); err != nil {
t.Fatal(err)
}
// Une clé d'API locale existe et n'a rien à faire dans la requête sortante.
@@ -143,7 +142,7 @@ func TestRouteDeTestRemonteLErreurDeLAPI(t *testing.T) {
// d'effacement.
func TestEditionSansToucherALaCleLaConserve(t *testing.T) {
testHome(t)
id, err := SavePreset("", "Groq", externalPresetContent("https://api.groq.com/openai/v1", "llama", "sk-gardee", "", false))
id, err := SavePreset("", "Groq", externalPresetContent("https://api.groq.com/openai/v1", "llama", "sk-gardee", ""))
if err != nil {
t.Fatal(err)
}
@@ -180,7 +179,7 @@ func TestEditionSansToucherALaCleLaConserve(t *testing.T) {
// Un preset externe demande explicitement d'écraser la clé par une vide.
func TestEffacementExpliciteDeLaCle(t *testing.T) {
testHome(t)
id, err := SavePreset("", "Local distant", externalPresetContent("http://192.168.1.20:8080", "qwen", "sk-a-jeter", "", false))
id, err := SavePreset("", "Local distant", externalPresetContent("http://192.168.1.20:8080", "qwen", "sk-a-jeter", ""))
if err != nil {
t.Fatal(err)
}
@@ -195,51 +194,3 @@ func TestEffacementExpliciteDeLaCle(t *testing.T) {
t.Fatalf("clé = %q, attendu effacée (keyTouched avec champ vide)", k)
}
}
// Le résumé de compactage n'envoie pas chat_template_kwargs (propre à
// llama.cpp) à une API distante : une API stricte répond 400 et la compaction
// échouait à chaque fois.
func TestCompactageExterneSansKwargsLlamaCpp(t *testing.T) {
testHome(t)
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
var body map[string]any
_ = json.NewDecoder(r.Body).Decode(&body)
if _, ok := body["chat_template_kwargs"]; ok {
w.WriteHeader(400)
_, _ = w.Write([]byte(`{"error":{"message":"Unrecognized request argument supplied: chat_template_kwargs"}}`))
return
}
sendJSON(w, 200, map[string]any{"choices": []any{map[string]any{"message": map[string]any{"content": "- résumé"}}}})
}))
t.Cleanup(srv.Close)
if err := WriteConfig(parseEnv(externalPresetContent(srv.URL, "gpt-4o-mini", "", "", false))); err != nil {
t.Fatal(err)
}
if _, err := summarizeTranscript(context.Background(), "user: bonjour\nassistant: salut"); err != nil {
t.Fatalf("compactage refusé par l'API distante : %v", err)
}
}
// Vision et benchmark d'un preset externe : la case du preset décide, et le
// bench (qui mesure le moteur local) refuse de tourner.
func TestPresetExterneVisionEtBench(t *testing.T) {
testHome(t)
if err := WriteConfig(parseEnv(externalPresetContent("https://api.openai.com/v1", "gpt-4o", "", "", true))); err != nil {
t.Fatal(err)
}
if !visionEnabled() || !engineSeesImages() {
t.Error("preset externe déclaré multimodal : la vision devrait être active")
}
if _, err := runBench(context.Background(), benchOpts{Prompt: 10, Predict: 10}, nil); err == nil {
t.Error("benchmark lancé sur un preset externe")
}
if got := chatModelName(); got != "gpt-4o" {
t.Errorf("badge de modèle = %q, attendu le modèle distant", got)
}
if err := WriteConfig(parseEnv(externalPresetContent("https://api.openai.com/v1", "gpt-4o", "", "", false))); err != nil {
t.Fatal(err)
}
if visionEnabled() {
t.Error("preset externe texte seul : la vision devrait être coupée")
}
}
-624
View File
@@ -1,624 +0,0 @@
package loki
import (
"bufio"
"encoding/binary"
"errors"
"fmt"
"io"
"math"
"math/bits"
"os"
"path/filepath"
"strconv"
"strings"
"sync"
)
// Lecteur des métadonnées GGUF. Plusieurs réglages du moteur dépendent de ce que
// le fichier contient VRAIMENT : un modèle qui embarque une tête MTP (prédiction
// de plusieurs jetons, vérifiée exactement par le modèle cible), le nombre de
// têtes KV par couche pour estimer le cache, une architecture hybride à état
// récurrent. Jusqu'ici Loki ne le savait qu'à travers le nom du fichier, ce qui
// ne prouve rien.
//
// On ne lit que l'en-tête, la section clé/valeur et la table des tenseurs : les
// poids eux-mêmes ne sont jamais touchés, quelques Mo au plus pour un modèle de
// 30 Go. Le fichier peut être en cours de téléchargement, tronqué, ou tout
// simplement pas un GGUF : chaque longueur lue est bornée par ce qui reste du
// fichier, et la moindre incohérence donne une erreur, jamais un plantage ni une
// allocation géante. Une erreur veut dire « on ne sait pas » : l'appelant reste
// sur le comportement prudent.
// GGUFInfo résume ce que Loki retient d'un modèle GGUF. Les champs absents du
// fichier restent à zéro.
type GGUFInfo struct {
Version int // version du format (1 à 3)
Arch string // general.architecture
BlockCount int // <arch>.block_count
NextN int // <arch>.nextn_predict_layers (couches MTP annoncées)
ContextLength int // <arch>.context_length (contexte d'entraînement)
ExpertCount int // <arch>.expert_count (0 = modèle dense)
KeyLen int // <arch>.attention.key_length
ValLen int // <arch>.attention.value_length
// HeadCountKV : valeur unique, ou maximum sur les couches quand le fichier
// donne un tableau par couche (HeadCountKVLayers, nil sinon). Sur un hybride,
// les couches récurrentes y valent 0.
HeadCountKV int
HeadCountKVLayers []int
FullAttnInterval int // <arch>.full_attention_interval, si présent
Hybrid bool // au moins une clé <arch>.ssm.* : couches à état récurrent
// Repli de llama.cpp quand key_length / value_length manquent : n_embd / n_head.
EmbeddingLength int // <arch>.embedding_length
HeadCount int // <arch>.attention.head_count (valeur unique seulement)
// Taille de l'état récurrent par couche (llama_hparams::n_embd_r / n_embd_s),
// pour estimer ce que pèse un état sauvegardé d'un modèle hybride.
SSMConv int // <arch>.ssm.conv_kernel
SSMInner int // <arch>.ssm.inner_size
SSMState int // <arch>.ssm.state_size
SSMGroups int // <arch>.ssm.group_count
// HasNextNTensor : le tenseur blk.{BlockCount-1}.nextn.eh_proj.weight existe
// dans l'une des tranches. C'est le test de llama.cpp lui-même pour reconnaître
// une tête MTP : la clé nextn_predict_layers seule ne suffit pas, certains GGUF
// la gardent alors que les tenseurs ont été retirés (tête publiée à part).
HasNextNTensor bool
TensorCount int // total sur toutes les tranches
}
// Bornes de lecture. Elles sont larges devant les vrais modèles (une poignée de
// milliers de tenseurs, une cinquantaine de clés hors vocabulaire) et servent
// seulement à refuser un fichier aberrant avant d'allouer quoi que ce soit.
const (
ggufMaxString = 64 << 20 // modèle de chat le plus long connu : quelques dizaines de Ko
ggufMaxKeyLen = 64 << 10
ggufMaxTensorName = 4096 // GGML_MAX_NAME vaut 64
ggufMaxKV = 1 << 20
ggufMaxTensors = 1 << 22
ggufMaxDims = 8 // GGML_MAX_DIMS vaut 4
ggufMaxArrayDepth = 4
ggufMaxLayerArray = 1 << 16
)
// Types de valeur GGUF (gguf.h).
const (
ggufUint8 uint32 = iota
ggufInt8
ggufUint16
ggufInt16
ggufUint32
ggufInt32
ggufFloat32
ggufBool
ggufString
ggufArray
ggufUint64
ggufInt64
ggufFloat64
)
var errGGUFTruncated = errors.New("GGUF tronqué")
// ggufScalarSize : taille en octets d'un type de taille fixe, 0 sinon.
func ggufScalarSize(t uint32) int64 {
switch t {
case ggufUint8, ggufInt8, ggufBool:
return 1
case ggufUint16, ggufInt16:
return 2
case ggufUint32, ggufInt32, ggufFloat32:
return 4
case ggufUint64, ggufInt64, ggufFloat64:
return 8
}
return 0
}
// ggufReader lit séquentiellement un GGUF en comptant ce qui reste du fichier :
// toute longueur annoncée au-delà est une incohérence, détectée AVANT de lire.
type ggufReader struct {
r *bufio.Reader
bo binary.ByteOrder
v1 bool // GGUF v1 : compteurs et longueurs sur 32 bits
size int64 // taille totale du fichier
left int64 // octets restants dans le fichier
buf [8]byte
}
func (g *ggufReader) fixed(n int) ([]byte, error) {
if int64(n) > g.left {
return nil, errGGUFTruncated
}
b := g.buf[:n]
if _, err := io.ReadFull(g.r, b); err != nil {
return nil, errGGUFTruncated
}
g.left -= int64(n)
return b, nil
}
func (g *ggufReader) u32() (uint32, error) {
b, err := g.fixed(4)
if err != nil {
return 0, err
}
return g.bo.Uint32(b), nil
}
func (g *ggufReader) u64() (uint64, error) {
b, err := g.fixed(8)
if err != nil {
return 0, err
}
return g.bo.Uint64(b), nil
}
// count lit un compteur ou une longueur : 32 bits en v1, 64 bits ensuite.
func (g *ggufReader) count() (uint64, error) {
if g.v1 {
v, err := g.u32()
return uint64(v), err
}
return g.u64()
}
// countSize : taille sur disque d'un compteur, pour borner les tableaux de chaînes.
func (g *ggufReader) countSize() int64 {
if g.v1 {
return 4
}
return 8
}
func (g *ggufReader) skip(n int64) error {
if n < 0 || n > g.left {
return errGGUFTruncated
}
for rest := n; rest > 0; {
step := min(rest, 1<<30)
if _, err := g.r.Discard(int(step)); err != nil {
return errGGUFTruncated
}
rest -= step
}
g.left -= n
return nil
}
// str lit une chaîne d'au plus max octets.
func (g *ggufReader) str(max uint64) (string, error) {
n, err := g.count()
if err != nil {
return "", err
}
if n > max {
return "", fmt.Errorf("chaîne GGUF de %d octets", n)
}
if int64(n) > g.left {
return "", errGGUFTruncated
}
b := make([]byte, n)
if _, err := io.ReadFull(g.r, b); err != nil {
return "", errGGUFTruncated
}
g.left -= int64(n)
return string(b), nil
}
func (g *ggufReader) skipStr() error {
n, err := g.count()
if err != nil {
return err
}
if n > ggufMaxString {
return fmt.Errorf("chaîne GGUF de %d octets", n)
}
return g.skip(int64(n))
}
// intVal lit une valeur entière (ou booléenne) de type t. ok vaut false pour les
// flottants, lus et ignorés : aucune des clés retenues n'en est un.
func (g *ggufReader) intVal(t uint32) (v int64, ok bool, err error) {
size := ggufScalarSize(t)
if size == 0 {
return 0, false, fmt.Errorf("type GGUF %d inattendu", t)
}
b, err := g.fixed(int(size))
if err != nil {
return 0, false, err
}
switch t {
case ggufUint8, ggufBool:
return int64(b[0]), true, nil
case ggufInt8:
return int64(int8(b[0])), true, nil
case ggufUint16:
return int64(g.bo.Uint16(b)), true, nil
case ggufInt16:
return int64(int16(g.bo.Uint16(b))), true, nil
case ggufUint32:
return int64(g.bo.Uint32(b)), true, nil
case ggufInt32:
return int64(int32(g.bo.Uint32(b))), true, nil
case ggufUint64:
u := g.bo.Uint64(b)
if u > math.MaxInt64 {
u = math.MaxInt64
}
return int64(u), true, nil
case ggufInt64:
return int64(g.bo.Uint64(b)), true, nil
}
return 0, false, nil // flottant
}
// skipArray saute n éléments de type et, tableaux imbriqués compris (profondeur
// bornée). Le vocabulaire (150 000 chaînes) passe par ici sans rien allouer.
func (g *ggufReader) skipArray(et uint32, n uint64, depth int) error {
if depth > ggufMaxArrayDepth {
return errors.New("tableaux GGUF trop imbriqués")
}
if size := ggufScalarSize(et); size > 0 {
if n > uint64(g.left/size) {
return errGGUFTruncated
}
return g.skip(int64(n) * size)
}
switch et {
case ggufString:
if n > uint64(g.left/g.countSize()) {
return errGGUFTruncated
}
for i := uint64(0); i < n; i++ {
if err := g.skipStr(); err != nil {
return err
}
}
return nil
case ggufArray:
// Chaque sous-tableau coûte au moins son type et son compteur.
if n > uint64(g.left/(4+g.countSize())) {
return errGGUFTruncated
}
for i := uint64(0); i < n; i++ {
sub, err := g.u32()
if err != nil {
return err
}
sn, err := g.count()
if err != nil {
return err
}
if err := g.skipArray(sub, sn, depth+1); err != nil {
return err
}
}
return nil
}
return fmt.Errorf("type GGUF %d inconnu", et)
}
// ggufFile : ce qu'on retient d'UNE tranche avant de résoudre les clés selon
// l'architecture (general.architecture n'est pas forcément la première clé).
type ggufFile struct {
version int
arch string
ints map[string]int64
arrays map[string][]int64 // tableaux d'entiers retenus (têtes KV par couche)
keys []string // noms de toutes les clés, pour repérer <arch>.ssm.*
tensors int
nextn []string // noms des tenseurs *.nextn.eh_proj.weight
}
// keepIntArray : les seuls tableaux qu'on lit au lieu de les sauter.
func keepIntArray(key string) bool {
return strings.HasSuffix(key, ".attention.head_count_kv")
}
// readGGUFHeader lit l'en-tête, les clés et la table des tenseurs d'un fichier.
func readGGUFHeader(path string) (*ggufFile, error) {
fh, err := os.Open(path)
if err != nil {
return nil, err
}
defer fh.Close()
st, err := fh.Stat()
if err != nil {
return nil, err
}
if st.IsDir() {
return nil, fmt.Errorf("%s est un dossier", baseName(path))
}
f, err := parseGGUF(fh, st.Size())
if err != nil {
return nil, fmt.Errorf("%s : %w", baseName(path), err)
}
return f, nil
}
// parseGGUF lit size octets au plus de r. Un panic éventuel (fichier
// pathologique non prévu par les bornes) est rattrapé en erreur : lire des
// métadonnées ne doit jamais faire tomber Loki.
func parseGGUF(r io.Reader, size int64) (f *ggufFile, err error) {
defer func() {
if p := recover(); p != nil {
f, err = nil, fmt.Errorf("GGUF illisible : %v", p)
}
}()
g := &ggufReader{r: bufio.NewReaderSize(r, 1<<20), bo: binary.LittleEndian, size: size, left: size}
magic, err := g.fixed(4)
if err != nil {
return nil, err
}
if string(magic) != "GGUF" {
return nil, errors.New("pas un fichier GGUF")
}
ver, err := g.u32()
if err != nil {
return nil, err
}
// Les GGUF gros-boutistes (s390x) gardent la même signature : seule la
// version lue à l'envers trahit l'ordre des octets.
if ver&0xFFFF == 0 {
if sw := bits.ReverseBytes32(ver); sw >= 1 && sw <= 3 {
g.bo, ver = binary.BigEndian, sw
}
}
if ver < 1 || ver > 3 {
return nil, fmt.Errorf("version GGUF %d non prise en charge", ver)
}
g.v1 = ver == 1
nTensors, err := g.count()
if err != nil {
return nil, err
}
nKV, err := g.count()
if err != nil {
return nil, err
}
if nKV > ggufMaxKV || nTensors > ggufMaxTensors {
return nil, fmt.Errorf("compteurs GGUF aberrants (%d clés, %d tenseurs)", nKV, nTensors)
}
f = &ggufFile{version: int(ver), ints: map[string]int64{}, arrays: map[string][]int64{}, tensors: int(nTensors)}
for i := uint64(0); i < nKV; i++ {
key, err := g.str(ggufMaxKeyLen)
if err != nil {
return nil, err
}
t, err := g.u32()
if err != nil {
return nil, err
}
f.keys = append(f.keys, key)
switch t {
case ggufString:
if key == "general.architecture" {
if f.arch, err = g.str(ggufMaxKeyLen); err != nil {
return nil, err
}
} else if err := g.skipStr(); err != nil {
return nil, err
}
case ggufArray:
et, err := g.u32()
if err != nil {
return nil, err
}
n, err := g.count()
if err != nil {
return nil, err
}
if keepIntArray(key) && et != ggufFloat32 && et != ggufFloat64 && ggufScalarSize(et) > 0 && n <= ggufMaxLayerArray {
vals := make([]int64, 0, min(n, uint64(g.left)))
for j := uint64(0); j < n; j++ {
v, _, err := g.intVal(et)
if err != nil {
return nil, err
}
vals = append(vals, v)
}
f.arrays[key] = vals
} else if err := g.skipArray(et, n, 1); err != nil {
return nil, err
}
default:
v, ok, err := g.intVal(t)
if err != nil {
return nil, err
}
if ok {
f.ints[key] = v
}
}
}
dimSize := int64(8)
if g.v1 {
dimSize = 4
}
var maxOff uint64
for i := uint64(0); i < nTensors; i++ {
name, err := g.str(ggufMaxTensorName)
if err != nil {
return nil, err
}
nd, err := g.u32()
if err != nil {
return nil, err
}
if nd > ggufMaxDims {
return nil, fmt.Errorf("tenseur %q à %d dimensions", name, nd)
}
// dimensions et type (u32), puis position des données (u64)
if err := g.skip(int64(nd)*dimSize + 4); err != nil {
return nil, err
}
off, err := g.u64()
if err != nil {
return nil, err
}
maxOff = max(maxOff, off)
if strings.HasSuffix(name, ".nextn.eh_proj.weight") {
f.nextn = append(f.nextn, name)
}
}
// Les données commencent au prochain multiple de general.alignment. Le début
// du dernier tenseur doit tenir dans le fichier : un GGUF coupé dans ses poids
// (copie interrompue, disque plein) a un en-tête intact, il faut le voir
// quand même. On ne connaît pas la taille de chaque tenseur sans la table des
// types ggml : vérifier son premier octet suffit à attraper l'essentiel.
align := int64(32)
if a, ok := f.ints["general.alignment"]; ok {
if a <= 0 || a > 1<<20 || a&(a-1) != 0 {
return nil, fmt.Errorf("alignement GGUF %d invalide", a)
}
align = a
}
if nTensors > 0 {
pos := g.size - g.left
dataStart := (pos + align - 1) / align * align
if maxOff >= uint64(g.size) || dataStart+int64(maxOff) >= g.size {
return nil, errGGUFTruncated
}
}
return f, nil
}
// clampInt ramène une valeur de métadonnée dans [0, MaxInt32] : un champ négatif
// ou démesuré n'a pas de sens pour un compte de couches ou de têtes.
func clampInt(v int64) int {
if v < 0 {
return 0
}
if v > math.MaxInt32 {
return math.MaxInt32
}
return int(v)
}
// ggufInfoFrom résout les clés de la première tranche selon l'architecture et
// cherche la tête MTP dans les tenseurs de toutes les tranches.
func ggufInfoFrom(first *ggufFile, all []*ggufFile) GGUFInfo {
in := GGUFInfo{Version: first.version, Arch: first.arch}
p := first.arch + "."
get := func(k string) int { return clampInt(first.ints[p+k]) }
in.BlockCount = get("block_count")
in.NextN = get("nextn_predict_layers")
in.ContextLength = get("context_length")
in.ExpertCount = get("expert_count")
in.KeyLen = get("attention.key_length")
in.ValLen = get("attention.value_length")
in.FullAttnInterval = get("full_attention_interval")
in.EmbeddingLength = get("embedding_length")
in.HeadCount = get("attention.head_count")
in.SSMConv = get("ssm.conv_kernel")
in.SSMInner = get("ssm.inner_size")
in.SSMState = get("ssm.state_size")
in.SSMGroups = get("ssm.group_count")
in.HeadCountKV = get("attention.head_count_kv")
if arr, ok := first.arrays[p+"attention.head_count_kv"]; ok {
in.HeadCountKVLayers = make([]int, len(arr))
in.HeadCountKV = 0
for i, v := range arr {
in.HeadCountKVLayers[i] = clampInt(v)
in.HeadCountKV = max(in.HeadCountKV, in.HeadCountKVLayers[i])
}
}
if first.arch != "" {
for _, k := range first.keys {
if strings.HasPrefix(k, p+"ssm.") {
in.Hybrid = true
break
}
}
}
want := ""
if in.BlockCount > 0 {
want = "blk." + strconv.Itoa(in.BlockCount-1) + ".nextn.eh_proj.weight"
}
for _, f := range all {
in.TensorCount += f.tensors
for _, n := range f.nextn {
if n == want {
in.HasNextNTensor = true
}
}
}
return in
}
// Cache par chemin : la même liste de modèles est consultée à chaque démarrage
// et à chaque affichage. L'empreinte (taille et date de chaque tranche) suffit à
// voir un fichier remplacé ou un téléchargement terminé ; les erreurs ne sont
// pas gardées, un fichier en cours de téléchargement sera relu une fois complet.
type ggufCacheEntry struct {
stamp string
info GGUFInfo
}
var (
ggufCacheMu sync.Mutex
ggufCache = map[string]ggufCacheEntry{}
)
const ggufCacheMax = 256
// ggufMeta lit les métadonnées du modèle désigné par path. Pour un modèle en
// tranches, path peut désigner n'importe laquelle : les clés viennent de la
// première (les suivantes ne portent que split.*), les tenseurs de toutes. Une
// tranche manquante est une erreur : le modèle ne démarrerait pas, et on ne
// saurait pas dire si la tête MTP s'y trouvait.
func ggufMeta(path string) (GGUFInfo, error) {
path = filepath.Clean(path)
dir := filepath.Dir(path)
fam := shardFamily(path)
paths := make([]string, len(fam))
var stamp strings.Builder
for i, n := range fam {
paths[i] = filepath.Join(dir, n)
st, err := os.Stat(paths[i])
if err != nil {
return GGUFInfo{}, err
}
if st.IsDir() {
return GGUFInfo{}, fmt.Errorf("%s est un dossier", n)
}
fmt.Fprintf(&stamp, "%d:%d;", st.Size(), st.ModTime().UnixNano())
}
key := paths[0]
ggufCacheMu.Lock()
e, ok := ggufCache[key]
ggufCacheMu.Unlock()
if ok && e.stamp == stamp.String() {
return cloneGGUFInfo(e.info), nil
}
files := make([]*ggufFile, 0, len(paths))
for _, p := range paths {
f, err := readGGUFHeader(p)
if err != nil {
return GGUFInfo{}, err
}
files = append(files, f)
}
info := ggufInfoFrom(files[0], files)
ggufCacheMu.Lock()
if len(ggufCache) >= ggufCacheMax {
ggufCache = map[string]ggufCacheEntry{}
}
ggufCache[key] = ggufCacheEntry{stamp: stamp.String(), info: info}
ggufCacheMu.Unlock()
return cloneGGUFInfo(info), nil
}
// cloneGGUFInfo : l'appelant peut modifier le tableau par couche sans toucher au
// cache.
func cloneGGUFInfo(in GGUFInfo) GGUFInfo {
if in.HeadCountKVLayers != nil {
in.HeadCountKVLayers = append([]int(nil), in.HeadCountKVLayers...)
}
return in
}
-417
View File
@@ -1,417 +0,0 @@
package loki
import (
"bytes"
"encoding/binary"
"math"
"os"
"path/filepath"
"reflect"
"strings"
"testing"
"time"
)
// ggufBuilder écrit un GGUF synthétique : en-tête, clés typées, table des
// tenseurs, sans aucune donnée de poids (le lecteur n'en lit jamais).
type ggufBuilder struct {
bo binary.ByteOrder
version uint32
nKV int
nT int
kv bytes.Buffer
tensors bytes.Buffer
}
func newGGUF() *ggufBuilder { return &ggufBuilder{bo: binary.LittleEndian, version: 3} }
func (b *ggufBuilder) cnt(w *bytes.Buffer, n uint64) {
if b.version == 1 {
binary.Write(w, b.bo, uint32(n))
} else {
binary.Write(w, b.bo, n)
}
}
func (b *ggufBuilder) str(w *bytes.Buffer, s string) {
b.cnt(w, uint64(len(s)))
w.WriteString(s)
}
func (b *ggufBuilder) key(k string, t uint32) {
b.nKV++
b.str(&b.kv, k)
binary.Write(&b.kv, b.bo, t)
}
func (b *ggufBuilder) u32(k string, v uint32) *ggufBuilder {
b.key(k, ggufUint32)
binary.Write(&b.kv, b.bo, v)
return b
}
func (b *ggufBuilder) i64(k string, v int64) *ggufBuilder {
b.key(k, ggufInt64)
binary.Write(&b.kv, b.bo, v)
return b
}
func (b *ggufBuilder) f32(k string, v float32) *ggufBuilder {
b.key(k, ggufFloat32)
binary.Write(&b.kv, b.bo, math.Float32bits(v))
return b
}
func (b *ggufBuilder) boolean(k string, v bool) *ggufBuilder {
b.key(k, ggufBool)
if v {
b.kv.WriteByte(1)
} else {
b.kv.WriteByte(0)
}
return b
}
func (b *ggufBuilder) s(k, v string) *ggufBuilder {
b.key(k, ggufString)
b.str(&b.kv, v)
return b
}
func (b *ggufBuilder) strArr(k string, vs []string) *ggufBuilder {
b.key(k, ggufArray)
binary.Write(&b.kv, b.bo, ggufString)
b.cnt(&b.kv, uint64(len(vs)))
for _, v := range vs {
b.str(&b.kv, v)
}
return b
}
func (b *ggufBuilder) i32Arr(k string, vs []int32) *ggufBuilder {
b.key(k, ggufArray)
binary.Write(&b.kv, b.bo, ggufInt32)
b.cnt(&b.kv, uint64(len(vs)))
for _, v := range vs {
binary.Write(&b.kv, b.bo, v)
}
return b
}
// nested : tableau de deux tableaux de u8, pour vérifier le saut récursif.
func (b *ggufBuilder) nested(k string) *ggufBuilder {
b.key(k, ggufArray)
binary.Write(&b.kv, b.bo, ggufArray)
b.cnt(&b.kv, 2)
for i := 0; i < 2; i++ {
binary.Write(&b.kv, b.bo, ggufUint8)
b.cnt(&b.kv, 3)
b.kv.Write([]byte{1, 2, 3})
}
return b
}
func (b *ggufBuilder) tensor(name string, dims ...uint64) *ggufBuilder {
b.str(&b.tensors, name)
binary.Write(&b.tensors, b.bo, uint32(len(dims)))
for _, d := range dims {
if b.version == 1 {
binary.Write(&b.tensors, b.bo, uint32(d))
} else {
binary.Write(&b.tensors, b.bo, d)
}
}
binary.Write(&b.tensors, b.bo, uint32(0)) // type F32
binary.Write(&b.tensors, b.bo, uint64(b.nT*32)) // position des données
b.nT++
return b
}
func (b *ggufBuilder) bytes() []byte {
var out bytes.Buffer
out.WriteString("GGUF")
binary.Write(&out, b.bo, b.version)
b.cnt(&out, uint64(b.nT))
b.cnt(&out, uint64(b.nKV))
out.Write(b.kv.Bytes())
out.Write(b.tensors.Bytes())
// bourrage jusqu'à l'alignement par défaut, puis 32 octets de « poids » par
// tenseur
if b.nT > 0 {
out.Write(make([]byte, (32-out.Len()%32)%32+32*b.nT))
}
return out.Bytes()
}
func writeGGUF(t *testing.T, dir, name string, data []byte) string {
t.Helper()
p := filepath.Join(dir, name)
if err := os.WriteFile(p, data, 0o644); err != nil {
t.Fatal(err)
}
return p
}
// qwenMTP : modèle hybride à 4 couches, têtes KV par couche (0 sur les couches
// récurrentes), tête MTP sur la dernière couche, vocabulaire à sauter.
func qwenMTP(b *ggufBuilder) *ggufBuilder {
return b.u32("general.alignment", 32).
strArr("tokenizer.ggml.tokens", []string{"<s>", "a", "bb", strings.Repeat("c", 300)}).
s("general.architecture", "qwen35").
s("general.name", "Qwen synthétique").
u32("qwen35.block_count", 4).
u32("qwen35.nextn_predict_layers", 1).
i64("qwen35.context_length", 262144).
u32("qwen35.attention.key_length", 256).
u32("qwen35.attention.value_length", 256).
i32Arr("qwen35.attention.head_count_kv", []int32{0, 0, 0, 4}).
u32("qwen35.full_attention_interval", 4).
u32("qwen35.ssm.conv_kernel", 4).
f32("qwen35.rope.freq_base", 1e7).
boolean("tokenizer.ggml.add_bos_token", false).
nested("bidon.imbrique").
tensor("token_embd.weight", 1024, 32000).
tensor("blk.0.attn_norm.weight", 1024).
tensor("blk.3.nextn.eh_proj.weight", 2048, 1024)
}
func TestGGUFMetaQwenHybrideMTP(t *testing.T) {
p := writeGGUF(t, t.TempDir(), "qwen.gguf", qwenMTP(newGGUF()).bytes())
got, err := ggufMeta(p)
if err != nil {
t.Fatal(err)
}
want := GGUFInfo{
Version: 3, Arch: "qwen35", BlockCount: 4, NextN: 1, ContextLength: 262144,
KeyLen: 256, ValLen: 256, HeadCountKV: 4, HeadCountKVLayers: []int{0, 0, 0, 4},
FullAttnInterval: 4, Hybrid: true, SSMConv: 4, HasNextNTensor: true, TensorCount: 3,
}
if !reflect.DeepEqual(got, want) {
t.Fatalf("ggufMeta =\n%+v\nattendu\n%+v", got, want)
}
}
// La clé nextn_predict_layers ne prouve rien : seul le tenseur de la DERNIÈRE
// couche compte, comme dans llama.cpp.
func TestGGUFMetaTeteMTPAbsenteOuMalPlacee(t *testing.T) {
dir := t.TempDir()
base := func() *ggufBuilder {
return newGGUF().s("general.architecture", "glm4moe").
u32("glm4moe.block_count", 47).
u32("glm4moe.nextn_predict_layers", 1).
u32("glm4moe.expert_count", 128).
u32("glm4moe.attention.head_count_kv", 8)
}
cases := []struct {
name string
b *ggufBuilder
}{
{"sans-tenseur.gguf", base().tensor("blk.45.ffn_up.weight", 8, 8)},
{"mauvaise-couche.gguf", base().tensor("blk.45.nextn.eh_proj.weight", 8, 8)},
}
for _, c := range cases {
got, err := ggufMeta(writeGGUF(t, dir, c.name, c.b.bytes()))
if err != nil {
t.Fatalf("%s : %v", c.name, err)
}
if got.HasNextNTensor || got.NextN != 1 || got.ExpertCount != 128 || got.HeadCountKV != 8 ||
got.HeadCountKVLayers != nil || got.Hybrid {
t.Fatalf("%s : %+v", c.name, got)
}
}
got, err := ggufMeta(writeGGUF(t, dir, "ok.gguf", base().tensor("blk.46.nextn.eh_proj.weight", 8, 8).bytes()))
if err != nil || !got.HasNextNTensor {
t.Fatalf("tête MTP en dernière couche non vue : %+v, %v", got, err)
}
}
// Modèle en tranches : les clés sont dans la première, la tête MTP dans la
// dernière. N'importe quelle tranche peut être désignée.
func TestGGUFMetaTranches(t *testing.T) {
dir := t.TempDir()
first := newGGUF().s("general.architecture", "qwen35").u32("qwen35.block_count", 4).
u32("split.count", 2).tensor("token_embd.weight", 8, 8)
second := newGGUF().u32("split.no", 1).u32("split.count", 2).
tensor("blk.3.nextn.eh_proj.weight", 8, 8).tensor("output.weight", 8, 8)
p1 := writeGGUF(t, dir, "m-00001-of-00002.gguf", first.bytes())
writeGGUF(t, dir, "m-00002-of-00002.gguf", second.bytes())
for _, p := range []string{p1, filepath.Join(dir, "m-00002-of-00002.gguf")} {
got, err := ggufMeta(p)
if err != nil {
t.Fatal(err)
}
if got.Arch != "qwen35" || !got.HasNextNTensor || got.TensorCount != 3 {
t.Fatalf("%s : %+v", baseName(p), got)
}
}
// tranche manquante : on ne sait pas, donc erreur
writeGGUF(t, dir, "n-00001-of-00002.gguf", first.bytes())
if _, err := ggufMeta(filepath.Join(dir, "n-00001-of-00002.gguf")); err == nil {
t.Fatal("tranche manquante acceptée")
}
// gguf-split --no-tensor-first-split : la première tranche ne porte que les
// clés, aucun tenseur ni aucune donnée. Elle doit passer telle quelle.
meta := newGGUF().s("general.architecture", "glm4moe").u32("glm4moe.block_count", 47).
u32("split.count", 2)
rest := newGGUF().u32("split.no", 1).tensor("blk.46.nextn.eh_proj.weight", 8, 8)
writeGGUF(t, dir, "s-00001-of-00002.gguf", meta.bytes())
writeGGUF(t, dir, "s-00002-of-00002.gguf", rest.bytes())
got, err := ggufMeta(filepath.Join(dir, "s-00001-of-00002.gguf"))
if err != nil || got.Arch != "glm4moe" || !got.HasNextNTensor || got.TensorCount != 1 {
t.Fatalf("première tranche sans tenseur : %+v, %v", got, err)
}
}
// v1 (compteurs 32 bits) et gros-boutiste : même résultat que le v3 ordinaire.
func TestGGUFMetaVersionsEtOrdre(t *testing.T) {
dir := t.TempDir()
v1 := newGGUF()
v1.version = 1
v2 := newGGUF()
v2.version = 2
be := newGGUF()
be.bo = binary.BigEndian
for name, b := range map[string]*ggufBuilder{"v1.gguf": v1, "v2.gguf": v2, "be.gguf": be} {
got, err := ggufMeta(writeGGUF(t, dir, name, qwenMTP(b).bytes()))
if err != nil {
t.Fatalf("%s : %v", name, err)
}
if got.Arch != "qwen35" || got.BlockCount != 4 || !got.HasNextNTensor ||
!reflect.DeepEqual(got.HeadCountKVLayers, []int{0, 0, 0, 4}) || got.Version != int(b.version) {
t.Fatalf("%s : %+v", name, got)
}
}
}
// Fichier en cours de téléchargement : chaque coupure donne une erreur, jamais
// un panic ni un faux résultat.
func TestGGUFMetaTronque(t *testing.T) {
dir := t.TempDir()
full := qwenMTP(newGGUF()).bytes()
// Toutes les coupures jusqu'au premier octet du dernier tenseur (le seul que
// le lecteur sait situer sans la table des types ggml).
for cut := 0; cut <= len(full)-32; cut++ {
if _, err := parseGGUF(bytes.NewReader(full[:cut]), int64(cut)); err == nil {
t.Fatalf("coupure à %d/%d acceptée", cut, len(full))
}
}
if _, err := parseGGUF(bytes.NewReader(full), int64(len(full))); err != nil {
t.Fatalf("fichier complet refusé : %v", err)
}
// et par le chemin fichier, pour une coupure dans l'en-tête
if _, err := ggufMeta(writeGGUF(t, dir, "cut.gguf", full[:len(full)/3])); err == nil {
t.Fatal("fichier coupé accepté")
}
}
func TestGGUFMetaFichiersAberrants(t *testing.T) {
dir := t.TempDir()
hdr := func(ver uint32, nT, nKV uint64) *bytes.Buffer {
var w bytes.Buffer
w.WriteString("GGUF")
binary.Write(&w, binary.LittleEndian, ver)
binary.Write(&w, binary.LittleEndian, nT)
binary.Write(&w, binary.LittleEndian, nKV)
return &w
}
// clé annoncée à 1 Eo
hugeKey := hdr(3, 0, 1)
binary.Write(hugeKey, binary.LittleEndian, uint64(1)<<60)
// tableau de chaînes annoncé à 2^62 éléments
hugeArr := hdr(3, 0, 1)
binary.Write(hugeArr, binary.LittleEndian, uint64(1))
hugeArr.WriteString("k")
binary.Write(hugeArr, binary.LittleEndian, ggufArray)
binary.Write(hugeArr, binary.LittleEndian, ggufString)
binary.Write(hugeArr, binary.LittleEndian, uint64(1)<<62)
// type de valeur inconnu
badType := hdr(3, 0, 1)
binary.Write(badType, binary.LittleEndian, uint64(1))
badType.WriteString("k")
binary.Write(badType, binary.LittleEndian, uint32(99))
binary.Write(badType, binary.LittleEndian, uint64(0))
// tenseur à 1000 dimensions
badDims := hdr(3, 1, 0)
binary.Write(badDims, binary.LittleEndian, uint64(1))
badDims.WriteString("t")
binary.Write(badDims, binary.LittleEndian, uint32(1000))
badDims.Write(make([]byte, 64))
cases := map[string][]byte{
"vide": nil,
"alignement": newGGUF().u32("general.alignment", 24).tensor("t", 1).bytes(),
"signature": []byte("GGML\x03\x00\x00\x00" + strings.Repeat("\x00", 16)),
"version": hdr(4, 0, 0).Bytes(),
"compteurs": hdr(3, 1<<40, 1<<40).Bytes(),
"cle-geante": hugeKey.Bytes(),
"tableau": hugeArr.Bytes(),
"type-inconnu": badType.Bytes(),
"dimensions": badDims.Bytes(),
}
for name, data := range cases {
if _, err := ggufMeta(writeGGUF(t, dir, name+".gguf", data)); err == nil {
t.Errorf("%s : accepté", name)
}
}
if _, err := ggufMeta(filepath.Join(dir, "absent.gguf")); err == nil {
t.Error("fichier absent accepté")
}
}
// Octet corrompu n'importe où dans l'en-tête (v1 et v3) : le lecteur doit rendre
// la main vite, sans panic remonté ni allocation démesurée. Les valeurs 0xFF et
// 0x7F transforment un compteur en nombre géant, le cas qui ferait mal.
func TestGGUFMetaOctetCorrompu(t *testing.T) {
v1 := newGGUF()
v1.version = 1
for _, b := range []*ggufBuilder{newGGUF(), v1} {
full := qwenMTP(b).bytes()
hdr := len(full) - 3*32 // le bourrage et les « poids » ne sont jamais lus
for i := 0; i < hdr; i++ {
for _, v := range []byte{0xFF, 0x7F, 0x00} {
mut := append([]byte(nil), full...)
mut[i] = v
// le filet de sécurité existe, mais aucune entrée ne doit y tomber
if _, err := parseGGUF(bytes.NewReader(mut), int64(len(mut))); err != nil &&
strings.Contains(err.Error(), "illisible") {
t.Fatalf("v%d, octet %d = %#x : %v", b.version, i, v, err)
}
}
}
}
}
// Le cache tient tant que taille et date sont les mêmes, et se renouvelle dès
// qu'elles changent (téléchargement terminé, fichier remplacé).
func TestGGUFMetaCache(t *testing.T) {
dir := t.TempDir()
data := qwenMTP(newGGUF()).bytes()
p := writeGGUF(t, dir, "c.gguf", data)
stamp := time.Date(2026, 1, 2, 3, 4, 5, 0, time.UTC)
if err := os.Chtimes(p, stamp, stamp); err != nil {
t.Fatal(err)
}
a, err := ggufMeta(p)
if err != nil {
t.Fatal(err)
}
a.HeadCountKVLayers[0] = 99 // ne doit pas atteindre le cache
// même taille, même date, contenu illisible : le cache répond
writeGGUF(t, dir, "c.gguf", make([]byte, len(data)))
if err := os.Chtimes(p, stamp, stamp); err != nil {
t.Fatal(err)
}
b, err := ggufMeta(p)
if err != nil || b.HeadCountKVLayers[0] != 0 || !b.HasNextNTensor {
t.Fatalf("cache non utilisé ou altéré : %+v, %v", b, err)
}
// date différente : relecture, donc erreur sur le contenu illisible
if err := os.Chtimes(p, stamp.Add(time.Second), stamp.Add(time.Second)); err != nil {
t.Fatal(err)
}
if _, err := ggufMeta(p); err == nil {
t.Fatal("fichier modifié servi depuis le cache")
}
}
+10 -124
View File
@@ -2,14 +2,11 @@ package loki
import (
"bufio"
"context"
"errors"
"fmt"
"os"
"os/exec"
"strconv"
"strings"
"time"
)
// backend_gpu.go — sélection du/des GPU utilisés par llama-server.
@@ -29,39 +26,6 @@ type gpuInfo struct {
MemTotal string // en MiB
MemUsed string
Cap string // compute capability
// Liaison PCIe et horloge mémoire, pour guider le placement entre cartes
// inégales (voir annotateDevices). Zéro = inconnu : pilote trop ancien,
// « [N/A] », ou requête retombée sur les seuls champs historiques.
BusID string
LinkGen int // génération PCIe ACTUELLE — elle descend au repos (économie d'énergie)
LinkGenMax int // génération maximale négociable carte + carte mère
LinkWidth int // largeur actuelle (×N), elle aussi réduite au repos sur certaines cartes
LinkWidthMax int
MemClockMax int // horloge mémoire maximale, MHz
}
// gpuBaseFields : la requête historique de detectGPUs, que tout pilote connaît.
// gpuLinkFields : les champs de liaison, ajoutés en fin de ligne. nvidia-smi
// refuse TOUTE la requête au moindre champ inconnu (« is not a valid field to
// query ») : la largeur du bus mémoire, qui n'existe pas, n'y figure donc pas,
// et un refus fait retomber sur la requête historique — jamais de GPU perdu
// pour une information de confort.
const (
gpuBaseFields = "index,name,memory.total,memory.used,compute_cap"
gpuLinkFields = "pci.bus_id,pcie.link.gen.current,pcie.link.gen.max,pcie.link.width.current,pcie.link.width.max,clocks.max.memory"
)
// nvidiaSmiGPUQuery lance « nvidia-smi --query-gpu=<fields> ». Variable pour
// que les tests substituent un faux nvidia-smi. Borné dans le temps comme la
// lecture des jauges (nvidiaSmiTimeout) : un pilote coincé ne doit pas figer
// « loki gpu » ni l'éditeur de preset.
var nvidiaSmiGPUQuery = func(fields string) ([]byte, error) {
ctx, cancel := context.WithTimeout(context.Background(), nvidiaSmiTimeout)
defer cancel()
cmd := hideCmd(exec.CommandContext(ctx, "nvidia-smi", "--query-gpu="+fields, "--format=csv,noheader,nounits"))
cmd.WaitDelay = time.Second
return cmd.Output()
}
func cmdGPU(args []string) error {
@@ -119,9 +83,6 @@ func gpuList() error {
for _, g := range gpus {
mark := " "
line := fmt.Sprintf("[%d] %s — %s/%s MiB (cc %s)", g.Index, g.Name, g.MemUsed, g.MemTotal, g.Cap)
if l := g.linkSummary(); l != "" {
line += " " + l
}
active := sel == "" || selected[g.Index]
if sel != "" && selected[g.Index] {
mark = green("● ")
@@ -148,10 +109,6 @@ func gpuList() error {
// gpuSet writes (or clears) CUDA_VISIBLE_DEVICES in config.env then offers a
// restart so the change takes effect.
func gpuSet(value string) error {
// La sélection GPU relance le moteur : pas pendant une optimisation.
if err := tuneGuard(); err != nil {
return err
}
if err := SetConfigKey("CUDA_VISIBLE_DEVICES", value); err != nil {
return err
}
@@ -174,46 +131,14 @@ func detectGPUs() ([]gpuInfo, error) {
if !hasTool("nvidia-smi") {
return nil, fmt.Errorf("nvidia-smi introuvable — sélection GPU disponible uniquement sur NVIDIA")
}
return queryGPUs(nvidiaSmiGPUQuery)
}
// queryGPUs : le cœur de detectGPUs, la requête passée en paramètre pour être
// testée sans nvidia-smi. UNE invocation d'ordinaire : champs historiques et
// liaison ensemble. La seconde n'a lieu que si nvidia-smi refuse un champ de
// liaison (pilote ancien) — pas sur un délai dépassé, qu'une relance ne ferait
// que doubler.
func queryGPUs(query func(fields string) ([]byte, error)) ([]gpuInfo, error) {
out, err := query(gpuBaseFields + "," + gpuLinkFields)
if err != nil && smiFieldRefused(out, err) {
out, err = query(gpuBaseFields)
}
out, err := hideCmd(exec.Command("nvidia-smi",
"--query-gpu=index,name,memory.total,memory.used,compute_cap",
"--format=csv,noheader,nounits")).Output()
if err != nil {
return nil, fmt.Errorf("nvidia-smi a échoué: %w", err)
}
gpus := parseGPUQuery(string(out))
if len(gpus) == 0 {
return nil, fmt.Errorf("aucun GPU NVIDIA détecté")
}
return gpus, nil
}
// smiFieldRefused : nvidia-smi a-t-il rejeté un champ de la requête ? Il
// l'écrit sur sa sortie standard et sort en code 2 (argument invalide).
func smiFieldRefused(out []byte, err error) bool {
if strings.Contains(string(out), "valid field") {
return true
}
var ee *exec.ExitError
return errors.As(err, &ee) && ee.ExitCode() == 2
}
// parseGPUQuery lit la sortie CSV de detectGPUs, avec ou sans les champs de
// liaison. Fonction pure. Une ligne de moins de cinq colonnes est ignorée,
// comme avant ; une valeur de liaison illisible (« [N/A] », « [Not
// Supported] ») vaut zéro, sans faire tomber la carte.
func parseGPUQuery(out string) []gpuInfo {
var gpus []gpuInfo
for _, line := range strings.Split(strings.TrimSpace(out), "\n") {
for _, line := range strings.Split(strings.TrimSpace(string(out)), "\n") {
parts := strings.Split(line, ",")
if len(parts) < 5 {
continue
@@ -222,51 +147,12 @@ func parseGPUQuery(out string) []gpuInfo {
parts[i] = strings.TrimSpace(parts[i])
}
idx, _ := strconv.Atoi(parts[0])
g := gpuInfo{Index: idx, Name: parts[1], MemTotal: parts[2], MemUsed: parts[3], Cap: parts[4]}
if len(parts) >= 11 {
if b := parts[5]; b != "" && b != "N/A" && !strings.HasPrefix(b, "[") {
g.BusID = b
}
g.LinkGen, g.LinkGenMax = smiNum(parts[6]), smiNum(parts[7])
g.LinkWidth, g.LinkWidthMax = smiNum(parts[8]), smiNum(parts[9])
g.MemClockMax = smiNum(parts[10])
}
gpus = append(gpus, g)
gpus = append(gpus, gpuInfo{
Index: idx, Name: parts[1], MemTotal: parts[2], MemUsed: parts[3], Cap: parts[4],
})
}
return gpus
}
// smiNum lit un entier de nvidia-smi ; « [N/A] », vide ou autre chose = 0.
func smiNum(s string) int {
n, err := strconv.Atoi(strings.TrimSpace(s))
if err != nil || n < 0 {
return 0
if len(gpus) == 0 {
return nil, fmt.Errorf("aucun GPU NVIDIA détecté")
}
return n
}
// linkSummary : la liaison PCIe en clair, « PCIe 4 ×16 », maximum d'abord —
// l'actuel descend au repos et ne dit rien de la carte sous charge. Vide =
// inconnue.
func (g gpuInfo) linkSummary() string {
if g.LinkGenMax == 0 && g.LinkWidthMax == 0 {
return ""
}
s := "PCIe"
if g.LinkGenMax > 0 {
s += " " + strconv.Itoa(g.LinkGenMax)
}
if g.LinkWidthMax > 0 {
s += " ×" + strconv.Itoa(g.LinkWidthMax)
}
if (g.LinkGen > 0 && g.LinkGen < g.LinkGenMax) || (g.LinkWidth > 0 && g.LinkWidth < g.LinkWidthMax) {
known := func(n int) string {
if n <= 0 {
return "?"
}
return strconv.Itoa(n)
}
s += " (actuellement " + known(g.LinkGen) + " ×" + known(g.LinkWidth) + ", réduit au repos)"
}
return s
return gpus, nil
}
-88
View File
@@ -1,88 +0,0 @@
package loki
import (
"errors"
"strings"
"testing"
)
// La sortie enrichie : liaison et horloge lues, « [N/A] » et « [Not
// Supported] » valent zéro sans faire tomber la carte.
func TestParseGPUQueryLiaison(t *testing.T) {
out := `0, NVIDIA GeForce RTX 5060 Ti, 16311, 900, 12.0, 00000000:01:00.0, 1, 5, 8, 8, 14001
1, NVIDIA GeForce RTX 3060, 12288, 300, 8.6, 00000000:05:00.0, [N/A], 4, [Not Supported], 16, [N/A]
`
g := parseGPUQuery(out)
if len(g) != 2 {
t.Fatalf("%d carte(s), attendu 2 : %+v", len(g), g)
}
if g[0].BusID != "00000000:01:00.0" || g[0].LinkGen != 1 || g[0].LinkGenMax != 5 ||
g[0].LinkWidth != 8 || g[0].LinkWidthMax != 8 || g[0].MemClockMax != 14001 {
t.Errorf("carte 0 mal lue : %+v", g[0])
}
if g[1].LinkGen != 0 || g[1].LinkGenMax != 4 || g[1].LinkWidth != 0 || g[1].LinkWidthMax != 16 || g[1].MemClockMax != 0 {
t.Errorf("valeurs [N/A] mal tolérées : %+v", g[1])
}
if g[1].MemTotal != "12288" || g[1].Cap != "8.6" {
t.Errorf("champs historiques perdus : %+v", g[1])
}
if s := g[0].linkSummary(); s != "PCIe 5 ×8 (actuellement 1 ×8, réduit au repos)" {
t.Errorf("résumé de liaison : %q", s)
}
if s := g[1].linkSummary(); s != "PCIe 4 ×16" {
t.Errorf("résumé sans valeur actuelle : %q", s)
}
}
// La sortie historique (cinq colonnes) se lit comme avant, sans liaison.
func TestParseGPUQueryHistorique(t *testing.T) {
g := parseGPUQuery("0, NVIDIA GeForce RTX 3080 Ti, 12288, 1442, 8.6\n")
if len(g) != 1 || g[0].Name != "NVIDIA GeForce RTX 3080 Ti" || g[0].MemTotal != "12288" {
t.Fatalf("lecture historique cassée : %+v", g)
}
if g[0].LinkGenMax != 0 || g[0].BusID != "" || g[0].linkSummary() != "" {
t.Errorf("liaison inventée : %+v", g[0])
}
}
// Un pilote qui refuse un champ de liaison : la requête historique prend le
// relais, les cartes restent détectées. Une autre erreur (délai dépassé) n'est
// pas relancée.
func TestQueryGPUsRepli(t *testing.T) {
var calls []string
refuse := func(fields string) ([]byte, error) {
calls = append(calls, fields)
if strings.Contains(fields, "pcie") {
return []byte(`Field "pcie.link.gen.max" is not a valid field to query.` + "\n"), errors.New("exit status 2")
}
return []byte("0, NVIDIA GeForce RTX 3060, 12288, 300, 8.6\n"), nil
}
g, err := queryGPUs(refuse)
if err != nil || len(g) != 1 || g[0].Name != "NVIDIA GeForce RTX 3060" {
t.Fatalf("repli raté : %+v, %v", g, err)
}
if len(calls) != 2 || calls[1] != gpuBaseFields {
t.Errorf("requêtes : %q", calls)
}
calls = nil
hang := func(fields string) ([]byte, error) {
calls = append(calls, fields)
return nil, errors.New("signal: killed")
}
if _, err := queryGPUs(hang); err == nil {
t.Error("erreur avalée")
}
if len(calls) != 1 {
t.Errorf("un délai dépassé relancé : %d requêtes", len(calls))
}
calls = nil
ok := func(fields string) ([]byte, error) {
calls = append(calls, fields)
return []byte("0, NVIDIA GeForce RTX 3060, 12288, 300, 8.6, 00000000:05:00.0, 4, 4, 16, 16, 7501\n"), nil
}
if g, err := queryGPUs(ok); err != nil || len(calls) != 1 || g[0].LinkWidthMax != 16 {
t.Errorf("une seule requête attendue : %d, %+v, %v", len(calls), g, err)
}
}
-3
View File
@@ -217,9 +217,6 @@ func llamacppInstall(args []string) error {
// ---------------------------------------------------------------------------
func llamacppUpdate(args []string) error {
if err := tuneGuard(); err != nil {
return err
}
ref := ""
clean := false
noRestart := false
+8 -142
View File
@@ -282,96 +282,10 @@ type dlState struct {
Part int `json:"part"` // tranche en cours (1-based)
Finished bool `json:"finished"`
Canceled bool `json:"canceled"`
Paused bool `json:"paused"`
Err string `json:"error"`
StartedAt int64 `json:"started_at"`
cancel context.CancelFunc `json:"-"` // set while in flight, cleared on finish
gate *dlGate `json:"-"`
}
// dlGate met un téléchargement en pause (repris d'AJEAN v0.18.1) : les
// connexions en cours sont coupées (rien ne reste ouvert vers le CDN pendant la
// pause), et chaque morceau attend la reprise puis repart de l'octet où il
// s'était arrêté, par une requête de plage.
type dlGate struct {
mu sync.Mutex
paused bool
wake chan struct{} // fermé à la reprise
attempt map[int]context.CancelFunc // requêtes en cours, coupées par la pause
next int
}
func newDlGate() *dlGate { return &dlGate{attempt: map[int]context.CancelFunc{}} }
func (g *dlGate) setPaused(p bool) {
if g == nil {
return
}
g.mu.Lock()
defer g.mu.Unlock()
if p == g.paused {
return
}
g.paused = p
if p {
g.wake = make(chan struct{})
for _, c := range g.attempt {
c()
}
return
}
close(g.wake)
}
// wait bloque tant que le téléchargement est en pause. false = annulé entre-temps.
func (g *dlGate) wait(ctx context.Context) bool {
if g == nil {
return ctx.Err() == nil
}
g.mu.Lock()
paused, wake := g.paused, g.wake
g.mu.Unlock()
if !paused {
return ctx.Err() == nil
}
select {
case <-wake:
return ctx.Err() == nil
case <-ctx.Done():
return false
}
}
func (g *dlGate) isPaused() bool {
if g == nil {
return false
}
g.mu.Lock()
defer g.mu.Unlock()
return g.paused
}
// begin ouvre une requête que la pause pourra couper ; la fonction rendue la referme.
func (g *dlGate) begin(ctx context.Context) (context.Context, func()) {
if g == nil {
return ctx, func() {}
}
actx, cancel := context.WithCancel(ctx)
g.mu.Lock()
id := g.next
g.next++
g.attempt[id] = cancel
if g.paused {
cancel() // pause tombée entre wait et begin
}
g.mu.Unlock()
return actx, func() {
g.mu.Lock()
delete(g.attempt, id)
g.mu.Unlock()
cancel()
}
}
var (
@@ -515,7 +429,7 @@ func handleModelDownload(w http.ResponseWriter, r *http.Request) {
ctx, cancel := context.WithCancel(context.Background())
st := &dlState{
Filename: name, URL: urls[0], Dir: filepath.Dir(dests[0]),
Parts: len(todoURLs), StartedAt: time.Now().Unix(), cancel: cancel, gate: newDlGate(),
Parts: len(todoURLs), StartedAt: time.Now().Unix(), cancel: cancel,
}
dlDownloads[name] = st
dlMu.Unlock()
@@ -845,9 +759,7 @@ func runDownloadSet(ctx context.Context, st *dlState, urls, dests []string) {
dt := now.Sub(lastAt).Seconds()
dlMu.Lock()
st.Done = cur
if st.Paused {
st.Speed = 0
} else if dt > 0 {
if dt > 0 {
inst := int64(float64(cur-last) / dt)
if st.Speed == 0 {
st.Speed = inst
@@ -915,7 +827,7 @@ func dlOnePart(ctx context.Context, st *dlState, dlURL, dest string, total int64
return err
}
}
if err := dlFetch(ctx, st.gate, f, dlURL, total, conns, done); err != nil {
if err := dlFetch(ctx, f, dlURL, total, conns, done); err != nil {
f.Close()
_ = os.Remove(tmp)
return err
@@ -933,9 +845,9 @@ func dlOnePart(ctx context.Context, st *dlState, dlURL, dest string, total int64
// dlFetch writes the whole body into f, either as one stream or as `conns`
// parallel byte ranges. done is incremented atomically as bytes land on disk.
func dlFetch(ctx context.Context, g *dlGate, f *os.File, dlURL string, total int64, conns int, done *int64) error {
func dlFetch(ctx context.Context, f *os.File, dlURL string, total int64, conns int, done *int64) error {
if conns <= 1 {
return dlChunk(ctx, g, f, dlURL, 0, total-1, total <= 0, done)
return dlChunk(ctx, f, dlURL, 0, total-1, total <= 0, done)
}
size := total / int64(conns)
var wg sync.WaitGroup
@@ -949,7 +861,7 @@ func dlFetch(ctx context.Context, g *dlGate, f *os.File, dlURL string, total int
wg.Add(1)
go func(i int, start, end int64) {
defer wg.Done()
errs[i] = dlChunk(ctx, g, f, dlURL, start, end, false, done)
errs[i] = dlChunk(ctx, f, dlURL, start, end, false, done)
}(i, start, end)
}
wg.Wait()
@@ -964,15 +876,11 @@ func dlFetch(ctx context.Context, g *dlGate, f *os.File, dlURL string, total int
// dlChunk downloads [start,end] into f at the right offset, retrying from where
// it stopped if the connection drops mid-chunk. With whole=true it streams the
// entire body sequentially (server without range support, unknown size).
func dlChunk(ctx context.Context, g *dlGate, f *os.File, dlURL string, start, end int64, whole bool, done *int64) error {
func dlChunk(ctx context.Context, f *os.File, dlURL string, start, end int64, whole bool, done *int64) error {
const attempts = 4
pos := start
var lastErr error
for try := 0; try < attempts; try++ {
// Une pause n'est pas un échec : on attend la reprise sans consommer d'essai.
if !g.wait(ctx) {
return ctx.Err()
}
if err := ctx.Err(); err != nil {
return err // cancelled: never retry
}
@@ -992,26 +900,18 @@ func dlChunk(ctx context.Context, g *dlGate, f *os.File, dlURL string, start, en
} else if pos > start {
rng = fmt.Sprintf("bytes=%d-", pos) // best-effort resume
}
actx, release := g.begin(ctx)
req, err := dlRequest(actx, dlURL, rng)
req, err := dlRequest(ctx, dlURL, rng)
if err != nil {
release()
return err
}
resp, err := dlClient.Do(req)
if err != nil {
release()
if g.isPaused() && ctx.Err() == nil {
try--
continue
}
lastErr = err
continue
}
if resp.StatusCode != 200 && resp.StatusCode != 206 {
err := dlSourceError(resp, dlURL)
resp.Body.Close()
release()
return err
}
if resp.StatusCode == 200 && pos > start {
@@ -1021,15 +921,10 @@ func dlChunk(ctx context.Context, g *dlGate, f *os.File, dlURL string, start, en
}
n, cerr := dlCopy(f, resp.Body, pos, done)
resp.Body.Close()
release()
pos += n
if cerr == nil {
return nil
}
if g.isPaused() && ctx.Err() == nil {
try-- // coupé par la pause : la reprise repart de pos
continue
}
lastErr = cerr
}
return lastErr
@@ -1086,35 +981,6 @@ func handleModelDownloadCancel(w http.ResponseWriter, r *http.Request) {
sendJSON(w, 200, map[string]any{"ok": true})
}
// handleModelDownloadPause met en pause ({"pause":true}) ou reprend
// ({"pause":false}) un téléchargement en cours. Le fichier partiel est gardé.
func handleModelDownloadPause(w http.ResponseWriter, r *http.Request) {
var req struct {
Filename string `json:"filename"`
Pause bool `json:"pause"`
}
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
sendJSON(w, 400, map[string]any{"ok": false, "error": err.Error()})
return
}
name := filepath.Base(strings.TrimSpace(req.Filename))
dlMu.Lock()
st, ok := dlDownloads[name]
if !ok || st.Finished {
dlMu.Unlock()
sendJSON(w, 404, map[string]any{"ok": false, "error": "aucun téléchargement en cours pour " + name})
return
}
st.Paused = req.Pause
if req.Pause {
st.Speed = 0
}
g := st.gate
dlMu.Unlock()
g.setPaused(req.Pause)
sendJSON(w, 200, map[string]any{"ok": true, "paused": req.Pause})
}
// cleanStalePartFiles removes leftover *.gguf.part files in every model dir at
// startup. A download killed by a crash or a service restart can't be resumed
// (its state lived in memory), so the partial file would otherwise sit there
-93
View File
@@ -231,96 +231,3 @@ func TestDLProbeSurfacesExplainedError(t *testing.T) {
t.Errorf("message %q sans indication sur le jeton", err)
}
}
type slowWriter struct{ http.ResponseWriter }
func (s slowWriter) Write(b []byte) (int, error) {
time.Sleep(2 * time.Millisecond)
return s.ResponseWriter.Write(b)
}
// Une pause coupe le flux, garde ce qui est écrit, et la reprise termine un
// fichier identique à la source (repris d'AJEAN v0.18.1).
func TestRunDownloadPauseResume(t *testing.T) {
data := make([]byte, 48<<20)
rand.New(rand.NewSource(2)).Read(data)
t.Setenv("LOKI_DL_CONNS", "4")
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
http.ServeContent(slowWriter{w}, r, "m.gguf", time.Time{}, bytes.NewReader(data))
}))
defer srv.Close()
dest := filepath.Join(t.TempDir(), "m.gguf")
st := &dlState{Filename: "m.gguf", gate: newDlGate()}
done := make(chan struct{})
go func() {
runDownloadSet(context.Background(), st, []string{srv.URL + "/m.gguf"}, []string{dest})
close(done)
}()
time.Sleep(300 * time.Millisecond)
st.gate.setPaused(true)
time.Sleep(1500 * time.Millisecond) // le compteur publié se fige
dlMu.Lock()
a := st.Done
dlMu.Unlock()
time.Sleep(1500 * time.Millisecond)
dlMu.Lock()
b, fin := st.Done, st.Finished
dlMu.Unlock()
if fin || a != b || a == 0 {
t.Fatalf("pause inopérante : %d puis %d octets, terminé=%v", a, b, fin)
}
st.gate.setPaused(false)
select {
case <-done:
case <-time.After(30 * time.Second):
t.Fatal("pas terminé après la reprise")
}
if st.Err != "" {
t.Fatalf("erreur après reprise : %s", st.Err)
}
got, err := os.ReadFile(dest)
if err != nil || !bytes.Equal(got, data) {
t.Fatalf("fichier différent de la source après reprise (err=%v)", err)
}
}
// La route : pause puis reprise d'un téléchargement connu, refus pour
// l'inconnu et pour un téléchargement terminé.
func TestHandleModelDownloadPause(t *testing.T) {
dlMu.Lock()
saved := dlDownloads
dlDownloads = map[string]*dlState{
"a.gguf": {Filename: "a.gguf", Speed: 9, gate: newDlGate()},
"b.gguf": {Filename: "b.gguf", Finished: true},
}
dlMu.Unlock()
t.Cleanup(func() { dlMu.Lock(); dlDownloads = saved; dlMu.Unlock() })
post := func(body string) (int, string) {
w := httptest.NewRecorder()
handleModelDownloadPause(w, httptest.NewRequest("POST", "/api/models/download/pause", strings.NewReader(body)))
return w.Code, strings.TrimSpace(w.Body.String())
}
if code, out := post(`{"filename":"../a.gguf","pause":true}`); code != 200 || out != `{"ok":true,"paused":true}` {
t.Fatalf("pause : %d %s", code, out)
}
dlMu.Lock()
st := dlDownloads["a.gguf"]
paused, speed := st.Paused, st.Speed
dlMu.Unlock()
if !paused || speed != 0 || !st.gate.isPaused() {
t.Errorf("état après pause : paused=%v speed=%d porte=%v", paused, speed, st.gate.isPaused())
}
if code, out := post(`{"filename":"a.gguf","pause":false}`); code != 200 || out != `{"ok":true,"paused":false}` || st.gate.isPaused() {
t.Fatalf("reprise : %d %s", code, out)
}
for _, body := range []string{`{"filename":"b.gguf","pause":true}`, `{"filename":"x.gguf","pause":true}`} {
if code, _ := post(body); code != 404 {
t.Errorf("%s : %d, attendu 404", body, code)
}
}
if code, _ := post(`[`); code != 400 {
t.Errorf("corps illisible : %d", code)
}
}
+3 -4
View File
@@ -46,11 +46,10 @@ func modelRefKey(v string) string {
}
// modelKeysOf liste les .gguf référencés par une configuration : le modèle, le
// projecteur vision, le brouillon (MODEL_DRAFT : une tête MTP effacée sous le
// moteur coupe la spéculation sans rien dire), et un --mmproj posé à la main
// dans EXTRA_ARGS (les presets d'avant la clé MMPROJ l'écrivaient là).
// projecteur vision, et un --mmproj posé à la main dans EXTRA_ARGS (les presets
// d'avant la clé MMPROJ l'écrivaient là).
func modelKeysOf(env map[string]string) []string {
vals := []string{env["MODEL"], env["MMPROJ"], env["MODEL_DRAFT"]}
vals := []string{env["MODEL"], env["MMPROJ"]}
args := splitArgs(env["EXTRA_ARGS"])
for i, a := range args {
if a == "--mmproj" && i+1 < len(args) {
@@ -142,14 +142,6 @@ func TestProjecteurCompteCommeReference(t *testing.T) {
if len(users) != 1 || users[0] != "Vision" {
t.Fatalf("--mmproj d'EXTRA_ARGS non compté : %v", users)
}
// La tête MTP publiée à part aussi (MODEL_DRAFT).
writeModel(t, modelsDir(), "mtp-Qwen3.6-27B.gguf", 4)
if _, err := SavePreset("", "MTP", `MODEL="m.gguf"`+"\n"+`SPEC=auto`+"\n"+`MODEL_DRAFT="mtp-Qwen3.6-27B.gguf"`+"\n"); err != nil {
t.Fatal(err)
}
if users, _ := modelUsers("mtp-Qwen3.6-27B.gguf"); len(users) != 1 || users[0] != "MTP" {
t.Fatalf("MODEL_DRAFT= non compté : %v", users)
}
}
// Une famille de tranches incomplète n'est pas « déjà là » : il reste à
+15 -21
View File
@@ -29,8 +29,7 @@ import (
"time"
)
// 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"
const llamaReleasesAPI = "https://api.github.com/repos/ggml-org/llama.cpp/releases/latest"
type ghAsset struct {
Name string `json:"name"`
@@ -42,25 +41,18 @@ 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) {
f := prebuiltVersionFields()
b, err := os.ReadFile(filepath.Join(prebuiltDir(), "VERSION"))
if err != nil {
return "", ""
}
f := strings.Fields(strings.TrimSpace(string(b)))
if len(f) > 0 {
tag = f[0]
}
if len(f) > 1 && !strings.HasPrefix(f[1], "fmt") {
if len(f) > 1 {
cudaVer = f[1]
}
return
@@ -72,13 +64,15 @@ func prebuiltVersion() (tag, cudaVer string) {
// sera refaite au lieu d'être déclarée « déjà à jour ».
const prebuiltFormat = "fmt2"
// 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.
// prebuiltVersionFormat lit le 3e champ du marqueur VERSION ("" si absent).
func prebuiltVersionFormat() string {
f := prebuiltVersionFields()
if len(f) > 1 && strings.HasPrefix(f[len(f)-1], "fmt") {
return f[len(f)-1]
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]
}
return ""
}
+3 -84
View File
@@ -113,69 +113,7 @@ func ListPresets() ([]Preset, error) {
})
}
sort.Slice(out, func(i, j int) bool { return out[i].Name < out[j].Name })
return applyPresetOrder(out), nil
}
// presetOrderKey : ordre d'affichage choisi par glisser-déposer dans l'UI
// (repris d'AJEAN v0.10.9), une liste d'IDs persistée en base.
const presetOrderKey = "preset_order"
func loadPresetOrder() []string {
var ids []string
getJSON(bkState, presetOrderKey, &ids)
return ids
}
func savePresetOrder(ids []string) error { return putJSON(bkState, presetOrderKey, ids) }
// applyPresetOrder réordonne `list` (déjà trié par nom) selon l'ordre stocké :
// d'abord les presets classés dans l'ordre choisi, puis les non classés
// (fraîchement créés) dans leur ordre alphabétique. Tri STABLE.
func applyPresetOrder(list []Preset) []Preset {
order := loadPresetOrder()
if len(order) == 0 {
return list
}
pos := map[string]int{}
for i, id := range order {
pos[id] = i
}
sort.SliceStable(list, func(i, j int) bool {
pi, oki := pos[list[i].ID]
pj, okj := pos[list[j].ID]
if oki && okj {
return pi < pj
}
if oki != okj {
return oki // un preset classé passe avant un non classé
}
return false // deux non classés : l'ordre alphabétique reste
})
return list
}
// presetCtx : la taille de contexte d'un preset, formatée pour la pastille de
// la liste (« 32K », « 128K », « 1M »). CTX absent = 32768, le défaut de serve.
func presetCtx(content string) string {
raw := strings.TrimSpace(parseEnv(content)["CTX"])
if raw == "" {
raw = "32768"
}
n, err := strconv.Atoi(raw)
if err != nil || n <= 0 {
return ""
}
// Multiple de 1024 (32768, 131072…) : écrit comme on le pense, « 32K »,
// « 128K » ; sinon décimal, comme on le tape (40000 → « 40K »).
switch {
case n >= 1024 && n%1024 == 0 && n < 1<<20:
return strconv.Itoa(n/1024) + "K"
case n >= 1000000:
return strings.TrimSuffix(strconv.FormatFloat(float64(n)/1000000, 'f', 1, 64), ".0") + "M"
case n >= 1000:
return strconv.Itoa((n+500)/1000) + "K"
}
return strconv.Itoa(n)
return out, nil
}
// uniquePresetID derives a unique filename id from a display name, appending
@@ -251,13 +189,7 @@ func safePresetPath(name string) (string, error) {
// version effaçait la clé, et le moteur repartait sur le défaut « toutes les
// interfaces » : une machine volontairement fermée se rouvrait toute seule au
// premier changement de preset.
// CUDA_LAUNCH_QUEUES (files de lancement CUDA, voir launchQueuesEnv) suit la
// même logique : un « off » posé parce que CETTE machine bloque ne doit pas
// disparaître à la bascule suivante.
// SLOT_PERSIST (état du slot gardé à la bascule, backend_serve_persist.go) est
// par nature un réglage de bascule : posé sur A seulement, il disparaîtrait en
// passant sur B, dont le lancement effacerait l'état gardé de A.
var preservedKeys = []string{"MEM_MODE", "CRAWL4AI_URL", "WEB_ENGINE", "CUDA_VISIBLE_DEVICES", "HOST", "CUDA_LAUNCH_QUEUES", "SLOT_PERSIST"}
var preservedKeys = []string{"MEM_MODE", "CRAWL4AI_URL", "WEB_ENGINE", "CUDA_VISIBLE_DEVICES", "HOST"}
// softPreservedKeys : préservées SEULEMENT si le preset d'arrivée ne les définit
// pas lui-même. CUDA_VISIBLE_DEVICES est dans ce cas : c'est d'ordinaire un
@@ -267,8 +199,7 @@ var preservedKeys = []string{"MEM_MODE", "CRAWL4AI_URL", "WEB_ENGINE", "CUDA_VIS
// inconditionnellement écrasait cette valeur par l'ancienne (mono-GPU) : le
// modèle se retrouvait entièrement sur une seule carte et mourait sur
// « cudaMalloc failed: out of memory ». Le preset explicite gagne.
// CUDA_LAUNCH_QUEUES aussi : un preset peut l'imposer pour son modèle.
var softPreservedKeys = map[string]bool{"CUDA_VISIBLE_DEVICES": true, "HOST": true, "CUDA_LAUNCH_QUEUES": true, "SLOT_PERSIST": true}
var softPreservedKeys = map[string]bool{"CUDA_VISIBLE_DEVICES": true, "HOST": true}
// applyPresetFile installe le preset comme configuration active, en réinjectant
// les réglages « appareil » par-dessus. Séparé de SwitchToPreset pour être
@@ -329,13 +260,6 @@ func presetImposesEngine(p string) bool {
// SwitchToPreset installe le preset et redémarre le service. Les réglages
// « appareil » (preservedKeys) sont conservés à travers la bascule.
func SwitchToPreset(target string) error {
if err := tuneGuard(); err != nil {
return err
}
// SLOT_PERSIST : relevé sur la configuration d'avant, gardé juste avant
// l'arrêt (llm_slotpersist.go). Sans la clé, ou hors du process web (rien
// n'y dit ce que porte le slot) : nil, rien ne se passe.
persist := slotPersistPrepare()
if err := applyPresetFile(target); err != nil {
return err
}
@@ -349,11 +273,9 @@ func SwitchToPreset(target string) error {
if !serviceIsActive() {
return nil
}
persist.save()
return serviceAction("stop")
}
fmt.Println(dim("[info] redémarrage du service..."))
persist.save()
return serviceAction("restart")
}
@@ -510,9 +432,6 @@ func DeletePreset(id string) error {
// sans ce ménage, il resterait orphelin et ressusciterait sur un preset recréé
// sous le même id.
_ = putStr(bkState, sysPromptKeyPrefix+id, "")
// Même ménage pour son benchmark : un preset recréé sous cet id en
// hériterait, alors qu'il a été mesuré sur un autre modèle.
deletePresetBench(id)
return nil
}
@@ -1,60 +0,0 @@
package loki
import (
"os"
"path/filepath"
"testing"
)
// Ordre choisi par glisser-déposer : les presets classés d'abord, dans cet
// ordre ; les nouveaux (non classés) ensuite, par nom.
func TestPresetOrder(t *testing.T) {
testHome(t)
_ = os.MkdirAll(presetsDir(), 0o755)
for _, id := range []string{"alpha", "bravo", "charlie", "delta"} {
if err := os.WriteFile(filepath.Join(presetsDir(), id+".env"), []byte("# NAME="+id+"\nCTX=4096\n"), 0o644); err != nil {
t.Fatal(err)
}
}
ids := func() []string {
list, err := ListPresets()
if err != nil {
t.Fatal(err)
}
var out []string
for _, p := range list {
out = append(out, p.ID)
}
return out
}
if got := ids(); got[0] != "alpha" || got[3] != "delta" {
t.Fatalf("sans ordre enregistré, tri par nom attendu : %v", got)
}
if err := savePresetOrder([]string{"charlie", "alpha"}); err != nil {
t.Fatal(err)
}
want := []string{"charlie", "alpha", "bravo", "delta"}
got := ids()
for i := range want {
if got[i] != want[i] {
t.Fatalf("ordre %v, attendu %v", got, want)
}
}
}
func TestPresetCtx(t *testing.T) {
cases := map[string]string{
"": "32K", // défaut de serve : 32768
"CTX=40000\n": "40K",
"CTX=\"131072\"\n": "128K",
"CTX=1000000\n": "1M",
"CTX=1500000\n": "1.5M",
"CTX=512\n": "512",
"CTX=abc\n": "",
}
for in, want := range cases {
if got := presetCtx(in); got != want {
t.Errorf("presetCtx(%q) = %q, attendu %q", in, got, want)
}
}
}
+2 -2
View File
@@ -47,7 +47,7 @@ func TestSwitchToPresetGardeLesReglagesMachine(t *testing.T) {
}
}
// Configuration courante : un preset + les réglages machine.
setConfig(t, "MODEL=ancien.gguf\nCTX=4096\nCUDA_VISIBLE_DEVICES=1\nWEB_ENGINE=go\nMEM_MODE=always\nCUDA_LAUNCH_QUEUES=off\n")
setConfig(t, "MODEL=ancien.gguf\nCTX=4096\nCUDA_VISIBLE_DEVICES=1\nWEB_ENGINE=go\nMEM_MODE=always\n")
target := filepath.Join(home, "cible.env")
write(target, "MODEL=nouveau.gguf\nCTX=8192\n")
@@ -60,7 +60,7 @@ func TestSwitchToPresetGardeLesReglagesMachine(t *testing.T) {
t.Fatalf("le preset n'a pas été appliqué : %v", cfg)
}
for k, want := range map[string]string{
"CUDA_VISIBLE_DEVICES": "1", "WEB_ENGINE": "go", "MEM_MODE": "always", "CUDA_LAUNCH_QUEUES": "off",
"CUDA_VISIBLE_DEVICES": "1", "WEB_ENGINE": "go", "MEM_MODE": "always",
} {
if cfg[k] != want {
t.Errorf("%s = %q après bascule, attendu %q (réglage machine effacé)", k, cfg[k], want)
+89 -562
View File
@@ -4,18 +4,15 @@ import (
"bytes"
"context"
"fmt"
"net"
"os"
"os/exec"
"path/filepath"
"runtime"
"strconv"
"strings"
"sync"
"time"
)
// helpSupportsReasoningFlag dit si ce llama-server accepte « --reasoning ».
// binSupportsReasoningFlag dit si ce llama-server accepte « --reasoning ».
//
// Le drapeau est récent : les moteurs plus anciens, et certains forks, ne le
// connaissent pas et REFUSENT de démarrer sur un argument inconnu. Comme on ne
@@ -25,11 +22,9 @@ import (
// L'aide se lit avec le même chemin de bibliothèques que le vrai lancement
// (setLibraryPath a déjà été appelé) : sans ça un moteur parfaitement valide
// échoue à s'exécuter (« libllama-common.so introuvable ») et on conclurait à
// tort qu'il ne gère pas le drapeau. Les tests de capacité prennent le TEXTE de
// l'aide, pas le binaire : buildServeArgs reste ainsi une fonction pure,
// testable avec une aide fabriquée.
func helpSupportsReasoningFlag(help string) bool {
return strings.Contains(help, "--reasoning ")
// tort qu'il ne gère pas le drapeau.
func binSupportsReasoningFlag(bin string) bool {
return strings.Contains(binHelp(bin), "--reasoning ")
}
// binHelp lit « <bin> --help » UNE fois par binaire et garde le texte. Plusieurs
@@ -61,19 +56,20 @@ func binHelp(bin string) string {
return h
}
// helpSupportsLoadMode : les moteurs récents ont fusionné --mlock, --mmap et
// binSupportsLoadMode : les moteurs récents ont fusionné --mlock, --mmap et
// --no-mmap dans un seul --load-mode ; les anciens ne connaissent que les trois
// drapeaux d'origine. On traduit donc à l'exécution, sans réécrire le preset :
// le même EXTRA_ARGS reste lançable sur les deux générations de moteur.
func helpSupportsLoadMode(help string) bool {
return strings.Contains(help, "--load-mode")
func binSupportsLoadMode(bin string) bool {
return strings.Contains(binHelp(bin), "--load-mode")
}
// helpSupportsNGLAuto : « -ngl auto » laisse llama.cpp mesurer la VRAM libre et
// binSupportsNGLAuto : « -ngl auto » laisse llama.cpp mesurer la VRAM libre et
// choisir le nombre de couches. Imposer un nombre (999 compris) désarme ce
// calcul — « n_gpu_layers already set by user to 999, abort » — et le moteur
// tente alors de tout mettre sur le GPU, quitte à échouer en cudaMalloc.
func helpSupportsNGLAuto(h string) bool {
func binSupportsNGLAuto(bin string) bool {
h := binHelp(bin)
i := strings.Index(h, "--n-gpu-layers")
if i < 0 {
i = strings.Index(h, "-ngl")
@@ -88,16 +84,16 @@ func helpSupportsNGLAuto(h string) bool {
return strings.Contains(h[i:end], "'auto'")
}
// helpFitsLayersItself : ce moteur sait-il répartir les couches tout seul ?
// binFitsLayersItself : ce moteur sait-il répartir les couches tout seul ?
//
// La lecture fine de l'aide (helpSupportsNGLAuto) reste la source de vérité, mais
// La lecture fine de l'aide (binSupportsNGLAuto) reste la source de vérité, mais
// elle dépend de la mise en page d'un texte d'aide — une description reformulée
// ou une colonne plus large, et on conclut « non » sur un moteur parfaitement
// capable, donc on lui réimpose 999 et l'abandon revient. --load-mode est arrivé
// dans la même vague que « -ngl auto » : sa présence sert de second témoin, plus
// grossier mais insensible à la mise en page.
func helpFitsLayersItself(help string) bool {
return helpSupportsNGLAuto(help) || helpSupportsLoadMode(help)
func binFitsLayersItself(bin string) bool {
return binSupportsNGLAuto(bin) || binSupportsLoadMode(bin)
}
// nglArgs traduit la valeur NGL du preset en arguments, et renvoie au passage la
@@ -144,37 +140,22 @@ func hasAnyFlag(args []string, flags ...string) bool {
return false
}
// normalizeLoadFlags choisit, au lancement, la forme des drapeaux de chargement
// que CE binaire comprend. llama.cpp récent a remplacé --mlock / --no-mmap par
// --load-mode (et refuse les anciens) ; un moteur ancien ou un fork ne connaît
// que les anciens. L'éditeur écrit --load-mode, un preset d'avant peut porter
// les anciens : on traduit dans le sens qu'il faut (repris d'AJEAN 0.16.0).
// normalizeLoadFlags traduit les drapeaux de chargement dépréciés vers
// --load-mode, sur les moteurs qui le connaissent :
//
// Vers --load-mode (moteur récent). Deux interrupteurs → une seule valeur, au
// sens où llama.cpp l'entend (« mlock » = PAS de mmap + résident) :
// --mlock → --load-mode mlock (garder le modèle en RAM)
// --no-mmap → --load-mode none (tout charger, pas de mmap)
// --mmap → --load-mode mmap
//
// --mlock seul → mmap+mlock (l'ancien sens de --mlock : mmap conservé)
// --no-mmap seul → none
// --mlock + --no-mmap → mlock
// --mmap → mmap
//
// ⚠️ Jusqu'ici --mlock seul devenait « mlock », qui COUPE le mmap : le modèle
// entier montait en RAM au lieu d'être mappé — un OOM assuré sur un modèle plus
// gros que la mémoire. Un --load-mode déjà écrit gagne sur tout : on retire
// alors simplement les vieux drapeaux.
//
// Vers les anciens drapeaux (moteur qui ne connaît pas --load-mode) :
//
// none → --no-mmap · mlock → --mlock --no-mmap · mmap+mlock → --mlock
// auto, mmap, dio → rien (défaut de l'ancien moteur)
//
// La note éventuelle (dio abandonné) est RENDUE, pas affichée : buildServeArgs
// reste pure et c'est cmdServe qui l'écrit, à sa place parmi les autres notes.
func normalizeLoadFlags(args []string, supportsLoadMode bool) ([]string, string) {
// Les deux à la fois (--mlock --no-mmap) : mlock l'emporte, c'est l'intention la
// plus forte (résident en RAM, jamais rendu au système). Un --load-mode déjà
// écrit à la main dans EXTRA_ARGS gagne sur tout : on retire alors simplement
// les vieux drapeaux. Sur un moteur ancien, rien n'est touché.
func normalizeLoadFlags(args []string, supportsLoadMode bool) []string {
if !supportsLoadMode {
return downgradeLoadMode(args)
return args
}
explicit := hasAnyFlag(args, "--load-mode", "-lm")
explicit := hasAnyFlag(args, "--load-mode")
mlock, nommap, mmap := false, false, false
out := make([]string, 0, len(args)+2)
for _, a := range args {
@@ -190,102 +171,23 @@ func normalizeLoadFlags(args []string, supportsLoadMode bool) ([]string, string)
}
}
if explicit || !(mlock || nommap || mmap) {
return out, ""
return out
}
mode := "mmap"
switch {
case mlock && nommap:
mode = "mlock"
case mlock:
mode = "mmap+mlock"
mode = "mlock"
case nommap:
mode = "none"
}
return append(out, "--load-mode", mode), ""
}
// downgradeLoadMode retraduit un --load-mode pour un moteur qui ne le connaît
// pas : le lui passer tel quel le ferait sortir en erreur au démarrage.
func downgradeLoadMode(args []string) ([]string, string) {
mode, found := "", false
kept := make([]string, 0, len(args))
for i := 0; i < len(args); i++ {
a := args[i]
if a == "--load-mode" || a == "-lm" {
found = true
if i+1 < len(args) {
mode = strings.ToLower(args[i+1])
i++
}
continue
}
if v, ok := strings.CutPrefix(a, "--load-mode="); ok {
found, mode = true, strings.ToLower(v)
continue
}
kept = append(kept, a)
}
if !found {
return args, ""
}
add := func(flag string) {
for _, a := range kept {
if a == flag {
return
}
}
kept = append(kept, flag)
}
switch mode {
case "none":
add("--no-mmap")
case "mlock":
add("--mlock")
add("--no-mmap")
case "mmap+mlock":
add("--mlock")
case "dio":
return kept, "ce moteur ne connaît pas --load-mode dio (DirectIO) : chargement par défaut"
}
return kept, ""
return append(out, "--load-mode", mode)
}
// cmdServe replaces the historic start.sh: read config.env, build the
// llama-server invocation, and exec it (replacing this process so systemd
// supervises llama-server directly).
//
// Tout ce qui touche au monde (fichiers, aide du moteur, variables
// d'environnement, port, exec) reste ICI ; la composition de la ligne de
// commande vit dans buildServeArgs, pure, pour qu'un changement de drapeau se
// vérifie en table de tests plutôt qu'en relançant un moteur.
func cmdServe(args []string) error {
cfg := ReadConfig()
// Moteur d'ESSAI de l'optimiseur (backend_tune_run.go) : sa configuration
// est une copie posée dans le dossier de l'essai, jamais config.env, et rien
// de ce que le vrai moteur laisse derrière lui (état des slots, jeton de
// SPEC=auto) n'est touché. Sans la variable, rien ne change.
trialDir := os.Getenv(tuneTrialEnv)
if trialDir != "" {
c, err := readTuneTrialConfig(trialDir)
if err != nil {
return err
}
cfg = c
}
// API externe : aucun moteur local. On sort SANS erreur, sinon systemd
// (Restart=on-failure) relance en boucle « MODEL non défini » (amont #95).
if isExternalConfig(cfg) {
fmt.Println("[info] preset externe actif : pas de moteur local à lancer")
return nil
}
// Preset ENGINE=strata (backend_strata.go) : un autre moteur, lancé par son
// propre serveur. L'optimiseur ne règle que llama-server.
if isStrataConfig(cfg) {
if trialDir != "" {
return fmt.Errorf("l'optimiseur ne s'applique pas à un preset Strata")
}
return serveStrata(cfg)
}
bin := cfg["BIN"]
if bin == "" {
return fmt.Errorf("BIN non défini — lance « loki edit »")
@@ -328,327 +230,40 @@ func cmdServe(args []string) error {
// Linux, PATH on Windows — handled inside execServer.
setLibraryPath(filepath.Dir(bin))
// La sélection GPU est posée AVANT de lire l'aide du moteur, comme elle l'a
// toujours été : le moteur interrogé voit déjà les mêmes devices que celui
// qu'on lancera. buildServeArgs la renvoie aussi dans env (même valeur).
applyServeEnv(cudaDeviceEnv(cfg))
si := serveSysInfo{Help: binHelp(bin), Model: model}
// Vision : le projecteur multimodal (mmproj-*.gguf) donne des yeux au modèle.
// C'est un fichier .gguf À PART du modèle, chargé via --mmproj. On le résout
// comme le modèle (nom simple cherché dans les dossiers déclarés, ou chemin
// absolu), pour qu'un preset écrit sous Windows reste lançable ailleurs et que
// le champ « Vision » de l'interface n'ait qu'à écrire le nom du fichier.
// Introuvable = on préfère le dire clairement plutôt que laisser llama-server
// mourir en boucle sur un « failed to load mmproj » cryptique.
if mm := strings.TrimSpace(cfg["MMPROJ"]); mm != "" {
mmPath, err := resolveServeModelPath(mm)
if err != nil {
return fmt.Errorf("projecteur vision introuvable : %s (%v)", mm, err)
}
if _, err := os.Stat(mmPath); err != nil {
return fmt.Errorf("projecteur vision introuvable : %s", mmPath)
}
si.MMProj = mmPath
}
// API_KEY protège le serveur quand il est exposé sur internet : llama-server
// exige alors l'en-tête "Authorization: Bearer <clé>". La clé est lue depuis
// $LOKI_HOME/.api_key en priorité (elle survit ainsi aux changements de preset
// qui réécrivent config.env), avec config.env comme repli rétro-compatible.
si.APIKey, _ = effectiveAPIKeyErr()
// Conteneur à l'étroit (cpuset, quota) : seul Linux l'expose, dans /proc et
// /sys. Un LLAMA_ARG_THREADS déjà posé est un choix de l'utilisateur — un -t
// de Loki l'écraserait, donc on ne sonde même pas.
if runtime.GOOS == "linux" && os.Getenv("LLAMA_ARG_THREADS") == "" {
si.CPU = cpusetThreads(os.DirFS("/"))
}
extra := splitArgs(cfg["EXTRA_ARGS"])
// Deux GPU ou plus : de quoi décider des files de lancement CUDA (voir
// launchQueuesEnv). Après la sélection GPU, qui fait partie de la réponse.
probeServeGPUs(cfg, extra, &si)
probeExpertEnv(&si)
probeLoadEnv(&si)
probeFidelityEnv(&si)
probeCacheRAM(cfg, extra, &si)
probeCkptEnv(&si)
probeSideSlotEnv(cfg, &si)
// SPLIT_MODE=tensor seulement : cartes listées par CE moteur, dans son ordre.
probeSplit(cfg, bin, &si)
// LOAD_GUARD : un refus envisagé sur la VRAM NVIDIA se décide sur les
// cartes du moteur (Vulkan sur cartes mixtes, ROCm…). Sinon rien n'est lu.
normExtra, _ := normalizeLoadFlags(extra, helpSupportsLoadMode(si.Help))
probeLoadDevices(cfg, normExtra, bin, &si)
spec := specMode(cfg)
if spec != "off" && spec != "" {
probeSpec(cfg, &si)
}
// Build du moteur : seulement pour un hybride ou SPEC=auto, les seuls cas où
// il décide de quelque chose (voir ckptArgs, specAutoBlocker) — inutile de
// lancer « --version » sinon.
if ggufHybrid(si.GGUF) || spec == "auto" {
si.EngineBuild = engineBuildTrusted(bin)
}
// Lu seulement ici ; rien n'est consommé avant que le port soit libre.
specMark := specAutoMark{FP: configFingerprint(cfg), Bin: bin, Build: si.EngineBuild}
specRecord := false
if spec == "auto" {
si.SpecAutoBlocked, specRecord = specAutoPeek(specMark)
}
probeSlotPersistEnv(cfg, &si)
if strings.Contains(si.Help, "--slot-save-path") && !hasAnyFlag(extra, "--slot-save-path") {
if trialDir != "" {
// L'essai a son propre dossier : celui du vrai moteur garde ses états.
si.SlotDir = prepareSlotDir(trialDir, false)
} else {
si.SlotDir = prepareSlotDir(LokiHome(), slotPersistOn(cfg))
}
}
llmArgs, env, notes := buildServeArgs(cfg, extra, bin, si)
applyServeEnv(env)
// SLOT_PERSIST (backend_serve_persist.go) : la clé de CE lancement, et les
// états gardés au-delà de deux retirés. Refusé pour ce preset (brouillon,
// poids sur CPU…) : ceux des autres presets restent, pour leur retour.
// Clé retirée : plus aucun état gardé sur le disque.
persistKey := ""
persistOK, _ := slotPersistPlan(cfg, extra, si)
switch {
case trialDir != "":
// Un essai ne garde rien et n'efface rien.
case persistOK:
persistKey = slotPersistKey(llmArgs, env, si, bin)
slotPersistPrune(si.SlotDir)
case !slotPersistOn(cfg):
slotPersistPurge(filepath.Join(LokiHome(), "slots"))
}
for _, n := range notes {
fmt.Fprintln(os.Stderr, "[loki serve] "+n)
}
// Chargement résident avec plus de poids en RAM que la machine n'en a :
// échec certain (voir loadModeRisk), LOAD_GUARD=off pour passer outre.
if _, refuse := loadModeRisk(cfg, llmArgs, si); refuse != "" {
return loadGuardRefusal(refuse, trialDir != "")
}
// Essai : écoute sur 127.0.0.1 et le port libre choisi par l'optimiseur,
// même si EXTRA_ARGS en nomme d'autres (le dernier --port gagne). La ligne
// composée est rendue à l'optimiseur, qui la compare à celle de référence
// AVANT de charger quoi que ce soit ; à blanc, on s'arrête là.
if trialDir != "" {
llmArgs = tuneForceAddr(llmArgs, cfg["HOST"], cfg["PORT"])
l := tuneLaunch{Bin: bin, Args: llmArgs, Env: env, Notes: notes, Probe: tuneProbeFrom(si)}
if err := writeTuneLaunch(trialDir, l); err != nil {
return err
}
if os.Getenv(tuneDryRunEnv) != "" {
return nil
}
}
// Working dir = LOKI_HOME so relative paths in EXTRA_ARGS (e.g. --mmproj
// mmproj-F16.gguf) still resolve.
_ = os.Chdir(LokiHome())
// Port déjà pris (souvent un llama-server orphelin qu'un stop n'a pas pu
// tuer) : deux moteurs sur un même port se partagent les requêtes au hasard,
// VRAM saturée et réponses du mauvais modèle. On refuse en clair.
host, port := argValue(llmArgs, "--host"), argValue(llmArgs, "--port")
if err := waitPortFree(host, port, 5*time.Second); err != nil {
return err
}
// Le type EFFECTIF : un -ctk/-ctv d'EXTRA_ARGS l'emporte sur KV_TYPE, et c'est
// lui que le moteur accélère ou non.
kt, vt, _ := effectiveKVTypes(cfg, extra, si.ArgEnv)
warnSlowKV(kt, vt)
// Jeton de tentative : l'ancien consommé (et l'échec inscrit), le nouveau
// posé, au dernier moment — port libre, l'ancien moteur est donc bien parti
// et le process web ne peut pas le confondre avec lui.
if spec == "auto" && trialDir == "" {
_, _, auto := specArgs(cfg, extra, si)
specAutoSettle(specMark, si.SpecAutoBlocked, specRecord, auto)
}
// Posée au dernier moment, port libre : l'ancien moteur est parti, la clé
// lue par le process web est celle du moteur qui répondra.
if trialDir == "" {
writeSlotPersistMarker(LokiHome(), persistKey)
}
fmt.Fprintf(os.Stderr, "[loki serve] %s model=%s port=%s\n",
bin, filepath.Base(model), port)
// Ligne de commande réellement lancée, gardée pour l'interface (amont #108).
if trialDir == "" {
recordEngineCmdline(env, llmArgs)
}
// Hand off to the llama-server process. On Unix this replaces the current
// process (exec); on Windows it runs as a child and waits. See sys_platform_*.go.
return execServer(bin, llmArgs)
}
// serveSysInfo rassemble ce que cmdServe a dû demander au monde avant de
// composer la ligne de commande : l'aide du moteur (d'où se déduisent ses
// capacités), les chemins déjà résolus et vérifiés, la clé d'API. Avec elle,
// buildServeArgs n'a plus besoin de toucher ni au disque ni au binaire.
type serveSysInfo struct {
Help string // sortie de « <bin> --help » ; vide = moteur inconnu, aucun risque pris
Model string // chemin du .gguf principal, résolu et vérifié
MMProj string // chemin du projecteur vision, résolu et vérifié ; vide = pas de vision
APIKey string // clé effective (.api_key, sinon config.env) ; vide = serveur ouvert
CPU cpuBudget // sonde de conteneur (Linux) ; zéro = llama.cpp choisit ses threads seul
LaunchQueues string // CUDA_SCALE_LAUNCH_QUEUES déjà dans l'environnement : choix de l'utilisateur, intouché
GPUs int // GPU visibles (CUDA_VISIBLE_DEVICES, sinon nvidia-smi) ; 0 = inconnu ou non sondé
ArgEnv map[string]string // LLAMA_ARG_* de l'environnement qui décident du pipeline, de --fit et du cache (serveArgEnv, fitArgEnv, fidelityArgEnv)
UserEnv map[string]string // GGML_* des réglages d'expert déjà posés (voir expertEnvKeys) : intouchés
// Cache de prompts (voir backend_serve_cache.go). Zéro = inconnu : le moteur
// garde son défaut.
GGUF *GGUFInfo // métadonnées du modèle ; nil = illisibles
ModelBytes int64 // taille du modèle, toutes tranches
RAMMiB int64 // RAM de la machine, limite du conteneur comprise
RAMAvailMiB int64 // RAM libre au lancement
VRAMMiB int64 // VRAM NVIDIA des cartes visibles
UnifiedMem bool // macOS : RAM et VRAM ne font qu'une (voir loadModeRisk)
// VRAM des cartes listées par le moteur lui-même (--list-devices), lue
// seulement quand LOAD_GUARD envisage un refus (probeLoadDevices) ; 0 =
// non lue : un avis au plus, jamais de refus sur la seule VRAM NVIDIA.
EngineVRAMMiB int64
SlotDir string // dossier de --slot-save-path, créé et vérifié ; vide = pas de drapeau
// Build d'un moteur officiel (engineBuildTrusted), lu seulement pour un
// modèle hybride ou SPEC=auto ; 0 = inconnu ou non lu : ni avis ni drapeau
// automatique.
EngineBuild int
// Décodage spéculatif (voir backend_serve_spec.go), lu seulement si SPEC le
// demande.
Draft string // MODEL_DRAFT résolu et vérifié ; vide = absent ou introuvable
DraftErr string // pourquoi MODEL_DRAFT n'a pas été trouvé
DraftGGUF *GGUFInfo // métadonnées du brouillon ; nil = illisibles
DraftIsModel bool // MODEL_DRAFT désigne le modèle principal lui-même (voir specArgs)
SpecAutoBlocked string // un essai automatique précédent a échoué : la raison
// Cartes listées par le moteur (--list-devices), lues seulement pour
// SPLIT_MODE=tensor (voir backend_serve_split.go) ; vide = inconnues.
SplitDevs []splitDev
}
// cudaDeviceEnv : sélection GPU (loki gpu), on filtre les devices visibles par
// llama-server. CUDA_DEVICE_ORDER=PCI_BUS_ID garantit que les index
// correspondent à ceux affichés par nvidia-smi (sinon CUDA réordonne par
// « device le plus rapide »).
func cudaDeviceEnv(cfg map[string]string) map[string]string {
env := map[string]string{}
// Sélection GPU (loki gpu) : on filtre les devices visibles par llama-server.
// CUDA_DEVICE_ORDER=PCI_BUS_ID garantit que les index correspondent à ceux
// affichés par nvidia-smi (sinon CUDA réordonne par "device le plus rapide").
if v := cfg["CUDA_VISIBLE_DEVICES"]; v != "" {
env["CUDA_VISIBLE_DEVICES"] = v
env["CUDA_DEVICE_ORDER"] = "PCI_BUS_ID"
_ = os.Setenv("CUDA_VISIBLE_DEVICES", v)
_ = os.Setenv("CUDA_DEVICE_ORDER", "PCI_BUS_ID")
}
return env
}
func applyServeEnv(env map[string]string) {
for k, v := range env {
_ = os.Setenv(k, v)
}
}
// serveKVTypes donne les types de cache K et V du preset : KV_TYPE pour les
// deux, KV_TYPE_K / KV_TYPE_V pour les régler séparément. Vide = défaut moteur.
func serveKVTypes(cfg map[string]string) (k, v string) {
kv := cfg["KV_TYPE"]
k, v = kv, kv
if s := cfg["KV_TYPE_K"]; s != "" {
k = s
}
if s := cfg["KV_TYPE_V"]; s != "" {
v = s
}
return k, v
}
// buildServeArgs compose la ligne de commande de llama-server (bin en tête) à
// partir du preset, d'EXTRA_ARGS tel que découpé par splitArgs et de ce que
// cmdServe a sondé. Fonction pure : elle renvoie aussi les variables
// d'environnement à poser et les notes à afficher, sans rien faire elle-même.
// L'ordre des arguments est celui qu'a toujours produit cmdServe — le moteur
// retient la DERNIÈRE occurrence d'un drapeau, donc l'ordre fait partie du
// comportement.
func buildServeArgs(cfg map[string]string, extra []string, bin string, si serveSysInfo) (args []string, env map[string]string, notes []string) {
get := func(key, fallback string) string {
if v, ok := cfg[key]; ok && v != "" {
return v
}
return fallback
}
ktv, vtv := serveKVTypes(cfg)
env = cudaDeviceEnv(cfg)
kv := get("KV_TYPE", "")
ktv := get("KV_TYPE_K", kv)
vtv := get("KV_TYPE_V", kv)
// EXTRA_ARGS est lu ICI, avant de composer la ligne de commande : ce que
// l'utilisateur a écrit à la main décide des défauts que Loki a le droit
// d'ajouter (voir hasAnyFlag). Il est aussi traduit vers les drapeaux de
// chargement actuels quand le moteur les attend (voir normalizeLoadFlags).
extra, loadNote := normalizeLoadFlags(extra, helpSupportsLoadMode(si.Help))
if loadNote != "" {
notes = append(notes, loadNote)
}
extra := normalizeLoadFlags(splitArgs(cfg["EXTRA_ARGS"]), binSupportsLoadMode(bin))
// Parallélisme de tenseurs (SPLIT_MODE=tensor, voir backend_serve_split.go) :
// sans la clé, splitOn est faux et rien de ce qui suit ne change. Accepté, il
// compte pour les réglages qui lisent le placement dans EXTRA_ARGS (files de
// lancement, graphes CUDA, --fit) comme un -sm tensor écrit à la main.
splitOn, splitA, splitNGLArgs, splitEnv, splitNotes := splitTensorArgs(cfg, extra, si)
place := extra
if splitOn {
place = append(append([]string{}, extra...), "-sm", "tensor")
}
for k, v := range splitEnv {
env[k] = v
}
notes = append(notes, splitNotes...)
// Files de lancement CUDA : une variable d'environnement, pas un drapeau —
// la ligne de commande n'en dépend pas (voir launchQueuesEnv).
q, qNotes := launchQueuesEnv(cfg, place, si)
if q != "" {
env["CUDA_SCALE_LAUNCH_QUEUES"] = q
}
notes = append(notes, qNotes...)
// Réglages d'expert passés par variable (voir backend_serve_expert.go) :
// absents du preset, ils ne posent rien.
g, gNotes := graphOptEnv(cfg, place, si)
if g != "" {
env["GGML_CUDA_GRAPH_OPT"] = g
}
o, oNotes := opOffloadMinBatchEnv(cfg, si)
if o != "" {
env["GGML_OP_OFFLOAD_MIN_BATCH"] = o
}
notes = append(append(notes, gNotes...), oNotes...)
// Threads : vide ou 0 = AUCUN drapeau, pour que llama.cpp prenne ses cœurs
// physiques au lieu de tous les threads logiques (voir threadArgs).
threads, threadNotes := threadArgs(cfg["THREADS"], cfg["THREADS_BATCH"], extra, si.CPU)
notes = append(notes, threadNotes...)
// Second slot (SIDE_SLOT, voir backend_serve_side.go) : sans la clé, sideCtx
// vaut 0 et rien de ce qui suit ne change.
sideCtx, sideMiB, sideWhy := sideSlotPlan(cfg, extra, si)
ctxArg := get("CTX", "32768")
if sideCtx > 0 {
ctxArg = strconv.Itoa(2 * sideCtx)
}
args = []string{bin,
"-m", si.Model,
"-c", ctxArg,
}
args = append(args, threads...)
args = append(args,
llmArgs := []string{bin,
"-m", model,
"-c", get("CTX", "32768"),
"-t", get("THREADS", "0"),
"-tb", get("THREADS_BATCH", "0"),
"-b", get("BATCH", "2048"),
"-ub", get("UBATCH", "512"),
"--host", get("HOST", "0.0.0.0"),
"--port", get("PORT", "8080"),
)
}
// --parallel 1 EXPLICITE : les llama-server récents ouvrent plusieurs slots
// par défaut, soit des tampons de calcul GPU multipliés d'autant — pour un
// serveur mono-utilisateur comme Loki, c'est de la VRAM brûlée pour rien.
@@ -657,15 +272,8 @@ func buildServeArgs(cfg map[string]string, extra []string, bin string, si serveS
// uniquement à cause de ce nouveau défaut. PARALLEL=n dans le preset pour qui
// veut vraiment servir plusieurs requêtes à la fois — ou --parallel dans
// EXTRA_ARGS, auquel cas on ne redouble pas le drapeau.
if sideCtx > 0 {
// Deux flux KV séparés de sideCtx jetons : aucun slot ne peut manger la
// place de l'autre (voir backend_serve_side.go).
args = append(args, "--parallel", "2", "--no-kv-unified")
} else if !hasAnyFlag(extra, "--parallel", "-np") {
args = append(args, "--parallel", get("PARALLEL", "1"))
}
if n := sideSlotNote(sideCtx, sideMiB, sideWhy, nglForced(cfg, extra)); n != "" {
notes = append(notes, n)
if !hasAnyFlag(extra, "--parallel", "-np") {
llmArgs = append(llmArgs, "--parallel", get("PARALLEL", "1"))
}
// Couches GPU. Quatre cas, dans cet ordre :
//
@@ -687,29 +295,35 @@ func buildServeArgs(cfg map[string]string, extra []string, bin string, si serveS
// ci-dessus. Sur un moteur qui connaît « auto », 999 devient donc auto, et on
// le DIT sur stderr plutôt que de le faire en douce. Qui veut réellement
// forcer tout sur le GPU écrit NGL=all (ou un nombre qui n'est pas 999).
if splitOn {
args = append(args, splitNGLArgs...) // « -ngl all », ou rien si EXTRA_ARGS l'a déjà
} else if !hasAnyFlag(extra, "-ngl", "--n-gpu-layers", "--gpu-layers") {
ngl, note := nglArgs(get("NGL", ""), helpFitsLayersItself(si.Help))
if !hasAnyFlag(extra, "-ngl", "--n-gpu-layers", "--gpu-layers") {
args, note := nglArgs(get("NGL", ""), binFitsLayersItself(bin))
if note != "" {
notes = append(notes, note)
fmt.Fprintln(os.Stderr, "[loki serve] "+note)
}
args = append(args, ngl...)
llmArgs = append(llmArgs, args...)
}
// Marge VRAM par carte du placement automatique (FIT_TARGET, voir
// fitTargetArgs) : seulement si --fit tournera vraiment.
fitt, fitNotes := fitTargetArgs(cfg, place, si)
args = append(append(args, fitt...), splitA...)
notes = append(notes, fitNotes...)
if ktv != "" {
args = append(args, "-ctk", ktv)
llmArgs = append(llmArgs, "-ctk", ktv)
}
if vtv != "" {
args = append(args, "-ctv", vtv)
llmArgs = append(llmArgs, "-ctv", vtv)
}
// Vision : chemin déjà résolu et vérifié par cmdServe (voir là-bas).
if si.MMProj != "" {
args = append(args, "--mmproj", si.MMProj)
// Vision : le projecteur multimodal (mmproj-*.gguf) donne des yeux au modèle.
// C'est un fichier .gguf À PART du modèle, chargé via --mmproj. On le résout
// comme le modèle (nom simple cherché dans les dossiers déclarés, ou chemin
// absolu), pour qu'un preset écrit sous Windows reste lançable ailleurs et que
// le champ « Vision » de l'interface n'ait qu'à écrire le nom du fichier.
// Introuvable = on préfère le dire clairement plutôt que laisser llama-server
// mourir en boucle sur un « failed to load mmproj » cryptique.
if mm := strings.TrimSpace(cfg["MMPROJ"]); mm != "" {
mmPath, err := resolveServeModelPath(mm)
if err != nil {
return fmt.Errorf("projecteur vision introuvable : %s (%v)", mm, err)
}
if _, err := os.Stat(mmPath); err != nil {
return fmt.Errorf("projecteur vision introuvable : %s", mmPath)
}
llmArgs = append(llmArgs, "--mmproj", mmPath)
}
// Raisonnement. Trois cas, et la nuance compte :
//
@@ -729,124 +343,37 @@ func buildServeArgs(cfg map[string]string, extra []string, bin string, si serveS
// contenu). L'anti-boucle côté llm_client.go reste le garde-fou. NE PAS forcer 0 :
// sur llama.cpp vanilla, 0 = "immediate end" → coupe tout le raisonnement
// (le fork ik_llama.cpp l'ignore). Configurable via REASONING_BUDGET.
args = append(args, "--reasoning", r, "--reasoning-budget", get("REASONING_BUDGET", "-1"))
} else if helpSupportsReasoningFlag(si.Help) {
llmArgs = append(llmArgs, "--reasoning", r, "--reasoning-budget", get("REASONING_BUDGET", "-1"))
} else if binSupportsReasoningFlag(bin) {
// Pas de budget ici : « off » suffit, et un budget sur un moteur qui
// n'attend rien d'autre ne ferait qu'ajouter une occasion d'échouer.
args = append(args, "--reasoning", "off")
llmArgs = append(llmArgs, "--reasoning", "off")
} else {
// Vieux moteur (ou fork) qui ne connaît pas le drapeau : le lui passer
// le ferait sortir en erreur au démarrage, donc boucler. On le dit et on
// continue sans — mieux vaut un modèle qui réfléchit qu'un moteur mort.
notes = append(notes, "ce moteur ne connaît pas --reasoning : impossible de désactiver le raisonnement")
fmt.Fprintf(os.Stderr, "[loki serve] ce moteur ne connaît pas --reasoning : impossible de désactiver le raisonnement\n")
}
}
// Conservation du raisonnement des tours passés : seulement si
// REASONING_PRESERVE est posée (voir reasoningPreserveArgs pour le compromis).
rp, rpNotes := reasoningPreserveArgs(cfg, extra, si)
args = append(args, rp...)
notes = append(notes, rpNotes...)
if si.APIKey != "" {
args = append(args, "--api-key", si.APIKey)
// API_KEY protège le serveur quand il est exposé sur internet : llama-server
// exige alors l'en-tête "Authorization: Bearer <clé>". La clé est lue depuis
// $LOKI_HOME/.api_key en priorité (elle survit ainsi aux changements de preset
// qui réécrivent config.env), avec config.env comme repli rétro-compatible.
if k, _ := effectiveAPIKeyErr(); k != "" {
llmArgs = append(llmArgs, "--api-key", k)
}
// Cache de prompts en RAM et dossier des slots (voir backend_serve_cache.go) :
// avant EXTRA_ARGS, qui garde le dernier mot.
cram, cramNotes := cacheRAMArgs(cfg, extra, si)
args = append(append(args, cram...), slotSaveArgs(cfg, extra, si)...)
notes = append(notes, cramNotes...)
if n := slotPersistNote(slotPersistPlan(cfg, extra, si)); n != "" {
notes = append(notes, n)
}
// Points de reprise des hybrides (voir backend_serve_ckpt.go).
ck, ckNotes := ckptArgs(cfg, extra, si)
args = append(args, ck...)
notes = append(notes, ckNotes...)
// Décodage spéculatif (voir backend_serve_spec.go) : rien tant que SPEC
// n'est pas posé.
sp, spNotes, _ := specArgs(cfg, extra, si)
args = append(args, sp...)
notes = append(notes, spNotes...)
// EXTRA_ARGS (déjà découpé comme le ferait le shell — les guillemets gardent
// ensemble un chemin qui contient des espaces) ferme la marche.
args = append(args, extra...)
// Garde-fous de fidélité (voir backend_serve_fidelity.go) : ils ne touchent à
// aucun drapeau, ils DISENT ce que la ligne finale change aux calculs.
ek, ev, src := effectiveKVTypes(cfg, extra, si.ArgEnv)
if n := kvFidelityNote(ek, ev, src, extra, si.ArgEnv); n != "" {
notes = append(notes, n)
}
notes = append(notes, lossyCacheNotes(extra, si)...)
// Experts MoE et mode de chargement (voir backend_serve_moe.go) : des notes
// seulement, la ligne ne bouge pas. Le refus d'un chargement voué à l'échec
// se décide dans cmdServe, sur cette même ligne.
notes = append(notes, moeNotes(cfg, extra, si)...)
if w, _ := loadModeRisk(cfg, args, si); w != "" {
notes = append(notes, w)
}
return args, env, notes
}
// EXTRA_ARGS (déjà découpé plus haut comme le ferait le shell — les guillemets
// gardent ensemble un chemin qui contient des espaces) ferme la marche.
llmArgs = append(llmArgs, extra...)
// argValue renvoie la valeur de la DERNIÈRE occurrence de flag (llama-server
// retient la dernière : EXTRA_ARGS peut surcharger --port).
func argValue(args []string, flag string) string {
v := ""
for i := 0; i+1 < len(args); i++ {
if args[i] == flag {
v = args[i+1]
}
}
return v
}
// Working dir = LOKI_HOME so relative paths in EXTRA_ARGS (e.g. --mmproj
// mmproj-F16.gguf) still resolve.
_ = os.Chdir(LokiHome())
// waitPortFree vérifie que personne n'écoute déjà sur host:port. On tente une
// connexion TCP (fiable même si l'occupant a bindé en SO_REUSEADDR, où un
// Listen de test pourrait réussir). Un moteur qu'on vient d'arrêter peut tenir
// le port quelques instants : on réessaie jusqu'à wait avant de conclure.
// Repris d'AJEAN 0.15.9.
func waitPortFree(host, port string, wait time.Duration) error {
if port == "" {
return nil
}
h := strings.Trim(host, "[]")
if h == "" || h == "0.0.0.0" || h == "::" {
h = "127.0.0.1"
}
addr := net.JoinHostPort(h, port)
deadline := time.Now().Add(wait)
for {
c, err := net.DialTimeout("tcp", addr, 500*time.Millisecond)
if err != nil {
return nil
}
c.Close()
if time.Now().After(deadline) {
return fmt.Errorf("le port %s est déjà utilisé (sans doute un ancien llama-server encore actif) : "+
"« loki stop », docker restart loki, ou change PORT", port)
}
time.Sleep(500 * time.Millisecond)
}
}
fmt.Fprintf(os.Stderr, "[loki serve] %s model=%s port=%s\n",
bin, filepath.Base(model), get("PORT", "8080"))
// fastKV : combinaisons K/V que llama.cpp CUDA accélère en Flash-Attention
// avec ses options de compilation par défaut (GGML_CUDA_FA_ALL_QUANTS=OFF) —
// c'est le cas du moteur de l'image server-cuda comme de ceux tirés par OCI.
// Les autres (q5_*, q4_1, combinaisons mixtes) marchent mais sont reconverties
// en f16 à chaque pas : la génération ralentit nettement (AJEAN 0.15.9).
var fastKV = map[string]bool{"f16|f16": true, "q8_0|q8_0": true, "q4_0|q4_0": true, "bf16|bf16": true}
func slowKV(k, v string) bool {
if k == "" {
k = "f16"
}
if v == "" {
v = "f16"
}
return !fastKV[k+"|"+v]
}
func warnSlowKV(k, v string) {
if slowKV(k, v) {
fmt.Fprintf(os.Stderr, "[loki serve] avertissement : cache KV %s/%s non accéléré par llama.cpp CUDA "+
"(converti en f16 à chaque pas, lent). Préfère q8_0/q8_0, q4_0/q4_0 ou f16.\n", k, v)
}
// Hand off to the llama-server process. On Unix this replaces the current
// process (exec); on Windows it runs as a child and waits. See sys_platform_*.go.
return execServer(bin, llmArgs)
}
-299
View File
@@ -1,299 +0,0 @@
package loki
import (
"reflect"
"testing"
)
// Deux aides de moteur fabriquées, réduites aux lignes que lisent les tests de
// capacité : un llama.cpp récent (--load-mode, « -ngl auto », --reasoning) et
// un ancien qui ne connaît que les drapeaux d'origine.
const (
helpRecent = `-ngl, --gpu-layers, --n-gpu-layers N max. number of layers to store in VRAM, either an exact number,
'auto', or 'all' (default: auto)
-lm, --load-mode MODE how to load the model: auto, mmap, mmap+mlock, mlock, none, dio
-rea, --reasoning [on|off|auto] use reasoning/thinking in the chat (default: auto)
`
helpOld = `-ngl, --gpu-layers, --n-gpu-layers N number of layers to store in VRAM
--mlock force system to keep model in RAM
--no-mmap do not memory-map model
`
)
// TestBuildServeArgs fige la ligne de commande produite pour les presets
// courants : la moindre différence d'ordre ou de valeur casse le test. Le
// moteur retient la DERNIÈRE occurrence d'un drapeau, donc l'ordre compte
// autant que les valeurs — c'est la base sur laquelle les réglages suivants
// viendront s'ajouter, chacun avec sa ligne ici.
func TestBuildServeArgs(t *testing.T) {
const bin, model = "/opt/llama/llama-server", "/models/Qwen3.6-27B-Q4_K_M.gguf"
base := []string{bin, "-m", model,
"-c", "32768", "-b", "2048", "-ub", "512",
"--host", "0.0.0.0", "--port", "8080"}
with := func(tail ...string) []string {
return append(append([]string{}, base...), tail...)
}
// withThreads insère -t / -tb à leur place, juste après -c.
withThreads := func(threads []string, tail ...string) []string {
out := append(append([]string{}, base[:5]...), threads...)
return append(append(out, base[5:]...), tail...)
}
cases := []struct {
name string
cfg map[string]string
si serveSysInfo
want []string
wantEnv map[string]string
wantNotes int
}{
{
name: "dense par défaut, moteur récent : 999 devient auto, et on le dit",
cfg: map[string]string{"NGL": "999"},
si: serveSysInfo{Help: helpRecent},
want: with("--parallel", "1", "-ngl", "auto"), wantNotes: 1,
},
{
name: "dense par défaut, moteur ancien : 999 reste 999",
cfg: map[string]string{"NGL": "999"},
si: serveSysInfo{Help: helpOld},
want: with("--parallel", "1", "-ngl", "999"),
},
{
name: "clé NGL absente, aide illisible : forme historique",
cfg: map[string]string{},
si: serveSysInfo{},
want: with("--parallel", "1", "-ngl", "999"),
},
{
name: "NGL forcé à un nombre : jamais touché",
cfg: map[string]string{"NGL": "28"},
si: serveSysInfo{Help: helpRecent},
want: with("--parallel", "1", "-ngl", "28"),
},
{
name: "NGL=auto : aucun drapeau",
cfg: map[string]string{"NGL": "auto"},
si: serveSysInfo{Help: helpRecent},
want: with("--parallel", "1"),
},
{
name: "réglages du preset et cache KV commun",
cfg: map[string]string{"CTX": "65536", "THREADS": "8", "THREADS_BATCH": "16", "BATCH": "4096",
"UBATCH": "1024", "HOST": "127.0.0.1", "PORT": "9090", "PARALLEL": "2", "NGL": "all",
"KV_TYPE": "q8_0"},
si: serveSysInfo{Help: helpRecent},
want: []string{bin, "-m", model,
"-c", "65536", "-t", "8", "-tb", "16", "-b", "4096", "-ub", "1024",
"--host", "127.0.0.1", "--port", "9090",
"--parallel", "2", "-ngl", "all", "-ctk", "q8_0", "-ctv", "q8_0"},
wantNotes: 1, // cache quantifié : dit, jamais retiré
},
{
name: "THREADS=0 et THREADS_BATCH=0 : aucun drapeau, llama.cpp prend ses cœurs physiques",
cfg: map[string]string{"NGL": "28", "THREADS": "0", "THREADS_BATCH": "0"},
si: serveSysInfo{Help: helpRecent},
want: with("--parallel", "1", "-ngl", "28"),
},
{
name: "THREADS=6 seul : -t 6, -tb laissé au moteur (il recopie -t)",
cfg: map[string]string{"NGL": "28", "THREADS": "6"},
si: serveSysInfo{Help: helpRecent},
want: withThreads([]string{"-t", "6"}, "--parallel", "1", "-ngl", "28"),
},
{
name: "THREADS_BATCH seul : -tb sans -t",
cfg: map[string]string{"NGL": "28", "THREADS_BATCH": "12"},
si: serveSysInfo{Help: helpRecent},
want: withThreads([]string{"-tb", "12"}, "--parallel", "1", "-ngl", "28"),
},
{
name: "THREADS illisible ou négatif : ignoré, et dit",
cfg: map[string]string{"NGL": "28", "THREADS": "auto", "THREADS_BATCH": "-1"},
si: serveSysInfo{Help: helpRecent},
want: with("--parallel", "1", "-ngl", "28"), wantNotes: 2,
},
{
name: "-t=6 dans EXTRA_ARGS : pas de -t de Loki",
cfg: map[string]string{"NGL": "28", "THREADS": "8", "THREADS_BATCH": "16", "EXTRA_ARGS": "-t=6 --threads-batch 10"},
si: serveSysInfo{Help: helpRecent},
want: with("--parallel", "1", "-ngl", "28", "-t=6", "--threads-batch", "10"),
},
{
// Sans -tb, llama.cpp remplacerait tout le réglage CPU du batch par
// celui de -t : le --poll-batch de l'utilisateur serait effacé.
name: "--poll-batch dans EXTRA_ARGS, THREADS vide : -tb 0 le protège, et on le dit",
cfg: map[string]string{"NGL": "28", "EXTRA_ARGS": "--poll-batch 0"},
si: serveSysInfo{Help: helpRecent},
want: withThreads([]string{"-tb", "0"}, "--parallel", "1", "-ngl", "28", "--poll-batch", "0"), wantNotes: 1,
},
{
name: "-Cb dans EXTRA_ARGS, THREADS=6 : -tb recopie 6",
cfg: map[string]string{"NGL": "28", "THREADS": "6", "EXTRA_ARGS": "-Cb 0xff"},
si: serveSysInfo{Help: helpRecent},
want: withThreads([]string{"-t", "6", "-tb", "6"}, "--parallel", "1", "-ngl", "28", "-Cb", "0xff"),
},
{
name: "-t=5 et --prio-batch dans EXTRA_ARGS : -tb recopie 5",
cfg: map[string]string{"NGL": "28", "EXTRA_ARGS": "-t=5 --prio-batch 2"},
si: serveSysInfo{Help: helpRecent},
want: withThreads([]string{"-tb", "5"}, "--parallel", "1", "-ngl", "28", "-t=5", "--prio-batch", "2"),
},
{
name: "-Crb et -tb dans EXTRA_ARGS : Loki se tait",
cfg: map[string]string{"NGL": "28", "EXTRA_ARGS": "-Crb 0-7 -tb 8"},
si: serveSysInfo{Help: helpRecent},
want: with("--parallel", "1", "-ngl", "28", "-Crb", "0-7", "-tb", "8"),
},
{
name: "conteneur à l'étroit : -t de la sonde, et une note",
cfg: map[string]string{"NGL": "28"},
si: serveSysInfo{Help: helpRecent, CPU: cpuBudget{N: 4, Engine: 8, Why: "quota cgroup de 4 CPU"}},
want: withThreads([]string{"-t", "4"}, "--parallel", "1", "-ngl", "28"), wantNotes: 1,
},
{
name: "THREADS explicite : la sonde se tait",
cfg: map[string]string{"NGL": "28", "THREADS": "6"},
si: serveSysInfo{Help: helpRecent, CPU: cpuBudget{N: 4, Engine: 8, Why: "quota"}},
want: withThreads([]string{"-t", "6"}, "--parallel", "1", "-ngl", "28"),
},
{
name: "masque d'affinité dans EXTRA_ARGS : la sonde se tait",
cfg: map[string]string{"NGL": "28", "EXTRA_ARGS": "-Cr 0-3"},
si: serveSysInfo{Help: helpRecent, CPU: cpuBudget{N: 4, Engine: 8, Why: "quota"}},
want: with("--parallel", "1", "-ngl", "28", "-Cr", "0-3"),
},
{
name: "cache KV séparé K/V",
cfg: map[string]string{"NGL": "28", "KV_TYPE": "q8_0", "KV_TYPE_V": "q4_0"},
si: serveSysInfo{Help: helpRecent},
want: with("--parallel", "1", "-ngl", "28", "-ctk", "q8_0", "-ctv", "q4_0"), wantNotes: 1,
},
{
// Preset MoE typique : experts sur CPU via EXTRA_ARGS, qui pose aussi
// --parallel, -ngl et le cache KV. Loki n'ajoute alors ni --parallel ni
// -ngl, et EXTRA_ARGS ferme la marche (la dernière occurrence gagne).
name: "MoE : EXTRA_ARGS avec -ot, --n-cpu-moe, -ctk q8_0",
cfg: map[string]string{"NGL": "999", "PARALLEL": "1",
"EXTRA_ARGS": `-ngl 99 --parallel 1 -ot "blk\.(\d+)\.ffn_.*_exps=CPU" --n-cpu-moe 30 -ctk q8_0 -ctv q8_0 -fa on`},
si: serveSysInfo{Help: helpRecent},
want: with("-ngl", "99", "--parallel", "1", "-ot", `blk\.(\d+)\.ffn_.*_exps=CPU`,
"--n-cpu-moe", "30", "-ctk", "q8_0", "-ctv", "q8_0", "-fa", "on"),
wantNotes: 1, // q8_0 venu d'EXTRA_ARGS : vu comme celui de KV_TYPE
},
{
name: "--n-gpu-layers=N dans EXTRA_ARGS : pas de -ngl en double",
cfg: map[string]string{"NGL": "999", "EXTRA_ARGS": "--n-gpu-layers=40 -np 2"},
si: serveSysInfo{Help: helpRecent},
want: with("--n-gpu-layers=40", "-np", "2"),
},
{
name: "raisonnement actif : budget illimité par défaut",
cfg: map[string]string{"NGL": "28", "REASONING": "on"},
si: serveSysInfo{Help: helpRecent},
want: with("--parallel", "1", "-ngl", "28", "--reasoning", "on", "--reasoning-budget", "-1"),
},
{
name: "raisonnement auto avec budget choisi",
cfg: map[string]string{"NGL": "28", "REASONING": " auto ", "REASONING_BUDGET": "4096"},
si: serveSysInfo{Help: helpRecent},
want: with("--parallel", "1", "-ngl", "28", "--reasoning", "auto", "--reasoning-budget", "4096"),
},
{
name: "raisonnement interdit, moteur qui connaît le drapeau",
cfg: map[string]string{"NGL": "28", "REASONING": "off"},
si: serveSysInfo{Help: helpRecent},
want: with("--parallel", "1", "-ngl", "28", "--reasoning", "off"),
},
{
name: "raisonnement interdit, vieux moteur : aucun drapeau, une note",
cfg: map[string]string{"NGL": "28", "REASONING": "off"},
si: serveSysInfo{Help: helpOld},
want: with("--parallel", "1", "-ngl", "28"), wantNotes: 1,
},
{
name: "vision et clé d'API, avant EXTRA_ARGS",
cfg: map[string]string{"NGL": "28", "EXTRA_ARGS": "-fa on"},
si: serveSysInfo{Help: helpRecent, MMProj: "/models/mmproj-F16.gguf", APIKey: "secret"},
want: with("--parallel", "1", "-ngl", "28", "--mmproj", "/models/mmproj-F16.gguf",
"--api-key", "secret", "-fa", "on"),
},
{
name: "drapeaux de chargement anciens → --load-mode sur moteur récent",
cfg: map[string]string{"NGL": "28", "EXTRA_ARGS": "--mlock --no-mmap -fa on"},
si: serveSysInfo{Help: helpRecent},
want: with("--parallel", "1", "-ngl", "28", "-fa", "on", "--load-mode", "mlock"),
},
{
name: "--load-mode retraduit pour un moteur ancien",
cfg: map[string]string{"NGL": "28", "EXTRA_ARGS": "--load-mode none"},
si: serveSysInfo{Help: helpOld},
want: with("--parallel", "1", "-ngl", "28", "--no-mmap"),
},
{
// dio n'existe pas sur l'ancien moteur : retiré, et dit par une note
// RENDUE (buildServeArgs n'écrit rien elle-même).
name: "--load-mode dio sur moteur ancien : retiré, une note",
cfg: map[string]string{"NGL": "28", "EXTRA_ARGS": "--load-mode dio -fa on"},
si: serveSysInfo{Help: helpOld},
want: with("--parallel", "1", "-ngl", "28", "-fa", "on"), wantNotes: 1,
},
{
name: "sélection GPU : variables d'environnement, ligne inchangée",
cfg: map[string]string{"NGL": "28", "CUDA_VISIBLE_DEVICES": "1,0"},
si: serveSysInfo{Help: helpRecent},
want: with("--parallel", "1", "-ngl", "28"),
wantEnv: map[string]string{"CUDA_VISIBLE_DEVICES": "1,0", "CUDA_DEVICE_ORDER": "PCI_BUS_ID"},
},
}
for _, c := range cases {
t.Run(c.name, func(t *testing.T) {
c.si.Model = model
args, env, notes := buildServeArgs(c.cfg, splitArgs(c.cfg["EXTRA_ARGS"]), bin, c.si)
if !reflect.DeepEqual(args, c.want) {
t.Fatalf("args\n got %q\nwant %q", args, c.want)
}
wantEnv := c.wantEnv
if wantEnv == nil {
wantEnv = map[string]string{}
}
if !reflect.DeepEqual(env, wantEnv) {
t.Fatalf("env = %v, want %v", env, wantEnv)
}
if len(notes) != c.wantNotes {
t.Fatalf("notes = %q, en attendait %d", notes, c.wantNotes)
}
})
}
}
func TestHelpCapabilities(t *testing.T) {
if !helpSupportsReasoningFlag(helpRecent) || helpSupportsReasoningFlag(helpOld) {
t.Fatal("--reasoning mal détecté")
}
if !helpSupportsLoadMode(helpRecent) || helpSupportsLoadMode(helpOld) {
t.Fatal("--load-mode mal détecté")
}
if !helpSupportsNGLAuto(helpRecent) || helpSupportsNGLAuto(helpOld) {
t.Fatal("« -ngl auto » mal détecté")
}
// Aide illisible = aucune capacité : on garde la forme historique.
if helpFitsLayersItself("") || helpSupportsReasoningFlag("") {
t.Fatal("une aide vide ne doit promettre aucune capacité")
}
}
func TestServeKVTypes(t *testing.T) {
for _, c := range []struct {
cfg map[string]string
k, v string
}{
{map[string]string{}, "", ""},
{map[string]string{"KV_TYPE": "q8_0"}, "q8_0", "q8_0"},
{map[string]string{"KV_TYPE": "q8_0", "KV_TYPE_K": "f16"}, "f16", "q8_0"},
{map[string]string{"KV_TYPE_V": "q4_0"}, "", "q4_0"},
} {
if k, v := serveKVTypes(c.cfg); k != c.k || v != c.v {
t.Errorf("%v : got %s/%s, want %s/%s", c.cfg, k, v, c.k, c.v)
}
}
}
-576
View File
@@ -1,576 +0,0 @@
package loki
import (
"context"
"fmt"
"io/fs"
"math"
"os"
"os/exec"
"path/filepath"
"runtime"
"strconv"
"strings"
"sync"
"time"
)
// Cache de prompts en RAM (--cache-ram) et isolation des travaux annexes.
//
// llama-server garde en RAM hôte une copie EXACTE des états de slot qu'il quitte
// (cache KV, état récurrent, points de reprise, contexte du brouillon MTP) et la
// recharge octet pour octet quand la conversation revient. Ce n'est pas une
// approximation : la seule différence avec un prefill complet est le bruit de
// virgule flottante dû à un découpage des lots différent, exactement comme le
// cache_prompt par défaut. Le type du cache KV n'est pas touché, il est copié
// tel quel.
//
// Le défaut du moteur (8 Gio) est trop petit pour une conversation longue d'un
// modèle dense : l'état principal (30 à 65 k jetons) ne tient plus à côté de
// celui d'un vérificateur ou d'un sous-agent, et la conversation est alors
// recalculée en entier — 30 à 80 s sur un 27B. Loki agrandit donc ce cache,
// mais seulement quand il sait mesurer le besoin et que la RAM est libre : il ne
// descend JAMAIS sous le défaut du moteur, et se tait dès que l'utilisateur a
// tranché (CACHE_RAM, -cram dans EXTRA_ARGS, LLAMA_ARG_CACHE_RAM).
//
// Tout ce qui décide est pur ; probeCacheRAM fait les lectures (GGUF, RAM,
// nvidia-smi) une fois, avant buildServeArgs.
// engineCacheRAMDefault : cache_ram_mib par défaut de llama.cpp (common.h).
const engineCacheRAMDefault = 8192
// cacheArgEnv : la variable qui règle le cache sans drapeau. llama.cpp donne la
// priorité à la ligne de commande : un --cache-ram de Loki l'écraserait.
var cacheArgEnv = []string{"LLAMA_ARG_CACHE_RAM"}
// probeCacheRAM complète si avec ce dont cacheRAMAuto a besoin. Chaque lecture
// ratée laisse un zéro, et un zéro veut dire « on ne sait pas » : le moteur
// garde alors son défaut.
func probeCacheRAM(cfg map[string]string, extra []string, si *serveSysInfo) {
if si.ArgEnv == nil {
si.ArgEnv = map[string]string{}
}
for _, k := range cacheArgEnv {
if v, ok := os.LookupEnv(k); ok && strings.TrimSpace(v) != "" {
si.ArgEnv[k] = v
}
}
if si.Model != "" {
si.ModelBytes = shardFamilySize(filepath.Dir(si.Model), filepath.Base(si.Model))
if g, err := ggufMeta(si.Model); err == nil {
si.GGUF = &g
}
}
used, total := ramUsageMB()
if total > 0 {
si.RAMMiB, si.RAMAvailMiB = int64(total), int64(total-used)
}
if runtime.GOOS == "linux" {
if limit, cur := cgroupMemMiB(os.DirFS("/")); limit > 0 {
if si.RAMMiB == 0 || limit < si.RAMMiB {
si.RAMMiB = limit
}
if free := limit - cur; free >= 0 && (si.RAMAvailMiB == 0 || free < si.RAMAvailMiB) {
si.RAMAvailMiB = free
}
}
}
// Mémoire unifiée : Apple Silicon seulement. Un Mac Intel à carte AMD a une
// VRAM à lui, que Loki ne sait pas mesurer (loadModeRisk la tient pour
// inconnue, jamais pour nulle).
si.UnifiedMem = runtime.GOOS == "darwin" && runtime.GOARCH == "arm64"
// macOS, AMD, Vulkan, --device choisi à la main : pas de total VRAM fiable,
// donc pas d'agrandissement.
if runtime.GOOS == "darwin" || hasAnyFlag(extra, "-dev", "--device") || si.ArgEnv["LLAMA_ARG_DEVICE"] != "" {
return
}
// La sélection du preset d'abord (cudaDeviceEnv l'accompagne toujours de
// PCI_BUS_ID), celle de l'environnement ensuite : l'aperçu de l'interface
// tourne dans un process où le preset n'a rien posé.
cvd, pci := cfg["CUDA_VISIBLE_DEVICES"], true
if cvd == "" {
cvd, pci = os.Getenv("CUDA_VISIBLE_DEVICES"), os.Getenv("CUDA_DEVICE_ORDER") == "PCI_BUS_ID"
}
si.VRAMMiB = nvidiaVRAMMiB(cvd, pci, 3*time.Second)
}
// cgroupMemMiB : limite mémoire du conteneur et sa consommation, en Mio (v2,
// sinon v1). 0 = pas de limite. Le MemAvailable de /proc/meminfo voit l'hôte
// entier : sans ça, un conteneur limité à 16 Go sur une machine de 64 Go se
// croirait riche.
func cgroupMemMiB(fsys fs.FS) (limit, current int64) {
read := func(p string) int64 {
b, err := fs.ReadFile(fsys, p)
if err != nil {
return 0
}
n, err := strconv.ParseInt(strings.TrimSpace(string(b)), 10, 64)
if err != nil || n <= 0 || n >= 1<<60 { // « max » ou la valeur géante du v1 sans limite
return 0
}
return n >> 20
}
if limit = read("sys/fs/cgroup/memory.max"); limit > 0 {
return limit, read("sys/fs/cgroup/memory.current")
}
if limit = read("sys/fs/cgroup/memory/memory.limit_in_bytes"); limit > 0 {
return limit, read("sys/fs/cgroup/memory/memory.usage_in_bytes")
}
return 0, 0
}
// nvidiaVRAMMiB : VRAM totale des cartes que llama-server verra. Borné dans le
// temps comme nvidiaGPUCount ; au moindre doute, 0.
func nvidiaVRAMMiB(cvd string, pciOrder bool, timeout time.Duration) int64 {
out, ok := smiTotals.get(time.Now, timeout)
if !ok {
return 0
}
return vramFromSMI(out, cvd, pciOrder)
}
// nvidiaTotalsQuery : la lecture brute des mémoires totales. Variable pour
// les tests.
var nvidiaTotalsQuery = func(timeout time.Duration) (string, error) {
if !hasTool("nvidia-smi") {
return "", exec.ErrNotFound
}
ctx, cancel := context.WithTimeout(context.Background(), timeout)
defer cancel()
out, err := hideCmd(exec.CommandContext(ctx, "nvidia-smi", "--query-gpu=index,memory.total",
"--format=csv,noheader,nounits")).Output()
return string(out), err
}
// smiTotalsCache : la lecture des mémoires totales, partagée quelques
// secondes. L'éditeur de preset demande à l'ouverture la taille du cache de
// prompts (et celle du second slot) et les avis MoE, en parallèle : chacun
// lançait SON nvidia-smi pour une réponse identique — la mémoire totale d'une
// carte ne bouge pas. Le verrou tenu pendant la lecture fait attendre les
// appels simultanés sur la même ; la sélection des cartes (CUDA_VISIBLE_DEVICES)
// s'applique après, sur la sortie brute. Un échec est gardé aussi, le temps du
// délai : la même salve ne relance pas trois lectures qui échoueront.
type smiTotalsCache struct {
mu sync.Mutex
exp time.Time
out string
ok bool
}
const smiTotalsTTL = 10 * time.Second
var smiTotals smiTotalsCache
func (c *smiTotalsCache) get(now func() time.Time, timeout time.Duration) (string, bool) {
c.mu.Lock()
defer c.mu.Unlock()
if now().Before(c.exp) {
return c.out, c.ok
}
out, err := nvidiaTotalsQuery(timeout)
c.out, c.ok, c.exp = out, err == nil, now().Add(smiTotalsTTL)
return c.out, c.ok
}
// vramFromSMI additionne la VRAM des cartes sélectionnées par
// CUDA_VISIBLE_DEVICES. Les index de nvidia-smi sont dans l'ordre du bus PCI ;
// ceux de CUDA aussi, mais seulement avec CUDA_DEVICE_ORDER=PCI_BUS_ID. Sans
// lui, on ne sait pas quelle carte est laquelle : on prend la plus petite autant
// de fois qu'il y a de cartes — un minorant, qui ne fait que rendre Loki plus
// prudent. Un UUID ou une tranche MIG : on ne sait pas, 0.
func vramFromSMI(out, cvd string, pciOrder bool) int64 {
byIdx := map[int]int64{}
var minMiB int64
for _, line := range strings.Split(strings.TrimSpace(out), "\n") {
f := strings.Split(line, ",")
if len(f) < 2 {
continue
}
i, err1 := strconv.Atoi(strings.TrimSpace(f[0]))
m, err2 := strconv.ParseInt(strings.TrimSpace(f[1]), 10, 64)
if err1 != nil || err2 != nil || m <= 0 {
return 0
}
byIdx[i] = m
if minMiB == 0 || m < minMiB {
minMiB = m
}
}
if len(byIdx) == 0 {
return 0
}
cvd = strings.TrimSpace(cvd)
if cvd == "" {
var sum int64
for _, m := range byIdx {
sum += m
}
return sum
}
var sum int64
n := 0
for _, d := range strings.Split(cvd, ",") {
if d = strings.TrimSpace(d); d == "" {
continue
}
i, err := strconv.Atoi(d)
if err != nil {
return 0
}
m, ok := byIdx[i]
if !ok {
return 0
}
sum += m
n++
}
if !pciOrder {
return minMiB * int64(n)
}
return sum
}
// kvTypeBytes : octets par élément d'un type de cache (blocs de 32 pour les
// types quantifiés de ggml). Inconnu : compté comme f16, l'estimation reste
// bornée par le plafond de RAM.
func kvTypeBytes(t string) float64 {
switch strings.ToLower(strings.TrimSpace(t)) {
case "f32":
return 4
case "q8_0":
return 34.0 / 32
case "q5_1":
return 24.0 / 32
case "q5_0":
return 22.0 / 32
case "q4_1":
return 20.0 / 32
case "q4_0", "iq4_nl":
return 18.0 / 32
}
return 2
}
// ggufStateBytes estime ce que pèse l'état d'une séquence : octets de cache KV
// par jeton (couches d'attention), et octets d'état récurrent (couches SSM d'un
// hybride, en f32 comme dans llama.cpp). 0 par jeton = on ne sait pas.
func ggufStateBytes(g GGUFInfo, kt, vt string) (perToken float64, recurrent int64) {
kl, vl := g.KeyLen, g.ValLen
if g.EmbeddingLength > 0 && g.HeadCount > 0 {
if kl == 0 {
kl = g.EmbeddingLength / g.HeadCount
}
if vl == 0 {
vl = g.EmbeddingLength / g.HeadCount
}
}
if kl == 0 || vl == 0 || g.BlockCount == 0 {
return 0, 0
}
heads, recLayers := 0, 0
switch {
case len(g.HeadCountKVLayers) > 0:
for _, h := range g.HeadCountKVLayers {
heads += h
if h == 0 && g.Hybrid {
recLayers++
}
}
case g.Hybrid && g.FullAttnInterval > 0:
attn := g.BlockCount / g.FullAttnInterval
heads, recLayers = attn*g.HeadCountKV, g.BlockCount-attn
default:
heads = g.BlockCount * g.HeadCountKV
if heads == 0 && g.Hybrid {
recLayers = g.BlockCount // récurrent pur
}
}
perToken = float64(heads) * (float64(kl)*kvTypeBytes(kt) + float64(vl)*kvTypeBytes(vt))
if recLayers > 0 {
conv := int64(0)
if g.SSMConv > 1 {
conv = int64(g.SSMConv-1) * int64(g.SSMInner+2*g.SSMGroups*g.SSMState)
}
recurrent = int64(recLayers) * (conv + int64(g.SSMState)*int64(g.SSMInner)) * 4
}
return perToken, recurrent
}
// serveCtx : la taille de contexte que le moteur ouvrira (-c d'EXTRA_ARGS, sinon
// CTX, sinon le défaut de Loki ; 0 = celle d'entraînement du modèle).
func serveCtx(cfg map[string]string, extra []string, g *GGUFInfo) int {
s := flagValue(extra, "-c", "--ctx-size")
if s == "" {
s = strings.TrimSpace(cfg["CTX"])
}
if s == "" {
s = "32768"
}
n, err := strconv.Atoi(s)
if err != nil || n < 0 {
return 0
}
if n == 0 && g != nil {
n = g.ContextLength
}
return n
}
// cpuWeights dit pourquoi des poids (ou le cache KV) vivent en RAM hôte. Le
// cache de prompts y ferait alors concurrence au cache de pages des experts
// mappés : on n'y touche pas. Vide = tout est sur GPU.
func cpuWeights(cfg map[string]string, extra []string, argEnv map[string]string, blocks int) string {
ot := flagValues(extra, "-ot", "--override-tensor")
if v := argEnv["LLAMA_ARG_OVERRIDE_TENSOR"]; v != "" {
ot = append(ot, v)
}
for _, v := range ot {
for _, el := range strings.Split(v, ",") {
if _, buft, ok := strings.Cut(el, "="); ok && strings.HasPrefix(strings.ToUpper(strings.TrimSpace(buft)), "CPU") {
return "tenseurs sur CPU (-ot)"
}
}
}
if hasAnyFlag(extra, "--cpu-moe", "-cmoe") || envTruthy(argEnv["LLAMA_ARG_CPU_MOE"]) {
return "experts sur CPU"
}
for _, n := range []struct{ env, short, long string }{
{"LLAMA_ARG_N_CPU_MOE", "-ncmoe", "--n-cpu-moe"},
{"LLAMA_ARG_N_CPU_FFN", "-ncffn", "--n-cpu-ffn"},
} {
for _, v := range append(flagValues(extra, n.short, n.long), argEnv[n.env]) {
if v = strings.TrimSpace(v); v != "" && v != "0" {
return "couches " + n.long + " sur CPU"
}
}
}
if !kvOffloaded(extra, argEnv) {
return "cache KV sur CPU"
}
ngl := flagValue(extra, "-ngl", "--n-gpu-layers", "--gpu-layers")
if ngl == "" {
ngl = strings.TrimSpace(cfg["NGL"])
if strings.EqualFold(ngl, "auto") {
ngl = argEnv["LLAMA_ARG_N_GPU_LAYERS"]
}
}
// 999 est la sentinelle « toutes » ; un nombre plus petit que le modèle (sa
// couche de sortie comprise) laisse des couches au CPU.
if n, err := strconv.Atoi(strings.TrimSpace(ngl)); err == nil && n != 999 && (blocks == 0 || n < blocks+1) {
return "couches GPU limitées à " + strconv.Itoa(n)
}
return ""
}
// cacheRAMAuto calcule la taille du cache de prompts quand CACHE_RAM est vide
// ou « auto ». 0 = garder le défaut du moteur, why dit pourquoi.
//
// Besoin : 2,5 fois l'état d'une conversation pleine (cache KV à CTX jetons,
// plus l'état récurrent et ses points de reprise sur un hybride) — de quoi
// garder la conversation principale ET l'état d'un travail annexe. Plafond : 30
// % de la RAM (limite du conteneur comprise) et la moitié de ce qui est libre au
// lancement. Ce plafond est un réglage, pas un filet : sous Linux, un
// dépassement finit en général par l'OOM killer, pas par le repli du moteur.
func cacheRAMAuto(cfg map[string]string, extra []string, si serveSysInfo) (mib int64, why string) {
switch {
case si.VRAMMiB <= 0:
return 0, "VRAM NVIDIA inconnue"
case si.GGUF == nil:
return 0, "métadonnées GGUF illisibles"
case si.RAMMiB <= 0 || si.RAMAvailMiB <= 0:
return 0, "RAM inconnue"
case si.ModelBytes <= 0:
return 0, "taille du modèle inconnue"
}
if r := cpuWeights(cfg, extra, si.ArgEnv, si.GGUF.BlockCount); r != "" {
return 0, r
}
kt, vt, _ := effectiveKVTypes(cfg, extra, si.ArgEnv)
perTok, rec := ggufStateBytes(*si.GGUF, kt, vt)
ctx := serveCtx(cfg, extra, si.GGUF)
if perTok <= 0 || ctx <= 0 {
return 0, "taille d'état inconnue"
}
kvBytes := perTok * float64(ctx)
if float64(si.ModelBytes)+kvBytes > 0.9*float64(si.VRAMMiB)*(1<<20) {
return 0, "modèle trop gros pour la VRAM : des poids restent en RAM"
}
state := kvBytes
if rec > 0 {
// Points de reprise : llama.cpp en garde jusqu'à 32 par séquence ; on en
// compte un par tranche de 8 k jetons, estimation grossière mais bornée.
state += float64(rec) * float64(min(32, ctx/8192+1)+1)
}
need := int64(math.Ceil(2.5 * state / (1 << 20)))
ceiling := min(si.RAMMiB*30/100, si.RAMAvailMiB/2)
if ceiling <= engineCacheRAMDefault {
return 0, fmt.Sprintf("RAM libre trop juste (plafond %d Mio)", ceiling)
}
if need <= engineCacheRAMDefault {
return 0, fmt.Sprintf("le défaut suffit (besoin estimé %d Mio)", need)
}
return min(need, ceiling), fmt.Sprintf("besoin estimé %d Mio, plafond %d Mio", need, ceiling)
}
// cacheRAMSetting lit CACHE_RAM : auto (vide ou « auto »), ou un nombre de Mio
// passé tel quel (-1 = sans limite, 0 = cache coupé). ok=false : valeur
// illisible.
func cacheRAMSetting(cfg map[string]string) (manual string, auto, ok bool) {
v := strings.TrimSpace(cfg["CACHE_RAM"])
if v == "" || strings.EqualFold(v, "auto") {
return "", true, true
}
if n, err := strconv.Atoi(v); err == nil && n >= -1 {
return strconv.Itoa(n), false, true
}
return "", false, false
}
// cacheRAMArgs : le --cache-ram que Loki ajoute, et ce qu'il en dit.
func cacheRAMArgs(cfg map[string]string, extra []string, si serveSysInfo) (args, notes []string) {
manual, auto, ok := cacheRAMSetting(cfg)
if !ok {
return nil, []string{"CACHE_RAM=" + cfg["CACHE_RAM"] + " illisible (un nombre de Mio, -1, 0 ou auto) : ignoré"}
}
if !strings.Contains(si.Help, "--cache-ram") {
if !auto {
notes = append(notes, "ce moteur ne connaît pas --cache-ram : CACHE_RAM ignoré")
}
return nil, notes
}
if hasAnyFlag(extra, "-cram", "--cache-ram") {
return nil, nil
}
if si.ArgEnv["LLAMA_ARG_CACHE_RAM"] != "" {
if !auto {
notes = append(notes, "LLAMA_ARG_CACHE_RAM est posée : CACHE_RAM ignoré")
}
return nil, notes
}
if !auto {
return []string{"--cache-ram", manual}, nil
}
n, why := cacheRAMAuto(cfg, extra, si)
if n <= 0 {
return nil, nil
}
return []string{"--cache-ram", strconv.FormatInt(n, 10)},
[]string{fmt.Sprintf("cache de prompts : --cache-ram %d (RAM hôte, copie exacte de l'état, jamais la VRAM ; %s). "+
"CACHE_RAM=<Mio> pour fixer, 0 pour couper.", n, why)}
}
// cacheRAMOff : le cache de prompts est-il coupé (0) par l'une des trois
// sources ? Sans lui, effacer un slot ne protège rien.
func cacheRAMOff(cfg map[string]string, extra []string, argEnv map[string]string) bool {
v := flagValue(extra, "-cram", "--cache-ram")
if v == "" {
v = argEnv["LLAMA_ARG_CACHE_RAM"]
}
if v == "" {
v, _, _ = cacheRAMSetting(cfg)
}
return strings.TrimSpace(v) == "0"
}
// cacheIsolationOn : CACHE_ISOLATE=off coupe l'effacement du slot après les
// travaux annexes (et donc --slot-save-path), sans toucher au code.
func cacheIsolationOn(cfg map[string]string) bool {
return !strings.EqualFold(strings.TrimSpace(cfg["CACHE_ISOLATE"]), "off")
}
// loopbackHost : le moteur n'écoute que sur la machine.
func loopbackHost(h string) bool {
switch strings.Trim(strings.TrimSpace(h), "[]") {
case "127.0.0.1", "::1", "localhost":
return true
}
return false
}
// slotSaveArgs : --slot-save-path, sans lequel llama-server refuse toute action
// sur /slots — l'effacement compris. Mais le drapeau ouvre aussi save et
// restore, qui écrivent des Gio sur le disque, à quiconque joint le moteur. On
// ne le pose donc que si le moteur est fermé aux autres (boucle locale, ou clé
// d'API exigée), que le dossier existe (le moteur refuse de démarrer sinon), et
// qu'il servira : à l'isolation (cache actif, CACHE_ISOLATE pas à off), ou à
// SLOT_PERSIST accepté (backend_serve_persist.go). Les proxys de Loki refusent
// de leur côté tout /slots qui n'est pas une lecture.
func slotSaveArgs(cfg map[string]string, extra []string, si serveSysInfo) []string {
if si.SlotDir == "" || !strings.Contains(si.Help, "--slot-save-path") || hasAnyFlag(extra, "--slot-save-path") {
return nil
}
persist, _ := slotPersistPlan(cfg, extra, si)
if !persist && (!cacheIsolationOn(cfg) || cacheRAMOff(cfg, extra, si.ArgEnv)) {
return nil
}
if !slotSaveSafe(cfg, extra, si) {
return nil
}
return []string{"--slot-save-path", si.SlotDir}
}
// prepareSlotDir crée LOKI_HOME/slots (0700) et le vide : un fichier présent ne
// peut venir que d'un appel qui n'aurait pas dû passer. Seule exception, avec
// keepPersist (SLOT_PERSIST demandé) : les états que Loki y a lui-même gardés
// à la bascule de preset, au nom reconnaissable. Vide = dossier indisponible,
// le drapeau ne sera pas posé.
func prepareSlotDir(home string, keepPersist bool) string {
dir := filepath.Join(home, "slots")
if err := os.MkdirAll(dir, 0o700); err != nil {
return ""
}
if st, err := os.Stat(dir); err != nil || !st.IsDir() {
return ""
}
if entries, err := os.ReadDir(dir); err == nil {
for _, e := range entries {
if e.Type().IsRegular() && !(keepPersist && slotPersistFileRe.MatchString(e.Name())) {
_ = os.Remove(filepath.Join(dir, e.Name()))
}
}
}
return dir
}
// cacheRAMPreview : la taille que le moteur prendra pour ce preset, pour
// l'éditeur. 0 = défaut du moteur (8192 Mio). Même calcul qu'au lancement, mais
// sans l'aide du moteur : l'aperçu dit ce que Loki demanderait.
func cacheRAMPreview(content string) (mib int64, why string) {
cfg := parseEnv(content)
if isExternalConfig(cfg) {
return 0, "preset externe"
}
extra := splitArgs(cfg["EXTRA_ARGS"])
if v := flagValue(extra, "-cram", "--cache-ram"); v != "" {
n, _ := strconv.ParseInt(v, 10, 64)
return n, "fixé par EXTRA_ARGS"
}
if v := strings.TrimSpace(os.Getenv("LLAMA_ARG_CACHE_RAM")); v != "" {
n, _ := strconv.ParseInt(v, 10, 64)
return n, "fixé par LLAMA_ARG_CACHE_RAM"
}
manual, auto, ok := cacheRAMSetting(cfg)
if !ok {
return 0, "CACHE_RAM illisible"
}
if !auto {
n, _ := strconv.ParseInt(manual, 10, 64)
return n, "fixé par CACHE_RAM"
}
si := serveSysInfo{ArgEnv: map[string]string{}}
for _, k := range append(append(append([]string{}, serveArgEnv...), fidelityArgEnv...), cacheArgEnv...) {
if v, ok := os.LookupEnv(k); ok && v != "" {
si.ArgEnv[k] = v
}
}
if m := strings.TrimSpace(cfg["MODEL"]); m != "" {
if p, err := resolveServeModelPath(m); err == nil {
si.Model = p
}
}
if si.Model == "" {
return 0, "modèle introuvable"
}
probeCacheRAM(cfg, extra, &si)
return cacheRAMAuto(cfg, extra, si)
}
-319
View File
@@ -1,319 +0,0 @@
package loki
import (
"errors"
"os"
"path/filepath"
"reflect"
"strings"
"sync"
"sync/atomic"
"testing"
"testing/fstest"
"time"
)
const helpCacheRAM = helpRecent + `-cram, --cache-ram N set the maximum cache size in MiB (default: 8192, -1 - no limit, 0 - disable)
--slot-save-path PATH path to save slot kv cache (default: disabled)
`
// denseGPU : un 27B dense de 17 Go sur deux cartes de 24 Go, machine de 64 Go.
// État : 64 couches × 4 têtes KV × (256+256) × 2 octets = 0,25 Mio par jeton.
func denseGPU() serveSysInfo {
return serveSysInfo{
Help: helpCacheRAM,
Model: "/models/dense.gguf",
GGUF: &GGUFInfo{Arch: "qwen3", BlockCount: 64, HeadCountKV: 4, KeyLen: 256, ValLen: 256,
ContextLength: 262144},
ModelBytes: 17 << 30,
RAMMiB: 65536, RAMAvailMiB: 60000,
VRAMMiB: 2 * 24576,
ArgEnv: map[string]string{},
}
}
func TestCacheRAMArgs(t *testing.T) {
cases := []struct {
name string
cfg map[string]string
extra []string
si func(*serveSysInfo)
want []string
notes int
}{
{name: "dense sur GPU, 64 Go, 65k : besoin 40 Gio, plafonné à 30 % de la RAM",
cfg: map[string]string{"CTX": "65536"}, want: []string{"--cache-ram", "19660"}, notes: 1},
{name: "contexte court : le défaut du moteur suffit",
cfg: map[string]string{"CTX": "8192"}},
{name: "cache q8_0 : l'état pèse deux fois moins, besoin sous le plafond",
cfg: map[string]string{"CTX": "32768", "KV_TYPE": "q8_0"}, want: []string{"--cache-ram", "10880"}, notes: 1},
{name: "même contexte en f16 : plafonné",
cfg: map[string]string{"CTX": "32768"}, want: []string{"--cache-ram", "19660"}, notes: 1},
{name: "-c d'EXTRA_ARGS l'emporte sur CTX",
cfg: map[string]string{"CTX": "65536"}, extra: []string{"-c", "8192"}},
{name: "MoE, experts sur CPU par -ot : jamais touché (pas de 4096)",
cfg: map[string]string{"CTX": "65536"}, extra: []string{"-ot", "exps=CPU"}},
{name: "-ot mixte avec un élément sur CPU",
cfg: map[string]string{"CTX": "65536"}, extra: []string{"-ot", "a=CUDA0,b=CPU"}},
{name: "-ot vers une autre carte seulement : tout reste en VRAM",
cfg: map[string]string{"CTX": "65536"}, extra: []string{"-ot", "blk\\.6.*=CUDA1"},
want: []string{"--cache-ram", "19660"}, notes: 1},
{name: "--n-cpu-moe",
cfg: map[string]string{"CTX": "65536"}, extra: []string{"--n-cpu-moe", "30"}},
{name: "LLAMA_ARG_N_CPU_MOE",
cfg: map[string]string{"CTX": "65536"}, si: func(s *serveSysInfo) { s.ArgEnv["LLAMA_ARG_N_CPU_MOE"] = "20" }},
{name: "cache KV sur CPU",
cfg: map[string]string{"CTX": "65536"}, extra: []string{"-nkvo"}},
{name: "NGL plus petit que le modèle",
cfg: map[string]string{"CTX": "65536", "NGL": "40"}},
{name: "NGL=999 : toutes les couches",
cfg: map[string]string{"CTX": "65536", "NGL": "999"}, want: []string{"--cache-ram", "19660"}, notes: 1},
{name: "modèle trop gros pour la VRAM",
cfg: map[string]string{"CTX": "65536"}, si: func(s *serveSysInfo) { s.ModelBytes = 40 << 30 }},
{name: "pas de VRAM connue (macOS, AMD, sans nvidia-smi)",
cfg: map[string]string{"CTX": "65536"}, si: func(s *serveSysInfo) { s.VRAMMiB = 0 }},
{name: "GGUF illisible",
cfg: map[string]string{"CTX": "65536"}, si: func(s *serveSysInfo) { s.GGUF = nil }},
{name: "RAM libre trop juste",
cfg: map[string]string{"CTX": "65536"}, si: func(s *serveSysInfo) { s.RAMAvailMiB = 12000 }},
{name: "CACHE_RAM fixé : passé tel quel",
cfg: map[string]string{"CTX": "65536", "CACHE_RAM": "12000"}, want: []string{"--cache-ram", "12000"}},
{name: "CACHE_RAM=-1 : sans limite, passé tel quel",
cfg: map[string]string{"CACHE_RAM": "-1"}, want: []string{"--cache-ram", "-1"}},
{name: "CACHE_RAM=0 : cache coupé",
cfg: map[string]string{"CACHE_RAM": "0"}, want: []string{"--cache-ram", "0"}},
{name: "CACHE_RAM=auto",
cfg: map[string]string{"CTX": "65536", "CACHE_RAM": "auto"}, want: []string{"--cache-ram", "19660"}, notes: 1},
{name: "CACHE_RAM illisible : ignoré, et dit",
cfg: map[string]string{"CACHE_RAM": "beaucoup"}, notes: 1},
{name: "-cram d'EXTRA_ARGS : Loki se tait",
cfg: map[string]string{"CTX": "65536", "CACHE_RAM": "12000"}, extra: []string{"-cram", "2048"}},
{name: "LLAMA_ARG_CACHE_RAM : la ligne de commande l'écraserait, Loki se tait",
cfg: map[string]string{"CTX": "65536"}, si: func(s *serveSysInfo) { s.ArgEnv["LLAMA_ARG_CACHE_RAM"] = "4096" }},
{name: "LLAMA_ARG_CACHE_RAM et CACHE_RAM : la variable gagne, et on le dit",
cfg: map[string]string{"CACHE_RAM": "12000"}, si: func(s *serveSysInfo) { s.ArgEnv["LLAMA_ARG_CACHE_RAM"] = "4096" }, notes: 1},
{name: "moteur sans --cache-ram : rien, même fixé",
cfg: map[string]string{"CACHE_RAM": "12000"}, si: func(s *serveSysInfo) { s.Help = helpRecent }, notes: 1},
}
for _, c := range cases {
t.Run(c.name, func(t *testing.T) {
si := denseGPU()
if c.si != nil {
c.si(&si)
}
got, notes := cacheRAMArgs(c.cfg, c.extra, si)
if !reflect.DeepEqual(got, c.want) {
t.Fatalf("args = %v, attendu %v (notes %v)", got, c.want, notes)
}
if len(notes) != c.notes {
t.Fatalf("notes = %v, attendu %d", notes, c.notes)
}
})
}
}
// Hybride (Qwen3.5) : 16 couches d'attention sur 64, état récurrent des 48
// autres, compté avec ses points de reprise.
func TestCacheRAMAutoHybride(t *testing.T) {
si := denseGPU()
layers := make([]int, 64)
for i := range layers {
if (i+1)%4 == 0 {
layers[i] = 4
}
}
si.GGUF = &GGUFInfo{Arch: "qwen35", BlockCount: 64, HeadCountKV: 4, HeadCountKVLayers: layers,
KeyLen: 256, ValLen: 256, FullAttnInterval: 4, Hybrid: true,
SSMConv: 4, SSMInner: 6144, SSMState: 128, SSMGroups: 16}
perTok, rec := ggufStateBytes(*si.GGUF, "", "")
if perTok != 65536 || rec != 156893184 {
t.Fatalf("état = %v o/jeton, %d o récurrents", perTok, rec)
}
// 4 Gio de KV + 10 états récurrents (9 points de reprise + l'état courant),
// × 2,5.
n, why := cacheRAMAuto(map[string]string{"CTX": "65536"}, nil, si)
if n != 13981 {
t.Fatalf("cacheRAMAuto = %d (%s), attendu 13981", n, why)
}
}
// Sans key_length, llama.cpp prend n_embd / n_head : nous aussi.
func TestGGUFStateBytesReplis(t *testing.T) {
g := GGUFInfo{BlockCount: 2, HeadCountKV: 2, EmbeddingLength: 1024, HeadCount: 8}
if p, _ := ggufStateBytes(g, "", ""); p != 2*2*(128+128)*2 {
t.Fatalf("repli n_embd/n_head : %v", p)
}
if p, _ := ggufStateBytes(GGUFInfo{BlockCount: 2, HeadCountKV: 2}, "", ""); p != 0 {
t.Fatalf("dimensions inconnues : %v, attendu 0", p)
}
}
func TestVRAMFromSMI(t *testing.T) {
const out = "0, 24576\n1, 16384\n"
cases := []struct {
cvd string
pci bool
want int64
}{
{"", false, 40960},
{"1", true, 16384},
{"0,1", true, 40960},
{"1", false, 16384}, // ordre inconnu : la plus petite carte
{"0", false, 16384},
{"GPU-1234", true, 0},
{"2", true, 0},
}
for _, c := range cases {
if got := vramFromSMI(out, c.cvd, c.pci); got != c.want {
t.Errorf("vramFromSMI(%q, %v) = %d, attendu %d", c.cvd, c.pci, got, c.want)
}
}
if got := vramFromSMI("[N/A]\n", "", false); got != 0 {
t.Errorf("sortie illisible : %d", got)
}
}
func TestCgroupMemMiB(t *testing.T) {
v2 := fstest.MapFS{
"sys/fs/cgroup/memory.max": {Data: []byte("17179869184\n")},
"sys/fs/cgroup/memory.current": {Data: []byte("4294967296\n")},
}
if l, c := cgroupMemMiB(v2); l != 16384 || c != 4096 {
t.Fatalf("v2 = %d/%d", l, c)
}
if l, _ := cgroupMemMiB(fstest.MapFS{"sys/fs/cgroup/memory.max": {Data: []byte("max\n")}}); l != 0 {
t.Fatalf("v2 sans limite = %d", l)
}
v1 := fstest.MapFS{"sys/fs/cgroup/memory/memory.limit_in_bytes": {Data: []byte("9223372036854771712\n")}}
if l, _ := cgroupMemMiB(v1); l != 0 {
t.Fatalf("v1 sans limite = %d", l)
}
}
func TestSlotSaveArgs(t *testing.T) {
want := []string{"--slot-save-path", "/data/slots"}
cases := []struct {
name string
cfg map[string]string
extra []string
si func(*serveSysInfo)
want []string
}{
{name: "boucle locale", cfg: map[string]string{"HOST": "127.0.0.1"}, want: want},
{name: "--host ::1 dans EXTRA_ARGS", extra: []string{"--host", "::1"}, want: want},
{name: "0.0.0.0 sans clé : ouvert à tous, pas de drapeau", cfg: map[string]string{}},
{name: "0.0.0.0 avec clé d'API", cfg: map[string]string{"HOST": "0.0.0.0"},
si: func(s *serveSysInfo) { s.APIKey = "k" }, want: want},
{name: "EXTRA_ARGS le pose déjà", cfg: map[string]string{"HOST": "127.0.0.1"},
extra: []string{"--slot-save-path", "/ailleurs"}},
{name: "CACHE_ISOLATE=off", cfg: map[string]string{"HOST": "127.0.0.1", "CACHE_ISOLATE": "off"}},
{name: "cache coupé : rien à isoler", cfg: map[string]string{"HOST": "127.0.0.1", "CACHE_RAM": "0"}},
{name: "cache coupé par EXTRA_ARGS", cfg: map[string]string{"HOST": "127.0.0.1"}, extra: []string{"-cram", "0"}},
{name: "moteur sans le drapeau", cfg: map[string]string{"HOST": "127.0.0.1"},
si: func(s *serveSysInfo) { s.Help = helpRecent }},
{name: "dossier indisponible", cfg: map[string]string{"HOST": "127.0.0.1"},
si: func(s *serveSysInfo) { s.SlotDir = "" }},
}
for _, c := range cases {
t.Run(c.name, func(t *testing.T) {
si := serveSysInfo{Help: helpCacheRAM, SlotDir: "/data/slots", ArgEnv: map[string]string{}}
if c.si != nil {
c.si(&si)
}
if got := slotSaveArgs(c.cfg, c.extra, si); !reflect.DeepEqual(got, c.want) {
t.Fatalf("slotSaveArgs = %v, attendu %v", got, c.want)
}
})
}
}
// Place dans la ligne complète : après --api-key, avant EXTRA_ARGS qui garde le
// dernier mot. Sans aide qui les connaisse, la ligne historique est intacte
// (TestBuildServeArgs).
func TestBuildServeArgsCacheRAM(t *testing.T) {
si := denseGPU()
si.APIKey, si.SlotDir = "k", "/data/slots"
args, _, _ := buildServeArgs(map[string]string{"CTX": "65536", "NGL": "999"}, []string{"--jinja"}, "/bin/llama-server", si)
got := strings.Join(args, " ")
if !strings.HasSuffix(got, "--api-key k --cache-ram 19660 --slot-save-path /data/slots --jinja") {
t.Fatalf("ligne = %s", got)
}
}
func TestPrepareSlotDir(t *testing.T) {
home := t.TempDir()
dir := prepareSlotDir(home, false)
if dir != filepath.Join(home, "slots") {
t.Fatalf("dossier = %q", dir)
}
stray := filepath.Join(dir, "slot0.bin")
if err := os.WriteFile(stray, []byte("x"), 0o600); err != nil {
t.Fatal(err)
}
if prepareSlotDir(home, false) == "" {
t.Fatal("second appel")
}
if _, err := os.Stat(stray); !os.IsNotExist(err) {
t.Fatal("un fichier laissé dans slots/ doit être purgé au lancement")
}
// Un fichier à la place du dossier : pas de drapeau, plutôt qu'un moteur
// qui refuse de démarrer.
bad := t.TempDir()
if err := os.WriteFile(filepath.Join(bad, "slots"), nil, 0o600); err != nil {
t.Fatal(err)
}
if got := prepareSlotDir(bad, false); got != "" {
t.Fatalf("slots est un fichier : %q", got)
}
}
// L'éditeur demande en parallèle le cache de prompts, le second slot et les
// avis MoE : une seule lecture nvidia-smi pour tous, la sélection des cartes
// appliquée ensuite. Après le délai, une lecture neuve ; un échec n'est pas
// relancé dans la même salve.
func TestNvidiaVRAMShared(t *testing.T) {
var calls atomic.Int32
fail := false
prevQ := nvidiaTotalsQuery
nvidiaTotalsQuery = func(time.Duration) (string, error) {
calls.Add(1)
time.Sleep(20 * time.Millisecond) // les appels simultanés attendent la même lecture
if fail {
return "", errors.New("exit status 9")
}
return "0, 16311\n1, 12288\n", nil
}
t.Cleanup(func() {
nvidiaTotalsQuery = prevQ
smiTotals.mu.Lock()
smiTotals.exp = time.Time{} // les tests suivants relisent nvidia-smi
smiTotals.mu.Unlock()
})
expire := func() {
smiTotals.mu.Lock()
smiTotals.exp = time.Time{}
smiTotals.mu.Unlock()
}
expire()
var wg sync.WaitGroup
got := make([]int64, 3)
for i, cvd := range []string{"", "0", "1"} {
wg.Add(1)
go func() { defer wg.Done(); got[i] = nvidiaVRAMMiB(cvd, true, time.Second) }()
}
wg.Wait()
if n := calls.Load(); n != 1 {
t.Errorf("%d lectures nvidia-smi pour trois demandes simultanées", n)
}
if !reflect.DeepEqual(got, []int64{16311 + 12288, 16311, 12288}) {
t.Errorf("sélection des cartes : %v", got)
}
expire()
fail = true
if v := nvidiaVRAMMiB("", true, time.Second); v != 0 {
t.Errorf("échec : %d, attendu 0", v)
}
if v := nvidiaVRAMMiB("", true, time.Second); v != 0 || calls.Load() != 2 {
t.Errorf("échec relancé dans la même salve : %d lectures", calls.Load())
}
}
-332
View File
@@ -1,332 +0,0 @@
package loki
import (
"fmt"
"os"
"strconv"
"strings"
"sync"
)
// Points de reprise des modèles hybrides (--ctx-checkpoints,
// --checkpoint-min-step) et conservation du raisonnement (--reasoning-preserve).
//
// Un modèle hybride (couches récurrentes : Qwen3.5/3.6, Qwen3-Next…) ne sait pas
// rembobiner son état récurrent comme un cache KV : llama-server en garde des
// copies exactes, les points de reprise, et repart du plus proche quand la
// conversation diverge ou revient du cache de prompts. Avant b10864 (PR #28302),
// le moteur effaçait un point trop proche du précédent (moins de 8192 jetons)
// MÊME quand la liste avait de la place : la reprise retombait jusqu'à ~8 k
// jetons en arrière, recalculés à chaque tour d'outil. Rien de ce que voit le
// modèle ne change ici — un point de reprise est une copie octet pour octet de
// l'état — seul le temps de prefill, et la RAM hôte que ces copies occupent.
//
// Loki ne touche à rien sans savoir : le build n'est cru que pour un moteur
// officiel (voir engineBuildTrusted), le drapeau n'est posé que si l'aide le
// connaît, et l'utilisateur garde le dernier mot (clés du preset, EXTRA_ARGS,
// LLAMA_ARG_*). Tout ce qui décide est pur ; cmdServe fait les lectures.
const (
// engineMinRecommended : premier build où l'espacement minimal n'évince un
// point de reprise que lorsque la liste est pleine (PR #28302, 5d806aa).
engineMinRecommended = 10864
// engineBuildFloor : en dessous, le numéro ne prouve rien. Un llama.cpp
// compilé depuis un clone superficiel (--depth=1) annonce « build 1 », un
// fork numérote à sa façon : traiter ça comme un moteur ancien poserait un
// avis faux et un drapeau inutile sur un moteur récent.
engineBuildFloor = 5000
// engineCkptDefault : n_ctx_checkpoints par défaut de llama.cpp (common.h).
engineCkptDefault = 32
// ckptAutoMinStep : espacement posé d'office sur un moteur ancien. Plus serré
// que les 8192 du moteur, il garde des points qu'il aurait jetés ; jamais 0,
// qui laisserait l'éviction FIFO jeter le point du début de la conversation
// dans une longue boucle d'outils.
ckptAutoMinStep = 2048
// ckptFallbackMiB : poids d'un point de reprise quand le GGUF ne permet pas de
// le calculer (70 à 200 Mio mesurés sur les hybrides Qwen) — on prend le haut.
ckptFallbackMiB = 200
)
// ckptArgEnv : les variables qui règlent la même chose sans drapeau. llama.cpp
// les lit avant la ligne de commande : un drapeau de Loki les écraserait.
var ckptArgEnv = []string{"LLAMA_ARG_CTX_CHECKPOINTS", "LLAMA_ARG_CHECKPOINT_MIN_SPACING_NT",
"LLAMA_ARG_REASONING_PRESERVE", "LLAMA_ARG_CHAT_TEMPLATE_KWARGS"}
// probeCkptEnv complète ArgEnv avec ckptArgEnv.
func probeCkptEnv(si *serveSysInfo) {
if si.ArgEnv == nil {
si.ArgEnv = map[string]string{}
}
for _, k := range ckptArgEnv {
if v, ok := os.LookupEnv(k); ok && strings.TrimSpace(v) != "" {
si.ArgEnv[k] = v
}
}
}
// hybridArchs : architectures hybrides connues, au cas où le GGUF ne porterait
// pas de clé ssm.* lisible.
var hybridArchs = map[string]bool{"qwen35": true, "qwen35moe": true, "qwen3next": true, "qwen4exp": true}
// ggufHybrid : le modèle a-t-il des couches récurrentes ? Jamais deviné d'après
// le nom du fichier : sans métadonnées, on répond non.
func ggufHybrid(g *GGUFInfo) bool {
return g != nil && (g.Hybrid || hybridArchs[strings.ToLower(g.Arch)])
}
// engineSource classe un moteur comme le panneau Moteur : celui de l'image,
// un moteur téléchargé par Loki depuis l'image officielle, le précompilé des
// releases officielles, ou tout le reste (« custom » : compilé, fork, chemin
// à la main).
func engineSource(bin string) string {
imageBin := providedEngineBin()
switch {
case imageBin != "" && samePath(bin, imageBin):
return "image"
case engineOwns(bin):
return "downloaded"
case prebuiltOwns(bin):
return "prebuilt"
}
return "custom"
}
// engineBuildTrust : le build n'est cru que pour un moteur publié par ggml-org,
// dont la CI numérote sur l'historique complet, et s'il est vraisemblable. 0 =
// inconnu : ni avis, ni drapeau automatique.
func engineBuildTrust(source string, build int) int {
if source == "custom" || build < engineBuildFloor {
return 0
}
return build
}
// engineBuildTrusted : build du moteur, ou 0 s'il n'est pas digne de confiance.
// Le « --version » est gardé par binaire, comme l'aide (binHelp), et la clé
// porte la taille et la date du fichier : un moteur recompilé au même chemin
// est relu.
func engineBuildTrusted(bin string) int {
src := engineSource(bin)
if src == "custom" {
return 0 // pas la peine de lancer le binaire
}
return engineBuildTrust(src, engineBuildCached(bin))
}
var (
buildMu sync.Mutex
buildCache = map[string]int{}
)
func engineBuildCached(bin string) int {
st, err := os.Stat(bin)
if err != nil {
return 0
}
key := fmt.Sprintf("%s|%d|%d", bin, st.Size(), st.ModTime().UnixNano())
buildMu.Lock()
defer buildMu.Unlock()
if b, ok := buildCache[key]; ok {
return b
}
b, _ := engineBuildOf(bin)
buildCache[key] = b
return b
}
// engineBuildNotice : l'avis du panneau Moteur et du journal de lancement pour
// un build trop ancien. Vide = rien à dire (à jour, ou build inconnu).
func engineBuildNotice(build int) string {
if build <= 0 || build >= engineMinRecommended {
return ""
}
return fmt.Sprintf("moteur b%d : les points de reprise des modèles hybrides (Qwen3.5/3.6, Qwen3-Next…) y sont "+
"évincés trop tôt — jusqu'à ~8 k jetons recalculés à chaque reprise. Corrigé à partir de b%d.",
build, engineMinRecommended)
}
// ckptCount : nombre de points de reprise par slot que le moteur gardera
// (CTX_CHECKPOINTS valide, sinon EXTRA_ARGS, sinon la variable, sinon le
// défaut). -1 = illisible ; un CTX_CHECKPOINTS illisible est ignoré (voir
// ckptArgs), le moteur garde alors son défaut.
func ckptCount(cfg map[string]string, extra []string, argEnv map[string]string) int {
v := flagValue(extra, "-ctxcp", "--ctx-checkpoints", "--swa-checkpoints")
if v == "" {
v = argEnv["LLAMA_ARG_CTX_CHECKPOINTS"]
}
if v == "" {
if n, err := strconv.Atoi(strings.TrimSpace(cfg["CTX_CHECKPOINTS"])); err == nil && n >= 0 {
return n
}
return engineCkptDefault
}
n, err := strconv.Atoi(strings.TrimSpace(v))
if err != nil || n < 0 {
return -1
}
return n
}
// ckptSlots : nombre de slots que le moteur ouvrira, chacun avec sa propre liste
// de points de reprise. --parallel d'EXTRA_ARGS, sinon PARALLEL, sinon 1 (Loki
// pose toujours --parallel, voir buildServeArgs). Illisible ou « auto » (-1) :
// 4, ce que choisissent les moteurs récents — on compte large.
func ckptSlots(cfg map[string]string, extra []string) int {
v := flagValue(extra, "-np", "--parallel")
if v == "" {
v = strings.TrimSpace(cfg["PARALLEL"])
}
if v == "" {
return 1
}
if n, err := strconv.Atoi(strings.TrimSpace(v)); err == nil && n > 0 {
return n
}
return 4
}
// ckptMiB : poids d'un point de reprise d'un hybride (état récurrent en f32).
func ckptMiB(g *GGUFInfo) int64 {
if g != nil {
if _, rec := ggufStateBytes(*g, "", ""); rec > 0 {
return (rec + (1 << 20) - 1) >> 20
}
}
return ckptFallbackMiB
}
// ramTight : le modèle occupe plus de 70 % de la RAM (mmap d'un gros MoE dont
// les experts vivent dans le cache de pages). Chaque Mio de points de reprise
// y est pris aux poids : plus de lectures disque, décodage plus lent.
func ramTight(si serveSysInfo) bool {
return si.RAMMiB > 0 && si.ModelBytes > 0 && float64(si.ModelBytes) > 0.7*float64(si.RAMMiB)*(1<<20)
}
// ckptArgs : --ctx-checkpoints et --checkpoint-min-step que Loki ajoute, et ce
// qu'il en dit.
//
// CTX_CHECKPOINTS=<n> → --ctx-checkpoints n (opt-in ; jamais de défaut relevé)
// CKPT_MIN_STEP=<n> → --checkpoint-min-step n (n > 0)
// clé absente → --checkpoint-min-step 2048 seulement sur un hybride,
// moteur officiel < b10864, RAM non serrée
//
// Chaque drapeau a sa propre garde : un --ctx-checkpoints écrit à la main ne
// coupe pas l'atténuation de l'espacement, qui est une autre question.
func ckptArgs(cfg map[string]string, extra []string, si serveSysInfo) (args, notes []string) {
hybrid := ggufHybrid(si.GGUF)
if v := strings.TrimSpace(cfg["CTX_CHECKPOINTS"]); v != "" {
n, err := strconv.Atoi(v)
switch {
case err != nil || n < 0:
notes = append(notes, "CTX_CHECKPOINTS="+v+" illisible (un nombre de points de reprise par slot, ≥ 0) : ignoré")
case !strings.Contains(si.Help, "--ctx-checkpoints"):
notes = append(notes, "ce moteur ne connaît pas --ctx-checkpoints : CTX_CHECKPOINTS ignoré")
case hasAnyFlag(extra, "-ctxcp", "--ctx-checkpoints", "--swa-checkpoints"):
case si.ArgEnv["LLAMA_ARG_CTX_CHECKPOINTS"] != "":
notes = append(notes, "LLAMA_ARG_CTX_CHECKPOINTS est posée : CTX_CHECKPOINTS ignoré")
default:
args = append(args, "--ctx-checkpoints", strconv.Itoa(n))
}
} else if hybrid && ramTight(si) && strings.Contains(si.Help, "--ctx-checkpoints") &&
!hasAnyFlag(extra, "-ctxcp", "--ctx-checkpoints", "--swa-checkpoints") && si.ArgEnv["LLAMA_ARG_CTX_CHECKPOINTS"] == "" {
// Un conseil, pas un réglage : moins de points de reprise, c'est plus de
// prefill, et seul l'utilisateur peut mesurer lequel des deux coûte le plus.
notes = append(notes, fmt.Sprintf("modèle hybride de %d Gio pour %d Gio de RAM : jusqu'à %d points de reprise "+
"(~%d Mio chacun) prennent sur le cache de pages des poids. CTX_CHECKPOINTS=8 à 16 si le débit baisse.",
si.ModelBytes>>30, si.RAMMiB>>10, engineCkptDefault, ckptMiB(si.GGUF)))
}
stepUser := hasAnyFlag(extra, "-cms", "--checkpoint-min-step") || si.ArgEnv["LLAMA_ARG_CHECKPOINT_MIN_SPACING_NT"] != ""
if v := strings.TrimSpace(cfg["CKPT_MIN_STEP"]); v != "" {
n, err := strconv.Atoi(v)
switch {
case err != nil || n <= 0:
// 0 = « aucun minimum » pour le moteur : la liste se remplit de points
// serrés et l'éviction FIFO finit par jeter celui du début de la
// conversation. On ne le pose pas, sans se taire pour autant.
notes = append(notes, "CKPT_MIN_STEP="+v+" refusé (un nombre de jetons > 0 ; 0 laisserait le moteur jeter "+
"le point de reprise du début de la conversation) : défaut du moteur")
case !strings.Contains(si.Help, "--checkpoint-min-step"):
notes = append(notes, "ce moteur ne connaît pas --checkpoint-min-step : CKPT_MIN_STEP ignoré")
case hasAnyFlag(extra, "-cms", "--checkpoint-min-step"):
case si.ArgEnv["LLAMA_ARG_CHECKPOINT_MIN_SPACING_NT"] != "":
notes = append(notes, "LLAMA_ARG_CHECKPOINT_MIN_SPACING_NT est posée : CKPT_MIN_STEP ignoré")
default:
args = append(args, "--checkpoint-min-step", strconv.Itoa(n))
}
return args, notes
}
notice := engineBuildNotice(si.EngineBuild)
if !hybrid || notice == "" {
return args, notes
}
n := ckptCount(cfg, extra, si.ArgEnv)
switch {
case stepUser || !strings.Contains(si.Help, "--checkpoint-min-step"):
return args, append(notes, notice)
case n <= 0:
return args, append(notes, notice) // points de reprise coupés : l'espacement ne sert à rien
case si.RAMMiB <= 0 || si.ModelBytes <= 0 || ramTight(si):
return args, append(notes, notice+" Pas d'espacement resserré d'office : RAM inconnue ou déjà prise par le modèle "+
"(CKPT_MIN_STEP=2048 pour l'imposer).")
}
// La liste est PAR SLOT : avec PARALLEL=4, quatre listes se remplissent.
total := n * ckptSlots(cfg, extra)
worst := int64(total) * ckptMiB(si.GGUF)
if si.RAMAvailMiB > 0 && worst > si.RAMAvailMiB/4 {
return args, append(notes, fmt.Sprintf("%s Pas d'espacement resserré d'office : %d points de reprise (~%d Mio) "+
"dépasseraient le quart de la RAM libre (CKPT_MIN_STEP=2048 pour l'imposer).", notice, total, worst))
}
args = append(args, "--checkpoint-min-step", strconv.Itoa(ckptAutoMinStep))
return args, append(notes, fmt.Sprintf("%s → --checkpoint-min-step %d : points de reprise exacts plus rapprochés, "+
"~%d Mio de RAM hôte au plus. CKPT_MIN_STEP=<jetons> pour fixer.", notice, ckptAutoMinStep, worst))
}
// reasoningPreserveArgs : REASONING_PRESERVE=on|off → --reasoning-preserve /
// --no-reasoning-preserve, seulement si la clé est posée.
//
// Le compromis, qui explique pourquoi Loki ne choisit pas : depuis b10763 le
// moteur active preserve_reasoning par défaut, et le gabarit garde alors la
// réflexion de TOUS les tours passés. Mais sans REASONING_ECHO, Loki ne renvoie
// pas le raisonnement des tours passés : un gabarit qui ne conserve rien de
// lui-même (Qwen3.6) rend alors des blocs de réflexion vides, là où « off » rend
// l'historique des anciens moteurs. À l'inverse, un gabarit qui conserve déjà de
// lui-même (Qwen3.8) changerait de rendu avec « off ». Aucun choix global n'est
// neutre pour tous les modèles : vide = défaut du moteur, inchangé.
//
// Avec REASONING_ECHO=on (llm_reasoning_echo.go), les deux clés se complètent
// sans se recouvrir : l'écho fournit le raisonnement, cette clé décide si le
// gabarit le garde au-delà du tour en cours. « on » donne alors un préfixe
// stable d'un message utilisateur à l'autre, au prix de plus de contexte ; le
// comptage de Loki suit le verdict de la sonde de gabarit (preservesHistory),
// pas cette clé, car un gabarit sans le réglage (Qwen3.5) l'ignore.
func reasoningPreserveArgs(cfg map[string]string, extra []string, si serveSysInfo) (args, notes []string) {
v := strings.ToLower(strings.TrimSpace(cfg["REASONING_PRESERVE"]))
if v == "" {
return nil, nil
}
flag := ""
switch v {
case "on":
flag = "--reasoning-preserve"
case "off":
flag = "--no-reasoning-preserve"
default:
return nil, []string{"REASONING_PRESERVE=" + cfg["REASONING_PRESERVE"] + " illisible (on ou off) : ignoré"}
}
switch {
case !strings.Contains(si.Help, "--no-reasoning-preserve"):
return nil, []string{"ce moteur ne connaît pas --reasoning-preserve : REASONING_PRESERVE ignoré"}
case hasAnyFlag(extra, "--reasoning-preserve", "--no-reasoning-preserve"):
return nil, nil
case strings.Contains(strings.Join(flagValues(extra, "--chat-template-kwargs"), " "), "preserve_reasoning"):
return nil, []string{"--chat-template-kwargs fixe déjà preserve_reasoning : REASONING_PRESERVE ignoré"}
case si.ArgEnv["LLAMA_ARG_REASONING_PRESERVE"] != "":
return nil, []string{"LLAMA_ARG_REASONING_PRESERVE est posée : REASONING_PRESERVE ignoré"}
case strings.Contains(si.ArgEnv["LLAMA_ARG_CHAT_TEMPLATE_KWARGS"], "preserve_reasoning"):
return nil, []string{"LLAMA_ARG_CHAT_TEMPLATE_KWARGS fixe déjà preserve_reasoning : REASONING_PRESERVE ignoré"}
}
return []string{flag}, nil
}
-247
View File
@@ -1,247 +0,0 @@
package loki
import (
"os"
"path/filepath"
"reflect"
"strings"
"testing"
)
const helpCkpt = helpRecent + `-ctxcp, --ctx-checkpoints, --swa-checkpoints N
max number of context checkpoints to create per slot (default: 32)
-cms, --checkpoint-min-step N minimum spacing between context checkpoints in tokens (default: 8192, 0 = no minimum)
--reasoning-preserve, --no-reasoning-preserve
preserve reasoning trace in the full history, not just the last assistant message (default: enabled)
`
// hybridOld : un Qwen3.6 27B hybride de 17 Go sur un moteur officiel b10678,
// machine de 64 Go. État récurrent : 48 couches × (3 × 10240 + 128 × 6144) × 4
// octets ≈ 150 Mio par point de reprise.
func hybridOld() serveSysInfo {
return serveSysInfo{
Help: helpCkpt,
Model: "/models/Qwen3.6-27B.gguf",
GGUF: &GGUFInfo{Arch: "qwen35", Hybrid: true, BlockCount: 64, FullAttnInterval: 4, HeadCountKV: 4,
KeyLen: 256, ValLen: 256, SSMConv: 4, SSMInner: 6144, SSMState: 128, SSMGroups: 16},
ModelBytes: 17 << 30,
RAMMiB: 65536,
RAMAvailMiB: 50000,
ArgEnv: map[string]string{},
EngineBuild: 10678,
}
}
func TestCkptArgs(t *testing.T) {
cms := []string{"--checkpoint-min-step", "2048"}
cases := []struct {
name string
cfg map[string]string
extra []string
si func(*serveSysInfo)
want []string
notes int
}{
{name: "hybride, moteur b10678 : espacement resserré d'office", want: cms, notes: 1},
{name: "hybride, moteur b11351 : rien", si: func(s *serveSysInfo) { s.EngineBuild = 11351 }},
{name: "hybride, build pile au correctif : rien", si: func(s *serveSysInfo) { s.EngineBuild = engineMinRecommended }},
{name: "build inconnu (compilé, clone superficiel, fork) : ni avis ni drapeau",
si: func(s *serveSysInfo) { s.EngineBuild = 0 }},
{name: "modèle dense sur moteur ancien : rien",
si: func(s *serveSysInfo) { s.GGUF = &GGUFInfo{Arch: "qwen3", BlockCount: 64} }},
{name: "métadonnées illisibles : jamais deviné au nom du fichier",
si: func(s *serveSysInfo) { s.GGUF = nil }},
{name: "arch hybride connue sans clé ssm.*", want: cms, notes: 1,
si: func(s *serveSysInfo) { s.GGUF = &GGUFInfo{Arch: "qwen3next"} }},
{name: "CKPT_MIN_STEP=4096 passé tel quel, quel que soit le build",
cfg: map[string]string{"CKPT_MIN_STEP": "4096"}, want: []string{"--checkpoint-min-step", "4096"},
si: func(s *serveSysInfo) { s.EngineBuild = 0 }},
{name: "CKPT_MIN_STEP=0 refusé, défaut du moteur, et on le dit",
cfg: map[string]string{"CKPT_MIN_STEP": "0"}, notes: 1},
{name: "-cms d'EXTRA_ARGS : Loki se tait sur l'espacement",
extra: []string{"-cms", "1024"}, notes: 1},
{name: "--checkpoint-min-step=1024 d'EXTRA_ARGS", cfg: map[string]string{"CKPT_MIN_STEP": "4096"},
extra: []string{"--checkpoint-min-step=1024"}},
{name: "LLAMA_ARG_CHECKPOINT_MIN_SPACING_NT posée : pas d'espacement d'office", notes: 1,
si: func(s *serveSysInfo) { s.ArgEnv["LLAMA_ARG_CHECKPOINT_MIN_SPACING_NT"] = "4096" }},
{name: "--ctx-checkpoints d'EXTRA_ARGS ne coupe pas l'espacement", extra: []string{"--ctx-checkpoints", "8"},
want: cms, notes: 1},
{name: "aide sans -cms : pas de drapeau, l'avis reste", notes: 1,
si: func(s *serveSysInfo) { s.Help = helpRecent }},
{name: "points de reprise coupés : l'espacement ne sert à rien", cfg: map[string]string{"CTX_CHECKPOINTS": "0"},
want: []string{"--ctx-checkpoints", "0"}, notes: 1},
{name: "MoE mmappé de 82 Go sur 64 Go : conseil, pas d'espacement d'office", notes: 2,
si: func(s *serveSysInfo) { s.ModelBytes = 82 << 30 }},
{name: "RAM inconnue : pas d'espacement d'office", notes: 1,
si: func(s *serveSysInfo) { s.RAMMiB = 0 }},
{name: "RAM libre trop juste pour 32 points", notes: 1,
si: func(s *serveSysInfo) { s.RAMAvailMiB = 8000 }},
// Une liste par slot : 2 × 32 × ~150 Mio tient dans le quart de 50 Go, 4 × non.
{name: "PARALLEL=2 : deux listes, encore dans le quart de la RAM libre", cfg: map[string]string{"PARALLEL": "2"},
want: cms, notes: 1},
{name: "PARALLEL=4 : quatre listes, trop pour la RAM libre", cfg: map[string]string{"PARALLEL": "4"}, notes: 1},
{name: "--parallel 4 d'EXTRA_ARGS l'emporte sur PARALLEL", cfg: map[string]string{"PARALLEL": "1"},
extra: []string{"--parallel", "4"}, notes: 1},
{name: "CTX_CHECKPOINTS=16 passé", cfg: map[string]string{"CTX_CHECKPOINTS": "16"},
want: []string{"--ctx-checkpoints", "16", "--checkpoint-min-step", "2048"}, notes: 1},
{name: "CTX_CHECKPOINTS illisible", cfg: map[string]string{"CTX_CHECKPOINTS": "beaucoup"}, want: cms, notes: 2},
{name: "CTX_CHECKPOINTS et LLAMA_ARG_CTX_CHECKPOINTS", cfg: map[string]string{"CTX_CHECKPOINTS": "16"},
want: cms, notes: 2,
si: func(s *serveSysInfo) { s.ArgEnv["LLAMA_ARG_CTX_CHECKPOINTS"] = "8" }},
{name: "CTX_CHECKPOINTS et -ctxcp d'EXTRA_ARGS", cfg: map[string]string{"CTX_CHECKPOINTS": "16"},
extra: []string{"-ctxcp", "8"}, want: cms, notes: 1},
{name: "moteur sans --ctx-checkpoints : CTX_CHECKPOINTS ignoré, dit", cfg: map[string]string{"CTX_CHECKPOINTS": "16"},
notes: 2, si: func(s *serveSysInfo) { s.Help = helpRecent }},
}
for _, c := range cases {
t.Run(c.name, func(t *testing.T) {
si := hybridOld()
if c.si != nil {
c.si(&si)
}
got, notes := ckptArgs(c.cfg, c.extra, si)
if !reflect.DeepEqual(got, c.want) {
t.Fatalf("ckptArgs = %v, attendu %v", got, c.want)
}
if len(notes) != c.notes {
t.Fatalf("notes = %q, attendu %d", notes, c.notes)
}
})
}
}
// Place dans la ligne complète : avant EXTRA_ARGS, qui garde le dernier mot.
func TestBuildServeArgsCkpt(t *testing.T) {
args, _, notes := buildServeArgs(map[string]string{"NGL": "999"}, []string{"--jinja"}, "/bin/llama-server", hybridOld())
if got := strings.Join(args, " "); !strings.HasSuffix(got, "--checkpoint-min-step 2048 --jinja") {
t.Fatalf("ligne = %s", got)
}
found := false
for _, n := range notes {
found = found || strings.Contains(n, "b10678")
}
if !found {
t.Fatalf("l'avis du moteur ancien manque : %q", notes)
}
}
func TestEngineBuildTrust(t *testing.T) {
cases := []struct {
src string
build int
want int
}{
{"downloaded", 10678, 10678},
{"image", 11351, 11351},
{"prebuilt", 10864, 10864},
{"custom", 10678, 0}, // compilé ou fork : numérotation inconnue
{"downloaded", 0, 0}, // --version illisible
{"image", 1, 0}, // clone superficiel
{"prebuilt", 4999, 0}, // sous le plancher
{"downloaded", 5000, 5000},
}
for _, c := range cases {
if got := engineBuildTrust(c.src, c.build); got != c.want {
t.Errorf("engineBuildTrust(%s, %d) = %d, attendu %d", c.src, c.build, got, c.want)
}
}
if engineBuildNotice(10678) == "" || engineBuildNotice(engineMinRecommended) != "" || engineBuildNotice(0) != "" {
t.Fatal("avis : seulement pour un build connu et antérieur au correctif")
}
}
func TestReasoningPreserveArgs(t *testing.T) {
cases := []struct {
name string
cfg map[string]string
extra []string
si func(*serveSysInfo)
want []string
notes int
}{
{name: "clé absente : rien, même sur un moteur qui connaît le drapeau"},
{name: "on", cfg: map[string]string{"REASONING_PRESERVE": "on"}, want: []string{"--reasoning-preserve"}},
{name: "OFF", cfg: map[string]string{"REASONING_PRESERVE": "OFF"}, want: []string{"--no-reasoning-preserve"}},
{name: "illisible", cfg: map[string]string{"REASONING_PRESERVE": "peut-être"}, notes: 1},
{name: "moteur sans le drapeau", cfg: map[string]string{"REASONING_PRESERVE": "off"}, notes: 1,
si: func(s *serveSysInfo) { s.Help = helpRecent }},
{name: "EXTRA_ARGS tranche déjà", cfg: map[string]string{"REASONING_PRESERVE": "off"},
extra: []string{"--reasoning-preserve"}},
{name: "--chat-template-kwargs le fixe", cfg: map[string]string{"REASONING_PRESERVE": "off"},
extra: []string{"--chat-template-kwargs", `{"preserve_reasoning":true}`}, notes: 1},
{name: "LLAMA_ARG_REASONING_PRESERVE posée", cfg: map[string]string{"REASONING_PRESERVE": "off"}, notes: 1,
si: func(s *serveSysInfo) { s.ArgEnv["LLAMA_ARG_REASONING_PRESERVE"] = "1" }},
{name: "LLAMA_ARG_CHAT_TEMPLATE_KWARGS le fixe", cfg: map[string]string{"REASONING_PRESERVE": "on"}, notes: 1,
si: func(s *serveSysInfo) {
s.ArgEnv["LLAMA_ARG_CHAT_TEMPLATE_KWARGS"] = `{"preserve_reasoning":false}`
}},
}
for _, c := range cases {
t.Run(c.name, func(t *testing.T) {
si := serveSysInfo{Help: helpCkpt, ArgEnv: map[string]string{}}
if c.si != nil {
c.si(&si)
}
got, notes := reasoningPreserveArgs(c.cfg, c.extra, si)
if !reflect.DeepEqual(got, c.want) {
t.Fatalf("reasoningPreserveArgs = %v, attendu %v", got, c.want)
}
if len(notes) != c.notes {
t.Fatalf("notes = %q, attendu %d", notes, c.notes)
}
})
}
}
// Après une mise à jour, la version téléchargée qui tournait reste installée :
// c'est le retour arrière sans réseau. Le moteur de l'image, lui, n'a rien à
// garder sous engine/.
func TestEngineKeepAfterUpdate(t *testing.T) {
testHome(t)
for _, tag := range []string{"server-cuda-b10500", "server-cuda-b10678", "server-cuda-b10900"} {
d := filepath.Join(engineDir(), tag)
if err := os.MkdirAll(d, 0o755); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(filepath.Join(d, "llama-server"), []byte("x"), 0o755); err != nil {
t.Fatal(err)
}
}
nouveau := filepath.Join(engineDir(), "server-cuda-b10900", "llama-server")
ancien := filepath.Join(engineDir(), "server-cuda-b10678", "llama-server")
keep := engineKeepAfterUpdate(nouveau, ancien, nil)
if !reflect.DeepEqual(keep, map[string]bool{"server-cuda-b10900": true, "server-cuda-b10678": true}) {
t.Fatalf("gardés = %v", keep)
}
enginePrune(keep, nil)
if got := engineInstalled(); !reflect.DeepEqual(got, []string{"server-cuda-b10678", "server-cuda-b10900"}) {
t.Fatalf("après ménage : %v", got)
}
if keep := engineKeepAfterUpdate(nouveau, "/app/llama-server", nil); len(keep) != 1 {
t.Fatalf("moteur de l'image : %v", keep)
}
if keep := engineKeepAfterUpdate(nouveau, nouveau, nil); len(keep) != 1 {
t.Fatalf("même version : %v", keep)
}
}
// Nombre de slots : chacun a sa liste de points de reprise.
func TestCkptSlots(t *testing.T) {
cases := []struct {
cfg map[string]string
extra []string
want int
}{
{want: 1},
{cfg: map[string]string{"PARALLEL": "3"}, want: 3},
{cfg: map[string]string{"PARALLEL": "3"}, extra: []string{"-np", "2"}, want: 2},
{extra: []string{"--parallel=6"}, want: 6},
{extra: []string{"--parallel", "-1"}, want: 4},
{cfg: map[string]string{"PARALLEL": "auto"}, want: 4},
}
for _, c := range cases {
if got := ckptSlots(c.cfg, c.extra); got != c.want {
t.Errorf("ckptSlots(%v, %v) = %d, attendu %d", c.cfg, c.extra, got, c.want)
}
}
}
-260
View File
@@ -1,260 +0,0 @@
package loki
import (
"os"
"strconv"
"strings"
)
// Réglages d'expert du moteur local, tous éteints tant que le preset ne les
// demande pas. Aucun ne touche au modèle : ni poids, ni contexte, ni
// échantillonnage — seulement OÙ et COMMENT le même calcul s'exécute.
//
// FIT_TARGET=1024,3072 → --fit-target : marge VRAM laissée libre par carte
// OP_OFFLOAD_MIN_BATCH=N → GGML_OP_OFFLOAD_MIN_BATCH : seuil d'envoi au GPU
// des poids restés en RAM
// CUDA_GRAPH_OPT=on → GGML_CUDA_GRAPH_OPT=1 : branches Q/K/V en parallèle
//
// Une variable déjà présente dans l'environnement (gabarit Docker/Unraid) est
// un choix de l'utilisateur : jamais touchée. Les variables passent par
// l'environnement du seul « loki serve », qui devient llama-server : le reste
// du conteneur ne les voit pas.
// expertEnvKeys : les variables du moteur que ces réglages posent.
var expertEnvKeys = []string{"GGML_CUDA_GRAPH_OPT", "GGML_OP_OFFLOAD_MIN_BATCH"}
// fitArgEnv : les LLAMA_ARG_* qui, sans drapeau, décident de --fit et de sa
// marge. llama.cpp les applique quand la ligne de commande se tait.
var fitArgEnv = []string{"LLAMA_ARG_FIT", "LLAMA_ARG_FIT_TARGET", "LLAMA_ARG_TENSOR_SPLIT"}
// probeExpertEnv relève ce que l'environnement a déjà tranché. Appelée après
// probeServeGPUs, dont elle complète ArgEnv.
func probeExpertEnv(si *serveSysInfo) {
si.UserEnv = map[string]string{}
for _, k := range expertEnvKeys {
if v := os.Getenv(k); v != "" {
si.UserEnv[k] = v
}
}
if si.ArgEnv == nil {
si.ArgEnv = map[string]string{}
}
for _, k := range fitArgEnv {
if v := os.Getenv(k); v != "" {
si.ArgEnv[k] = v
}
}
}
// fitTargetMin : la marge par défaut de llama.cpp (common.h), en Mio. En
// dessous, fit range plus serré que ce qu'il sait estimer — le tampon du modèle
// MTP, qu'il ne mesure pas toujours, suffit alors à ramener le « cudaMalloc
// failed: out of memory » au premier long prompt. On remonte, en le disant.
const fitTargetMin = 1024
// fitTargetMaxValues : llama.cpp refuse (et le serveur meurt en boucle) une
// liste aussi longue que son nombre maximal de cartes ; 8 reste loin du bord.
const fitTargetMaxValues = 8
// parseFitTarget valide FIT_TARGET : des entiers en Mio séparés par « , » ou
// « / », une valeur par carte dans l'ordre que voit le moteur (une seule vaut
// pour toutes). Jamais transmis brut : llama.cpp lit chaque valeur avec
// std::stoull, qui lève sur le moindre caractère parasite — moteur mort au
// démarrage, relancé en boucle par systemd ou Docker.
func parseFitTarget(v string) (vals []int, raised bool, ok bool) {
parts := strings.FieldsFunc(v, func(r rune) bool { return r == ',' || r == '/' })
if len(parts) == 0 || len(parts) > fitTargetMaxValues ||
strings.Count(v, ",")+strings.Count(v, "/") != len(parts)-1 {
return nil, false, false
}
for _, p := range parts {
p = strings.TrimSpace(p)
if p == "" || strings.Trim(p, "0123456789") != "" || len(p) > 7 {
return nil, false, false
}
n, _ := strconv.Atoi(p)
if n < fitTargetMin {
n, raised = fitTargetMin, true
}
vals = append(vals, n)
}
return vals, raised, true
}
// fitTargetArgs traduit FIT_TARGET en --fit-target. Fonction pure.
//
// La marge règle combien de VRAM --fit laisse libre sur chaque carte ; sur deux
// cartes inégales (5060 Ti + 3060), une marge plus large sur la lente pousse
// plus de couches vers la rapide. Placement seulement : mêmes poids, mêmes
// types de cache, même contexte. Rien n'est posé quand :
//
// - le moteur ne connaît pas --fit-target (un drapeau inconnu le tue) ;
// - EXTRA_ARGS ou LLAMA_ARG_FIT_TARGET l'ont déjà fixée ;
// - le contexte n'est pas un nombre positif : avec -c 0, fit a le droit de
// RÉDUIRE le contexte pour tenir, et une marge plus large l'y pousserait
// sans un mot — de l'information perdue pour le modèle ;
// - --fit ne tournera pas (voir fitBlocker) : la clé ne ferait rien, et une
// comparaison A/B mesurerait deux fois le même placement.
func fitTargetArgs(cfg map[string]string, extra []string, si serveSysInfo) (args, notes []string) {
raw := strings.TrimSpace(cfg["FIT_TARGET"])
if raw == "" {
return nil, nil
}
ignore := func(why string) (a, n []string) {
return nil, []string{"FIT_TARGET=" + raw + " ignoré : " + why}
}
vals, raised, ok := parseFitTarget(raw)
switch {
case !ok:
return ignore("des Mio par carte, ex. 1024,3072 (8 valeurs au plus)")
case !strings.Contains(si.Help, "--fit-target"):
return ignore("ce moteur ne connaît pas --fit-target")
case hasAnyFlag(extra, "-fitt", "--fit-target"):
return ignore("--fit-target déjà dans EXTRA_ARGS")
case si.ArgEnv["LLAMA_ARG_FIT_TARGET"] != "":
return ignore("LLAMA_ARG_FIT_TARGET déjà posé dans l'environnement")
}
if ctx, ok := ctxFixed(cfg, extra); !ok {
return ignore("contexte « " + ctx + " » non fixé — --fit pourrait le réduire pour tenir ; donne un CTX chiffré")
}
if why := fitBlocker(cfg, extra, si); why != "" {
return ignore("--fit inactif (" + why + "), la marge ne servirait à rien")
}
s := make([]string, len(vals))
for i, n := range vals {
s[i] = strconv.Itoa(n)
}
v := strings.Join(s, ",")
note := "--fit-target " + v + " (FIT_TARGET, Mio libres par carte, dans l'ordre vu par le moteur) — " +
"vérifie « offloaded N/N layers » dans le journal"
if raised {
note += " ; marges sous 1024 Mio remontées à 1024"
}
return []string{"--fit-target", v}, []string{note}
}
// fitBlocker dit pourquoi llama.cpp ne lancera PAS --fit (common/fit.cpp :
// « already set by user, abort »), ou vide s'il le lancera. Les couches GPU
// sont lues comme buildServeArgs les écrira : EXTRA_ARGS d'abord, sinon la
// traduction de NGL par nglArgs (999 y devient auto), sinon — NGL=auto, aucun
// drapeau — la variable du moteur.
func fitBlocker(cfg map[string]string, extra []string, si serveSysInfo) string {
fit := flagValue(extra, "-fit", "--fit")
if fit == "" {
fit = si.ArgEnv["LLAMA_ARG_FIT"]
}
switch strings.ToLower(strings.TrimSpace(fit)) {
case "off", "disabled", "false", "0":
return "--fit off"
}
ngl := flagValue(extra, "-ngl", "--n-gpu-layers", "--gpu-layers")
if ngl == "" {
if a, _ := nglArgs(cfg["NGL"], helpFitsLayersItself(si.Help)); len(a) == 2 {
ngl = a[1]
} else {
ngl = si.ArgEnv["LLAMA_ARG_N_GPU_LAYERS"]
}
}
// -1 est la valeur par défaut du moteur, celle qu'« auto » écrit : fit ne
// la prend pas pour un choix de l'utilisateur.
if ngl = strings.TrimSpace(ngl); ngl != "" && ngl != "-1" && !strings.EqualFold(ngl, "auto") {
return "couches GPU fixées à " + ngl
}
if hasAnyFlag(extra, "-ts", "--tensor-split") || si.ArgEnv["LLAMA_ARG_TENSOR_SPLIT"] != "" {
return "--tensor-split"
}
if r := tensorOverride(extra, si.ArgEnv); r != "" {
return r
}
sm := flagValue(extra, "-sm", "--split-mode")
if sm == "" {
sm = si.ArgEnv["LLAMA_ARG_SPLIT_MODE"]
}
// row ET tensor : fit abandonne sur l'une comme sur l'autre (« not
// implemented », common/fit.cpp).
if sm = strings.ToLower(strings.TrimSpace(sm)); sm == "row" || sm == "tensor" {
return "-sm " + sm
}
return ""
}
// opOffloadMinBatchEnv traduit OP_OFFLOAD_MIN_BATCH en GGML_OP_OFFLOAD_MIN_BATCH.
// Fonction pure.
//
// Quand des poids restent en RAM (experts MoE sur CPU), llama.cpp recopie vers
// le GPU ceux dont un lot a besoin dès qu'il compte au moins 32 jetons ; en
// dessous, il calcule sur CPU. Monter le seuil garde sur CPU les lots plus
// courts : utile seulement si une spéculation ngram propose des brouillons de
// 32 jetons ou plus (les lots de vérification MTP, quelques jetons, restent
// déjà sous 32). Le prix : un tour d'outil de 32 à N-1 jetons se calcule lui
// aussi sur CPU. N se trouve à la mesure, pas d'office. Même distribution ;
// les logits ne sont pas identiques au bit près, comme déjà entre décodage CPU
// et prefill GPU.
func opOffloadMinBatchEnv(cfg map[string]string, si serveSysInfo) (val string, notes []string) {
raw := strings.TrimSpace(cfg["OP_OFFLOAD_MIN_BATCH"])
if raw == "" || si.UserEnv["GGML_OP_OFFLOAD_MIN_BATCH"] != "" {
return "", nil
}
n, err := strconv.Atoi(raw)
if err != nil || n < 1 || n > 1<<20 {
return "", []string{"OP_OFFLOAD_MIN_BATCH=" + raw + " ignoré : un nombre de jetons, ex. 128"}
}
v := strconv.Itoa(n)
return v, []string{"GGML_OP_OFFLOAD_MIN_BATCH=" + v + " (OP_OFFLOAD_MIN_BATCH) : lots de moins de " + v +
" jetons calculés sur CPU pour les poids restés en RAM"}
}
// graphOptEnv traduit CUDA_GRAPH_OPT=on en GGML_CUDA_GRAPH_OPT=1. Fonction pure.
//
// Au décodage, llama.cpp lance alors les trois branches Q, K et V de chaque
// couche sur des flux CUDA concurrents. Chaque opération garde son noyau et ses
// entrées : sortie identique au jeton près en amont (PR #28198, perplexité
// inchangée). Gain modeste — quelques % sur un MoE tout GPU, rien sur un dense,
// dilué quand les experts tournent sur CPU ou que MTP vérifie par lots. Sans
// effet avant #28198 en multi-GPU ou si les graphes CUDA sont coupés.
//
// Expérimental : sur une architecture qu'il n'a pas vue, le repérage des
// branches peut s'arrêter sur un GGML_ASSERT, et le moteur redémarre en boucle.
// Une variable ne se détecte pas dans l'aide du moteur : on le dit au
// lancement, pour que « CUDA_GRAPH_OPT=off puis redémarrer » vienne à l'esprit.
//
// Refusé quand Q, K et V d'une même couche pourraient finir sur deux cartes
// (-sm row/tensor, -ot visant l'attention) : #28198 en fait une condition
// d'exactitude.
func graphOptEnv(cfg map[string]string, extra []string, si serveSysInfo) (val string, notes []string) {
raw := strings.TrimSpace(cfg["CUDA_GRAPH_OPT"])
switch strings.ToLower(raw) {
case "", "off":
return "", nil
case "on":
default:
return "", []string{"CUDA_GRAPH_OPT=" + raw + " ignoré (on ou off)"}
}
if si.UserEnv["GGML_CUDA_GRAPH_OPT"] != "" {
return "", nil
}
if why := qkvSplitRisk(extra, si.ArgEnv); why != "" {
return "", []string{"CUDA_GRAPH_OPT ignoré : " + why + " peut séparer Q, K et V d'une couche entre cartes"}
}
return "1", []string{"GGML_CUDA_GRAPH_OPT=1 (CUDA_GRAPH_OPT, expérimental) — si le moteur s'arrête sur un " +
"GGML_ASSERT, CUDA_GRAPH_OPT=off puis redémarre"}
}
// qkvSplitRisk : un placement qui pourrait répartir les tenseurs d'attention
// d'une même couche sur plusieurs appareils. La découpe par couches (défaut,
// --tensor-split compris) garde chaque couche entière : rien à craindre.
func qkvSplitRisk(extra []string, argEnv map[string]string) string {
sm := flagValue(extra, "-sm", "--split-mode")
if sm == "" {
sm = argEnv["LLAMA_ARG_SPLIT_MODE"]
}
if sm = strings.ToLower(strings.TrimSpace(sm)); sm == "row" || sm == "tensor" {
return "-sm " + sm
}
for _, v := range append(flagValues(extra, "-ot", "--override-tensor"), argEnv["LLAMA_ARG_OVERRIDE_TENSOR"]) {
if strings.Contains(strings.ToLower(v), "attn") {
return "-ot sur l'attention"
}
}
return ""
}
-218
View File
@@ -1,218 +0,0 @@
package loki
import (
"reflect"
"testing"
)
// helpFit : un moteur récent qui connaît --fit et sa marge.
const helpFit = helpRecent + `-fit, --fit [on|off] whether to adjust unset arguments to fit in device memory
-fitt, --fit-target MiB0,MiB1,... target margin per device for --fit (default: 1024)
`
// TestFitTargetArgs : --fit-target n'est passé que validé, sur un moteur qui le
// connaît, avec un contexte fixé, et quand --fit tournera vraiment.
func TestFitTargetArgs(t *testing.T) {
fit := serveSysInfo{Help: helpFit}
cases := []struct {
name string
cfg map[string]string
si serveSysInfo
want []string
wantNotes int
}{
{name: "clé absente : rien, sans un mot",
cfg: map[string]string{}, si: fit},
{name: "deux marges : passées, et on le dit",
cfg: map[string]string{"FIT_TARGET": "1024,3072"}, si: fit,
want: []string{"--fit-target", "1024,3072"}, wantNotes: 1},
{name: "séparateur / : normalisé en virgules",
cfg: map[string]string{"FIT_TARGET": "2048/1536"}, si: fit,
want: []string{"--fit-target", "2048,1536"}, wantNotes: 1},
{name: "une seule valeur : vaut pour toutes les cartes",
cfg: map[string]string{"FIT_TARGET": " 2048 "}, si: fit,
want: []string{"--fit-target", "2048"}, wantNotes: 1},
{name: "marge sous le défaut : remontée à 1024",
cfg: map[string]string{"FIT_TARGET": "256,3072"}, si: fit,
want: []string{"--fit-target", "1024,3072"}, wantNotes: 1},
{name: "valeur non numérique : jamais transmise (stoull ferait mourir le moteur)",
cfg: map[string]string{"FIT_TARGET": "1G,2G"}, si: fit, wantNotes: 1},
{name: "valeur négative : refusée",
cfg: map[string]string{"FIT_TARGET": "-1024"}, si: fit, wantNotes: 1},
{name: "séparateur doublé : refusé",
cfg: map[string]string{"FIT_TARGET": "1024,,2048"}, si: fit, wantNotes: 1},
{name: "trop de valeurs : refusé",
cfg: map[string]string{"FIT_TARGET": "1,2,3,4,5,6,7,8,9"}, si: fit, wantNotes: 1},
{name: "moteur sans --fit-target : rien, le drapeau le tuerait",
cfg: map[string]string{"FIT_TARGET": "1024,3072"}, si: serveSysInfo{Help: helpRecent}, wantNotes: 1},
{name: "-fitt déjà dans EXTRA_ARGS : l'utilisateur a tranché",
cfg: map[string]string{"FIT_TARGET": "1024", "EXTRA_ARGS": "-fitt 2048"}, si: fit, wantNotes: 1},
{name: "LLAMA_ARG_FIT_TARGET dans l'environnement : intouché",
cfg: map[string]string{"FIT_TARGET": "1024"},
si: serveSysInfo{Help: helpFit, ArgEnv: map[string]string{"LLAMA_ARG_FIT_TARGET": "4096"}}, wantNotes: 1},
{name: "CTX=0 : fit pourrait réduire le contexte, refusé",
cfg: map[string]string{"FIT_TARGET": "1024,3072", "CTX": "0"}, si: fit, wantNotes: 1},
{name: "-c 0 dans EXTRA_ARGS l'emporte sur CTX : refusé",
cfg: map[string]string{"FIT_TARGET": "1024", "CTX": "65536", "EXTRA_ARGS": "-c 0"}, si: fit, wantNotes: 1},
{name: "CTX absent : Loki passe -c 32768, accepté",
cfg: map[string]string{"FIT_TARGET": "1024"}, si: fit,
want: []string{"--fit-target", "1024"}, wantNotes: 1},
{name: "NGL=999 : devient -ngl auto, fit tourne",
cfg: map[string]string{"FIT_TARGET": "1024", "NGL": "999"}, si: fit,
want: []string{"--fit-target", "1024"}, wantNotes: 1},
{name: "NGL chiffré : fit abandonne, rien",
cfg: map[string]string{"FIT_TARGET": "1024", "NGL": "40"}, si: fit, wantNotes: 1},
{name: "NGL=all : fit abandonne, rien",
cfg: map[string]string{"FIT_TARGET": "1024", "NGL": "all"}, si: fit, wantNotes: 1},
{name: "-ngl auto dans EXTRA_ARGS : fit tourne",
cfg: map[string]string{"FIT_TARGET": "1024", "NGL": "40", "EXTRA_ARGS": "-ngl auto"}, si: fit,
want: []string{"--fit-target", "1024"}, wantNotes: 1},
{name: "NGL=auto et LLAMA_ARG_N_GPU_LAYERS=30 : rien",
cfg: map[string]string{"FIT_TARGET": "1024", "NGL": "auto"},
si: serveSysInfo{Help: helpFit, ArgEnv: map[string]string{"LLAMA_ARG_N_GPU_LAYERS": "30"}}, wantNotes: 1},
{name: "--tensor-split : rien",
cfg: map[string]string{"FIT_TARGET": "1024", "EXTRA_ARGS": "--tensor-split 0.965,0.035"}, si: fit, wantNotes: 1},
{name: "preset MoE (-ot, --n-cpu-moe) : rien",
cfg: map[string]string{"FIT_TARGET": "1024", "EXTRA_ARGS": `-ot "per_layer_token_embd=CPU" --n-cpu-moe 40`},
si: fit, wantNotes: 1},
{name: "--cpu-moe : rien",
cfg: map[string]string{"FIT_TARGET": "1024", "EXTRA_ARGS": "-cmoe"}, si: fit, wantNotes: 1},
{name: "--fit off : rien",
cfg: map[string]string{"FIT_TARGET": "1024", "EXTRA_ARGS": "--fit off"}, si: fit, wantNotes: 1},
{name: "LLAMA_ARG_FIT=off : rien",
cfg: map[string]string{"FIT_TARGET": "1024"},
si: serveSysInfo{Help: helpFit, ArgEnv: map[string]string{"LLAMA_ARG_FIT": "off"}}, wantNotes: 1},
{name: "-sm row : rien",
cfg: map[string]string{"FIT_TARGET": "1024", "EXTRA_ARGS": "-sm row"}, si: fit, wantNotes: 1},
{name: "-sm tensor : fit abandonne aussi, rien",
cfg: map[string]string{"FIT_TARGET": "1024", "EXTRA_ARGS": "--split-mode tensor"}, si: fit, wantNotes: 1},
{name: "LLAMA_ARG_SPLIT_MODE=tensor : rien",
cfg: map[string]string{"FIT_TARGET": "1024"},
si: serveSysInfo{Help: helpFit, ArgEnv: map[string]string{"LLAMA_ARG_SPLIT_MODE": "tensor"}}, wantNotes: 1},
{name: "-sm layer : fit tourne",
cfg: map[string]string{"FIT_TARGET": "1024", "EXTRA_ARGS": "-sm layer"}, si: fit,
want: []string{"--fit-target", "1024"}, wantNotes: 1},
{name: "-ngl -1 (le défaut du moteur) : fit tourne",
cfg: map[string]string{"FIT_TARGET": "1024", "EXTRA_ARGS": "-ngl -1"}, si: fit,
want: []string{"--fit-target", "1024"}, wantNotes: 1},
{name: "--device choisi : le placement reste à fit, accepté",
cfg: map[string]string{"FIT_TARGET": "1024,3072", "EXTRA_ARGS": "--device CUDA1,CUDA0"}, si: fit,
want: []string{"--fit-target", "1024,3072"}, wantNotes: 1},
}
for _, c := range cases {
t.Run(c.name, func(t *testing.T) {
got, notes := fitTargetArgs(c.cfg, splitArgs(c.cfg["EXTRA_ARGS"]), c.si)
if !reflect.DeepEqual(got, c.want) {
t.Fatalf("args = %q, attendu %q", got, c.want)
}
if len(notes) != c.wantNotes {
t.Fatalf("notes = %q, en attendait %d", notes, c.wantNotes)
}
})
}
}
// TestGraphOptEnv : GGML_CUDA_GRAPH_OPT=1 seulement sur demande, jamais
// par-dessus l'environnement, jamais quand Q/K/V pourraient changer de carte.
func TestGraphOptEnv(t *testing.T) {
cases := []struct {
name string
cfg map[string]string
si serveSysInfo
want string
wantNotes int
}{
{name: "clé absente : rien", cfg: map[string]string{}},
{name: "off : rien", cfg: map[string]string{"CUDA_GRAPH_OPT": "off"}},
{name: "on : posé, et on le dit", cfg: map[string]string{"CUDA_GRAPH_OPT": "on"}, want: "1", wantNotes: 1},
{name: "ON (casse libre) : posé", cfg: map[string]string{"CUDA_GRAPH_OPT": "ON"}, want: "1", wantNotes: 1},
{name: "valeur inconnue : ignorée, dite", cfg: map[string]string{"CUDA_GRAPH_OPT": "2"}, wantNotes: 1},
{name: "variable déjà posée (même à 0) : intouchée",
cfg: map[string]string{"CUDA_GRAPH_OPT": "on"},
si: serveSysInfo{UserEnv: map[string]string{"GGML_CUDA_GRAPH_OPT": "0"}}},
{name: "découpe par couches + --tensor-split : couches entières, posé",
cfg: map[string]string{"CUDA_GRAPH_OPT": "on", "EXTRA_ARGS": "--device CUDA1,CUDA0 -ts 0.6,0.4"},
want: "1", wantNotes: 1},
{name: "-sm row : refusé",
cfg: map[string]string{"CUDA_GRAPH_OPT": "on", "EXTRA_ARGS": "-sm row"}, wantNotes: 1},
{name: "LLAMA_ARG_SPLIT_MODE=tensor : refusé",
cfg: map[string]string{"CUDA_GRAPH_OPT": "on"},
si: serveSysInfo{ArgEnv: map[string]string{"LLAMA_ARG_SPLIT_MODE": "tensor"}}, wantNotes: 1},
{name: "-ot sur l'attention : refusé",
cfg: map[string]string{"CUDA_GRAPH_OPT": "on", "EXTRA_ARGS": `-ot "blk\.1\.attn_k.*=CUDA1"`}, wantNotes: 1},
{name: "-ot des experts seulement : posé",
cfg: map[string]string{"CUDA_GRAPH_OPT": "on", "EXTRA_ARGS": `-ot "ffn_.*_exps=CPU" --n-cpu-moe 40`},
want: "1", wantNotes: 1},
}
for _, c := range cases {
t.Run(c.name, func(t *testing.T) {
got, notes := graphOptEnv(c.cfg, splitArgs(c.cfg["EXTRA_ARGS"]), c.si)
if got != c.want {
t.Fatalf("valeur = %q, attendu %q", got, c.want)
}
if len(notes) != c.wantNotes {
t.Fatalf("notes = %q, en attendait %d", notes, c.wantNotes)
}
})
}
}
// TestOpOffloadMinBatchEnv : la variable n'existe que si la clé est posée et
// valide, et ne remplace jamais celle de l'environnement.
func TestOpOffloadMinBatchEnv(t *testing.T) {
cases := []struct {
name string
cfg map[string]string
si serveSysInfo
want string
wantNotes int
}{
{name: "clé absente : rien", cfg: map[string]string{}},
{name: "128 : posé", cfg: map[string]string{"OP_OFFLOAD_MIN_BATCH": "128"}, want: "128", wantNotes: 1},
{name: "espaces autour : posé", cfg: map[string]string{"OP_OFFLOAD_MIN_BATCH": " 64 "}, want: "64", wantNotes: 1},
{name: "0 : ignoré, dit", cfg: map[string]string{"OP_OFFLOAD_MIN_BATCH": "0"}, wantNotes: 1},
{name: "texte : ignoré, dit", cfg: map[string]string{"OP_OFFLOAD_MIN_BATCH": "beaucoup"}, wantNotes: 1},
{name: "variable déjà posée : intouchée",
cfg: map[string]string{"OP_OFFLOAD_MIN_BATCH": "128"},
si: serveSysInfo{UserEnv: map[string]string{"GGML_OP_OFFLOAD_MIN_BATCH": "32"}}},
}
for _, c := range cases {
t.Run(c.name, func(t *testing.T) {
got, notes := opOffloadMinBatchEnv(c.cfg, c.si)
if got != c.want {
t.Fatalf("valeur = %q, attendu %q", got, c.want)
}
if len(notes) != c.wantNotes {
t.Fatalf("notes = %q, en attendait %d", notes, c.wantNotes)
}
})
}
}
// Les trois clés absentes, la ligne de commande et l'environnement ne bougent
// pas ; posées, --fit-target arrive avant EXTRA_ARGS et les variables vont
// dans env, nulle part ailleurs.
func TestBuildServeArgsExpertToggles(t *testing.T) {
const bin, model = "/opt/llama/llama-server", "/models/Qwen3.6-27B-Q4_K_M.gguf"
si := serveSysInfo{Help: helpFit, Model: model}
cfg := map[string]string{"CTX": "65536", "EXTRA_ARGS": "--device CUDA0"}
base, baseEnv, baseNotes := buildServeArgs(cfg, splitArgs(cfg["EXTRA_ARGS"]), bin, si)
if len(baseEnv) != 0 || len(baseNotes) != 0 {
t.Fatalf("sans les clés : env = %v, notes = %q", baseEnv, baseNotes)
}
on := map[string]string{"CTX": "65536", "EXTRA_ARGS": "--device CUDA0",
"FIT_TARGET": "1024,3072", "OP_OFFLOAD_MIN_BATCH": "128", "CUDA_GRAPH_OPT": "on"}
args, env, notes := buildServeArgs(on, splitArgs(on["EXTRA_ARGS"]), bin, si)
n := len(base)
want := append(append(append([]string{}, base[:n-2]...), "--fit-target", "1024,3072"), base[n-2:]...)
if !reflect.DeepEqual(args, want) {
t.Fatalf("args\n got %q\nwant %q", args, want)
}
wantEnv := map[string]string{"GGML_CUDA_GRAPH_OPT": "1", "GGML_OP_OFFLOAD_MIN_BATCH": "128"}
if !reflect.DeepEqual(env, wantEnv) {
t.Fatalf("env = %v, want %v", env, wantEnv)
}
if len(notes) != 3 {
t.Fatalf("notes = %q", notes)
}
}
-217
View File
@@ -1,217 +0,0 @@
package loki
import (
"fmt"
"os"
"strconv"
"strings"
)
// Garde-fous de fidélité. Loki ne choisit JAMAIS à la place de l'utilisateur un
// réglage qui change ce que le modèle calcule : pas de cache KV quantifié posé
// d'office, pas de réutilisation approximative du cache, pas de contexte qui
// glisse en jetant des jetons. Ces réglages restent possibles — ce sont des
// compromis légitimes quand la VRAM manque — mais ils doivent se VOIR : un
// preset qui tourne en q8_0 depuis EXTRA_ARGS pendant que l'interface affiche
// « f16 » est exactement l'altération silencieuse qu'on refuse.
//
// Tout ici est pur : buildServeArgs en tire des notes, cmdServe les écrit.
// fidelityArgEnv : les LLAMA_ARG_* qui, sans drapeau, posent un cache
// quantifié, un cache approché ou la vision. llama.cpp les applique AVANT la
// ligne de commande : un -ctk de KV_TYPE ou d'EXTRA_ARGS les écrase, mais seuls
// ils décident. LLAMA_ARG_NO_CONTEXT_SHIFT est celle des moteurs anciens.
var fidelityArgEnv = []string{"LLAMA_ARG_CACHE_TYPE_K", "LLAMA_ARG_CACHE_TYPE_V",
"LLAMA_ARG_CONTEXT_SHIFT", "LLAMA_ARG_NO_CONTEXT_SHIFT", "LLAMA_ARG_CACHE_REUSE",
"LLAMA_ARG_MMPROJ", "LLAMA_ARG_MMPROJ_URL", "LLAMA_ARG_SPEC_SYNTH_LEN"}
// probeFidelityEnv complète ArgEnv avec fidelityArgEnv. Appelée après
// probeServeGPUs, qui crée ArgEnv.
func probeFidelityEnv(si *serveSysInfo) {
if si.ArgEnv == nil {
si.ArgEnv = map[string]string{}
}
for _, k := range fidelityArgEnv {
if v, ok := os.LookupEnv(k); ok && v != "" {
si.ArgEnv[k] = v
}
}
}
// effectiveKVTypes donne les types de cache K et V que le moteur utilisera
// VRAIMENT. Trois sources, de la plus faible à la plus forte : les variables
// LLAMA_ARG_CACHE_TYPE_K/V (lues avant la ligne de commande), le preset
// (serveKVTypes, traduit en -ctk/-ctv), puis EXTRA_ARGS, qui ferme la ligne :
// llama-server retient la dernière occurrence d'un drapeau, un -ctk q8_0 écrit
// à la main l'emporte donc sur KV_TYPE. src nomme la source la plus forte qui a
// tranché l'un des deux (vide : aucune). Vide = défaut du moteur (f16).
func effectiveKVTypes(cfg map[string]string, extra []string, argEnv map[string]string) (k, v, src string) {
k, v = argEnv["LLAMA_ARG_CACHE_TYPE_K"], argEnv["LLAMA_ARG_CACHE_TYPE_V"]
if k != "" || v != "" {
src = "défini par LLAMA_ARG_CACHE_TYPE_K/V"
}
if ck, cv := serveKVTypes(cfg); ck != "" || cv != "" {
src = "KV_TYPE"
if ck != "" {
k = ck
}
if cv != "" {
v = cv
}
}
if s := flagValue(extra, "-ctk", "--cache-type-k"); s != "" {
k, src = s, "défini par EXTRA_ARGS"
}
if s := flagValue(extra, "-ctv", "--cache-type-v"); s != "" {
v, src = s, "défini par EXTRA_ARGS"
}
return k, v, src
}
// kvFidelity classe un type de cache par rapport au f16 de référence. Rang 0 =
// mêmes calculs (f16, ou f32 qui ne perd rien) ; plus le rang monte, plus les
// sorties s'en écartent. Un type inconnu (sorti après cette version) est
// supposé altérer : on préfère un avertissement de trop à un silence.
func kvFidelity(t string) (rank int, label string) {
switch strings.ToLower(strings.TrimSpace(t)) {
case "", "f16", "f32":
return 0, ""
case "bf16":
return 1, "numérique différente de f16 (mantisse plus courte)"
case "q8_0", "q5_0", "q5_1":
return 2, "modifie légèrement les sorties par rapport à f16"
case "q4_0", "q4_1", "iq4_nl":
return 3, "perte mesurable par rapport à f16"
default:
return 2, "modifie les sorties par rapport à f16"
}
}
// kvFidelityNote dit, quand le cache n'est pas en f16, ce que ce choix coûte en
// fidélité et d'où il vient. Le choix reste celui du preset : Loki ne le change
// jamais, ni dans un sens ni dans l'autre. Pas d'estimation de la VRAM qu'un
// retour en f16 demanderait : sans les métadonnées du GGUF, ce serait un chiffre
// inventé. En revanche, si le placement est figé à la main (-ot, --n-cpu-moe N>0,
// en drapeau ou en variable : tensorOverride), --fit ne tourne pas et ne
// rattrapera pas ce surplus — mieux vaut le savoir avant de basculer et de
// finir en « out of memory ».
func kvFidelityNote(k, v, src string, extra []string, argEnv map[string]string) string {
rk, lk := kvFidelity(k)
rv, lv := kvFidelity(v)
if rk == 0 && rv == 0 {
return ""
}
label := lk
if rv > rk {
label = lv
}
note := fmt.Sprintf("cache KV %s/%s (%s) : %s — choix de l'utilisateur, laissé tel quel.",
orF16(k), orF16(v), src, label)
if tensorOverride(extra, argEnv) != "" {
note += " Repasser en f16 demande plus de VRAM, et le placement fixé par -ot/--n-cpu-moe " +
"empêche --fit de compenser : il faudrait sans doute relever --n-cpu-moe."
}
return note
}
func orF16(t string) string {
if t == "" {
return "f16"
}
return t
}
// lossyCacheNotes avertit des réglages de cache qui changent ce que voit le
// modèle. Seuls deux le font :
//
// - le glissement de contexte : contexte plein → le moteur JETTE des jetons
// anciens et continue, le modèle perd une partie de la conversation sans le
// savoir ;
// - --cache-reuse N (N > 0) : recolle des morceaux de cache calculés sous un
// AUTRE préfixe, simplement décalés — le résultat n'est plus celui d'un
// calcul complet. --cache-reuse 0 le désactive : rien à dire.
//
// --swa-full n'en fait PAS partie : il garde le cache complet des couches à
// fenêtre glissante, ce qui rend la réutilisation du préfixe exacte (au prix de
// VRAM), et ne fait rien sur un modèle sans SWA. Correspondance exacte des noms
// (flagValue) : --no-context-shift ne déclenche rien, et la dernière occurrence
// gagne comme dans llama-server. Les variables LLAMA_ARG_* comptent quand la
// ligne de commande se tait.
//
// Avec la vision chargée (MMPROJ, --mmproj d'EXTRA_ARGS ou LLAMA_ARG_MMPROJ),
// llama.cpp désactive lui-même les deux (non pris en charge en multimodal) : on
// le dit plutôt que de crier au loup. Les modèles hybrides ou récurrents les
// ignorent aussi, mais sans métadonnées du GGUF on ne sait pas les reconnaître
// ici : on se tait sur ce point plutôt que deviner.
func lossyCacheNotes(extra []string, si serveSysInfo) []string {
var notes []string
ignored := ""
if si.MMProj != "" || hasAnyFlag(extra, "-mm", "--mmproj", "-mmu", "--mmproj-url") ||
si.ArgEnv["LLAMA_ARG_MMPROJ"] != "" || si.ArgEnv["LLAMA_ARG_MMPROJ_URL"] != "" {
ignored = " (vision chargée : llama.cpp l'ignore de toute façon)"
}
switch on, src := contextShift(extra, si.ArgEnv, si.Help); {
case on && src == "":
notes = append(notes, "avertissement : ce llama-server ancien glisse le contexte par défaut — contexte "+
"plein, il jette des jetons anciens et le modèle perd une partie de la conversation sans le savoir"+
ignored+". --no-context-shift dans EXTRA_ARGS l'en empêche.")
case on:
notes = append(notes, "avertissement : --context-shift ("+src+") — contexte plein, le moteur jette "+
"des jetons anciens et le modèle perd une partie de la conversation sans le savoir"+ignored+".")
}
s, src := flagValue(extra, "--cache-reuse"), "EXTRA_ARGS"
if s == "" {
s, src = si.ArgEnv["LLAMA_ARG_CACHE_REUSE"], "LLAMA_ARG_CACHE_REUSE"
}
if n, err := strconv.Atoi(strings.TrimSpace(s)); err == nil && n > 0 {
notes = append(notes, fmt.Sprintf("avertissement : --cache-reuse %d (%s) — réutilise des morceaux "+
"de cache calculés sous un autre préfixe : les sorties ne sont plus exactement celles d'un calcul "+
"complet%s.", n, src, ignored))
}
// Acceptation SYNTHÉTIQUE (« benchmarking only ») : le moteur garde des
// jetons du brouillon au hasard, sans les comparer à ce qu'aurait tiré le
// modèle. Loki ne les pose jamais ; écrits à la main, on le crie.
if f := hasFlagPrefix(extra, "--spec-synth-"); f != "" || si.ArgEnv["LLAMA_ARG_SPEC_SYNTH_LEN"] != "" {
if f == "" {
f = "LLAMA_ARG_SPEC_SYNTH_LEN"
}
notes = append(notes, "avertissement : "+f+" — le moteur accepte des jetons du brouillon AU HASARD, sans "+
"vérification : les réponses ne sont plus celles du modèle. Réservé aux mesures, retire-le.")
}
return notes
}
// contextShift dit si le moteur glissera le contexte, et qui l'a décidé (vide :
// son propre défaut). --context-shift et --no-context-shift peuvent cohabiter
// dans EXTRA_ARGS (preset copié, puis corrigé) ; comme llama-server, la
// dernière occurrence décide. Sans drapeau, la variable du moteur, puis son
// défaut — qui a changé : les llama-server d'avant --context-shift (mi-2025) ne
// connaissent que --no-context-shift et GLISSENT par défaut. Aide vide (moteur
// inconnu) : on ne suppose rien.
func contextShift(extra []string, argEnv map[string]string, help string) (on bool, src string) {
set := false
for _, a := range extra {
name, _, _ := strings.Cut(a, "=")
switch name {
case "--context-shift":
on, set = true, true
case "--no-context-shift":
on, set = false, true
}
}
if set {
return on, "EXTRA_ARGS"
}
if strings.Contains(help, "--no-context-shift") && !strings.Contains(help, "--context-shift") {
// Moteur ancien : seul LLAMA_ARG_NO_CONTEXT_SHIFT (lu comme un booléen
// vrai) l'arrête ; LLAMA_ARG_CONTEXT_SHIFT lui est inconnue.
if envTruthy(argEnv["LLAMA_ARG_NO_CONTEXT_SHIFT"]) {
return false, "LLAMA_ARG_NO_CONTEXT_SHIFT"
}
return true, ""
}
if envTruthy(argEnv["LLAMA_ARG_CONTEXT_SHIFT"]) {
return true, "LLAMA_ARG_CONTEXT_SHIFT"
}
return false, ""
}
@@ -1,307 +0,0 @@
package loki
import (
"context"
"encoding/json"
"go/ast"
"go/parser"
"go/token"
"net/http"
"net/http/httptest"
"net/url"
"path/filepath"
"reflect"
"strconv"
"strings"
"sync"
"testing"
)
func TestEffectiveKVTypes(t *testing.T) {
const ea, kv = "défini par EXTRA_ARGS", "KV_TYPE"
const env = "défini par LLAMA_ARG_CACHE_TYPE_K/V"
for _, c := range []struct {
name string
cfg map[string]string
extra string
argEnv map[string]string
k, v string
src string
}{
{"rien : défaut moteur", map[string]string{}, "", nil, "", "", ""},
{"KV_TYPE seul", map[string]string{"KV_TYPE": "q8_0"}, "", nil, "q8_0", "q8_0", kv},
{"EXTRA_ARGS l'emporte sur KV_TYPE", map[string]string{"KV_TYPE": "f16"},
"--cache-type-k q8_0 --cache-type-v q8_0", nil, "q8_0", "q8_0", ea},
{"formes courtes, la dernière gagne", map[string]string{},
"-ctk q4_0 -ctv q4_0 -ctk q8_0", nil, "q8_0", "q4_0", ea},
{"forme --flag=valeur", map[string]string{}, "--cache-type-v=q8_0", nil, "", "q8_0", ea},
{"K seul dans EXTRA_ARGS, V du preset", map[string]string{"KV_TYPE_V": "q4_0"},
"-ctk q8_0", nil, "q8_0", "q4_0", ea},
// Les variables passent AVANT la ligne de commande : seules, elles
// décident ; un -ctk venu de KV_TYPE les écrase.
{"variables seules", map[string]string{}, "",
map[string]string{"LLAMA_ARG_CACHE_TYPE_K": "q8_0", "LLAMA_ARG_CACHE_TYPE_V": "q8_0"}, "q8_0", "q8_0", env},
{"KV_TYPE_K écrase la variable K, la variable V reste", map[string]string{"KV_TYPE_K": "f16"}, "",
map[string]string{"LLAMA_ARG_CACHE_TYPE_K": "q4_0", "LLAMA_ARG_CACHE_TYPE_V": "q8_0"}, "f16", "q8_0", kv},
} {
t.Run(c.name, func(t *testing.T) {
k, v, src := effectiveKVTypes(c.cfg, splitArgs(c.extra), c.argEnv)
if k != c.k || v != c.v || src != c.src {
t.Fatalf("got %q/%q src=%q, want %q/%q src=%q", k, v, src, c.k, c.v, c.src)
}
})
}
}
func TestKVFidelityNote(t *testing.T) {
for _, c := range []struct {
name string
k, v string
src string
extra string
argEnv map[string]string
want []string // fragments attendus ; nil = aucune note
fit bool // la note doit parler de --fit (placement figé)
}{
{"défaut moteur : rien à dire", "", "", "", "", nil, nil, false},
{"f16 explicite : rien à dire", "f16", "f16", "KV_TYPE", "", nil, nil, false},
{"f32 ne perd rien", "f32", "f32", "KV_TYPE", "", nil, nil, false},
{"q8_0 : léger écart", "q8_0", "q8_0", "KV_TYPE", "", nil,
[]string{"q8_0/q8_0", "KV_TYPE", "modifie légèrement"}, false},
{"q4_0 : perte mesurable", "q4_0", "q4_0", "KV_TYPE", "", nil, []string{"perte mesurable"}, false},
{"mixte : le pire des deux", "q8_0", "q4_0", "KV_TYPE", "", nil, []string{"perte mesurable"}, false},
{"bf16 : numérique différente", "bf16", "bf16", "KV_TYPE", "", nil, []string{"mantisse"}, false},
{"V seul quantifié", "", "q8_0", "KV_TYPE", "", nil, []string{"f16/q8_0"}, false},
{"type inconnu : supposé altérer", "q3_k", "q3_k", "KV_TYPE", "", nil, []string{"modifie les sorties"}, false},
{"venu d'EXTRA_ARGS, placement figé : --fit ne compense pas", "q8_0", "q8_0", "défini par EXTRA_ARGS",
"-ot per_layer_token_embd.weight=CPU --n-cpu-moe 40 --cache-type-k q8_0", nil,
[]string{"défini par EXTRA_ARGS", "relever --n-cpu-moe"}, true},
// --n-cpu-moe 0 ne place rien : --fit tourne, rien à en dire.
{"--n-cpu-moe 0 : placement libre", "q8_0", "q8_0", "défini par EXTRA_ARGS",
"--n-cpu-moe 0 -ctk q8_0 -ctv q8_0", nil, []string{"q8_0/q8_0"}, false},
{"experts sur CPU par variable : placement figé aussi", "q8_0", "q8_0", "KV_TYPE", "",
map[string]string{"LLAMA_ARG_N_CPU_MOE": "30"}, []string{"relever --n-cpu-moe"}, true},
} {
t.Run(c.name, func(t *testing.T) {
got := kvFidelityNote(c.k, c.v, c.src, splitArgs(c.extra), c.argEnv)
if c.want == nil {
if got != "" {
t.Fatalf("note inattendue : %q", got)
}
return
}
for _, w := range c.want {
if !strings.Contains(got, w) {
t.Fatalf("note %q sans %q", got, w)
}
}
if strings.Contains(got, "--fit") != c.fit {
t.Fatalf("note %q : mention de --fit attendue = %v", got, c.fit)
}
})
}
}
func TestLossyCacheNotes(t *testing.T) {
// Aides des deux générations de llama-server : l'ancienne ne connaît que
// --no-context-shift et glisse par défaut, la récente ne glisse pas.
const helpShiftOld = "--no-context-shift disables context shift on infinite text generation (default: disabled)"
const helpShiftNew = "--context-shift, --no-context-shift whether to use context shift on infinite text generation (default: disabled)"
envOf := func(kv ...string) map[string]string {
m := map[string]string{}
for i := 0; i+1 < len(kv); i += 2 {
m[kv[i]] = kv[i+1]
}
return m
}
for _, c := range []struct {
name string
extra string
si serveSysInfo
want []string // un fragment par note attendue, dans l'ordre
}{
{"rien", "", serveSysInfo{}, nil},
{"--context-shift", "--context-shift", serveSysInfo{}, []string{"--context-shift (EXTRA_ARGS)"}},
{"--no-context-shift ne déclenche rien", "--no-context-shift", serveSysInfo{}, nil},
{"la dernière occurrence décide", "--context-shift --no-context-shift", serveSysInfo{}, nil},
{"--cache-reuse 256", "--cache-reuse 256", serveSysInfo{}, []string{"--cache-reuse 256 (EXTRA_ARGS)"}},
{"--cache-reuse=256", "--cache-reuse=256", serveSysInfo{}, []string{"--cache-reuse 256"}},
{"--cache-reuse 0 : désactivé", "--cache-reuse 0", serveSysInfo{}, nil},
{"--swa-full est sans perte", "--swa-full", serveSysInfo{}, nil},
{"les deux", "--context-shift --cache-reuse 64", serveSysInfo{}, []string{"jette", "--cache-reuse 64"}},
{"vision (MMPROJ) : llama.cpp les ignore, on le dit", "--context-shift --cache-reuse 64",
serveSysInfo{MMProj: "/m/mmproj.gguf"}, []string{"l'ignore", "l'ignore"}},
{"vision par --mmproj d'EXTRA_ARGS : pareil", "--mmproj mmproj-F16.gguf --cache-reuse 64",
serveSysInfo{}, []string{"l'ignore"}},
// Les variables du moteur comptent quand la ligne de commande se tait.
{"LLAMA_ARG_CACHE_REUSE", "", serveSysInfo{ArgEnv: envOf("LLAMA_ARG_CACHE_REUSE", "128")},
[]string{"--cache-reuse 128 (LLAMA_ARG_CACHE_REUSE)"}},
{"--cache-reuse 0 d'EXTRA_ARGS écrase la variable", "--cache-reuse 0",
serveSysInfo{ArgEnv: envOf("LLAMA_ARG_CACHE_REUSE", "128")}, nil},
{"LLAMA_ARG_CONTEXT_SHIFT=1", "", serveSysInfo{Help: helpShiftNew, ArgEnv: envOf("LLAMA_ARG_CONTEXT_SHIFT", "1")},
[]string{"(LLAMA_ARG_CONTEXT_SHIFT)"}},
{"moteur récent sans drapeau : rien", "", serveSysInfo{Help: helpShiftNew}, nil},
// Défaut d'un moteur ancien : il jette des jetons sans qu'on ait rien écrit.
{"moteur ancien : glisse par défaut, on le dit", "", serveSysInfo{Help: helpShiftOld},
[]string{"glisse le contexte par défaut"}},
{"moteur ancien, --no-context-shift : rien", "--no-context-shift", serveSysInfo{Help: helpShiftOld}, nil},
{"moteur ancien, LLAMA_ARG_NO_CONTEXT_SHIFT=1 : rien", "",
serveSysInfo{Help: helpShiftOld, ArgEnv: envOf("LLAMA_ARG_NO_CONTEXT_SHIFT", "1")}, nil},
{"moteur inconnu (aide vide) : on ne suppose rien", "", serveSysInfo{}, nil},
} {
t.Run(c.name, func(t *testing.T) {
got := lossyCacheNotes(splitArgs(c.extra), c.si)
if len(got) != len(c.want) {
t.Fatalf("notes = %q, en attendait %d", got, len(c.want))
}
for i, w := range c.want {
if !strings.Contains(got[i], w) {
t.Fatalf("note %q sans %q", got[i], w)
}
}
})
}
}
// Ce que buildServeArgs fait des garde-fous : JAMAIS de drapeau ajouté ou
// retiré, seulement des notes. Sans KV_TYPE, aucun -ctk/-ctv : le moteur garde
// son f16.
func TestBuildServeArgsFidelite(t *testing.T) {
const bin = "/opt/llama/llama-server"
si := serveSysInfo{Help: helpRecent, Model: "/models/m.gguf"}
for _, c := range []struct {
name string
cfg map[string]string
notes []string // fragments attendus parmi les notes ; nil = aucune note de fidélité
}{
{"KV_TYPE absent : ni -ctk ni -ctv, rien à dire", map[string]string{"NGL": "28"}, nil},
{"--swa-full : sans perte, silence", map[string]string{"NGL": "28", "EXTRA_ARGS": "--swa-full"}, nil},
{"cache quantifié par EXTRA_ARGS : vu", map[string]string{"NGL": "28",
"EXTRA_ARGS": "--cache-type-k q8_0 --cache-type-v q8_0"}, []string{"défini par EXTRA_ARGS"}},
{"--cache-reuse 256 : averti", map[string]string{"NGL": "28", "EXTRA_ARGS": "--cache-reuse 256"},
[]string{"--cache-reuse 256"}},
{"--context-shift : averti", map[string]string{"NGL": "28", "EXTRA_ARGS": "--context-shift"},
[]string{"--context-shift"}},
} {
t.Run(c.name, func(t *testing.T) {
extra := splitArgs(c.cfg["EXTRA_ARGS"])
args, _, notes := buildServeArgs(c.cfg, extra, bin, si)
if c.cfg["KV_TYPE"] == "" && !hasAnyFlag(extra, "-ctk", "-ctv", "--cache-type-k", "--cache-type-v") &&
hasAnyFlag(args, "-ctk", "-ctv", "--cache-type-k", "--cache-type-v") {
t.Fatalf("Loki a posé un type de cache de lui-même : %q", args)
}
// La ligne de commande se termine exactement par EXTRA_ARGS, intouché.
if tail := args[len(args)-len(extra):]; strings.Join(tail, " ") != strings.Join(extra, " ") {
t.Fatalf("EXTRA_ARGS modifié : %q", tail)
}
all := strings.Join(notes, "\n")
if c.notes == nil {
if strings.Contains(all, "cache KV") || strings.Contains(all, "avertissement") {
t.Fatalf("note de fidélité inattendue : %q", notes)
}
return
}
for _, w := range c.notes {
if !strings.Contains(all, w) {
t.Fatalf("notes %q sans %q", notes, w)
}
}
})
}
}
// Aucune requête de Loki vers le moteur ne coupe le cache de préfixe
// (cache_prompt:false) ni n'active la réutilisation approximative
// (n_cache_reuse). On vérifie les corps RÉELLEMENT envoyés par le chat et par
// la compaction, contre un faux llama-server local.
func TestRequetesSansCacheApproximatif(t *testing.T) {
testHome(t)
var mu sync.Mutex
var bodies []map[string]any
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
var body map[string]any
_ = json.NewDecoder(r.Body).Decode(&body)
mu.Lock()
bodies = append(bodies, body)
mu.Unlock()
if body["stream"] == true {
w.Header().Set("Content-Type", "text/event-stream")
w.WriteHeader(200)
_, _ = w.Write([]byte(sseChunk("bonjour")))
_, _ = w.Write([]byte(`data: {"choices":[{"delta":{},"finish_reason":"stop"}]}` + "\n\ndata: [DONE]\n\n"))
return
}
sendJSON(w, 200, map[string]any{"choices": []any{map[string]any{"message": map[string]any{"content": "- résumé"}}}})
}))
t.Cleanup(srv.Close)
u, err := url.Parse(srv.URL)
if err != nil {
t.Fatal(err)
}
if err := SetConfigKey("PORT", u.Port()); err != nil {
t.Fatal(err)
}
if _, err := runChat(context.Background(), []Message{{Role: "user", Content: "salut"}}, 0.7, Caps{},
func(StreamEvent) bool { return true }); err != nil {
t.Fatalf("runChat : %v", err)
}
if _, err := summarizeTranscript(context.Background(), "user: bonjour\nassistant: salut"); err != nil {
t.Fatalf("compaction : %v", err)
}
mu.Lock()
defer mu.Unlock()
if len(bodies) < 2 {
t.Fatalf("%d requête(s) reçue(s), attendu chat + compaction", len(bodies))
}
for _, b := range bodies {
if v, ok := b["cache_prompt"]; ok && v != true {
t.Errorf("cache_prompt=%v envoyé au moteur : le préfixe serait recalculé ou faussé", v)
}
if _, ok := b["n_cache_reuse"]; ok {
t.Errorf("n_cache_reuse envoyé au moteur : réutilisation approximative du cache")
}
}
}
// Filet sur tout le paquet : les clés cache_prompt et n_cache_reuse n'existent
// dans le code QUE pour le benchmark, qui mesure le prefill et doit donc couper
// le cache. Lecture de l'arbre syntaxique (pas du texte) : un commentaire qui
// les mentionne ne compte pas, un nouveau fichier est couvert d'office.
func TestCleCacheReserveeAuBench(t *testing.T) {
files, err := filepath.Glob("*.go")
if err != nil {
t.Fatal(err)
}
allowed := map[string]bool{"llm_bench.go": true}
fset := token.NewFileSet()
for _, f := range files {
if strings.HasSuffix(f, "_test.go") || allowed[f] {
continue
}
file, err := parser.ParseFile(fset, f, nil, parser.SkipObjectResolution)
if err != nil {
t.Fatal(err)
}
banned := func(s string) bool { return s == "cache_prompt" || s == "n_cache_reuse" }
ast.Inspect(file, func(n ast.Node) bool {
// Un champ de structure sérialisé (étiquette json:"cache_prompt")
// enverrait la clé aussi sûrement qu'une map : on lit les étiquettes.
if f, ok := n.(*ast.Field); ok && f.Tag != nil {
if tag, err := strconv.Unquote(f.Tag.Value); err == nil {
if name, _, _ := strings.Cut(reflect.StructTag(tag).Get("json"), ","); banned(name) {
t.Errorf("%s : champ %q hors du benchmark", fset.Position(f.Pos()), name)
}
}
return true
}
lit, ok := n.(*ast.BasicLit)
if !ok || lit.Kind != token.STRING {
return true
}
if s, err := strconv.Unquote(lit.Value); err == nil && banned(s) {
t.Errorf("%s : clé %q hors du benchmark", fset.Position(lit.Pos()), s)
}
return true
})
}
}
+5 -50
View File
@@ -1,10 +1,8 @@
package loki
import (
"net"
"reflect"
"testing"
"time"
)
func TestHasAnyFlag(t *testing.T) {
@@ -33,25 +31,16 @@ func TestNormalizeLoadFlags(t *testing.T) {
supports bool
want []string
}{
{"moteur ancien : anciens drapeaux intacts", []string{"--mlock", "--no-mmap"}, false,
{"moteur ancien : rien ne bouge", []string{"--mlock", "--no-mmap"}, false,
[]string{"--mlock", "--no-mmap"}},
// L'ancien --mlock gardait le mmap : « mlock » de llama.cpp le coupe.
{"mlock seul → mmap+mlock", []string{"--mlock", "-fa"}, true,
[]string{"-fa", "--load-mode", "mmap+mlock"}},
{"mlock traduit", []string{"--mlock", "-fa"}, true,
[]string{"-fa", "--load-mode", "mlock"}},
{"no-mmap traduit", []string{"--no-mmap"}, true,
[]string{"--load-mode", "none"}},
{"mmap traduit", []string{"--mmap"}, true,
[]string{"--load-mode", "mmap"}},
{"mlock + no-mmap → mlock", []string{"--no-mmap", "--mlock"}, true,
{"mlock l'emporte sur no-mmap", []string{"--no-mmap", "--mlock"}, true,
[]string{"--load-mode", "mlock"}},
{"moteur ancien : none retraduit", []string{"-fa", "--load-mode", "none"}, false,
[]string{"-fa", "--no-mmap"}},
{"moteur ancien : mlock retraduit", []string{"--load-mode", "mlock"}, false,
[]string{"--mlock", "--no-mmap"}},
{"moteur ancien : mmap+mlock retraduit", []string{"-lm", "mmap+mlock"}, false,
[]string{"--mlock"}},
{"moteur ancien : dio abandonné", []string{"--load-mode=dio", "-fa"}, false,
[]string{"-fa"}},
{"--load-mode explicite gagne, vieux drapeaux retirés",
[]string{"--mlock", "--load-mode", "dio"}, true,
[]string{"--load-mode", "dio"}},
@@ -60,15 +49,10 @@ func TestNormalizeLoadFlags(t *testing.T) {
}
for _, c := range cases {
t.Run(c.name, func(t *testing.T) {
got, note := normalizeLoadFlags(c.in, c.supports)
got := normalizeLoadFlags(c.in, c.supports)
if !reflect.DeepEqual(got, c.want) {
t.Fatalf("got %q, want %q", got, c.want)
}
// Seul « dio » abandonné sur un moteur ancien se signale : les autres
// traductions gardent le sens demandé.
if wantNote := c.name == "moteur ancien : dio abandonné"; (note != "") != wantNote {
t.Fatalf("note = %q, attendue : %v", note, wantNote)
}
})
}
}
@@ -107,32 +91,3 @@ func TestNGLArgs(t *testing.T) {
})
}
}
func TestArgValueEtPortLibre(t *testing.T) {
if got := argValue([]string{"--port", "8080", "-c", "1", "--port", "9000"}, "--port"); got != "9000" {
t.Fatalf("la dernière occurrence doit gagner, got %q", got)
}
ln, err := net.Listen("tcp", "127.0.0.1:0")
if err != nil {
t.Fatal(err)
}
_, port, _ := net.SplitHostPort(ln.Addr().String())
if waitPortFree("0.0.0.0", port, 600*time.Millisecond) == nil {
t.Fatal("un port occupé doit être refusé")
}
ln.Close()
if err := waitPortFree("0.0.0.0", port, time.Second); err != nil {
t.Fatalf("un port libre doit passer : %v", err)
}
}
func TestSlowKV(t *testing.T) {
for _, c := range []struct {
k, v string
lent bool
}{{"", "", false}, {"q8_0", "q8_0", false}, {"q4_0", "q4_0", false}, {"q8_0", "q4_0", true}, {"q5_1", "q5_1", true}} {
if slowKV(c.k, c.v) != c.lent {
t.Errorf("%s/%s : lent attendu %v", c.k, c.v, c.lent)
}
}
}
-628
View File
@@ -1,628 +0,0 @@
package loki
import (
"fmt"
"os"
"regexp"
"strconv"
"strings"
)
// Experts MoE et chargement du modèle : des avis et un garde-fou, jamais de
// réécriture silencieuse du preset.
//
// moeNotes au lancement et dans l'éditeur : experts placés à la
// main (--fit ne les place pas), UBATCH conseillé quand
// --fit laisse des experts en RAM, moteur sans --fit
// loadModeRisk --load-mode none/mlock/dio/mmap+mlock (ou --no-mmap,
// --mlock) avec plus de poids en RAM que la machine n'en
// a : avertissement ; refus seulement si l'échec est
// certain, LOAD_GUARD=off pour passer outre
// autoPlacementPreset COPIE d'un preset MoE placé à la main, en placement
// automatique ; l'original n'est pas touché
//
// Rien de tout cela ne change ce que calcule le modèle : placement des poids
// et découpage des lots seulement, mêmes types de cache, même contexte.
// otPatterns : chaque motif « regex=tampon » des -ot / --override-tensor (et
// de LLAMA_ARG_OVERRIDE_TENSOR) ; llama.cpp en accepte plusieurs par drapeau,
// séparés par des virgules.
func otPatterns(extra []string, argEnv map[string]string) []string {
vals := flagValues(extra, "-ot", "--override-tensor")
if v := argEnv["LLAMA_ARG_OVERRIDE_TENSOR"]; v != "" {
vals = append(vals, v)
}
var out []string
for _, v := range vals {
for _, el := range strings.Split(v, ",") {
if el = strings.TrimSpace(el); el != "" {
out = append(out, el)
}
}
}
return out
}
// otTargetsExperts : le motif vise-t-il les tenseurs d'experts (ffn_*_exps,
// ffn_*_chexps) ? Un motif qui ne vise que la couche d'entrée
// (per_layer_token_embd) n'en est pas.
func otTargetsExperts(pattern string) bool {
return strings.Contains(strings.ToLower(pattern), "exps")
}
// otOnCPU : le tampon du motif est-il la RAM hôte (CPU, CPU_REPACK…) ?
func otOnCPU(el string) bool {
_, buft, ok := strings.Cut(el, "=")
return ok && strings.HasPrefix(strings.ToUpper(strings.TrimSpace(buft)), "CPU")
}
// cpuExperts dit comment des experts MoE sont envoyés en RAM à la main, ou
// vide. Plus étroit que cpuWeights : un -ot ne compte que s'il vise les
// experts, et le cache KV ou un NGL partiel n'en sont pas.
func cpuExperts(extra []string, argEnv map[string]string) string {
for _, el := range otPatterns(extra, argEnv) {
pat, _, _ := strings.Cut(el, "=")
if otTargetsExperts(pat) && otOnCPU(el) {
return "-ot experts=CPU"
}
}
if hasAnyFlag(extra, "--cpu-moe", "-cmoe") || envTruthy(argEnv["LLAMA_ARG_CPU_MOE"]) {
return "--cpu-moe"
}
for _, v := range append(flagValues(extra, "-ncmoe", "--n-cpu-moe"), argEnv["LLAMA_ARG_N_CPU_MOE"]) {
if v = strings.TrimSpace(v); v != "" && v != "0" {
return "--n-cpu-moe " + v
}
}
return ""
}
// fitFlagRe : « --fit » lui-même dans l'aide, pas seulement --fit-target.
var fitFlagRe = regexp.MustCompile(`(^|[\s,])--fit([\s,=\[]|$)`)
// helpSupportsFit : ce moteur sait-il placer les poids tout seul (--fit) ?
func helpSupportsFit(help string) bool { return fitFlagRe.MatchString(help) }
// serveUBatch : le micro-lot que verra le moteur (-ub d'EXTRA_ARGS, sinon
// UBATCH, sinon le défaut de Loki). 0 = illisible.
func serveUBatch(cfg map[string]string, extra []string) int {
s := flagValue(extra, "-ub", "--ubatch-size")
if s == "" {
s = strings.TrimSpace(cfg["UBATCH"])
}
if s == "" {
s = "512"
}
n, err := strconv.Atoi(strings.TrimSpace(s))
if err != nil || n < 0 {
return 0
}
return n
}
// gibText : des Mio en « 12,3 Gio ».
func gibText(mib int64) string {
return strings.Replace(strconv.FormatFloat(float64(mib)/1024, 'f', 1, 64), ".", ",", 1) + " Gio"
}
// moeNotes : ce qu'il faut savoir du placement d'un MoE, pour ce lancement.
// Fonction pure, comme nglArgs : des notes seulement, aucun drapeau.
//
// - experts placés à la main (-ot visant les experts, --n-cpu-moe,
// --cpu-moe) : --fit ne les place pas. Monter UBATCH seul y serait un
// piège : le tampon de calcul grossit sur CHAQUE carte, sans personne pour
// rééquilibrer, et le moteur finit en « cudaMalloc failed » ;
// - placement auto, modèle plus gros que la VRAM : --fit laisse des experts
// en RAM, recopiés vers le GPU à chaque micro-lot du prefill — un UBATCH de
// 2048 ou plus en paie quatre fois moins. --fit, lui, retient de la place
// pour ce tampon plus gros ;
// - moteur sans --fit (ancien, fork) : rien ne sort les experts du GPU,
// garder --n-cpu-moe ;
// - --fit coupé par un autre -ot : même chose.
//
// Muette sur un modèle dense, un preset externe, une VRAM inconnue (macOS,
// --device, AMD) ou une aide du moteur illisible.
func moeNotes(cfg map[string]string, extra []string, si serveSysInfo) []string {
if isExternalConfig(cfg) || si.GGUF == nil || si.GGUF.ExpertCount <= 0 {
return nil
}
ub := serveUBatch(cfg, extra)
if r := cpuExperts(extra, si.ArgEnv); r != "" {
n := "experts placés à la main (" + r + ") : --fit ne les place pas"
if ub > 0 && ub <= 512 {
n += " ; UBATCH plus grand = tampon de calcul plus gros sur chaque GPU : monte --n-cpu-moe d'abord, " +
"ou passe au placement auto (--fit, « Dupliquer en placement auto » dans l'éditeur)"
}
return []string{n}
}
modelMiB := si.ModelBytes >> 20
if modelMiB <= 0 || si.VRAMMiB <= 0 || si.UnifiedMem || strings.TrimSpace(si.Help) == "" {
return nil
}
cards := int64(si.GPUs)
if cards < 1 {
cards = 1
}
if modelMiB <= si.VRAMMiB-fitTargetMin*cards {
return nil // tout tient sur GPU : pas d'expert recopié
}
sizes := "modèle " + gibText(modelMiB) + ", VRAM " + gibText(si.VRAMMiB)
if !helpSupportsFit(si.Help) {
// Un NGL chiffré sous 999 laisse déjà des couches (et leurs experts) au
// CPU : l'utilisateur a tranché.
if n, err := strconv.Atoi(strings.TrimSpace(firstNonEmpty(flagValue(extra, "-ngl", "--n-gpu-layers", "--gpu-layers"), cfg["NGL"]))); err == nil && n < 999 {
return nil
}
return []string{"MoE plus gros que la VRAM (" + sizes + ") : ce moteur ne place pas les experts (pas de --fit) — " +
"garde --n-cpu-moe"}
}
if why := fitBlocker(cfg, extra, si); why != "" {
if why == "surcharge de tenseurs" {
return []string{"MoE plus gros que la VRAM (" + sizes + ") et --fit coupé par un -ot qui ne vise pas les " +
"experts : rien ne les sort du GPU — --n-cpu-moe, ou retire ce -ot"}
}
return nil
}
if ub > 0 && ub <= 512 {
return []string{"UBATCH 2048+ recommandé : --fit laisse des experts en RAM (" + sizes + "), recopiés vers le " +
"GPU à chaque micro-lot du prefill ; BATCH 4096 avec — à mesurer (bench complet)"}
}
return nil
}
// firstNonEmpty : la première valeur non vide.
func firstNonEmpty(vals ...string) string {
for _, v := range vals {
if strings.TrimSpace(v) != "" {
return v
}
}
return ""
}
// loadArgEnv : les variables qui règlent le chargement sans drapeau.
// LLAMA_ARG_RPC n'en est pas une, mais dit que des poids partent sur des
// machines distantes (loadModeRisk).
var loadArgEnv = []string{"LLAMA_ARG_LOAD_MODE", "LLAMA_ARG_MLOCK", "LLAMA_ARG_NO_MMAP", "LLAMA_ARG_RPC"}
// probeLoadEnv relève ces variables. Appelée après probeServeGPUs, qui
// remet ArgEnv à zéro.
func probeLoadEnv(si *serveSysInfo) {
if si.ArgEnv == nil {
si.ArgEnv = map[string]string{}
}
for _, k := range loadArgEnv {
if v := strings.TrimSpace(os.Getenv(k)); v != "" {
si.ArgEnv[k] = v
}
}
}
// residentLoadMode : le mode de chargement qui garde ENTIÈREMENT en RAM les
// poids laissés au CPU, tel qu'écrit (« --load-mode none », « --no-mmap »…),
// ou vide. mmap (et auto) laisse le système relire les pages au besoin : lent
// quand la RAM manque, jamais fatal. none et dio copient les poids en mémoire
// anonyme, mlock et mmap+mlock les verrouillent. Lu sur la ligne finale
// (normalizeLoadFlags a déjà traduit l'une ou l'autre forme), puis dans les
// LLAMA_ARG_* si elle se tait.
func residentLoadMode(args []string, argEnv map[string]string) string {
mode := strings.ToLower(strings.TrimSpace(flagValue(args, "--load-mode", "-lm")))
mlock, nommap := hasAnyFlag(args, "--mlock"), hasAnyFlag(args, "--no-mmap")
if mode == "" && !mlock && !nommap {
mode = strings.ToLower(strings.TrimSpace(argEnv["LLAMA_ARG_LOAD_MODE"]))
if mode == "" {
mlock, nommap = envTruthy(argEnv["LLAMA_ARG_MLOCK"]), envTruthy(argEnv["LLAMA_ARG_NO_MMAP"])
}
}
switch mode {
case "none", "mlock", "dio", "mmap+mlock":
return "--load-mode " + mode
}
switch {
case mlock && nommap:
return "--mlock --no-mmap"
case mlock:
return "--mlock"
case nommap:
return "--no-mmap"
}
return ""
}
// loadGuardMarker : la fin du message de refus, que l'interface reconnaît
// dans le journal du service (modelLoadError).
const loadGuardMarker = "LOAD_GUARD=off pour lancer quand même"
// loadGuardOff : LOAD_GUARD=off, la clé d'échappement du refus.
func loadGuardOff(cfg map[string]string) bool {
switch strings.ToLower(strings.TrimSpace(cfg["LOAD_GUARD"])) {
case "off", "0", "false", "non", "no":
return true
}
return false
}
// loadGuardExitStatus : le code de sortie de « loki serve » quand LOAD_GUARD
// refuse. Le refus est certain et ne dépend que du preset et de la machine :
// relancer ne changerait rien. Sous systemd, Restart=on-failure relançait
// pourtant toutes les trois secondes, sans fin ; l'unité écrite par
// « loki install » porte donc RestartPreventExitStatus pour ce code-là
// (EX_CONFIG de sysexits.h : erreur de configuration). Le conteneur, lui, ne
// relance jamais le moteur de lui-même.
const loadGuardExitStatus = 78
// unitPreventsRefusalRestart : l'unité systemd (son texte) s'abstient-elle de
// relancer sur loadGuardExitStatus ? Une unité écrite par une version
// d'avant ne le fait pas.
func unitPreventsRefusalRestart(unit string) bool {
for _, line := range strings.Split(unit, "\n") {
k, v, ok := strings.Cut(strings.TrimSpace(line), "=")
if !ok || strings.TrimSpace(k) != "RestartPreventExitStatus" {
continue
}
for _, f := range strings.Fields(v) {
if f == strconv.Itoa(loadGuardExitStatus) {
return true
}
}
}
return false
}
// loadGuardRefusal : l'erreur rendue par cmdServe pour un refus, avec son code
// de sortie. Un superviseur qui relancerait quand même sur ce code (unité
// systemd d'une version d'avant, launchd, qui relance toute sortie non nulle)
// reçoit 0 : une sortie « propre » n'est pas relancée, et la raison reste en
// clair dans le journal, où l'interface la reconnaît (modelLoadError). Le
// moteur d'essai de l'optimiseur garde le vrai code : c'est lui qui dit
// l'échec à « loki tune ».
func loadGuardRefusal(refuse string, trial bool) error {
code := loadGuardExitStatus
if !trial {
if restarts, hint := supervisorRestartsRefusal(); restarts {
code = 0
if hint != "" {
fmt.Fprintln(os.Stderr, "[loki serve] "+hint)
}
}
}
return &exitStatusError{code: code, err: fmt.Errorf("%s", refuse)}
}
// loadModeRisk : un mode de chargement résident avec trop de poids en RAM.
// Fonction pure ; args est la ligne finale (ou EXTRA_ARGS normalisé).
//
// llama.cpp #26110 : un modèle de 65 Go sur 60 Go de RAM, --load-mode none →
// swap, décodage de 25 à 7,5 t/s ; mlock → processus tué faute de mémoire.
// Seuls comptent les poids qui RESTENT en RAM : ceux qui partent en VRAM n'y
// séjournent pas. On en connaît une borne basse — modèle moins VRAM totale
// (tout le modèle sur macOS, mémoire unifiée) — et une estimation haute quand
// la VRAM est inconnue (le modèle entier).
//
// - refuse : la borne basse dépasse 90 % de la RAM effective (limite du
// conteneur comprise) — l'échec est certain. LOAD_GUARD=off lève le refus ;
// - warn : l'estimation dépasse 80 % — on prévient, on lance.
//
// Un refus fait sortir « loki serve » en erreur, comme un modèle introuvable :
// mieux vaut la raison en clair qu'un moteur tué en boucle par le noyau. Le code
// de sortie (loadGuardRefusal) empêche le superviseur de relancer en boucle.
func loadModeRisk(cfg map[string]string, args []string, si serveSysInfo) (warn, refuse string) {
if isExternalConfig(cfg) {
return "", ""
}
// Un moteur qui connaît --load-mode n'a plus LLAMA_ARG_MLOCK ni
// LLAMA_ARG_NO_MMAP : restées dans l'environnement, elles n'ont aucun effet
// et ne doivent pas faire refuser un lancement en mmap.
env := si.ArgEnv
if helpSupportsLoadMode(si.Help) && (env["LLAMA_ARG_MLOCK"] != "" || env["LLAMA_ARG_NO_MMAP"] != "") {
env = map[string]string{"LLAMA_ARG_LOAD_MODE": si.ArgEnv["LLAMA_ARG_LOAD_MODE"]}
}
mode := residentLoadMode(args, env)
modelMiB := si.ModelBytes >> 20
if mode == "" || modelMiB <= 0 || si.RAMMiB <= 0 {
return "", ""
}
// Serveurs RPC : une part des poids part sur d'autres machines, la VRAM
// locale ne borne plus rien — inconnu, donc un avis au plus, jamais un refus.
remote := hasAnyFlag(args, "--rpc") || strings.TrimSpace(si.ArgEnv["LLAMA_ARG_RPC"]) != ""
low, est, upTo, unsure := int64(0), modelMiB, true, false
switch {
case remote:
case si.UnifiedMem:
low, est, upTo = modelMiB, modelMiB, false
case si.EngineVRAMMiB > 0:
// Les cartes que le moteur liste lui-même : rien au-delà ne peut
// recevoir de poids, la borne basse est sûre.
low = max(modelMiB-si.EngineVRAMMiB, 0)
est, upTo = low, false
case si.VRAMMiB > 0:
// nvidia-smi seulement : un moteur Vulkan sur cartes mixtes NVIDIA +
// AMD en utilise davantage, un moteur ROCm d'autres. Une estimation,
// jamais une borne : un avis au plus (voir probeLoadDevices).
est, upTo, unsure = max(modelMiB-si.VRAMMiB, 0), false, true
}
ram := si.RAMMiB
what := "environ "
if upTo {
what = "jusqu'à "
}
if unsure && est*10 > ram*9 && !loadGuardOff(cfg) {
return fmt.Sprintf("%s : environ %s de poids en RAM (modèle %s, VRAM NVIDIA %s) pour %s — échec probable ; "+
"refus seulement si les cartes listées par le moteur le confirment, lancement sinon ; "+
"--load-mode mmap est plus sûr", mode, gibText(est), gibText(modelMiB), gibText(si.VRAMMiB), gibText(ram)), ""
}
if low*10 > ram*9 {
msg := fmt.Sprintf("%s : au moins %s de poids restent en RAM (modèle %s) pour %s de RAM, limite du conteneur "+
"comprise — échec certain : swap et décodage effondré, ou processus tué (llama.cpp #26110). "+
"--load-mode mmap laisse le système relire les pages au besoin", mode, gibText(low), gibText(modelMiB), gibText(ram))
if loadGuardOff(cfg) {
return msg + " ; LOAD_GUARD=off : lancé quand même", ""
}
return "", msg + " ; " + loadGuardMarker
}
if est*10 > ram*8 {
return fmt.Sprintf("%s : %s%s de poids en RAM pour %s — au-delà, swap (décodage de 25 à 7,5 t/s dans "+
"llama.cpp #26110) ou processus tué ; --load-mode mmap est plus sûr", mode, what, gibText(est), gibText(ram)), ""
}
return "", ""
}
// loadGuardNeedsDevices : un refus se profile sur la seule VRAM NVIDIA
// (nvidia-smi) ? Il ne sera prononcé que sur les cartes du moteur lui-même —
// à lire d'abord (probeLoadDevices). Fonction pure.
func loadGuardNeedsDevices(cfg map[string]string, args []string, si serveSysInfo) bool {
if si.EngineVRAMMiB > 0 || si.VRAMMiB <= 0 {
return false
}
si.EngineVRAMMiB = si.VRAMMiB
_, refuse := loadModeRisk(cfg, args, si)
return refuse != ""
}
// engineDevsVRAM : la VRAM des cartes listées par le moteur, toutes
// additionnées — sans --device, llama.cpp ne place rien ailleurs, et une carte
// qu'il listerait sans s'en servir (iGPU) ne fait que rendre le refus plus
// rare. 0 (inconnu) si la liste est vide ou si une carte annonce 0 Mio (déjà
// pleine : lecture transitoire, voir handleBackendDevices).
func engineDevsVRAM(devs []splitDev) int64 {
var total int64
for _, d := range devs {
if d.TotalMiB <= 0 {
return 0
}
total += d.TotalMiB
}
return total
}
// probeLoadDevices : seulement quand loadGuardNeedsDevices — un mode de
// chargement résident qu'on s'apprête à refuser. Les cartes de SPLIT_MODE si
// elles sont déjà lues, sinon --list-devices dans l'environnement du
// lancement (sélection GPU comprise). Échec de lecture : EngineVRAMMiB reste
// à 0, loadModeRisk avertit sans refuser. args : EXTRA_ARGS normalisé.
func probeLoadDevices(cfg map[string]string, args []string, bin string, si *serveSysInfo) {
if !loadGuardNeedsDevices(cfg, args, *si) {
return
}
devs := si.SplitDevs
if len(devs) == 0 {
devs = listEngineDevices(bin)
}
si.EngineVRAMMiB = engineDevsVRAM(devs)
}
// previewServeHelp : l'aide supposée d'un moteur récent, pour les aperçus de
// l'éditeur (le process web ne lance pas le moteur pour lire son aide). Le
// lancement, lui, décide sur la vraie aide.
const previewServeHelp = "--load-mode --fit [on|off] --fit-target"
// moePreview : les avis de moeNotes et loadModeRisk pour l'éditeur, calculés
// comme au lancement, sur un moteur supposé récent.
func moePreview(content string) (notes []string, load string) {
cfg := parseEnv(content)
if isExternalConfig(cfg) {
return nil, ""
}
si := serveSysInfo{Help: previewServeHelp, ArgEnv: map[string]string{}}
for _, k := range append(append(append([]string{}, serveArgEnv...), fitArgEnv...), loadArgEnv...) {
if v, ok := os.LookupEnv(k); ok && strings.TrimSpace(v) != "" {
si.ArgEnv[k] = v
}
}
if m := strings.TrimSpace(cfg["MODEL"]); m != "" {
if p, err := resolveServeModelPath(m); err == nil {
si.Model = p
}
}
if si.Model == "" {
return nil, ""
}
extra := splitArgs(cfg["EXTRA_ARGS"])
probeCacheRAM(cfg, extra, &si)
norm, _ := normalizeLoadFlags(extra, true)
// Les cartes du moteur, sans le lancer : la dernière liste complète qu'il a
// donnée à l'éditeur (devices.json). Absente : un avis, pas un refus.
if loadGuardNeedsDevices(cfg, norm, si) {
bin := prebuiltResolveBin(firstNonEmpty(cfg["BIN"], ReadConfig()["BIN"]))
if list, ok := devPersistGet(bin + "\x00" + cfg["CUDA_VISIBLE_DEVICES"]); ok {
si.EngineVRAMMiB = engineDevsVRAM(splitDevsFrom(list))
}
}
warn, refuse := loadModeRisk(cfg, norm, si)
if refuse != "" {
warn = "refusé au lancement — " + refuse
}
return moeNotes(cfg, extra, si), warn
}
// autoPlacementPreset prépare la COPIE d'un preset MoE placé à la main, en
// placement automatique. Fonction pure : elle rend le nouveau contenu et la
// liste de ce qui change ; l'appelant l'enregistre sous un nouveau nom, et le
// preset d'origine reste tel quel — le repli si --fit échoue.
//
// Dans la copie :
//
// - retirés d'EXTRA_ARGS : les -ot qui visent les experts, un -ot qui
// n'envoie au CPU que per_layer_token_embd (couche d'entrée : llama.cpp la
// laisse toujours au CPU, le motif est redondant mais coupe --fit),
// --n-cpu-moe, --cpu-moe, -ngl, --tensor-split, --fit off, -b/-ub et les
// drapeaux de chargement ;
// - NGL retiré (Loki écrit -ngl auto sur un moteur qui sait placer),
// UBATCH=2048, BATCH=4096, « --load-mode mmap » (rien sur un moteur
// ancien, qui charge déjà ainsi) ;
// - CTX, cache KV, échantillonnage, raisonnement : intouchés.
//
// Refus (err) quand la copie ne tournerait pas en --fit — un autre -ot, -sm
// row/tensor, SPLIT_MODE=tensor, --n-cpu-ffn — ou quand CTX vaut 0 (--fit
// pourrait réduire le contexte), et quand il n'y a rien à migrer. Jamais de
// --tensor-read-lazy ni --lazy-mode : leur nom change d'un moteur à l'autre.
func autoPlacementPreset(content string) (out string, changes []string, err error) {
cfg := parseEnv(content)
if isExternalConfig(cfg) {
return "", nil, fmt.Errorf("preset externe : le modèle tourne ailleurs")
}
extra := splitArgs(cfg["EXTRA_ARGS"])
var kept []string
migrated := false
drop := func(s string) { changes = append(changes, "retiré : "+s) }
for i := 0; i < len(extra); i++ {
a := extra[i]
name, val, hasEq := strings.Cut(a, "=")
value := func() string {
if hasEq {
return val
}
if i+1 < len(extra) {
i++
return extra[i]
}
return ""
}
switch name {
case "-ot", "--override-tensor":
v := value()
var rest []string
for _, el := range strings.Split(v, ",") {
el = strings.TrimSpace(el)
pat, _, _ := strings.Cut(el, "=")
switch {
case el == "":
case otTargetsExperts(pat):
drop(name + " " + el + " (experts : --fit les place)")
migrated = true
case strings.Contains(strings.ToLower(pat), "per_layer_token_embd") && otOnCPU(el):
drop(name + " " + el + " (couche d'entrée, toujours au CPU)")
default:
rest = append(rest, el)
}
}
if len(rest) > 0 {
kept = append(kept, name, strings.Join(rest, ","))
}
case "-ncmoe", "--n-cpu-moe":
v := value()
drop(name + " " + v)
if strings.TrimSpace(v) != "0" {
migrated = true
}
case "-cmoe", "--cpu-moe":
drop(name)
migrated = true
case "-ngl", "--n-gpu-layers", "--gpu-layers", "-ts", "--tensor-split",
"-b", "--batch-size", "-ub", "--ubatch-size", "--load-mode", "-lm":
drop(name + " " + value())
case "--mlock", "--no-mmap", "--mmap":
drop(name)
case "-fit", "--fit":
v := value()
switch strings.ToLower(strings.TrimSpace(v)) {
case "off", "disabled", "false", "0":
drop(name + " " + v)
default:
kept = append(kept, name, v)
}
default:
kept = append(kept, a)
}
}
if !migrated {
return "", nil, fmt.Errorf("rien à migrer : ce preset ne place pas d'experts à la main (-ot …exps…, --n-cpu-moe, --cpu-moe)")
}
kept = append(kept, "--load-mode", "mmap")
nc := map[string]string{}
for k, v := range cfg {
nc[k] = v
}
nc["EXTRA_ARGS"] = joinArgs(kept)
nc["UBATCH"], nc["BATCH"] = "2048", "4096"
delete(nc, "NGL")
if ctx, ok := ctxFixed(nc, kept); !ok {
return "", nil, fmt.Errorf("contexte « %s » non chiffré : --fit pourrait le réduire pour tenir — donne un CTX chiffré d'abord", ctx)
}
if splitModeKey(nc) == "tensor" {
return "", nil, fmt.Errorf("SPLIT_MODE=tensor coupe --fit : retire-le d'abord")
}
if why := fitBlocker(nc, kept, serveSysInfo{Help: previewServeHelp, ArgEnv: map[string]string{}}); why != "" {
return "", nil, fmt.Errorf("--fit resterait inactif (%s) : retire-le à la main d'abord", why)
}
if ngl := strings.TrimSpace(cfg["NGL"]); ngl != "" {
changes = append(changes, "NGL="+ngl+" retiré : le moteur place les couches (-ngl auto)")
}
changes = append(changes, "UBATCH=2048, BATCH=4096 (avant : "+firstNonEmpty(cfg["UBATCH"], "512")+" / "+
firstNonEmpty(cfg["BATCH"], "2048")+")", "--load-mode mmap")
ctx, _ := ctxFixed(nc, kept)
changes = append(changes, "CTX conservé : "+ctx)
if k, v := serveKVTypes(nc); k != "" || v != "" ||
flagValue(kept, "-ctk", "--cache-type-k") != "" || flagValue(kept, "-ctv", "--cache-type-v") != "" {
changes = append(changes, "cache KV conservé tel quel — garde-le identique pour comparer les deux presets")
}
out = content
out = presetSetKey(out, "EXTRA_ARGS", nc["EXTRA_ARGS"])
out = presetSetKey(out, "UBATCH", "2048")
out = presetSetKey(out, "BATCH", "4096")
out = presetSetKey(out, "NGL", "")
return out, changes, nil
}
// joinArgs : l'inverse de splitArgs — un argument qui contient un blanc est
// entouré d'apostrophes (de guillemets s'il en contient une), pour être relu
// d'un seul tenant.
func joinArgs(args []string) string {
parts := make([]string, len(args))
for i, a := range args {
switch {
case a == "":
parts[i] = `""`
case strings.ContainsAny(a, " \t"):
if strings.Contains(a, "'") {
parts[i] = `"` + a + `"`
} else {
parts[i] = "'" + a + "'"
}
default:
parts[i] = a
}
}
return strings.Join(parts, " ")
}
// presetSetKey remplace les lignes KEY=… d'un preset (toutes : parseEnv
// retient la dernière, une ancienne restée plus bas l'emporterait), l'ajoute à
// la fin si elle manque, les retire si val est vide. Le reste du fichier —
// commentaires, ordre, NAME — ne bouge pas : c'est l'équivalent de cfgWriteKey
// côté éditeur.
func presetSetKey(content, key, val string) string {
re := regexp.MustCompile(`(?m)^[ \t]*(?:export[ \t]+)?` + regexp.QuoteMeta(key) + `[ \t]*=.*$`)
if val == "" {
return regexp.MustCompile(`(?m)^[ \t]*(?:export[ \t]+)?`+regexp.QuoteMeta(key)+`[ \t]*=.*(?:\n|$)`).
ReplaceAllLiteralString(content, "")
}
line := key + "=" + quoteValue(val)
if re.MatchString(content) {
return re.ReplaceAllLiteralString(content, line)
}
if content != "" && !strings.HasSuffix(content, "\n") {
content += "\n"
}
return content + line + "\n"
}
-389
View File
@@ -1,389 +0,0 @@
package loki
import (
"errors"
"fmt"
"reflect"
"strings"
"testing"
)
const gib = int64(1) << 30
// Un -ot ne compte comme experts en RAM que s'il vise les experts ET la RAM ;
// celui de la couche d'entrée (per_layer_token_embd) n'en est pas.
func TestCpuExperts(t *testing.T) {
cases := []struct {
extra string
env map[string]string
want bool
}{
{`-ot per_layer_token_embd.weight=CPU`, nil, false},
{`-ot "blk\..*\.ffn_.*_exps\.=CPU"`, nil, true},
{`--override-tensor=per_layer_token_embd.weight=CPU,\.ffn_(up|down)_exps=CPU`, nil, true},
{`-ot \.ffn_.*_exps=CUDA1`, nil, false},
{`-cmoe`, nil, true},
{`--n-cpu-moe 0`, nil, false},
{`--n-cpu-moe 40`, nil, true},
{`-ncmoe=12`, nil, true},
{``, map[string]string{"LLAMA_ARG_N_CPU_MOE": "20"}, true},
{``, map[string]string{"LLAMA_ARG_CPU_MOE": "1"}, true},
{`-nkvo -ngl 20`, nil, false},
}
for _, c := range cases {
if got := cpuExperts(splitArgs(c.extra), c.env) != ""; got != c.want {
t.Errorf("cpuExperts(%q, %v) = %v, want %v", c.extra, c.env, got, c.want)
}
}
}
// moeNotes : les avis, au bon endroit et seulement là.
func TestMoeNotes(t *testing.T) {
moe := &GGUFInfo{ExpertCount: 128, BlockCount: 48}
big := serveSysInfo{Help: helpFit, GGUF: moe, ModelBytes: 82 * gib, VRAMMiB: 28 << 10, GPUs: 2}
cases := []struct {
name string
cfg map[string]string
si serveSysInfo
want string // sous-chaîne attendue ; vide = aucune note
}{
{name: "modèle dense : rien",
cfg: map[string]string{"EXTRA_ARGS": "--n-cpu-moe 40"},
si: serveSysInfo{Help: helpFit, GGUF: &GGUFInfo{BlockCount: 64}, ModelBytes: 82 * gib, VRAMMiB: 28 << 10}},
{name: "placement manuel, UBATCH 512 : ne pas monter UBATCH seul",
cfg: map[string]string{"EXTRA_ARGS": "--n-cpu-moe 40"}, si: big, want: "monte --n-cpu-moe d'abord"},
{name: "placement manuel, UBATCH 2048 : --fit ne les place pas, sans conseil de lot",
cfg: map[string]string{"EXTRA_ARGS": "--n-cpu-moe 40", "UBATCH": "2048"}, si: big, want: "--fit ne les place pas"},
{name: "placement auto, MoE plus gros que la VRAM, UBATCH 512 : UBATCH 2048+",
cfg: map[string]string{}, si: big, want: "UBATCH 2048+ recommandé"},
{name: "placement auto, UBATCH 4096 : rien",
cfg: map[string]string{"UBATCH": "4096", "BATCH": "4096"}, si: big},
{name: "-ub dans EXTRA_ARGS l'emporte sur UBATCH",
cfg: map[string]string{"UBATCH": "512", "EXTRA_ARGS": "-ub 2048"}, si: big},
{name: "le modèle tient en VRAM : rien",
cfg: map[string]string{},
si: serveSysInfo{Help: helpFit, GGUF: moe, ModelBytes: 20 * gib, VRAMMiB: 28 << 10, GPUs: 2}},
{name: "moteur sans --fit : garder --n-cpu-moe",
cfg: map[string]string{}, si: serveSysInfo{Help: helpOld, GGUF: moe, ModelBytes: 82 * gib, VRAMMiB: 28 << 10},
want: "garde --n-cpu-moe"},
{name: "moteur sans --fit, NGL partiel : l'utilisateur a tranché",
cfg: map[string]string{"NGL": "20"}, si: serveSysInfo{Help: helpOld, GGUF: moe, ModelBytes: 82 * gib, VRAMMiB: 28 << 10}},
{name: "aide illisible : rien",
cfg: map[string]string{}, si: serveSysInfo{GGUF: moe, ModelBytes: 82 * gib, VRAMMiB: 28 << 10}},
{name: "-ot de la seule couche d'entrée : --fit coupé, experts tous sur GPU",
cfg: map[string]string{"EXTRA_ARGS": "-ot per_layer_token_embd.weight=CPU"}, si: big,
want: "-ot qui ne vise pas les experts"},
{name: "VRAM inconnue : rien",
cfg: map[string]string{}, si: serveSysInfo{Help: helpFit, GGUF: moe, ModelBytes: 82 * gib}},
{name: "macOS, mémoire unifiée : rien",
cfg: map[string]string{}, si: serveSysInfo{Help: helpFit, GGUF: moe, ModelBytes: 82 * gib, VRAMMiB: 28 << 10, UnifiedMem: true}},
{name: "preset externe : rien",
cfg: map[string]string{extKeyFlag: "1", extKeyURL: "https://api.example.com/v1", "EXTRA_ARGS": "--n-cpu-moe 40"}, si: big},
}
for _, c := range cases {
notes := moeNotes(c.cfg, splitArgs(c.cfg["EXTRA_ARGS"]), c.si)
switch {
case c.want == "" && len(notes) != 0:
t.Errorf("%s : notes inattendues %q", c.name, notes)
case c.want != "" && (len(notes) != 1 || !strings.Contains(notes[0], c.want)):
t.Errorf("%s : notes %q, attendu %q", c.name, notes, c.want)
}
}
// Placement manuel en UBATCH 2048 : pas de conseil de lot.
n := moeNotes(map[string]string{"UBATCH": "2048"}, splitArgs("--n-cpu-moe 40"), big)
if len(n) != 1 || strings.Contains(n[0], "UBATCH") {
t.Errorf("conseil de lot en trop : %q", n)
}
}
// loadModeRisk : un avertissement d'ordinaire, un refus seulement quand la
// borne basse des poids en RAM dépasse 90 % de la RAM effective.
func TestLoadModeRisk(t *testing.T) {
cases := []struct {
name string
cfg map[string]string
args string
si serveSysInfo
warn, refuse bool
}{
{name: "mmap : rien",
args: "--load-mode mmap", si: serveSysInfo{ModelBytes: 82 * gib, VRAMMiB: 16 << 10, RAMMiB: 60 << 10}},
{name: "sans drapeau : rien",
si: serveSysInfo{ModelBytes: 82 * gib, VRAMMiB: 16 << 10, RAMMiB: 60 << 10}},
{name: "none, 66 Gio certains en RAM pour 60 : refus (#26110)",
args: "--load-mode none", si: serveSysInfo{ModelBytes: 82 * gib, EngineVRAMMiB: 16 << 10, RAMMiB: 60 << 10}, refuse: true},
{name: "même chose, LOAD_GUARD=off : avertissement seulement",
cfg: map[string]string{"LOAD_GUARD": "off"}, args: "--load-mode none",
si: serveSysInfo{ModelBytes: 82 * gib, EngineVRAMMiB: 16 << 10, RAMMiB: 60 << 10}, warn: true},
{name: "mlock, 54 Gio en RAM pour 64 : avertissement",
args: "--load-mode mlock", si: serveSysInfo{ModelBytes: 82 * gib, VRAMMiB: 28 << 10, RAMMiB: 64 << 10}, warn: true},
{name: "dense tout en VRAM sur une machine à peu de RAM : rien (pas de faux positif)",
args: "--load-mode none", si: serveSysInfo{ModelBytes: 16 * gib, VRAMMiB: 24 << 10, RAMMiB: 8 << 10}},
{name: "VRAM inconnue : avertissement « jusqu'à », jamais de refus",
args: "--load-mode dio", si: serveSysInfo{ModelBytes: 82 * gib, RAMMiB: 60 << 10}, warn: true},
{name: "macOS, mémoire unifiée : tout le modèle compte",
args: "--load-mode mmap+mlock", si: serveSysInfo{ModelBytes: 60 * gib, RAMMiB: 64 << 10, UnifiedMem: true}, refuse: true},
{name: "moteur ancien : --no-mmap",
args: "--no-mmap", si: serveSysInfo{ModelBytes: 82 * gib, EngineVRAMMiB: 16 << 10, RAMMiB: 60 << 10}, refuse: true},
{name: "moteur ancien : --mlock --no-mmap",
args: "--mlock --no-mmap", si: serveSysInfo{ModelBytes: 82 * gib, VRAMMiB: 28 << 10, RAMMiB: 64 << 10}, warn: true},
{name: "LLAMA_ARG_NO_MMAP dans l'environnement",
si: serveSysInfo{ModelBytes: 82 * gib, EngineVRAMMiB: 16 << 10, RAMMiB: 60 << 10,
ArgEnv: map[string]string{"LLAMA_ARG_NO_MMAP": "1"}}, refuse: true},
// Corrections de relecture du lot 2 : pas de refus sur une borne fausse.
{name: "LLAMA_ARG_NO_MMAP sans effet sur un moteur à --load-mode : rien",
si: serveSysInfo{Help: "--load-mode", ModelBytes: 82 * gib, VRAMMiB: 16 << 10, RAMMiB: 60 << 10,
ArgEnv: map[string]string{"LLAMA_ARG_NO_MMAP": "1"}}},
{name: "--rpc : une part des poids part ailleurs, avis seulement",
args: "--load-mode none --rpc 10.0.0.2:50052", si: serveSysInfo{ModelBytes: 82 * gib, VRAMMiB: 16 << 10, RAMMiB: 60 << 10}, warn: true},
{name: "LLAMA_ARG_RPC : de même",
args: "--load-mode none", si: serveSysInfo{ModelBytes: 82 * gib, VRAMMiB: 16 << 10, RAMMiB: 60 << 10,
ArgEnv: map[string]string{"LLAMA_ARG_RPC": "10.0.0.2:50052"}}, warn: true},
{name: "limite du conteneur (RAM effective) : refus",
args: "--load-mode none", si: serveSysInfo{ModelBytes: 40 * gib, EngineVRAMMiB: 16 << 10, RAMMiB: 16 << 10}, refuse: true},
// Lot 3 : la VRAM comptée est celle des cartes du moteur ; nvidia-smi
// seul ne fait jamais refuser.
{name: "VRAM NVIDIA seule, cartes du moteur non lues : avertissement, jamais de refus",
args: "--load-mode none", si: serveSysInfo{ModelBytes: 82 * gib, VRAMMiB: 16 << 10, RAMMiB: 60 << 10}, warn: true},
{name: "VRAM NVIDIA seule, LOAD_GUARD=off : l'avis ordinaire",
cfg: map[string]string{"LOAD_GUARD": "off"}, args: "--load-mode none",
si: serveSysInfo{ModelBytes: 82 * gib, VRAMMiB: 16 << 10, RAMMiB: 60 << 10}, warn: true},
{name: "Vulkan, NVIDIA 12 Gio + AMD 16 Gio listées par le moteur : rien (faux refus sur la seule VRAM NVIDIA)",
args: "--load-mode none", si: serveSysInfo{ModelBytes: 70 * gib, VRAMMiB: 12 << 10, EngineVRAMMiB: 28 << 10, RAMMiB: 60 << 10}},
{name: "taille inconnue : rien",
args: "--load-mode none", si: serveSysInfo{VRAMMiB: 16 << 10, RAMMiB: 16 << 10}},
}
for _, c := range cases {
cfg := c.cfg
if cfg == nil {
cfg = map[string]string{}
}
warn, refuse := loadModeRisk(cfg, splitArgs(c.args), c.si)
if (warn != "") != c.warn || (refuse != "") != c.refuse {
t.Errorf("%s : warn=%q refuse=%q", c.name, warn, refuse)
}
if refuse != "" && !strings.Contains(refuse, "LOAD_GUARD=off") {
t.Errorf("%s : refus sans clé d'échappement : %q", c.name, refuse)
}
if loadGuardOff(cfg) && strings.Contains(warn, "refus") {
t.Errorf("%s : refus évoqué avec LOAD_GUARD=off : %q", c.name, warn)
}
}
}
// Sans ces clés, la ligne et l'environnement ne bougent pas quand le modèle
// est un MoE : seules des notes s'ajoutent.
func TestBuildServeArgsMoeNotesOnly(t *testing.T) {
const bin, model = "/opt/llama/llama-server", "/models/Qwen-MoE-Q4.gguf"
cfg := map[string]string{"CTX": "65536", "EXTRA_ARGS": "--n-cpu-moe 40 --load-mode none"}
extra := splitArgs(cfg["EXTRA_ARGS"])
plain := serveSysInfo{Help: helpFit, Model: model}
moe := plain
moe.GGUF, moe.ModelBytes, moe.VRAMMiB, moe.RAMMiB = &GGUFInfo{ExpertCount: 128}, 82*gib, 28<<10, 64<<10
a1, e1, n1 := buildServeArgs(cfg, extra, bin, plain)
a2, e2, n2 := buildServeArgs(cfg, extra, bin, moe)
if !reflect.DeepEqual(a1, a2) || !reflect.DeepEqual(e1, e2) {
t.Fatalf("la ligne a bougé :\n%q\n%q\nenv %v / %v", a1, a2, e1, e2)
}
if len(n2) != len(n1)+2 {
t.Fatalf("notes attendues (placement manuel + chargement) : %q", n2)
}
}
// La copie en placement auto du preset MoE de l'utilisateur.
func TestAutoPlacementPreset(t *testing.T) {
src := "# NAME=QWEN 3.6 MOE\n# experts à la main\nMODEL=Qwen-MoE-Q4.gguf\nCTX=65536\nNGL=999\nUBATCH=512\n" +
`EXTRA_ARGS="-ot per_layer_token_embd.weight=CPU --n-cpu-moe 40 --cache-type-k q8_0 --cache-type-v q8_0 --jinja --mlock --chat-template-file '/mes modèles/tpl.jinja'"` + "\n"
out, changes, err := autoPlacementPreset(src)
if err != nil {
t.Fatal(err)
}
cfg := parseEnv(out)
wantExtra := []string{"--cache-type-k", "q8_0", "--cache-type-v", "q8_0", "--jinja",
"--chat-template-file", "/mes modèles/tpl.jinja", "--load-mode", "mmap"}
if got := splitArgs(cfg["EXTRA_ARGS"]); !reflect.DeepEqual(got, wantExtra) {
t.Errorf("EXTRA_ARGS = %q\nwant %q", got, wantExtra)
}
if cfg["UBATCH"] != "2048" || cfg["BATCH"] != "4096" || cfg["CTX"] != "65536" || cfg["MODEL"] != "Qwen-MoE-Q4.gguf" {
t.Errorf("clés : %v", cfg)
}
if _, ok := cfg["NGL"]; ok {
t.Errorf("NGL gardé : %v", cfg)
}
if !strings.Contains(out, "# experts à la main") || !strings.HasPrefix(out, "# NAME=QWEN 3.6 MOE") {
t.Errorf("commentaires perdus :\n%s", out)
}
if len(changes) == 0 || !strings.Contains(strings.Join(changes, "\n"), "cache KV conservé") {
t.Errorf("changements : %q", changes)
}
// L'original n'est pas touché (chaîne passée par valeur, mais on vérifie la
// fonction : elle ne rend rien d'autre que la copie).
if parseEnv(src)["UBATCH"] != "512" {
t.Error("source modifiée")
}
refus := []struct{ name, content string }{
{"rien à migrer", "CTX=65536\nEXTRA_ARGS=--jinja\n"},
{"CTX=0", "CTX=0\nEXTRA_ARGS=--n-cpu-moe 40\n"},
{"-ot hors experts restant", `CTX=65536` + "\n" + `EXTRA_ARGS="-ot blk\.0\.attn.*=CUDA0,\.ffn_.*_exps=CPU"` + "\n"},
{"-sm row", "CTX=65536\nEXTRA_ARGS=\"--n-cpu-moe 40 -sm row\"\n"},
{"SPLIT_MODE=tensor", "CTX=65536\nSPLIT_MODE=tensor\nEXTRA_ARGS=--n-cpu-moe 40\n"},
{"--n-cpu-ffn", "CTX=65536\nEXTRA_ARGS=\"--n-cpu-moe 40 --n-cpu-ffn 4\"\n"},
{"preset externe", extKeyFlag + "=1\n" + extKeyURL + "=https://api.example.com/v1\nEXTRA_ARGS=--n-cpu-moe 40\n"},
}
for _, r := range refus {
if _, _, err := autoPlacementPreset(r.content); err == nil {
t.Errorf("%s : accepté", r.name)
}
}
// --cpu-moe et -ngl / --tensor-split / --fit off retirés ensemble.
out, _, err = autoPlacementPreset("CTX=32768\nEXTRA_ARGS=\"-cmoe -ngl 99 -ts 1,1 --fit off -ub 512\"\n")
if err != nil {
t.Fatal(err)
}
if got := parseEnv(out)["EXTRA_ARGS"]; got != "--load-mode mmap" {
t.Errorf("EXTRA_ARGS = %q", got)
}
}
// presetSetKey : remplace, ajoute, retire — sans toucher aux voisines (BATCH
// n'est pas UBATCH) ni aux commentaires.
func TestPresetSetKey(t *testing.T) {
src := "# BATCH=1\nUBATCH=512\nBATCH=2048\n"
if got := presetSetKey(src, "BATCH", "4096"); got != "# BATCH=1\nUBATCH=512\nBATCH=4096\n" {
t.Errorf("remplacement : %q", got)
}
if got := presetSetKey(src, "NGL", "auto"); got != src+"NGL=auto\n" {
t.Errorf("ajout : %q", got)
}
if got := presetSetKey("A=1", "B", "2"); got != "A=1\nB=2\n" {
t.Errorf("ajout sans fin de ligne : %q", got)
}
if got := presetSetKey(src, "UBATCH", ""); got != "# BATCH=1\nBATCH=2048\n" {
t.Errorf("retrait : %q", got)
}
if got := presetSetKey("X=\"a b\"\n", "X", "c d"); parseEnv(got)["X"] != "c d" {
t.Errorf("guillemets : %q", got)
}
// Clé en double : parseEnv retient la dernière, toutes sont réécrites.
if got := presetSetKey("NGL=999\nCTX=1\nexport NGL=40\n", "NGL", "auto"); parseEnv(got)["NGL"] != "auto" {
t.Errorf("doublon : %q", got)
}
if got := presetSetKey("NGL=999\nCTX=1\nNGL=40", "NGL", ""); got != "CTX=1\n" {
t.Errorf("retrait des doublons : %q", got)
}
}
// joinArgs relu par splitArgs rend les mêmes arguments.
func TestJoinArgsRoundTrip(t *testing.T) {
args := []string{"--jinja", "--chat-template-file", "/mes modèles/tpl.jinja", "-ot", `blk\.(1|2)\.ffn=CPU`, "", "it's here"}
if got := splitArgs(joinArgs(args)); !reflect.DeepEqual(got, args) {
t.Errorf("aller-retour : %q", got)
}
}
// Le refus de LOAD_GUARD s'arrête avant tout chargement : l'interface doit le
// dire plutôt qu'un « chargement… » sans fin, même après un chargement réussi
// plus haut dans le journal.
func TestModelLoadErrorLoadGuard(t *testing.T) {
_, refuse := loadModeRisk(map[string]string{}, splitArgs("--load-mode none"),
serveSysInfo{ModelBytes: 82 * gib, EngineVRAMMiB: 16 << 10, RAMMiB: 60 << 10})
log := "llama_model_load: loading model\nmain: model loaded\nserver is listening\n[ERREUR] " + refuse + "\n"
if got := modelLoadErrorFrom(log); !strings.Contains(got, "LOAD_GUARD=off") {
t.Errorf("refus non signalé : %q", got)
}
// Une tentative suivante qui charge efface le refus.
if got := modelLoadErrorFrom(log + "loading model\nmodel loaded\n"); got != "" {
t.Errorf("refus ancien encore signalé : %q", got)
}
}
// Un refus de LOAD_GUARD ne doit pas être relancé en boucle : l'unité systemd
// écrite par « loki install » exclut son code de sortie, une unité d'avant ne
// le fait pas.
func TestUnitPreventsRefusalRestart(t *testing.T) {
for _, c := range []struct {
unit string
want bool
}{
{"[Service]\nRestart=on-failure\nRestartSec=3\n", false},
{"[Service]\nRestart=on-failure\nRestartPreventExitStatus=78\n", true},
{"[Service]\n RestartPreventExitStatus = 1 78 SIGKILL\n", true},
{"[Service]\nRestartPreventExitStatus=178\n", false},
{"[Service]\n# RestartPreventExitStatus=78\n", false},
} {
if got := unitPreventsRefusalRestart(c.unit); got != c.want {
t.Errorf("%q : %v, attendu %v", c.unit, got, c.want)
}
}
}
// Le refus sort avec son propre code (pas 1), y compris enveloppé ; une erreur
// ordinaire garde 1. Le moteur d'essai garde toujours le vrai code.
func TestLoadGuardRefusalExitStatus(t *testing.T) {
err := loadGuardRefusal("refus ; "+loadGuardMarker, true)
if got := exitStatusOf(err); got != loadGuardExitStatus {
t.Errorf("essai : code %d, attendu %d", got, loadGuardExitStatus)
}
if !strings.Contains(err.Error(), loadGuardMarker) {
t.Errorf("raison perdue : %q", err)
}
if got := exitStatusOf(fmt.Errorf("enveloppé : %w", err)); got != loadGuardExitStatus {
t.Errorf("enveloppé : code %d", got)
}
if got := exitStatusOf(errors.New("modèle introuvable")); got != 1 {
t.Errorf("erreur ordinaire : code %d, attendu 1", got)
}
// Hors superviseur (les tests ne tournent ni sous systemd ni sous launchd) :
// le vrai code aussi.
if restarts, _ := supervisorRestartsRefusal(); !restarts {
if got := exitStatusOf(loadGuardRefusal("refus", false)); got != loadGuardExitStatus {
t.Errorf("hors superviseur : code %d", got)
}
}
}
// Le refus envisagé sur la VRAM NVIDIA attend les cartes du moteur ; rien
// n'est lu sans refus en vue.
func TestLoadGuardNeedsDevices(t *testing.T) {
none := splitArgs("--load-mode none")
base := serveSysInfo{ModelBytes: 82 * gib, VRAMMiB: 16 << 10, RAMMiB: 60 << 10}
if !loadGuardNeedsDevices(map[string]string{}, none, base) {
t.Error("refus en vue sur nvidia-smi : cartes du moteur non demandées")
}
confirmed := base
confirmed.EngineVRAMMiB = 16 << 10
for name, c := range map[string]struct {
cfg map[string]string
args []string
si serveSysInfo
}{
"déjà lues": {map[string]string{}, none, confirmed},
"LOAD_GUARD=off": {map[string]string{"LOAD_GUARD": "off"}, none, base},
"mmap": {map[string]string{}, splitArgs("--load-mode mmap"), base},
"VRAM inconnue": {map[string]string{}, none, serveSysInfo{ModelBytes: 82 * gib, RAMMiB: 60 << 10}},
"loin du seuil": {map[string]string{}, none, serveSysInfo{ModelBytes: 30 * gib, VRAMMiB: 16 << 10, RAMMiB: 60 << 10}},
} {
if loadGuardNeedsDevices(c.cfg, c.args, c.si) {
t.Errorf("%s : cartes demandées sans refus en vue", name)
}
}
}
// La VRAM des cartes du moteur : toutes additionnées, inconnue dès qu'une
// carte annonce 0 Mio (déjà pleine) ; devices.json relu en JSON (float64).
func TestEngineDevsVRAM(t *testing.T) {
mixed := splitDevsFrom([]map[string]any{
{"id": "Vulkan0", "total_mib": 12288},
{"id": "Vulkan1", "total_mib": float64(16368)},
})
if got := engineDevsVRAM(mixed); got != 12288+16368 {
t.Errorf("cartes mixtes : %d", got)
}
if got := engineDevsVRAM([]splitDev{{"CUDA0", 12288}, {"CUDA1", 0}}); got != 0 {
t.Errorf("carte à 0 Mio : %d, attendu inconnu", got)
}
if got := engineDevsVRAM(nil); got != 0 {
t.Errorf("liste vide : %d", got)
}
}
-267
View File
@@ -1,267 +0,0 @@
package loki
import (
"crypto/sha256"
"encoding/hex"
"fmt"
"os"
"path/filepath"
"regexp"
"sort"
"strings"
)
// État du slot gardé à travers une bascule de preset (SLOT_PERSIST, off par
// défaut).
//
// Basculer d'un preset A vers B puis revenir à A relance llama-server deux
// fois : au retour, la conversation de A est recalculée en entier — 30 à 80 s
// sur un 27B dense. Avec SLOT_PERSIST=on, Loki demande au moteur, juste avant
// la bascule, d'écrire l'état du slot 0 sur disque (/slots/0?action=save), et
// le recharge au retour, avant la première requête de la discussion
// (llm_slotpersist.go). llama-server ne reprend un état que sur un préfixe de
// jetons identique : au pire, rien n'est repris.
//
// Mais rien, dans le fichier, ne dit quel moteur ni quels réglages ont calculé
// ce cache : llama.cpp ne vérifie que les types et les tailles. Le recharger
// sous un autre build, une autre fenêtre ou un autre placement donnerait au
// modèle un contexte que ce moteur n'aurait jamais produit. D'où la clé : une
// empreinte de TOUT ce qui touche au calcul — ligne de commande complète
// (modèle, fenêtre, types KV, NGL, lots, gabarit, EXTRA_ARGS…), variables
// LLAMA_ARG_*, GGML_* et CUDA_*, identité (taille, date) du modèle et de ses
// tranches, du projecteur, du brouillon, du binaire et de ses bibliothèques.
// Un fichier ne se recharge que sous la même clé ; une mise à jour du moteur
// change donc tout, et n'est jamais un cas de reprise.
//
// Refusé d'emblée : le décodage spéculatif (la sauvegarde de llama.cpp ne
// garde que le modèle cible, pas le brouillon MTP — l'état rechargé serait
// incohérent pour lui), les poids sur CPU (MoE déporté : états de plusieurs
// Gio pour un recalcul de toute façon lent), et un moteur joignable par
// d'autres sans clé d'API : save et restore écrivent des Gio sur le disque.
// slotPersistOn : la clé SLOT_PERSIST est-elle posée ?
func slotPersistOn(cfg map[string]string) bool {
switch strings.ToLower(strings.TrimSpace(cfg["SLOT_PERSIST"])) {
case "on", "1", "true", "yes", "oui":
return true
}
return false
}
// slotPersistArgEnv : variables qui contredisent SLOT_PERSIST sans drapeau.
var slotPersistArgEnv = []string{"LLAMA_ARG_SLOT_SAVE_PATH", "LLAMA_ARG_SPEC_TYPE", "LLAMA_ARG_SPEC_DRAFT_MODEL",
"LLAMA_ARG_SPEC_DRAFT_HF_REPO"}
// probeSlotPersistEnv complète ArgEnv, seulement si la clé est posée.
func probeSlotPersistEnv(cfg map[string]string, si *serveSysInfo) {
if !slotPersistOn(cfg) {
return
}
if si.ArgEnv == nil {
si.ArgEnv = map[string]string{}
}
for _, k := range slotPersistArgEnv {
if v, ok := os.LookupEnv(k); ok && strings.TrimSpace(v) != "" {
si.ArgEnv[k] = v
}
}
}
// slotPersistPlan : SLOT_PERSIST vaut-il pour ce lancement ? why dit pourquoi
// non (vide = clé absente).
func slotPersistPlan(cfg map[string]string, extra []string, si serveSysInfo) (ok bool, why string) {
if !slotPersistOn(cfg) {
return false, ""
}
switch {
case isExternalConfig(cfg):
return false, "preset externe"
case !strings.Contains(si.Help, "--slot-save-path"):
return false, "ce moteur ne connaît pas --slot-save-path"
case hasAnyFlag(extra, "--slot-save-path") || si.ArgEnv["LLAMA_ARG_SLOT_SAVE_PATH"] != "":
return false, "--slot-save-path est déjà réglé à la main"
case si.SlotDir == "":
return false, "dossier des slots indisponible"
case specMode(cfg) != "off" || specUserSet(extra, si.ArgEnv) || si.ArgEnv["LLAMA_ARG_SPEC_TYPE"] != "":
return false, "décodage spéculatif : la sauvegarde du moteur ne garde pas l'état du brouillon"
case splitModeKey(cfg) != "":
return false, "SPLIT_MODE : état réparti entre cartes, sauvegarde non validée"
}
if !slotSaveSafe(cfg, extra, si) {
return false, "moteur joignable par d'autres sans clé d'API (HOST=127.0.0.1 ou une clé d'API)"
}
blocks := 0
if si.GGUF != nil {
blocks = si.GGUF.BlockCount
}
if r := cpuWeights(cfg, extra, si.ArgEnv, blocks); r != "" {
return false, r
}
return true, ""
}
// slotSaveSafe : le moteur n'est joignable que par la machine, ou exige une
// clé d'API. Sans ça, --slot-save-path donnerait à n'importe qui le droit
// d'écrire des Gio sur le disque.
func slotSaveSafe(cfg map[string]string, extra []string, si serveSysInfo) bool {
host := flagValue(extra, "--host")
if host == "" {
host = cfg["HOST"]
}
if host == "" {
host = "0.0.0.0"
}
return loopbackHost(host) || si.APIKey != ""
}
// slotPersistNote : ce que le lancement dit de SLOT_PERSIST.
func slotPersistNote(ok bool, why string) string {
if ok || why == "" {
return ""
}
return "SLOT_PERSIST refusé (" + why + ") : l'état du slot ne sera pas gardé à la bascule de preset"
}
// slotPersistFileRe : les seuls fichiers que Loki écrit dans le dossier des
// slots — clé du moteur, empreinte de la discussion.
var slotPersistFileRe = regexp.MustCompile(`^persist-[0-9a-f]{16}-[0-9a-f]{12}\.bin$`)
// slotPersistMaxFiles : au plus deux états gardés (A→B→A, puis B→A→B).
const slotPersistMaxFiles = 2
// slotPersistKey : l'empreinte de tout ce qui décide du calcul du cache.
// args : la ligne de commande finale (la clé d'API n'y entre pas) ; env : ce
// que buildServeArgs pose. Les fichiers sont pris par taille et date : un
// modèle retéléchargé, un moteur mis à jour changent la clé.
func slotPersistKey(args []string, env map[string]string, si serveSysInfo, bin string) string {
h := sha256.New()
w := func(s string) { h.Write([]byte(s)); h.Write([]byte{0}) }
for i := 0; i < len(args); i++ {
if args[i] == "--api-key" {
i++
continue
}
w(args[i])
}
vars := map[string]string{}
for _, kv := range os.Environ() {
k, v, _ := strings.Cut(kv, "=")
if strings.HasPrefix(k, "LLAMA_ARG_") || strings.HasPrefix(k, "GGML_") || strings.HasPrefix(k, "CUDA_") {
vars[k] = v
}
}
for k, v := range env {
vars[k] = v
}
delete(vars, "LLAMA_ARG_API_KEY")
keys := make([]string, 0, len(vars))
for k := range vars {
keys = append(keys, k)
}
sort.Strings(keys)
for _, k := range keys {
w(k + "=" + vars[k])
}
stat := func(p string) {
if p == "" {
return
}
if st, err := os.Stat(p); err == nil {
w(fmt.Sprintf("%s|%d|%d", p, st.Size(), st.ModTime().UnixNano()))
} else {
w(p + "|?")
}
}
if si.Model != "" {
for _, n := range shardFamily(filepath.Base(si.Model)) {
stat(filepath.Join(filepath.Dir(si.Model), n))
}
stat(si.Model)
}
stat(si.MMProj)
stat(si.Draft)
stat(bin)
// Bibliothèques du moteur (libllama, ggml-cuda…) : une mise à jour peut
// les changer sans toucher au binaire.
if entries, err := os.ReadDir(filepath.Dir(bin)); err == nil {
for _, e := range entries {
n := strings.ToLower(e.Name())
if strings.HasSuffix(n, ".so") || strings.Contains(n, ".so.") || strings.HasSuffix(n, ".dll") || strings.HasSuffix(n, ".dylib") {
stat(filepath.Join(filepath.Dir(bin), e.Name()))
}
}
}
w(fmt.Sprintf("build=%d", si.EngineBuild))
return hex.EncodeToString(h.Sum(nil))[:16]
}
// slotPersistMarker : la clé du moteur lancé, écrite par « loki serve » juste
// avant de lui céder la place, lue par le process web. Hors du dossier des
// slots : le moteur ne peut pas l'écraser par un save.
func slotPersistMarker(home string) string { return filepath.Join(home, "slots-engine.key") }
// writeSlotPersistMarker pose (key non vide) ou retire la clé du moteur.
func writeSlotPersistMarker(home, key string) {
p := slotPersistMarker(home)
if key == "" {
_ = os.Remove(p)
return
}
_ = os.WriteFile(p, []byte(key+"\n"), 0o600)
}
// readSlotPersistMarker : la clé du moteur en service ; vide = SLOT_PERSIST
// refusé ou absent à son lancement.
func readSlotPersistMarker(home string) string {
b, err := os.ReadFile(slotPersistMarker(home))
if err != nil {
return ""
}
k := strings.TrimSpace(string(b))
if len(k) != 16 {
return ""
}
if _, err := hex.DecodeString(k); err != nil {
return ""
}
return k
}
// slotPersistPrune garde les slotPersistMaxFiles états les plus récents.
func slotPersistPrune(dir string) {
type f struct {
name string
at int64
}
var fs []f
entries, err := os.ReadDir(dir)
if err != nil {
return
}
for _, e := range entries {
if !e.Type().IsRegular() || !slotPersistFileRe.MatchString(e.Name()) {
continue
}
if info, err := e.Info(); err == nil {
fs = append(fs, f{e.Name(), info.ModTime().UnixNano()})
}
}
sort.Slice(fs, func(i, j int) bool { return fs[i].at > fs[j].at })
for i := slotPersistMaxFiles; i < len(fs); i++ {
_ = os.Remove(filepath.Join(dir, fs[i].name))
}
}
// slotPersistPurge retire tous les états gardés : la clé n'est plus là, ils
// n'ont plus rien à faire sur le disque.
func slotPersistPurge(dir string) {
entries, err := os.ReadDir(dir)
if err != nil {
return
}
for _, e := range entries {
if e.Type().IsRegular() && slotPersistFileRe.MatchString(e.Name()) {
_ = os.Remove(filepath.Join(dir, e.Name()))
}
}
}
-279
View File
@@ -1,279 +0,0 @@
package loki
import (
"context"
"fmt"
"os"
"os/exec"
"strings"
"time"
)
// Files de lancement CUDA (CUDA_SCALE_LAUNCH_QUEUES) sur deux GPU ou plus.
//
// Quand llama.cpp répartit les couches entre plusieurs cartes et que le modèle
// y tient en entier, il fait travailler les cartes en pipeline : pendant que la
// seconde calcule un micro-lot, la première attaque le suivant. Encore faut-il
// que le CPU puisse empiler assez de lancements CUDA d'avance ; avec la file par
// défaut du pilote, il bloque et le pipeline se vide. « 4x » quadruple cette
// file (PR llama.cpp #19042) : c'est une affaire de pilote, pas de calcul — mêmes
// noyaux, mêmes entrées, même ordre, donc une sortie identique au jeton près. Le
// prix : ~120 Mo de RAM système par GPU.
//
// llama.cpp l'a posé par défaut puis retiré (#19227) après des blocages sur
// Jetson (mémoire unifiée, MoE décodé en partie sur CPU) ; ses docs le
// recommandent désormais à la main en multi-GPU. Loki le pose donc lui-même,
// mais seulement là où le pipeline peut réellement exister (voir
// pipelineBlocker) : ailleurs le gain est nul et l'on ne s'exposerait qu'au
// seul cas de panne jamais rapporté.
//
// Gain non mesuré sur la machine de référence (5060 Ti + 3060, 27B) : en amont,
// +10 à 25 % de prompt seulement sur un 70B réparti sur deux grosses cartes,
// rien de mesurable sur un 8B. Le décodage ne bouge pas. Pour vérifier qu'il
// s'applique, le journal du moteur doit dire « pipeline parallelism enabled ».
//
// Choix de l'utilisateur, par ordre de priorité :
//
// CUDA_SCALE_LAUNCH_QUEUES déjà dans l'environnement → intouché (conteneur Unraid…)
// CUDA_LAUNCH_QUEUES=off → rien
// CUDA_LAUNCH_QUEUES=0.25x|0.5x|2x|4x → cette valeur, sans condition
// vide ou auto → 4x si ≥ 2 GPU et pipeline possible
//
// Une autre valeur serait lue comme 1x par le pilote, sans un mot : on l'ignore
// en le disant.
const launchQueuesAuto = "4x"
// launchQueueValues : les seules valeurs que le pilote CUDA comprend.
var launchQueueValues = map[string]bool{"0.25x": true, "0.5x": true, "2x": true, "4x": true}
// serveArgEnv : les variables LLAMA_ARG_* qui font, sans drapeau, ce que lit
// pipelineBlocker. llama.cpp les applique quand le drapeau manque — les ignorer
// ferait conclure à un pipeline que le moteur n'ouvrira pas.
var serveArgEnv = []string{"LLAMA_ARG_DEVICE", "LLAMA_ARG_SPLIT_MODE", "LLAMA_ARG_N_GPU_LAYERS",
"LLAMA_ARG_OVERRIDE_TENSOR", "LLAMA_ARG_CPU_MOE", "LLAMA_ARG_N_CPU_MOE", "LLAMA_ARG_N_CPU_FFN",
"LLAMA_ARG_KV_OFFLOAD", "LLAMA_ARG_NO_KV_OFFLOAD"}
// launchQueuesEnv décide de CUDA_SCALE_LAUNCH_QUEUES. Fonction pure : le nombre
// de GPU et l'environnement arrivent déjà sondés dans si. Valeur vide = ne rien
// poser.
func launchQueuesEnv(cfg map[string]string, extra []string, si serveSysInfo) (val string, notes []string) {
if si.LaunchQueues != "" {
return "", nil
}
want := strings.ToLower(strings.TrimSpace(cfg["CUDA_LAUNCH_QUEUES"]))
switch {
case want == "off":
return "", nil
case launchQueueValues[want]:
return want, []string{"CUDA_SCALE_LAUNCH_QUEUES=" + want + " (CUDA_LAUNCH_QUEUES)"}
case want != "" && want != "auto":
notes = append(notes, "CUDA_LAUNCH_QUEUES="+cfg["CUDA_LAUNCH_QUEUES"]+
" ignoré (0.25x, 0.5x, 2x, 4x ou off) → automatique")
}
n := servedGPUCount(extra, si)
if n < 2 || pipelineBlocker(cfg, extra, si.ArgEnv) != "" {
return "", notes
}
return launchQueuesAuto, append(notes, fmt.Sprintf("CUDA_SCALE_LAUNCH_QUEUES=%s (%d GPU, parallélisme pipeline "+
"possible) — CUDA_LAUNCH_QUEUES=off pour s'en passer", launchQueuesAuto, n))
}
// servedGPUCount : combien de GPU CUDA llama-server utilisera. Le --device du
// preset d'abord (c'est là que l'éditeur range le choix des cartes), puis ce
// que cmdServe a compté (CUDA_VISIBLE_DEVICES, sinon nvidia-smi). Un --device
// qui ne nomme que des cartes Vulkan ou Metal compte zéro : la variable ne
// concerne que CUDA.
func servedGPUCount(extra []string, si serveSysInfo) int {
dev := flagValue(extra, "-dev", "--device")
if dev == "" {
dev = si.ArgEnv["LLAMA_ARG_DEVICE"]
}
if dev == "" {
return si.GPUs
}
n := 0
for _, d := range strings.Split(dev, ",") {
if strings.HasPrefix(strings.ToUpper(strings.TrimSpace(d)), "CUDA") {
n++
}
}
return n
}
// pipelineBlocker dit pourquoi llama.cpp n'ouvrira PAS le pipeline entre
// cartes (llama-context.cpp : toutes les couches sur GPU, découpe par couches,
// cache KV sur GPU, aucune surcharge de tenseurs). Vide = il peut l'ouvrir.
// --n-cpu-moe, --cpu-moe et --n-cpu-ffn sont des surcharges de tenseurs
// déguisées : le preset MoE à experts sur CPU n'a donc jamais de pipeline.
//
// Attention à la façon dont llama.cpp lit ces réglages (common/arg.cpp) : les
// variables LLAMA_ARG_* passent d'abord, la ligne de commande ensuite. Pour un
// réglage à valeur unique (-sm, -ngl, -kvo/-nkvo), la ligne de commande
// l'emporte ; mais les surcharges de tenseurs S'ACCUMULENT — variable et
// drapeaux, chaque occurrence ajoute les siennes. Un --n-cpu-moe 0 n'annule
// donc pas un LLAMA_ARG_N_CPU_MOE=30.
func pipelineBlocker(cfg map[string]string, extra []string, argEnv map[string]string) string {
if r := tensorOverride(extra, argEnv); r != "" {
return r
}
if !kvOffloaded(extra, argEnv) {
return "cache KV sur CPU"
}
sm := flagValue(extra, "-sm", "--split-mode")
if sm == "" {
sm = argEnv["LLAMA_ARG_SPLIT_MODE"]
}
if sm != "" && sm != "layer" {
return "découpe " + sm
}
// Couches GPU : celles d'EXTRA_ARGS, sinon NGL (que buildServeArgs traduit
// toujours en -ngl, sauf NGL=auto qui ne pose rien — la variable du moteur
// décide alors). Un nombre choisi — hors la sentinelle 999 — laisse en
// général des couches au CPU ; auto, all et 999 visent le tout-GPU.
ngl := flagValue(extra, "-ngl", "--n-gpu-layers", "--gpu-layers")
if ngl == "" {
ngl = strings.TrimSpace(cfg["NGL"])
if strings.EqualFold(ngl, "auto") {
ngl = argEnv["LLAMA_ARG_N_GPU_LAYERS"]
}
}
if ngl = strings.ToLower(strings.TrimSpace(ngl)); ngl != "" && ngl != "auto" && ngl != "all" && ngl != "999" {
return "couches GPU limitées à " + ngl
}
return ""
}
// tensorOverride dit si des tenseurs sont placés à la main (-ot et ses formes
// déguisées --cpu-moe, --n-cpu-moe, --n-cpu-ffn), en drapeau ou en variable.
// Vide = aucun. Le pipeline entre cartes s'y arrête, et --fit aussi
// (« tensor_buft_overrides already set by user, abort »).
func tensorOverride(extra []string, argEnv map[string]string) string {
if hasAnyFlag(extra, "-ot", "--override-tensor", "--cpu-moe", "-cmoe") ||
argEnv["LLAMA_ARG_OVERRIDE_TENSOR"] != "" || envTruthy(argEnv["LLAMA_ARG_CPU_MOE"]) {
return "surcharge de tenseurs"
}
for _, n := range []struct{ env, short, long string }{
{"LLAMA_ARG_N_CPU_MOE", "-ncmoe", "--n-cpu-moe"},
{"LLAMA_ARG_N_CPU_FFN", "-ncffn", "--n-cpu-ffn"},
} {
for _, v := range append(flagValues(extra, n.short, n.long), argEnv[n.env]) {
if v = strings.TrimSpace(v); v != "" && v != "0" {
return "couches " + n.long + " sur CPU"
}
}
}
return ""
}
// kvOffloaded : le cache KV reste-t-il sur GPU ? Le dernier -kvo/-nkvo de la
// ligne de commande tranche ; sinon LLAMA_ARG_NO_KV_OFFLOAD, dont la SEULE
// présence vaut « non » pour llama.cpp (quelle que soit sa valeur), puis
// LLAMA_ARG_KV_OFFLOAD lu comme un booléen.
func kvOffloaded(extra []string, argEnv map[string]string) bool {
on, set := true, false
for _, a := range extra {
name, _, _ := strings.Cut(a, "=")
switch name {
case "-kvo", "--kv-offload":
on, set = true, true
case "-nkvo", "--no-kv-offload":
on, set = false, true
}
}
if set {
return on
}
if argEnv["LLAMA_ARG_NO_KV_OFFLOAD"] != "" {
return false
}
switch strings.ToLower(strings.TrimSpace(argEnv["LLAMA_ARG_KV_OFFLOAD"])) {
case "off", "disabled", "false", "0":
return false
}
return true
}
// envTruthy lit un drapeau sans valeur passé par variable, comme llama.cpp :
// seuls on, enabled, true et 1 l'activent.
func envTruthy(v string) bool {
switch strings.TrimSpace(v) {
case "on", "enabled", "true", "1":
return true
}
return false
}
// flagValues : les valeurs de TOUTES les occurrences d'un drapeau (« -x 1 » ou
// « -x=1 »), pour ceux que llama.cpp cumule au lieu de garder le dernier.
func flagValues(args []string, flags ...string) []string {
var vals []string
for i, a := range args {
name, val, hasEq := strings.Cut(a, "=")
for _, f := range flags {
if name != f {
continue
}
if hasEq {
vals = append(vals, val)
} else if i+1 < len(args) {
vals = append(vals, args[i+1])
}
}
}
return vals
}
// probeServeGPUs remplit ce dont launchQueuesEnv a besoin. On ne sonde
// nvidia-smi que si la réponse peut servir : variable déjà posée, « off », un
// --device explicite ou CUDA_VISIBLE_DEVICES suffisent à trancher sans lui.
func probeServeGPUs(cfg map[string]string, extra []string, si *serveSysInfo) {
si.LaunchQueues = os.Getenv("CUDA_SCALE_LAUNCH_QUEUES")
si.ArgEnv = map[string]string{}
for _, k := range serveArgEnv {
if v, ok := os.LookupEnv(k); ok && v != "" {
si.ArgEnv[k] = v
} else if ok && k == "LLAMA_ARG_NO_KV_OFFLOAD" {
si.ArgEnv[k] = "1" // présente mais vide : llama.cpp n'y lit que la présence
}
}
if si.LaunchQueues != "" || strings.EqualFold(strings.TrimSpace(cfg["CUDA_LAUNCH_QUEUES"]), "off") {
return
}
if hasAnyFlag(extra, "-dev", "--device") || si.ArgEnv["LLAMA_ARG_DEVICE"] != "" {
return
}
// cmdServe a déjà posé la sélection de config.env dans l'environnement :
// os.Getenv voit donc le preset d'abord, le conteneur ensuite.
if cvd := strings.TrimSpace(os.Getenv("CUDA_VISIBLE_DEVICES")); cvd != "" {
for _, d := range strings.Split(cvd, ",") {
if strings.TrimSpace(d) != "" {
si.GPUs++
}
}
return
}
si.GPUs = nvidiaGPUCount(3 * time.Second)
}
// nvidiaGPUCount compte les cartes NVIDIA. Borné dans le temps : un pilote
// coincé peut faire pendre nvidia-smi, et le démarrage du moteur ne doit pas
// en dépendre. Au moindre doute, 0 — on ne pose rien.
func nvidiaGPUCount(timeout time.Duration) int {
if !hasTool("nvidia-smi") {
return 0
}
ctx, cancel := context.WithTimeout(context.Background(), timeout)
defer cancel()
out, err := hideCmd(exec.CommandContext(ctx, "nvidia-smi", "--query-gpu=index", "--format=csv,noheader")).Output()
if err != nil {
return 0
}
n := 0
for _, line := range strings.Split(string(out), "\n") {
if strings.TrimSpace(line) != "" {
n++
}
}
return n
}
-136
View File
@@ -1,136 +0,0 @@
package loki
import (
"reflect"
"testing"
)
// TestLaunchQueuesEnv : CUDA_SCALE_LAUNCH_QUEUES n'est posé que là où le
// pipeline entre cartes peut exister, et jamais par-dessus un choix de
// l'utilisateur.
func TestLaunchQueuesEnv(t *testing.T) {
two := serveSysInfo{GPUs: 2}
cases := []struct {
name string
cfg map[string]string
si serveSysInfo
want string
wantNotes int
}{
{name: "2 GPU, dense tout sur GPU : 4x, et on le dit",
cfg: map[string]string{"NGL": "999"}, si: two, want: "4x", wantNotes: 1},
{name: "NGL=all ou auto : pipeline possible",
cfg: map[string]string{"NGL": "all"}, si: two, want: "4x", wantNotes: 1},
{name: "1 GPU : rien",
cfg: map[string]string{"NGL": "999"}, si: serveSysInfo{GPUs: 1}},
{name: "nombre de GPU inconnu : rien",
cfg: map[string]string{}},
{name: "variable déjà posée par l'utilisateur : intouchée",
cfg: map[string]string{"CUDA_LAUNCH_QUEUES": "2x"}, si: serveSysInfo{GPUs: 2, LaunchQueues: "1x"}},
{name: "CUDA_LAUNCH_QUEUES=off : rien",
cfg: map[string]string{"CUDA_LAUNCH_QUEUES": "off"}, si: two},
{name: "valeur explicite valide : posée telle quelle, même sur 1 GPU",
cfg: map[string]string{"CUDA_LAUNCH_QUEUES": "2X"}, si: serveSysInfo{GPUs: 1}, want: "2x", wantNotes: 1},
{name: "valeur invalide : ignorée, dite, puis automatique",
cfg: map[string]string{"CUDA_LAUNCH_QUEUES": "8x"}, si: two, want: "4x", wantNotes: 2},
{name: "valeur invalide sur 1 GPU : seulement la note",
cfg: map[string]string{"CUDA_LAUNCH_QUEUES": "beaucoup"}, si: serveSysInfo{GPUs: 1}, wantNotes: 1},
{name: "preset MoE (-ot, --n-cpu-moe) sur 2 GPU : rien",
cfg: map[string]string{"EXTRA_ARGS": `-ot "per_layer_token_embd=CPU" --n-cpu-moe 40`}, si: two},
{name: "--n-cpu-moe seul : rien",
cfg: map[string]string{"EXTRA_ARGS": "--n-cpu-moe=12"}, si: two},
{name: "--n-cpu-moe 0 : aucun expert sur CPU, pipeline possible",
cfg: map[string]string{"EXTRA_ARGS": "--n-cpu-moe 0"}, si: two, want: "4x", wantNotes: 1},
{name: "--cpu-moe : rien",
cfg: map[string]string{"EXTRA_ARGS": "--cpu-moe"}, si: two},
{name: "-sm row : rien",
cfg: map[string]string{"EXTRA_ARGS": "-sm row"}, si: two},
{name: "--split-mode=layer explicite : 4x",
cfg: map[string]string{"EXTRA_ARGS": "--split-mode=layer"}, si: two, want: "4x", wantNotes: 1},
{name: "NGL=28 : des couches restent au CPU, rien",
cfg: map[string]string{"NGL": "28"}, si: two},
{name: "-ngl 40 dans EXTRA_ARGS l'emporte sur NGL=999 : rien",
cfg: map[string]string{"NGL": "999", "EXTRA_ARGS": "-ngl 40"}, si: two},
{name: "cache KV sur CPU : rien",
cfg: map[string]string{"EXTRA_ARGS": "-nkvo"}, si: two},
{name: "--device d'une seule carte : rien",
cfg: map[string]string{"EXTRA_ARGS": "--device CUDA0"}, si: two},
{name: "--device de deux cartes CUDA, nvidia-smi non sondé : 4x",
cfg: map[string]string{"EXTRA_ARGS": "--device CUDA1,CUDA0 --tensor-split 0.6,0.4"}, want: "4x", wantNotes: 1},
{name: "--device Vulkan : la variable ne concerne pas ce moteur",
cfg: map[string]string{"EXTRA_ARGS": "-dev Vulkan0,Vulkan1"}, si: two},
{name: "--device none : rien",
cfg: map[string]string{"EXTRA_ARGS": "-dev none"}, si: two},
{name: "LLAMA_ARG_N_CPU_MOE dans l'environnement du moteur : rien",
cfg: map[string]string{}, si: serveSysInfo{GPUs: 2, ArgEnv: map[string]string{"LLAMA_ARG_N_CPU_MOE": "30"}}},
{name: "LLAMA_ARG_SPLIT_MODE=none : rien",
cfg: map[string]string{}, si: serveSysInfo{GPUs: 2, ArgEnv: map[string]string{"LLAMA_ARG_SPLIT_MODE": "none"}}},
{name: "NGL=auto et LLAMA_ARG_N_GPU_LAYERS=20 : rien",
cfg: map[string]string{"NGL": "auto"}, si: serveSysInfo{GPUs: 2, ArgEnv: map[string]string{"LLAMA_ARG_N_GPU_LAYERS": "20"}}},
{name: "LLAMA_ARG_CPU_MOE=0 : désactivé, pipeline possible",
cfg: map[string]string{}, si: serveSysInfo{GPUs: 2, ArgEnv: map[string]string{"LLAMA_ARG_CPU_MOE": "0"}}, want: "4x", wantNotes: 1},
{name: "LLAMA_ARG_DEVICE d'une seule carte : rien",
cfg: map[string]string{}, si: serveSysInfo{GPUs: 2, ArgEnv: map[string]string{"LLAMA_ARG_DEVICE": "CUDA1"}}},
// Relecture : ce que llama.cpp fait vraiment de ses variables.
{name: "--n-cpu-moe 0 n'annule pas LLAMA_ARG_N_CPU_MOE (surcharges cumulées) : rien",
cfg: map[string]string{"EXTRA_ARGS": "--n-cpu-moe 0"},
si: serveSysInfo{GPUs: 2, ArgEnv: map[string]string{"LLAMA_ARG_N_CPU_MOE": "30"}}},
{name: "deux --n-cpu-moe, le dernier à 0 : le premier compte quand même, rien",
cfg: map[string]string{"EXTRA_ARGS": "--n-cpu-moe 20 --n-cpu-moe 0"}, si: two},
{name: "--n-cpu-ffn (FFN dense sur CPU) : rien",
cfg: map[string]string{"EXTRA_ARGS": "-ncffn 8"}, si: two},
{name: "LLAMA_ARG_OVERRIDE_TENSOR : rien",
cfg: map[string]string{}, si: serveSysInfo{GPUs: 2, ArgEnv: map[string]string{"LLAMA_ARG_OVERRIDE_TENSOR": "exps=CPU"}}},
{name: "LLAMA_ARG_CPU_MOE=yes : llama.cpp ne l'active pas, 4x",
cfg: map[string]string{}, si: serveSysInfo{GPUs: 2, ArgEnv: map[string]string{"LLAMA_ARG_CPU_MOE": "yes"}}, want: "4x", wantNotes: 1},
{name: "LLAMA_ARG_NO_KV_OFFLOAD=0 : sa seule présence coupe l'offload, rien",
cfg: map[string]string{}, si: serveSysInfo{GPUs: 2, ArgEnv: map[string]string{"LLAMA_ARG_NO_KV_OFFLOAD": "0"}}},
{name: "LLAMA_ARG_KV_OFFLOAD=false : rien",
cfg: map[string]string{}, si: serveSysInfo{GPUs: 2, ArgEnv: map[string]string{"LLAMA_ARG_KV_OFFLOAD": "false"}}},
{name: "-kvo sur la ligne l'emporte sur LLAMA_ARG_NO_KV_OFFLOAD : 4x",
cfg: map[string]string{"EXTRA_ARGS": "-kvo"},
si: serveSysInfo{GPUs: 2, ArgEnv: map[string]string{"LLAMA_ARG_NO_KV_OFFLOAD": "1"}}, want: "4x", wantNotes: 1},
{name: "-nkvo puis -kvo : le dernier gagne, 4x",
cfg: map[string]string{"EXTRA_ARGS": "-nkvo -kvo"}, si: two, want: "4x", wantNotes: 1},
{name: "NGL absent : Loki passe -ngl auto, LLAMA_ARG_N_GPU_LAYERS ne compte pas, 4x",
cfg: map[string]string{}, si: serveSysInfo{GPUs: 2, ArgEnv: map[string]string{"LLAMA_ARG_N_GPU_LAYERS": "20"}}, want: "4x", wantNotes: 1},
{name: "NGL=Auto (casse libre, comme nglArgs) : 4x",
cfg: map[string]string{"NGL": "Auto"}, si: two, want: "4x", wantNotes: 1},
{name: "-sm tensor : pas de pipeline par couches, rien",
cfg: map[string]string{"EXTRA_ARGS": "-sm tensor"}, si: two},
}
for _, c := range cases {
t.Run(c.name, func(t *testing.T) {
got, notes := launchQueuesEnv(c.cfg, splitArgs(c.cfg["EXTRA_ARGS"]), c.si)
if got != c.want {
t.Fatalf("valeur = %q, attendu %q", got, c.want)
}
if len(notes) != c.wantNotes {
t.Fatalf("notes = %q, en attendait %d", notes, c.wantNotes)
}
})
}
}
// La variable passe par l'environnement : la ligne de commande ne bouge pas
// d'un octet, et la sélection GPU l'accompagne.
func TestBuildServeArgsLaunchQueues(t *testing.T) {
const bin, model = "/opt/llama/llama-server", "/models/Qwen3.6-27B-Q4_K_M.gguf"
cfg := map[string]string{"NGL": "999", "CUDA_VISIBLE_DEVICES": "1,0"}
si := serveSysInfo{Help: helpRecent, Model: model}
without, _, _ := buildServeArgs(cfg, nil, bin, si)
si.GPUs = 2
args, env, notes := buildServeArgs(cfg, nil, bin, si)
if !reflect.DeepEqual(args, without) {
t.Fatalf("la ligne de commande a changé\n got %q\nwant %q", args, without)
}
want := map[string]string{"CUDA_VISIBLE_DEVICES": "1,0", "CUDA_DEVICE_ORDER": "PCI_BUS_ID",
"CUDA_SCALE_LAUNCH_QUEUES": "4x"}
if !reflect.DeepEqual(env, want) {
t.Fatalf("env = %v, want %v", env, want)
}
// Une note pour 999 → auto, une pour les files de lancement.
if len(notes) != 2 {
t.Fatalf("notes = %q", notes)
}
}
Loaded 100 of 676 files, more files were not shown because too many files have changed in this diff. Show more