mirror of
https://github.com/R0m1k3/Loki.git
synced 2026-10-22 06:28:11 +02:00
Compare commits
170
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
4c2cf8b4e6 | ||
|
|
68dd65cd5a | ||
|
|
01c11be4ae | ||
|
|
00e09a71e3 | ||
|
|
cbbe62ff97 | ||
|
|
e8563d992c | ||
|
|
ded1c97c17 | ||
|
|
92e9adee7d | ||
|
|
db1f20c0a4 | ||
|
|
fe51bdeee9 | ||
|
|
7288db632e | ||
|
|
f66d1b7621 | ||
|
|
bed6d07739 | ||
|
|
5197ee2542 | ||
|
|
90d6dd4a9a | ||
|
|
35886da9b1 | ||
|
|
76d9dce74a | ||
|
|
1d33ca5a49 | ||
|
|
e94b625931 | ||
|
|
869bd18bff | ||
|
|
9dfce204bf | ||
|
|
c157da2141 | ||
|
|
ad52da18a9 | ||
|
|
ff5c6d4571 | ||
|
|
f79b74dac3 | ||
|
|
a005626e66 | ||
|
|
89ed527c96 | ||
|
|
e444202b4b | ||
|
|
8a61879bfd | ||
|
|
f6c191922f | ||
|
|
84ae3a0cc4 | ||
|
|
8f61870224 | ||
|
|
9a0455be8f | ||
|
|
ed9b2e8c1c | ||
|
|
8c9bb6b450 | ||
|
|
0f3a3d52c1 | ||
|
|
78766a28d9 | ||
|
|
2c926a8274 | ||
|
|
25e31c1c26 | ||
|
|
090f15615e | ||
|
|
3fc36a3acc | ||
|
|
ec55f7d3d6 | ||
|
|
4c8ac93581 | ||
|
|
5edcad50a5 | ||
|
|
2db4cf1090 | ||
|
|
a4f8bd3ec4 | ||
|
|
2da2efc1fd | ||
|
|
a50b8f3cc6 | ||
|
|
13bcbc74fe | ||
|
|
1e5f0904f2 | ||
|
|
db9b0b44f5 | ||
|
|
eddcf97e3b | ||
|
|
cbd0d5e356 | ||
|
|
8bba80dee8 | ||
|
|
28f90543de | ||
|
|
46bfab3343 | ||
|
|
17ec2291d4 | ||
|
|
a33e5ccffe | ||
|
|
bbea5fcd3f | ||
|
|
98196e1730 | ||
|
|
57629f7254 | ||
|
|
911501ac0b | ||
|
|
dc262a44b8 | ||
|
|
f11f667a4c | ||
|
|
8fdef5455b | ||
|
|
daa2e7e4c1 | ||
|
|
a739534104 | ||
|
|
bc388d7a01 | ||
|
|
ad46bb69a6 | ||
|
|
306c7a2f6c | ||
|
|
846b561efb | ||
|
|
e96ffe319c | ||
|
|
b5649eb506 | ||
|
|
f2d2669690 | ||
|
|
ef11454a21 | ||
|
|
fea055fd52 | ||
|
|
e114ba3800 | ||
|
|
73bbf92f69 | ||
|
|
bec3d2cbfd | ||
|
|
a73b20ea04 | ||
|
|
74c2d5105c | ||
|
|
091902039d | ||
|
|
27ce303d7f | ||
|
|
f15baa79aa | ||
|
|
d8e2c3dbf6 | ||
|
|
4c615d131f | ||
|
|
e862ac834e | ||
|
|
7defedf141 | ||
|
|
0c1760c31a | ||
|
|
461ef8030d | ||
|
|
c38b1ac43e | ||
|
|
f62271b7fe | ||
|
|
b2fc40d48e | ||
|
|
c744b744ee | ||
|
|
99e456b9a2 | ||
|
|
73db4459d7 | ||
|
|
d805d9f1ba | ||
|
|
0d75e90386 | ||
|
|
b98ee5c3e7 | ||
|
|
dc6a001581 | ||
|
|
2fcafa4ffe | ||
|
|
a4b0885a22 | ||
|
|
c9c9ac1ddd | ||
|
|
451770bfcf | ||
|
|
7017b66c08 | ||
|
|
bea29bc6a9 | ||
|
|
a672760f3c | ||
|
|
d4fa4488cc | ||
|
|
a99d988e70 | ||
|
|
4b859c4eb6 | ||
|
|
0a6914c936 | ||
|
|
342f787eff | ||
|
|
f1f73cfc50 | ||
|
|
6ea661d705 | ||
|
|
e236069c92 | ||
|
|
6afe07a823 | ||
|
|
30ce3a4e86 | ||
|
|
a463ccbd9f | ||
|
|
393004cb06 | ||
|
|
7d614324a0 | ||
|
|
853e59c085 | ||
|
|
e7fb63b1dd | ||
|
|
6aaf9360b4 | ||
|
|
c2a2cc5748 | ||
|
|
ac6d8ad144 | ||
|
|
5016396998 | ||
|
|
502f23e833 | ||
|
|
634cfb61f3 | ||
|
|
ecff7de5a3 | ||
|
|
127013b32b | ||
|
|
4a9a5a1712 | ||
|
|
5dd14c9de5 | ||
|
|
5d37bef0ce | ||
|
|
5b834d54b2 | ||
|
|
c24472b91f | ||
|
|
911ffbfeaf | ||
|
|
5f7203a935 | ||
|
|
4b659ac186 | ||
|
|
ac40a2556a | ||
|
|
a325e98dad | ||
|
|
f0fef91773 | ||
|
|
a4db6d4da1 | ||
|
|
d50c8a56c6 | ||
|
|
432308c5b2 | ||
|
|
c888afb493 | ||
|
|
b7d7bfbe59 | ||
|
|
568f344273 | ||
|
|
5f00b6f041 | ||
|
|
39487f9449 | ||
|
|
78d15cefbf | ||
|
|
f0d5711a1a | ||
|
|
a94c752529 | ||
|
|
9d02592d4f | ||
|
|
9bffff78c2 | ||
|
|
f87c7b615b | ||
|
|
986a636c20 | ||
|
|
889a85888d | ||
|
|
5954d75118 | ||
|
|
539e36ea49 | ||
|
|
28ba44eb46 | ||
|
|
7a57322a62 | ||
|
|
c1d3757f23 | ||
|
|
f74f64ed90 | ||
|
|
b0c53e32af | ||
|
|
2097c9f915 | ||
|
|
eb58f7982f | ||
|
|
630e84cc08 | ||
|
|
4932999d5c | ||
|
|
7c9aa04fd9 | ||
|
|
3af481ff05 |
No files matched your search
@@ -8,3 +8,4 @@ dist/
|
||||
!README.md
|
||||
.env
|
||||
.env.*
|
||||
rust/target/
|
||||
@@ -31,6 +31,24 @@ 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,6 +54,7 @@ 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
|
||||
@@ -61,3 +62,56 @@ 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
|
||||
@@ -22,3 +22,6 @@ Thumbs.db
|
||||
.pytest_cache/
|
||||
__pycache__/
|
||||
node_modules/
|
||||
|
||||
# Rust (façade loki-rs)
|
||||
rust/target/
|
||||
+32
-1
@@ -42,6 +42,23 @@ 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
|
||||
@@ -87,8 +104,12 @@ 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 \
|
||||
git tini ca-certificates curl gnupg python3 python3-venv \
|
||||
&& curl -fsSL https://deb.nodesource.com/setup_22.x | bash - \
|
||||
&& apt-get install -y --no-install-recommends nodejs \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
@@ -127,6 +148,7 @@ 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
|
||||
@@ -143,6 +165,15 @@ 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
|
||||
|
||||
@@ -52,6 +52,16 @@ et le **chiffrement de la mémoire** (`mem_crypto.go`, `mem_vault.go`,
|
||||
**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.0 »,
|
||||
`backend_moe.go`) : Loki télécharge le paquet figé publié par AJEAN sur sa
|
||||
release `moe-v1.0` (sources de Strata et binaire Linux CUDA 12 compilé, avec
|
||||
les modifications d'AJEAN), vérifié par SHA-256. Ni Strata ni le modèle ne sont
|
||||
inclus dans l'image : ils sont téléchargés à la demande.
|
||||
|
||||
## Services externes
|
||||
|
||||
Le tunnel d'accès distant continue de pointer vers **ajean.link**, le relais
|
||||
|
||||
@@ -16,7 +16,9 @@ seule image.
|
||||
|
||||
```
|
||||
┌────────────────── conteneur loki ──────────────────┐
|
||||
│ loki web (UI + API, port 8090, premier plan) │
|
||||
│ loki-rs serve (façade Rust, port 8090) │
|
||||
│ │ relaie HTTP/SSE/WebSocket │
|
||||
│ loki web (UI + API Go, 127.0.0.1:8091) │
|
||||
│ │ pilote (fichier PID — pas de systemd) │
|
||||
│ loki serve ──exec──► llama-server (CUDA, :8080) │
|
||||
│ ▲ modèles .gguf │
|
||||
@@ -35,6 +37,13 @@ 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)
|
||||
|
||||
@@ -93,6 +102,70 @@ 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.0 », 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, carte d'appoint).
|
||||
|
||||
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
|
||||
@@ -108,6 +181,32 @@ 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
|
||||
@@ -125,6 +224,29 @@ redémarrages (volume `/data`). Variables d'environnement du conteneur :
|
||||
| `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
|
||||
@@ -135,7 +257,7 @@ un reverse proxy (`loki.mondomaine.fr`), définir une clé
|
||||
`LOKI_TRUSTED_HOSTS`.
|
||||
|
||||
En CLI dans le conteneur : `docker exec -it loki loki status` (aussi :
|
||||
`logs`, `restart`, `config`, `bench`, `test`…).
|
||||
`logs`, `restart`, `config`, `bench`, `tune`, `test`…).
|
||||
|
||||
### Données et persistance
|
||||
|
||||
@@ -356,6 +478,9 @@ 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
|
||||
@@ -461,6 +586,526 @@ 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) |
|
||||
@@ -475,7 +1120,7 @@ Retirées par ce fork :
|
||||
| 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) | `docker compose pull` |
|
||||
| Mise à jour | `ajean update` (binaire GitHub) | au redémarrage du conteneur (release `rolling`) ; `docker compose pull` si l'image change |
|
||||
|
||||
Le reste — mémoire, outils, protocole, moteur d'inférence — est celui d'AJEAN.
|
||||
Pour récupérer les évolutions de l'amont :
|
||||
@@ -536,6 +1181,40 @@ 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.
|
||||
|
||||
|
||||
+128
-72
@@ -1,93 +1,149 @@
|
||||
# Loki 0.14.0
|
||||
# Loki 0.15.0
|
||||
|
||||
Loki rattrape AJEAN jusqu'à la 0.17.6 et reprend une série d'idées d'OpenFox
|
||||
pour le mode Code. Le gros du travail est invisible : des discussions qui ne se
|
||||
bloquent plus sur un contexte plein, des réponses qui survivent à une coupure
|
||||
réseau, un agent de code qui gaspille moins de tours.
|
||||
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 contexte ne bloque plus la discussion
|
||||
La section **Performance** du README rassemble toutes les clés, leur défaut et
|
||||
leur prix.
|
||||
|
||||
Sur une fenêtre de 65k, un tour qui lisait plusieurs gros fichiers d'un coup
|
||||
passait de 60 % à plus de 130 % sans jamais compacter, et le moteur répondait
|
||||
par un 400 qui figeait la discussion.
|
||||
## Actif d'office, sans perte
|
||||
|
||||
- Le compactage en cours de tour compte aussi les résultats d'outils que le
|
||||
moteur n'a pas encore vus.
|
||||
- Tout résultat d'outil est borné à 30 000 caractères **pour le modèle** ;
|
||||
l'interface garde le résultat complet (« voir plus »).
|
||||
- En dernier recours, les gros résultats sont tronqués puis les plus vieux
|
||||
échanges retirés, au lieu de laisser remonter l'erreur.
|
||||
- Le compactage ne fait plus répondre deux fois à une vieille question, et le
|
||||
texte écrit avant un appel d'outil n'est plus rangé en double.
|
||||
**La conversation n'est plus recalculée pour rien.**
|
||||
|
||||
## Presets : API compatible OpenAI
|
||||
- **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.
|
||||
|
||||
Les presets d'API externe gagnent ce qui leur manquait :
|
||||
**Le matériel est mieux employé.**
|
||||
|
||||
- une case **« le modèle accepte les images »** pour un modèle distant
|
||||
multimodal ;
|
||||
- plus de `chat_template_kwargs` (propre à llama.cpp) envoyé à l'API : une API
|
||||
stricte répondait 400 et **chaque compactage échouait** ;
|
||||
- **reprise automatique** quand le flux se coupe en pleine réponse (Wi-Fi, VPN,
|
||||
proxy) : jusqu'à 5 fois, le modèle reprend là où il s'était arrêté ;
|
||||
- un 401, 429 ou 502 n'est plus pris pour un appel d'outil mal formé ;
|
||||
- le débit est mesuré même quand l'API ne le donne pas ; le benchmark refuse de
|
||||
mesurer un moteur local qui n'est pas celui du preset ; le badge des réponses
|
||||
porte le nom du modèle distant.
|
||||
- **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).
|
||||
|
||||
## Mode Code
|
||||
**Loki lui-même pèse moins.**
|
||||
|
||||
- **La vérification attend que le builder ait fini.** Il marque chaque critère
|
||||
`completed` une fois fait ; tant qu'il en reste d'ouverts, il est relancé au
|
||||
lieu de payer une passe de vérification sur un travail inachevé. Plus aucune
|
||||
vérification ne part quand il vient de poser une question.
|
||||
- **Écriture refusée dès son chemin** : un `write` sur un fichier jamais lu
|
||||
était refusé après tout le fichier généré. Le chemin passe maintenant en
|
||||
premier et le refus coupe la génération aussitôt.
|
||||
- **Appel d'outil écrit en texte** (`<tool_call>`, format XML de Qwen3-Coder) :
|
||||
repéré pendant le flux, coupé, et la relance cite l'extrait fautif.
|
||||
- `read_file`, `str_replace`, `old_string`… appris d'autres agents sont
|
||||
traduits vers les vrais outils.
|
||||
- Les consignes du dépôt (**AGENTS.md**, CLAUDE.md) font partie du contexte.
|
||||
- Terminal : `cmd | tail -N` rend le **vrai** code de sortie, la sortie déjà
|
||||
produite est gardée au délai dépassé, les couleurs ANSI sont retirées.
|
||||
- `git_status` / `git_diff` trouvent le dépôt cloné dans un sous-dossier.
|
||||
- Le compactage garde les fichiers touchés, les erreurs résolues et l'état des
|
||||
critères.
|
||||
- É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.
|
||||
|
||||
## Tâches et chat
|
||||
**On peut enfin mesurer.**
|
||||
|
||||
- **« Rappelle-moi dans 20 minutes »** : `task_create` accepte `in_minutes` ou
|
||||
`at`, à l'heure du navigateur (le conteneur tourne en UTC).
|
||||
- **Écrire pendant que l'IA répond** : le message part en file et le modèle en
|
||||
tient compte dans la suite de sa réponse ; deux appareils qui envoient en
|
||||
même temps ne se refusent plus.
|
||||
- Longues discussions : au chargement, les 20 derniers échanges, le début d'un
|
||||
clic ; la liste des discussions se dessine par pages et montre la nouvelle
|
||||
dès le premier message.
|
||||
- Mémoire : un mode par projet, et un quatrième, « recherche ».
|
||||
- Appels d'outils parallèles séparés selon leur index ; bascule de preset par
|
||||
identifiant ; un raisonnement qui reprend après la réponse a sa propre bulle.
|
||||
- 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.
|
||||
|
||||
## Sécurité et données
|
||||
**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.
|
||||
|
||||
- **Un site tiers ne peut plus piloter Loki** depuis le navigateur (Origin /
|
||||
Sec-Fetch-Site, DNS rebinding). ⚠️ Derrière un reverse proxy par nom de
|
||||
domaine, définis une clé de pilotage ou `LOKI_TRUSTED_HOSTS` **avant** la mise
|
||||
à jour, et vérifie que le proxy transmet l'en-tête `Host` d'origine.
|
||||
- **Chiffrement de la mémoire** : l'activer rendait illisibles la discussion
|
||||
active et le mode Code. Corrigé ; une base déjà touchée est réparée au
|
||||
déverrouillage. Les résultats d'outils (« voir plus ») et les images du fil
|
||||
sont désormais chiffrés eux aussi, et les images effacées après 24 h.
|
||||
## En opt-in : à essayer, à mesurer
|
||||
|
||||
## Moteur
|
||||
| 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 |
|
||||
|
||||
- `--mlock` seul ne coupe plus le mmap (OOM au chargement).
|
||||
- Port déjà occupé refusé avec un message clair ; cache KV lent signalé.
|
||||
- Réponses web compressées en gzip.
|
||||
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.
|
||||
|
||||
## 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.
@@ -2,13 +2,13 @@
|
||||
"FixedFileInfo": {
|
||||
"FileVersion": {
|
||||
"Major": 0,
|
||||
"Minor": 14,
|
||||
"Minor": 15,
|
||||
"Patch": 0,
|
||||
"Build": 0
|
||||
},
|
||||
"ProductVersion": {
|
||||
"Major": 0,
|
||||
"Minor": 14,
|
||||
"Minor": 15,
|
||||
"Patch": 0,
|
||||
"Build": 0
|
||||
},
|
||||
@@ -25,7 +25,7 @@
|
||||
"LegalCopyright": "Copyright (c) 2026 Loki contributors. MIT License.",
|
||||
"OriginalFilename": "loki.exe",
|
||||
"ProductName": "Loki",
|
||||
"ProductVersion": "0.14.0",
|
||||
"ProductVersion": "0.15.0",
|
||||
"Comments": "https://github.com/R0m1k3/Loki — projet open source (MIT)"
|
||||
},
|
||||
"VarFileInfo": {
|
||||
|
||||
@@ -16,8 +16,10 @@
|
||||
# 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 : Compose Down puis Up avec « pull image », ou
|
||||
# « docker compose pull » avant Up.
|
||||
# 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.
|
||||
# ───────────────────────────────────────────────────────────────────────────
|
||||
services:
|
||||
loki:
|
||||
@@ -39,6 +41,9 @@ services:
|
||||
# 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
|
||||
@@ -64,4 +69,17 @@ services:
|
||||
- /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
|
||||
@@ -37,4 +37,8 @@ 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
|
||||
+39
-3
@@ -1,6 +1,7 @@
|
||||
#!/bin/sh
|
||||
# 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.
|
||||
# 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).
|
||||
set -eu
|
||||
|
||||
: "${LOKI_HOME:=/data}"
|
||||
@@ -15,6 +16,33 @@ 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.
|
||||
@@ -59,4 +87,12 @@ else
|
||||
echo "[entrypoint] aucun MODEL configuré — télécharge un modèle depuis l'UI pour démarrer le moteur"
|
||||
fi
|
||||
|
||||
exec loki web "${LOKI_WEB_PORT:-8090}"
|
||||
# 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
|
||||
@@ -0,0 +1,122 @@
|
||||
# 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`).
|
||||
@@ -0,0 +1,150 @@
|
||||
# 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.
|
||||
@@ -0,0 +1,74 @@
|
||||
# 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é.
|
||||
@@ -0,0 +1,49 @@
|
||||
# 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.
|
||||
@@ -0,0 +1,93 @@
|
||||
# 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.
|
||||
@@ -0,0 +1,44 @@
|
||||
# Lot 10 — réglages partagés tenus par la façade
|
||||
|
||||
## Contexte
|
||||
|
||||
Quatre routes ne font que lire et écrire des réglages en base, partagés entre
|
||||
tous les appareils :
|
||||
|
||||
| Route | Port de | Ce qu'elle règle |
|
||||
|---|---|---|
|
||||
| `GET\|POST /api/prefs` | `web_prefs.go` | thème, affichage, prénom (24 caractères, sans caractère de contrôle), avatars (liste fermée) |
|
||||
| `GET\|POST /api/sysprompt` | `web_sysprompt.go` | prompt système du preset actif (`state/sysprompt:<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.
|
||||
@@ -0,0 +1,63 @@
|
||||
# Lot 11 — fichiers de la discussion tenus par la façade
|
||||
|
||||
## Contexte
|
||||
|
||||
Les fichiers rangés par discussion vivaient dans le Go :
|
||||
|
||||
- `web_upload.go` : dépôt, téléchargement, image ;
|
||||
- `web_files.go` : panneau Fichiers ;
|
||||
- `web_files_zip.go` : archive ;
|
||||
- `web_thumb.go` et `web_upload_orient.go` : vignettes ;
|
||||
- `chat_convfiles.go` : chemins.
|
||||
|
||||
Lot 11 : la façade les sert quand `LOKI_FILES=rust` (`convfiles.rs`,
|
||||
expérimental ; sans la variable rien ne change).
|
||||
|
||||
| Route | Rôle |
|
||||
|---|---|
|
||||
| `POST /api/chat/upload` | dépôt en morceaux base64 (8 Mio), fichier de 1 Gio au plus, espace disque vérifié, `.part` renommé à la fin sous un nom sûr et unique |
|
||||
| `GET /api/chat/files` | panneau : dossiers d'abord puis du plus récent au plus ancien, tailles récursives (liens non suivis), vue « hors discussion » |
|
||||
| `POST /api/chat/file/delete` | suppression ; refus de la racine et du dossier des discussions |
|
||||
| `GET /api/chat/file` | fiche, tranche base64 (tunnel E2E) ou brut en pièce jointe (plage d'octets) |
|
||||
| `GET /api/chat/image` | type reniflé sur le contenu, en ligne sous une CSP fermée ; vignette JPEG `thumb=<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.
|
||||
@@ -0,0 +1,47 @@
|
||||
# Lot 12 — notifications Web Push tenues par la façade
|
||||
|
||||
## Contexte
|
||||
|
||||
Les notifications « réponse prête » vivaient dans le Go : `push.go`,
|
||||
`web_push.go` et webpush-go. Lot 12 : la façade les tient quand
|
||||
`LOKI_PUSH=rust` (`push.rs`, expérimental ; sans la variable rien ne change).
|
||||
|
||||
| Route | Rôle |
|
||||
|---|---|
|
||||
| `GET /api/push/key` | clé publique VAPID, générée au premier appel puis stable |
|
||||
| `POST /api/push/subscribe` | enregistre un abonnement (idempotent par endpoint, clés mises à jour) |
|
||||
| `POST /api/push/unsubscribe` | le retire |
|
||||
| `POST /internal/facade/v1/push/send` | (le Go, jeton interne) pousse une notification à tous les abonnés, en tâche de fond |
|
||||
|
||||
Les envois du Go passent tous par `sendPushToAll` : fin de tour du chat Go,
|
||||
fin de tour tenu par la façade (`/internal/v1/turn/done`), fin de tâche. Ils
|
||||
sont délégués à la façade. Clés et abonnements restent en base aux mêmes
|
||||
clés (`push_vapid_priv`, `push_vapid_pub`, `push_subs`).
|
||||
|
||||
Chiffrement RFC 8291 (`aes128gcm`), enregistrement complété à 4096 octets
|
||||
comme webpush-go. Signature VAPID RFC 8292 (JWT ES256, 12 h, sujet
|
||||
« mailto: »). TTL de 120 s, urgence haute. Un abonnement refusé pour de bon
|
||||
(404/410) est purgé. La cryptographie passe par ring, déjà présent via
|
||||
rustls. Une façade construite sans TLS répond que les notifications sont
|
||||
indisponibles.
|
||||
|
||||
## Écarts assumés
|
||||
|
||||
- Messages des journaux d'envoi au format des crates Rust.
|
||||
|
||||
## Parité
|
||||
|
||||
- `internal/loki/parity_push_test.go` tire du Go :
|
||||
- les durées du corps de notification ;
|
||||
- onze appels de route (clé, abonnements valides et refusés, mise à jour
|
||||
des clés, désabonnements), avec la liste en base après chacun.
|
||||
|
||||
`tests/push.rs` rejoue le tout par la façade. La liste en base est
|
||||
comparée à l'octet près, et la clé reste la même d'un appel à l'autre.
|
||||
- Le chiffrement reproduit l'exemple de l'annexe A de la RFC 8291 à l'octet
|
||||
près.
|
||||
- Un envoi de bout en bout vers un faux service de push vérifie :
|
||||
- les en-têtes ;
|
||||
- la signature VAPID, contrôlée avec la clé rangée ;
|
||||
- le corps, déchiffré côté navigateur avec la clé de l'abonnement ;
|
||||
- l'abonnement en 410, purgé.
|
||||
@@ -0,0 +1,117 @@
|
||||
# Lot 13 — mémoire tenue par la façade
|
||||
|
||||
## Contexte
|
||||
|
||||
La mémoire de Loki vivait dans le Go :
|
||||
- `chat_memory.go`, `mem_index.go`, `mem_io.go` et `mem_fsutil.go` pour les pages ;
|
||||
- `moveMemPage` (`projects.go`) pour le déplacement entre projets ;
|
||||
- `tracker.go` et `web_tracker.go` pour les trackers ;
|
||||
- les routes du mode agent.
|
||||
|
||||
Lot 13 : la façade tient ces routes quand `LOKI_MEM=rust` (`memory.rs`,
|
||||
expérimental). Sans la variable, rien ne change.
|
||||
|
||||
| Route | Rôle |
|
||||
|---|---|
|
||||
| `GET /api/mem?name=` | contenu d'une page (modèle de page neuve sans nom) |
|
||||
| `POST /api/mem/save` | crée, écrase ou renomme une page ; l'index suit |
|
||||
| `POST /api/mem/delete` | supprime une page ; sa ligne d'index part avec elle |
|
||||
| `POST /api/mem/move` | déplace une page vers un autre projet ; les deux index suivent |
|
||||
| `GET /api/agent` (alias `/api/tools`, `/api/skills`) | mode agent, compactage, mode mémoire, liste des pages |
|
||||
| `POST /api/agent/toggle` (et alias) | mode agent (`state/agent`) |
|
||||
| `POST /api/agent/compact` | compactage automatique (`COMPACT`) |
|
||||
| `GET|POST /api/memory` | mode mémoire du projet actif, repli sur `MEM_MODE` |
|
||||
| `GET|POST /api/tracker` | panneau des trackers : liste, détail, ajout, modification, suppression, renommage, déplacement |
|
||||
|
||||
### Disque et base
|
||||
|
||||
Les pages restent des fichiers `memory/<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.
|
||||
@@ -0,0 +1,97 @@
|
||||
# Lot 14 — `loki serve` composé en Rust
|
||||
|
||||
## Contexte
|
||||
|
||||
Depuis le lot 3, la supervision du moteur est en Rust : `loki-rs engine
|
||||
start|stop|restart`. Mais elle lançait encore `loki serve` (Go), qui compose
|
||||
la ligne de commande de llama-server avant de lui céder la place.
|
||||
|
||||
Cette ligne vit dans `backend_serve*.go`, environ 4 000 lignes. Chaque option
|
||||
y a été réglée contre un défaut mesuré :
|
||||
- couches GPU et placement automatique (`--fit`, `--fit-target`) ;
|
||||
- threads et sonde de conteneur ;
|
||||
- files de lancement CUDA ;
|
||||
- second slot, parallélisme de tenseurs ;
|
||||
- cache de prompts, points de reprise des hybrides ;
|
||||
- décodage spéculatif (MTP, brouillon, n-grammes) ;
|
||||
- garde-fous de fidélité, garde-fou de chargement (LOAD_GUARD) ;
|
||||
- état des slots gardé à la bascule.
|
||||
|
||||
Lot 14 : `loki-rs engine serve` fait ce travail quand `LOKI_SERVE=rust`
|
||||
(expérimental). Le superviseur le lance à la place de `loki serve`. Sans la
|
||||
variable, rien ne change.
|
||||
|
||||
## Découpage
|
||||
|
||||
- `serve/` : port fonction par fonction. `mod.rs` contient
|
||||
`build_serve_args` et les outils de drapeaux. Les modules suivent les
|
||||
fichiers Go :
|
||||
- `threads`, `queues`, `expert`, `fidelity` ;
|
||||
- `side`, `split`, `cache`, `ckpt` ;
|
||||
- `spec`, `moe`, `persist`.
|
||||
|
||||
Tout ce qui décide est pur.
|
||||
- `serve/run.rs` porte `cmdServe`. Il lit la configuration, la clé d'API, les
|
||||
dossiers de modèles et le jeton de tentative de SPEC=auto par l'API interne
|
||||
du Go. Il sonde ensuite :
|
||||
- l'aide du moteur, `--version`, `--list-devices` (bornés dans le temps,
|
||||
avec les bibliothèques du moteur) ;
|
||||
- le GGUF ;
|
||||
- la RAM, la VRAM (nvidia-smi), le cgroup et le cpuset ;
|
||||
- les variables `LLAMA_ARG_*` et `GGML_*`.
|
||||
|
||||
Il vérifie que le port est libre et écrit le marqueur de SLOT_PERSIST. Il
|
||||
garde la commande en base pour l'interface (`engine_cmdline`), puis fait
|
||||
`exec` du moteur.
|
||||
- La façade passe au Go l'adresse de son API interne (`LOKI_GO_ADDR`), à côté
|
||||
du jeton. Le superviseur, lancé par le Go, en hérite, et `engine serve` aussi.
|
||||
|
||||
## Restent au Go
|
||||
|
||||
`loki serve` (Go) est lancé à notre place dans ces cas, à l'identique :
|
||||
- un preset Strata (son propre serveur, lot à venir) ;
|
||||
- le moteur d'essai de l'optimiseur (`LOKI_TUNE_TRIAL`) ;
|
||||
- l'absence d'API interne (lancement à la main hors de la façade).
|
||||
|
||||
Restent aussi au Go les aperçus de l'éditeur de preset (`/api/preset/cacheram`,
|
||||
`/api/preset/moe`, « Dupliquer en placement auto »). Ils viendront avec le lot
|
||||
des presets.
|
||||
|
||||
## Écarts assumés
|
||||
|
||||
- Le message d'un brouillon (`MODEL_DRAFT`) introuvable cite l'erreur système
|
||||
au format Rust.
|
||||
- Pas de cache de l'aide ni du build par binaire : `engine serve` ne compose
|
||||
qu'une ligne par lancement, le cache du Go ne servait qu'au process web.
|
||||
- Le jeton de tentative de SPEC=auto est lu puis réécrit par l'API interne,
|
||||
sans transaction : le Go seul l'efface, une fois le moteur prêt.
|
||||
|
||||
## Parité
|
||||
|
||||
`internal/loki/parity_serve_test.go` tire du Go 1 872 lancements, chacun
|
||||
avec sa ligne de commande, son environnement et ses notes. Les lancements
|
||||
croisent :
|
||||
- 12 machines (VRAM, RAM, GGUF dense, MoE, hybride, par couches, architecture
|
||||
exclue, brouillons, mémoire unifiée, conteneur à l'étroit) ;
|
||||
- 112 presets ;
|
||||
- 3 aides de moteur (récent, ancien, inconnu) ;
|
||||
- 13 jeux de variables `LLAMA_ARG_*`, et des variables `GGML_*`.
|
||||
|
||||
Le journal comprend aussi les sondes pures :
|
||||
- listes de CPU, cpuset et quotas, mémoire du cgroup ;
|
||||
- sortie de nvidia-smi, `--list-devices`, build du moteur ;
|
||||
- forme copiable de la commande ;
|
||||
- verdicts de SPEC=auto ;
|
||||
- drapeaux de chargement, taille d'état par jeton.
|
||||
|
||||
`tests/serve.rs` rejoue le tout contre `serve::build_serve_args`, argument
|
||||
par argument. Une mutation du calcul du cache, des files CUDA ou des bornes
|
||||
des n-grammes fait échouer le rejeu.
|
||||
|
||||
Deux essais de bout en bout lancent le vrai `loki-rs engine serve` contre un
|
||||
faux Go et un faux llama-server. Ils vérifient :
|
||||
- la ligne exécutée et le chemin des bibliothèques ;
|
||||
- la commande gardée en base ;
|
||||
- le refus d'un port pris, d'un modèle absent ;
|
||||
- la sortie propre d'un preset externe ;
|
||||
- la délégation au Go (sans API interne, essai de l'optimiseur, Strata).
|
||||
@@ -0,0 +1,86 @@
|
||||
# Lot 15 — presets tenus par la façade
|
||||
|
||||
## Contexte
|
||||
|
||||
Les presets vivaient dans le Go :
|
||||
- `backend_presets.go` pour l'écriture ;
|
||||
- les handlers de `web_api.go` et `web_external.go` ;
|
||||
- les aperçus de l'éditeur (`cacheRAMPreview`, `sideSlotPreview`,
|
||||
`moePreview`, `autoPlacementPreset`).
|
||||
|
||||
Lot 15 : la façade tient ces routes quand `LOKI_PRESETS=rust`
|
||||
(`presets_api.rs`, expérimental). Sans la variable, rien ne change.
|
||||
|
||||
| Route | Rôle |
|
||||
|---|---|
|
||||
| `GET /api/presets` | liste, avec pastilles (contexte, vision, quantification, raisonnement, Strata, externe) et mesure du bench si elle correspond à la configuration actuelle |
|
||||
| `POST /api/presets/order` | ordre choisi par glisser-déposer |
|
||||
| `GET /api/preset?id=` | contenu et prompt système ; sans id, la graine d'un preset neuf (BIN personnalisé, HOST, PORT) |
|
||||
| `POST /api/preset/save` | crée (nom de fichier unique) ou met à jour ; le preset EN SERVICE est appliqué tout de suite |
|
||||
| `POST /api/preset/delete` | refusé pour le preset actif ; emporte prompt système et mesure, et le modèle sur demande |
|
||||
| `POST /api/preset/cacheram`, `/api/preset/moe` | aperçus de l'éditeur, calculés comme au lancement (lot 14) |
|
||||
| `POST /api/preset/autoplace` | copie en placement automatique d'un preset MoE placé à la main |
|
||||
| `GET /api/preset/external?id=`, `POST /api/preset/external/save`, `/api/preset/external/test` | presets externes ; la clé n'est jamais rendue, le test fait un vrai appel de complétion |
|
||||
| `POST /api/switch` | bascule de preset |
|
||||
|
||||
Les presets restent des fichiers `presets/*.env`, et leur prompt système une
|
||||
clé de la base. Les aperçus réutilisent le port de `loki serve` (lot 14),
|
||||
sondes comprises.
|
||||
|
||||
## Restent au Go
|
||||
|
||||
Tout ce qui touche au moteur reste au Go, jusqu'au lot qui le portera. Le Go
|
||||
expose deux routes internes pour cela (`facade_presets.go`).
|
||||
|
||||
`POST /internal/v1/preset/apply {path, mode}` applique un preset à la
|
||||
configuration vivante :
|
||||
- `switch` : l'état du slot est relevé avant (SLOT_PERSIST), puis le moteur
|
||||
est relancé, ou arrêté pour un preset externe ;
|
||||
- `save` : application, puis relance ;
|
||||
- `apply` : la configuration seule (preset externe en service modifié).
|
||||
|
||||
`GET /internal/v1/tune/guard` dit si une optimisation tient le moteur. La
|
||||
réponse est alors un 409 `{ok: false, tuning: true}`, comme le Go.
|
||||
|
||||
## Écarts assumés
|
||||
|
||||
- La suppression d'un preset retire sa mesure sans réécrire les autres avec
|
||||
leurs champs à zéro, comme le faisait la struct du Go. Le Go les relit
|
||||
pareil.
|
||||
- Le message d'un échec de réseau du test externe est celui de reqwest.
|
||||
|
||||
## Parité
|
||||
|
||||
`internal/loki/parity_presets_test.go` tire du Go deux séries.
|
||||
|
||||
Les fonctions pures :
|
||||
- nom d'affichage, guillemets, format `.env`, noms refusés ;
|
||||
- quantification, raisonnement, modèle ;
|
||||
- URL de complétions, contenu d'un preset externe, message d'erreur d'API ;
|
||||
- arguments rejoints, clé réécrite dans un preset ;
|
||||
- huit copies en placement auto ;
|
||||
- empreinte de mesure, moteur imposé par un preset.
|
||||
|
||||
Puis 70 appels de route, avec après chacun les dossiers `presets/` et
|
||||
`models/`, et les clés touchées en base. Ils couvrent :
|
||||
- créations, doublons, noms refusés, erreurs JSON ;
|
||||
- ordre, pastilles et mesures (empreinte, repli sur le modèle, autre
|
||||
configuration) ;
|
||||
- preset actif et suppressions (modèle compris, partagé, absent) ;
|
||||
- presets externes et leur test contre un faux service (succès, 401 au
|
||||
format OpenAI, 404 brut, 500 vide) ;
|
||||
- placement auto et aperçus.
|
||||
|
||||
`tests/presets_api.rs` rejoue le tout par la façade, et une mutation fait
|
||||
échouer le rejeu.
|
||||
|
||||
La bascule et l'enregistrement du preset en service sont vérifiés de bout en
|
||||
bout contre le faux Go :
|
||||
- appels d'application, avec leur mode ;
|
||||
- refus quand une optimisation tourne ;
|
||||
- erreur d'application rendue ;
|
||||
- prompt système.
|
||||
|
||||
Côté Go, `TestFacadePresetApply` vérifie `presetApply` : configuration
|
||||
appliquée, appareils préservés, moteur relancé, chemin hors du dossier
|
||||
refusé.
|
||||
@@ -0,0 +1,90 @@
|
||||
# Lot 16 — pilotage du moteur tenu par la façade
|
||||
|
||||
## Contexte
|
||||
|
||||
Le Go pilotait le moteur depuis l'interface :
|
||||
- `svcHandler` (`web_api.go`) pour `/api/start`, `/api/stop` et
|
||||
`/api/restart` ;
|
||||
- `handleVramUnload` et `handleVramReload` (`web_vram.go`) pour rendre la
|
||||
carte graphique à la machine, puis la reprendre ;
|
||||
- `serviceAction` (`sys_service.go`) : le verrou de l'optimiseur, puis, en
|
||||
conteneur, le superviseur Rust `loki-rs engine <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.
|
||||
@@ -0,0 +1,92 @@
|
||||
# Lot 17 — interrupteurs système tenus par la façade
|
||||
|
||||
## Contexte
|
||||
|
||||
Quatre réglages de l'interface vivaient dans le Go :
|
||||
- l'accès internet de l'IA : `handleInternet`, `chat_internet.go`,
|
||||
`web_fetch_go.go` (moteur web, serveur Crawl4AI, clé, joignabilité) ;
|
||||
- le pilotage du navigateur : `handleComputer`, `chromePath`,
|
||||
`visionEnabled` ;
|
||||
- la clé de complétion du moteur : `handleAPIKey`, `backend_config.go`,
|
||||
`oaiBaseURL` ;
|
||||
- l'écoute réseau du moteur : `handleNetwork`, `sys_network.go`.
|
||||
|
||||
Lot 17 : la façade tient ces routes quand `LOKI_SYSTEM=rust` (`system.rs`,
|
||||
expérimental). Sans la variable, rien ne change.
|
||||
|
||||
| Route | Rôle |
|
||||
|---|---|
|
||||
| `GET\|POST /api/internet` | interrupteur, moteur web (`go` ou `crawl4ai`), URL et clé du serveur Crawl4AI (la clé n'est jamais rendue, seulement ses quatre derniers caractères), joignabilité |
|
||||
| `GET\|POST /api/computer` | interrupteur du navigateur, binaire trouvé, vision du modèle |
|
||||
| `GET\|POST /api/apikey` | clé de complétion : génération, saisie, effacement ; adresse `/v1` à coller dans un client tiers |
|
||||
| `GET\|POST /api/network` | moteur ouvert au réseau ou à cette machine seulement (`HOST`), adresse de l'endpoint |
|
||||
|
||||
Les réglages restent des clés de la base, écrites comme le Go : une chaîne
|
||||
vide efface la clé, et l'URL comme les clés ne laissent rien traîner dans la
|
||||
configuration.
|
||||
|
||||
La joignabilité de Crawl4AI suit `crawlReachable` :
|
||||
- `/health`, puis `/`, en moins de deux secondes en tout ;
|
||||
- clé envoyée en `Authorization: Bearer` ;
|
||||
- moins de 500 = joignable ;
|
||||
- cache de 30 s par URL, vidé quand l'URL, la clé ou le moteur changent.
|
||||
|
||||
## Restent au Go
|
||||
|
||||
Le Go expose trois routes internes (`facade_system.go`) :
|
||||
- `POST /internal/v1/web/reach/reset` : le cache de joignabilité des outils
|
||||
web, encore exécutés par le Go, est vidé à chaque changement ;
|
||||
- `POST /internal/v1/cdp/shutdown` : le Chromium des outils `browser_*` se
|
||||
ferme quand l'interrupteur se coupe ;
|
||||
- `POST /internal/v1/service/action {action, if_active}` : une clé neuve ne
|
||||
s'applique qu'au redémarrage du moteur. La façade le relance elle-même si
|
||||
elle le pilote (lot 16), sinon le Go, seulement s'il tourne.
|
||||
|
||||
`GET /internal/v1/tune/guard` (lot 15) refuse un changement de clé pendant une
|
||||
optimisation.
|
||||
|
||||
Le relais du tunnel vers la façade garde désormais l'hôte et
|
||||
`X-Forwarded-Proto` du client : l'adresse de la clé de complétion est celle
|
||||
que le client a tapée, pas celle de la façade.
|
||||
|
||||
## Correction en passant
|
||||
|
||||
`maskAPIKey` paniquait sur une clé d'un seul octet (`k[len(k)-2:]`).
|
||||
`/api/apikey` tombait alors à chaque lecture tant que la clé restait en place.
|
||||
Une clé d'un ou deux octets s'affiche maintenant « … », côté Go comme côté
|
||||
Rust.
|
||||
|
||||
## Écarts assumés
|
||||
|
||||
- Hors Windows, aucun pare-feu n'est piloté : la façade ne tourne qu'en
|
||||
conteneur Linux, où le Go répondait déjà « inconnu ».
|
||||
- La clé masquée est coupée sur les octets, comme le Go. Un caractère coupé
|
||||
devient U+FFFD, octet par octet, comme son encodeur JSON.
|
||||
|
||||
## Parité
|
||||
|
||||
`internal/loki/parity_system_test.go` tire du Go 61 appels de route, avec
|
||||
après chacun les clés touchées en base et les en-têtes reçus par trois faux
|
||||
serveurs Crawl4AI (200, 500, 404). Ils couvrent :
|
||||
- moteurs inconnus, URL avec et sans slashs, injoignable, sans schéma ;
|
||||
- clés longues, courtes, d'un caractère, résiduelle en configuration ;
|
||||
- erreurs de type qui laissent passer les autres champs, `null` ;
|
||||
- vision par projecteur, preset externe ou Strata ;
|
||||
- clé générée (masquée dans le journal), saisie, de treize octets, repli sur
|
||||
`config.env`, adresse publique, `X-Forwarded-Proto`, refus pendant une
|
||||
optimisation ;
|
||||
- écoute ouverte, fermée, `localhost`, `::1`, port illisible.
|
||||
|
||||
Il tire aussi huit dispositions du dossier de Playwright pour le Chromium
|
||||
retenu.
|
||||
|
||||
`tests/system.rs` rejoue le tout par une seule façade, cache de
|
||||
joignabilité compris. Il vérifie de bout en bout ce qui reste au Go : cache
|
||||
vidé, navigateur fermé, moteur relancé par le Go, ou par le superviseur
|
||||
quand la façade pilote le moteur. Une mutation de chaque règle fait échouer
|
||||
les tests.
|
||||
|
||||
Côté Go :
|
||||
- `TestFacadeSysteme` vérifie le relais et les trois routes internes ;
|
||||
- `TestFacadeRelaisGardeHote` vérifie l'hôte gardé ;
|
||||
- `TestMaskAPIKeyCourte` vérifie le masque des clés courtes.
|
||||
@@ -0,0 +1,78 @@
|
||||
# Lot 18 — hub des projets tenu par la façade
|
||||
|
||||
## Contexte
|
||||
|
||||
Le hub des projets vivait dans le Go :
|
||||
- `handleProjects` et `projectDTOs` (`web_projects.go`) ;
|
||||
- les mutations de `projects.go` : `createProject`, `renameProject`,
|
||||
`setProjectDesc`, `deleteProject` ;
|
||||
- `projectSwitch` (`chat_sessions.go`).
|
||||
|
||||
Lot 18 : la façade tient `/api/projects` quand `LOKI_PROJECTS=rust`
|
||||
(`projects_api.rs`, expérimental). Sans la variable, rien ne change.
|
||||
|
||||
| Action | Rôle |
|
||||
|---|---|
|
||||
| `GET` | les projets, le plus ancien d'abord, avec leurs discussions, pages (`.md` visibles, index exclu) et trackers comptés ; le projet actif |
|
||||
| `create` | slug translittéré (`Réseau NAS` → `reseau-nas`), unique (`-2`, `-3`…), `projet` à défaut, 40 octets au plus ; nom de 60 runes au plus ; dossier `memory/<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.
|
||||
@@ -0,0 +1,65 @@
|
||||
# Lot 19 — export, mode Code et critères de la discussion
|
||||
|
||||
## Contexte
|
||||
|
||||
Avec `LOKI_CHAT=rust`, la façade tient la conversation (lot 6), mais trois
|
||||
routes de la discussion ouverte restaient au Go :
|
||||
- `/api/chat/export` (`chat_export.go`). Il relisait la copie enregistrée,
|
||||
qui peut avoir un temps de retard sur la conversation vivante ;
|
||||
- `/api/chat/mode` (`code_mode.go`) : mode Chat ou Code de la discussion, et
|
||||
ses critères ;
|
||||
- `/api/chat/criteria` : critères édités à la main.
|
||||
|
||||
Lot 19 : ces trois routes rejoignent celles de la conversation. La façade
|
||||
les sert quand elle la tient (`chatx.rs`, `facadeChatRoutes` côté Go), sans
|
||||
nouvelle variable. Sans `LOKI_CHAT=rust`, rien ne change.
|
||||
|
||||
| Route | Rôle |
|
||||
|---|---|
|
||||
| `GET /api/chat/export` | Markdown (fait pour être lu) ou JSON (fait pour être retraité), tiré de la conversation vivante ; options `format`, `reasoning`, `tools`, `results`, `turns` |
|
||||
| `GET\|POST /api/chat/mode` | mode de la discussion ouverte (`chat/mode:<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.
|
||||
@@ -0,0 +1,93 @@
|
||||
# Lot 20 — serveurs MCP tenus par la façade
|
||||
|
||||
## Contexte
|
||||
|
||||
Le Go tenait les serveurs MCP (Model Context Protocol) :
|
||||
- `mcp_config.go` : la configuration, dans `state/mcp` ;
|
||||
- `mcp_catalog.go` : le catalogue embarqué ;
|
||||
- `mcp_client.go` : le pool de sessions, avec le SDK MCP Go ;
|
||||
- les routes `/api/mcp*` de `web_api.go`.
|
||||
|
||||
Le pool sert deux usages : l'état affiché dans l'interface, et les outils
|
||||
`mcp__<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.
|
||||
@@ -0,0 +1,26 @@
|
||||
# Lot 21 — mise à jour de Loki tenue par la façade
|
||||
|
||||
## Contexte
|
||||
|
||||
`/api/update` (version disponible) et `/api/update/apply` (téléchargement et
|
||||
remplacement du binaire) vivaient dans `sys_update.go`. En conteneur
|
||||
(`LOKI_CONTAINER=1`), le Go n'y fait rien. L'image se met à jour par Docker,
|
||||
et les deux routes le disent (`containerUpdateNote`) :
|
||||
- `/api/update` : `{current, available: false, note}` ;
|
||||
- `/api/update/apply` : 409 `{ok: false, error: note}`.
|
||||
|
||||
## Le lot
|
||||
|
||||
La façade ne tourne qu'en conteneur. Elle tient ces deux routes quand
|
||||
`LOKI_UPDATE=rust` (`sysupdate.rs`, expérimental) et `LOKI_CONTAINER=1`.
|
||||
Sinon `loki-rs` ne passe pas la variable au Go, qui garde le téléchargement
|
||||
et le remplacement du binaire hors conteneur.
|
||||
|
||||
La version annoncée est celle du Go (`/internal/v1/auth`). Les binaires de
|
||||
la façade suivent toujours `loki-rs self-update`.
|
||||
|
||||
## Parité
|
||||
|
||||
`internal/loki/parity_update_test.go` tire du Go les quatre réponses en
|
||||
conteneur (GET et POST sur chaque route). `tests/sysupdate.rs` les rejoue par
|
||||
la façade, version du Go comprise.
|
||||
@@ -0,0 +1,111 @@
|
||||
# Lot 22 — backends llama.cpp et job des moteurs tenus par la façade
|
||||
|
||||
## Contexte
|
||||
|
||||
Le panneau Moteur s'appuie sur une famille de routes du Go :
|
||||
- `web_llamacpp.go` : statut, vérification git, bascule fast/opt, job ;
|
||||
- `backend_prebuilt.go` : binaires précompilés officiels ;
|
||||
- `web_api.go` : backends custom et leur désinstallation ;
|
||||
- `web_devices.go` : GPU vus par un moteur.
|
||||
|
||||
Elles partagent **un** job (`lcJob`) avec la mise à jour du moteur de l'image
|
||||
(`web_engine.go`) et l'installation de Strata (`backend_strata.go`). L'entête
|
||||
du job est en base (`state/build_job`), ses lignes dans
|
||||
`$LOKI_HOME/loki-build.log`. Au démarrage, un job noté « en cours » est déclaré
|
||||
interrompu (`lcRestore`).
|
||||
|
||||
## Le lot
|
||||
|
||||
Avec `LOKI_BACKENDS=rust` (expérimental), la façade (`rust/loki-rs/src/backends`)
|
||||
sert les routes suivantes :
|
||||
|
||||
| Route | Rôle |
|
||||
| --- | --- |
|
||||
| `/api/llamacpp` | statut : dépôt git, binaire, moteur de l'image, précompilé, moteur téléchargé, plan de build, conseil, job |
|
||||
| `/api/llamacpp/check` | `git fetch`, puis le retard sur origin |
|
||||
| `/api/llamacpp/prebuilt/check` | dernière release officielle comparée à la version installée |
|
||||
| `/api/llamacpp/prebuilt` | job : téléchargement, extraction (liens compris), VERSION, ménage, BIN, service arrêté puis relancé |
|
||||
| `/api/llamacpp/use` | bascule fast/opt, refusée pendant une optimisation |
|
||||
| `/api/llamacpp/job` | progression et lignes à partir d'un décalage |
|
||||
| `/api/llamacpp/uninstall-custom` | désinstallation d'un backend custom |
|
||||
| `/api/backends`, `/api/backends/custom` | les backends présents |
|
||||
| `/api/backends/devices` | GPU vus par un moteur, avec un cache mémoire et un cache disque |
|
||||
|
||||
**Le job vit dans la façade** (`backends/job.rs`) : même entête en base, même
|
||||
journal, même reprise au démarrage. Les jobs que le Go fait encore tourner lui
|
||||
rapportent chaque étape par l'API interne `/internal/facade/v1/build-job/*`
|
||||
(`start`, `line`, `phase`, `fail`, `done`, `commits`, `end`, `snapshot`).
|
||||
Ces jobs sont :
|
||||
- la compilation depuis les sources (`install`, `update`, `install-custom`,
|
||||
relayées au Go qui garde son refus 409 quand l'image fournit le moteur) ;
|
||||
- la mise à jour du moteur de l'image ;
|
||||
- Strata.
|
||||
|
||||
Côté Go (`facade_backends.go`), quand la façade tient le lot :
|
||||
- `startLcJob`, `lcAppend`, `lcPhase`, `lcFail`, `lcDone`, `lcSetCommit` et
|
||||
`lcJobSnapshot` passent par la façade ;
|
||||
- le garde de l'optimiseur reste au Go, avant le démarrage ;
|
||||
- le Go ne restaure plus de job au démarrage ;
|
||||
- les routes reçues par le tunnel sont relayées à la façade.
|
||||
|
||||
Pour arrêter et relancer le service, la façade pilote le moteur si elle le
|
||||
tient (lot 16), sinon elle le demande au Go (`/internal/v1/service/action`,
|
||||
avec `if_active`). Les sondes de la machine passent par un PATH et une racine
|
||||
injectables (`backends/probe.rs`) :
|
||||
- `nvidia-smi`, `nvcc`, `hipcc`, `/opt/rocm` ;
|
||||
- `glslc`, `vulkaninfo`, `ldconfig` et les chemins de `libvulkan`.
|
||||
|
||||
## Correction du Go
|
||||
|
||||
`prebuiltVersionFormat` lisait le format comme 3ᵉ champ du marqueur VERSION.
|
||||
Hors CUDA Windows, la version CUDA est vide : le marqueur
|
||||
« `b300 fmt2` » n'a alors que deux champs. Le format n'était donc jamais
|
||||
reconnu, et chaque vérification retéléchargeait la release sous Linux et
|
||||
macOS au lieu de dire « déjà à jour ». Le format est désormais le dernier
|
||||
champ, et la version CUDA ignore ce champ. Le port Rust suit.
|
||||
|
||||
## Parité
|
||||
|
||||
`internal/loki/parity_backends_test.go` (Linux) tire du Go un journal rejoué
|
||||
par `tests/backends.rs`. Le journal est sauté si `/usr/local/cuda*` ou
|
||||
`/opt/rocm` existent sur la machine.
|
||||
|
||||
Le journal contient :
|
||||
- **le statut** dans tous ses états :
|
||||
- dépôt git reproductible (dates et identité fixées), retard connu ou vérifié, tête détachée ;
|
||||
- origine perdue, BIN dans un fork ;
|
||||
- précompilé versionné, moteur téléchargé, moteur de l'image ;
|
||||
- **les backends custom** et chaque refus de désinstallation ;
|
||||
- **la bascule fast/opt** ;
|
||||
- **la dernière release** :
|
||||
- refus HTTP, tag vide, JSON illisible ;
|
||||
- aucun asset adapté ;
|
||||
- **le job précompilé** :
|
||||
- installation, « déjà à jour », ancien format réextrait ;
|
||||
- optimisation en cours, GitHub qui refuse ;
|
||||
- archive absente, archive piégée (`../`), archive sans serveur ;
|
||||
- **les GPU** :
|
||||
- liaison PCIe, mémoire complétée par nvidia-smi ;
|
||||
- cache, mémoire nulle non figée, repli sur le disque ;
|
||||
- moteur planté, tué ou non exécutable, pilote ancien, cartes de même nom ;
|
||||
- **après chaque appel** : BIN, entête en base, journal, `devices.json` et l'arborescence de `backends/` et `engine/` ;
|
||||
- **machine par machine** (dix fausses machines) : le plan de build, le conseil et la variante de release ;
|
||||
- **le job pas à pas** :
|
||||
- phases déduites des lignes de build, troncature à 4000 lignes, vues à partir d'un décalage ;
|
||||
- écritures en base limitées à une toutes les 2 s ;
|
||||
- refus d'un second job ;
|
||||
- reprise au démarrage dans tous les cas : absent, illisible, fini, interrompu, journal vide ou trop long.
|
||||
|
||||
Autres tests :
|
||||
- le Go (`TestFacadeBackends`) vérifie que ses jobs rapportent chaque étape à
|
||||
la façade, dans l'ordre, et que la compilation n'est pas relayée ;
|
||||
- la façade (`api_interne_du_job`, `service_arrete_puis_relance`) vérifie
|
||||
l'API interne et le service arrêté puis relancé autour de l'installation.
|
||||
|
||||
## Écarts assumés
|
||||
|
||||
- Les erreurs réseau (GitHub injoignable) gardent le texte de `reqwest`.
|
||||
- Quand un asset de la release a un champ de mauvais type, le message n'est
|
||||
pas celui du Go.
|
||||
- `git fetch` lancé par `/api/llamacpp/check` pendant un job du Go n'écrit
|
||||
plus ses lignes dans ce job.
|
||||
@@ -0,0 +1,85 @@
|
||||
# Lot 23 — sonde de gabarit portée en Rust
|
||||
|
||||
## Contexte
|
||||
|
||||
La sonde de gabarit (`chat_tplprobe.go`) dit ce que le gabarit de chat du
|
||||
modèle chargé fait de ce qu'on lui envoie :
|
||||
|
||||
- `preserves_history` : le raisonnement d'un tour passé est-il encore rendu ?
|
||||
- `prefix_stable` : après un tour d'outil, le rendu précédent reste-t-il un
|
||||
préfixe exact du suivant ?
|
||||
- `renders_reasoning` : `reasoning_content` est-il rendu quelque part ?
|
||||
|
||||
Le Go la lance après chaque complétion du fil principal, avec les outils et
|
||||
les `chat_template_kwargs` de la requête. Trois choses lisent son verdict :
|
||||
|
||||
- la politique d'écho du raisonnement (`REASONING_ECHO`) ;
|
||||
- le rappel de budget glissé dans le résultat d'outil (`NUDGE_IN_TOOL`) ;
|
||||
- le résumé des performances (`/api/perf/summary`).
|
||||
|
||||
Le relevé du rendu (`tplRenderPrint`) et sa comparaison (`tplRenderDiff`)
|
||||
servent le panneau Moteur, autour d'une mise à jour.
|
||||
|
||||
Quand la façade tenait la conversation (`LOKI_CHAT=rust`), plus aucune sonde
|
||||
ne tournait :
|
||||
|
||||
- l'écho décidait sans verdict ;
|
||||
- `NUDGE_IN_TOOL` gardait la conversation au Go (`facadeChatBlocker`).
|
||||
|
||||
## Le lot
|
||||
|
||||
`rust/loki-rs/src/chat/tplprobe.rs` porte la sonde entière :
|
||||
|
||||
- les requêtes au moteur local (`/health`, `/props`, `/apply-template`, avec la
|
||||
clé du moteur et les relances sur 503) ;
|
||||
- les rendus synthétiques et leurs verdicts ;
|
||||
- le cache par build, modèle, gabarit et forme ;
|
||||
- le dernier verdict, global et par forme ;
|
||||
- la ligne de journal ;
|
||||
- le déclenchement borné (une sonde en vol au plus, une par minute) ;
|
||||
- le relevé du rendu et sa comparaison.
|
||||
|
||||
Avec `LOKI_CHAT=rust` :
|
||||
|
||||
- **la boucle du tour** signale chaque complétion lue en entier sur le moteur
|
||||
local, outils ni coupés ni neutralisés (`TurnHost::completed`). L'hôte lance
|
||||
la sonde pour le fil principal seulement : ni tâche ni sous-agent ;
|
||||
- **la politique d'écho** lit le dernier verdict ;
|
||||
- **`NUDGE_IN_TOOL`** est porté (`nudge_in_tool_key`, `nudge_into_tool`,
|
||||
`TurnHost::prefix_unstable`). Il ne garde plus la conversation au Go ;
|
||||
- **le résumé des performances**, que sert encore le Go, relit le verdict de la
|
||||
façade (`/internal/facade/v1/chat/tpl`, `facadeTplCaps`).
|
||||
|
||||
## Parité
|
||||
|
||||
`internal/loki/parity_tplprobe_test.go` enregistre chaque échange de la sonde
|
||||
du Go avec un faux moteur : méthode, chemin, clé, corps envoyé, réponse.
|
||||
|
||||
Les comportements de moteur couverts :
|
||||
|
||||
- gabarit Qwen3, gabarit qui garde tout, raisonnement jamais rendu, amorce
|
||||
ignorée ;
|
||||
- 404, exception du gabarit, clé exigée (inconnue ou connue) ;
|
||||
- 503 relancés ou persistants, 429 ;
|
||||
- `/props` illisible, moteur pas prêt, réponse sans prompt ;
|
||||
- rendus incohérents, preset externe.
|
||||
|
||||
Le journal contient aussi :
|
||||
|
||||
- le verdict, sa ligne de journal, `tplCapsCurrent` et `tplCapsFor` ;
|
||||
- le cache : même moteur, gabarit changé, build changé ;
|
||||
- le relevé du rendu sur trois gabarits, et une erreur ;
|
||||
- neuf comparaisons de relevés ;
|
||||
- l'empreinte de six formes.
|
||||
|
||||
`tests/tplprobe.rs` rejoue chaque échange contre le port. Le faux moteur rend
|
||||
les réponses enregistrées et exige des requêtes identiques, octet pour octet.
|
||||
Il vérifie aussi le déclenchement borné.
|
||||
|
||||
Autres tests :
|
||||
|
||||
- `tests/lot6.rs` vérifie la sonde lancée après un vrai tour de la façade ;
|
||||
- les tests de la boucle vérifient le rappel dans le résultat d'outil et les
|
||||
complétions signalées ;
|
||||
- `TestFacadeSondeDeGabarit` vérifie, côté Go, que le résumé des performances
|
||||
relit le verdict de la façade.
|
||||
@@ -0,0 +1,130 @@
|
||||
# Lot 24 — panneau « moteur de l'image » tenu par la façade
|
||||
|
||||
## Contexte
|
||||
|
||||
Le panneau Moteur permet de mettre à jour llama.cpp sans reconstruire
|
||||
l'image. Le moteur est pris dans l'image de conteneur officielle
|
||||
(`ghcr.io/ggml-org/llama.cpp:server-cuda-b#####`), dont on ne télécharge que
|
||||
les couches du sommet, celles qui portent `/app`. Trois fichiers du Go le
|
||||
servent :
|
||||
|
||||
- `web_engine.go` : statut, dernier build publié, mise à jour, bascule,
|
||||
suppression ;
|
||||
- `web_engine_reco.go` : version recommandée, risque de rendu du
|
||||
raisonnement (défaut de b10763), retour à la version précédente,
|
||||
vérification du rendu du gabarit avant/après la bascule ;
|
||||
- `backend_engine_oci.go` : dialogue avec le registre, extraction des
|
||||
couches, essai à blanc.
|
||||
|
||||
La mise à jour est un job, le job des moteurs que la façade tient depuis le
|
||||
lot 22. Le risque de rendu et la vérification du rendu reposent sur la sonde
|
||||
de gabarit, portée au lot 23.
|
||||
|
||||
## Le lot
|
||||
|
||||
Avec `LOKI_ENGINE_IMAGE=rust` **et** `LOKI_BACKENDS=rust` (expérimental), la
|
||||
façade (`rust/loki-rs/src/engineimg`) sert les routes suivantes :
|
||||
|
||||
| Route | Rôle |
|
||||
| --- | --- |
|
||||
| `/api/engine` | statut : binaire, build, source, variante, versions installées, avis, encart « version recommandée », version précédente, relevé du rendu |
|
||||
| `/api/engine/check` | dernier build publié pour la variante, comparé au courant |
|
||||
| `/api/engine/update` | job : recherche, téléchargement des couches du sommet, essai à blanc, REASONING_PRESERVE=off si demandé, bascule, ménage, vérification du rendu |
|
||||
| `/api/engine/use` | bascule vers une version installée ou vers le moteur de l'image |
|
||||
| `/api/engine/remove` | suppression d'une version installée (refusée pour celle qui tourne) |
|
||||
| `/api/engine/plan` | sur clic : version visée (dernière ou recommandée) et risque de rendu |
|
||||
| `/api/engine/rollback` | retour au moteur d'avant la dernière bascule |
|
||||
|
||||
Sans les backends, loki-rs ne passe pas la variable au Go : le panneau
|
||||
reste au Go et le dit au démarrage. `/api/engine/cmdline` reste au Go.
|
||||
|
||||
La sonde de gabarit est celle de la conversation quand la façade la tient
|
||||
(`LOKI_CHAT=rust`) : le relevé du rendu rejoue alors la forme de ses vraies
|
||||
requêtes. Sinon, la façade a sa propre sonde, qui sonde une requête nue.
|
||||
|
||||
Pour REASONING_PRESERVE=off posée dans le preset actif, la façade réutilise
|
||||
l'enregistrement du lot 15 (`save_applying`). Le service est arrêté puis
|
||||
relancé comme au lot 22 : par la façade si elle pilote le moteur, sinon par
|
||||
le Go (`/internal/v1/service/action`).
|
||||
|
||||
Côté Go, `facade_engineimg.go` relaie à la façade les routes reçues par le
|
||||
tunnel.
|
||||
|
||||
## Parité
|
||||
|
||||
`internal/loki/parity_engineimg_test.go` (Linux) tire du Go un journal
|
||||
rejoué par `tests/engineimg.rs`. Il tourne contre :
|
||||
|
||||
- un faux registre OCI : jeton, index multi-architecture, manifeste annoté,
|
||||
couches .tar.gz ;
|
||||
- un faux moteur local (le `fakeTpl` du Go, porté) pour la sonde et le rendu.
|
||||
|
||||
Le journal couvre :
|
||||
|
||||
- **le statut** : moteur de l'image, téléchargé ou compilé, avec la version
|
||||
précédente (absente quand c'est le moteur courant), le relevé du rendu,
|
||||
sans moteur d'image, sans aucun moteur ;
|
||||
- **le dernier build** : publié, sans numéro, ou refusé (jeton refusé ou
|
||||
illisible, manifeste refusé ou illisible, aucune image pour
|
||||
l'architecture) ;
|
||||
- **le plan** :
|
||||
- dernière version (avec ou sans numéro) ou recommandée (build trop
|
||||
ancien, tag figé absent, sans numéro), et déjà à jour ;
|
||||
- chaque verdict du risque de rendu : moteur arrêté, gabarit vide,
|
||||
REASONING_PRESERVE posée, EXTRA_ARGS, gabarit sans les variables, gabarit
|
||||
qui garde la réflexion, build antérieur au défaut, preset externe ;
|
||||
- **la mise à jour** :
|
||||
- couches du sommet vers la base ; une couche du dessus masque celle du
|
||||
dessous ; liens absolus ramenés dans `/app` ; marqueurs de suppression et
|
||||
sous-dossiers ignorés ; couche sans fichier du moteur ; couche du runtime
|
||||
ignorée ; phase du téléchargement ;
|
||||
- essai à blanc ;
|
||||
- risque de rendu rappelé dans le journal, établi ou impossible à vérifier
|
||||
(moteur local arrêté) ;
|
||||
- REASONING_PRESERVE=off : posée dans le preset actif ou dans la
|
||||
configuration, déjà posée, ou inconnue du moteur ;
|
||||
- ménage : versions triées par build, dossier sans moteur épargné, version
|
||||
précédente gardée ;
|
||||
- rendu comparé avant/après : changé, identique, ou impossible sur le
|
||||
nouveau moteur ;
|
||||
- déjà à jour, corps illisible ;
|
||||
- **les échecs** :
|
||||
- optimisation en cours, version inconnue, image sans serveur ou sans
|
||||
version annoncée, manifeste sans couche, runtime au sommet, couche
|
||||
introuvable, tag illisible ;
|
||||
- moteur qui ne démarre pas (12 dernières lignes de sa sortie) ou qui ne
|
||||
voit aucune carte ;
|
||||
- **les bascules** : vers une version installée, vers l'image, vers une
|
||||
version absente, pendant une optimisation ; retour, deux fois, et en GET ;
|
||||
- **la suppression** : version (`../` neutralisé), version absente, moteur
|
||||
utilisé ;
|
||||
- **après chaque appel** : BIN, REASONING_PRESERVE, `engine/previous-bin` et
|
||||
l'arborescence de `engine/`.
|
||||
|
||||
Autres tests :
|
||||
|
||||
- la façade vérifie le service arrêté puis relancé autour d'une mise à jour
|
||||
et redémarré par une bascule (`service_arrete_puis_relance`), le relevé
|
||||
« non vérifié à temps », le 503 sans backends, les variantes et les
|
||||
vérifications périmées ;
|
||||
- le Go (`TestFacadeMoteurImage`) vérifie que les sept routes partent à la
|
||||
façade, et elles seulement.
|
||||
|
||||
## Mutations
|
||||
|
||||
74 mutations du port Rust, toutes tuées sauf trois, équivalentes :
|
||||
|
||||
- la garde « moteur d'image absent » de la source : `same_path` ne
|
||||
rapproche jamais un chemin vide ;
|
||||
- l'essai à blanc qui relance l'inventaire des cartes quand l'ancien moteur
|
||||
est le nouveau : même résultat ;
|
||||
- une vérification du rendu périmée qui continue d'attendre son moteur : son
|
||||
relevé est de toute façon écarté au rangement.
|
||||
|
||||
## Écarts assumés
|
||||
|
||||
- Les erreurs réseau (registre injoignable) gardent le texte de `reqwest`.
|
||||
- Un manifeste dont un champ a le mauvais type ne donne pas le message du
|
||||
Go : le nom Go du type anonyme n'est pas reproduit.
|
||||
- La vérification du rendu attend son délai à la cadence choisie (5 s), sans
|
||||
le contexte Go qui coupe aussi une requête en cours au bout de 20 min.
|
||||
@@ -0,0 +1,121 @@
|
||||
# Lot 25 — Strata tenu par la façade
|
||||
|
||||
## Contexte
|
||||
|
||||
Strata est le deuxième moteur de Loki. Il répartit les experts d'un MoE de
|
||||
125 B (famille Qwen3.8-Flash-Next) entre VRAM, RAM et disque. Le moteur est
|
||||
le paquet **figé** publié par AJEAN : les sources et le binaire Linux CUDA 12,
|
||||
vérifiés par SHA-256. Un preset le choisit (`ENGINE=strata`).
|
||||
|
||||
`backend_strata.go` sert trois routes :
|
||||
|
||||
- `/api/strata` : la machine (cartes, RAM, disque, Python), ce qui y tient,
|
||||
la recommandation par version et les modèles installés, avec leur
|
||||
configuration de lancement en clair ;
|
||||
- `/api/strata/settings` : les réglages d'un modèle installé. S'il est en
|
||||
service, la configuration est réappliquée et le moteur relancé ;
|
||||
- `/api/strata/install` : un job des moteurs. Il télécharge et vérifie le
|
||||
paquet, lance l'installeur de Strata, écrit le preset et bascule dessus.
|
||||
|
||||
Le lancement (`loki serve` d'un preset Strata) vit aussi dans ce fichier.
|
||||
|
||||
## Le lot
|
||||
|
||||
Avec `LOKI_STRATA=rust` **et** `LOKI_BACKENDS=rust` (expérimental), la façade
|
||||
(`rust/loki-rs/src/strata`) sert les trois routes :
|
||||
|
||||
- **la décision pure** (`strata/mod.rs`) :
|
||||
- placement des experts : drop, ram, arena ou disk, selon la RAM
|
||||
disponible, la carte d'appoint et `experts.bin` ;
|
||||
- configuration de lancement : réglages validés, carte d'aide, mode des
|
||||
experts, réglages du preset ;
|
||||
- ce qui tient sur la machine, la recommandation, les réglages, les noms ;
|
||||
- **la machine** (`strata/api.rs`) :
|
||||
- cartes via `nvidia-smi` (la sonde des backends) ;
|
||||
- Python 3.10+ et son module venv ;
|
||||
- RAM totale et disponible (`/proc/meminfo`) ;
|
||||
- RAM du moteur Strata en marche (arbre du service dans `/proc`) ;
|
||||
- place libre ;
|
||||
- **l'installation** : le job des moteurs. La sortie de l'installeur est
|
||||
découpée en lignes comme le `sinkWriter` du Go : une barre `\r` n'est
|
||||
montrée que toutes les 10 s. La bascule finale passe par le Go
|
||||
(`/internal/v1/preset/apply`, mode `switch`), qui applique le preset et
|
||||
relance le service.
|
||||
|
||||
Sans les backends, loki-rs ne passe pas la variable au Go et Strata reste au
|
||||
Go. Le lancement reste au Go (`loki serve`, que `loki-rs engine serve` lui
|
||||
délègue pour un preset Strata).
|
||||
|
||||
Côté Go, `facade_strata.go` relaie à la façade les routes reçues par le
|
||||
tunnel.
|
||||
|
||||
## Correction du Go
|
||||
|
||||
`/api/strata/settings` décidait si le preset était en service **après** y
|
||||
avoir écrit les nouveaux réglages. L'empreinte du nouveau contenu ne
|
||||
correspondait alors plus à la configuration active : changer un réglage du
|
||||
modèle en service ne l'appliquait jamais, et le moteur n'était pas relancé.
|
||||
« En service » se décide désormais avant l'écriture, comme dans
|
||||
`SavePresetApplying`. Le port Rust suit.
|
||||
|
||||
## Parité
|
||||
|
||||
`internal/loki/parity_strata_test.go` (Linux x86_64) tire du Go un journal
|
||||
rejoué par `tests/strata.rs`. Pour le tirer, le Go reçoit des crochets de
|
||||
test : `strataReleaseBase` (variable), `meminfoPath`, `strataDiskFree` et
|
||||
`serviceActionOSFn`. Le journal couvre :
|
||||
|
||||
- **la machine** :
|
||||
- sans carte, nvidia-smi en panne ou vide, carte trop ancienne ou trop
|
||||
petite, carte d'aide trop ancienne ;
|
||||
- trois cartes de même VRAM : la plus récente devant (capacité de calcul
|
||||
arrondie au dixième) ;
|
||||
- Python absent, en panne, illisible, trop ancien, sans venv ;
|
||||
- 16, 47 ou 64 Go de RAM, meminfo sans MemAvailable ;
|
||||
- disque presque plein ou juste trop petit (marge de 5 Go), données déjà
|
||||
présentes ;
|
||||
- **les réglages** :
|
||||
- GET, corps illisible, preset vide ou avec chemin, preset absent ou
|
||||
llama.cpp ;
|
||||
- hors limites, experts inconnus ;
|
||||
- preset inactif ; preset actif (appliqué et relancé), et pendant une
|
||||
optimisation ;
|
||||
- **l'installation** :
|
||||
- GET, corps illisible, quant inconnu, optimisation en cours ;
|
||||
- machine sans carte, modèle qui ne tient pas ;
|
||||
- release introuvable, moteur altéré (empreinte) ;
|
||||
- installation complète, puis réinstallation (sources déjà là et
|
||||
décompressées, moteur téléchargé périmé retéléchargé) ;
|
||||
- installeur en échec, installeur sans configuration ;
|
||||
- **les modèles installés** :
|
||||
- détails de lancement du modèle actif et de modèles inactifs (experts
|
||||
sur disque ou en RAM, vision coupée, sans carte d'aide) ;
|
||||
- mode du dernier lancement ;
|
||||
- `experts.bin` avec le moteur du paquet ou d'un autre paquet ;
|
||||
- RAM juste sous ou au-dessus du seuil du mode hors RAM, mode hors RAM
|
||||
coupé dans le preset ;
|
||||
- configuration de lancement disparue ou illisible ;
|
||||
- **après chaque appel** : les actions du service, les clés de la
|
||||
configuration et l'arborescence de `presets/` et `strata/` (sans le
|
||||
contenu des archives).
|
||||
|
||||
Autres tests :
|
||||
|
||||
- les tests du Go (`backend_strata_test.go`), portés (`strata/tests.rs`) :
|
||||
configuration de lancement, placement des experts, recommandation,
|
||||
réglages, noms ;
|
||||
- la RAM du moteur en marche, le découpage en lignes et les nombres écrits
|
||||
à la Go ;
|
||||
- le Go (`TestFacadeStrata`) : les trois routes partent à la façade, et
|
||||
elles seules.
|
||||
|
||||
## Mutations
|
||||
|
||||
71 mutations du port Rust, toutes tuées (les dix survivantes du premier
|
||||
passage l'ont été par les étapes et les tests ajoutés ci-dessus).
|
||||
|
||||
## Écarts assumés
|
||||
|
||||
- Les erreurs réseau (release injoignable) gardent le texte de `reqwest`.
|
||||
- Une copie du moteur qui échoue (`prebuilt/`) ne donne pas exactement le
|
||||
message du Go, qui ouvre la source puis la destination.
|
||||
@@ -0,0 +1,193 @@
|
||||
# Lot 26 — Tâches planifiées tenues par la façade
|
||||
|
||||
## Contexte
|
||||
|
||||
Une tâche planifiée tourne seule, sans utilisateur en face. Il en existe
|
||||
deux sortes :
|
||||
|
||||
- **une tâche IA** : une consigne, exécutée par l'agent dans un fil
|
||||
éphémère ;
|
||||
- **une tâche « script seul »** : un script du dossier protégé
|
||||
(`$LOKI_HOME/scripts`), lancé sans modèle. Sa sortie devient le
|
||||
compte-rendu.
|
||||
|
||||
Le Go les tient dans plusieurs fichiers :
|
||||
|
||||
- `tasks.go` : la base (`bkTasks`) et l'interrupteur maître ;
|
||||
- `tasks_cron.go` : les fréquences ;
|
||||
- `tasks_sched.go` : le planificateur, un tic par minute et une tâche due
|
||||
par tic ;
|
||||
- `tasks_run.go` et `tasks_script.go` : l'exécution ;
|
||||
- `web_tasks.go` : sept routes.
|
||||
|
||||
Les fréquences prennent quatre formes :
|
||||
|
||||
- `@every 2h` : intervalle glissant d'au moins une minute ;
|
||||
- `@every 3d@9:30` : tous les N jours, à une heure murale ;
|
||||
- `@once 2026-10-01 16:54` : une seule fois ;
|
||||
- une expression cron à cinq champs.
|
||||
|
||||
L'heure murale est celle du fuseau de la tâche. Sans fuseau, ou avec un
|
||||
fuseau inconnu, c'est l'heure locale du serveur.
|
||||
|
||||
## Le lot
|
||||
|
||||
Avec `LOKI_TASKS=rust` (expérimental), la façade (`rust/loki-rs/src/tasks`)
|
||||
sert les routes `/api/tasks*` et fait tourner le planificateur, que le Go ne
|
||||
démarre plus. Elle exécute elle-même les tâches script :
|
||||
|
||||
- le shell de l'agent (`run_shell`), dans le dossier de la discussion
|
||||
ouverte ;
|
||||
- une limite de 10 min ;
|
||||
- un arrêt par `/api/tasks/stop`.
|
||||
|
||||
Une tâche IA passe par le verrou de génération et les outils de l'agent, qui
|
||||
restent au Go. La façade la lui fait exécuter par `/internal/v1/tasks/run`,
|
||||
que le Go traite comme son propre planificateur l'aurait fait :
|
||||
|
||||
- preset épinglé ;
|
||||
- projet forcé ;
|
||||
- `RunAutonomous` ;
|
||||
- état persisté ;
|
||||
- notification.
|
||||
|
||||
Moteur occupé ou modèle pas prêt : la tâche ne tourne pas, son état ne bouge
|
||||
pas, et le tic suivant la réessaie. Le Go rend la main à la fin du passage.
|
||||
|
||||
Les notifications des tâches script partent par la façade si elle tient les
|
||||
notifications, sinon par le Go (`/internal/v1/push/send`).
|
||||
|
||||
Les fréquences demandent l'heure murale d'un fuseau IANA. `src/tz.rs` porte
|
||||
ce que le Go fait de la zoneinfo du système :
|
||||
|
||||
- lecture des TZif v1 et v2, et de la règle POSIX qui prolonge les
|
||||
transitions ;
|
||||
- `time.Local` (`$TZ`, `/etc/localtime`) ;
|
||||
- `time.Date`, avec la normalisation des débordements ;
|
||||
- les heures qui sautent ou qui se répètent, résolues comme le Go.
|
||||
|
||||
`tasks/cron.rs` porte `time.ParseDuration` du Go, messages d'erreur compris.
|
||||
|
||||
Côté Go :
|
||||
|
||||
- `facade_tasks.go` : le relais des routes reçues par le tunnel et les deux
|
||||
routes internes ;
|
||||
- `web_server.go` : le planificateur n'est pas démarré ;
|
||||
- `tasksNow` : l'heure des tâches, remplaçable en test.
|
||||
|
||||
## Correction de la façade
|
||||
|
||||
Les appels de la façade à l'API interne du Go étaient tous bornés à 10 s. Un
|
||||
outil exécuté par le Go (`/internal/v1/tool/exec`) qui dure plus longtemps
|
||||
était coupé :
|
||||
|
||||
- une page web lente ;
|
||||
- le navigateur ;
|
||||
- un serveur MCP tenu par le Go.
|
||||
|
||||
Le résultat partait en erreur alors que l'outil continuait. Ces appels sont
|
||||
désormais bornés à 30 min. Une tâche IA exécutée par le Go est attendue sans
|
||||
autre limite que 24 h.
|
||||
|
||||
## Ce que les mutations ont corrigé dans le port
|
||||
|
||||
Le premier passage des mutations a laissé survivre des cas que le journal ne
|
||||
voyait pas. Confrontés au Go, plusieurs étaient de vrais écarts :
|
||||
|
||||
- **dates lointaines** : un instant en nanosecondes sur 64 bits s'arrête en
|
||||
2262. Le Go accepte `@once 2400-02-29 10:00`, ou un intervalle en jours
|
||||
qui mène au-delà. Les instants des fréquences passent sur 128 bits ;
|
||||
- **intervalle en jours démesuré** : `@every 200000d` déborde dans le Go
|
||||
(`time.Duration`), qui rend une date passée. Le port déborde de même ;
|
||||
- **l'heure qui saute** : `@every 1d@02:30` à Paris, le jour du passage à
|
||||
l'heure d'été, tombe à 3 h 30. Le Go avance ensuite de jour en jour depuis
|
||||
ce candidat (`AddDate`) et reste à 3 h 30. Le port repartait de 2 h 30 ;
|
||||
- **compte-rendu tronqué** : le Go coupe à 4000 octets, au milieu d'un
|
||||
caractère s'il le faut, et chaque octet orphelin devient U+FFFD à
|
||||
l'encodage JSON. Le port reculait à une frontière de caractère ;
|
||||
- **nom de fuseau** : le Go refuse tout nom qui contient `..`, pas seulement
|
||||
un composant `..`.
|
||||
|
||||
## Parité
|
||||
|
||||
`internal/loki/parity_tasks_test.go` (Linux) tire du Go un journal rejoué
|
||||
par `tests/tasks.rs`. L'heure des tâches est fixée pas à pas ; les
|
||||
identifiants tirés au hasard deviennent `{{ID1}}`, `{{ID2}}`… dans l'ordre où
|
||||
ils apparaissent. Le journal couvre :
|
||||
|
||||
- **l'écran** :
|
||||
- scripts du dossier (sous-dossiers compris) ;
|
||||
- mode agent, interrupteur maître (rangé à « 0 » : pas suspendu) ;
|
||||
- projets, mémoire coupée, internet armé ;
|
||||
- la tâche en cours pendant un script ;
|
||||
- **l'enregistrement** :
|
||||
- corps illisible, sans nom, sans consigne ;
|
||||
- script absent, dossier, remontée, chemin absolu, séparateur Windows,
|
||||
caractère interdit ;
|
||||
- chaque erreur de fréquence : vide, intervalle trop court, illisible ou
|
||||
sans unité, jours ou heure invalides, cron à 4 champs, hors bornes, en
|
||||
lettres, plage inversée, pas nul, jamais satisfait, date illisible ou
|
||||
déjà passée ;
|
||||
- chaque forme de fréquence, dans plusieurs fuseaux : Paris (dont l'heure
|
||||
qui saute), New York, Calcutta, fuseau inconnu ;
|
||||
- id inconnu gardé, projet inconnu remplacé par l'actif ;
|
||||
- modification avec ou sans changement de fréquence ;
|
||||
- **la bascule, l'interrupteur maître, la suppression** ;
|
||||
- **les lancements** :
|
||||
- tâche absente ;
|
||||
- tâche IA avec moteur occupé (409) ou modèle pas prêt (503) ;
|
||||
- script en succès, en échec, arrêté à la main ;
|
||||
- script qui échoue après un succès, puis se rétablit ;
|
||||
- tâche script rangée sans script, au nom refusé, au script disparu ;
|
||||
- **le planificateur** :
|
||||
- tic suspendu, tic pendant une optimisation ;
|
||||
- une tâche IA due qui attend le modèle ;
|
||||
- une tâche due par tic, dans l'ordre des noms ;
|
||||
- la tâche « une fois » qui s'éteint ;
|
||||
- une tâche sans prochain passage (posé sans la lancer) ;
|
||||
- une fréquence devenue invalide ;
|
||||
- deux tâches de même nom (rangées par id).
|
||||
|
||||
Après chaque étape, le journal enregistre les tâches en base.
|
||||
|
||||
Autres tests :
|
||||
|
||||
- fuseaux et fréquences confrontés au Go :
|
||||
- Paris aux deux changements d'heure, New York, Calcutta ;
|
||||
- avant la première transition, pile à une transition, la table avant la
|
||||
règle prolongée ;
|
||||
- `$TZ` (vide, « : », chemin, nom), `/etc/localtime` ;
|
||||
- noms refusés, « UTC » sans base ;
|
||||
- mois hors bornes, années bissextiles, mois de 30 jours ;
|
||||
- `ParseDuration` et ses débordements, cron (plages, pas, bornes, à 78 et
|
||||
364 jours) ;
|
||||
- le compte-rendu tronqué, les noms de script ;
|
||||
- l'amorçage au démarrage (tâches actives sans prochain passage) ;
|
||||
- la durée d'un passage, à une horloge qui avance ;
|
||||
- la tâche IA en cours vue par l'écran ;
|
||||
- la tâche IA lancée à la main part au Go (`tache_ia_executee_par_le_go`) ;
|
||||
- le planificateur tourne à son pas sans demande ;
|
||||
- sans `LOKI_TASKS`, les routes partent au Go ;
|
||||
- le Go (`TestFacadeTaches`) :
|
||||
- les sept routes partent à la façade, et elles seules ;
|
||||
- `/internal/v1/tasks/run` sur une tâche IA sans modèle (occupée, rien
|
||||
d'enregistré) et sur une tâche script ;
|
||||
- `/internal/v1/push/send`.
|
||||
|
||||
## Mutations
|
||||
|
||||
79 mutations du port Rust, dont 77 tuées. Les survivantes du premier passage
|
||||
l'ont été par les tests et les étapes ajoutés ci-dessus. Restent deux
|
||||
équivalentes :
|
||||
|
||||
- le refus d'un nom de script absolu **après** nettoyage : sous Unix, un nom
|
||||
qui ne commence pas par `/` ne le devient pas (le contrôle du Go vaut pour
|
||||
Windows) ;
|
||||
- `web_on` sans `internet_enabled` : la joignabilité refuse déjà un moteur
|
||||
Crawl4AI sans adresse, seul cas où les deux diffèrent.
|
||||
|
||||
## Écarts assumés
|
||||
|
||||
- Pendant une tâche IA, le Go force le projet de la tâche. Une tâche
|
||||
enregistrée sans projet à ce moment-là prend, côté façade, le projet actif
|
||||
rangé.
|
||||
@@ -0,0 +1,151 @@
|
||||
# Lot 27 — Banc d'essai tenu par la façade
|
||||
|
||||
## Contexte
|
||||
|
||||
Le banc d'essai mesure le moteur local tel que le chat s'en sert, sur
|
||||
`/v1/chat/completions`, avec le gabarit, le raisonnement et
|
||||
l'échantillonnage du preset (`llm_bench.go`, `llm_bench_job.go`). Il a deux
|
||||
modes :
|
||||
|
||||
- **rapide** : un préchauffage jeté, puis la ligne courte (~2000 jetons de
|
||||
prompt, ~300 générés) ;
|
||||
- **complet** : la ligne courte, puis une discussion à profondeur réelle —
|
||||
un prefill à froid de D jetons, et trois tours qui y ajoutent chacun ~2048
|
||||
jetons neufs. La reprise du cache est lue dans la réponse, jamais
|
||||
supposée.
|
||||
|
||||
Il tourne en tâche de fond derrière quatre routes :
|
||||
|
||||
- `POST /api/bench` lance (202 et un id) ;
|
||||
- `GET /api/bench/status` dit la phase, puis le résultat ou l'erreur ;
|
||||
- `POST /api/bench/cancel` annule ;
|
||||
- `GET /api/bench/last` rend la dernière mesure enregistrée.
|
||||
|
||||
Pendant la mesure, le bench tient le verrou de génération de la
|
||||
conversation. Un message, une tâche planifiée ou un client `/v1` reçoivent
|
||||
le refus du bench. Seule une mesure complète est enregistrée : la
|
||||
dernière, et celle du preset actif.
|
||||
|
||||
## Le lot
|
||||
|
||||
Avec `LOKI_BENCH=rust` (expérimental), la façade (`rust/loki-rs/src/bench`)
|
||||
sert les quatre routes et déroule le protocole contre le moteur :
|
||||
|
||||
- `bench/mod.rs` : le protocole, porté du Go à l'octet près. On y trouve :
|
||||
- les mêmes corpus : la prose du Go et l'interface web embarquée, de la
|
||||
même source ;
|
||||
- les mêmes requêtes ;
|
||||
- le même calcul de profondeur, de reprise du cache et de débit ;
|
||||
- les mêmes messages d'erreur ;
|
||||
- `bench/job.rs` : le job, les routes et l'enregistrement (`last_bench`,
|
||||
`bench_presets`, avec l'empreinte de configuration du Go).
|
||||
|
||||
Le verrou de génération reste celui de la conversation du Go. La façade le
|
||||
prend par le **bail du moteur**, avec le motif `bench` :
|
||||
|
||||
- le Go note alors `benching` et donne aux autres le refus du benchmark ;
|
||||
- changer de discussion ne rompt pas le bail ;
|
||||
- `benchRunning()` le voit aussi : les clients `/v1`, le préchauffage et
|
||||
l'optimiseur attendent ;
|
||||
- un stop du chat côté Go (signalé au renouvellement), un Reset ou un bail
|
||||
perdu annulent le bench.
|
||||
|
||||
Le Go prête aussi à la façade :
|
||||
|
||||
- **le travail annexe** (`/internal/v1/engine/sidejob`) : à la fin, le slot
|
||||
du bench est effacé si c'est sans risque, comme pour un bench du Go ;
|
||||
- **la télémétrie** (`/internal/v1/bench/perf`) : la trace du bench dans
|
||||
`perf_log`.
|
||||
|
||||
Le port, la clé de l'API locale et le second slot (`SIDE_SLOT`) viennent de
|
||||
`/internal/v1/oai/route`. Les niveaux de raisonnement appris sont ceux de la
|
||||
conversation de la façade si elle la tient, sinon ceux du bench.
|
||||
|
||||
La ligne de commande (`loki bench`) reste au Go.
|
||||
|
||||
## Correction des tests du Go
|
||||
|
||||
Trois tests du préchauffage échouaient dix minutes par jour, avant minuit :
|
||||
le préchauffage se tait alors (`prewarmNearMidnight`), et ils lisaient
|
||||
l'heure réelle. L'heure de ce garde-fou devient remplaçable (`prewarmNow`),
|
||||
et les tests la fixent à midi.
|
||||
|
||||
## Parité
|
||||
|
||||
`internal/loki/parity_bench_test.go` (Linux) tire du Go un journal rejoué
|
||||
par `tests/bench.rs` contre le même faux moteur (`fakeBenchEngine`, porté).
|
||||
Chaque requête reçue par le moteur est comparée ; les longs textes (corpus)
|
||||
sont réduits à leur taille et à leur empreinte. Le journal couvre :
|
||||
|
||||
- **les routes** : statut et dernière mesure sans bench, GET refusés,
|
||||
annulation sans bench ;
|
||||
- **les refus au lancement** : preset externe, optimisation en cours, modèle
|
||||
pas prêt, génération en cours, second lancement pendant un bench ;
|
||||
- **les échecs dans le job** : moteur occupé par un autre (`/slots`), preset
|
||||
Strata ;
|
||||
- **les mesures** :
|
||||
- mode rapide, puis mode complet (profondeur, trois tours, reprise du
|
||||
cache, brouillon) ;
|
||||
- contexte trop petit (profondeur sautée), contexte inconnu du moteur
|
||||
(celui du preset) ;
|
||||
- paramètres bornés, petits paramètres, corps illisible ;
|
||||
- **les échecs d'une mesure** :
|
||||
- préchauffage, ligne courte, prefill à froid ou tour en profondeur en
|
||||
erreur ;
|
||||
- réponse sans timings ;
|
||||
- **le raisonnement** : niveau refusé par le gabarit puis rejoué, puis le
|
||||
niveau appris envoyé d'emblée ;
|
||||
- **budget et annulation** : budget d'une requête dépassé (profondeur
|
||||
partielle, non enregistrée), annulation pendant la profondeur ;
|
||||
- **après chaque étape** : la dernière mesure et les mesures par preset.
|
||||
|
||||
Autres tests :
|
||||
|
||||
- ceux du Go (`llm_bench_test.go`), portés (`bench/tests.rs`) : profondeur,
|
||||
types de cache, requête, morceaux du corpus, reprise du cache, relectures
|
||||
du disque, empreinte de l'enregistrement ;
|
||||
- la réponse du moteur (timings, cache, brouillon, nombres illisibles),
|
||||
`Duration.String` du Go, les flottants écrits à la Go ;
|
||||
- la façade : le bail « bench », le travail annexe (slot servi ou non), la
|
||||
trace, la clé de l'API locale, le second slot, le stop du chat et le bail
|
||||
perdu, les routes sans la façade ;
|
||||
- le Go (`TestFacadeBench`) :
|
||||
- les quatre routes partent à la façade, et elles seules ;
|
||||
- le bail « bench » : refus, bascule de discussion, libération,
|
||||
expiration ;
|
||||
- le travail annexe et la trace.
|
||||
|
||||
## Mutations
|
||||
|
||||
84 mutations du port Rust, dont 81 tuées. Les survivantes du premier passage
|
||||
l'ont été par les tests ajoutés :
|
||||
|
||||
- `bench/tests.rs` : la tranche pile à la fin du corpus, un tour sans
|
||||
attendu à côté d'un tour mesuré, les débits nuls rendus par le moteur
|
||||
(recalculés), le niveau de raisonnement rejoué une seule fois, le
|
||||
préchauffage et la ligne courte d'un `run` rapide (paramètres par défaut,
|
||||
64 jetons de préchauffage au moins, code ajouté juste au-delà de la
|
||||
prose), la profondeur arrêtée (contexte plein, corpus épuisé, réponse du
|
||||
tour précédent reprise, note de reprise selon le modèle hybride) ;
|
||||
- `tests/bench.rs` : un second lancement pendant la prise du bail, le preset
|
||||
relu au départ du job (devenu externe, ou optimiseur lancé entre-temps),
|
||||
la mesure enregistrée sous le preset ACTIF ;
|
||||
- le journal : un prompt juste au-delà de la prose.
|
||||
|
||||
Restent trois équivalentes :
|
||||
|
||||
- l'annulation vérifiée avant d'envoyer : déjà annulé, le `select!` rend
|
||||
l'annulation à son premier tour, avant que la requête ne parte ;
|
||||
- la progression d'un job remplacé : un seul bench à la fois, un job fini ne
|
||||
rapporte plus rien ;
|
||||
- le PID nul du moteur : `/proc/0` n'existe pas, la lecture échoue de toute
|
||||
façon.
|
||||
|
||||
## Écarts assumés
|
||||
|
||||
- Une erreur réseau vers le moteur garde le texte de `reqwest` après le
|
||||
préfixe du Go (`Post "url": …`).
|
||||
- Un niveau de raisonnement appris par le bench de la façade n'est pas
|
||||
transmis au chat du Go, quand le Go tient la conversation (et
|
||||
inversement) : la première requête paie le refus, puis la traduction est
|
||||
retenue.
|
||||
@@ -0,0 +1,93 @@
|
||||
# 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.
|
||||
@@ -0,0 +1,101 @@
|
||||
# Lot 8 — routes des modèles tenues par la façade
|
||||
|
||||
## Contexte
|
||||
|
||||
Les modèles `.gguf` vivaient dans le Go :
|
||||
|
||||
- la liste du sélecteur (`handleModels`, `backend_models_refs.go`) ;
|
||||
- les dossiers de modèles (`backend_model_dirs.go`) ;
|
||||
- la suppression (`deleteModelFile`) ;
|
||||
- les téléchargements (`backend_models.go`, `backend_models_shards.go`) ;
|
||||
- le catalogue Hugging Face (`backend_hf.go`, `web_hf.go`, `backend_catalog.go`).
|
||||
|
||||
Lot 8 : la façade tient tout cela quand `LOKI_MODELS=rust` (expérimental ;
|
||||
sans la variable rien ne change, tout part au Go).
|
||||
|
||||
## Ce que sert la façade
|
||||
|
||||
| Route | Module | Rôle |
|
||||
|---|---|---|
|
||||
| `GET /api/models` | `models.rs` | `.gguf` des dossiers déclarés (tranches 2..N exclues), taille de la famille, tranches manquantes, qui les référence (presets, configuration en service) |
|
||||
| `POST /api/models/delete` | `models.rs` | refus si en service ; 409 avec la liste des presets sans `force` ; le modèle part avec toutes ses tranches, octets libérés rendus |
|
||||
| `GET\|POST /api/models/dirs` | `models.rs` | dossiers de modèles, nombre de modèles, espace libre ; ajout (dossier existant) et retrait |
|
||||
| `POST /api/models/download/probe` | `downloads.rs` | taille du modèle (toutes ses tranches), espace libre, « ça tient ? », ou « déjà là » avec la valeur de `MODEL=` |
|
||||
| `POST /api/models/download` | `downloads.rs` | lance en arrière-plan ; 409 si déjà en cours ; « déjà là » si toutes les tranches sont présentes |
|
||||
| `GET /api/models/download/status` | `downloads.rs` | état de chaque téléchargement de ce lancement |
|
||||
| `POST /api/models/download/cancel` | `downloads.rs` | annule ; ce que le téléchargement a écrit est effacé |
|
||||
| `GET /api/hf/search` | `hf.rs` | dépôts GGUF, matériel local joint |
|
||||
| `GET /api/hf/files` | `hf.rs` | modèles, projecteurs et poids MTP d'un dépôt, verdict mémoire de chaque modèle (contexte du preset actif, projecteur du dépôt) |
|
||||
| `GET\|POST /api/hf/token` | `hf.rs` | jeton vérifié auprès de Hugging Face avant d'être enregistré, jamais rendu en clair |
|
||||
|
||||
Mêmes contrats (corps, codes, messages) et même mécanique que le Go, entre
|
||||
autres :
|
||||
|
||||
- téléchargement :
|
||||
- lien de page Hugging Face (`/blob/`) converti en lien direct ;
|
||||
- modèle en tranches rapatrié en entier ;
|
||||
- place disque vérifiée sur le total, avec 256 Mio de marge ; jamais de
|
||||
refus sur FUSE, overlayfs, NFS, SMB, Ceph ou GlusterFS ;
|
||||
- plages d'octets en parallèle (`LOKI_DL_CONNS`) et reprise d'une
|
||||
connexion coupée ;
|
||||
- jeton envoyé aux seuls hôtes Hugging Face ;
|
||||
- catalogue : réponses mises en cache (60 s pour une recherche, 10 min pour un
|
||||
dépôt), cache vidé à l'enregistrement d'un jeton ;
|
||||
- `.part` orphelins vidés au démarrage — par la façade : le Go ne le fait
|
||||
plus, il effacerait un téléchargement qu'elle mène.
|
||||
|
||||
## Côté Go (`facade_models.go`)
|
||||
|
||||
Le tunnel du relais et la redistribution E2E arrivent au Go sans passer par
|
||||
la façade : `withFacadeChat` lui remet ces routes, authentifiées par le
|
||||
jeton interne, comme celles du chat. Façade injoignable : 502 « modèles
|
||||
indisponibles : … ».
|
||||
|
||||
La façade transmet `LOKI_MODELS=rust` et son adresse au Go qu'elle lance.
|
||||
|
||||
## Écarts assumés
|
||||
|
||||
- Une source qui ignore les plages n'est plus lue en entier pendant la sonde.
|
||||
- Erreurs de transport (réseau, TLS) au format de reqwest.
|
||||
- Message d'un corps JSON illisible : celui de serde (l'interface n'en envoie
|
||||
jamais).
|
||||
- Hôte d'un lien collé en majuscules converti comme les autres.
|
||||
- Espace libre et mémoire vive inconnus hors Unix/Linux (la façade n'est
|
||||
livrée que pour Linux).
|
||||
- `/api/models/download/status` trié par date de début.
|
||||
- Nombres flottants du matériel écrits `24.0` (le Go écrit `24`) : même
|
||||
valeur JSON.
|
||||
|
||||
## Parité
|
||||
|
||||
Journaux tirés du Go et rejoués en Rust (`LOKI_PARITY_UPDATE=1` pour
|
||||
régénérer) :
|
||||
|
||||
- `internal/loki/parity_dl_test.go` → `tests/parity_models/download.json` →
|
||||
`tests/downloads.rs` : lien normalisé, famille de tranches,
|
||||
`Content-Range`, messages de refus, hôtes et dépôts Hugging Face, dossier
|
||||
de destination ;
|
||||
- `internal/loki/parity_models_test.go` → `tests/parity_models/models.json` →
|
||||
`tests/models.rs` : quantification déduite du nom, découpage
|
||||
d'`EXTRA_ARGS`, jeton masqué, `gated`, classement d'un dépôt, choix du
|
||||
projecteur, verdicts mémoire, puis un scénario sur disque de 20 appels
|
||||
(liste, dossiers, suppressions avec et sans `force`, modèle en service,
|
||||
tranches, chemins refusés) rejoué à l'identique, fichiers restants et
|
||||
dossiers enregistrés compris.
|
||||
|
||||
Deux mutations (`replacen` → `replace` sur `/blob/`, `--mmproj` ignoré)
|
||||
font échouer les rejeux.
|
||||
|
||||
De bout en bout :
|
||||
|
||||
- téléchargements contre un faux Hugging Face : trois connexions sur des
|
||||
plages disjointes, source sans plages, tranches dont une présente, dépôt
|
||||
verrouillé, panne pendant la deuxième tranche, annulation et 409 ;
|
||||
- catalogue contre un faux Hugging Face : recherche, cache, taille LFS,
|
||||
dépôt verrouillé ou absent, jeton refusé ou accepté ;
|
||||
- toutes les routes par la façade (clé de pilotage, remise du Go, base du Go
|
||||
écrite), et laissées au Go sans la variable.
|
||||
|
||||
## Reste
|
||||
|
||||
- Essai réel sur une installation avant d'en faire le défaut.
|
||||
@@ -0,0 +1,96 @@
|
||||
# Lot 9 — dictée vocale tenue par la façade
|
||||
|
||||
## Contexte
|
||||
|
||||
La dictée vocale vivait dans le Go :
|
||||
|
||||
- `dictate_config.go` : réglages ;
|
||||
- `dictate_models.go` : catalogue Parakeet, téléchargement et extraction ;
|
||||
- `dictate_server.go` : supervision de `sherpa-onnx-offline-websocket-server`
|
||||
et protocole WebSocket ;
|
||||
- `web_transcribe.go` : routes.
|
||||
|
||||
Lot 9 : la façade la tient quand `LOKI_DICTATE=rust` (expérimental ; sans la
|
||||
variable rien ne change).
|
||||
|
||||
## Ce que sert la façade (`rust/loki-rs/src/dictate.rs`)
|
||||
|
||||
| Route | Rôle |
|
||||
|---|---|
|
||||
| `POST /api/transcribe` | un WAV (≥ 1000 octets, ≤ 32 Mio) entre, le texte sort ; modèle absent : 503 `{downloading, pct}` et téléchargement lancé (relancé après un échec, qui est dit) |
|
||||
| `GET\|POST /api/dictate/config` | modèle (validé contre le catalogue) et réactivité ; enregistrer arrête le serveur, qui repart au prochain mot avec le nouveau modèle |
|
||||
| `GET /api/dictate/models` | catalogue trié par taille, présence des quatre fichiers |
|
||||
| `GET\|POST /api/dictate/models/download` | un téléchargement à la fois, progression sur les octets reçus |
|
||||
| `GET /api/dictate/state` | serveur allumé (et depuis quand), modèle, présence, téléchargement |
|
||||
| `POST /internal/facade/v1/dictate/stop` | (le Go, jeton interne) arrêt sans attendre derrière un démarrage |
|
||||
|
||||
Même mécanique que le Go :
|
||||
|
||||
**Archive `.tar.bz2`**
|
||||
- Décompressée au fil du téléchargement, dans un dossier `.part` renommé à la fin.
|
||||
- Entrées absolues ou qui remontent refusées, liens ignorés.
|
||||
|
||||
**Serveur de transcription**
|
||||
- Lancé à la demande sur un port libre de la boucle locale, GPU masqués, sans
|
||||
le jeton interne dans son environnement.
|
||||
- Prêt dès que sa socket répond.
|
||||
- Une mort au démarrage est rapportée avec sa cause et la dernière ligne du
|
||||
journal ; un processus qui ne répond pas fait échouer le démarrage au bout
|
||||
de 2 minutes.
|
||||
- Éteint après dix minutes d'inactivité.
|
||||
- `shutdown_fast` tue le processus qui démarre au lieu d'attendre le verrou.
|
||||
|
||||
**Protocole**
|
||||
- En-tête de 8 octets (fréquence, nombre d'octets), échantillons float32 en
|
||||
trames de 10 Kio, une trame texte en retour.
|
||||
- Annotations (`[musique]`, `(rires)`) écartées.
|
||||
|
||||
## Côté Go (`facade_dictate.go`)
|
||||
|
||||
- `withFacadeChat` remet les routes du tunnel et de la redistribution E2E à la
|
||||
façade. Si elle ne répond pas : 502 « dictée indisponible : … ».
|
||||
- `asrShutdownVite` passe par la façade quand elle tient la dictée : son
|
||||
serveur n'est pas un enfant de ce process. Cela couvre la libération de la
|
||||
VRAM et l'arrêt du moteur principal par l'optimiseur.
|
||||
|
||||
## Écarts assumés
|
||||
|
||||
- Messages d'erreur de transport (WebSocket, HTTP) et de décompression au
|
||||
format des crates Rust.
|
||||
- Fin de connexion sans réponse : « lecture du résultat : connexion fermée ».
|
||||
|
||||
## Parité
|
||||
|
||||
`internal/loki/parity_dictate_test.go` tire du Go ce journal :
|
||||
|
||||
- treize WAV : mono, bloc `LIST` intercalé, stéréo moyenné, flottant 32 bits,
|
||||
données tronquées, impaires, vides, et les refus ;
|
||||
- les chemins d'archive ;
|
||||
- les marqueurs ;
|
||||
- les réglages par défaut ;
|
||||
- les arguments du serveur ;
|
||||
- les adresses des modèles ;
|
||||
- la présence ;
|
||||
- treize appels de route.
|
||||
|
||||
Le tout est rangé dans `tests/parity_dictate/dictate.json` et rejoué par
|
||||
`tests/dictate.rs`, les routes par la façade. Une mutation (premier canal au
|
||||
lieu de la moyenne) fait échouer le rejeu.
|
||||
|
||||
`tests/dictate.rs` couvre aussi :
|
||||
|
||||
- le protocole contre un faux serveur WebSocket (en-tête, découpage
|
||||
10240 + 10240 + 3528, marqueurs, réponse illisible, serveur injoignable) ;
|
||||
- l'extraction (lien ignoré, entrée qui remonte refusée sans rien laisser,
|
||||
archive corrompue) ;
|
||||
- le téléchargement depuis un faux serveur (100 %, HTTP 404, modèle inconnu,
|
||||
un seul à la fois) ;
|
||||
- la supervision d'un faux sherpa-onnx (script Python qui parle le
|
||||
protocole) :
|
||||
- transcription de bout en bout par la façade ;
|
||||
- même serveur réutilisé ;
|
||||
- arrêt demandé par le Go, et refusé sans le jeton ;
|
||||
- réglage changé ;
|
||||
- les démarrages ratés : sortie en code 3 avec sa dernière ligne, serveur
|
||||
muet, arrêt « vite » qui tue le démarrage, binaire introuvable, modèle
|
||||
absent.
|
||||
@@ -0,0 +1,263 @@
|
||||
# Lot 28 — Optimiseur tenu par la façade
|
||||
|
||||
## Contexte
|
||||
|
||||
L'optimiseur sans perte (`backend_tune*.go`) cherche des réglages du moteur
|
||||
plus rapides **sans rien changer à ce que le modèle calcule** : même
|
||||
contexte, mêmes slots, mêmes types de cache, même échantillonnage. Il
|
||||
procède ainsi :
|
||||
|
||||
1. Il arrête le vrai moteur.
|
||||
2. Il lance chaque essai sur une COPIE de la configuration :
|
||||
- posée dans `LOKI_HOME/tune/run/<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.
|
||||
@@ -0,0 +1,82 @@
|
||||
# Lot 29 — Télémétrie des complétions de la façade
|
||||
|
||||
## Contexte
|
||||
|
||||
Le Go range chaque complétion dans un anneau en mémoire (`perf_log.go`).
|
||||
Pour chacune, il note :
|
||||
|
||||
- combien du prompt venait du cache, combien a été recalculé ;
|
||||
- à quelle vitesse ;
|
||||
- ce que le décodage spéculatif a gardé.
|
||||
|
||||
Il expose des agrégats sur `/api/perf/summary`. Il calcule aussi la **perte
|
||||
de cache** d'une complétion : ce que le cache aurait dû reprendre de la
|
||||
précédente complétion de la même discussion, et n'a pas repris. Il nomme ce
|
||||
qui s'est intercalé entre les deux, et signale la perte à l'interface
|
||||
au-delà d'un seuil tiré du preset.
|
||||
|
||||
Quand la façade tient la conversation (`LOKI_CHAT=rust`), ses complétions
|
||||
n'apparaissaient nulle part. Le résumé des performances ignorait les tours ;
|
||||
l'optimiseur tirait ses poids d'une télémétrie vide ; l'interface ne
|
||||
recevait jamais la perte de cache.
|
||||
|
||||
## Le lot
|
||||
|
||||
Les complétions de la façade rejoignent l'anneau du Go, par un seul point
|
||||
d'entrée, `/internal/v1/turn/perf`. Il est arrivé sur `main` en même temps que
|
||||
ce lot (« Conversation tenue par Rust : télémétrie et requêtes en vol comme
|
||||
le Go »), et la fusion n'en garde qu'un.
|
||||
|
||||
- `TurnHost::perf_record` range une complétion. La boucle du tour l'appelle :
|
||||
- après chaque complétion lue, complète ou non, numérotée dans le tour
|
||||
(`perfIter`) ;
|
||||
- avec les empreintes seulement si elle est complète (`perf_prefix`,
|
||||
FNV-1a cumulé des messages envoyés) ;
|
||||
- pour une complétion coupée par un stop, rangée incomplète.
|
||||
|
||||
La dernière statistique d'une complétion complète porte ce que le Go en
|
||||
dit : la nature, la perte de cache, ce qui s'est intercalé et l'alerte.
|
||||
- La nature, posée par l'hôte :
|
||||
- `main` pour un tour et pour les corrections du builder du mode Code ;
|
||||
- `verify` pour la passe de vérification isolée ;
|
||||
- `compact` pour un résumé de compaction, rangé dès que sa réponse se lit,
|
||||
sans empreintes.
|
||||
- `chat/perf.rs` porte ce que la façade relève elle-même : la réponse entière
|
||||
d'un résumé (`perfRecFromWire`, sans échec possible, avec les mêmes
|
||||
tolérances que `perfNum`), traduite en statistiques d'une complétion.
|
||||
- Le Go range le relevé comme les siens (`perfRecFromStats`) : numéro,
|
||||
instant, perte de cache, ce qui s'est intercalé. Il calcule l'alerte au
|
||||
seuil du preset actif (`perfLostAlertAt`), jamais pour un preset externe.
|
||||
- La sonde du gabarit n'en part pas : la façade la tient (lot 23) et la lance
|
||||
elle-même après une complétion complète de forme normale. Le Go lui demande
|
||||
son verdict (`facadeTplCaps`).
|
||||
|
||||
`/api/perf/summary` reste au Go, qui tient l'anneau.
|
||||
|
||||
## Tests
|
||||
|
||||
- `chat/perf.rs` :
|
||||
- relevé d'une réponse entière : nombres en chaîne, valeurs hors bornes,
|
||||
octet parasite, cache d'OpenAI, timings d'un autre type ;
|
||||
- empreintes cumulées, FNV-1a de référence.
|
||||
- La boucle du tour :
|
||||
- deux complétions numérotées, totaux, empreintes qui se prolongent ;
|
||||
- dernière statistique avec la nature et la perte vues par le Go ;
|
||||
- complétion coupée rangée incomplète, sans statistique finale.
|
||||
- Le vrai hôte (`tests/lot6.rs`) :
|
||||
- complétions d'un tour rangées par le Go, rattachées à la discussion ;
|
||||
- résumés de compaction (rangés si la réponse se lit, pas sinon).
|
||||
- Le Go (`web_internal_turnperf_test.go`) :
|
||||
- complétion rangée dans l'anneau ;
|
||||
- perte et ce qui s'est intercalé, alerte ;
|
||||
- preset externe sans alerte, complétion incomplète sans empreintes ;
|
||||
- nature d'un résumé gardée, un tour par défaut ;
|
||||
- corps illisible, GET refusé.
|
||||
|
||||
## Écarts assumés
|
||||
|
||||
- Les empreintes sont calculées sur le JSON des messages tel que Rust
|
||||
l'écrit (sans l'échappement HTML du Go). Elles ne se comparent qu'entre
|
||||
complétions de la même discussion et de la même nature, toutes produites
|
||||
par la façade.
|
||||
- Une complétion coupée par un stop est rangée sans ses compteurs partiels.
|
||||
@@ -0,0 +1,54 @@
|
||||
# Lot 30 — Motifs de relance personnalisés tenus par la façade
|
||||
|
||||
## Contexte
|
||||
|
||||
Quand le modèle écrit un appel d'outil en toutes lettres au lieu de
|
||||
l'émettre par le protocole, Loki le reconnaît et relance le tour avec une
|
||||
consigne corrective. Les motifs embarqués couvrent les formats connus. Un
|
||||
expert peut les remplacer par la clé d'état `retry_patterns` :
|
||||
|
||||
- c'est un tableau JSON de regex du Go, chacune compilée en `(?mi)` ;
|
||||
- une regex illisible est sautée ;
|
||||
- une surcharge illisible ou vide laisse les motifs embarqués.
|
||||
|
||||
Jusqu'ici, une surcharge posée gardait la conversation au Go
|
||||
(`facadeChatBlocker`), même avec `LOKI_CHAT=rust`.
|
||||
|
||||
## Le lot
|
||||
|
||||
La façade lit la surcharge au démarrage de sa conversation
|
||||
(`chat::generate::retry_patterns`), comme le Go la compile une fois par
|
||||
processus. Le Go ne garde plus la conversation pour cette clé.
|
||||
|
||||
Les regex sont celles du Go (paquet `regexp`), pas celles de `regex`.
|
||||
`chat::stream::go_regex` les réécrit pour garder le sens du Go :
|
||||
|
||||
- les classes Perl et les limites de mot du Go sont ASCII ; celles de
|
||||
`regex` sont Unicode :
|
||||
- hors d'une classe : `\w`, `\W`, `\d`, `\D`, `\s`, `\S` deviennent des
|
||||
classes ASCII ;
|
||||
- dans une classe : leur équivalent dans la classe ;
|
||||
- `\b`, `\B` deviennent `(?-u:\b)`, `(?-u:\B)` ;
|
||||
- `\Q…\E` (texte littéral) devient le texte échappé ; le Go le refuse dans
|
||||
une classe, la traduction aussi ;
|
||||
- dans une classe, `[`, `&&`, `~~` sont littéraux pour le Go, et `--` est un
|
||||
intervalle : ils sont échappés ;
|
||||
- l'octal du Go (`\101`) est permis (`octal(true)`). Une référence arrière
|
||||
(`\1`), que le Go refuse, est refusée.
|
||||
|
||||
## Parité
|
||||
|
||||
`internal/loki/parity_retry_test.go` compile côté Go une soixantaine de
|
||||
regex (dont celles qu'il refuse). Il note leur première correspondance sur
|
||||
onze textes : accents, chiffres non ASCII, espaces insécables, crochets,
|
||||
majuscules accentuées. Le journal (`tests/parity_retry/patterns.json`) est
|
||||
rejoué par `tests/retry.rs` : même compilation, même correspondance, même
|
||||
position.
|
||||
|
||||
Autres tests :
|
||||
|
||||
- la lecture de la surcharge : absente, `null`, vide, mal typée, suivie de
|
||||
texte ;
|
||||
- les regex sautées, les correspondances ;
|
||||
- la surcharge lue au démarrage de la conversation de la façade ;
|
||||
- côté Go, la clé ne garde plus la conversation.
|
||||
@@ -0,0 +1,88 @@
|
||||
# Lot 31 — Slots du moteur pour le tour tenu par la façade
|
||||
|
||||
## Contexte
|
||||
|
||||
Trois clés opt-in du moteur local gardaient la conversation au Go
|
||||
(`facadeChatBlocker`), faute d'être honorées par la façade :
|
||||
|
||||
- `SIDE_SLOT` (`llm_sideslot.go`) : un second slot pour les travaux annexes.
|
||||
Chaque requête de Loki porte son `id_slot` : 0 pour le fil de la
|
||||
discussion (étape d'un tour, résumé en continuation), 1 pour le reste
|
||||
(vérification, résumé sur transcription). Un cache KV à court de place en
|
||||
plein calcul est rejoué tel quel, sans compaction : c'est la faute du
|
||||
voisin, pas un prompt trop long.
|
||||
- `SLOT_PERSIST` (`llm_slotpersist.go`) : l'état du slot de la discussion est
|
||||
gardé sur disque à la bascule de preset, et rechargé avant la première
|
||||
requête de la discussion au retour.
|
||||
|
||||
Et l'isolation des travaux annexes (`engineSideJob`, `CACHE_ISOLATE`) : après
|
||||
la passe de vérification du mode Code, le Go efface le slot local s'il a
|
||||
servi. La façade la sautait (écart du lot 7), le Go ne voyant pas passer ses
|
||||
requêtes.
|
||||
|
||||
Depuis la fusion de `main`, le Go voit passer chaque requête de la façade au
|
||||
moteur local (tickets « en vol », `/internal/v1/oai/begin`, `served`, `end`).
|
||||
La machinerie peut donc rester au Go ; il suffit que la façade la consulte.
|
||||
|
||||
## Le lot
|
||||
|
||||
### Go
|
||||
|
||||
- `/internal/v1/turn/slot` (`facade_slots.go`) :
|
||||
- entrée : `{"conv":"…","main":true}` ;
|
||||
- sortie : `{"slot":0|1|-1}`, la valeur d'`engineSlotFor` ;
|
||||
- avec `main` et `conv`, recharge d'abord l'état gardé de la discussion
|
||||
(`slotPersistRestore`), comme la boucle Go avant une requête d'un tour.
|
||||
- La sauvegarde à la bascule ne change pas. Le slot porte la discussion dès
|
||||
que le moteur accepte une étape de son tour : `/internal/v1/oai/served`
|
||||
appelle `engineMarkMain`.
|
||||
- `SIDE_SLOT` et `SLOT_PERSIST` ne gardent plus la conversation.
|
||||
|
||||
### Rust
|
||||
|
||||
- `TurnHost::engine_slot`, appelé avant chaque requête. La boucle pose
|
||||
l'`id_slot` rendu dans le corps.
|
||||
- Le rejeu « cache KV plein » (`sideSlotPoolError`) n'a lieu qu'avec un slot
|
||||
en service et un moteur local :
|
||||
- deux fois au plus, après l'attente des reprises réseau ;
|
||||
- puis l'erreur telle quelle ;
|
||||
- ni compaction ni réduction.
|
||||
- `GoTurnHost` :
|
||||
- moteur externe : aucun slot ;
|
||||
- étape d'un tour : `main` avec la discussion (donc le rechargement
|
||||
`SLOT_PERSIST`) ;
|
||||
- vérification : le second slot, et le ticket « en vol » ouvert sur lui
|
||||
(`side`).
|
||||
- `EngineSummarizer` : un résumé est désormais compté en vol, comme
|
||||
`postSummary`. Il part sur le second slot s'il est fait sur la
|
||||
transcription, sur le slot 0 s'il est fait en continuation.
|
||||
- La passe de vérification est encadrée par `/internal/v1/engine/sidejob`.
|
||||
À la fin, le Go efface le slot si le moteur a servi.
|
||||
|
||||
## Tests
|
||||
|
||||
- Go (`facade_slots_test.go`) :
|
||||
- sans la clé, aucun slot et `/props` jamais lu ;
|
||||
- fil de la discussion sur le 0, le reste sur le 1 ;
|
||||
- second slot hors service ;
|
||||
- rechargement une seule fois, pour la bonne discussion et une étape de tour
|
||||
seulement ;
|
||||
- une étape de la façade acceptée par le moteur suffit à la sauvegarde ;
|
||||
- corps illisible, GET refusé.
|
||||
- La boucle du tour :
|
||||
- `id_slot` dans chaque requête, slot demandé avant chaque envoi ;
|
||||
- cache KV plein : rejoué deux fois sans compaction, puis l'erreur ;
|
||||
- sans slot, ou moteur externe : un débordement comme un autre ;
|
||||
- motif `sideSlotPoolError` repris des cas du Go.
|
||||
- Le vrai hôte (`tests/lot6.rs`) :
|
||||
- contrat du mode Code : le tour sur le 0, la vérification sur le 1 ;
|
||||
- tickets ouverts sur le second slot, travail annexe ouvert puis rendu ;
|
||||
- résumés : transcription sur le 1, continuation sur le 0, tickets rendus.
|
||||
|
||||
## Écarts assumés
|
||||
|
||||
- Le rejeu « cache KV plein » se fie au slot demandé pour cette requête. Le Go
|
||||
relit `sideSlotLive` au moment de l'erreur ; les deux lectures partagent
|
||||
le même cache de dix secondes.
|
||||
- Le slot est demandé avant de composer la requête, et non juste avant de
|
||||
l'envoyer : rien ne se passe entre les deux.
|
||||
@@ -0,0 +1,97 @@
|
||||
# Lot 32 — Images gardées (KEEP_TURN_IMAGES) dans le tour de la façade
|
||||
|
||||
## Contexte
|
||||
|
||||
Sans la clé, une image montrée par un outil (capture, `see_image`) est
|
||||
éphémère. Le modèle la voit pendant le tour, puis elle disparaît de
|
||||
l'historique. Le tour suivant renvoie donc un fil où elle manque : le
|
||||
préfixe en cache diverge à elle, et toute la boucle d'outils qui suivait est
|
||||
recalculée.
|
||||
|
||||
`KEEP_TURN_IMAGES` (`chat_keep_images.go`) garde l'image telle qu'envoyée,
|
||||
sous conditions :
|
||||
|
||||
- vision réellement active ;
|
||||
- image rangée par référence et relue à l'octet près ;
|
||||
- coût **mesuré** par le moteur, jamais estimé ;
|
||||
- toutes les images gardées tiennent dans 10 % de la fenêtre.
|
||||
|
||||
Les images gardées partent avant tout résumé. La clé gardait la conversation
|
||||
au Go.
|
||||
|
||||
## Le lot
|
||||
|
||||
### Go (`facade_keepimg.go`)
|
||||
|
||||
- `/internal/v1/turn/keepimg` rend deux choses :
|
||||
- si la vision est réellement active (`visionEnabled`, `engineSeesImages`) ;
|
||||
- le budget des images gardées (`keepImgBudget`).
|
||||
- `/internal/v1/turn/keepimg/store` range une image relayée par référence
|
||||
(`refRelayImage`) :
|
||||
- fichier chiffré si la mémoire l'est ;
|
||||
- date rafraîchie ;
|
||||
- relu à l'octet près.
|
||||
|
||||
Il rend le message qui la porte ainsi et son poids en octets, ou un refus.
|
||||
- `KEEP_TURN_IMAGES` ne garde plus la conversation.
|
||||
|
||||
### Rust
|
||||
|
||||
- `chat/keepimg.rs` porte l'état du tour (`keepImages`) :
|
||||
- `relay` : image gardée à l'essai, dans `messages` et `extra` ;
|
||||
- `sending` ;
|
||||
- `observe` : mesure de la requête qui l'a montrée la première, partage au
|
||||
prorata des octets, seuil de 16 jetons, budget ;
|
||||
- `rewritten` ;
|
||||
- `publishable` ;
|
||||
- `finish` : une image à l'essai jamais mesurée redevient éphémère.
|
||||
|
||||
Une image refusée arrête le tour pour les suivantes.
|
||||
- La boucle du tour :
|
||||
- `TurnHost::keep_images` donne le budget, mais seulement pour le fil
|
||||
principal d'une discussion, avec la clé et la vision active ;
|
||||
- `TurnHost::keep_image` range l'image par le Go ;
|
||||
- l'historique réécrit en cours de tour est publié sans les relais non
|
||||
gardés.
|
||||
- Les images gardées partent d'abord, avant tout résumé :
|
||||
- au filet réactif (prompt trop long) : requête rejouée sans elles ;
|
||||
- en cours de tour, au seuil : la compaction ne suit que si elle reste
|
||||
nécessaire ;
|
||||
- sur une complétion coupée par une fenêtre pleine : rejouée sans résumé
|
||||
si leur retrait suffit.
|
||||
- Début et fin de tour (`keptImagesTurnStart`) : les images gardées sont
|
||||
retirées si le contexte le demande, ou si elles ne doivent plus partir
|
||||
(clé retirée, vision absente).
|
||||
|
||||
La compaction et la réduction forcée retiraient déjà les images gardées
|
||||
d'abord (`chat/compact.rs`).
|
||||
|
||||
## Tests
|
||||
|
||||
- `chat/keepimg` :
|
||||
- clé ;
|
||||
- gardée au coût mesuré ;
|
||||
- budget ;
|
||||
- sans mesure (pas d'usage, complétion coupée, pas de base) ;
|
||||
- requête relancée ;
|
||||
- rangement refusé ;
|
||||
- partage entre deux images ;
|
||||
- publication, réécriture, fin de tour, budget plein avant rangement.
|
||||
- La boucle du tour :
|
||||
- requête identique avec et sans la clé, image gardée par référence au
|
||||
coût mesuré ;
|
||||
- refus : budget, pas d'usage, rangement refusé ;
|
||||
- image à l'essai en fin de tour ;
|
||||
- retrait en cours de tour, au filet réactif et sur une fenêtre pleine, à
|
||||
chaque fois sans résumé.
|
||||
- Le vrai hôte (`tests/lot6.rs`) :
|
||||
- image rangée par le Go, vue en data-URL par le moteur, gardée avec son
|
||||
coût ;
|
||||
- clé retirée : l'image part au début du tour suivant, légende et marque
|
||||
gardées.
|
||||
- Le Go (`facade_keepimg_test.go`) :
|
||||
- état sans projecteur puis vu par le moteur, budget ;
|
||||
- rangement relu à l'octet près ;
|
||||
- refus : texte seul, URL distante, format inconnu, base64 illisible, deux
|
||||
images ;
|
||||
- corps illisible, GET refusé.
|
||||
@@ -0,0 +1,112 @@
|
||||
# Lot 33 — Préchauffage (PREWARM) tenu par la façade
|
||||
|
||||
## Contexte
|
||||
|
||||
Certains recalculs sont inévitables :
|
||||
|
||||
- le prompt réécrit par une compaction ;
|
||||
- le dernier message assistant rendu autrement au tour suivant ;
|
||||
- la discussion reprise après une tâche qui a pris le slot.
|
||||
|
||||
Sans la clé, ils tombent sur le premier jeton du message suivant.
|
||||
|
||||
`PREWARM` (`chat_prewarm.go`) envoie au moteur local, dès qu'il est libre, la
|
||||
requête du prochain tour telle qu'elle partira, suivie d'une sentinelle « . »
|
||||
et avec `max_tokens` 1. Le moteur calcule tout le préfixe ; le vrai tour ne
|
||||
calcule plus que son propre message.
|
||||
|
||||
Le préchauffage ne passe jamais devant un vrai travail :
|
||||
|
||||
- toute requête de Loki l'annule, sauf un tour qui le prolonge exactement ;
|
||||
- un seul est en vol à la fois ;
|
||||
- aucune erreur ne remonte.
|
||||
|
||||
La clé gardait la conversation au Go.
|
||||
|
||||
## Le lot
|
||||
|
||||
La façade tient l'historique : c'est elle qui assemble et envoie. Le Go
|
||||
garde le compteur des requêtes en vol (`engineGate`) et ce qui annule.
|
||||
|
||||
### Go (`facade_prewarm.go`)
|
||||
|
||||
- `/internal/v1/prewarm/check` dit si le préchauffage peut partir :
|
||||
- clé posée, moteur local ;
|
||||
- ni travail annexe, ni bench, ni optimiseur en cours ;
|
||||
- loin de minuit ;
|
||||
- moteur en bonne santé ;
|
||||
- slot visé : un seul slot, ou celui de la discussion avec SIDE_SLOT.
|
||||
- `/internal/v1/prewarm/begin` :
|
||||
- recharge l'état SLOT_PERSIST de la discussion ;
|
||||
- inscrit le préchauffage comme LA requête en vol (`prewarmBegin`) ;
|
||||
- son annulation (une autre requête part) est relayée à la façade
|
||||
(`/internal/facade/v1/chat/prewarm/cancel`).
|
||||
- `/internal/v1/prewarm/end` :
|
||||
- rend la place ;
|
||||
- note ce que le moteur a repris (`noteEnginePrompt`).
|
||||
- `/internal/v1/oai/begin` accepte `prewarm_keep` : l'étape d'un tour de la
|
||||
façade qui prolonge le préchauffage ne l'annule pas.
|
||||
- Après une tâche planifiée, le déclencheur part à la façade
|
||||
(`/internal/facade/v1/chat/prewarm`).
|
||||
- PREWARM ne garde plus la conversation.
|
||||
|
||||
### Rust (`chat/prewarm.rs`)
|
||||
|
||||
- L'état partagé :
|
||||
- le préchauffage en vol, son inscription, son annulation ;
|
||||
- les capacités du dernier tour de chaque discussion (64 au plus).
|
||||
- L'assemblage : les mêmes fonctions que le tour.
|
||||
- Vue du Go, historique sans blocs `<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.
|
||||
@@ -0,0 +1,92 @@
|
||||
# Lot 34 — Bloc projet figé (PROJ_SNAPSHOT) dans le tour de la façade
|
||||
|
||||
## Contexte
|
||||
|
||||
Le contexte du projet part en tête du premier message utilisateur :
|
||||
|
||||
- description ;
|
||||
- index mémoire ;
|
||||
- trackers ;
|
||||
- consignes du dépôt ;
|
||||
- avec la clé, la date et le dossier de travail.
|
||||
|
||||
Reconstruit à chaque tour, il change dès qu'une page est créée ou qu'un
|
||||
tracker reçoit un point. Tout ce qui le suit dans le prompt est alors
|
||||
recalculé.
|
||||
|
||||
`PROJ_SNAPSHOT` (`chat_projsnap.go`) garde une copie figée du bloc par
|
||||
discussion. Les changements arrivent en `<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`.
|
||||
+48
@@ -0,0 +1,48 @@
|
||||
# Lot 35 — Routes de base par le tunnel du relais
|
||||
|
||||
## Contexte
|
||||
|
||||
La façade sert toujours neuf routes, sans variable (`api.rs`) :
|
||||
|
||||
- `/api/ping` ;
|
||||
- `/api/status` ;
|
||||
- `/api/config` ;
|
||||
- `/api/engine/cmdline` ;
|
||||
- `/api/model/layers` ;
|
||||
- `/api/paths` ;
|
||||
- `/api/ram` ;
|
||||
- `/api/vram` ;
|
||||
- `/api/service/log`.
|
||||
|
||||
Mais le tunnel du relais (accès distant, E2E) sert le mux du Go tel quel.
|
||||
`withFacadeChat` ne remet à la façade que les routes des lots. Un utilisateur
|
||||
distant tombait donc encore sur les handlers Go de ces neuf routes : deux
|
||||
implémentations à garder d'accord.
|
||||
|
||||
## Le lot
|
||||
|
||||
- Go (`facade_core.go`) : une table des routes de base, remises à la façade
|
||||
par `withFacadeChat` dès qu'elle tourne (adresse et jeton interne
|
||||
présents), sans variable. Façade muette : 502 « façade indisponible ».
|
||||
Sans façade (`LOKI_FACADE=0`), le Go les sert lui-même.
|
||||
- Rust (`api.rs`) : une requête relayée par le Go (jeton interne) a déjà été
|
||||
authentifiée par lui. Ni clé ni hôte local ne sont exigés de nouveau,
|
||||
comme pour les routes des lots ; la version du Go est lue quand même pour
|
||||
`/api/ping`.
|
||||
|
||||
## Tests
|
||||
|
||||
- Go (`facade_core_test.go`) :
|
||||
- les neuf routes relayées avec le jeton ;
|
||||
- une route du Go gardée ;
|
||||
- façade muette ;
|
||||
- sans façade, servies ici.
|
||||
- Rust (`tests/lot2.rs`) :
|
||||
- relayée par le Go avec un hôte distant et une clé posée : servie par la
|
||||
façade, pas renvoyée au Go ;
|
||||
- sans jeton, ou avec un faux : refusée.
|
||||
|
||||
## Reste à faire
|
||||
|
||||
L'interface statique et `/v1` passent encore par le Go sur le tunnel. Ses
|
||||
handlers sont gardés pour `LOKI_FACADE=0`.
|
||||
@@ -0,0 +1,56 @@
|
||||
# Lot 36 — Appels d'outils MCP sans détour par le Go
|
||||
|
||||
## Contexte
|
||||
|
||||
Depuis le lot 20, la façade tient le pool MCP (`LOKI_MCP=rust`) : sessions
|
||||
stdio et HTTP, registre des outils exposés. Le tour de la façade, lui,
|
||||
faisait encore exécuter un appel `mcp__*` par le Go
|
||||
(`/internal/v1/tool/exec`).
|
||||
|
||||
Le Go ne l'exécutait pas lui-même : `mcpCall` renvoyait l'appel à la façade
|
||||
(`/internal/facade/v1/mcp/call`). Chaque appel faisait donc deux
|
||||
allers-retours locaux pour revenir au même pool.
|
||||
|
||||
Pour un outil MCP, le Go n'ajoute rien au résultat (`toolStepExtras`) :
|
||||
|
||||
- ni capture ;
|
||||
- ni aperçu du navigateur ;
|
||||
- ni image relayée.
|
||||
|
||||
Seul le texte revient.
|
||||
|
||||
## Le lot
|
||||
|
||||
- `ChatShared` porte le pool (`mcp`), le même que celui des routes (créé une
|
||||
fois dans `main.rs`).
|
||||
- `GoTurnHost::exec_tool` : un nom `mcp__*`, pool tenu → `Mcp::call`
|
||||
directement. Le résultat est celui de la route interne :
|
||||
- texte aplati et coupé ;
|
||||
- « [l'outil a renvoyé une erreur] » ;
|
||||
- « [ok] (aucune sortie) » ;
|
||||
- une reconnexion si la session est morte, puis « [erreur MCP] … ».
|
||||
- Une reconnexion annonce la version du Go dans `clientInfo`, comme la route
|
||||
interne. Elle est lue une fois (`/internal/v1/auth`) et retenue.
|
||||
- Sans pool tenu (`LOKI_MCP` absent), rien ne change : l'appel part au Go,
|
||||
qui l'exécute avec son propre pool.
|
||||
|
||||
Le Go garde la route interne pour les tours qu'il exécute encore :
|
||||
|
||||
- sous-agents ;
|
||||
- tâches IA ;
|
||||
- discussion rendue au Go.
|
||||
|
||||
## Tests
|
||||
|
||||
`tests/lot6.rs` (`outil_mcp_par_le_pool_de_la_facade`) fait tourner un faux
|
||||
serveur MCP stdio. Un tour de la façade appelle :
|
||||
|
||||
- un outil qui répond ;
|
||||
- un outil dont le serveur meurt.
|
||||
|
||||
Le test vérifie :
|
||||
|
||||
- aucun appel au Go ;
|
||||
- le résultat de chaque outil tel que le modèle le reçoit ;
|
||||
- l'erreur après une reconnexion ;
|
||||
- la version du Go annoncée à cette reconnexion.
|
||||
@@ -0,0 +1,82 @@
|
||||
# Lot 37 — Outils task_* de l'agent en Rust
|
||||
|
||||
## Contexte
|
||||
|
||||
Depuis le lot 26, la façade tient les tâches planifiées (`LOKI_TASKS=rust`) :
|
||||
|
||||
- les routes ;
|
||||
- le planificateur ;
|
||||
- les tâches « script seul ».
|
||||
|
||||
Les quatre outils par lesquels le modèle gère lui-même ses tâches
|
||||
(`tasks_tools.go`) restaient au Go, appelés par `/internal/v1/tool/exec` :
|
||||
|
||||
- `task_list` ;
|
||||
- `task_create` ;
|
||||
- `task_update` ;
|
||||
- `task_delete`.
|
||||
|
||||
## Le lot
|
||||
|
||||
`src/tasks/tools.rs` porte les quatre outils, mêmes messages au caractère
|
||||
près :
|
||||
|
||||
- **`task_list`** :
|
||||
- liste du projet actif, aperçu d'une ligne par tâche ;
|
||||
- compte-rendu coupé à 300 octets, un caractère coupé valant U+FFFD par
|
||||
octet comme à l'encodage JSON du Go ;
|
||||
- avec un id, la tâche seule et son compte-rendu complet ;
|
||||
- une tâche d'un autre projet est « introuvable » ;
|
||||
- **`task_create`** :
|
||||
- tâche unique « dans N minutes » (durée tronquée comme
|
||||
`time.Duration(v)`) ou « à HH:MM » (le lendemain si l'heure est passée,
|
||||
à la même heure murale) ou « à AAAA-MM-JJ HH:MM » ;
|
||||
- fréquence validée ;
|
||||
- script vérifié dans le dossier protégé ;
|
||||
- tâche rattachée au projet actif ;
|
||||
- fuseau : celui du navigateur, sinon celui de la première tâche qui en a
|
||||
un ;
|
||||
- **`task_update`** : modification partielle ; la prochaine exécution est
|
||||
recalculée sur une nouvelle fréquence, ou à la reprise d'une tâche qui
|
||||
n'en a pas ;
|
||||
- **`task_delete`**.
|
||||
|
||||
Un projet disparu vaut le projet par défaut (`taskProjectOf`).
|
||||
|
||||
`GoTurnHost::exec_tool` les exécute quand la façade tient les tâches
|
||||
(`ChatShared.tasks`, la même base que celle des routes). Sinon l'appel part
|
||||
au Go comme avant.
|
||||
|
||||
Côté Go, les outils prennent l'heure des tâches (`tasksNow`) au lieu de
|
||||
`time.Now`. Rien ne change en service ; le journal de parité peut ainsi la
|
||||
fixer.
|
||||
|
||||
## Parité
|
||||
|
||||
`internal/loki/parity_tasks_tools_test.go` tire du Go un journal de 44
|
||||
appels (`tests/parity_tasks/tools.json`). Chaque étape porte :
|
||||
|
||||
- l'heure, le fuseau, le projet actif, les projets connus ;
|
||||
- les scripts présents ;
|
||||
- une tâche posée telle quelle en base ;
|
||||
- les arguments tels que le modèle les envoie ;
|
||||
- le résultat tel qu'il le reçoit ;
|
||||
- les tâches en base après coup.
|
||||
|
||||
Le journal couvre :
|
||||
|
||||
- l'heure du trou du changement d'heure ;
|
||||
- une date passée ou impossible ;
|
||||
- des arguments d'un autre type ;
|
||||
- un script absent, hostile ou dossier ;
|
||||
- le compte-rendu coupé au milieu d'un caractère ;
|
||||
- le fuseau d'une tâche existante.
|
||||
|
||||
`tests/tasks.rs` (`parite_des_outils`) le rejoue contre le port Rust.
|
||||
|
||||
`tests/lot6.rs` (`outil_task_par_la_facade`) fait tourner un tour de la
|
||||
façade qui crée une tâche. Il vérifie :
|
||||
|
||||
- rien ne passe par le Go ;
|
||||
- la tâche est rangée au fuseau de l'utilisateur, dans le projet actif ;
|
||||
- le résultat est celui que le modèle reçoit.
|
||||
@@ -0,0 +1,77 @@
|
||||
# Lot 38 — L'outil criteria du mode Code dans la façade
|
||||
|
||||
## Contexte
|
||||
|
||||
En mode Code, le modèle tient les critères d'acceptation de sa tâche avec
|
||||
l'outil `criteria` :
|
||||
|
||||
- les poser ;
|
||||
- les marquer faits ;
|
||||
- les lister ;
|
||||
- les effacer.
|
||||
|
||||
La passe de vérification les marque ensuite `passed` ou `failed`.
|
||||
|
||||
Depuis le lot 19, la façade sert déjà les critères de l'écran
|
||||
(`/api/chat/criteria`, `chat/crit:<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.
|
||||
@@ -0,0 +1,92 @@
|
||||
# Lot 39 — Anneau de télémétrie tenu par la façade
|
||||
|
||||
## Contexte
|
||||
|
||||
Chaque complétion qui passe par le moteur est rangée dans un anneau
|
||||
(`perf_log.go`). L'anneau :
|
||||
|
||||
- calcule la perte de cache d'une complétion contre la précédente de la même
|
||||
discussion et de la même nature ;
|
||||
- nomme ce qui s'est intercalé (sous-agent, client externe…) ;
|
||||
- signale une perte au-delà du seuil du preset (stats finales) ;
|
||||
- nourrit `/api/perf/summary` et les poids de l'optimiseur.
|
||||
|
||||
Depuis le lot 29, la façade envoyait chaque complétion de ses tours au Go
|
||||
(`/internal/v1/turn/perf`). C'était un aller-retour par complétion pour un
|
||||
anneau tenu ailleurs.
|
||||
|
||||
## Le lot
|
||||
|
||||
**Rust** (`src/perfring.rs`, port de `perf_log.go`) :
|
||||
|
||||
- l'anneau : 5000 entrées, 128 discussions suivies, 500 entrées remontées au
|
||||
plus pour nommer ce qui s'est intercalé ;
|
||||
- la perte : seulement si la complétion prolonge strictement la précédente
|
||||
(empreintes cumulées des messages), cache connu, complétion allée au bout ;
|
||||
- la ligne `[perf]` (LOKI_PERF_LOG) ;
|
||||
- le seuil d'alerte, dans le même ordre de priorité qu'au lancement :
|
||||
- `-cms` d'EXTRA_ARGS ;
|
||||
- `LLAMA_ARG_CHECKPOINT_MIN_SPACING_NT` ;
|
||||
- `CKPT_MIN_STEP` ;
|
||||
- 2048 sur un modèle hybride ;
|
||||
- plus le micro-batch ;
|
||||
- le résumé de `/api/perf/summary` (par nature, par profondeur, brouillon,
|
||||
dernier verdict de la sonde de gabarit).
|
||||
|
||||
`ChatShared` porte l'anneau. Les tours, les passes de vérification, les
|
||||
résumés de compaction et le préchauffage de la façade y rangent leurs
|
||||
complétions directement. Les marques (perte, ce qui s'est intercalé,
|
||||
alerte) partent sur les stats finales comme avant.
|
||||
|
||||
La façade sert :
|
||||
|
||||
- `POST /internal/facade/v1/perf/record` : une complétion faite par le Go
|
||||
(sous-agent, tâche IA, bench, compaction et préchauffage de ses tours,
|
||||
client externe de `/v1`), rendue telle que rangée ;
|
||||
- `POST /internal/facade/v1/perf/snapshot` : l'anneau, pour les poids de
|
||||
l'optimiseur ;
|
||||
- `GET /api/perf/summary`.
|
||||
|
||||
**Go** (`facade_perf.go`) : quand la façade tient la conversation,
|
||||
`perfRecord` range chez elle, et `perfSnapshot` relit son anneau
|
||||
(optimiseur, contexte de `/internal/v1/tune/context`). `/api/perf/summary`
|
||||
lui est remis (`facadeChatRoutes`). Façade injoignable : l'anneau du Go
|
||||
prend le relais. Sans façade, ou la conversation rendue au Go
|
||||
(`LOKI_CHAT=go`), rien ne change.
|
||||
|
||||
La route `/internal/v1/turn/perf` est retirée : plus personne ne l'appelle.
|
||||
|
||||
## Parité
|
||||
|
||||
`internal/loki/parity_perf_test.go` tire du Go un journal
|
||||
(`tests/parity_perf/perf.json`). Quatorze complétions y sont rangées, avec
|
||||
les empreintes de leurs messages ; elles couvrent :
|
||||
|
||||
- prolongement ;
|
||||
- perte après un sous-agent et un client externe ;
|
||||
- cache inconnu ;
|
||||
- divergence ;
|
||||
- autre discussion ;
|
||||
- complétion coupée ;
|
||||
- compaction, bench, vérification.
|
||||
|
||||
Pour chaque complétion, le journal garde ce qui est rangé, la ligne
|
||||
`[perf]` et l'alerte. Il porte aussi le résumé, le résumé vide, et onze
|
||||
seuils d'alerte. `src/perfring/tests.rs` le rejoue, avec l'anneau borné et
|
||||
l'heure posée.
|
||||
|
||||
`tests/lot6.rs` :
|
||||
|
||||
- `cache_perdu_mesure_par_la_facade` : deux tours de la même discussion ;
|
||||
la perte est mesurée par la façade et signalée dans les stats finales ;
|
||||
- `anneau_de_la_facade_pour_le_go` : ce que le Go confie (perte après un
|
||||
client externe), ce qu'il relit, le jeton exigé, `/api/perf/summary`
|
||||
servi par la façade ;
|
||||
- les tests du lot 29 lisent désormais l'anneau de la façade.
|
||||
|
||||
`internal/loki/facade_perf_test.go` (`TestFacadePerfAnneau`) :
|
||||
|
||||
- complétion confiée à la façade ;
|
||||
- anneau relu ;
|
||||
- repli sur l'anneau du Go quand la façade est en panne ;
|
||||
- route remise.
|
||||
@@ -0,0 +1,87 @@
|
||||
# Lot 40 — Outils mémoire de l'agent dans la façade
|
||||
|
||||
## Contexte
|
||||
|
||||
Depuis le lot 13, la façade tient la mémoire quand `LOKI_MEM=rust` :
|
||||
|
||||
- pages ;
|
||||
- index `MEMORY.md` ;
|
||||
- trackers ;
|
||||
- mode mémoire.
|
||||
|
||||
Les outils par lesquels le modèle s'en sert partaient pourtant au Go
|
||||
(`/internal/v1/tool/exec`) :
|
||||
|
||||
- `mem_search`, `mem_read`, `mem_add`, `mem_edit` ;
|
||||
- `tracker`.
|
||||
|
||||
Deux processus écrivaient donc les mêmes pages et le même index.
|
||||
|
||||
## Le lot
|
||||
|
||||
`src/memory/tools.rs` porte les outils (`chat_memory.go`, `tracker.go`),
|
||||
mêmes résultats au caractère près. Il réutilise les lectures et écritures des
|
||||
routes de la mémoire :
|
||||
|
||||
- pages scellées selon le chiffrement ;
|
||||
- `.bak` de la version précédente ;
|
||||
- écriture vérifiée ;
|
||||
- index tenu par le code.
|
||||
|
||||
Chaque outil :
|
||||
|
||||
- **`mem_search`** : classement par couverture des termes puis par
|
||||
pertinence (rareté IDF, fréquence amortie, bonus du nom et du titre) ;
|
||||
extrait coupé à l'octet comme le Go (U+FFFD par octet d'un caractère
|
||||
coupé) ; limite 8 par défaut, 30 au plus ;
|
||||
- **`mem_read`** : lignes numérotées, plage, 500 au plus ;
|
||||
- **`mem_add`** : jamais par-dessus une page existante, index tenu (seed au
|
||||
nom du projet), diff d'ajout ;
|
||||
- **`mem_edit`** : `old` unique, édition déjà en place reconnue, diff ;
|
||||
- **`tracker`** :
|
||||
- vue d'ensemble ;
|
||||
- squelette (années, année en cours par mois) ;
|
||||
- une année par mois ;
|
||||
- points (60 plus récents au plus) ;
|
||||
- ajout, modification, suppression d'un point (jamais d'un tracker
|
||||
entier) ;
|
||||
- arguments d'un autre type rendus comme `str` du Go.
|
||||
|
||||
`tracker_add` prend désormais l'heure en paramètre (`tracker_add_at`).
|
||||
|
||||
`ChatShared.memory` porte la même mémoire que les routes. `GoTurnHost::exec_tool`
|
||||
y exécute les outils quand la façade la tient ; sinon l'appel part au Go
|
||||
comme avant.
|
||||
|
||||
Côté Go, l'heure des trackers (« maintenant », année en cours) passe par
|
||||
`trackerNow`, que le journal fixe ; rien ne change en service.
|
||||
|
||||
## Parité
|
||||
|
||||
`internal/loki/parity_mem_tools_test.go` tire du Go un journal de 57 appels
|
||||
(`tests/parity_mem/outils.json`), en fuseau UTC et à heure fixée. Chaque
|
||||
étape porte :
|
||||
|
||||
- les arguments ;
|
||||
- le libellé du tour ;
|
||||
- le résultat, le diff ;
|
||||
- les fichiers de la mémoire du projet (`.bak` et index compris) ;
|
||||
- les trackers en base.
|
||||
|
||||
Il couvre :
|
||||
|
||||
- recherche : couverture, rareté, requête vide, limites, extrait coupé ;
|
||||
- lecture : plage, au-delà, page absente, nom invalide ;
|
||||
- ajout : existant, nom invalide, majuscules ;
|
||||
- modification : rejouée, déjà en place, absente, en double, vide ;
|
||||
- trackers : chaque vue, dates illisibles, nom en nombre, identifiants
|
||||
inconnus.
|
||||
|
||||
`tests/memory.rs` (`outils_memoire_comme_le_go`) le rejoue contre le port.
|
||||
|
||||
`tests/lot6.rs` (`outil_memoire_par_la_facade`) fait tourner un tour de la
|
||||
façade qui crée une page. Il vérifie :
|
||||
|
||||
- rien ne passe par le Go ;
|
||||
- la page est rangée dans le projet actif, l'index tenu ;
|
||||
- le résultat est celui que le modèle reçoit.
|
||||
@@ -0,0 +1,133 @@
|
||||
# Lot 41 — Compilation de llama.cpp depuis les sources dans la façade
|
||||
|
||||
## Contexte
|
||||
|
||||
Depuis le lot 22, la façade tient les backends llama.cpp quand
|
||||
`LOKI_BACKENDS=rust` :
|
||||
|
||||
- statut, vérification git ;
|
||||
- binaires précompilés ;
|
||||
- bascule fast/opt ;
|
||||
- backends custom ;
|
||||
- le job des moteurs.
|
||||
|
||||
La compilation depuis les sources restait au Go. Il en rapportait chaque
|
||||
étape au job de la façade par l'API interne
|
||||
(`/internal/facade/v1/build-job/*`). Elle couvre trois routes :
|
||||
|
||||
- `POST /api/llamacpp/install` `{force}` ;
|
||||
- `POST /api/llamacpp/update` `{clean}` ;
|
||||
- `POST /api/llamacpp/install-custom` `{repo, name, ref}`.
|
||||
|
||||
## Le lot
|
||||
|
||||
`src/backends/build.rs` porte la compilation sous Linux et macOS. Sources Go :
|
||||
|
||||
- `backend_build.go` : plan, CMake, étapes suivies ;
|
||||
- `lcRunInstall`, `lcRunUpdate`, `lcBuildAndSwitch`, `lcAppendLogTail`
|
||||
(`web_llamacpp.go`) ;
|
||||
- `installCustomBackend`, `deriveBackendName`, `looksLikeGitURL`
|
||||
(`backend_llamacpp.go`) ;
|
||||
- `autoInstallTool`, `ensureCompiler` (`sys_platform_unix.go`).
|
||||
|
||||
Le port couvre :
|
||||
|
||||
- **le plan complet** (`detectBuildPlan`) :
|
||||
- drapeaux Release, natif, UI de llama-server coupée ;
|
||||
- Metal sous macOS ;
|
||||
- CUDA quand nvcc et un GPU NVIDIA sont là : F16, racine du toolkit et
|
||||
compilateur explicites hors `/usr`, architectures lues par
|
||||
`nvidia-smi` ;
|
||||
- HIP, puis Vulkan ;
|
||||
- **CMake** :
|
||||
- `build/` configuré ailleurs (dépôt déplacé) ou `clean` : mis de côté en
|
||||
`build.old` ;
|
||||
- nvcc dans le PATH et `CUDACXX` ;
|
||||
- `configure.log` et `build.log` dans le dépôt ;
|
||||
- erreurs « configuration CMake échouée » et « compilation échouée » ;
|
||||
- **les étapes suivies** :
|
||||
- sorties standard et d'erreur dans un même tube, ordre gardé ;
|
||||
- git : découpage du `sinkWriter` (barre qui se redessine montrée toutes
|
||||
les 10 s, reste sans fin de ligne perdu) ;
|
||||
- CMake : découpage du `bufio.Scanner` (dernier morceau rendu, ligne de
|
||||
plus de 1 Mio qui arrête la lecture) ;
|
||||
- octets invalides en U+FFFD un à un ;
|
||||
- erreurs au format d'`exec` (`exit status N`, `signal: killed`,
|
||||
programme introuvable) ;
|
||||
- **les outils** :
|
||||
- git et cmake, puis cc, c++, make ;
|
||||
- un manquant est installé au mieux par apt-get, dnf, pacman ou brew
|
||||
(sudo hors root) ;
|
||||
- sinon le message du Go ;
|
||||
- **les jobs** :
|
||||
- installation : clone, compilation propre, BIN ; dépôt présent : bascule
|
||||
en mise à jour ; `force` : supprimé puis recloné ;
|
||||
- mise à jour : fetch, pull au besoin, commits avant et après ;
|
||||
« déjà à jour » sans recompiler ; service arrêté le temps du build et
|
||||
relancé même en échec ;
|
||||
- échec du build : fin des deux journaux (30 lignes) remontée dans le job ;
|
||||
- backend custom : URL vérifiée, nom tiré du dépôt (propriétaire ajouté à
|
||||
un fork nommé llama.cpp) ou assaini, clone ou mise à jour, ref, sans
|
||||
toucher à BIN.
|
||||
|
||||
Les routes répondent comme le Go :
|
||||
|
||||
- moteur fourni par l'image : 409 et le message du Go ;
|
||||
- custom sans URL : 400 « URL du dépôt requise » ;
|
||||
- optimisation en cours : 409 ;
|
||||
- job déjà lancé : 409 ;
|
||||
- sinon 200 `{"ok":true}` et le job tourne.
|
||||
|
||||
Le service moteur est celui de la façade si elle le pilote, sinon celui du
|
||||
Go (`/internal/v1/service/action`), comme pour les binaires précompilés.
|
||||
|
||||
Côté Go, `facadeBackendsRoutes` gagne les trois routes hors Windows. Sous
|
||||
Windows, la compilation reste au Go, qui rapporte toujours ses étapes à la
|
||||
façade :
|
||||
|
||||
- générateur Visual Studio ;
|
||||
- intégration CUDA de MSBuild ;
|
||||
- `CUDA_PATH`.
|
||||
|
||||
Les messages du terminal du Go (outils manquants, dépendance connue absente
|
||||
après un configure en échec) vont à la sortie d'erreur de la façade.
|
||||
|
||||
## Parité
|
||||
|
||||
`internal/loki/parity_build_test.go` (Linux) tire du Go un journal de 15
|
||||
étapes (`tests/parity_backends/build.json`). De faux git, cmake et
|
||||
compilateur, rangés dans le journal, sont seuls sur le PATH. Chaque étape
|
||||
porte :
|
||||
|
||||
- la vue du job (lignes, phase, erreur, commits, fichiers compilés) ;
|
||||
- BIN ;
|
||||
- les dépôts qui ont un `build.old`.
|
||||
|
||||
Il couvre :
|
||||
|
||||
- mise à jour sans dépôt ;
|
||||
- installation, puis installation sur un dépôt présent et installation
|
||||
forcée ;
|
||||
- déjà à jour, nouveau commit, recompilation propre ;
|
||||
- build en échec (fin des journaux) ;
|
||||
- cmake absent, compilateur absent ;
|
||||
- clone en échec ;
|
||||
- custom : URL invalide, nouveau fork, nom assaini sur un ref, dossier
|
||||
existant sur un ref (URL `git@`).
|
||||
|
||||
`tests/backends.rs` (`compilation_comme_le_go`) rejoue le journal par la
|
||||
façade : mêmes faux outils dans le PATH des sondes, POST de la route, job
|
||||
suivi jusqu'au bout. `compilation_refusee` vérifie les refus des routes.
|
||||
|
||||
Les tests unitaires de `build.rs` couvrent :
|
||||
|
||||
- la barre de progression (`TestSinkWriterProgression`) ;
|
||||
- le scanner (fin `\r\n`, dernier morceau, octets invalides, ligne
|
||||
démesurée) ;
|
||||
- la racine du toolkit CUDA (`TestCudaToolkitRoot`) ;
|
||||
- le cache CMake déplacé ;
|
||||
- noms et URL des backends custom ;
|
||||
- le plan par machine (CPU, nvcc du PATH, toolkit versionné,
|
||||
architectures, HIP, Vulkan) ;
|
||||
- les erreurs des commandes (code de sortie, signal, programme absent,
|
||||
journal écrit).
|
||||
@@ -0,0 +1,121 @@
|
||||
# Lot 42 — Outils web de l'agent dans la façade
|
||||
|
||||
## Contexte
|
||||
|
||||
La façade tient la conversation par défaut. Les outils web de ses tours
|
||||
partaient pourtant au Go (`/internal/v1/tool/exec`) :
|
||||
|
||||
- `web_search` ;
|
||||
- `web_images` ;
|
||||
- `web_open`, `web_read`, `web_grep`.
|
||||
|
||||
Le cache des pages (10 min) était donc au Go : `web_open` puis `web_read`
|
||||
faisaient chacun un aller-retour.
|
||||
|
||||
## Le lot
|
||||
|
||||
`src/webtools.rs` porte les outils. Sources Go :
|
||||
|
||||
- `chat_internet_tools.go` : les quatre outils de lecture et de recherche ;
|
||||
- `chat_internet.go` : client Crawl4AI, cache, lecture des résultats
|
||||
DuckDuckGo ;
|
||||
- `chat_web_images.go` : recherche d'images.
|
||||
|
||||
Ce qui est porté :
|
||||
|
||||
- **le cache** :
|
||||
- clé : l'URL, suivie de l'empreinte MD5 des options qui changent la page
|
||||
(actions, attente, bandeaux), à l'échappement HTML près du Go ;
|
||||
- `web_read` et `web_grep` lisent l'ouverture la plus récente d'une URL,
|
||||
toutes options confondues ;
|
||||
- 10 minutes ;
|
||||
- **Crawl4AI** :
|
||||
- corps envoyé au Go près (clés triées, échappement HTML) ;
|
||||
- clé d'accès en `Bearer` ;
|
||||
- réponse `{results:[…]}` ou résultat seul, lue comme `json.Unmarshal`
|
||||
(types faux, `null`, casse des clés) ;
|
||||
- markdown en chaîne ou `{raw_markdown, fit_markdown}` ;
|
||||
- erreurs : HTTP (fin du corps), échec du crawl, réponse vide, serveur
|
||||
injoignable (texte de l'erreur du client Go) ;
|
||||
- **les lignes** : `\r\n`, blancs de fin, vides et doublons consécutifs
|
||||
réduits ; plan des titres ;
|
||||
- **la recherche par Crawl4AI** : titres, liens `uddg` décodés deux fois
|
||||
comme le Go, entités HTML, extrait tiré des liens qui suivent, doublons et
|
||||
liens DuckDuckGo écartés, défi anti-bot ;
|
||||
- **les images** :
|
||||
- jeton `vqd` de la page, puis `i.js` ;
|
||||
- mêmes en-têtes ;
|
||||
- cookies gardés par hôte, comme le jar du client Go ;
|
||||
- réponse lue comme `json.Unmarshal` (erreurs de syntaxe et de type au
|
||||
texte du Go) ;
|
||||
- **`web_grep`** :
|
||||
- regex du Go (`(?i)`, classes ASCII) ;
|
||||
- erreurs au texte de `regexp` pour les fautes courantes ;
|
||||
- **le plafond** de 8000 caractères ;
|
||||
- **les arguments** : nombres tronqués comme `int(float64)` du Go.
|
||||
|
||||
Le moteur intégré lit le HTML avec go-readability, html-to-markdown et le
|
||||
parseur HTML5 de x/net : rien en Rust ne les reproduit au caractère près.
|
||||
Ces deux étapes restent au Go, derrière deux appels sans état :
|
||||
|
||||
- `POST /internal/v1/web/fetch` `{url}` → `{markdown}` ou `{error}`
|
||||
(`goFetchMarkdown`) ;
|
||||
- `POST /internal/v1/web/search` `{query, limit}` → `{results}` ou `{error}`
|
||||
(`goSearch`).
|
||||
|
||||
`ChatShared.web` porte les outils ; `GoTurnHost::exec_tool` les exécute
|
||||
avant tout le reste. `web_screenshot` reste au Go (navigateur piloté).
|
||||
|
||||
Le traducteur des regex du Go (`go_regex`) refuse désormais :
|
||||
|
||||
- la répétition d'une répétition (`a**`, `a*??`, `a{2}*`) ;
|
||||
- un compte au-delà de 1000 ou inversé.
|
||||
|
||||
Un `{` qui n'ouvre pas de répétition y devient littéral, comme au Go. Les
|
||||
motifs `retry_patterns` en profitent.
|
||||
|
||||
Côté Go, pour le journal :
|
||||
|
||||
- `webNow` : l'horloge du cache ;
|
||||
- `ddgImagesBase` et `ddgHTMLBase` : les adresses de DuckDuckGo.
|
||||
|
||||
## Écart connu
|
||||
|
||||
reqwest pose `Accept: */*` sur une requête qui n'en a pas ; le client Go
|
||||
n'en pose aucun. Seule la page de recherche d'images est concernée, sans
|
||||
effet sur DuckDuckGo.
|
||||
|
||||
## Parité
|
||||
|
||||
`internal/loki/parity_web_tools_test.go` tire du Go un journal de 74
|
||||
appels (`tests/parity_web/outils.json`). Chaque appel porte :
|
||||
|
||||
- le résultat ;
|
||||
- les requêtes reçues par le faux Crawl4AI (méthode, clé, type, corps) ;
|
||||
- les requêtes reçues par le faux DuckDuckGo (chemin, en-têtes, cookies) ;
|
||||
- les pages et recherches demandées au moteur intégré.
|
||||
|
||||
Il couvre :
|
||||
|
||||
- recherche : limites, anti-bot, aucun résultat, panne ;
|
||||
- lecture : plages, bornes, au-delà, page jamais ouverte ;
|
||||
- grep : contexte fusionné, plafond, sans résultat, classes ASCII, sept
|
||||
regex invalides ;
|
||||
- cache : réouverture, forcée, options, la plus récente, péremption ;
|
||||
- GitHub ;
|
||||
- les réponses de Crawl4AI ;
|
||||
- serveur injoignable, sans serveur, sans clé ;
|
||||
- moteur intégré : ouverture, texte brut, page absente, recherche ;
|
||||
- images : doublons, cookie gardé, jeton absent, pannes, réponses
|
||||
illisibles ou mal typées, `null`.
|
||||
|
||||
`tests/webtools.rs` le rejoue contre le port. Le faux Go sert ce que le
|
||||
moteur intégré a tiré des pages.
|
||||
|
||||
D'autres tests complètent le journal :
|
||||
|
||||
- `tests/lot6.rs` (`outil_web_par_la_facade`) : un tour de la façade ouvre
|
||||
une page sans passer par `/tool/exec` ;
|
||||
- `TestInternalMoteurWebIntegre` : les deux appels internes du Go ;
|
||||
- tests unitaires : MD5, clés du cache, URL, entités, nombres, erreurs de
|
||||
regex, réponses de Crawl4AI.
|
||||
@@ -0,0 +1,82 @@
|
||||
# Lot 43 — Jobs d'arrière-plan du mode Code dans la façade
|
||||
|
||||
## Contexte
|
||||
|
||||
En mode Code, le modèle lance un serveur de dev, un watcher ou un build long
|
||||
avec `bash_bg`. Il en suit la sortie avec `bash_tail`, qui sait aussi
|
||||
l'arrêter. Les deux outils partaient au Go (`/internal/v1/tool/exec`), qui
|
||||
gardait les processus. C'étaient les derniers outils shell du tour encore au
|
||||
Go, `bash` étant porté depuis le lot 7.
|
||||
|
||||
## Le lot
|
||||
|
||||
`src/tools/jobs.rs` porte `code_jobs.go`.
|
||||
|
||||
**`bash_bg`** :
|
||||
|
||||
- refus avant tout lancement :
|
||||
- commande vide ;
|
||||
- commande dangereuse (même politique que `bash`) ;
|
||||
- optimisation en cours (`/internal/v1/tune/guard`) ;
|
||||
- journal `.loki/jobs/<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.
|
||||
@@ -0,0 +1,138 @@
|
||||
# Lot 44 — Navigateur piloté dans la façade
|
||||
|
||||
## Contexte
|
||||
|
||||
Le contrôle du navigateur donne à l'agent un Chromium local, piloté par le
|
||||
protocole DevTools (CDP). L'agent l'utilise ainsi :
|
||||
|
||||
- il ouvre une page et lit les éléments interactifs numérotés ;
|
||||
- il clique, tape, défile par numéro ;
|
||||
- avec la vision, il voit une capture quadrillée et clique en
|
||||
coordonnées.
|
||||
|
||||
La façade tient la conversation par défaut, mais la session Chrome, les
|
||||
outils `browser_*` et l'anneau des aperçus montrés à l'utilisateur étaient
|
||||
au Go (`/internal/v1/tool/exec`).
|
||||
|
||||
## Le lot
|
||||
|
||||
`src/browser/` porte quatre fichiers Go :
|
||||
|
||||
- `computer_cdp.go` : client CDP, lancement, scripts ;
|
||||
- `computer_use.go` : les neuf outils ;
|
||||
- `browser_grid.go` : la grille de la capture ;
|
||||
- `cu_autoshot.go` : les aperçus.
|
||||
|
||||
Ce qui est porté :
|
||||
|
||||
- **le lancement** :
|
||||
- binaire imposé, celui de Playwright ou du système ;
|
||||
- profil temporaire `loki-cu-*`, et ceux de plus d'une heure balayés ;
|
||||
- mêmes arguments (headless sauf `LOKI_CU_HEADFUL`) ;
|
||||
- tué avec la façade (`PR_SET_PDEATHSIG`) ;
|
||||
- port lu dans `DevToolsActivePort`, cible « page » par `/json` ;
|
||||
- viewport fixe 1280×800 ;
|
||||
- script qui garde les nouveaux onglets dans l'onglet courant ;
|
||||
- **la session** :
|
||||
- une seule, lancée à la demande ;
|
||||
- vérifiée par un ping avant chaque outil et relancée si elle ne répond
|
||||
plus ;
|
||||
- commandes multiplexées par numéro, 30 s au plus ;
|
||||
- requêtes réseau suivies pour attendre le calme (600 ms, 6 s au plus)
|
||||
après un chargement ;
|
||||
- **les scripts** injectés, copiés du Go à l'octet près (le journal
|
||||
compare leurs empreintes) :
|
||||
- éléments actionnables et visibles ;
|
||||
- shadow DOM ;
|
||||
- nom d'un champ par son libellé, sans révéler un mot de passe ;
|
||||
- recherche non destructive ;
|
||||
- liste déroulante native réglée par son option ;
|
||||
- **les outils**, mêmes textes :
|
||||
- snapshot dédoublonné ;
|
||||
- indices de défilement ;
|
||||
- erreurs de navigation (`errorText`, page `chrome-error://`,
|
||||
`ERR_ABORTED` bénin) ;
|
||||
- `back` et `forward` ;
|
||||
- touches nommées ;
|
||||
- plafond de 8000 caractères ;
|
||||
- **la capture** (`browser_screenshot`, vision active) :
|
||||
- PNG propre rangé dans le dossier de travail
|
||||
(`capture-AAAAMMJJ-HHMMSS.png`, heure locale) ;
|
||||
- image du modèle quadrillée tous les 100 px, mêmes pixels que le Go ;
|
||||
à 1280×800 elle est sous la taille maximale et part telle quelle ;
|
||||
- relayée au modèle comme `see_image` ;
|
||||
- **les aperçus** :
|
||||
- après une action qui change la page, jamais après une erreur ;
|
||||
- JPEG en mémoire, anneau de huit ;
|
||||
- servis par `/api/chat/cushot`, que le Go remet désormais à la façade.
|
||||
|
||||
`ChatShared.browser` porte le navigateur du processus ;
|
||||
`GoTurnHost::exec_tool` exécute les outils et prend l'aperçu.
|
||||
|
||||
Un seul navigateur : quand la façade tient la conversation, le Go lui passe
|
||||
ses propres appels (`facade_browser.go`) :
|
||||
|
||||
- outils des sous-agents et des tâches IA ;
|
||||
- aperçus ;
|
||||
- fermeture (interrupteur coupé, ligne de commande).
|
||||
|
||||
Ils passent par `POST /internal/facade/v1/browser`, avec trois opérations :
|
||||
`tool`, `shot`, `shutdown`. Si la façade ne répond pas, le navigateur du Go
|
||||
prend le relais. Couper l'interrupteur côté façade (lot 17) ferme les deux.
|
||||
|
||||
## Écarts connus
|
||||
|
||||
- Encodeurs PNG et JPEG : mêmes pixels, octets différents.
|
||||
- Une capture au-delà de 1568 px ne serait pas réduite (le viewport fixe
|
||||
rend le cas impossible).
|
||||
|
||||
## Parité
|
||||
|
||||
`internal/loki/parity_browser_test.go` (Linux) tire du Go un journal de 42
|
||||
étapes (`tests/parity_browser/browser.json`) contre un faux Chrome :
|
||||
|
||||
- un script annonce le port d'un faux serveur CDP ;
|
||||
- le faux CDP répond selon le scénario de chaque étape et note chaque
|
||||
commande.
|
||||
|
||||
Chaque étape porte :
|
||||
|
||||
- le résultat ;
|
||||
- les commandes CDP (méthode, paramètres, scripts résumés par leur
|
||||
empreinte) ;
|
||||
- pour une capture, ses dimensions et dix pixels de la grille ;
|
||||
- les captures rangées.
|
||||
|
||||
Les arguments de lancement sont relevés aussi. Le journal couvre :
|
||||
|
||||
- ouverture, snapshot inchangé, réouverture complète ;
|
||||
- snapshots vide, `null`, sans valeur, illisible, exception JS ;
|
||||
- défilements ;
|
||||
- clics par numéro, introuvable, refusé, en coordonnées ;
|
||||
- saisies libre, dans un champ, liste déroulante, option absente, champ
|
||||
introuvable ;
|
||||
- touches ;
|
||||
- recherches ;
|
||||
- navigations en échec, page d'erreur, interruption bénigne, `back`,
|
||||
`forward`, commande refusée ;
|
||||
- captures sans vision, quadrillée, refusée ;
|
||||
- sortie plafonnée.
|
||||
|
||||
`tests/browser.rs` le rejoue contre le port, puis vérifie l'aperçu (JPEG
|
||||
de l'anneau) et la fermeture (profil retiré).
|
||||
|
||||
`vrai_navigateur` (avec `LOKI_CU_SMOKE=1`, comme `TestCDPSmoke`) pilote un
|
||||
vrai Chromium sur une page locale : ouverture, recherche, saisie, liste
|
||||
déroulante, clic qui navigue, `back`, capture 1280×800, aperçu, navigation
|
||||
en échec.
|
||||
|
||||
D'autres tests complètent :
|
||||
|
||||
- `tests/lot6.rs` (`navigateur_par_la_facade`) : un tour de la façade ouvre
|
||||
une page sans passer par le Go, avec aperçu ; l'API du Go et
|
||||
`/api/chat/cushot` par une façade complète ;
|
||||
- `TestFacadeNavigateur` : le Go passe ses outils, captures, aperçus et
|
||||
fermeture à la façade, et se replie sur son navigateur si elle est en
|
||||
panne ;
|
||||
- tests unitaires : outils, nombres au format du Go, arguments, grille,
|
||||
profils, lancement impossible.
|
||||
@@ -0,0 +1,116 @@
|
||||
# Lot 45 — Outils d'image de l'agent dans la façade
|
||||
|
||||
## Contexte
|
||||
|
||||
Deux outils de l'agent montrent une image au modèle :
|
||||
|
||||
- `see_image` charge un fichier image du disque dans la vision du
|
||||
modèle ;
|
||||
- `web_screenshot` photographie une page rendue par Playwright, la range
|
||||
dans le dossier de la discussion et donne à l'agent la ligne markdown
|
||||
qui la montre à l'utilisateur.
|
||||
|
||||
La façade tient la conversation par défaut, mais ces deux outils passaient
|
||||
encore par le Go (`/internal/v1/tool/exec`), qui renvoyait l'image à
|
||||
relayer dans `vision_img`.
|
||||
|
||||
## Le lot
|
||||
|
||||
`src/imagetools.rs` porte trois fichiers Go :
|
||||
|
||||
- `chat_vision_tool.go` (`see_image`) ;
|
||||
- `chat_screenshot.go` (`web_screenshot`) ;
|
||||
- `prepareImageForModel` (`web_upload_orient.go`).
|
||||
|
||||
Ce qui est porté :
|
||||
|
||||
- **la préparation de l'image**, la même que pour les pièces jointes :
|
||||
- orientation EXIF d'une JPEG redressée dans les pixels ;
|
||||
- grand côté ramené sous 1568 px (mêmes pixels que le Go) ;
|
||||
- PNG réencodée en PNG, le reste en JPEG qualité 88 ;
|
||||
- rien à faire, ou image que la bibliothèque standard du Go ne décode
|
||||
pas (WebP, BMP, PNG cassée) : les octets d'origine ;
|
||||
- **la sonde du moteur** (`engineSeesImages`) :
|
||||
- preset externe : sa case « vision » ;
|
||||
- sinon `GET /props` du moteur local, authentifié par la clé de
|
||||
complétion, gardé 10 s ;
|
||||
- appelée en dernier, après toutes les vérifications du fichier ;
|
||||
- **`see_image`**, mêmes textes :
|
||||
- vision coupée, poste distant, chemin manquant ;
|
||||
- format non reconnu, fichier introuvable, dossier ;
|
||||
- trop lourde (12 Mo au plus) ;
|
||||
- moteur aveugle, fichier vide ;
|
||||
- chemin relatif au dossier de travail de l'agent, ou absolu ;
|
||||
- **`web_screenshot`**, mêmes textes :
|
||||
- Playwright absent, url manquante ou invalide ;
|
||||
- internet coupé : seule une adresse locale passe (`localhost`,
|
||||
`127.0.0.1`, `::1`, `0.0.0.0`, `*.localhost`, casse comprise comme
|
||||
le Go) ;
|
||||
- mêmes arguments de Playwright (viewport, page entière, attente,
|
||||
90 s au plus) ;
|
||||
- erreur résumée par ses trois dernières lignes non vides ;
|
||||
- aucune image ou image vide refusée ;
|
||||
- capture rangée dans `captures/` du dossier de la discussion, nommée
|
||||
par l'hôte et l'heure locale ;
|
||||
- ménage : 20 captures et 40 Mo au plus, la nouvelle reste ;
|
||||
- relayée au modèle (« Voici la capture demandée. ») seulement si la
|
||||
vision est active et que le moteur voit les images.
|
||||
|
||||
`ChatShared.vision` porte la sonde du processus ; `GoTurnHost::exec_tool`
|
||||
exécute les deux outils :
|
||||
|
||||
- `see_image` prend le dossier de travail et le poste visé du contexte
|
||||
des outils (lot 7) ; contexte indisponible : le Go prend le relais ;
|
||||
- `web_screenshot` range ses captures dans le dossier de la discussion
|
||||
principale, sous le dossier de travail de l'agent, et les montre par
|
||||
`ToolOutcome.image`.
|
||||
|
||||
Rien ne change côté Go : ces outils n'ont pas d'état partagé, et le Go
|
||||
garde les siens pour ses sous-agents et ses tâches IA.
|
||||
|
||||
## Écarts connus
|
||||
|
||||
- Encodeurs PNG et JPEG : mêmes pixels, octets différents.
|
||||
|
||||
## Parité
|
||||
|
||||
`internal/loki/parity_image_tools_test.go` (Linux) tire du Go un journal
|
||||
(`tests/parity_images/images.json`) avec ses fichiers (en base64), un faux
|
||||
moteur (`/props` authentifié, vision réglée par étape, sondes comptées) et
|
||||
un faux Playwright (script qui note ses arguments et produit l'image, rien,
|
||||
un fichier vide ou une erreur).
|
||||
|
||||
Chaque étape porte :
|
||||
|
||||
- le résultat, avec le texte relayé au modèle ;
|
||||
- l'image relayée :
|
||||
- identique à un fichier : son nom ;
|
||||
- réencodée : son type et ses dimensions ;
|
||||
- en PNG : cinq pixels ;
|
||||
- la capture montrée, les arguments de Playwright, le nombre de sondes ;
|
||||
- le dossier des captures.
|
||||
|
||||
Le journal couvre :
|
||||
|
||||
- `see_image` :
|
||||
- vision coupée ;
|
||||
- chemin vide, pas une image, introuvable, dossier, trop lourde ;
|
||||
- moteur aveugle, fichier vide ;
|
||||
- onze images : PNG, JPEG, GIF, WebP, BMP, PNG cassée, JPEG tournée,
|
||||
grandes images réduites en largeur et en hauteur, sous-dossier,
|
||||
chemin absolu ;
|
||||
- `web_screenshot` :
|
||||
- url manquante ou invalide, internet coupé ;
|
||||
- capture locale sans vision, capture vue par le modèle ;
|
||||
- page entière réduite ;
|
||||
- vision mais moteur aveugle ;
|
||||
- Playwright en échec, image vide, aucune image ;
|
||||
- ménage des captures.
|
||||
|
||||
`tests/imagetools.rs` le rejoue contre le port. D'autres tests
|
||||
complètent :
|
||||
|
||||
- `tests/lot6.rs` (`outils_image_par_la_facade`) : un tour de la façade
|
||||
appelle les deux outils sans passer par le Go ;
|
||||
- tests unitaires : types d'image, noms de capture, adresses locales,
|
||||
dernières lignes, préparation, ménage, refus avant Playwright.
|
||||
@@ -0,0 +1,97 @@
|
||||
# Lot 46 — Outils recall et recall_search dans la façade
|
||||
|
||||
## Contexte
|
||||
|
||||
La compaction résume les vieux tours d'une discussion. Avant de résumer un
|
||||
gros bloc (un code, une page, un diff), elle l'archive verbatim sous un id
|
||||
court (`r7`) que le résumé cite. L'agent a deux outils pour le retrouver :
|
||||
|
||||
- `recall(id)` rend le bloc exact ;
|
||||
- `recall_search(query)` retrouve un bloc et son id par mots-clés, même
|
||||
sorti du résumé courant.
|
||||
|
||||
La façade compacte elle-même et archive par le Go
|
||||
(`/internal/v1/recall/archive`, lot 6) : l'id vient de la séquence de la
|
||||
base, que seul le Go tient. Les deux outils passaient encore par
|
||||
`/internal/v1/tool/exec`.
|
||||
|
||||
## Le lot
|
||||
|
||||
`src/recall.rs` porte `toolRecall` et `toolRecallSearch`
|
||||
(`chat_recall.go`) :
|
||||
|
||||
- **l'id tolérant** (`recallIDToSeq`) :
|
||||
- espaces, un préfixe `r` puis un préfixe `R`, espaces encore ;
|
||||
- des chiffres seulement : ni signe, ni dépassement de 64 bits ;
|
||||
- un id mal formé ne lit pas l'archive ;
|
||||
- **les mots-clés** (`recallTokens`) :
|
||||
- mise en minuscules rune par rune, comme `strings.ToLower` (`İ`
|
||||
donne `i`, `Σ` donne `σ`) ;
|
||||
- coupés sur tout ce qui n'est ni `a`–`z`, ni `0`–`9`, ni `à`–`ÿ` ;
|
||||
- deux runes au moins ;
|
||||
- **le score** :
|
||||
- un point par terme présent dans le contenu, deux dans le libellé ;
|
||||
- à égalité, le plus récent d'abord ;
|
||||
- 5 résultats par défaut, 20 au plus, limite tronquée comme
|
||||
`int(float64)` ;
|
||||
- **les textes**, mêmes que le Go :
|
||||
- id ou requête manquants ;
|
||||
- bloc introuvable (id cité par `%q`) ;
|
||||
- bloc rappelé verbatim ;
|
||||
- liste des résultats avec un extrait d'une ligne de 160 runes ;
|
||||
- aucun résultat.
|
||||
|
||||
L'archive reste au Go, qui la sert à la façade :
|
||||
|
||||
- `GET /internal/v1/recall/block?seq=N` : le bloc (200), ou 404 s'il est
|
||||
absent ou chiffré sous une mémoire verrouillée ;
|
||||
- `GET /internal/v1/recall/blocks` : tous les blocs lisibles, par séquence
|
||||
croissante.
|
||||
|
||||
`recallGet` et `recallSearch` du Go passent par les mêmes lectures
|
||||
(`recallGetSeq`, `recallAll`).
|
||||
|
||||
`GoTurnHost::exec_tool` exécute les deux outils avant le repli sur le Go.
|
||||
Si le Go ne répond pas, l'appel passe par lui.
|
||||
|
||||
## Parité
|
||||
|
||||
`internal/loki/parity_recall_test.go` archive 35 blocs variés :
|
||||
|
||||
- accents et casse Unicode ;
|
||||
- balises et entités ;
|
||||
- bloc vide ;
|
||||
- premières lignes longues ;
|
||||
- vingt-cinq blocs semblables.
|
||||
|
||||
Il tire ensuite du Go un journal de 41 appels
|
||||
(`tests/parity_recall/recall.json`), avec les blocs tels que l'archive les
|
||||
sert :
|
||||
|
||||
- `recall` :
|
||||
- id absent, non texte, blanc ;
|
||||
- variantes de préfixe et d'espaces ;
|
||||
- zéros de tête ;
|
||||
- mal formé, négatif, signe plus, zéro, absent, démesuré ;
|
||||
- guillemets dans l'id ;
|
||||
- `recall_search` :
|
||||
- requête absente, non texte, blanche ;
|
||||
- mots trop courts ;
|
||||
- casse, accents, `İ`, grec hors des mots ;
|
||||
- souligné coupé ;
|
||||
- extraits bornés ;
|
||||
- limites : une, tronquée, négative, par défaut, au plus, en texte
|
||||
ignorée ;
|
||||
- aucun résultat ;
|
||||
- ponctuation.
|
||||
|
||||
`tests/recall.rs` le rejoue : le faux Go sert les mêmes blocs. Il vérifie
|
||||
aussi le repli quand le Go ne répond pas.
|
||||
|
||||
D'autres tests complètent :
|
||||
|
||||
- `tests/lot6.rs` (`recall_par_la_facade`) : un tour de la façade rappelle
|
||||
et cherche sans passer par `/tool/exec` ;
|
||||
- `TestRecallInterneLecture` : les deux lectures du Go (absent, seq
|
||||
illisible, méthode refusée, ordre) ;
|
||||
- tests unitaires : ids, mots-clés, score, extraits, textes.
|
||||
@@ -0,0 +1,115 @@
|
||||
# Lot 47 — Outil subagent du mode Code dans la façade
|
||||
|
||||
## Contexte
|
||||
|
||||
En mode Code, le builder peut déléguer une question bornée à un rôle en
|
||||
lecture seule :
|
||||
|
||||
- `explorer` cartographie le code ;
|
||||
- `code-reviewer` relit un changement ;
|
||||
- `planner` découpe un travail et pose les critères d'acceptation.
|
||||
|
||||
Le rôle travaille dans son propre contexte : le prompt du rôle, le
|
||||
briefing machine et la tâche, rien de l'historique. Sa trace n'est ni
|
||||
rangée ni affichée. Seul son texte final revient au builder, comme
|
||||
résultat d'outil. Sur un modèle local, à la fenêtre étroite, c'est ce qui
|
||||
évite de payer dix lectures de fichiers dans le fil principal.
|
||||
|
||||
La façade tient le tour du builder et sa passe de vérification, mais
|
||||
`subagent` passait encore par `/internal/v1/tool/exec` : le Go faisait
|
||||
tourner un `runChat` à lui.
|
||||
|
||||
## Le lot
|
||||
|
||||
`src/chat/subagent.rs` porte `toolSubagent` (`code_subagent.go`).
|
||||
|
||||
Avant tout appel au moteur, il fait les mêmes refus que le Go, dans le
|
||||
même ordre, avec les mêmes textes :
|
||||
|
||||
- rôle inconnu (`verifier` compris : seule la boucle du contrat le lance) ;
|
||||
- tâche vide ;
|
||||
- appel depuis un tour qui n'est ni le fil principal ni le builder
|
||||
(garde-fou anti-récursion).
|
||||
|
||||
Le contexte du rôle vient du Go
|
||||
(`POST /internal/v1/turn/subagent {role}`), qui rend :
|
||||
|
||||
- le prompt du rôle suivi du briefing machine ;
|
||||
- les outils du rôle ;
|
||||
- ses capacités (agent, Code, mémoire coupée).
|
||||
|
||||
`subagentView` et `subagentFraming` sont partagés avec `toolSubagent`.
|
||||
|
||||
Le tour du rôle tourne ici avec `run_turn`, comme la passe de
|
||||
vérification :
|
||||
|
||||
- l'endpoint, le raisonnement, l'écho, les motifs de relance et l'arrêt
|
||||
sont ceux du tour qui délègue (`SubTurn`) ;
|
||||
- la température est 0,6 (le TEMP du preset l'emporte) ;
|
||||
- la tâche porte le cadrage du rôle :
|
||||
- planner : critères et étapes, sans demander confirmation ;
|
||||
- autres rôles : seulement les trouvailles, `fichier:ligne` ;
|
||||
- c'est un travail annexe (`engineSideJob`) : second slot, puis slot
|
||||
effacé à la fin pour que le builder reparte du cache RAM ;
|
||||
- la télémétrie le compte à part (`subagent`) ;
|
||||
- les critères posés par le planner s'affichent comme ceux du fil.
|
||||
|
||||
Le rapport se construit ainsi :
|
||||
|
||||
- seul le texte écrit après le dernier outil compte ;
|
||||
- il est rogné de ses blancs, puis préfixé du rôle ;
|
||||
- au-delà de 6000 runes, il est tronqué (`…[tronqué]`) ;
|
||||
- vide : « n'a rien répondu » ;
|
||||
- erreur du tour : « sous-agent <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.
|
||||
@@ -44,20 +44,45 @@ 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
|
||||
mu sync.Mutex
|
||||
buf []byte
|
||||
lastProg time.Time // dernière ligne de progression (\r) émise
|
||||
}
|
||||
|
||||
// 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.IndexByte(string(s.buf), '\n')
|
||||
i := strings.IndexAny(string(s.buf), "\r\n")
|
||||
if i < 0 {
|
||||
break
|
||||
}
|
||||
emitBuildLine(strings.TrimRight(string(s.buf[:i]), "\r"))
|
||||
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)
|
||||
}
|
||||
s.buf = s.buf[i+1:]
|
||||
}
|
||||
return len(p), nil
|
||||
|
||||
@@ -0,0 +1,29 @@
|
||||
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)
|
||||
}
|
||||
}
|
||||
@@ -73,11 +73,16 @@ func writeAPIKey(key string) error {
|
||||
}
|
||||
|
||||
// maskAPIKey renders a key for display: keep the "sk-loki-" prefix and last 4
|
||||
// chars, elide the middle. Empty in → empty out.
|
||||
// chars, elide the middle. Empty in → empty out. Une clé d'un ou deux octets
|
||||
// n'est pas montrée du tout : k[len(k)-2:] paniquait sur un seul octet, et
|
||||
// /api/apikey tombait à chaque lecture tant que la clé restait en place.
|
||||
func maskAPIKey(k string) string {
|
||||
if k == "" {
|
||||
return ""
|
||||
}
|
||||
if len(k) <= 2 {
|
||||
return "…"
|
||||
}
|
||||
if len(k) <= 13 {
|
||||
return "…" + k[len(k)-2:]
|
||||
}
|
||||
@@ -92,6 +97,9 @@ 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:
|
||||
|
||||
@@ -0,0 +1,43 @@
|
||||
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)
|
||||
}
|
||||
}
|
||||
+124
-10
@@ -2,11 +2,14 @@ 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.
|
||||
@@ -26,6 +29,39 @@ 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 {
|
||||
@@ -83,6 +119,9 @@ 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("● ")
|
||||
@@ -109,6 +148,10 @@ 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
|
||||
}
|
||||
@@ -131,14 +174,46 @@ func detectGPUs() ([]gpuInfo, error) {
|
||||
if !hasTool("nvidia-smi") {
|
||||
return nil, fmt.Errorf("nvidia-smi introuvable — sélection GPU disponible uniquement sur NVIDIA")
|
||||
}
|
||||
out, err := hideCmd(exec.Command("nvidia-smi",
|
||||
"--query-gpu=index,name,memory.total,memory.used,compute_cap",
|
||||
"--format=csv,noheader,nounits")).Output()
|
||||
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)
|
||||
}
|
||||
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(string(out)), "\n") {
|
||||
for _, line := range strings.Split(strings.TrimSpace(out), "\n") {
|
||||
parts := strings.Split(line, ",")
|
||||
if len(parts) < 5 {
|
||||
continue
|
||||
@@ -147,12 +222,51 @@ func detectGPUs() ([]gpuInfo, error) {
|
||||
parts[i] = strings.TrimSpace(parts[i])
|
||||
}
|
||||
idx, _ := strconv.Atoi(parts[0])
|
||||
gpus = append(gpus, gpuInfo{
|
||||
Index: idx, Name: parts[1], MemTotal: parts[2], MemUsed: parts[3], Cap: parts[4],
|
||||
})
|
||||
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)
|
||||
}
|
||||
if len(gpus) == 0 {
|
||||
return nil, fmt.Errorf("aucun GPU NVIDIA détecté")
|
||||
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
|
||||
}
|
||||
return gpus, nil
|
||||
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
|
||||
}
|
||||
@@ -0,0 +1,88 @@
|
||||
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)
|
||||
}
|
||||
}
|
||||
@@ -217,6 +217,9 @@ func llamacppInstall(args []string) error {
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
func llamacppUpdate(args []string) error {
|
||||
if err := tuneGuard(); err != nil {
|
||||
return err
|
||||
}
|
||||
ref := ""
|
||||
clean := false
|
||||
noRestart := false
|
||||
|
||||
@@ -29,7 +29,8 @@ import (
|
||||
"time"
|
||||
)
|
||||
|
||||
const llamaReleasesAPI = "https://api.github.com/repos/ggml-org/llama.cpp/releases/latest"
|
||||
// llamaReleasesAPI : variable pour que les tests la pointent sur un faux GitHub.
|
||||
var llamaReleasesAPI = "https://api.github.com/repos/ggml-org/llama.cpp/releases/latest"
|
||||
|
||||
type ghAsset struct {
|
||||
Name string `json:"name"`
|
||||
@@ -41,18 +42,25 @@ func prebuiltDir() string {
|
||||
return filepath.Join(backendsDir(), "llama.cpp-prebuilt")
|
||||
}
|
||||
|
||||
// prebuiltVersionFields : les champs du marqueur VERSION, « tag cudaVer
|
||||
// format ». La version CUDA est vide hors CUDA Windows : « b300 fmt2 » n'a
|
||||
// alors que DEUX champs, et le format est le second.
|
||||
func prebuiltVersionFields() []string {
|
||||
b, err := os.ReadFile(filepath.Join(prebuiltDir(), "VERSION"))
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
return strings.Fields(strings.TrimSpace(string(b)))
|
||||
}
|
||||
|
||||
// prebuiltVersion lit le marqueur VERSION du dossier prebuilt : "tag cudaVer"
|
||||
// (cudaVer vide hors CUDA Windows).
|
||||
func prebuiltVersion() (tag, cudaVer string) {
|
||||
b, err := os.ReadFile(filepath.Join(prebuiltDir(), "VERSION"))
|
||||
if err != nil {
|
||||
return "", ""
|
||||
}
|
||||
f := strings.Fields(strings.TrimSpace(string(b)))
|
||||
f := prebuiltVersionFields()
|
||||
if len(f) > 0 {
|
||||
tag = f[0]
|
||||
}
|
||||
if len(f) > 1 {
|
||||
if len(f) > 1 && !strings.HasPrefix(f[1], "fmt") {
|
||||
cudaVer = f[1]
|
||||
}
|
||||
return
|
||||
@@ -64,15 +72,13 @@ func prebuiltVersion() (tag, cudaVer string) {
|
||||
// sera refaite au lieu d'être déclarée « déjà à jour ».
|
||||
const prebuiltFormat = "fmt2"
|
||||
|
||||
// prebuiltVersionFormat lit le 3e champ du marqueur VERSION ("" si absent).
|
||||
// prebuiltVersionFormat lit le format, dernier champ du marqueur VERSION (""
|
||||
// si absent). Lu jadis comme 3e champ, il manquait dès que la version CUDA
|
||||
// était vide (Linux, macOS) : chaque vérification réinstallait la release.
|
||||
func prebuiltVersionFormat() string {
|
||||
b, err := os.ReadFile(filepath.Join(prebuiltDir(), "VERSION"))
|
||||
if err != nil {
|
||||
return ""
|
||||
}
|
||||
f := strings.Fields(strings.TrimSpace(string(b)))
|
||||
if len(f) > 2 {
|
||||
return f[2]
|
||||
f := prebuiltVersionFields()
|
||||
if len(f) > 1 && strings.HasPrefix(f[len(f)-1], "fmt") {
|
||||
return f[len(f)-1]
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
@@ -113,7 +113,69 @@ func ListPresets() ([]Preset, error) {
|
||||
})
|
||||
}
|
||||
sort.Slice(out, func(i, j int) bool { return out[i].Name < out[j].Name })
|
||||
return out, nil
|
||||
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)
|
||||
}
|
||||
|
||||
// uniquePresetID derives a unique filename id from a display name, appending
|
||||
@@ -192,7 +254,10 @@ func safePresetPath(name string) (string, error) {
|
||||
// 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.
|
||||
var preservedKeys = []string{"MEM_MODE", "CRAWL4AI_URL", "WEB_ENGINE", "CUDA_VISIBLE_DEVICES", "HOST", "CUDA_LAUNCH_QUEUES"}
|
||||
// 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"}
|
||||
|
||||
// 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
|
||||
@@ -203,7 +268,7 @@ var preservedKeys = []string{"MEM_MODE", "CRAWL4AI_URL", "WEB_ENGINE", "CUDA_VIS
|
||||
// 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}
|
||||
var softPreservedKeys = map[string]bool{"CUDA_VISIBLE_DEVICES": true, "HOST": true, "CUDA_LAUNCH_QUEUES": true, "SLOT_PERSIST": true}
|
||||
|
||||
// applyPresetFile installe le preset comme configuration active, en réinjectant
|
||||
// les réglages « appareil » par-dessus. Séparé de SwitchToPreset pour être
|
||||
@@ -264,6 +329,13 @@ 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
|
||||
}
|
||||
@@ -277,9 +349,11 @@ 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")
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,60 @@
|
||||
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)
|
||||
}
|
||||
}
|
||||
}
|
||||
+147
-11
@@ -9,6 +9,7 @@ import (
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"runtime"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
@@ -259,6 +260,32 @@ func downgradeLoadMode(args []string) ([]string, string) {
|
||||
// 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 »")
|
||||
@@ -341,11 +368,19 @@ func cmdServe(args []string) error {
|
||||
// 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 == "auto" || spec == "mtp" {
|
||||
if spec != "off" && spec != "" {
|
||||
probeSpec(cfg, &si)
|
||||
}
|
||||
// Build du moteur : seulement pour un hybride ou SPEC=auto, les seuls cas où
|
||||
@@ -360,15 +395,56 @@ func cmdServe(args []string) error {
|
||||
if spec == "auto" {
|
||||
si.SpecAutoBlocked, specRecord = specAutoPeek(specMark)
|
||||
}
|
||||
probeSlotPersistEnv(cfg, &si)
|
||||
if strings.Contains(si.Help, "--slot-save-path") && !hasAnyFlag(extra, "--slot-save-path") {
|
||||
si.SlotDir = prepareSlotDir(LokiHome())
|
||||
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.
|
||||
@@ -388,13 +464,23 @@ func cmdServe(args []string) error {
|
||||
// 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" {
|
||||
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.
|
||||
@@ -424,7 +510,12 @@ type serveSysInfo struct {
|
||||
RAMMiB int64 // RAM de la machine, limite du conteneur comprise
|
||||
RAMAvailMiB int64 // RAM libre au lancement
|
||||
VRAMMiB int64 // VRAM NVIDIA des cartes visibles
|
||||
SlotDir string // dossier de --slot-save-path, créé et vérifié ; vide = pas de drapeau
|
||||
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
|
||||
@@ -436,7 +527,12 @@ type serveSysInfo struct {
|
||||
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
|
||||
@@ -498,16 +594,30 @@ func buildServeArgs(cfg map[string]string, extra []string, bin string, si serveS
|
||||
notes = append(notes, loadNote)
|
||||
}
|
||||
|
||||
// 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, extra, si)
|
||||
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, extra, si)
|
||||
g, gNotes := graphOptEnv(cfg, place, si)
|
||||
if g != "" {
|
||||
env["GGML_CUDA_GRAPH_OPT"] = g
|
||||
}
|
||||
@@ -521,9 +631,16 @@ func buildServeArgs(cfg map[string]string, extra []string, bin string, si serveS
|
||||
// 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", get("CTX", "32768"),
|
||||
"-c", ctxArg,
|
||||
}
|
||||
args = append(args, threads...)
|
||||
args = append(args,
|
||||
@@ -540,9 +657,16 @@ 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 !hasAnyFlag(extra, "--parallel", "-np") {
|
||||
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)
|
||||
}
|
||||
// Couches GPU. Quatre cas, dans cet ordre :
|
||||
//
|
||||
// NGL=auto → rien du tout (le moteur décide seul)
|
||||
@@ -563,7 +687,9 @@ 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 !hasAnyFlag(extra, "-ngl", "--n-gpu-layers", "--gpu-layers") {
|
||||
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 note != "" {
|
||||
notes = append(notes, note)
|
||||
@@ -572,8 +698,8 @@ func buildServeArgs(cfg map[string]string, extra []string, bin string, si serveS
|
||||
}
|
||||
// Marge VRAM par carte du placement automatique (FIT_TARGET, voir
|
||||
// fitTargetArgs) : seulement si --fit tournera vraiment.
|
||||
fitt, fitNotes := fitTargetArgs(cfg, extra, si)
|
||||
args = append(args, fitt...)
|
||||
fitt, fitNotes := fitTargetArgs(cfg, place, si)
|
||||
args = append(append(args, fitt...), splitA...)
|
||||
notes = append(notes, fitNotes...)
|
||||
if ktv != "" {
|
||||
args = append(args, "-ctk", ktv)
|
||||
@@ -628,6 +754,9 @@ func buildServeArgs(cfg map[string]string, extra []string, bin string, si serveS
|
||||
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...)
|
||||
@@ -647,6 +776,13 @@ func buildServeArgs(cfg map[string]string, extra []string, bin string, si serveS
|
||||
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
|
||||
}
|
||||
|
||||
|
||||
@@ -11,6 +11,7 @@ import (
|
||||
"runtime"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
)
|
||||
|
||||
@@ -74,8 +75,12 @@ func probeCacheRAM(cfg map[string]string, extra []string, si *serveSysInfo) {
|
||||
}
|
||||
}
|
||||
}
|
||||
// Mémoire unifiée (macOS), AMD, Vulkan, --device choisi à la main : pas de
|
||||
// total VRAM fiable, donc pas d'agrandissement.
|
||||
// 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
|
||||
}
|
||||
@@ -117,17 +122,54 @@ func cgroupMemMiB(fsys fs.FS) (limit, current int64) {
|
||||
// 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 {
|
||||
if !hasTool("nvidia-smi") {
|
||||
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()
|
||||
if err != nil {
|
||||
return 0
|
||||
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
|
||||
}
|
||||
return vramFromSMI(string(out), cvd, pciOrder)
|
||||
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
|
||||
@@ -450,30 +492,29 @@ func loopbackHost(h string) bool {
|
||||
// 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
|
||||
// que l'isolation servira : cache actif, CACHE_ISOLATE pas à off. Les proxys de
|
||||
// Loki refusent de leur côté tout /slots qui n'est pas une lecture.
|
||||
// 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") || !cacheIsolationOn(cfg) || cacheRAMOff(cfg, extra, si.ArgEnv) {
|
||||
if si.SlotDir == "" || !strings.Contains(si.Help, "--slot-save-path") || hasAnyFlag(extra, "--slot-save-path") {
|
||||
return nil
|
||||
}
|
||||
host := flagValue(extra, "--host")
|
||||
if host == "" {
|
||||
host = cfg["HOST"]
|
||||
persist, _ := slotPersistPlan(cfg, extra, si)
|
||||
if !persist && (!cacheIsolationOn(cfg) || cacheRAMOff(cfg, extra, si.ArgEnv)) {
|
||||
return nil
|
||||
}
|
||||
if host == "" {
|
||||
host = "0.0.0.0"
|
||||
}
|
||||
if !loopbackHost(host) && si.APIKey == "" {
|
||||
if !slotSaveSafe(cfg, extra, si) {
|
||||
return nil
|
||||
}
|
||||
return []string{"--slot-save-path", si.SlotDir}
|
||||
}
|
||||
|
||||
// prepareSlotDir crée LOKI_HOME/slots (0700) et le vide : Loki n'y sauve jamais
|
||||
// rien, un fichier présent ne peut venir que d'un appel qui n'aurait pas dû
|
||||
// passer. Vide = dossier indisponible, le drapeau ne sera pas posé.
|
||||
func prepareSlotDir(home string) string {
|
||||
// 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 ""
|
||||
@@ -483,7 +524,7 @@ func prepareSlotDir(home string) string {
|
||||
}
|
||||
if entries, err := os.ReadDir(dir); err == nil {
|
||||
for _, e := range entries {
|
||||
if e.Type().IsRegular() {
|
||||
if e.Type().IsRegular() && !(keepPersist && slotPersistFileRe.MatchString(e.Name())) {
|
||||
_ = os.Remove(filepath.Join(dir, e.Name()))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,12 +1,16 @@
|
||||
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)
|
||||
@@ -238,7 +242,7 @@ func TestBuildServeArgsCacheRAM(t *testing.T) {
|
||||
|
||||
func TestPrepareSlotDir(t *testing.T) {
|
||||
home := t.TempDir()
|
||||
dir := prepareSlotDir(home)
|
||||
dir := prepareSlotDir(home, false)
|
||||
if dir != filepath.Join(home, "slots") {
|
||||
t.Fatalf("dossier = %q", dir)
|
||||
}
|
||||
@@ -246,7 +250,7 @@ func TestPrepareSlotDir(t *testing.T) {
|
||||
if err := os.WriteFile(stray, []byte("x"), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if prepareSlotDir(home) == "" {
|
||||
if prepareSlotDir(home, false) == "" {
|
||||
t.Fatal("second appel")
|
||||
}
|
||||
if _, err := os.Stat(stray); !os.IsNotExist(err) {
|
||||
@@ -258,7 +262,58 @@ func TestPrepareSlotDir(t *testing.T) {
|
||||
if err := os.WriteFile(filepath.Join(bad, "slots"), nil, 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if got := prepareSlotDir(bad); got != "" {
|
||||
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())
|
||||
}
|
||||
}
|
||||
@@ -289,12 +289,19 @@ func ckptArgs(cfg map[string]string, extra []string, si serveSysInfo) (args, not
|
||||
//
|
||||
// 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 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é.
|
||||
// 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 == "" {
|
||||
|
||||
@@ -23,7 +23,7 @@ import (
|
||||
// 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_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.
|
||||
@@ -168,6 +168,16 @@ func lossyCacheNotes(extra []string, si serveSysInfo) []string {
|
||||
"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
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,628 @@
|
||||
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"
|
||||
}
|
||||
@@ -0,0 +1,389 @@
|
||||
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)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,267 @@
|
||||
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()))
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,211 @@
|
||||
package loki
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"math"
|
||||
"os"
|
||||
"strconv"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// Second slot pour les travaux annexes (SIDE_SLOT, off par défaut).
|
||||
//
|
||||
// En --parallel 1, un vérificateur, un sous-agent, une tâche ou un bench
|
||||
// prennent LE slot de la conversation : son état part dans le cache RAM et
|
||||
// revient après — quand il y tient encore. Avec SIDE_SLOT=on, le moteur ouvre
|
||||
// deux slots, la conversation garde le slot 0 et tout le reste passe par le
|
||||
// slot 1 (id_slot, voir llm_sideslot.go) : l'état principal ne bouge plus du
|
||||
// tout.
|
||||
//
|
||||
// Le dimensionnement est le cœur de l'affaire. Un cache KV UNIFIÉ partagé entre
|
||||
// deux slots laisse chacun grandir jusqu'à la taille du pool : quand les deux
|
||||
// travaillent et que le pool est plein, llama.cpp renvoie « Context size has
|
||||
// been exceeded. » aux DEUX, et Loki prendrait ça pour un débordement de la
|
||||
// conversation — compaction, voire réduction, d'un fil qui tenait très bien
|
||||
// dans sa fenêtre. Et --kv-unified-per-slot plafonne tous les slots à la même
|
||||
// valeur, sans rien garantir sur la somme. On prend donc le cache NON unifié
|
||||
// (--no-kv-unified) avec -c 2×CTX : llama.cpp découpe alors le pool en deux
|
||||
// flux de CTX jetons chacun, séparés. Un débordement concurrent est impossible
|
||||
// par construction, chaque slot a exactement la fenêtre de la conversation (les
|
||||
// travaux annexes ne sont pas raccourcis), et --cache-idle-slots ne vide plus
|
||||
// les slots au repos (il ne le fait que sous cache unifié) : rien d'autre à
|
||||
// régler.
|
||||
//
|
||||
// Le prix : le cache KV (et l'état récurrent d'un hybride) en double, en VRAM.
|
||||
// On refuse donc l'option quand le compte ne tient pas, quand des poids vivent
|
||||
// en RAM (le KV en plus pousserait le placement automatique à sortir des
|
||||
// couches du GPU), et dès que l'utilisateur a réglé lui-même l'un des leviers
|
||||
// en jeu : on ne mélange pas ses réglages et les nôtres.
|
||||
|
||||
// sideSlotOn : la clé SIDE_SLOT est-elle posée ?
|
||||
func sideSlotOn(cfg map[string]string) bool {
|
||||
switch strings.ToLower(strings.TrimSpace(cfg["SIDE_SLOT"])) {
|
||||
case "on", "1", "true", "yes", "oui":
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// sideSlotConflictFlags : les drapeaux d'EXTRA_ARGS qui décident déjà de ce que
|
||||
// SIDE_SLOT règle. Un seul suffit à refuser.
|
||||
var sideSlotConflictFlags = []string{
|
||||
"-c", "--ctx-size",
|
||||
"-np", "--parallel",
|
||||
"-kvu", "--kv-unified", "-no-kvu", "--no-kv-unified",
|
||||
"--kv-unified-per-slot",
|
||||
"--cache-idle-slots", "--no-cache-idle-slots",
|
||||
}
|
||||
|
||||
// sideSlotArgEnv : les mêmes leviers posés par variable. La ligne de commande
|
||||
// les écraserait sans rien dire : un choix de l'utilisateur, on s'abstient.
|
||||
var sideSlotArgEnv = []string{"LLAMA_ARG_N_PARALLEL", "LLAMA_ARG_KV_UNIFIED", "LLAMA_ARG_KV_UNIFIED_PER_SLOT",
|
||||
"LLAMA_ARG_CACHE_IDLE_SLOTS", "LLAMA_ARG_CTX_SIZE"}
|
||||
|
||||
// probeSideSlotEnv complète ArgEnv avec sideSlotArgEnv, seulement si la clé est
|
||||
// posée : sans elle, rien n'est lu.
|
||||
func probeSideSlotEnv(cfg map[string]string, si *serveSysInfo) {
|
||||
if !sideSlotOn(cfg) {
|
||||
return
|
||||
}
|
||||
if si.ArgEnv == nil {
|
||||
si.ArgEnv = map[string]string{}
|
||||
}
|
||||
for _, k := range sideSlotArgEnv {
|
||||
if v, ok := os.LookupEnv(k); ok && strings.TrimSpace(v) != "" {
|
||||
si.ArgEnv[k] = v
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// sideSlotPlan décide de SIDE_SLOT pour ce lancement. ctx > 0 : accepté, chaque
|
||||
// slot aura ctx jetons (le pool en fait le double) ; extraMiB est la VRAM de
|
||||
// plus, estimée. ctx == 0 : refusé (why dit pourquoi), ou clé absente (why vide)
|
||||
// — la ligne de commande est alors celle d'avant, à l'octet près.
|
||||
func sideSlotPlan(cfg map[string]string, extra []string, si serveSysInfo) (ctx int, extraMiB int64, why string) {
|
||||
if !sideSlotOn(cfg) {
|
||||
return 0, 0, ""
|
||||
}
|
||||
if isExternalConfig(cfg) {
|
||||
return 0, 0, "preset externe"
|
||||
}
|
||||
if p := strings.TrimSpace(cfg["PARALLEL"]); p != "" && p != "2" {
|
||||
return 0, 0, "PARALLEL=" + p + " (SIDE_SLOT ouvre exactement 2 slots)"
|
||||
}
|
||||
for _, f := range sideSlotConflictFlags {
|
||||
if hasAnyFlag(extra, f) {
|
||||
return 0, 0, f + " est réglé dans EXTRA_ARGS"
|
||||
}
|
||||
}
|
||||
for _, k := range sideSlotArgEnv {
|
||||
if si.ArgEnv[k] != "" {
|
||||
return 0, 0, k + " est posée"
|
||||
}
|
||||
}
|
||||
if !strings.Contains(si.Help, "--no-kv-unified") {
|
||||
return 0, 0, "ce moteur ne connaît pas --no-kv-unified"
|
||||
}
|
||||
n, err := strconv.Atoi(strings.TrimSpace(cfg["CTX"]))
|
||||
if strings.TrimSpace(cfg["CTX"]) == "" {
|
||||
n, err = 32768, nil
|
||||
}
|
||||
if err != nil || n <= 0 {
|
||||
return 0, 0, "CTX doit être un nombre de jetons explicite"
|
||||
}
|
||||
extraMiB, why = sideSlotVRAM(cfg, extra, si, n)
|
||||
if why != "" {
|
||||
return 0, 0, why
|
||||
}
|
||||
return n, extraMiB, ""
|
||||
}
|
||||
|
||||
// sideSlotVRAM estime la VRAM du second slot (cache KV de ctx jetons et état
|
||||
// récurrent) et vérifie que modèle + deux états tiennent dans 90 % de la VRAM,
|
||||
// la même marge que cacheRAMAuto. Inconnu = refus : on ne double pas le cache
|
||||
// KV à l'aveugle, c'est exactement la panne de mémoire qu'on a déjà vécue.
|
||||
func sideSlotVRAM(cfg map[string]string, extra []string, si serveSysInfo, ctx int) (extraMiB int64, why string) {
|
||||
switch {
|
||||
case si.VRAMMiB <= 0:
|
||||
return 0, "VRAM NVIDIA inconnue (macOS, AMD, --device…)"
|
||||
case si.GGUF == nil:
|
||||
return 0, "métadonnées GGUF illisibles"
|
||||
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)
|
||||
if perTok <= 0 {
|
||||
return 0, "taille d'état inconnue"
|
||||
}
|
||||
state := perTok*float64(ctx) + float64(rec)
|
||||
if float64(si.ModelBytes)+2*state > 0.9*float64(si.VRAMMiB)*(1<<20) {
|
||||
return 0, fmt.Sprintf("VRAM insuffisante : modèle + 2 × %d Mio d'état dépassent 90 %% de %d Mio",
|
||||
int64(math.Ceil(state/(1<<20))), si.VRAMMiB)
|
||||
}
|
||||
return int64(math.Ceil(state / (1 << 20))), ""
|
||||
}
|
||||
|
||||
// sideSlotNote : ce que le lancement dit de SIDE_SLOT, accepté ou refusé.
|
||||
func sideSlotNote(ctx int, extraMiB int64, why string, nglForced bool) string {
|
||||
if ctx <= 0 {
|
||||
if why == "" {
|
||||
return ""
|
||||
}
|
||||
return "SIDE_SLOT refusé (" + why + ") : un seul slot, comme sans la clé"
|
||||
}
|
||||
n := fmt.Sprintf("SIDE_SLOT : 2 slots de %d jetons (-c %d, --no-kv-unified), ~%d Mio de VRAM en plus "+
|
||||
"pour le second (cache KV et état récurrent ; le brouillon MTP éventuel et les points de reprise en RAM hôte "+
|
||||
"doublent aussi)", ctx, 2*ctx, extraMiB)
|
||||
if nglForced {
|
||||
return n + " ; NGL est imposé : surveille la VRAM au premier long prompt"
|
||||
}
|
||||
return n + " ; le placement automatique sortira des couches du GPU s'il manque de place"
|
||||
}
|
||||
|
||||
// sideSlotPreview : l'avis de SIDE_SLOT pour l'éditeur de preset, calculé comme
|
||||
// au lancement, à l'aide du moteur près (supposée connaître --no-kv-unified :
|
||||
// un moteur trop ancien sera refusé au lancement, et le journal le dira).
|
||||
func sideSlotPreview(content string) (on bool, extraMiB int64, why string) {
|
||||
cfg := parseEnv(content)
|
||||
if !sideSlotOn(cfg) {
|
||||
return false, 0, ""
|
||||
}
|
||||
if isExternalConfig(cfg) {
|
||||
return false, 0, "preset externe"
|
||||
}
|
||||
extra := splitArgs(cfg["EXTRA_ARGS"])
|
||||
si := serveSysInfo{Help: "--no-kv-unified", ArgEnv: map[string]string{}}
|
||||
for _, k := range append(append(append(append([]string{}, serveArgEnv...), fidelityArgEnv...), cacheArgEnv...), sideSlotArgEnv...) {
|
||||
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 false, 0, "modèle introuvable"
|
||||
}
|
||||
probeCacheRAM(cfg, extra, &si)
|
||||
ctx, mib, why := sideSlotPlan(cfg, extra, si)
|
||||
return ctx > 0, mib, why
|
||||
}
|
||||
|
||||
// nglForced : le preset impose-t-il un nombre de couches GPU ? Alors le
|
||||
// placement automatique ne tourne pas, et un second état de trop finit en
|
||||
// panne de mémoire plutôt qu'en couches sorties du GPU. 999 est la sentinelle
|
||||
// « toutes », traduite en auto sur un moteur récent (voir nglArgs).
|
||||
func nglForced(cfg map[string]string, extra []string) bool {
|
||||
v := flagValue(extra, "-ngl", "--n-gpu-layers", "--gpu-layers")
|
||||
if v == "" {
|
||||
v = strings.TrimSpace(cfg["NGL"])
|
||||
if v == "999" {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return v != "" && !strings.EqualFold(v, "auto")
|
||||
}
|
||||
@@ -0,0 +1,147 @@
|
||||
package loki
|
||||
|
||||
import (
|
||||
"reflect"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// helpSide : un moteur qui connaît le cache KV non unifié.
|
||||
const helpSide = helpCacheRAM + `-kvu, --kv-unified, -no-kvu, --no-kv-unified
|
||||
use single unified KV buffer shared across all sequences
|
||||
`
|
||||
|
||||
// sideCfg : le preset de denseGPU (0,25 Mio d'état par jeton) à 32k, avec ou
|
||||
// sans SIDE_SLOT.
|
||||
func sideCfg(kv ...string) map[string]string {
|
||||
cfg := map[string]string{"CTX": "32768", "NGL": "999"}
|
||||
for i := 0; i+1 < len(kv); i += 2 {
|
||||
cfg[kv[i]] = kv[i+1]
|
||||
}
|
||||
return cfg
|
||||
}
|
||||
|
||||
func sideSI() serveSysInfo {
|
||||
si := denseGPU()
|
||||
si.Help = helpSide
|
||||
return si
|
||||
}
|
||||
|
||||
// Sans la clé (ou à off), la ligne de commande est celle d'avant, à l'octet
|
||||
// près, notes comprises.
|
||||
func TestSideSlotDefautIdentique(t *testing.T) {
|
||||
for _, v := range []string{"", "off", "0"} {
|
||||
cfg := sideCfg()
|
||||
if v != "" {
|
||||
cfg["SIDE_SLOT"] = v
|
||||
}
|
||||
got, _, notes := buildServeArgs(cfg, nil, "/bin/llama-server", sideSI())
|
||||
want, _, wantNotes := buildServeArgs(sideCfg(), nil, "/bin/llama-server", sideSI())
|
||||
if !reflect.DeepEqual(got, want) || !reflect.DeepEqual(notes, wantNotes) {
|
||||
t.Fatalf("SIDE_SLOT=%q change la ligne :\n%v\n%v", v, got, want)
|
||||
}
|
||||
if argValue(got, "--parallel") != "1" || argValue(got, "-c") != "32768" {
|
||||
t.Fatalf("défaut : %v", got)
|
||||
}
|
||||
for _, a := range got {
|
||||
if a == "--no-kv-unified" {
|
||||
t.Fatal("--no-kv-unified sans la clé")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Accepté : deux slots de CTX jetons chacun, flux KV séparés — le pool fait
|
||||
// le double, aucun slot ne peut déborder sur l'autre.
|
||||
func TestSideSlotAccepte(t *testing.T) {
|
||||
for _, cfg := range []map[string]string{sideCfg("SIDE_SLOT", "on"), sideCfg("SIDE_SLOT", "on", "PARALLEL", "2")} {
|
||||
args, _, notes := buildServeArgs(cfg, nil, "/bin/llama-server", sideSI())
|
||||
if argValue(args, "-c") != "65536" || argValue(args, "--parallel") != "2" {
|
||||
t.Fatalf("args = %v", args)
|
||||
}
|
||||
n := 0
|
||||
for i, a := range args {
|
||||
if a == "--no-kv-unified" {
|
||||
n++
|
||||
if args[i-1] != "2" || args[i-2] != "--parallel" {
|
||||
t.Fatalf("--no-kv-unified mal placé : %v", args)
|
||||
}
|
||||
}
|
||||
if a == "--kv-unified-per-slot" || a == "-kvu" || a == "--kv-unified" {
|
||||
t.Fatalf("drapeau inattendu %s", a)
|
||||
}
|
||||
}
|
||||
if n != 1 {
|
||||
t.Fatalf("--no-kv-unified × %d", n)
|
||||
}
|
||||
found := false
|
||||
for _, s := range notes {
|
||||
if strings.HasPrefix(s, "SIDE_SLOT : 2 slots de 32768 jetons") && strings.Contains(s, "~8192 Mio") {
|
||||
found = true
|
||||
}
|
||||
}
|
||||
if !found {
|
||||
t.Fatalf("note de VRAM absente : %q", notes)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Refusé : la ligne est exactement celle sans la clé, plus une note qui dit
|
||||
// pourquoi.
|
||||
func TestSideSlotRefus(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
cfg map[string]string
|
||||
extra []string
|
||||
si func(*serveSysInfo)
|
||||
why string
|
||||
}{
|
||||
{name: "PARALLEL=3", cfg: sideCfg("SIDE_SLOT", "on", "PARALLEL", "3"), why: "PARALLEL=3"},
|
||||
{name: "-c dans EXTRA_ARGS", extra: []string{"-c", "16384"}, why: "-c "},
|
||||
{name: "--ctx-size=", extra: []string{"--ctx-size=16384"}, why: "--ctx-size"},
|
||||
{name: "-np", extra: []string{"-np", "4"}, why: "-np"},
|
||||
{name: "-kvu", extra: []string{"-kvu"}, why: "-kvu"},
|
||||
{name: "--no-kv-unified", extra: []string{"--no-kv-unified"}, why: "--no-kv-unified"},
|
||||
{name: "--kv-unified-per-slot", extra: []string{"--kv-unified-per-slot", "8192"}, why: "--kv-unified-per-slot"},
|
||||
{name: "--cache-idle-slots", extra: []string{"--cache-idle-slots"}, why: "--cache-idle-slots"},
|
||||
{name: "--no-cache-idle-slots", extra: []string{"--no-cache-idle-slots"}, why: "--no-cache-idle-slots"},
|
||||
{name: "variable LLAMA_ARG_N_PARALLEL", si: func(si *serveSysInfo) { si.ArgEnv["LLAMA_ARG_N_PARALLEL"] = "4" }, why: "LLAMA_ARG_N_PARALLEL"},
|
||||
{name: "variable LLAMA_ARG_KV_UNIFIED", si: func(si *serveSysInfo) { si.ArgEnv["LLAMA_ARG_KV_UNIFIED"] = "1" }, why: "LLAMA_ARG_KV_UNIFIED"},
|
||||
{name: "moteur sans --no-kv-unified", si: func(si *serveSysInfo) { si.Help = helpCacheRAM }, why: "--no-kv-unified"},
|
||||
{name: "CTX=0", cfg: sideCfg("SIDE_SLOT", "on", "CTX", "0"), why: "CTX"},
|
||||
{name: "VRAM trop juste à 64k", cfg: sideCfg("SIDE_SLOT", "on", "CTX", "65536"), why: "VRAM insuffisante"},
|
||||
{name: "VRAM inconnue", si: func(si *serveSysInfo) { si.VRAMMiB = 0 }, why: "VRAM"},
|
||||
{name: "GGUF illisible", si: func(si *serveSysInfo) { si.GGUF = nil }, why: "GGUF"},
|
||||
{name: "experts sur CPU", extra: []string{"--n-cpu-moe", "20"}, why: "CPU"},
|
||||
{name: "-ot sur CPU", extra: []string{"-ot", "exps=CPU"}, why: "CPU"},
|
||||
{name: "NGL partiel", cfg: sideCfg("SIDE_SLOT", "on", "NGL", "30"), why: "couches GPU"},
|
||||
}
|
||||
for _, c := range cases {
|
||||
cfg := c.cfg
|
||||
if cfg == nil {
|
||||
cfg = sideCfg("SIDE_SLOT", "on")
|
||||
}
|
||||
si := sideSI()
|
||||
if c.si != nil {
|
||||
c.si(&si)
|
||||
}
|
||||
_, _, why := sideSlotPlan(cfg, c.extra, si)
|
||||
if !strings.Contains(why, c.why) {
|
||||
t.Errorf("%s : why = %q, attendu %q", c.name, why, c.why)
|
||||
}
|
||||
got, _, notes := buildServeArgs(cfg, c.extra, "/bin/llama-server", si)
|
||||
off := map[string]string{}
|
||||
for k, v := range cfg {
|
||||
if k != "SIDE_SLOT" {
|
||||
off[k] = v
|
||||
}
|
||||
}
|
||||
want, _, wantNotes := buildServeArgs(off, c.extra, "/bin/llama-server", si)
|
||||
if !reflect.DeepEqual(got, want) {
|
||||
t.Errorf("%s : refus qui change la ligne\n%v\n%v", c.name, got, want)
|
||||
}
|
||||
if len(notes) != len(wantNotes)+1 {
|
||||
t.Errorf("%s : notes %q", c.name, notes)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -22,6 +22,8 @@ import (
|
||||
// MODEL_DRAFT=<nom> → la tête ou le brouillon, résolu comme MMPROJ
|
||||
// SPEC_N_MAX=<n> → --spec-draft-n-max (défaut du moteur : 3)
|
||||
// SPEC_SAMPLING=probabilistic → seulement avec SPEC=mtp ; greedy sinon
|
||||
// SPEC=ngram → n-grammes tirés du contexte (ngram-mod), sans brouillon
|
||||
// SPEC=mtp+ngram → les deux ; un n-gramme trouvé passe avant la tête MTP
|
||||
//
|
||||
// Rien n'y touche au modèle : chaque jeton émis est tiré par l'échantillonneur
|
||||
// du modèle cible, un jeton du brouillon n'est gardé que s'il coïncide
|
||||
@@ -30,6 +32,11 @@ import (
|
||||
// empêcher le moteur de démarrer. D'où l'opt-in, les garde-fous d'auto et le
|
||||
// jeton de tentative (specAutoVerdict) : un lancement automatique qui n'a
|
||||
// jamais répondu ne se retente pas avec la même configuration.
|
||||
//
|
||||
// Une vérification par lot n'emprunte pas les mêmes noyaux qu'un décodage jeton
|
||||
// par jeton : même distribution, mais pas le même texte au bit près — deux
|
||||
// candidats presque à égalité peuvent s'inverser à graine égale, comme entre
|
||||
// prefill et décodage. On compare donc des sessions rejouées, pas des diffs.
|
||||
|
||||
const (
|
||||
// specAutoMinBuild : premier build officiel où Loki ose l'auto (MTP serveur,
|
||||
@@ -37,6 +44,19 @@ const (
|
||||
// peut connaître le drapeau sans le tenir.
|
||||
specAutoMinBuild = 11009
|
||||
specNMaxLimit = 64
|
||||
|
||||
// Bornes de ngram-mod, toujours écrites en clair : --spec-default pose les
|
||||
// mêmes aujourd'hui, mais son code porte un TODO qui annonce d'autres types —
|
||||
// une mise à jour du moteur changerait le réglage sans un mot. Un brouillon
|
||||
// part dès 24 jetons qui se répètent mot pour mot, et fait 48 à 64 jetons :
|
||||
// ce que recopie une boucle d'outils (chemins, diffs, arguments JSON).
|
||||
specNgramNMatch = 24
|
||||
specNgramNMin = 48
|
||||
specNgramNMax = 64
|
||||
|
||||
// ggmlOffloadMinBatchDefault : seuil par défaut du moteur (ggml-cuda.cu) à
|
||||
// partir duquel un lot recopie vers le GPU les poids restés en RAM.
|
||||
ggmlOffloadMinBatchDefault = 32
|
||||
)
|
||||
|
||||
// specArgEnv : les variables qui règlent la spéculation sans drapeau. Posées,
|
||||
@@ -45,10 +65,28 @@ var specArgEnv = []string{"LLAMA_ARG_SPEC_TYPE", "LLAMA_ARG_SPEC_DRAFT_MODEL", "
|
||||
"LLAMA_ARG_SPEC_DRAFT_N_MAX", "LLAMA_ARG_SPEC_DRAFT_SAMPLING"}
|
||||
|
||||
// specUserFlags : la spéculation réglée à la main. Les ajouter EN PLUS ferait
|
||||
// cumuler les types (--spec-type s'additionne) ou charger deux brouillons.
|
||||
var specUserFlags = []string{"--spec-type", "-md", "--model-draft", "--spec-draft-model",
|
||||
// cumuler les types (--spec-type s'additionne, sans l'avertissement « specified
|
||||
// multiple times » des autres drapeaux) ou charger deux brouillons.
|
||||
var specUserFlags = []string{"--spec-type", "--spec_type", "-md", "--model-draft", "--spec-draft-model",
|
||||
"-hfd", "-hfrd", "--spec-draft-hf", "--hf-repo-draft", "--spec-default"}
|
||||
|
||||
// specUserPrefixes : des réglages de n-grammes à la main (--spec-ngram-mod-n-max,
|
||||
// --spec-ngram-simple-size-n…) disent la même chose qu'un --spec-type.
|
||||
var specUserPrefixes = []string{"--spec-ngram-", "--spec_ngram_"}
|
||||
|
||||
// hasFlagPrefix : un drapeau d'args commence-t-il par l'un de ces préfixes ?
|
||||
func hasFlagPrefix(args []string, prefixes ...string) string {
|
||||
for _, a := range args {
|
||||
for _, p := range prefixes {
|
||||
if strings.HasPrefix(a, p) {
|
||||
name, _, _ := strings.Cut(a, "=")
|
||||
return name
|
||||
}
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// probeSpec : variables du moteur, puis MODEL_DRAFT résolu comme MMPROJ (nom
|
||||
// simple cherché dans les dossiers déclarés, ou chemin absolu). Introuvable ne
|
||||
// bloque PAS le lancement, contrairement au projecteur : le brouillon n'est
|
||||
@@ -75,6 +113,11 @@ func probeSpec(cfg map[string]string, si *serveSysInfo) {
|
||||
return
|
||||
}
|
||||
si.Draft = p
|
||||
if a, err := os.Stat(p); err == nil {
|
||||
if b, err := os.Stat(si.Model); err == nil && os.SameFile(a, b) {
|
||||
si.DraftIsModel = true
|
||||
}
|
||||
}
|
||||
if g, err := ggufMeta(p); err == nil {
|
||||
si.DraftGGUF = &g
|
||||
}
|
||||
@@ -91,20 +134,23 @@ func specSidecarArch(arch string) bool {
|
||||
return false
|
||||
}
|
||||
|
||||
// specMode lit SPEC : "off", "auto", "mtp", ou "" si illisible.
|
||||
// specMode lit SPEC : "off", "auto", "mtp", "ngram", "mtp+ngram", ou "" si
|
||||
// illisible.
|
||||
func specMode(cfg map[string]string) string {
|
||||
switch v := strings.ToLower(strings.TrimSpace(cfg["SPEC"])); v {
|
||||
case "", "off", "non", "no", "0":
|
||||
return "off"
|
||||
case "auto", "mtp":
|
||||
case "auto", "mtp", "ngram", "mtp+ngram":
|
||||
return v
|
||||
case "ngram+mtp":
|
||||
return "mtp+ngram"
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// specUserSet : EXTRA_ARGS ou l'environnement règlent déjà la spéculation.
|
||||
func specUserSet(extra []string, argEnv map[string]string) bool {
|
||||
if hasAnyFlag(extra, specUserFlags...) {
|
||||
if hasAnyFlag(extra, specUserFlags...) || hasFlagPrefix(extra, specUserPrefixes...) != "" {
|
||||
return true
|
||||
}
|
||||
for _, k := range []string{"LLAMA_ARG_SPEC_TYPE", "LLAMA_ARG_SPEC_DRAFT_MODEL", "LLAMA_ARG_SPEC_DRAFT_HF_REPO"} {
|
||||
@@ -200,11 +246,28 @@ func specAutoBlocker(cfg map[string]string, extra []string, si serveSysInfo) str
|
||||
// et s'ils viennent de SPEC=auto (cmdServe pose alors le jeton de tentative).
|
||||
// Fonction pure ; cmdServe a déjà résolu MODEL_DRAFT et lu les GGUF.
|
||||
func specArgs(cfg map[string]string, extra []string, si serveSysInfo) (args, notes []string, auto bool) {
|
||||
// MODEL_DRAFT = le modèle principal lui-même (choisi dans la liste des
|
||||
// brouillons de l'éditeur) : -md recharge alors les 27B une seconde fois
|
||||
// (common_speculative_init_result charge tout fichier -md comme un modèle
|
||||
// à part), quand la tête MTP intégrée se greffe sur le modèle déjà en VRAM.
|
||||
// Vécu : « allocating 9628.34 MiB on device 0 » pour le brouillon, moteur
|
||||
// mort ; ou, quand ça passait, des couches renvoyées sur le CPU et 5 t/s au
|
||||
// lieu de 22. On l'ignore : même tête, sans les poids en double.
|
||||
if si.DraftIsModel && strings.TrimSpace(cfg["MODEL_DRAFT"]) != "" {
|
||||
c := make(map[string]string, len(cfg))
|
||||
for k, v := range cfg {
|
||||
c[k] = v
|
||||
}
|
||||
delete(c, "MODEL_DRAFT")
|
||||
args, notes, auto = specArgs(c, extra, si)
|
||||
return args, append([]string{"MODEL_DRAFT est le modèle principal lui-même : ignoré — la tête MTP intégrée " +
|
||||
"sert de brouillon sans recharger les poids (retire MODEL_DRAFT du preset)"}, notes...), auto
|
||||
}
|
||||
mode := specMode(cfg)
|
||||
draftKey := strings.TrimSpace(cfg["MODEL_DRAFT"])
|
||||
switch mode {
|
||||
case "":
|
||||
return nil, []string{"SPEC=" + cfg["SPEC"] + " illisible (off, auto ou mtp) : sans décodage spéculatif"}, false
|
||||
return nil, []string{"SPEC=" + cfg["SPEC"] + " illisible (off, auto, mtp, ngram ou mtp+ngram) : sans décodage spéculatif"}, false
|
||||
case "off":
|
||||
if draftKey != "" {
|
||||
return nil, []string{"MODEL_DRAFT ignoré : SPEC=off (auto ou mtp pour s'en servir)"}, false
|
||||
@@ -218,49 +281,40 @@ func specArgs(cfg map[string]string, extra []string, si serveSysInfo) (args, not
|
||||
return nil, []string{"SPEC=" + mode + " : " + why + " — sans décodage spéculatif"}, false
|
||||
}
|
||||
|
||||
// La source : MODEL_DRAFT s'il est posé, sinon la tête MTP du modèle. Dans
|
||||
// les deux cas, on regarde les TENSEURS, comme llama.cpp : une clé
|
||||
// nextn_predict_layers seule ne prouve pas que la tête est dans le fichier.
|
||||
mtp := false
|
||||
switch {
|
||||
case draftKey != "":
|
||||
switch {
|
||||
case si.Draft == "":
|
||||
why := "MODEL_DRAFT=" + draftKey + " introuvable"
|
||||
if si.DraftErr != "" {
|
||||
why += " (" + si.DraftErr + ")"
|
||||
}
|
||||
// N-grammes : leurs garde-fous d'abord, ils valent pour les deux formes.
|
||||
if mode == "ngram" || mode == "mtp+ngram" {
|
||||
if why := ngramBlocker(cfg, extra, si, mode); why != "" {
|
||||
return skip(why)
|
||||
case si.DraftGGUF == nil:
|
||||
return skip("MODEL_DRAFT illisible (GGUF incomplet ou en cours de téléchargement ?)")
|
||||
case si.DraftGGUF.HasNextNTensor:
|
||||
if !helpSupportsMTP(si.Help) {
|
||||
return skip("ce moteur ne connaît pas draft-mtp")
|
||||
}
|
||||
// Type explicite : llama.cpp ne le devine que sur la première tranche.
|
||||
args, mtp = []string{"-md", si.Draft, "--spec-type", "draft-mtp"}, true
|
||||
case specSidecarArch(si.DraftGGUF.Arch):
|
||||
return skip("MODEL_DRAFT est une tête " + si.DraftGGUF.Arch +
|
||||
", pas un modèle brouillon : règle -md et --spec-type dans EXTRA_ARGS")
|
||||
default:
|
||||
if !strings.Contains(si.Help, "--spec-type") || !strings.Contains(si.Help, "draft-simple") {
|
||||
return skip("ce moteur ne connaît pas --spec-type draft-simple")
|
||||
}
|
||||
// Sans type, un brouillon qui n'est pas une tête MTP serait chargé en
|
||||
// VRAM puis jamais utilisé.
|
||||
args = []string{"-md", si.Draft, "--spec-type", "draft-simple"}
|
||||
}
|
||||
case si.GGUF == nil:
|
||||
return skip("métadonnées du modèle illisibles")
|
||||
case !si.GGUF.HasNextNTensor:
|
||||
if mode == "mtp" || si.GGUF.NextN > 0 {
|
||||
return skip("pas de tête MTP dans ce fichier (publiée à part ? MODEL_DRAFT=mtp-….gguf)")
|
||||
}
|
||||
if mode == "ngram" {
|
||||
args, notes = ngramSpecArgs(cfg, extra, si, "")
|
||||
return args, notes, false
|
||||
}
|
||||
|
||||
src, mtp, why, quiet := specSource(mode, draftKey, si)
|
||||
if mode == "mtp+ngram" && !mtp {
|
||||
// Pas de tête MTP utilisable : la passer quand même ferait mourir le
|
||||
// moteur sur « failed to create MTP context », et boucler. Les n-grammes,
|
||||
// eux, n'ont besoin de rien.
|
||||
if why == "" {
|
||||
why = "MODEL_DRAFT n'est pas une tête MTP"
|
||||
}
|
||||
return nil, nil, false // auto sur un modèle sans MTP : rien à dire
|
||||
case !helpSupportsMTP(si.Help):
|
||||
return skip("ce moteur ne connaît pas draft-mtp")
|
||||
default:
|
||||
args, mtp = []string{"--spec-type", "draft-mtp"}, true
|
||||
args, notes = ngramSpecArgs(cfg, extra, si, "SPEC=mtp+ngram : sans MTP ("+why+") → n-grammes seuls. ")
|
||||
return args, notes, false
|
||||
}
|
||||
if why != "" {
|
||||
return skip(why)
|
||||
}
|
||||
if quiet {
|
||||
return nil, nil, false
|
||||
}
|
||||
args = src
|
||||
if mode == "mtp+ngram" {
|
||||
// Une seule liste : --spec-type s'additionne, mais une valeur unique se
|
||||
// lit d'un coup d'œil dans le journal.
|
||||
args[len(args)-1] = "draft-mtp,ngram-mod"
|
||||
args = append(args, ngramModParams()...)
|
||||
}
|
||||
|
||||
label := "brouillon " + baseName(si.Draft)
|
||||
@@ -275,7 +329,10 @@ func specArgs(cfg map[string]string, extra []string, si serveSysInfo) (args, not
|
||||
notes = append(notes, "SPEC=auto → "+label+" : sortie inchangée (chaque jeton est vérifié par le modèle), "+
|
||||
"~1-2 Go de VRAM en plus. Mesure le prefill ; SPEC=off pour couper.")
|
||||
} else {
|
||||
note := "SPEC=mtp → " + label + " imposé : sortie inchangée, ~1-2 Go de VRAM en plus"
|
||||
note := "SPEC=" + mode + " → " + label + " imposé : sortie inchangée, ~1-2 Go de VRAM en plus"
|
||||
if mode == "mtp+ngram" {
|
||||
note += " ; n-grammes en plus (ngram-mod, prioritaires quand ils trouvent une répétition)"
|
||||
}
|
||||
if why := fitBlocker(cfg, extra, si); why != "" || hasAnyFlag(extra, "-dev", "--device") {
|
||||
if why == "" {
|
||||
why = "--device"
|
||||
@@ -290,6 +347,9 @@ func specArgs(cfg map[string]string, extra []string, si serveSysInfo) (args, not
|
||||
note += " ; MTP qwen4exp très récent"
|
||||
}
|
||||
notes = append(notes, note)
|
||||
if mode == "mtp+ngram" {
|
||||
notes = append(notes, ngramCostNotes(cfg, extra, si)...)
|
||||
}
|
||||
}
|
||||
|
||||
// Nombre de jetons anticipés : défaut du moteur (3) si la clé est vide.
|
||||
@@ -314,7 +374,7 @@ func specArgs(cfg map[string]string, extra []string, si serveSysInfo) (args, not
|
||||
case "", "greedy":
|
||||
case "probabilistic":
|
||||
switch {
|
||||
case mode != "mtp":
|
||||
case mode == "auto":
|
||||
notes = append(notes, "SPEC_SAMPLING=probabilistic ignoré avec SPEC=auto (SPEC=mtp pour le choisir) : greedy")
|
||||
case statefulSampler(extra) != "":
|
||||
notes = append(notes, "SPEC_SAMPLING=probabilistic refusé avec "+statefulSampler(extra)+
|
||||
@@ -334,6 +394,158 @@ func specArgs(cfg map[string]string, extra []string, si serveSysInfo) (args, not
|
||||
return args, notes, auto
|
||||
}
|
||||
|
||||
// specSource : d'où vient le brouillon de SPEC=auto|mtp|mtp+ngram. MODEL_DRAFT
|
||||
// s'il est posé, sinon la tête MTP du modèle ; dans les deux cas on regarde les
|
||||
// TENSEURS, comme llama.cpp : une clé nextn_predict_layers seule ne prouve pas
|
||||
// que la tête est dans le fichier. why non vide = aucun brouillon ; quiet =
|
||||
// rien à dire (auto sur un modèle sans MTP).
|
||||
func specSource(mode, draftKey string, si serveSysInfo) (args []string, mtp bool, why string, quiet bool) {
|
||||
switch {
|
||||
case draftKey != "":
|
||||
switch {
|
||||
case si.Draft == "":
|
||||
why := "MODEL_DRAFT=" + draftKey + " introuvable"
|
||||
if si.DraftErr != "" {
|
||||
why += " (" + si.DraftErr + ")"
|
||||
}
|
||||
return nil, false, why, false
|
||||
case si.DraftGGUF == nil:
|
||||
return nil, false, "MODEL_DRAFT illisible (GGUF incomplet ou en cours de téléchargement ?)", false
|
||||
case si.DraftGGUF.HasNextNTensor:
|
||||
if !helpSupportsMTP(si.Help) {
|
||||
return nil, false, "ce moteur ne connaît pas draft-mtp", false
|
||||
}
|
||||
// Type explicite : llama.cpp ne le devine que sur la première tranche.
|
||||
return []string{"-md", si.Draft, "--spec-type", "draft-mtp"}, true, "", false
|
||||
case specSidecarArch(si.DraftGGUF.Arch):
|
||||
return nil, false, "MODEL_DRAFT est une tête " + si.DraftGGUF.Arch +
|
||||
", pas un modèle brouillon : règle -md et --spec-type dans EXTRA_ARGS", false
|
||||
case mode == "mtp+ngram":
|
||||
return nil, false, "", false // un modèle brouillon n'est pas une tête MTP
|
||||
default:
|
||||
if !strings.Contains(si.Help, "--spec-type") || !strings.Contains(si.Help, "draft-simple") {
|
||||
return nil, false, "ce moteur ne connaît pas --spec-type draft-simple", false
|
||||
}
|
||||
// Sans type, un brouillon qui n'est pas une tête MTP serait chargé en
|
||||
// VRAM puis jamais utilisé.
|
||||
return []string{"-md", si.Draft, "--spec-type", "draft-simple"}, false, "", false
|
||||
}
|
||||
case si.GGUF == nil:
|
||||
return nil, false, "métadonnées du modèle illisibles", false
|
||||
case !si.GGUF.HasNextNTensor:
|
||||
if mode != "auto" || si.GGUF.NextN > 0 {
|
||||
return nil, false, "pas de tête MTP dans ce fichier (publiée à part ? MODEL_DRAFT=mtp-….gguf)", false
|
||||
}
|
||||
return nil, false, "", true
|
||||
case !helpSupportsMTP(si.Help):
|
||||
return nil, false, "ce moteur ne connaît pas draft-mtp", false
|
||||
}
|
||||
return []string{"--spec-type", "draft-mtp"}, true, "", false
|
||||
}
|
||||
|
||||
// ngramModParams : les trois bornes de ngram-mod, toujours explicites.
|
||||
func ngramModParams() []string {
|
||||
return []string{
|
||||
"--spec-ngram-mod-n-match", strconv.Itoa(specNgramNMatch),
|
||||
"--spec-ngram-mod-n-min", strconv.Itoa(specNgramNMin),
|
||||
"--spec-ngram-mod-n-max", strconv.Itoa(specNgramNMax),
|
||||
}
|
||||
}
|
||||
|
||||
// ngramBlocker dit pourquoi SPEC=ngram|mtp+ngram ne s'active pas, ou vide.
|
||||
//
|
||||
// - Le moteur doit connaître --spec-ngram-mod-n-match, pas seulement le mot
|
||||
// « ngram-mod » : les moteurs d'avant le renommage ont encore le type dans
|
||||
// leur liste mais refusent les drapeaux actuels, et meurent au démarrage.
|
||||
// - SPEC=ngram n'a pas de brouillon : un --spec-draft-* d'EXTRA_ARGS veut dire
|
||||
// qu'on règle un brouillon à la main, on ne mélange pas.
|
||||
// - Poids en RAM (experts MoE sur CPU…) : llama.cpp recopie vers le GPU les
|
||||
// poids d'un lot dès GGML_OP_OFFLOAD_MIN_BATCH jetons (32 par défaut). Un
|
||||
// lot de vérification de n_max+1 jetons au-dessus du seuil ferait passer
|
||||
// les experts de chaque couche par le PCIe — voire par le disque si le
|
||||
// modèle mappé dépasse la RAM — à chaque brouillon. Refusé tant que le seuil
|
||||
// n'est pas relevé au-dessus du lot.
|
||||
func ngramBlocker(cfg map[string]string, extra []string, si serveSysInfo, mode string) string {
|
||||
if !strings.Contains(si.Help, "--spec-ngram-mod-n-match") {
|
||||
return "ce moteur ne connaît pas --spec-ngram-mod-n-match"
|
||||
}
|
||||
if mode == "ngram" {
|
||||
if f := hasFlagPrefix(extra, "--spec-draft-", "--spec_draft_"); f != "" {
|
||||
return f + " dans EXTRA_ARGS (brouillon réglé à la main)"
|
||||
}
|
||||
}
|
||||
blocks := 0
|
||||
if si.GGUF != nil {
|
||||
blocks = si.GGUF.BlockCount
|
||||
}
|
||||
if r := cpuWeights(cfg, extra, si.ArgEnv, blocks); r != "" {
|
||||
if thr := opOffloadThreshold(cfg, si); specNgramNMax+1 >= thr {
|
||||
return fmt.Sprintf("%s : un lot de vérification de %d jetons atteint GGML_OP_OFFLOAD_MIN_BATCH (%d) et "+
|
||||
"recopierait ces poids vers le GPU à chaque brouillon — OP_OFFLOAD_MIN_BATCH=128 pour l'essayer, à mesurer",
|
||||
r, specNgramNMax+1, thr)
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// opOffloadThreshold : le seuil GGML_OP_OFFLOAD_MIN_BATCH que verra le moteur —
|
||||
// celui de l'environnement, sinon celui d'OP_OFFLOAD_MIN_BATCH, sinon le défaut.
|
||||
func opOffloadThreshold(cfg map[string]string, si serveSysInfo) int {
|
||||
v := strings.TrimSpace(si.UserEnv["GGML_OP_OFFLOAD_MIN_BATCH"])
|
||||
if v == "" {
|
||||
v, _ = opOffloadMinBatchEnv(cfg, si)
|
||||
}
|
||||
if n, err := strconv.Atoi(v); err == nil && n > 0 {
|
||||
return n
|
||||
}
|
||||
return ggmlOffloadMinBatchDefault
|
||||
}
|
||||
|
||||
// ngramSpecArgs : SPEC=ngram, ou le repli de mtp+ngram sans tête MTP (lead dit
|
||||
// pourquoi). Pas de --spec-draft-* : il n'y a pas de brouillon.
|
||||
func ngramSpecArgs(cfg map[string]string, extra []string, si serveSysInfo, lead string) (args, notes []string) {
|
||||
args = append([]string{"--spec-type", "ngram-mod"}, ngramModParams()...)
|
||||
notes = append(notes, lead+fmt.Sprintf("SPEC=ngram → n-grammes du contexte (ngram-mod, brouillons de %d à %d jetons) : "+
|
||||
"distribution inchangée (chaque jeton vérifié), ~16 Mo de RAM ; utile surtout en mode code, à mesurer "+
|
||||
"(acceptation par nature dans /api/perf/summary)", specNgramNMin, specNgramNMax))
|
||||
if lead == "" {
|
||||
// --spec-type explicite : llama.cpp ne choisit plus de lui-même la tête MTP.
|
||||
switch {
|
||||
case strings.TrimSpace(cfg["MODEL_DRAFT"]) != "":
|
||||
notes = append(notes, "MODEL_DRAFT ignoré avec SPEC=ngram : SPEC=mtp+ngram pour les deux")
|
||||
case si.GGUF != nil && si.GGUF.HasNextNTensor:
|
||||
notes = append(notes, "tête MTP du modèle inutilisée avec SPEC=ngram : SPEC=mtp+ngram pour les deux")
|
||||
}
|
||||
}
|
||||
if strings.TrimSpace(cfg["SPEC_N_MAX"]) != "" || strings.TrimSpace(cfg["SPEC_SAMPLING"]) != "" {
|
||||
notes = append(notes, "SPEC_N_MAX et SPEC_SAMPLING ignorés : les n-grammes n'ont pas de brouillon à régler")
|
||||
}
|
||||
return args, append(notes, ngramCostNotes(cfg, extra, si)...)
|
||||
}
|
||||
|
||||
// ngramCostNotes : ce que les longs brouillons coûtent, là où ça se voit.
|
||||
func ngramCostNotes(cfg map[string]string, extra []string, si serveSysInfo) []string {
|
||||
var notes []string
|
||||
if si.GGUF != nil && si.GGUF.Hybrid {
|
||||
notes = append(notes, "modèle hybride : chaque brouillon n-gramme copie l'état récurrent (point de reprise en RAM "+
|
||||
"hôte) et le recharge s'il est rejeté — compare le temps par tour, pas seulement le débit")
|
||||
}
|
||||
blocks := 0
|
||||
if si.GGUF != nil {
|
||||
blocks = si.GGUF.BlockCount
|
||||
}
|
||||
if cpuWeights(cfg, extra, si.ArgEnv, blocks) != "" && si.ModelBytes > 0 && si.RAMMiB > 0 &&
|
||||
si.ModelBytes > si.RAMMiB<<20 {
|
||||
notes = append(notes, "modèle plus gros que la RAM, poids sur CPU : un lot de vérification lit la plupart des "+
|
||||
"experts de chaque couche — surveille les lectures disque, pas seulement les jetons/s")
|
||||
}
|
||||
if nglForced(cfg, extra) {
|
||||
notes = append(notes, fmt.Sprintf("NGL imposé (--fit inactif) : les logits de %d positions par slot prennent "+
|
||||
"quelques dizaines de Mio de VRAM en plus", specNgramNMax+1))
|
||||
}
|
||||
return notes
|
||||
}
|
||||
|
||||
// --- Jeton de tentative ------------------------------------------------------
|
||||
//
|
||||
// Un essai automatique qui fait tomber le moteur ne doit pas se rejouer : sous
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
package loki
|
||||
|
||||
import (
|
||||
"os"
|
||||
"reflect"
|
||||
"slices"
|
||||
"strings"
|
||||
@@ -107,6 +108,13 @@ func TestSpecArgs(t *testing.T) {
|
||||
s.Draft = "/models/mtp-Qwen3.6-27B-Q8_0.gguf"
|
||||
s.DraftGGUF = &GGUFInfo{Arch: "qwen35", BlockCount: 65, HasNextNTensor: true}
|
||||
}},
|
||||
{name: "MODEL_DRAFT = le modèle lui-même : pas de -md, la tête intégrée, et on le dit",
|
||||
cfg: with(forced, "MODEL_DRAFT", "Qwen3.6-27B-MTP.gguf"), notes: 2, want: mtp,
|
||||
si: func(s *serveSysInfo) {
|
||||
s.Draft = s.Model
|
||||
s.DraftGGUF = s.GGUF
|
||||
s.DraftIsModel = true
|
||||
}},
|
||||
{name: "MODEL_DRAFT petit modèle : -md + draft-simple, sinon chargé pour rien",
|
||||
cfg: with(forced, "MODEL_DRAFT", "Qwen3-0.6B.gguf"), notes: 1,
|
||||
want: []string{"-md", "/models/Qwen3-0.6B.gguf", "--spec-type", "draft-simple", "--spec-draft-sampling", "greedy"},
|
||||
@@ -332,3 +340,187 @@ func TestLastOffload(t *testing.T) {
|
||||
t.Errorf("tout sur GPU : got %d/%d", n, m)
|
||||
}
|
||||
}
|
||||
|
||||
// helpNgram : un moteur qui connaît les drapeaux actuels de ngram-mod.
|
||||
const helpNgram = helpSpec + `--spec-ngram-mod-n-min N minimum number of ngram tokens (default: 48)
|
||||
--spec-ngram-mod-n-max N maximum number of ngram tokens (default: 64)
|
||||
--spec-ngram-mod-n-match N ngram-mod lookup length (default: 24)
|
||||
`
|
||||
|
||||
func TestSpecArgsNgram(t *testing.T) {
|
||||
ngram := []string{"--spec-type", "ngram-mod",
|
||||
"--spec-ngram-mod-n-match", "24", "--spec-ngram-mod-n-min", "48", "--spec-ngram-mod-n-max", "64"}
|
||||
both := []string{"--spec-type", "draft-mtp,ngram-mod",
|
||||
"--spec-ngram-mod-n-match", "24", "--spec-ngram-mod-n-min", "48", "--spec-ngram-mod-n-max", "64",
|
||||
"--spec-draft-sampling", "greedy"}
|
||||
cfgOf := 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
|
||||
}
|
||||
dense := func(s *serveSysInfo) { s.GGUF = &GGUFInfo{Arch: "llama", BlockCount: 32} }
|
||||
cases := []struct {
|
||||
name string
|
||||
cfg map[string]string
|
||||
extra []string
|
||||
si func(*serveSysInfo)
|
||||
want []string
|
||||
notes int
|
||||
}{
|
||||
{name: "ngram, modèle dense : forme explicite, jamais --spec-default",
|
||||
cfg: cfgOf("SPEC", "ngram"), si: dense, want: ngram, notes: 1},
|
||||
{name: "ngram+mtp s'écrit aussi dans l'autre sens",
|
||||
cfg: cfgOf("SPEC", "ngram+mtp"), want: both, notes: 2},
|
||||
{name: "ngram sur un modèle hybride à tête MTP : tête inutilisée et points de reprise dits",
|
||||
cfg: cfgOf("SPEC", "ngram"), want: ngram, notes: 3},
|
||||
{name: "ngram, moteur qui n'a que le type dans sa liste : rien",
|
||||
cfg: cfgOf("SPEC", "ngram"), notes: 1, si: func(s *serveSysInfo) { s.Help = helpSpec }},
|
||||
{name: "mtp+ngram, moteur sans drapeaux ngram-mod : rien",
|
||||
cfg: cfgOf("SPEC", "mtp+ngram"), notes: 1, si: func(s *serveSysInfo) { s.Help = helpSpec }},
|
||||
{name: "mtp+ngram avec la tête dans le fichier : une seule liste de types",
|
||||
cfg: cfgOf("SPEC", "mtp+ngram"), want: both, notes: 2},
|
||||
{name: "mtp+ngram sans tête MTP : n-grammes seuls, jamais « failed to create MTP context »",
|
||||
cfg: cfgOf("SPEC", "mtp+ngram"), si: dense, want: ngram, notes: 1},
|
||||
{name: "mtp+ngram, moteur sans draft-mtp : n-grammes seuls",
|
||||
cfg: cfgOf("SPEC", "mtp+ngram"), want: ngram, notes: 2,
|
||||
si: func(s *serveSysInfo) {
|
||||
s.Help = strings.ReplaceAll(s.Help, "draft-mtp", "draft-xxx")
|
||||
}},
|
||||
{name: "mtp+ngram avec un petit modèle brouillon : n-grammes seuls, pas de -md",
|
||||
cfg: cfgOf("SPEC", "mtp+ngram", "MODEL_DRAFT", "Qwen3-0.6B.gguf"), want: ngram, notes: 2,
|
||||
si: func(s *serveSysInfo) {
|
||||
s.Draft = "/models/Qwen3-0.6B.gguf"
|
||||
s.DraftGGUF = &GGUFInfo{Arch: "qwen3", BlockCount: 28}
|
||||
}},
|
||||
{name: "mtp+ngram avec la tête MTP publiée à part",
|
||||
cfg: cfgOf("SPEC", "mtp+ngram", "MODEL_DRAFT", "mtp-q.gguf"), notes: 2,
|
||||
want: append([]string{"-md", "/models/mtp-q.gguf"}, both...),
|
||||
si: func(s *serveSysInfo) {
|
||||
s.GGUF.HasNextNTensor = false
|
||||
s.Draft = "/models/mtp-q.gguf"
|
||||
s.DraftGGUF = &GGUFInfo{Arch: "qwen35", BlockCount: 65, HasNextNTensor: true}
|
||||
}},
|
||||
{name: "ngram + MODEL_DRAFT : brouillon ignoré, et dit",
|
||||
cfg: cfgOf("SPEC", "ngram", "MODEL_DRAFT", "mtp-q.gguf"), si: dense, want: ngram, notes: 2},
|
||||
{name: "ngram + SPEC_N_MAX : ignoré, et dit",
|
||||
cfg: cfgOf("SPEC", "ngram", "SPEC_N_MAX", "4"), si: dense, want: ngram, notes: 2},
|
||||
{name: "--spec-type dans EXTRA_ARGS : Loki se tait",
|
||||
cfg: cfgOf("SPEC", "ngram"), extra: []string{"--spec-type", "ngram-simple"}, notes: 1},
|
||||
{name: "--spec_type=… dans EXTRA_ARGS : Loki se tait",
|
||||
cfg: cfgOf("SPEC", "ngram"), extra: []string{"--spec_type=ngram-mod"}, notes: 1},
|
||||
{name: "--spec-default dans EXTRA_ARGS : Loki se tait",
|
||||
cfg: cfgOf("SPEC", "mtp+ngram"), extra: []string{"--spec-default"}, notes: 1},
|
||||
{name: "--spec-ngram-mod-n-max dans EXTRA_ARGS : Loki se tait",
|
||||
cfg: cfgOf("SPEC", "ngram"), extra: []string{"--spec-ngram-mod-n-max=16"}, notes: 1},
|
||||
{name: "-md dans EXTRA_ARGS : Loki se tait",
|
||||
cfg: cfgOf("SPEC", "mtp+ngram"), extra: []string{"-md", "d.gguf"}, notes: 1},
|
||||
{name: "LLAMA_ARG_SPEC_TYPE posée : Loki se tait", cfg: cfgOf("SPEC", "ngram"), notes: 1,
|
||||
si: func(s *serveSysInfo) { s.ArgEnv["LLAMA_ARG_SPEC_TYPE"] = "ngram-mod" }},
|
||||
{name: "ngram + --spec-draft-n-max à la main : rien",
|
||||
cfg: cfgOf("SPEC", "ngram"), si: dense, extra: []string{"--spec-draft-n-max", "8"}, notes: 1},
|
||||
{name: "mtp+ngram + --spec-draft-n-max à la main : la règle de SPEC=mtp",
|
||||
cfg: cfgOf("SPEC", "mtp+ngram"), extra: []string{"--spec-draft-n-max", "8"}, want: both, notes: 2},
|
||||
{name: "experts sur CPU, seuil par défaut : refusé, OP_OFFLOAD_MIN_BATCH suggéré",
|
||||
cfg: cfgOf("SPEC", "ngram"), si: dense, extra: []string{"--n-cpu-moe", "40"}, notes: 1},
|
||||
{name: "experts sur CPU, mtp+ngram : refusé aussi",
|
||||
cfg: cfgOf("SPEC", "mtp+ngram"), extra: []string{"-ot", "exps=CPU"}, notes: 1},
|
||||
{name: "experts sur CPU, OP_OFFLOAD_MIN_BATCH=64 : encore sous le lot de 65, refusé",
|
||||
cfg: cfgOf("SPEC", "ngram", "OP_OFFLOAD_MIN_BATCH", "64"), si: dense, extra: []string{"-cmoe"}, notes: 1},
|
||||
{name: "experts sur CPU, OP_OFFLOAD_MIN_BATCH=128 : accepté",
|
||||
cfg: cfgOf("SPEC", "ngram", "OP_OFFLOAD_MIN_BATCH", "128"), si: dense, extra: []string{"-cmoe"}, want: ngram, notes: 1},
|
||||
{name: "experts sur CPU, GGML_OP_OFFLOAD_MIN_BATCH=256 dans l'environnement : accepté",
|
||||
cfg: cfgOf("SPEC", "ngram"), extra: []string{"--n-cpu-moe", "40"}, want: ngram, notes: 1,
|
||||
si: func(s *serveSysInfo) {
|
||||
dense(s)
|
||||
s.UserEnv = map[string]string{"GGML_OP_OFFLOAD_MIN_BATCH": "256"}
|
||||
}},
|
||||
{name: "experts sur CPU, modèle plus gros que la RAM : accepté avec l'avertissement",
|
||||
cfg: cfgOf("SPEC", "ngram", "OP_OFFLOAD_MIN_BATCH", "128"), extra: []string{"--n-cpu-moe", "40"},
|
||||
want: ngram, notes: 2,
|
||||
si: func(s *serveSysInfo) {
|
||||
dense(s)
|
||||
s.ModelBytes, s.RAMMiB = 82<<30, 64<<10
|
||||
}},
|
||||
{name: "NGL imposé : logits en VRAM dits",
|
||||
cfg: cfgOf("SPEC", "ngram", "NGL", "40"), si: dense, want: ngram, notes: 2},
|
||||
}
|
||||
for _, c := range cases {
|
||||
t.Run(c.name, func(t *testing.T) {
|
||||
si := mtpReady()
|
||||
si.Help = helpNgram
|
||||
if c.si != nil {
|
||||
c.si(&si)
|
||||
}
|
||||
got, notes, isAuto := specArgs(c.cfg, c.extra, si)
|
||||
if !reflect.DeepEqual(got, c.want) {
|
||||
t.Errorf("args = %q, attendu %q", got, c.want)
|
||||
}
|
||||
if isAuto {
|
||||
t.Error("ngram n'est jamais automatique")
|
||||
}
|
||||
if len(notes) != c.notes {
|
||||
t.Errorf("%d notes, attendu %d : %q", len(notes), c.notes, notes)
|
||||
}
|
||||
for _, a := range got {
|
||||
if a == "--spec-default" || strings.HasPrefix(a, "--spec-synth") {
|
||||
t.Errorf("drapeau interdit %q", a)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// Sans SPEC, la même ligne qu'avant, quel que soit le moteur.
|
||||
func TestBuildServeArgsNgramDefault(t *testing.T) {
|
||||
si := mtpReady()
|
||||
si.Help = helpNgram
|
||||
base, _, _ := buildServeArgs(map[string]string{}, nil, "/bin/llama-server", si)
|
||||
for _, a := range base {
|
||||
if strings.HasPrefix(a, "--spec") {
|
||||
t.Fatalf("SPEC absent : aucun drapeau de spéculation, %q", base)
|
||||
}
|
||||
}
|
||||
got, _, _ := buildServeArgs(map[string]string{"SPEC": "ngram"}, []string{"--jinja"}, "/bin/llama-server", si)
|
||||
i := slices.Index(got, "--spec-type")
|
||||
if i < 0 || got[i+1] != "ngram-mod" || slices.Index(got, "--jinja") < i {
|
||||
t.Fatalf("SPEC=ngram : %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSynthAcceptanceWarned(t *testing.T) {
|
||||
for _, extra := range [][]string{{"--spec-synth-len", "3"}, {"--spec-synth-rates=0.5,0.5"}} {
|
||||
if n := lossyCacheNotes(extra, serveSysInfo{ArgEnv: map[string]string{}}); len(n) != 1 ||
|
||||
!strings.Contains(n[0], "AU HASARD") {
|
||||
t.Errorf("%q : %q", extra, n)
|
||||
}
|
||||
}
|
||||
si := serveSysInfo{ArgEnv: map[string]string{"LLAMA_ARG_SPEC_SYNTH_LEN": "3"}}
|
||||
if n := lossyCacheNotes(nil, si); len(n) != 1 {
|
||||
t.Errorf("variable : %q", n)
|
||||
}
|
||||
}
|
||||
|
||||
// probeSpec reconnaît un MODEL_DRAFT qui désigne le modèle principal.
|
||||
func TestProbeSpecDraftIsModel(t *testing.T) {
|
||||
t.Setenv("LOKI_HOME", t.TempDir())
|
||||
dir := t.TempDir()
|
||||
model := dir + "/Swift-27B.gguf"
|
||||
other := dir + "/mtp-Swift.gguf"
|
||||
for _, p := range []string{model, other} {
|
||||
if err := os.WriteFile(p, []byte("GGUF"), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
si := serveSysInfo{Model: model}
|
||||
probeSpec(map[string]string{"MODEL_DRAFT": model}, &si)
|
||||
if !si.DraftIsModel {
|
||||
t.Errorf("MODEL_DRAFT = le modèle, non reconnu (Draft=%q, err=%q)", si.Draft, si.DraftErr)
|
||||
}
|
||||
si = serveSysInfo{Model: model}
|
||||
probeSpec(map[string]string{"MODEL_DRAFT": other}, &si)
|
||||
if si.DraftIsModel || si.Draft != other {
|
||||
t.Errorf("tête à part prise pour le modèle : %+v", si)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,381 @@
|
||||
package loki
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"math"
|
||||
"os"
|
||||
"os/exec"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
// Parallélisme de tenseurs (SPLIT_MODE=tensor, off par défaut) : expérimental.
|
||||
//
|
||||
// La découpe par couches (défaut de llama.cpp) donne à chaque carte des
|
||||
// couches entières, traversées l'une après l'autre. En mode tensor, chaque
|
||||
// couche est coupée entre les cartes, qui calculent en même temps puis
|
||||
// additionnent leurs morceaux (all-reduce). Sur deux RTX 4090, l'amont mesure
|
||||
// +21-30 % au décodage, mais -37 % au prefill — et la boucle d'agent de Loki
|
||||
// relit beaucoup. Sur deux GeForce en PCIe sans P2P, la somme passe par
|
||||
// l'hôte : un gain net par tour est loin d'être acquis. D'où l'opt-in, et une
|
||||
// longue liste de refus propres plutôt qu'un moteur qui meurt en boucle.
|
||||
//
|
||||
// Fidélité : l'all-reduce change l'ordre des additions (bruit de virgule
|
||||
// flottante), mais le chemin NCCL va plus loin — au-delà de 32 768 éléments il
|
||||
// COMPRESSE les sommes partielles en BF16 (ggml-cuda.cu), soit à chaque couche
|
||||
// de chaque prefill : une perte de précision du calcul, de la classe d'une
|
||||
// quantification. Loki pose donc GGML_CUDA_ALLREDUCE=internal (réduction dans
|
||||
// le type natif), sauf si l'utilisateur a choisi lui-même. Le cache KV doit
|
||||
// rester f16, bf16 ou f32 : le mode tensor ne gère pas les caches quantifiés,
|
||||
// et Loki ne réécrit jamais un type de cache d'office (ça change le budget
|
||||
// VRAM). Pas de --fit en mode tensor (common/fit.cpp) : Loki estime la VRAM
|
||||
// lui-même et refuse si le compte ne tient pas, sans jamais réduire CTX.
|
||||
|
||||
// splitDev : une carte telle que la voit CE moteur (--list-devices), dans son
|
||||
// ordre — celui que suivent -ts et --device.
|
||||
type splitDev struct {
|
||||
ID string // CUDA0, Vulkan1…
|
||||
TotalMiB int64
|
||||
}
|
||||
|
||||
// splitTensorDenyArch : les architectures que llama.cpp refuse en mode tensor
|
||||
// (llm_arch_supports_sm_tensor) — il mourrait au chargement, et systemd
|
||||
// relancerait en boucle. qwen4exp n'y est pas, mais Loki l'écarte : son preset
|
||||
// place les experts sur CPU avec un cache q8_0, l'inverse de ce qu'exige le
|
||||
// mode tensor.
|
||||
var splitTensorDenyArch = map[string]bool{
|
||||
"grok": true, "mpt": true, "plamo2": true, "minicpm3": true, "gemma3n": true, "mamba": true, "mamba2": true,
|
||||
"jamba": true, "falcon-h1": true, "olmo2": true, "olmoe": true, "deepseek2": true, "deepseek32": true,
|
||||
"hy_v4": true, "dots3note": true, "glm-dsa": true, "bitnet": true, "t5": true, "nemotron_h": true,
|
||||
"nemotron_h_moe": true, "granitehybrid": true, "minimax-01": true, "minimax-m2": true, "minimax-m3": true,
|
||||
"mistral4": true, "kimi-linear": true, "bailingmoe3": true, "kimi-k3": true, "glm5-next": true,
|
||||
"qwen3tts": true, "qwen4exp": true,
|
||||
}
|
||||
|
||||
// splitArgEnv : les variables qui décident de ce que SPLIT_MODE règlerait.
|
||||
var splitArgEnv = []string{"LLAMA_ARG_FLASH_ATTN", "LLAMA_ARG_BACKEND_SAMPLING", "LLAMA_ARG_TENSOR_SPLIT"}
|
||||
|
||||
// splitModeKey lit SPLIT_MODE : "" (clé absente, off ou layer — le défaut du
|
||||
// moteur, rien à poser), "tensor", ou "?" si illisible.
|
||||
func splitModeKey(cfg map[string]string) string {
|
||||
switch v := strings.ToLower(strings.TrimSpace(cfg["SPLIT_MODE"])); v {
|
||||
case "", "off", "layer", "non", "no", "0":
|
||||
return ""
|
||||
case "tensor":
|
||||
return v
|
||||
}
|
||||
return "?"
|
||||
}
|
||||
|
||||
// helpSupportsSplitTensor : la liste LITTÉRALE des valeurs de -sm. Le mot
|
||||
// « tensor » seul est partout dans l'aide (--tensor-split, --override-tensor) :
|
||||
// s'y fier ferait passer -sm tensor à un moteur qui le refuse au démarrage.
|
||||
func helpSupportsSplitTensor(help string) bool {
|
||||
return strings.Contains(help, "{none,layer,row,tensor}")
|
||||
}
|
||||
|
||||
// probeSplit : seulement si SPLIT_MODE=tensor et que le moteur le propose —
|
||||
// sans la clé, rien n'est lu ni lancé. --list-devices tourne dans
|
||||
// l'environnement du vrai lancement (bibliothèques, CUDA_VISIBLE_DEVICES et
|
||||
// PCI_BUS_ID déjà posés par cmdServe) : même ordre de cartes que le moteur.
|
||||
func probeSplit(cfg map[string]string, bin string, si *serveSysInfo) {
|
||||
if splitModeKey(cfg) != "tensor" || !helpSupportsSplitTensor(si.Help) {
|
||||
return
|
||||
}
|
||||
if si.ArgEnv == nil {
|
||||
si.ArgEnv = map[string]string{}
|
||||
}
|
||||
for _, k := range splitArgEnv {
|
||||
if v, ok := os.LookupEnv(k); ok && strings.TrimSpace(v) != "" {
|
||||
si.ArgEnv[k] = v
|
||||
}
|
||||
}
|
||||
if si.UserEnv == nil {
|
||||
si.UserEnv = map[string]string{}
|
||||
}
|
||||
if v := os.Getenv("GGML_CUDA_ALLREDUCE"); v != "" {
|
||||
si.UserEnv["GGML_CUDA_ALLREDUCE"] = v
|
||||
}
|
||||
si.SplitDevs = listEngineDevices(bin)
|
||||
}
|
||||
|
||||
// listEngineDevices : les cartes que CE moteur voit (--list-devices), dans
|
||||
// l'environnement du processus — celui du vrai lancement quand cmdServe
|
||||
// appelle. nil si le moteur échoue : sortie tronquée (carte pleine, moteur
|
||||
// planté), on ne s'y fie pas.
|
||||
func listEngineDevices(bin string) []splitDev {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 20*time.Second)
|
||||
defer cancel()
|
||||
out, err := hideCmd(exec.CommandContext(ctx, bin, "--list-devices")).CombinedOutput()
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
return splitDevsFrom(parseListDevices(string(out)))
|
||||
}
|
||||
|
||||
// splitDevsFrom : les cartes de parseListDevices (ou de devices.json, relu en
|
||||
// JSON : les nombres y sont des float64).
|
||||
func splitDevsFrom(list []map[string]any) []splitDev {
|
||||
var devs []splitDev
|
||||
for _, d := range list {
|
||||
id, _ := d["id"].(string)
|
||||
var total int64
|
||||
switch v := d["total_mib"].(type) {
|
||||
case int:
|
||||
total = int64(v)
|
||||
case float64:
|
||||
total = int64(v)
|
||||
}
|
||||
devs = append(devs, splitDev{ID: id, TotalMiB: total})
|
||||
}
|
||||
return devs
|
||||
}
|
||||
|
||||
// splitPlan décide de SPLIT_MODE=tensor pour ce lancement. Fonction pure.
|
||||
//
|
||||
// on : accepté ; la ligne gagne -sm tensor et ses compagnons
|
||||
// why : pourquoi refusé (vide = clé absente)
|
||||
// devs : les cartes du découpage, dans l'ordre du moteur
|
||||
// needMiB : la VRAM estimée (poids + cache de CTX jetons)
|
||||
func splitPlan(cfg map[string]string, extra []string, si serveSysInfo) (on bool, why string, devs []splitDev, needMiB int64) {
|
||||
switch splitModeKey(cfg) {
|
||||
case "":
|
||||
return false, "", nil, 0
|
||||
case "?":
|
||||
return false, "SPLIT_MODE=" + strings.TrimSpace(cfg["SPLIT_MODE"]) + " illisible (tensor ou off)", nil, 0
|
||||
}
|
||||
refuse := func(why string) (bool, string, []splitDev, int64) { return false, why, nil, 0 }
|
||||
if isExternalConfig(cfg) {
|
||||
return refuse("preset externe")
|
||||
}
|
||||
if !helpSupportsSplitTensor(si.Help) {
|
||||
return refuse("ce moteur ne propose pas -sm tensor")
|
||||
}
|
||||
if sm := flagValue(extra, "-sm", "--split-mode"); sm != "" {
|
||||
return refuse("-sm " + sm + " déjà dans EXTRA_ARGS")
|
||||
}
|
||||
if si.ArgEnv["LLAMA_ARG_SPLIT_MODE"] != "" {
|
||||
return refuse("LLAMA_ARG_SPLIT_MODE déjà posée")
|
||||
}
|
||||
fa := flagValue(extra, "-fa", "--flash-attn")
|
||||
if fa == "" {
|
||||
fa = si.ArgEnv["LLAMA_ARG_FLASH_ATTN"]
|
||||
}
|
||||
switch strings.ToLower(strings.TrimSpace(fa)) {
|
||||
case "off", "disabled", "false", "0":
|
||||
return refuse("Flash Attention coupée (-fa " + fa + ") : le mode tensor l'exige")
|
||||
}
|
||||
if hasAnyFlag(extra, "-bs", "--backend-sampling") || envTruthy(si.ArgEnv["LLAMA_ARG_BACKEND_SAMPLING"]) {
|
||||
return refuse("échantillonnage sur GPU (--backend-sampling) non pris en charge en mode tensor")
|
||||
}
|
||||
k, v, _ := effectiveKVTypes(cfg, extra, si.ArgEnv)
|
||||
if !splitKVOK(k) || !splitKVOK(v) {
|
||||
return refuse(fmt.Sprintf("cache KV %s/%s : le mode tensor n'accepte que f16, bf16 ou f32 "+
|
||||
"(jamais réécrit d'office : la VRAM changerait)", kvName(k), kvName(v)))
|
||||
}
|
||||
if r := tensorOverride(extra, si.ArgEnv); r != "" {
|
||||
return refuse(r + " : le mode tensor met tout sur les cartes")
|
||||
}
|
||||
if !kvOffloaded(extra, si.ArgEnv) {
|
||||
return refuse("cache KV sur CPU")
|
||||
}
|
||||
switch {
|
||||
case si.GGUF == nil || si.ModelBytes <= 0:
|
||||
return refuse("métadonnées ou taille du modèle illisibles")
|
||||
case splitTensorDenyArch[strings.ToLower(si.GGUF.Arch)]:
|
||||
return refuse("architecture " + si.GGUF.Arch + " exclue du mode tensor")
|
||||
}
|
||||
if _, why := splitNGL(cfg, extra, si.GGUF.BlockCount); why != "" {
|
||||
return refuse(why)
|
||||
}
|
||||
ctxS, ok := ctxFixed(cfg, extra)
|
||||
if !ok {
|
||||
return refuse("contexte « " + ctxS + " » non chiffré : pas de --fit en mode tensor, donne un CTX explicite")
|
||||
}
|
||||
if sideSlotOn(cfg) {
|
||||
return refuse("SIDE_SLOT : cache doublé, sans --fit pour rattraper")
|
||||
}
|
||||
if specMode(cfg) != "off" || specUserSet(extra, si.ArgEnv) {
|
||||
return refuse("décodage spéculatif : brouillon et vérification par lots non validés en mode tensor")
|
||||
}
|
||||
devs, why = splitDevices(extra, si)
|
||||
if why != "" {
|
||||
return refuse(why)
|
||||
}
|
||||
// VRAM : poids + cache de CTX jetons (et état récurrent) contre 90 % des
|
||||
// cartes, la marge de SIDE_SLOT. Les tampons de calcul n'y sont pas : c'est
|
||||
// un minimum, pas une promesse — le journal dira le reste.
|
||||
ctx, _ := strconv.Atoi(ctxS)
|
||||
perTok, rec := ggufStateBytes(*si.GGUF, k, v)
|
||||
if perTok <= 0 {
|
||||
return refuse("taille du cache KV inconnue pour ce modèle")
|
||||
}
|
||||
var total int64
|
||||
for _, d := range devs {
|
||||
total += d.TotalMiB
|
||||
}
|
||||
mm := int64(0)
|
||||
if si.MMProj != "" {
|
||||
if st, err := os.Stat(si.MMProj); err == nil {
|
||||
mm = st.Size()
|
||||
}
|
||||
}
|
||||
need := float64(si.ModelBytes+mm) + perTok*float64(ctx) + float64(rec)
|
||||
needMiB = int64(math.Ceil(need / (1 << 20)))
|
||||
if need > 0.9*float64(total)*(1<<20) {
|
||||
return refuse(fmt.Sprintf("VRAM insuffisante sans --fit : ~%d Mio (poids + cache de %d jetons) pour %d Mio "+
|
||||
"sur %d cartes — CTX n'est jamais réduit d'office", needMiB, ctx, total, len(devs)))
|
||||
}
|
||||
return true, "", devs, needMiB
|
||||
}
|
||||
|
||||
func splitKVOK(t string) bool {
|
||||
switch strings.ToLower(strings.TrimSpace(t)) {
|
||||
case "", "f16", "bf16", "f32":
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func kvName(t string) string {
|
||||
if t == "" {
|
||||
return "f16"
|
||||
}
|
||||
return t
|
||||
}
|
||||
|
||||
// splitNGL : les couches GPU en mode tensor, où tout doit être sur les cartes.
|
||||
// Un -ngl d'EXTRA_ARGS reste celui de l'utilisateur (rien à ajouter) s'il dit
|
||||
// « tout » ; sinon NGL est traduit en « -ngl all » (999, auto et la clé absente
|
||||
// visent déjà le tout-GPU, mais auto ne fait rien sans --fit). Une valeur qui
|
||||
// laisse des couches au CPU est refusée.
|
||||
func splitNGL(cfg map[string]string, extra []string, blocks int) (args []string, why string) {
|
||||
all := func(v string) bool {
|
||||
v = strings.ToLower(strings.TrimSpace(v))
|
||||
if v == "all" || v == "999" {
|
||||
return true
|
||||
}
|
||||
n, err := strconv.Atoi(v)
|
||||
return err == nil && blocks > 0 && n >= blocks+1
|
||||
}
|
||||
if v := flagValue(extra, "-ngl", "--n-gpu-layers", "--gpu-layers"); v != "" {
|
||||
if all(v) {
|
||||
return nil, ""
|
||||
}
|
||||
return nil, "-ngl " + v + " dans EXTRA_ARGS : le mode tensor veut toutes les couches sur les cartes (all)"
|
||||
}
|
||||
switch v := strings.TrimSpace(cfg["NGL"]); {
|
||||
case v == "" || strings.EqualFold(v, "auto") || all(v):
|
||||
return []string{"-ngl", "all"}, ""
|
||||
default:
|
||||
return nil, "NGL=" + v + " : le mode tensor veut toutes les couches sur les cartes (all)"
|
||||
}
|
||||
}
|
||||
|
||||
// splitDevices : les cartes du découpage. --device (EXTRA_ARGS ou variable)
|
||||
// choisit lesquelles, dans son ordre ; sinon toutes celles que liste le
|
||||
// moteur. Toutes CUDA (l'all-reduce n'existe qu'entre cartes CUDA), deux au
|
||||
// moins.
|
||||
func splitDevices(extra []string, si serveSysInfo) ([]splitDev, string) {
|
||||
if len(si.SplitDevs) == 0 {
|
||||
return nil, "cartes inconnues (--list-devices n'a rien répondu)"
|
||||
}
|
||||
devs := si.SplitDevs
|
||||
sel := flagValue(extra, "-dev", "--device")
|
||||
if sel == "" {
|
||||
sel = si.ArgEnv["LLAMA_ARG_DEVICE"]
|
||||
}
|
||||
if sel = strings.TrimSpace(sel); sel != "" {
|
||||
devs = nil
|
||||
for _, name := range strings.Split(sel, ",") {
|
||||
name = strings.TrimSpace(name)
|
||||
found := false
|
||||
for _, d := range si.SplitDevs {
|
||||
if strings.EqualFold(d.ID, name) {
|
||||
devs, found = append(devs, d), true
|
||||
break
|
||||
}
|
||||
}
|
||||
if !found {
|
||||
return nil, "--device " + name + " inconnu de ce moteur"
|
||||
}
|
||||
}
|
||||
}
|
||||
for _, d := range devs {
|
||||
if !strings.HasPrefix(strings.ToUpper(d.ID), "CUDA") {
|
||||
return nil, d.ID + " n'est pas une carte CUDA"
|
||||
}
|
||||
}
|
||||
if len(devs) < 2 {
|
||||
return nil, "une seule carte"
|
||||
}
|
||||
return devs, ""
|
||||
}
|
||||
|
||||
// splitTS : la répartition par défaut, au prorata de la VRAM TOTALE de chaque
|
||||
// carte (la libre varie avec ce qui tourne encore au lancement), en pour cent.
|
||||
func splitTS(devs []splitDev) string {
|
||||
var total int64
|
||||
for _, d := range devs {
|
||||
total += d.TotalMiB
|
||||
}
|
||||
if total <= 0 {
|
||||
return ""
|
||||
}
|
||||
parts := make([]string, len(devs))
|
||||
for i, d := range devs {
|
||||
parts[i] = strconv.FormatInt(int64(math.Round(100*float64(d.TotalMiB)/float64(total))), 10)
|
||||
}
|
||||
return strings.Join(parts, ",")
|
||||
}
|
||||
|
||||
// splitTensorArgs : ce que SPLIT_MODE=tensor ajoute à la ligne, à la place du -ngl
|
||||
// habituel (nglOut), l'environnement et les notes. on=false : rien, et la
|
||||
// ligne reste celle d'avant à l'octet près.
|
||||
func splitTensorArgs(cfg map[string]string, extra []string, si serveSysInfo) (on bool, args, ngl []string, env map[string]string, notes []string) {
|
||||
ok, why, devs, needMiB := splitPlan(cfg, extra, si)
|
||||
if !ok {
|
||||
if why != "" {
|
||||
notes = append(notes, "SPLIT_MODE=tensor refusé ("+why+") : découpe par couches, comme sans la clé")
|
||||
}
|
||||
return false, nil, nil, nil, notes
|
||||
}
|
||||
ngl, _ = splitNGL(cfg, extra, si.GGUF.BlockCount)
|
||||
args = []string{"-sm", "tensor"}
|
||||
ts := ""
|
||||
if !hasAnyFlag(extra, "-ts", "--tensor-split") && si.ArgEnv["LLAMA_ARG_TENSOR_SPLIT"] == "" {
|
||||
if ts = splitTS(devs); ts != "" {
|
||||
args = append(args, "-ts", ts)
|
||||
}
|
||||
}
|
||||
// llama.cpp active de lui-même Flash Attention en mode tensor ; l'écrire
|
||||
// le rend lisible dans le journal. Jamais en double d'un -fa de l'utilisateur.
|
||||
if !hasAnyFlag(extra, "-fa", "--flash-attn") && si.ArgEnv["LLAMA_ARG_FLASH_ATTN"] == "" {
|
||||
args = append(args, "-fa", "on")
|
||||
}
|
||||
env = map[string]string{}
|
||||
note := fmt.Sprintf("SPLIT_MODE=tensor (expérimental) : -sm tensor sur %d cartes", len(devs))
|
||||
if ts != "" {
|
||||
note += ", -ts " + ts + " (VRAM totale)"
|
||||
}
|
||||
note += fmt.Sprintf(", ~%d Mio estimés sans --fit ; décodage plus rapide, prefill plus lent — compare le temps par tour", needMiB)
|
||||
switch ar := strings.ToLower(strings.TrimSpace(si.UserEnv["GGML_CUDA_ALLREDUCE"])); ar {
|
||||
case "":
|
||||
env["GGML_CUDA_ALLREDUCE"] = "internal"
|
||||
note += " ; GGML_CUDA_ALLREDUCE=internal : sommes dans le type natif (NCCL les compresserait en BF16, avec perte). " +
|
||||
"« NCCL not compiled in » ou « NCCL is unavailable » au journal : sans effet ici. GeForce sans P2P : les " +
|
||||
"sommes passent par l'hôte ; GGML_CUDA_P2P=1 à poser soi-même si le pilote le permet"
|
||||
case "nccl":
|
||||
note += " ; avertissement : GGML_CUDA_ALLREDUCE=nccl (environnement) — NCCL compresse en BF16 les grosses " +
|
||||
"réductions du prefill : calcul avec perte. « NCCL not compiled in » au journal = repli sur la réduction interne"
|
||||
default:
|
||||
note += " ; GGML_CUDA_ALLREDUCE=" + ar + " (environnement) gardé"
|
||||
}
|
||||
if si.GGUF.Hybrid {
|
||||
note += " ; modèle hybride : état récurrent et points de reprise répartis entre cartes, non validés en amont"
|
||||
}
|
||||
notes = append(notes, note)
|
||||
return true, args, ngl, env, notes
|
||||
}
|
||||
@@ -0,0 +1,214 @@
|
||||
package loki
|
||||
|
||||
import (
|
||||
"reflect"
|
||||
"slices"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
const helpTensor = helpRecent + `-sm, --split-mode {none,layer,row,tensor}
|
||||
how to split the model across multiple GPUs
|
||||
-ts, --tensor-split N0,N1,N2,... fraction of the model to offload to each GPU
|
||||
`
|
||||
|
||||
// tensorReady : un hybride 64 couches de 16 Gio sur 16 + 12 Gio de VRAM, sur
|
||||
// un moteur qui propose -sm tensor. Le cas où SPLIT_MODE=tensor est accepté.
|
||||
func tensorReady() serveSysInfo {
|
||||
return serveSysInfo{
|
||||
Help: helpTensor,
|
||||
Model: "/models/Qwen3.6-27B.gguf",
|
||||
GGUF: &GGUFInfo{Arch: "qwen35", BlockCount: 64, KeyLen: 256, ValLen: 256, HeadCountKV: 4,
|
||||
Hybrid: true, FullAttnInterval: 4},
|
||||
ModelBytes: 16 << 30,
|
||||
ArgEnv: map[string]string{},
|
||||
UserEnv: map[string]string{},
|
||||
SplitDevs: []splitDev{{ID: "CUDA0", TotalMiB: 16311}, {ID: "CUDA1", TotalMiB: 12288}},
|
||||
}
|
||||
}
|
||||
|
||||
func TestSplitTensorPlan(t *testing.T) {
|
||||
tensor := map[string]string{"SPLIT_MODE": "tensor", "CTX": "65536"}
|
||||
with := func(kv ...string) map[string]string {
|
||||
m := map[string]string{}
|
||||
for k, v := range tensor {
|
||||
m[k] = v
|
||||
}
|
||||
for i := 0; i+1 < len(kv); i += 2 {
|
||||
m[kv[i]] = kv[i+1]
|
||||
}
|
||||
return m
|
||||
}
|
||||
cases := []struct {
|
||||
name string
|
||||
cfg map[string]string
|
||||
extra []string
|
||||
si func(*serveSysInfo)
|
||||
on bool
|
||||
why string // fragment attendu de la raison du refus
|
||||
}{
|
||||
{name: "clé absente : rien, en silence", cfg: map[string]string{}},
|
||||
{name: "SPLIT_MODE=layer : le défaut, rien", cfg: map[string]string{"SPLIT_MODE": "layer"}},
|
||||
{name: "accepté", cfg: tensor, on: true},
|
||||
{name: "illisible", cfg: with("SPLIT_MODE", "rows"), why: "illisible"},
|
||||
{name: "aide qui ne cite que --tensor-split : refusé",
|
||||
cfg: tensor, why: "-sm tensor", si: func(s *serveSysInfo) { s.Help = helpRecent + "-ts, --tensor-split N0,N1\n" }},
|
||||
{name: "preset externe", cfg: with(extKeyFlag, "1"), why: "externe"},
|
||||
{name: "-sm dans EXTRA_ARGS : l'utilisateur garde la main", cfg: tensor, extra: []string{"-sm", "layer"}, why: "-sm layer"},
|
||||
{name: "LLAMA_ARG_SPLIT_MODE", cfg: tensor, why: "LLAMA_ARG_SPLIT_MODE",
|
||||
si: func(s *serveSysInfo) { s.ArgEnv["LLAMA_ARG_SPLIT_MODE"] = "row" }},
|
||||
{name: "-fa off : refusé", cfg: tensor, extra: []string{"-fa", "off"}, why: "Flash Attention"},
|
||||
{name: "-fa on : accepté", cfg: tensor, extra: []string{"-fa", "on"}, on: true},
|
||||
{name: "--backend-sampling : refusé", cfg: tensor, extra: []string{"--backend-sampling"}, why: "backend-sampling"},
|
||||
{name: "LLAMA_ARG_BACKEND_SAMPLING : refusé", cfg: tensor, why: "backend-sampling",
|
||||
si: func(s *serveSysInfo) { s.ArgEnv["LLAMA_ARG_BACKEND_SAMPLING"] = "1" }},
|
||||
{name: "KV_TYPE=q8_0 : refusé, jamais réécrit", cfg: with("KV_TYPE", "q8_0"), why: "q8_0"},
|
||||
{name: "-ctk q8_0 dans EXTRA_ARGS (preset MoE) : refusé", cfg: tensor,
|
||||
extra: []string{"--cache-type-k", "q8_0", "--cache-type-v", "q8_0"}, why: "q8_0"},
|
||||
{name: "KV_TYPE_V=q4_0 : refusé", cfg: with("KV_TYPE_V", "q4_0"), why: "q4_0"},
|
||||
{name: "KV bf16 : accepté", cfg: with("KV_TYPE", "bf16"), on: true},
|
||||
{name: "--n-cpu-moe : refusé", cfg: tensor, extra: []string{"--n-cpu-moe", "40"}, why: "n-cpu-moe"},
|
||||
{name: "-ot : refusé", cfg: tensor, extra: []string{"-ot", "exps=CPU"}, why: "surcharge"},
|
||||
{name: "cache KV sur CPU : refusé", cfg: tensor, extra: []string{"-nkvo"}, why: "KV sur CPU"},
|
||||
{name: "NGL=40 : refusé", cfg: with("NGL", "40"), why: "NGL=40"},
|
||||
{name: "NGL=999 : accepté", cfg: with("NGL", "999"), on: true},
|
||||
{name: "-ngl 99 dans EXTRA_ARGS (≥ toutes les couches) : accepté", cfg: tensor, extra: []string{"-ngl", "99"}, on: true},
|
||||
{name: "-ngl 30 dans EXTRA_ARGS : refusé", cfg: tensor, extra: []string{"-ngl", "30"}, why: "-ngl 30"},
|
||||
{name: "CTX=0 : refusé", cfg: with("CTX", "0"), why: "non chiffré"},
|
||||
{name: "VRAM insuffisante : refusé, CTX jamais réduit", cfg: with("CTX", "262144"), why: "VRAM insuffisante"},
|
||||
{name: "qwen4exp : refusé", cfg: tensor, why: "qwen4exp", si: func(s *serveSysInfo) { s.GGUF.Arch = "qwen4exp" }},
|
||||
{name: "mamba (refusé par llama.cpp) : refusé proprement", cfg: tensor, why: "mamba",
|
||||
si: func(s *serveSysInfo) { s.GGUF.Arch = "mamba" }},
|
||||
{name: "GGUF illisible", cfg: tensor, why: "illisibles", si: func(s *serveSysInfo) { s.GGUF = nil }},
|
||||
{name: "SPEC=mtp : refusé", cfg: with("SPEC", "mtp"), why: "spéculatif"},
|
||||
{name: "SIDE_SLOT=on : refusé", cfg: with("SIDE_SLOT", "on"), why: "SIDE_SLOT"},
|
||||
{name: "une seule carte", cfg: tensor, why: "une seule carte",
|
||||
si: func(s *serveSysInfo) { s.SplitDevs = s.SplitDevs[:1] }},
|
||||
{name: "cartes inconnues", cfg: tensor, why: "inconnues", si: func(s *serveSysInfo) { s.SplitDevs = nil }},
|
||||
{name: "moteur Vulkan : refusé", cfg: tensor, why: "CUDA",
|
||||
si: func(s *serveSysInfo) { s.SplitDevs = []splitDev{{"Vulkan0", 16311}, {"Vulkan1", 12288}} }},
|
||||
{name: "--device d'une seule carte : refusé", cfg: tensor, extra: []string{"--device", "CUDA0"}, why: "une seule carte"},
|
||||
{name: "--device inconnu : refusé", cfg: tensor, extra: []string{"-dev", "CUDA0,CUDA7"}, why: "CUDA7"},
|
||||
}
|
||||
for _, c := range cases {
|
||||
t.Run(c.name, func(t *testing.T) {
|
||||
si := tensorReady()
|
||||
if c.si != nil {
|
||||
c.si(&si)
|
||||
}
|
||||
on, why, _, _ := splitPlan(c.cfg, c.extra, si)
|
||||
if on != c.on {
|
||||
t.Fatalf("on = %v, attendu %v (%q)", on, c.on, why)
|
||||
}
|
||||
if !strings.Contains(why, c.why) || (c.why == "" && why != "") {
|
||||
t.Errorf("raison %q, attendu %q", why, c.why)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestSplitTensorArgs(t *testing.T) {
|
||||
const bin = "/opt/llama/llama-server"
|
||||
cfg := map[string]string{"SPLIT_MODE": "tensor", "CTX": "65536", "NGL": "999"}
|
||||
|
||||
// Clé absente : la ligne et l'environnement d'avant, à l'octet près, même
|
||||
// sur un moteur et une machine où le mode tensor serait possible.
|
||||
plain := map[string]string{"CTX": "65536", "NGL": "999"}
|
||||
got, env, _ := buildServeArgs(plain, nil, bin, tensorReady())
|
||||
si := tensorReady()
|
||||
si.Help, si.SplitDevs = helpRecent, nil
|
||||
want, wantEnv, _ := buildServeArgs(plain, nil, bin, si)
|
||||
if !reflect.DeepEqual(got, want) || !reflect.DeepEqual(env, wantEnv) {
|
||||
t.Fatalf("sans SPLIT_MODE : %q %v, attendu %q %v", got, env, want, wantEnv)
|
||||
}
|
||||
|
||||
got, env, notes := buildServeArgs(cfg, nil, bin, tensorReady())
|
||||
tail := []string{"-ngl", "all", "-sm", "tensor", "-ts", "57,43", "-fa", "on"}
|
||||
if i := slices.Index(got, "-ngl"); i < 0 || !reflect.DeepEqual(got[i:i+len(tail)], tail) {
|
||||
t.Fatalf("args = %q, attendu …%q…", got, tail)
|
||||
}
|
||||
if env["GGML_CUDA_ALLREDUCE"] != "internal" {
|
||||
t.Errorf("GGML_CUDA_ALLREDUCE = %q, attendu internal (NCCL compresse en BF16)", env["GGML_CUDA_ALLREDUCE"])
|
||||
}
|
||||
if len(notes) == 0 || !strings.Contains(strings.Join(notes, "\n"), "NCCL") {
|
||||
t.Errorf("la note doit parler de NCCL : %q", notes)
|
||||
}
|
||||
|
||||
// Les choix de l'utilisateur gagnent et ne sont jamais doublés.
|
||||
extra := []string{"-ts", "60,40", "-fa", "on", "-ngl", "all"}
|
||||
got, _, _ = buildServeArgs(cfg, extra, bin, tensorReady())
|
||||
for _, f := range []string{"-ts", "-fa", "-ngl", "-sm"} {
|
||||
if n := countFlag(got, f); n != 1 {
|
||||
t.Errorf("%s présent %d fois : %q", f, n, got)
|
||||
}
|
||||
}
|
||||
|
||||
// --device dans l'ordre inverse : la répartition suit.
|
||||
got, _, _ = buildServeArgs(cfg, []string{"--device", "CUDA1,CUDA0"}, bin, tensorReady())
|
||||
if argValue(got, "-ts") != "43,57" {
|
||||
t.Errorf("-ts = %q, attendu 43,57", argValue(got, "-ts"))
|
||||
}
|
||||
|
||||
// GGML_CUDA_ALLREDUCE déjà posé : gardé, et NCCL signalé comme avec perte.
|
||||
si = tensorReady()
|
||||
si.UserEnv["GGML_CUDA_ALLREDUCE"] = "nccl"
|
||||
_, env, notes = buildServeArgs(cfg, nil, bin, si)
|
||||
if _, set := env["GGML_CUDA_ALLREDUCE"]; set {
|
||||
t.Errorf("choix de l'utilisateur écrasé : %v", env)
|
||||
}
|
||||
if !strings.Contains(strings.Join(notes, "\n"), "BF16") {
|
||||
t.Errorf("NCCL doit être signalé : %q", notes)
|
||||
}
|
||||
|
||||
// Les réglages qui lisent le placement voient le mode tensor : pas de
|
||||
// graphes Q/K/V en parallèle, pas de --fit-target, pas de file CUDA.
|
||||
withOpts := map[string]string{"CUDA_GRAPH_OPT": "on", "FIT_TARGET": "1024"}
|
||||
for k, v := range cfg {
|
||||
withOpts[k] = v
|
||||
}
|
||||
got, env, _ = buildServeArgs(withOpts, nil, bin, tensorReady())
|
||||
if env["GGML_CUDA_GRAPH_OPT"] != "" || slices.Contains(got, "--fit-target") {
|
||||
t.Errorf("mode tensor : ni GGML_CUDA_GRAPH_OPT ni --fit-target, %q %v", got, env)
|
||||
}
|
||||
|
||||
// Moteur sans -sm tensor : aucun compagnon (-ngl all, -ts, -fa), une note.
|
||||
si = tensorReady()
|
||||
si.Help = helpRecent
|
||||
got, env, notes = buildServeArgs(cfg, nil, bin, si)
|
||||
if slices.Contains(got, "-sm") || slices.Contains(got, "-ts") || slices.Contains(got, "-fa") ||
|
||||
argValue(got, "-ngl") != "auto" || env["GGML_CUDA_ALLREDUCE"] != "" {
|
||||
t.Errorf("refusé : ligne d'avant attendue, %q %v", got, env)
|
||||
}
|
||||
if !strings.Contains(strings.Join(notes, "\n"), "SPLIT_MODE=tensor refusé") {
|
||||
t.Errorf("le refus doit se dire : %q", notes)
|
||||
}
|
||||
}
|
||||
|
||||
func countFlag(args []string, f string) int {
|
||||
n := 0
|
||||
for _, a := range args {
|
||||
if a == f {
|
||||
n++
|
||||
}
|
||||
}
|
||||
return n
|
||||
}
|
||||
|
||||
func TestSplitTensorSlotPersist(t *testing.T) {
|
||||
si := tensorReady()
|
||||
si.Help += "--slot-save-path PATH\n"
|
||||
si.SlotDir = "/data/slots"
|
||||
cfg := map[string]string{"SLOT_PERSIST": "on", "HOST": "127.0.0.1", "SPLIT_MODE": "tensor"}
|
||||
if ok, why := slotPersistPlan(cfg, nil, si); ok || !strings.Contains(why, "SPLIT_MODE") {
|
||||
t.Errorf("SLOT_PERSIST avec SPLIT_MODE : refusé attendu, got %v %q", ok, why)
|
||||
}
|
||||
}
|
||||
|
||||
func TestHelpSupportsSplitTensor(t *testing.T) {
|
||||
if helpSupportsSplitTensor(helpRecent + "-ts, --tensor-split N0,N1 fraction\n--override-tensor\n") {
|
||||
t.Error("« tensor » ailleurs dans l'aide ne suffit pas")
|
||||
}
|
||||
if !helpSupportsSplitTensor(helpTensor) {
|
||||
t.Error("liste littérale de -sm : oui")
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,379 @@
|
||||
package loki
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func strataArgs(t *testing.T, cfg map[string]any) []string {
|
||||
t.Helper()
|
||||
var out []string
|
||||
for _, a := range cfg["args"].([]any) {
|
||||
out = append(out, a.(string))
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func argAfter(args []string, flag string) string {
|
||||
for i := 0; i+1 < len(args); i++ {
|
||||
if args[i] == flag {
|
||||
return args[i+1]
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func TestStrataBuildConfigApplyTuning(t *testing.T) {
|
||||
base := map[string]any{
|
||||
"exe": "/x/engine-cuda12/strata",
|
||||
"args": []any{"--pack", "/p", "--prefill", "8192", "--max-context", "131072"},
|
||||
"port": 8080.0,
|
||||
}
|
||||
out, err := strataBuildConfig(base, map[string]string{"PORT": "8081", "CTX": "65536"}, "k", "ram")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
a := strataArgs(t, out)
|
||||
for flag, want := range map[string]string{"--prefill": "16384", "--short-read": "150", "--prompt-cache-root": "256", "--max-context": "65536", "--pack": "/p"} {
|
||||
if got := argAfter(a, flag); got != want {
|
||||
t.Errorf("%s = %q, attendu %q (args %v)", flag, got, want, a)
|
||||
}
|
||||
}
|
||||
if strings.Count(strings.Join(a, " "), "--prefill") != 1 {
|
||||
t.Errorf("--prefill dupliqué : %v", a)
|
||||
}
|
||||
if strataHasArg(a, "--remote-expert-opt") || strataHasArg(a, "--expert-cache-device1") {
|
||||
t.Errorf("aide 2e carte sans 2e carte : %v", a)
|
||||
}
|
||||
if out["port"] != 8081 || out["api_key"] != "k" || out["host"] != "127.0.0.1" {
|
||||
t.Errorf("port/clé/hôte : %v %v %v", out["port"], out["api_key"], out["host"])
|
||||
}
|
||||
// la config de l'installeur n'est pas modifiée
|
||||
if base["args"].([]any)[3] != "8192" {
|
||||
t.Error("la config de base a été modifiée")
|
||||
}
|
||||
}
|
||||
|
||||
func TestStrataBuildConfigHelperGPU(t *testing.T) {
|
||||
base := map[string]any{"args": []any{"--pack", "/p", "--mmap-experts", "--vram-reserve-mib", "700"}, "gpu": 1.0,
|
||||
"vision": map[string]any{"exe": "v", "gpu": true}}
|
||||
out, err := strataBuildConfig(base, map[string]string{"STRATA_HELPER_GPU": "0"}, "", "ram")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
a := strataArgs(t, out)
|
||||
if argAfter(a, "--expert-cache-device1") != "auto" || !strataHasArg(a, "--remote-expert-opt") {
|
||||
t.Errorf("aide 2e carte absente : %v", a)
|
||||
}
|
||||
if strataHasArg(a, "--vram-reserve-mib") || strataHasArg(a, "700") {
|
||||
t.Errorf("réserve de VRAM gardée : %v", a)
|
||||
}
|
||||
if _, has := out["api_key"]; has {
|
||||
t.Error("api_key posée sans clé")
|
||||
}
|
||||
if _, has := out["gpu"]; has {
|
||||
t.Error("clé gpu gardée avec une carte d'aide (le serveur en ferait un --layer-split)")
|
||||
}
|
||||
if out["vision"].(map[string]any)["cuda_device"] != 0 {
|
||||
t.Errorf("encodeur pas sur la carte d'aide : %v", out["vision"])
|
||||
}
|
||||
if out["env"].(map[string]any)["STRATA_PLE_BATCH"] != "0" {
|
||||
t.Errorf("STRATA_PLE_BATCH absent : %v", out["env"])
|
||||
}
|
||||
}
|
||||
|
||||
func TestStrataCudaDevices(t *testing.T) {
|
||||
cases := []struct {
|
||||
main, helper, want string
|
||||
}{
|
||||
{"1", "0", "1,0"},
|
||||
{"0", "-1", "0"},
|
||||
{"0", "", "0"},
|
||||
{"", "", ""},
|
||||
}
|
||||
for _, c := range cases {
|
||||
got := strataCudaDevices(map[string]string{"STRATA_MAIN_GPU": c.main, "STRATA_HELPER_GPU": c.helper})
|
||||
if got != c.want {
|
||||
t.Errorf("main=%q helper=%q : %q, attendu %q", c.main, c.helper, got, c.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestStrataRecommend(t *testing.T) {
|
||||
// la machine de test : 5060 Ti 16 Go + 3070 8 Go, 47 Go de RAM, disque large
|
||||
env := strataEnv{Supported: true, Main: 1, Helper: 0, RAMGB: 47, AvailGB: 47, DiskFreeGB: 400,
|
||||
GPUs: []strataGPU{{Index: 0, VRAMGB: 8, Arch: 86}, {Index: 1, VRAMGB: 16, Arch: 120}}}
|
||||
if got := strataRecommend("swift", env); got != "IQ3_XXS" {
|
||||
t.Errorf("swift : %q, attendu IQ3_XXS", got)
|
||||
}
|
||||
// IQ3_S demande de garder 50 Go d'experts en RAM : pas sur 47 Go
|
||||
if got := strataRecommend("qwen", env); got != "IQ3_XXS" {
|
||||
t.Errorf("qwen 47 Go : %q, attendu IQ3_XXS", got)
|
||||
}
|
||||
f := strataFitFor(strataQuants["IQ3_XXS"], env, 0)
|
||||
// trop juste pour tout garder en RAM : experts lus depuis le disque (le mode
|
||||
// hors RAM demande un experts.bin, qu'une installation neuve n'a pas)
|
||||
if !f.OK || !f.Mmap || f.Drop {
|
||||
t.Errorf("IQ3_XXS sur 47 Go : ok=%v mmap=%v drop=%v (attendu ok, disque)", f.OK, f.Mmap, f.Drop)
|
||||
}
|
||||
// 64 Go : IQ3_S tient en RAM
|
||||
env.RAMGB, env.AvailGB = 64, 64
|
||||
if got := strataRecommend("qwen", env); got != "IQ3_S" {
|
||||
t.Errorf("qwen 64 Go : %q, attendu IQ3_S", got)
|
||||
}
|
||||
// 16 Go de RAM et une seule carte de 8 Go : rien ne tient
|
||||
small := strataEnv{Supported: true, Main: 0, Helper: -1, RAMGB: 16, DiskFreeGB: 400,
|
||||
GPUs: []strataGPU{{Index: 0, VRAMGB: 8, Arch: 86}}}
|
||||
if got := strataRecommend("swift", small); got != "" {
|
||||
t.Errorf("petite machine : %q, attendu aucune recommandation", got)
|
||||
}
|
||||
// disque trop petit
|
||||
env.DiskFreeGB = 50
|
||||
if f := strataFitFor(strataQuants["Q2_0"], env, 0); f.OK {
|
||||
t.Error("Q2_0 accepté avec 50 Go de disque")
|
||||
}
|
||||
}
|
||||
|
||||
func TestStrataPresetName(t *testing.T) {
|
||||
if got := strataPresetName("swift", "IQ3_XXS"); got != "STRATA FLASH NEXT SWIFT 1.5 IQ3_XXS" {
|
||||
t.Errorf("%q", got)
|
||||
}
|
||||
if got := strataPresetName("qwen", "Q2_0"); got != "STRATA FLASH NEXT CLASSIQUE Q2_0" {
|
||||
t.Errorf("%q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestStrataFitCountsExistingData(t *testing.T) {
|
||||
env := strataEnv{Supported: true, Main: 1, Helper: 0, RAMGB: 47, DiskFreeGB: 32,
|
||||
GPUs: []strataGPU{{Index: 0, VRAMGB: 8, Arch: 86}, {Index: 1, VRAMGB: 16, Arch: 120}}}
|
||||
if f := strataFitFor(strataQuants["IQ3_XXS"], env, 120); !f.OK {
|
||||
t.Errorf("données déjà présentes, refusé quand même : %s", f.Why)
|
||||
}
|
||||
if f := strataFitFor(strataQuants["IQ3_XXS"], env, 0); f.OK {
|
||||
t.Error("32 Go libres sans données : accepté")
|
||||
}
|
||||
}
|
||||
|
||||
func TestStrataBuildConfigPresetOverrides(t *testing.T) {
|
||||
base := map[string]any{"args": []any{"--kv", "int8", "--spec", "4"}}
|
||||
out, _ := strataBuildConfig(base, map[string]string{"STRATA_KV": "fp16", "STRATA_SPEC": "3"}, "", "ram")
|
||||
a := strataArgs(t, out)
|
||||
if argAfter(a, "--kv") != "fp16" || argAfter(a, "--spec") != "3" {
|
||||
t.Errorf("%v", a)
|
||||
}
|
||||
}
|
||||
|
||||
func TestStrataDetailsFor(t *testing.T) {
|
||||
home := t.TempDir()
|
||||
t.Setenv("LOKI_HOME", home)
|
||||
base := filepath.Join(home, "base.json")
|
||||
if err := os.WriteFile(base, []byte(`{"args":["--pack","/p","--kv","int8","--spec","4","--max-context","131072"],"vision":{"exe":"v"}}`), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
_ = os.MkdirAll(presetsDir(), 0o755)
|
||||
preset := "ENGINE=strata\nSTRATA_CONFIG=" + base + "\nSTRATA_MAIN_GPU=1\nSTRATA_HELPER_GPU=0\nSTRATA_MMAP=1\nSTRATA_KV=fp16\nSTRATA_KV_RESIDENT=32768\nSTRATA_SPEC=3\n"
|
||||
if err := os.WriteFile(filepath.Join(presetsDir(), "X.env"), []byte(preset), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
env := strataEnv{GPUs: []strataGPU{{Index: 0, Name: "NVIDIA GeForce RTX 3070", VRAMGB: 8}, {Index: 1, Name: "NVIDIA GeForce RTX 5060 Ti", VRAMGB: 16}}}
|
||||
d := strataDetailsFor("X", env)
|
||||
if d == nil {
|
||||
t.Fatal("pas de détails")
|
||||
}
|
||||
if d.KV != "fp16" || d.KVResident != "32768" || d.Spec != "3" || d.Ctx != "131072" || !d.Mmap ||
|
||||
d.MainGPU != "RTX 5060 Ti (16 Go)" || d.HelperGPU != "RTX 3070 (8 Go)" || d.VisionGPU != "RTX 3070 (8 Go)" ||
|
||||
d.ShortRead != "150" || d.CacheRoot != "256" || d.Prefill != "16384" {
|
||||
t.Errorf("%+v", *d)
|
||||
}
|
||||
}
|
||||
|
||||
func TestStrataHaveGBPerChoice(t *testing.T) {
|
||||
t.Setenv("LOKI_HOME", t.TempDir())
|
||||
write := func(rel string, n int) {
|
||||
p := filepath.Join(strataDataDir(), rel)
|
||||
_ = os.MkdirAll(filepath.Dir(p), 0o755)
|
||||
if err := os.WriteFile(p, make([]byte, n), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
write("models/swift-IQ3_XXS/a.gguf", 3000)
|
||||
write("packs/swift-iq3_xxs/experts.bin", 2000)
|
||||
write("mtp/rt/x", 500)
|
||||
write("models/IQ2_XS/b.gguf", 7000) // un autre choix : ne compte pas pour swift IQ3_XXS
|
||||
if got := strataHaveGB("swift", "IQ3_XXS") * 1e9; got != 5500 {
|
||||
t.Errorf("swift IQ3_XXS : %v octets, attendu 5500", got)
|
||||
}
|
||||
if got := strataHaveGB("swift", "IQ2_XS") * 1e9; got != 500 {
|
||||
t.Errorf("swift IQ2_XS : %v octets, attendu 500 (seulement la tête MTP commune)", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestStrataApplySettings(t *testing.T) {
|
||||
in := "# NAME=x\nENGINE=strata\nCTX=131072\nSTRATA_KV=fp16\nSTRATA_SPEC=3\nSTRATA_HELPER_GPU=0\n"
|
||||
out, err := strataApplySettings(in, strataSettingsReq{Ctx: 65536, KV: "int8", Spec: 4, Vision: false, Helper: false})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
cfg := parseEnv(out)
|
||||
if cfg["CTX"] != "65536" || cfg["STRATA_KV"] != "int8" || cfg["STRATA_SPEC"] != "4" || cfg["STRATA_VISION"] != "0" ||
|
||||
cfg["STRATA_HELPER"] != "0" || cfg["STRATA_HELPER_GPU"] != "0" || cfg["ENGINE"] != "strata" {
|
||||
t.Errorf("%q", out)
|
||||
}
|
||||
if strings.Count(out, "CTX=") != 1 {
|
||||
t.Errorf("CTX dupliqué : %q", out)
|
||||
}
|
||||
if strataHelperOn(cfg) || strataCudaDevices(map[string]string{"STRATA_MAIN_GPU": "1", "STRATA_HELPER_GPU": "0", "STRATA_HELPER": "0"}) != "1" {
|
||||
t.Error("carte d'aide coupée mais encore utilisée")
|
||||
}
|
||||
base := map[string]any{"args": []any{"--vision", "--pack", "/p"}, "vision": map[string]any{"exe": "v"}}
|
||||
b, _ := strataBuildConfig(base, cfg, "", "ram")
|
||||
if strataHasArg(strataArgs(t, b), "--vision") || b["vision"] != nil || strataHasArg(strataArgs(t, b), "--expert-cache-device1") {
|
||||
t.Errorf("vision / aide coupées mais présentes : %v", b)
|
||||
}
|
||||
for _, bad := range []strataSettingsReq{{Ctx: 1000, KV: "fp16", Spec: 3}, {Ctx: 32768, KV: "q2", Spec: 3}, {Ctx: 32768, KV: "fp16", Spec: 9}} {
|
||||
if _, err := strataApplySettings(in, bad); err == nil {
|
||||
t.Errorf("accepté : %+v", bad)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestEngineOf(t *testing.T) {
|
||||
cases := map[string]string{"": engineLlamacpp, "llamacpp": engineLlamacpp, "strata": engineStrata, " Strata ": engineStrata, "moe": engineStrata, "autre": engineLlamacpp}
|
||||
for in, want := range cases {
|
||||
if got := engineOf(map[string]string{"ENGINE": in}); got != want {
|
||||
t.Errorf("ENGINE=%q : %q, attendu %q", in, got, want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestModelLoadErrorStrata(t *testing.T) {
|
||||
ok := strataServeMarker + " (paquet AJEAN MoE 1.0)\nloading the model (the first start takes a minute or two) ...\nready: http://127.0.0.1:8080/v1 (OpenAI: /v1/chat/completions)\n"
|
||||
if got := modelLoadErrorFrom(ok); got != "" {
|
||||
t.Errorf("Strata prêt signalé en échec : %q", got)
|
||||
}
|
||||
crash := strataServeMarker + "\nTraceback (most recent call last):\n File \"server.py\", line 1\nRuntimeError: boom\n"
|
||||
if got := modelLoadErrorFrom(crash); !strings.Contains(got, "Strata") {
|
||||
t.Errorf("trace Python non signalée : %q", got)
|
||||
}
|
||||
// une trace d'un lancement PRÉCÉDENT ne compte pas
|
||||
if got := modelLoadErrorFrom(crash + strataServeMarker + "\nloading the model ...\n"); got != "" {
|
||||
t.Errorf("ancienne trace prise pour l'échec en cours : %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
// Où vont les experts : décidé sur la RAM DISPONIBLE. Vécu : 62 Go dont 18 pris
|
||||
// ailleurs, mode « hors RAM » choisi sur la RAM totale, moteur tué par le noyau.
|
||||
func TestStrataResolveExperts(t *testing.T) {
|
||||
home := t.TempDir()
|
||||
t.Setenv("LOKI_HOME", home)
|
||||
pack := filepath.Join(home, "pack")
|
||||
_ = os.MkdirAll(pack, 0o755)
|
||||
args := []string{"--pack", pack}
|
||||
cfg := map[string]string{"STRATA_CONFIG": "/x/strata-swift-iq3_xxs.json", "STRATA_HELPER_GPU": "1", "STRATA_DROP": "1"}
|
||||
// IQ3_XXS : 42,9 Go d'experts
|
||||
cases := []struct {
|
||||
experts string
|
||||
avail float64
|
||||
want string
|
||||
}{
|
||||
{"", 44, "disk"}, // le cas vécu : 44 Go disponibles, pas de experts.bin
|
||||
{"", 60, "ram"}, // assez de RAM pour tout charger avec la marge
|
||||
{"auto", 52.8, "disk"},
|
||||
{"disk", 60, "disk"}, // choix de l'utilisateur
|
||||
{"ram", 20, "ram"}, // choix de l'utilisateur, même à l'étroit
|
||||
}
|
||||
for _, c := range cases {
|
||||
cfg["STRATA_EXPERTS"] = c.experts
|
||||
if got := strataResolveExperts(cfg, args, c.avail, 12); got != c.want {
|
||||
t.Errorf("experts=%q dispo=%.0f : %s, attendu %s", c.experts, c.avail, got, c.want)
|
||||
}
|
||||
}
|
||||
// experts.bin présent (moteur du paquet) : le mode hors RAM et le mappage deviennent possibles
|
||||
_ = os.WriteFile(filepath.Join(pack, "experts.bin"), []byte("x"), 0o644)
|
||||
_ = os.MkdirAll(filepath.Join(strataSrcDir(), "engine-cuda12"), 0o755)
|
||||
_ = os.WriteFile(strataEngineMarker(), []byte(strataEngineAsset+"\n"), 0o644)
|
||||
cfg["STRATA_EXPERTS"] = ""
|
||||
if got := strataResolveExperts(cfg, args, 44, 12); got != "drop" {
|
||||
t.Errorf("44 Go + carte d'appoint 12 Go + experts.bin : %s, attendu drop", got)
|
||||
}
|
||||
if got := strataResolveExperts(cfg, args, 30, 12); got != "arena" {
|
||||
t.Errorf("30 Go + experts.bin : %s, attendu arena", got)
|
||||
}
|
||||
cfg["STRATA_HELPER"] = "0"
|
||||
if got := strataResolveExperts(cfg, args, 44, 12); got != "arena" {
|
||||
t.Errorf("carte d'appoint coupée : %s, attendu arena", got)
|
||||
}
|
||||
cfg["STRATA_EXPERTS"] = "disk"
|
||||
if got := strataResolveExperts(cfg, args, 60, 12); got != "arena" {
|
||||
t.Errorf("disque demandé avec experts.bin : %s, attendu arena", got)
|
||||
}
|
||||
// quant inconnu : on ne parie pas sur la RAM
|
||||
if got := strataResolveExperts(map[string]string{"STRATA_CONFIG": "/x/autre.json"}, nil, 500, 0); got != "disk" {
|
||||
t.Errorf("quant inconnu : %s, attendu disk", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestStrataBuildConfigModes(t *testing.T) {
|
||||
base := map[string]any{"args": []any{"--pack", "/p", "--resident-experts", "--pcie-frac", "0.3"}, "env": map[string]any{"X": "1"}}
|
||||
cfg := map[string]string{"STRATA_HELPER_GPU": "1"}
|
||||
env := func(m map[string]any) map[string]any { return m["env"].(map[string]any) }
|
||||
|
||||
out, _ := strataBuildConfig(base, cfg, "", "disk")
|
||||
a := strataArgs(t, out)
|
||||
if !strataHasArg(a, "--mmap-experts") || strataHasArg(a, "--resident-experts") || argAfter(a, "--pcie-frac") != "0" ||
|
||||
env(out)["STRATA_ARENA_MMAP"] != nil || env(out)["X"] != "1" {
|
||||
t.Errorf("disk : %v %v", a, env(out))
|
||||
}
|
||||
out, _ = strataBuildConfig(base, cfg, "", "ram")
|
||||
a = strataArgs(t, out)
|
||||
if strataHasArg(a, "--mmap-experts") || strataHasArg(a, "--resident-experts") || env(out)["STRATA_ARENA_MMAP"] != nil || env(out)["STRATA_REMOTE_DROP"] != nil {
|
||||
t.Errorf("ram : %v %v", a, env(out))
|
||||
}
|
||||
out, _ = strataBuildConfig(base, cfg, "", "arena")
|
||||
if a = strataArgs(t, out); argAfter(a, "--pcie-frac") != "0" || env(out)["STRATA_ARENA_MMAP"] != "1" {
|
||||
t.Errorf("arena : %v %v", a, env(out))
|
||||
}
|
||||
out, _ = strataBuildConfig(base, cfg, "", "drop")
|
||||
if a = strataArgs(t, out); strataHasArg(a, "--pcie-frac") || env(out)["STRATA_REMOTE_DROP"] != "1" {
|
||||
t.Errorf("drop : la part PCIe doit être automatique : %v %v", a, env(out))
|
||||
}
|
||||
// drop sans carte d'appoint : rien de spécial, c'est le mode ram
|
||||
out, _ = strataBuildConfig(base, map[string]string{"STRATA_HELPER_GPU": "-1"}, "", "drop")
|
||||
if env(out)["STRATA_REMOTE_DROP"] != nil {
|
||||
t.Error("mode hors RAM sans carte d'appoint")
|
||||
}
|
||||
}
|
||||
|
||||
func TestStrataApplySettingsExperts(t *testing.T) {
|
||||
in := "ENGINE=strata\nSTRATA_EXPERTS=auto\n"
|
||||
r := strataSettingsReq{Ctx: 131072, KV: "fp16", Spec: 3, Experts: "disk"}
|
||||
out, err := strataApplySettings(in, r)
|
||||
if err != nil || parseEnv(out)["STRATA_EXPERTS"] != "disk" {
|
||||
t.Fatalf("%v %q", err, out)
|
||||
}
|
||||
r.Experts = "nvme"
|
||||
if _, err := strataApplySettings(in, r); err == nil {
|
||||
t.Error("placement inconnu accepté")
|
||||
}
|
||||
r.Experts = ""
|
||||
if out, _ := strataApplySettings(in, r); parseEnv(out)["STRATA_EXPERTS"] != "auto" {
|
||||
t.Errorf("vide doit laisser le choix en place : %q", out)
|
||||
}
|
||||
}
|
||||
|
||||
// Vécu : bascule de Strata vers llama-server, l'encodeur de vision ne trouve
|
||||
// plus de VRAM. La pile d'appels cite « load_model » : elle ne doit pas passer
|
||||
// pour une nouvelle tentative qui effacerait la cause, écrite juste avant.
|
||||
func TestModelLoadErrorOOMBeforeBacktrace(t *testing.T) {
|
||||
log := "0.00.247.285 I srv load_model: loading model '/data/models/Swift.gguf'\n" +
|
||||
"4.47.957.764 E ggml_backend_cuda_buffer_type_alloc_buffer: allocating 884.62 MiB on device 0: cudaMalloc failed: out of memory\n" +
|
||||
"/app/ggml/src/ggml-backend.cpp:328: GGML_ASSERT(buffer) failed\n" +
|
||||
"/data/engine/libmtmd.so.0(mtmd_init_from_file+0x31)[0x14bd4fea4c01]\n" +
|
||||
"/data/engine/libllama-server-impl.so(_ZN19server_context_impl10load_modelER13common_params+0x7ff)[0x14bd50b9f1ff]\n" +
|
||||
"/lib/x86_64-linux-gnu/libc.so.6(__libc_start_main+0x8b)[0x14bd5059a28b]\n"
|
||||
if got := modelLoadErrorFrom(log); !strings.Contains(got, "mémoire GPU insuffisante") {
|
||||
t.Errorf("cause mémoire perdue derrière la pile d'appels : %q", got)
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,392 @@
|
||||
package loki
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"flag"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"os"
|
||||
"os/signal"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"sync"
|
||||
"syscall"
|
||||
"time"
|
||||
)
|
||||
|
||||
// L'optimiseur côté web et en ligne de commande.
|
||||
//
|
||||
// POST /api/tune lance (202), 409 si le moteur est occupé
|
||||
// GET /api/tune/status phase, essais, ETA ; puis le résultat
|
||||
// POST /api/tune/cancel annule (l'essai en cours est arrêté, le moteur relancé)
|
||||
// POST /api/tune/apply {target:"copy"|"preset"} — SEULEMENT sur un clic
|
||||
// GET /api/tune/last dernier résultat d'un preset, et si le moteur a changé depuis
|
||||
//
|
||||
// Rien ne tourne de soi-même : ni au démarrage, ni après une mise à jour du
|
||||
// moteur (l'interface PROPOSE alors de relancer).
|
||||
|
||||
// tuneJob : l'optimisation en cours ou la dernière (une seule à la fois).
|
||||
var tuneJob struct {
|
||||
mu sync.Mutex
|
||||
running bool
|
||||
tracker *tuneTracker
|
||||
cancel context.CancelFunc
|
||||
result *tuneResult
|
||||
err string
|
||||
finished time.Time
|
||||
// application en cours (preset réécrit, redémarrage, sonde)
|
||||
applying bool
|
||||
applyLog []string
|
||||
applyMsg string
|
||||
applyErr string
|
||||
}
|
||||
|
||||
// tuneRunner est runTune, remplaçable dans les tests.
|
||||
var tuneRunner = runTune
|
||||
|
||||
func tuneStart(opts tuneOpts) (int, error) {
|
||||
if err := tunePreflight(context.Background(), true); err != nil {
|
||||
code := http.StatusConflict
|
||||
if externalActive() {
|
||||
code = http.StatusBadRequest
|
||||
}
|
||||
return code, err
|
||||
}
|
||||
tuneJob.mu.Lock()
|
||||
defer tuneJob.mu.Unlock()
|
||||
if tuneJob.running || tuneJob.applying {
|
||||
return http.StatusConflict, errTuneBusy
|
||||
}
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
// Le verrou de la discussion : un message, une tâche, une compaction
|
||||
// reçoivent « optimisation en cours » au lieu d'attendre un moteur absent.
|
||||
// Le bouton stop du chat annule l'optimisation, comme un bench.
|
||||
release, err := conv.measureLease(cancel, errTuneBusy)
|
||||
if err != nil {
|
||||
cancel()
|
||||
return http.StatusConflict, conv.busyReason()
|
||||
}
|
||||
tr := &tuneTracker{}
|
||||
tuneJob.running, tuneJob.tracker, tuneJob.cancel = true, tr, cancel
|
||||
tuneJob.result, tuneJob.err = nil, ""
|
||||
tuneJob.applyLog, tuneJob.applyMsg, tuneJob.applyErr = nil, "", ""
|
||||
opts.Via = "web"
|
||||
go func() {
|
||||
defer cancel()
|
||||
res, err := tuneRunner(ctx, opts, tr)
|
||||
tr.mu.Lock()
|
||||
tr.ended = time.Now()
|
||||
tr.mu.Unlock()
|
||||
release()
|
||||
tuneJob.mu.Lock()
|
||||
defer tuneJob.mu.Unlock()
|
||||
tuneJob.running, tuneJob.cancel, tuneJob.finished = false, nil, time.Now()
|
||||
tuneJob.result = res
|
||||
if err != nil {
|
||||
tuneJob.err = err.Error()
|
||||
if ctx.Err() != nil {
|
||||
tuneJob.err = "optimisation annulée"
|
||||
}
|
||||
}
|
||||
}()
|
||||
return http.StatusAccepted, nil
|
||||
}
|
||||
|
||||
func tuneStatus() map[string]any {
|
||||
tuneJob.mu.Lock()
|
||||
defer tuneJob.mu.Unlock()
|
||||
st := map[string]any{"ok": true, "running": tuneJob.running, "applying": tuneJob.applying}
|
||||
if tuneJob.tracker != nil {
|
||||
for k, v := range tuneJob.tracker.snapshot() {
|
||||
st[k] = v
|
||||
}
|
||||
}
|
||||
if !tuneJob.running {
|
||||
if tuneJob.result != nil {
|
||||
st["result"] = tuneJob.result
|
||||
}
|
||||
if tuneJob.err != "" {
|
||||
st["error"] = tuneJob.err
|
||||
}
|
||||
}
|
||||
if len(tuneJob.applyLog) > 0 || tuneJob.applyMsg != "" || tuneJob.applyErr != "" {
|
||||
st["apply"] = map[string]any{"log": tuneJob.applyLog, "message": tuneJob.applyMsg, "error": tuneJob.applyErr}
|
||||
}
|
||||
// Une optimisation lancée ailleurs (« loki tune » en ligne de commande).
|
||||
if o, busy := tuneActive(); busy && !tuneJob.running && !tuneJob.applying {
|
||||
st["elsewhere"] = o.Via
|
||||
}
|
||||
return st
|
||||
}
|
||||
|
||||
func handleTune(w http.ResponseWriter, r *http.Request) {
|
||||
if !postOnly(w, r) {
|
||||
return
|
||||
}
|
||||
var body struct {
|
||||
Placement bool `json:"placement"`
|
||||
OptIn bool `json:"optin"`
|
||||
BudgetMin int `json:"budget_min"`
|
||||
Stages []string `json:"stages"`
|
||||
}
|
||||
_ = json.NewDecoder(http.MaxBytesReader(w, r.Body, 1<<16)).Decode(&body)
|
||||
opts := tuneOpts{Placement: body.Placement, OptIn: body.OptIn, Stages: body.Stages}
|
||||
if body.BudgetMin > 0 {
|
||||
opts.Budget = time.Duration(body.BudgetMin) * time.Minute
|
||||
}
|
||||
code, err := tuneStart(opts)
|
||||
if err != nil {
|
||||
sendJSON(w, code, map[string]any{"ok": false, "error": err.Error()})
|
||||
return
|
||||
}
|
||||
sendJSON(w, code, map[string]any{"ok": true})
|
||||
}
|
||||
|
||||
func handleTuneStatus(w http.ResponseWriter, r *http.Request) { sendJSON(w, 200, tuneStatus()) }
|
||||
|
||||
func handleTuneCancel(w http.ResponseWriter, r *http.Request) {
|
||||
if !postOnly(w, r) {
|
||||
return
|
||||
}
|
||||
tuneJob.mu.Lock()
|
||||
ok := tuneJob.running && tuneJob.cancel != nil
|
||||
if ok {
|
||||
tuneJob.cancel()
|
||||
}
|
||||
tuneJob.mu.Unlock()
|
||||
sendJSON(w, 200, map[string]any{"ok": ok})
|
||||
}
|
||||
|
||||
// handleTuneApply écrit le dernier résultat, sur le clic de l'utilisateur
|
||||
// (l'interface a montré le diff et demandé confirmation). Copie : immédiat.
|
||||
// Preset : en arrière-plan (redémarrage, sonde, retour arrière si besoin).
|
||||
func handleTuneApply(w http.ResponseWriter, r *http.Request) {
|
||||
if !postOnly(w, r) {
|
||||
return
|
||||
}
|
||||
var body struct {
|
||||
Target string `json:"target"`
|
||||
}
|
||||
_ = json.NewDecoder(http.MaxBytesReader(w, r.Body, 1<<16)).Decode(&body)
|
||||
tuneJob.mu.Lock()
|
||||
res := tuneJob.result
|
||||
busy := tuneJob.running || tuneJob.applying
|
||||
tuneJob.mu.Unlock()
|
||||
if busy {
|
||||
sendJSON(w, 409, map[string]any{"ok": false, "error": errTuneBusy.Error()})
|
||||
return
|
||||
}
|
||||
if res == nil {
|
||||
sendJSON(w, 400, map[string]any{"ok": false, "error": "aucun résultat à appliquer"})
|
||||
return
|
||||
}
|
||||
if body.Target == tuneApplyCopy {
|
||||
msg, err := tuneApply(context.Background(), res, tuneApplyCopy, func(string) {})
|
||||
if err != nil {
|
||||
sendJSON(w, 200, map[string]any{"ok": false, "error": err.Error()})
|
||||
return
|
||||
}
|
||||
sendJSON(w, 200, map[string]any{"ok": true, "message": msg})
|
||||
return
|
||||
}
|
||||
if body.Target != tuneApplyPreset {
|
||||
sendJSON(w, 400, map[string]any{"ok": false, "error": "cible inconnue"})
|
||||
return
|
||||
}
|
||||
if _, _, err := tunePlanApply(res); err != nil {
|
||||
sendJSON(w, 200, map[string]any{"ok": false, "error": err.Error()})
|
||||
return
|
||||
}
|
||||
if conv.isGenerating() {
|
||||
sendJSON(w, 409, map[string]any{"ok": false, "error": conv.busyReason().Error()})
|
||||
return
|
||||
}
|
||||
// Vérifié et posé d'un seul tenant : deux clics rapprochés passaient tous
|
||||
// deux le test du début, et le perdant remettait applying à faux pendant
|
||||
// que le premier écrivait encore.
|
||||
tuneJob.mu.Lock()
|
||||
if tuneJob.running || tuneJob.applying {
|
||||
tuneJob.mu.Unlock()
|
||||
sendJSON(w, 409, map[string]any{"ok": false, "error": errTuneBusy.Error()})
|
||||
return
|
||||
}
|
||||
tuneJob.applying, tuneJob.applyLog, tuneJob.applyMsg, tuneJob.applyErr = true, nil, "", ""
|
||||
tuneJob.mu.Unlock()
|
||||
go func() {
|
||||
say := func(s string) {
|
||||
tuneJob.mu.Lock()
|
||||
tuneJob.applyLog = append(tuneJob.applyLog, s)
|
||||
tuneJob.mu.Unlock()
|
||||
}
|
||||
msg, err := tuneApply(context.Background(), res, tuneApplyPreset, say)
|
||||
tuneJob.mu.Lock()
|
||||
defer tuneJob.mu.Unlock()
|
||||
tuneJob.applying = false
|
||||
tuneJob.applyMsg = msg
|
||||
if err != nil {
|
||||
tuneJob.applyErr = err.Error()
|
||||
}
|
||||
}()
|
||||
sendJSON(w, 202, map[string]any{"ok": true})
|
||||
}
|
||||
|
||||
// handleTuneLast : le dernier résultat enregistré d'un preset, et si le moteur
|
||||
// a changé depuis (build différent) — l'interface propose alors de relancer.
|
||||
func handleTuneLast(w http.ResponseWriter, r *http.Request) {
|
||||
id := r.URL.Query().Get("id")
|
||||
res, ok := loadTuneResult(id)
|
||||
if !ok {
|
||||
sendJSON(w, 200, map[string]any{"ok": false})
|
||||
return
|
||||
}
|
||||
out := map[string]any{"ok": true, "result": res}
|
||||
if content, err := ReadPreset(id); err == nil {
|
||||
out["preset_changed"] = presetFingerprint([]byte(content)) != res.PresetFP
|
||||
}
|
||||
if bin := tuneEngineBin(ReadConfig()); bin != "" && res.EngineBuild > 0 {
|
||||
if b := engineBuildCached(bin); b > 0 && b != res.EngineBuild {
|
||||
out["engine_changed"] = fmt.Sprintf("b%d → b%d", res.EngineBuild, b)
|
||||
}
|
||||
}
|
||||
sendJSON(w, 200, out)
|
||||
}
|
||||
|
||||
// tuneEngineBin : le binaire du moteur, résolu comme cmdServe le fait.
|
||||
func tuneEngineBin(cfg map[string]string) string {
|
||||
bin := strings.TrimSpace(cfg["BIN"])
|
||||
if bin == "" {
|
||||
return ""
|
||||
}
|
||||
if !filepath.IsAbs(bin) {
|
||||
bin = filepath.Join(LokiHome(), bin)
|
||||
}
|
||||
return prebuiltResolveBin(bin)
|
||||
}
|
||||
|
||||
// --- ligne de commande -------------------------------------------------------------
|
||||
|
||||
// cmdTune : « loki tune [--placement] [--opt-in] [--budget MIN] [--stages …] ». Mesure, montre,
|
||||
// puis DEMANDE où écrire le résultat — rien n'est écrit sans réponse. Comme
|
||||
// « loki bench », ce processus ne voit pas la discussion du processus web :
|
||||
// seule la vérification /slots (moteur occupé) protège un tour en cours ; une
|
||||
// fois le verrou pris, le processus web refuse tout nouveau tour. Le bouton de
|
||||
// l'interface, lui, voit tout.
|
||||
func cmdTune(args []string) error {
|
||||
fs := flag.NewFlagSet("tune", flag.ContinueOnError)
|
||||
placement := fs.Bool("placement", false, "essayer --fit à la place du placement manuel (réécrit EXTRA_ARGS)")
|
||||
optIn := fs.Bool("opt-in", false, "essayer aussi SPEC, CUDA_GRAPH_OPT et --backend-sampling")
|
||||
budget := fs.Int("budget", int(tuneDefaultBudget/time.Minute), "budget en minutes")
|
||||
stages := fs.String("stages", "", "étapes de base, séparées par des virgules : marges,lots,delestage,threads,files (vide = toutes)")
|
||||
if err := fs.Parse(args); err != nil {
|
||||
return err
|
||||
}
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
defer cancel()
|
||||
if err := tunePreflight(ctx, false); err != nil {
|
||||
return err
|
||||
}
|
||||
fmt.Printf("\n %s — essais sur un moteur privé (127.0.0.1), config.env jamais modifié.\n", cyan("Optimiseur sans perte"))
|
||||
fmt.Println(dim(" Le chat et les tâches attendent pendant la mesure ; Ctrl-C annule (le moteur est relancé)."))
|
||||
fmt.Println()
|
||||
// Ctrl-C annule proprement : l'essai est arrêté, le vrai moteur relancé.
|
||||
// Le terminal fermé (SIGHUP ; CTRL_CLOSE_EVENT, livré en SIGTERM sous
|
||||
// Windows) ou un kill aussi : sans ça, le processus mourait sans ses defers,
|
||||
// le vrai moteur restait arrêté et l'essai gardait la VRAM.
|
||||
sig := make(chan os.Signal, 1)
|
||||
signal.Notify(sig, os.Interrupt, syscall.SIGTERM, syscall.SIGHUP)
|
||||
defer signal.Stop(sig)
|
||||
go func() {
|
||||
if _, ok := <-sig; ok {
|
||||
cancel()
|
||||
}
|
||||
}()
|
||||
tr := &tuneTracker{print: true}
|
||||
var only []string
|
||||
for _, s := range strings.Split(*stages, ",") {
|
||||
if s = strings.TrimSpace(s); s != "" {
|
||||
only = append(only, s)
|
||||
}
|
||||
}
|
||||
res, err := runTune(ctx, tuneOpts{Placement: *placement, OptIn: *optIn,
|
||||
Budget: time.Duration(*budget) * time.Minute, Via: "cli", Stages: only}, tr)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
// Les essais sont finis : Ctrl-C reprend son sens ordinaire aux questions.
|
||||
signal.Stop(sig)
|
||||
printTuneResult(res)
|
||||
if len(res.Set) == 0 {
|
||||
return nil
|
||||
}
|
||||
in := bufio.NewScanner(os.Stdin)
|
||||
ask := func(q string) string {
|
||||
fmt.Print(q)
|
||||
if !in.Scan() {
|
||||
return ""
|
||||
}
|
||||
return strings.ToLower(strings.TrimSpace(in.Text()))
|
||||
}
|
||||
switch ask(" Enregistrer ? [c] dans une copie du preset · [p] dans « " + res.PresetName + " » (redémarre le moteur) · [N] non : ") {
|
||||
case "c":
|
||||
msg, err := tuneApply(context.Background(), res, tuneApplyCopy, func(string) {})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
fmt.Println(green("[ok] ") + msg)
|
||||
case "p":
|
||||
if res.Placement && ask(" Le placement réécrit EXTRA_ARGS (voir ci-dessus). Confirmer ? [o/N] ") != "o" {
|
||||
fmt.Println(dim(" rien n'a été écrit"))
|
||||
return nil
|
||||
}
|
||||
msg, err := tuneApply(context.Background(), res, tuneApplyPreset, func(s string) { fmt.Println(" " + s) })
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
fmt.Println(green("[ok] ") + msg)
|
||||
default:
|
||||
fmt.Println(dim(" rien n'a été écrit (le résultat reste visible dans l'éditeur du preset)"))
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func printTuneResult(res *tuneResult) {
|
||||
fmt.Println()
|
||||
row := func(t tuneTrial) {
|
||||
turn := "—"
|
||||
if t.TurnSec > 0 {
|
||||
turn = fmt.Sprintf("%.1f s", t.TurnSec)
|
||||
}
|
||||
why := t.Why
|
||||
if t.Headroom >= 0 {
|
||||
why += fmt.Sprintf(" · %d Mio libres", t.Headroom)
|
||||
}
|
||||
fmt.Printf(" %-10s %-44s %8s %-8s %s\n", t.Stage, t.Label, turn, t.Status, strings.TrimPrefix(why, " · "))
|
||||
}
|
||||
fmt.Printf(" %s (K=%.0f jetons lus, G=%.0f écrits, %.0f %% à froid à %d jetons — %s)\n",
|
||||
cyan("Durée d'un tour type"), res.Weights.K, res.Weights.G, res.Weights.FCold*100, res.Depth, res.Weights.From)
|
||||
row(res.Baseline)
|
||||
for _, t := range res.Trials {
|
||||
row(t)
|
||||
}
|
||||
for _, s := range res.Skipped {
|
||||
fmt.Println(dim(" sauté — " + s))
|
||||
}
|
||||
for _, n := range res.Notes {
|
||||
fmt.Println(yellow(" ! ") + n)
|
||||
}
|
||||
if res.Partial != "" {
|
||||
fmt.Println(yellow(" ! résultat partiel : ") + res.Partial)
|
||||
}
|
||||
fmt.Println()
|
||||
if len(res.Set) == 0 {
|
||||
fmt.Println(" La configuration actuelle reste la meilleure : rien à changer.")
|
||||
return
|
||||
}
|
||||
fmt.Printf(" %s %s : −%.1f %% par tour\n", green("Meilleur :"), res.Best, res.Gain*100)
|
||||
for _, c := range res.Changes {
|
||||
fmt.Println(" " + c)
|
||||
}
|
||||
fmt.Println()
|
||||
}
|
||||
@@ -0,0 +1,477 @@
|
||||
package loki
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
)
|
||||
|
||||
// Verrou de l'optimiseur (loki tune, bouton « Optimiser »).
|
||||
//
|
||||
// Pendant une optimisation, la VRAM appartient au moteur d'ESSAI : le vrai
|
||||
// moteur est arrêté, et le relancer à côté (bascule de preset, enregistrement du
|
||||
// preset actif, choix des GPU, mise à jour du moteur, « recharger » la VRAM…)
|
||||
// chargerait deux modèles sur des cartes qui n'en tiennent qu'un — un OOM certain,
|
||||
// et une mesure faussée. Un drapeau en mémoire ne suffit pas : « loki tune » en
|
||||
// ligne de commande et le processus web sont deux processus distincts. Le verrou
|
||||
// est donc un FICHIER de LOKI_HOME, créé de façon exclusive, que tout le monde
|
||||
// lit :
|
||||
//
|
||||
// - serviceAction("start"/"restart") le consulte sur toutes les plateformes —
|
||||
// aucun démarrage du moteur ne passe à côté ;
|
||||
// - le chat, les tâches planifiées (qui attendent), les jobs du mode code, les
|
||||
// clients /v1, le bench, la bascule et l'enregistrement de preset refusent
|
||||
// avec une phrase claire.
|
||||
//
|
||||
// Le fichier porte l'identité du processus qui le tient (PID, instant de
|
||||
// démarrage, binaire) et celle de l'essai en cours. Un verrou dont le
|
||||
// propriétaire est mort (Loki redémarré en plein essai) est périmé : avant tout
|
||||
// démarrage du moteur, l'essai orphelin est arrêté — seulement si PID, instant
|
||||
// de démarrage ET binaire concordent, jamais sur la foi d'un PID recyclé — puis
|
||||
// le fichier est retiré.
|
||||
|
||||
const tuneLockName = "tune.lock"
|
||||
|
||||
// tuneLockFreshness : un verrou illisible plus jeune que ça est en cours
|
||||
// d'écriture, pas abandonné.
|
||||
const tuneLockFreshness = 10 * time.Second
|
||||
|
||||
var errTuneBusy = errors.New("optimisation en cours (loki tune) : le moteur est réservé aux essais — " +
|
||||
"attends la fin ou annule-la")
|
||||
|
||||
// tuneProc : un processus désigné sans ambiguïté.
|
||||
type tuneProc struct {
|
||||
PID int `json:"pid"`
|
||||
Start string `json:"start,omitempty"` // procIdentity ; vide = illisible au moment de l'écrire
|
||||
Exes []string `json:"exes,omitempty"` // binaires admis (un « loki serve » devient llama-server par exec)
|
||||
}
|
||||
|
||||
// tuneOwner : le contenu du verrou.
|
||||
type tuneOwner struct {
|
||||
Owner tuneProc `json:"owner"`
|
||||
Via string `json:"via"` // « web » ou « cli »
|
||||
Phase string `json:"phase"` // « essais » ou « application »
|
||||
Since int64 `json:"since"`
|
||||
// Nonce : distingue deux verrous pris par le même processus dans la même
|
||||
// seconde (tuneHeldKey). Absent des verrous d'une version d'avant : 0.
|
||||
Nonce int64 `json:"nonce,omitempty"`
|
||||
Trial *tuneProc `json:"trial,omitempty"`
|
||||
// MainWasActive : le vrai moteur tournait avant l'optimisation. Un verrou
|
||||
// périmé trouvé au démarrage du processus web le relance.
|
||||
MainWasActive bool `json:"main_was_active"`
|
||||
// Application en cours (phase « application ») : la version d'avant du
|
||||
// preset, sauvée avant toute écriture. Un verrou périmé dans cette phase
|
||||
// veut dire que l'application n'a jamais été vérifiée : elle est défaite.
|
||||
Backup string `json:"backup,omitempty"`
|
||||
PresetID string `json:"preset_id,omitempty"`
|
||||
PresetName string `json:"preset_name,omitempty"`
|
||||
}
|
||||
|
||||
func tuneLockPath() string { return filepath.Join(LokiHome(), tuneLockName) }
|
||||
|
||||
// tuneProcAlive dit si p désigne encore le MÊME processus. Variable pour les
|
||||
// tests.
|
||||
var tuneProcAlive = func(p tuneProc) bool {
|
||||
start, exe, ok := procIdentity(p.PID)
|
||||
if !ok {
|
||||
return false
|
||||
}
|
||||
if p.Start != "" && start != p.Start {
|
||||
return false
|
||||
}
|
||||
if exe == "" || len(p.Exes) == 0 {
|
||||
return true
|
||||
}
|
||||
for _, e := range p.Exes {
|
||||
if sameExe(e, exe) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// sameExe compare deux binaires par leur nom de fichier (sans .exe, sans
|
||||
// casse) : /proc/<pid>/exe résout les liens symboliques, ps n'en donne parfois
|
||||
// que le nom.
|
||||
func sameExe(a, b string) bool {
|
||||
norm := func(s string) string {
|
||||
s = strings.TrimSuffix(strings.TrimSpace(s), " (deleted)") // binaire remplacé (Linux)
|
||||
s = strings.ToLower(filepath.Base(strings.ReplaceAll(s, `\`, "/")))
|
||||
return strings.TrimSuffix(s, ".exe")
|
||||
}
|
||||
return a != "" && b != "" && norm(a) == norm(b)
|
||||
}
|
||||
|
||||
// selfProc : ce processus, tel que le verrou le note.
|
||||
func selfProc() tuneProc {
|
||||
p := tuneProc{PID: os.Getpid()}
|
||||
if start, exe, ok := procIdentity(p.PID); ok {
|
||||
p.Start = start
|
||||
if exe != "" {
|
||||
p.Exes = []string{exe}
|
||||
}
|
||||
}
|
||||
if self, err := os.Executable(); err == nil && len(p.Exes) == 0 {
|
||||
p.Exes = []string{self}
|
||||
}
|
||||
return p
|
||||
}
|
||||
|
||||
func readTuneOwner(path string) (tuneOwner, bool) {
|
||||
var o tuneOwner
|
||||
b, err := os.ReadFile(path)
|
||||
if err != nil || json.Unmarshal(b, &o) != nil || o.Owner.PID <= 0 {
|
||||
return tuneOwner{}, false
|
||||
}
|
||||
return o, true
|
||||
}
|
||||
|
||||
// tuneActive : une optimisation tient-elle le moteur, dans ce processus ou dans
|
||||
// un autre ? Un verrou périmé (propriétaire mort) ne compte pas.
|
||||
func tuneActive() (tuneOwner, bool) {
|
||||
o, ok := readTuneOwner(tuneLockPath())
|
||||
if !ok {
|
||||
return tuneOwner{}, false
|
||||
}
|
||||
return o, tuneOwnerAlive(o)
|
||||
}
|
||||
|
||||
// tuneHeld : verrous pris par CE processus et pas encore rendus (tuneHeldKey).
|
||||
var tuneHeld sync.Map
|
||||
|
||||
// tuneFileMu sérialise, DANS CE PROCESSUS, les opérations sur le fichier de
|
||||
// verrou : prise, réécriture, rendu, et le déplacement-relecture du ménage
|
||||
// (tuneReapStale). Sans lui, le ménage pouvait déplacer un verrou vivant
|
||||
// pendant que release() le cherchait : release, ne le trouvant plus, rendait
|
||||
// l'inscription ; le ménage le remettait ensuite en place, désormais orphelin,
|
||||
// et le ménage suivant le prenait pour une optimisation interrompue (moteur
|
||||
// relancé, preset « rétabli »). Vu en CI : TestTuneRecoverNeverReapsOwnLock.
|
||||
var tuneFileMu sync.Mutex
|
||||
|
||||
func tuneHeldKey(o tuneOwner) string { return fmt.Sprintf("%s|%d|%d", o.Via, o.Since, o.Nonce) }
|
||||
|
||||
// tuneOwnerAlive : le propriétaire d'un verrou vit-il encore ? Un verrou qui
|
||||
// porte NOTRE PID sans être l'un des nôtres vient d'un processus mort dont le
|
||||
// PID nous est échu (redémarrage) : périmé, quoi que dise son identité.
|
||||
func tuneOwnerAlive(o tuneOwner) bool {
|
||||
if o.Owner.PID == os.Getpid() {
|
||||
if _, ours := tuneHeld.Load(tuneHeldKey(o)); !ours {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return tuneProcAlive(o.Owner)
|
||||
}
|
||||
|
||||
// tuneGuard : nil si rien ne tient le moteur, errTuneBusy sinon.
|
||||
func tuneGuard() error {
|
||||
if _, busy := tuneActive(); busy {
|
||||
return errTuneBusy
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// tuneDenyHTTP répond 409 et rend true quand une optimisation tient le moteur.
|
||||
func tuneDenyHTTP(w http.ResponseWriter) bool {
|
||||
if err := tuneGuard(); err != nil {
|
||||
sendJSON(w, http.StatusConflict, map[string]any{"ok": false, "tuning": true, "error": err.Error()})
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// tuneLock : le verrou tenu par CE processus.
|
||||
type tuneLock struct {
|
||||
mu sync.Mutex
|
||||
path string
|
||||
owner tuneOwner
|
||||
held bool // inscrit dans tuneHeld, jusqu'à release
|
||||
}
|
||||
|
||||
// tuneLockAcquire prend le verrou, ou dit qui le tient. Un verrou périmé est
|
||||
// d'abord écarté (tuneReapStale), essai orphelin compris.
|
||||
func tuneLockAcquire(via string, mainActive bool) (*tuneLock, error) {
|
||||
path := tuneLockPath()
|
||||
if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
l := &tuneLock{path: path, owner: tuneOwner{Owner: selfProc(), Via: via, Phase: "essais",
|
||||
Since: time.Now().Unix(), Nonce: time.Now().UnixNano(), MainWasActive: mainActive}}
|
||||
// Inscrit comme nôtre AVANT que le fichier n'existe : la reprise du
|
||||
// processus web (tuneRecoverWatch, toutes les 30 s) tient pour mort un
|
||||
// verrou qui porte notre PID sans être inscrit, et l'écarterait entre la
|
||||
// création et l'inscription.
|
||||
key := tuneHeldKey(l.owner)
|
||||
tuneHeld.Store(key, true)
|
||||
for attempt := 0; attempt < 3; attempt++ {
|
||||
tuneFileMu.Lock()
|
||||
f, err := os.OpenFile(path, os.O_CREATE|os.O_EXCL|os.O_WRONLY, 0o644)
|
||||
if err == nil {
|
||||
b, _ := json.Marshal(l.owner)
|
||||
_, werr := f.Write(b)
|
||||
cerr := f.Close()
|
||||
if werr != nil || cerr != nil {
|
||||
_ = os.Remove(path)
|
||||
tuneHeld.Delete(key)
|
||||
tuneFileMu.Unlock()
|
||||
return nil, fmt.Errorf("verrou de l'optimiseur : %v", errors.Join(werr, cerr))
|
||||
}
|
||||
l.held = true
|
||||
tuneFileMu.Unlock()
|
||||
return l, nil
|
||||
}
|
||||
tuneFileMu.Unlock()
|
||||
if !os.IsExist(err) {
|
||||
tuneHeld.Delete(key)
|
||||
return nil, err
|
||||
}
|
||||
if o, busy := tuneActive(); busy {
|
||||
tuneHeld.Delete(key)
|
||||
return nil, fmt.Errorf("une optimisation tourne déjà (%s, PID %d, depuis %s)", o.Via, o.Owner.PID,
|
||||
time.Unix(o.Since, 0).Format("15:04"))
|
||||
}
|
||||
tuneReapStale()
|
||||
}
|
||||
tuneHeld.Delete(key)
|
||||
return nil, errors.New("verrou de l'optimiseur indisponible : réessaie")
|
||||
}
|
||||
|
||||
// write réécrit le verrou d'un bloc (fichier voisin puis renommage) : un
|
||||
// lecteur ne voit jamais un JSON à moitié écrit.
|
||||
func (l *tuneLock) write() error {
|
||||
tuneFileMu.Lock()
|
||||
defer tuneFileMu.Unlock()
|
||||
return l.writeLocked()
|
||||
}
|
||||
|
||||
// writeLocked : write, tuneFileMu déjà tenu par l'appelant.
|
||||
func (l *tuneLock) writeLocked() error {
|
||||
b, err := json.Marshal(l.owner)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
tmp := l.path + ".tmp-" + strconv.Itoa(os.Getpid())
|
||||
if err := os.WriteFile(tmp, b, 0o644); err != nil {
|
||||
return err
|
||||
}
|
||||
// Windows refuse de remplacer un fichier qu'un autre processus lit à cet
|
||||
// instant (tuneGuard d'une requête) : on réessaie un peu avant de renoncer.
|
||||
for i := 0; ; i++ {
|
||||
err := os.Rename(tmp, l.path)
|
||||
if err == nil || i == 4 {
|
||||
if err != nil {
|
||||
_ = os.Remove(tmp)
|
||||
}
|
||||
return err
|
||||
}
|
||||
time.Sleep(50 * time.Millisecond)
|
||||
}
|
||||
}
|
||||
|
||||
// setTrial note l'essai en cours (nil : aucun), AVANT qu'il ne charge quoi que
|
||||
// ce soit d'important : c'est ce qui permet à un Loki redémarré de l'arrêter.
|
||||
func (l *tuneLock) setTrial(p *tuneProc) {
|
||||
l.mu.Lock()
|
||||
defer l.mu.Unlock()
|
||||
l.owner.Trial = p
|
||||
_ = l.write()
|
||||
}
|
||||
|
||||
func (l *tuneLock) setPhase(phase string) {
|
||||
l.mu.Lock()
|
||||
defer l.mu.Unlock()
|
||||
l.owner.Phase = phase
|
||||
_ = l.write()
|
||||
}
|
||||
|
||||
// setBackup note la sauvegarde du preset avant son écriture (tuneApply).
|
||||
func (l *tuneLock) setBackup(backup, id, name string) {
|
||||
l.mu.Lock()
|
||||
defer l.mu.Unlock()
|
||||
l.owner.Backup, l.owner.PresetID, l.owner.PresetName = backup, id, name
|
||||
_ = l.write()
|
||||
}
|
||||
|
||||
// release rend le verrou — seulement s'il est encore le nôtre.
|
||||
//
|
||||
// La reprise du processus web (tuneRecoverWatch) tourne toutes les 30 s,
|
||||
// pendant que ce même processus prend et rend ses verrous. Elle tient pour
|
||||
// mort un verrou à notre PID qui n'est plus inscrit (tuneHeld) : si le fichier
|
||||
// survivait à l'inscription — sous Windows, un fichier qu'un autre lit à cet
|
||||
// instant ne se supprime pas —, elle l'écarterait, et en phase « application »
|
||||
// défairait le preset tout juste vérifié (tuneUndoApply), ou relancerait le
|
||||
// moteur. Le verrou est donc d'abord réécrit NEUTRE (rien à défaire, rien à
|
||||
// relancer, aucun essai), puis retiré, et l'inscription ne part qu'après.
|
||||
func (l *tuneLock) release() {
|
||||
l.mu.Lock()
|
||||
defer l.mu.Unlock()
|
||||
tuneFileMu.Lock()
|
||||
defer tuneFileMu.Unlock()
|
||||
if l.held {
|
||||
// Un fichier absent un instant (renommé par un ménage qui le remet en
|
||||
// place, voir tuneReapStale) n'est pas un fichier rendu : on relit.
|
||||
ours := false
|
||||
for i := 0; i < 5; i++ {
|
||||
if o, ok := readTuneOwner(l.path); ok {
|
||||
ours = sameTuneLock(o, l.owner)
|
||||
break
|
||||
}
|
||||
time.Sleep(20 * time.Millisecond)
|
||||
}
|
||||
if ours {
|
||||
l.owner.Phase, l.owner.Trial, l.owner.MainWasActive = "rendu", nil, false
|
||||
l.owner.Backup, l.owner.PresetID, l.owner.PresetName = "", "", ""
|
||||
_ = l.writeLocked()
|
||||
for i := 0; i < 5; i++ {
|
||||
if err := os.Remove(l.path); err == nil || os.IsNotExist(err) {
|
||||
break
|
||||
}
|
||||
time.Sleep(20 * time.Millisecond)
|
||||
}
|
||||
}
|
||||
l.held = false
|
||||
tuneHeld.Delete(tuneHeldKey(l.owner))
|
||||
return
|
||||
}
|
||||
if o, ok := readTuneOwner(l.path); ok && sameTuneLock(o, l.owner) {
|
||||
_ = os.Remove(l.path)
|
||||
}
|
||||
}
|
||||
|
||||
// tuneReapStale écarte un verrou dont le propriétaire est mort : l'essai qu'il
|
||||
// désigne est arrêté s'il est encore là (identité complète vérifiée), puis le
|
||||
// fichier disparaît. Rend MainWasActive du verrou écarté (false sans verrou
|
||||
// périmé). Le renommage d'abord : de deux processus qui trouvent le même verrou
|
||||
// périmé, un seul l'emporte, et aucun ne retire un verrou neuf posé entre-temps.
|
||||
func tuneReapStale() (mainWasActive bool) {
|
||||
o, ok := tuneReapClaim()
|
||||
if !ok {
|
||||
return false
|
||||
}
|
||||
if o.Trial != nil && tuneProcAlive(*o.Trial) {
|
||||
fmt.Fprintf(os.Stderr, "[loki tune] essai orphelin (PID %d) d'une optimisation interrompue : arrêt\n", o.Trial.PID)
|
||||
tuneKillTree(o.Trial.PID, nil)
|
||||
}
|
||||
tuneUndoApply(o)
|
||||
return o.MainWasActive
|
||||
}
|
||||
|
||||
// tuneReapClaim : la partie de tuneReapStale qui touche au fichier, sous
|
||||
// tuneFileMu — lecture, déplacement, relecture, remise en place. Rend le
|
||||
// verrou périmé effectivement retiré (ok=false : rien à écarter).
|
||||
func tuneReapClaim() (tuneOwner, bool) {
|
||||
tuneFileMu.Lock()
|
||||
defer tuneFileMu.Unlock()
|
||||
path := tuneLockPath()
|
||||
o, ok := readTuneOwner(path)
|
||||
if !ok {
|
||||
// Illisible (écriture interrompue) : périmé lui aussi, s'il est là —
|
||||
// mais pas tout juste créé : entre sa création exclusive et l'écriture de
|
||||
// son contenu, un verrou neuf est vide quelques instants, et le retirer
|
||||
// laisserait passer une seconde optimisation ou un démarrage du moteur.
|
||||
if fi, err := os.Stat(path); err == nil && time.Since(fi.ModTime()) > tuneLockFreshness {
|
||||
_ = os.Remove(path)
|
||||
}
|
||||
return tuneOwner{}, false
|
||||
}
|
||||
if tuneOwnerAlive(o) {
|
||||
return tuneOwner{}, false
|
||||
}
|
||||
stale := fmt.Sprintf("%s.stale-%d-%d", path, os.Getpid(), time.Now().UnixNano())
|
||||
if os.Rename(path, stale) != nil {
|
||||
return tuneOwner{}, false
|
||||
}
|
||||
defer os.Remove(stale)
|
||||
// Entre la lecture et le renommage, le verrou lu a pu être rendu et un
|
||||
// neuf, vivant, pris à sa place : c'est alors CELUI-LÀ qu'on vient de
|
||||
// déplacer. La reprise du processus web tournant toutes les 30 s, pendant
|
||||
// que ce même processus prend et rend ses verrous, ce n'est plus un cas
|
||||
// d'école. On relit donc ce qu'on a déplacé ; un autre verrou est remis en
|
||||
// place (un lien : jamais par-dessus un verrou posé entre-temps).
|
||||
if got, ok := readTuneOwner(stale); !ok || !sameTuneLock(got, o) || tuneOwnerAlive(got) {
|
||||
if err := os.Link(stale, path); err != nil {
|
||||
fmt.Fprintf(os.Stderr, "[loki tune] verrou vivant déplacé par erreur, remise en place impossible : %v\n", err)
|
||||
}
|
||||
return tuneOwner{}, false
|
||||
}
|
||||
return o, true
|
||||
}
|
||||
|
||||
// sameTuneLock : a et b désignent-ils la même prise de verrou (propriétaire et
|
||||
// instant de la prise), quelle que soit sa phase ?
|
||||
func sameTuneLock(a, b tuneOwner) bool {
|
||||
return a.Owner.PID == b.Owner.PID && a.Owner.Start == b.Owner.Start && tuneHeldKey(a) == tuneHeldKey(b)
|
||||
}
|
||||
|
||||
// tuneUndoApply : une application interrompue avant sa vérification (Loki tué
|
||||
// pendant la sonde) remet la version d'avant du preset — c'est ce que la
|
||||
// vérification aurait fait en échouant. Sans sauvegarde notée, rien.
|
||||
func tuneUndoApply(o tuneOwner) {
|
||||
if o.Phase != "application" || o.Backup == "" || o.PresetID == "" {
|
||||
return
|
||||
}
|
||||
b, err := os.ReadFile(o.Backup)
|
||||
if err != nil {
|
||||
fmt.Fprintf(os.Stderr, "[loki tune] application interrompue : sauvegarde illisible (%v)\n", err)
|
||||
return
|
||||
}
|
||||
if _, _, err := SavePresetApplying(o.PresetID, o.PresetName, string(b)); err != nil {
|
||||
fmt.Fprintf(os.Stderr, "[loki tune] application interrompue : retour impossible (%v) — l'ancienne version est dans %s\n", err, o.Backup)
|
||||
return
|
||||
}
|
||||
fmt.Fprintf(os.Stderr, "[loki tune] application interrompue avant sa vérification : ancienne version du preset rétablie\n")
|
||||
}
|
||||
|
||||
// tuneRecoverStale : une optimisation interrompue (Loki redémarré en plein
|
||||
// essai, « loki tune » tué par kill -9) a laissé le vrai moteur arrêté et peut-
|
||||
// être un essai en VRAM. On arrête l'essai, puis on relance le moteur s'il
|
||||
// tournait avant — ce que l'optimisation aurait fait en finissant. Sans
|
||||
// verrou, un simple stat : rien d'autre n'est lu.
|
||||
func tuneRecoverStale() {
|
||||
if _, err := os.Stat(tuneLockPath()); err != nil {
|
||||
return
|
||||
}
|
||||
if tuneReapStale() {
|
||||
tuneRecoverStart()
|
||||
}
|
||||
}
|
||||
|
||||
// tuneRecoverStart relance le moteur après une optimisation interrompue, sauf
|
||||
// preset externe, moteur déjà reparti ou configuration incomplète. Variable
|
||||
// pour les tests.
|
||||
var tuneRecoverStart = func() {
|
||||
if externalActive() || serviceIsActive() {
|
||||
return
|
||||
}
|
||||
if err := preflightEngine(); err != nil {
|
||||
return
|
||||
}
|
||||
fmt.Println(dim("[loki tune] optimisation interrompue : relance du moteur"))
|
||||
// serviceAction, pas serviceActionOS : la reprise tourne désormais toutes
|
||||
// les 30 s, et une optimisation neuve a pu prendre le verrou entre le
|
||||
// ménage et ce démarrage — tuneGuard le voit, et le moteur reste arrêté
|
||||
// pour elle.
|
||||
_ = serviceAction("start")
|
||||
}
|
||||
|
||||
// tuneRecoverEvery : la période de la reprise dans le processus web.
|
||||
const tuneRecoverEvery = 30 * time.Second
|
||||
|
||||
// tuneRecoverWatch : au démarrage du processus web, puis à chaque tic. Le
|
||||
// démarrage seul ne suffisait pas : un « loki tune » en ligne de commande tué
|
||||
// sans ses defers (kill -9, terminal perdu) laissait le moteur arrêté jusqu'au
|
||||
// prochain redémarrage de l'interface. Le tic ne coûte qu'un stat tant
|
||||
// qu'aucun verrou n'existe ; pendant une optimisation vivante, une lecture du
|
||||
// verrou et de l'identité de son propriétaire.
|
||||
func tuneRecoverWatch(tick <-chan time.Time) {
|
||||
tuneRecoverStale()
|
||||
for range tick {
|
||||
tuneRecoverStale()
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large.
Load diff
File diff suppressed because it is too large.
Load diff
@@ -64,6 +64,8 @@ func cmdChat(args []string) error {
|
||||
full := strings.Builder{}
|
||||
inReason := false
|
||||
var stats *StatsEvent
|
||||
// Raisonnement de la réponse finale (REASONING_ECHO), rangé avec elle.
|
||||
var echo *ReasoningEcho
|
||||
// Print the assistant prefix once; reasoning is shown inline with a tag.
|
||||
fmt.Print(cyan("loki") + " > ")
|
||||
caps := globalCaps()
|
||||
@@ -74,22 +76,22 @@ func cmdChat(args []string) error {
|
||||
fmt.Println(dim("[contexte compacté pour tenir dans la fenêtre]"))
|
||||
}
|
||||
sent, tools := prepareTurn(msgs, caps)
|
||||
hist := msgs
|
||||
extra, err := runChatTools(context.Background(), sent, tools, 0.7, caps, func(ev StreamEvent) bool {
|
||||
switch {
|
||||
case ev.Err != nil:
|
||||
fmt.Printf("\n%s\n", red("[erreur] "+ev.Err.Error()))
|
||||
case ev.NewHistory != nil:
|
||||
// Compaction survenue pendant le tour : elle REMPLACE l'historique (elle
|
||||
// contient déjà le tour en cours), préfixe système injecté retiré. Sans
|
||||
// ça le terminal repartirait du fil complet au tour suivant.
|
||||
base := ev.NewHistory
|
||||
for len(base) > 0 && base[0].Role == "system" {
|
||||
base = base[1:]
|
||||
}
|
||||
msgs = append([]Message(nil), base...)
|
||||
// contient déjà le tour en cours), préfixe système injecté retiré —
|
||||
// le /sys de l'utilisateur, lui, reste (historyFromView). Sans ça le
|
||||
// terminal repartirait du fil complet au tour suivant.
|
||||
msgs = historyFromView(ev.NewHistory, hist)
|
||||
fmt.Println(dim("\n[contexte compacté pour tenir dans la fenêtre]"))
|
||||
case ev.Stats != nil:
|
||||
stats = ev.Stats
|
||||
case ev.Echo != nil:
|
||||
echo = ev.Echo
|
||||
case ev.DropReasoning:
|
||||
// Le tour a « pensé sans agir » : on relance. Impossible d'effacer le
|
||||
// texte déjà imprimé en terminal — on referme juste la ligne reasoning.
|
||||
@@ -113,7 +115,7 @@ func cmdChat(args []string) error {
|
||||
case "edit":
|
||||
icon = "✏️"
|
||||
verb = "édition"
|
||||
case "web_search", "web_open", "web_read", "web_grep":
|
||||
case "web_search", "web_images", "web_open", "web_read", "web_grep":
|
||||
icon = "🌐"
|
||||
verb = "web"
|
||||
case "web_screenshot":
|
||||
@@ -155,7 +157,7 @@ func cmdChat(args []string) error {
|
||||
// answer, so next turn the model remembers it already did them instead
|
||||
// of re-invoking the same skill/command from scratch.
|
||||
msgs = append(msgs, extra...)
|
||||
msgs = append(msgs, Message{Role: "assistant", Content: full.String()})
|
||||
msgs = append(msgs, withEcho(Message{Role: "assistant", Content: full.String()}, echo))
|
||||
}
|
||||
}
|
||||
}
|
||||
+270
-55
@@ -72,6 +72,9 @@ func compactEnabled() bool {
|
||||
|
||||
// ctxWindow renvoie la fenêtre de contexte configurée (config.env CTX), 32768
|
||||
// par défaut — la même valeur que celle passée à llama-server au lancement.
|
||||
// Avec SIDE_SLOT, le moteur reçoit -c 2×CTX, mais chacun de ses deux slots
|
||||
// fait CTX jetons : la fenêtre d'une conversation, comme d'un travail annexe,
|
||||
// reste CTX (backend_serve_side.go).
|
||||
func ctxWindow() int {
|
||||
if v := ReadConfig()["CTX"]; v != "" {
|
||||
if n, err := strconv.Atoi(v); err == nil && n > 0 {
|
||||
@@ -171,21 +174,31 @@ func msgText(m Message) string {
|
||||
// par token, plus un forfait par message pour le rôle et les délimiteurs). C'est
|
||||
// volontairement approximatif : le comptage EXACT vient de llama.cpp
|
||||
// (PromptTokensTotal) ; ici on veut juste décider quand compacter.
|
||||
// Sans le raisonnement renvoyé (REASONING_ECHO) : celui-ci dépend de la
|
||||
// position du message et de la politique d'envoi, il s'ajoute par echoTokens
|
||||
// (estimateTokens, compactBounds).
|
||||
func msgTokens(m Message) int {
|
||||
n := 4
|
||||
n += len(msgText(m)) / 4
|
||||
for _, tc := range m.ToolCalls {
|
||||
n += (len(tc.Function.Name) + len(tc.Function.Arguments)) / 4
|
||||
}
|
||||
return n
|
||||
// Image gardée (KEEP_TURN_IMAGES) : son coût mesuré par le moteur, que le
|
||||
// seul marqueur textuel sous-estimerait de loin. 0 sans la clé.
|
||||
return n + m.imgTokens
|
||||
}
|
||||
|
||||
// estimateTokens estime la taille de l'historique en tokens.
|
||||
// estimateTokens estime la taille de l'historique en tokens, raisonnement
|
||||
// renvoyé compris quand il part réellement (echoTokens : rien sans
|
||||
// REASONING_ECHO).
|
||||
func estimateTokens(msgs []Message) int {
|
||||
total := 0
|
||||
for _, m := range msgs {
|
||||
total += msgTokens(m)
|
||||
}
|
||||
for _, n := range echoTokens(msgs) {
|
||||
total += n
|
||||
}
|
||||
return total
|
||||
}
|
||||
|
||||
@@ -229,6 +242,42 @@ func compactNeeded(msgs []Message, used, peakGen int) bool {
|
||||
return compactEnabled() && genHeadroomShort(used, peakGen, ctxWindow())
|
||||
}
|
||||
|
||||
// compactRetryDue : une compaction en cours de tour peut-elle être tentée ?
|
||||
// Après un essai qui n'a rien réduit (résumé raté, réduction sous 20 %),
|
||||
// chaque étape suivante relançait un résumé complet voué au même échec : plus
|
||||
// d'une heure de tentatives à vide, une par étape, sur une longue tâche (AJEAN
|
||||
// 68659e73). compactRefusedSkip ne couvre que la discussion principale avec
|
||||
// COMPACT_CONTINUATION ; ce garde-fou vaut pour tout tour, tâches et
|
||||
// sous-agents compris. Jamais à 90 % de la fenêtre ou plus : là, essayer
|
||||
// encore vaut mieux que déborder.
|
||||
func compactRetryDue(retryAt, used int, msgs []Message) bool {
|
||||
if retryAt <= 0 {
|
||||
return true
|
||||
}
|
||||
est := compactUsedOrEstimate(used, msgs)
|
||||
return est >= retryAt || est*10 >= ctxWindow()*9
|
||||
}
|
||||
|
||||
// compactNextRetry : seuil du prochain essai après une compaction en cours de
|
||||
// tour. 0 (aucun) si elle a réduit ; sinon le contexte d'aujourd'hui plus 5 %
|
||||
// de la fenêtre.
|
||||
func compactNextRetry(changed bool, used int, msgs []Message) int {
|
||||
if changed {
|
||||
return 0
|
||||
}
|
||||
est := compactUsedOrEstimate(used, msgs)
|
||||
next := est + ctxWindow()/20
|
||||
fmt.Fprintf(os.Stderr, "[compact] rien réduit en cours de tour : prochain essai à %d jetons (contexte à %d)\n", next, est)
|
||||
return next
|
||||
}
|
||||
|
||||
func compactUsedOrEstimate(used int, msgs []Message) int {
|
||||
if used > 0 {
|
||||
return used
|
||||
}
|
||||
return estimateTokens(msgs)
|
||||
}
|
||||
|
||||
// genHeadroomShort : la fenêtre ne laisse plus la place d'une génération de
|
||||
// 1,2 × la plus longue récente (plancher de 8 k jetons, ramené à 1/8 de la
|
||||
// fenêtre pour les petites), plus une petite marge. Fonction pure, testable.
|
||||
@@ -331,8 +380,14 @@ func compactBounds(msgs []Message, tailBudget int) (head, tailStart int) {
|
||||
}
|
||||
tailStart = len(msgs)
|
||||
acc := 0
|
||||
// Même compte que estimateTokens, qui fixe le budget : raisonnement renvoyé
|
||||
// compris, sinon la queue avalait plus que sa part.
|
||||
echo := echoTokens(msgs)
|
||||
for i := len(msgs) - 1; i >= head; i-- {
|
||||
acc += msgTokens(msgs[i])
|
||||
if echo != nil {
|
||||
acc += echo[i]
|
||||
}
|
||||
tailStart = i
|
||||
if acc >= tailBudget {
|
||||
break
|
||||
@@ -352,6 +407,35 @@ func compactBounds(msgs []Message, tailBudget int) (head, tailStart int) {
|
||||
}
|
||||
|
||||
func compactMessages(ctx context.Context, msgs []Message, caps Caps) ([]Message, bool) {
|
||||
out, changed, _ := compactMessagesOpt(ctx, msgs, caps, compactOpts{})
|
||||
return out, changed
|
||||
}
|
||||
|
||||
// compactMessagesOpt : compactMessages, avec les options de
|
||||
// COMPACT_CONTINUATION (chat_compact_cont.go) — options nulles, exactement le
|
||||
// chemin d'avant. Bornes, archives, demande réinjectée et sortie restent sur la
|
||||
// vue MODÈLE (msgs) dans tous les cas ; la vue d'envoi ne sert qu'à construire
|
||||
// la requête de résumé. refused : un résumé a été obtenu, mais la réduction
|
||||
// reste sous les 20 % exigés.
|
||||
//
|
||||
// Images gardées par KEEP_TURN_IMAGES (chat_keep_images.go) : retirées
|
||||
// D'ABORD, dans tout l'historique — leur légende et imageLostMarker restent.
|
||||
// Leur retrait suffit à changer l'historique, même si le résumé qui suit est
|
||||
// refusé. Sans la clé, il n'y en a aucune : chemin d'avant.
|
||||
func compactMessagesOpt(ctx context.Context, msgs []Message, caps Caps, opt compactOpts) (out []Message, changed, refused bool) {
|
||||
stripped, freed := dropKeptImages(msgs)
|
||||
if freed == 0 {
|
||||
return compactMessagesCore(ctx, msgs, caps, opt)
|
||||
}
|
||||
logKeptImagesDropped("compaction", freed)
|
||||
if out, changed, _ = compactMessagesCore(ctx, stripped, caps, opt); changed {
|
||||
return out, true, false
|
||||
}
|
||||
return stripped, true, false
|
||||
}
|
||||
|
||||
// compactMessagesCore : la compaction elle-même (voir compactMessagesOpt).
|
||||
func compactMessagesCore(ctx context.Context, msgs []Message, caps Caps, opt compactOpts) (out []Message, changed, refused bool) {
|
||||
// Budget de queue = fraction de la CONVERSATION (pas de la fenêtre). Le lier à
|
||||
// la fenêtre était le bug : une conversation de 25k tokens dans une fenêtre de
|
||||
// 64k gardait 16k (0.25×64k) en queue → torse minuscule → réduction < 20% →
|
||||
@@ -362,11 +446,20 @@ func compactMessages(ctx context.Context, msgs []Message, caps Caps) ([]Message,
|
||||
|
||||
// Rien à compacter : le torse [head, tailStart) est vide.
|
||||
if tailStart <= head {
|
||||
return msgs, false
|
||||
return msgs, false, false
|
||||
}
|
||||
|
||||
torso := msgs[head:tailStart]
|
||||
|
||||
// Même un résumé vide ne ferait pas passer la garantie de réduction (plus
|
||||
// bas) : inutile d'appeler le modèle ni d'archiver quoi que ce soit, la
|
||||
// compaction serait refusée. Borne basse exacte, jamais un refus de plus
|
||||
// qu'aujourd'hui. Avec COMPACT_CONTINUATION seulement.
|
||||
if opt.precheck && compactHopeless(msgs, head, tailStart) {
|
||||
fmt.Fprintf(os.Stderr, "[compact] résumé non demandé : même vide, la réduction resterait sous 20 %%\n")
|
||||
return msgs, false, false
|
||||
}
|
||||
|
||||
// 3. Archivage des gros blocs (mémoire longue). AVANT de compacter, chaque bloc
|
||||
// du torse assez long est enregistré VERBATIM sous un id (r7…) : le résumé et
|
||||
// le repli le remplacent par une référence à cet id, que le modèle pourra
|
||||
@@ -380,18 +473,24 @@ func compactMessages(ctx context.Context, msgs []Message, caps Caps) ([]Message,
|
||||
var index []recallEntry
|
||||
if caps.Agent && compactEnabled() {
|
||||
names := torsoToolNames(torso)
|
||||
var blocks []recallArchiveInput
|
||||
var at []int // position dans le torse de chaque bloc
|
||||
for i, m := range torso {
|
||||
if !recallEligible(m) {
|
||||
continue
|
||||
}
|
||||
content := msgText(m)
|
||||
label := recallLabel(m.Role, names[m.ToolCallID], content)
|
||||
id, aerr := archiveRecallBlock(label, m.Role, content)
|
||||
if aerr != nil {
|
||||
continue
|
||||
blocks = append(blocks, recallArchiveInput{Label: recallLabel(m.Role, names[m.ToolCallID], content), Role: m.Role, Content: content})
|
||||
at = append(at, i)
|
||||
}
|
||||
// Une seule transaction pour tous les blocs (AJEAN #130).
|
||||
if entries, aerr := archiveRecallBlocks(blocks); aerr == nil {
|
||||
for k, e := range entries {
|
||||
archived[at[k]] = &recallEntry{id: e.id, label: e.label}
|
||||
index = append(index, e)
|
||||
}
|
||||
archived[i] = &recallEntry{id: id, label: label}
|
||||
index = append(index, recallEntry{id: id, label: label})
|
||||
} else if len(blocks) > 0 {
|
||||
fmt.Fprintf(os.Stderr, "[compact] archives recall non écrites (%v) : repli sans rappel\n", aerr)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -403,6 +502,10 @@ func compactMessages(ctx context.Context, msgs []Message, caps Caps) ([]Message,
|
||||
pruned := make([]Message, len(torso))
|
||||
for i, m := range torso {
|
||||
pruned[i] = m
|
||||
// Le raisonnement renvoyé (REASONING_ECHO) d'un tour compacté ne repart
|
||||
// pas : comme le résumé, qui ne l'a jamais vu (renderTranscript), le torse
|
||||
// revient à ce que Loki envoyait sans la clé.
|
||||
pruned[i].ReasoningContent, pruned[i].ReasoningModel = "", ""
|
||||
if e := archived[i]; e != nil {
|
||||
pruned[i].Content = recallMarker(e.id, e.label)
|
||||
continue
|
||||
@@ -450,7 +553,31 @@ func compactMessages(ctx context.Context, msgs []Message, caps Caps) ([]Message,
|
||||
}
|
||||
}
|
||||
}
|
||||
summary, err := summarizeTranscriptFor(ctx, renderTranscript(forSummary), caps.Code)
|
||||
// Sans les mises à jour du projet (PROJ_SNAPSHOT) : ce sont des données de
|
||||
// Loki, pas la conversation, et le bloc projet est repris tout neuf après la
|
||||
// compaction. Sans la clé, rien à retirer.
|
||||
forSummary = stripContextUpdates(forSummary)
|
||||
var summary string
|
||||
var err error
|
||||
if opt.view != nil {
|
||||
// COMPACT_CONTINUATION : le résumé prolonge le prompt que le moteur a
|
||||
// déjà en cache. Au moindre écart, repli sur la transcription d'avant.
|
||||
summary, err = summarizeContinuation(ctx, msgs, tailStart, opt.view, caps.Code)
|
||||
if err != nil {
|
||||
fmt.Fprintf(os.Stderr, "[compact] continuation écartée (%v) : résumé sur transcription\n", err)
|
||||
summary, err = summarizeTranscriptFor(ctx, renderTranscript(forSummary), caps.Code)
|
||||
}
|
||||
} else {
|
||||
summary, err = summarizeTranscriptFor(ctx, renderTranscript(forSummary), caps.Code)
|
||||
}
|
||||
// La cause d'un résumé raté n'était écrite nulle part : on ne voyait que des
|
||||
// compactions « changé=false » à répétition, sans savoir pourquoi (AJEAN
|
||||
// 68659e73).
|
||||
if err != nil {
|
||||
fmt.Fprintf(os.Stderr, "[compact] résumé raté : %v\n", err)
|
||||
} else if summaryLooksEmpty(summary) {
|
||||
fmt.Fprintf(os.Stderr, "[compact] résumé vide ou dégénéré (%d car.)\n", len([]rune(strings.TrimSpace(summary))))
|
||||
}
|
||||
var mid []Message
|
||||
if err != nil || summaryLooksEmpty(summary) {
|
||||
// Résumé raté (erreur, vide, ou juste une référence recall) → on garde le
|
||||
@@ -475,7 +602,7 @@ func compactMessages(ctx context.Context, msgs []Message, caps Caps) ([]Message,
|
||||
}
|
||||
mid = []Message{
|
||||
{Role: "user", Content: sumMsg},
|
||||
{Role: "assistant", Content: "Understood. I'll resume from exactly where I left off, using the findings above, and call recall(id) if I need the full content of an archived block, without redoing work that is already done."},
|
||||
{Role: "assistant", Content: compactAckMsg},
|
||||
}
|
||||
}
|
||||
|
||||
@@ -500,31 +627,13 @@ func compactMessages(ctx context.Context, msgs []Message, caps Caps) ([]Message,
|
||||
// Un rappel de loki (budget d'outils, relance) a le rôle `user` sans être une
|
||||
// demande (isLokiInjected) : ni il ne prouve qu'une demande a été servie, ni
|
||||
// il ne se réinjecte à la place de la vraie.
|
||||
tailHasUser := false
|
||||
for _, m := range msgs[tailStart:] {
|
||||
if m.Role == "user" && !isLokiInjected(m) {
|
||||
tailHasUser = true
|
||||
break
|
||||
}
|
||||
}
|
||||
summarized := err == nil && !summaryLooksEmpty(summary)
|
||||
var pending []Message
|
||||
for i := len(torso) - 1; i >= 0 && summarized && !tailHasUser; i-- {
|
||||
if torso[i].Role != "user" || isLokiInjected(torso[i]) {
|
||||
continue
|
||||
}
|
||||
if strings.HasPrefix(msgText(torso[i]), compactSummaryPrefix) {
|
||||
continue // résumé d'une compaction précédente, pas une demande
|
||||
}
|
||||
pending = []Message{torso[i]}
|
||||
break
|
||||
if summarized {
|
||||
pending = compactPending(msgs, torso, tailStart)
|
||||
}
|
||||
|
||||
out := make([]Message, 0, head+len(mid)+len(pending)+len(msgs)-tailStart)
|
||||
out = append(out, msgs[:head]...)
|
||||
out = append(out, mid...)
|
||||
out = append(out, pending...)
|
||||
out = append(out, msgs[tailStart:]...)
|
||||
out = compactAssemble(msgs, head, tailStart, mid, pending)
|
||||
|
||||
// Garantie de réduction : on n'accepte la compaction que si elle enlève au
|
||||
// moins ~20% du contexte estimé. Sinon (torse déjà maigre, résumé peu rentable)
|
||||
@@ -532,9 +641,55 @@ func compactMessages(ctx context.Context, msgs []Message, caps Caps) ([]Message,
|
||||
// vraiment réduire, puis re-déclenchait aussitôt.
|
||||
before, after := estimateTokens(msgs), estimateTokens(out)
|
||||
if after > before*4/5 {
|
||||
return msgs, false
|
||||
return msgs, false, summarized
|
||||
}
|
||||
return out, true
|
||||
return out, true, false
|
||||
}
|
||||
|
||||
// compactAckMsg : la réponse assistant synthétique qui suit le résumé.
|
||||
const compactAckMsg = "Understood. I'll resume from exactly where I left off, using the findings above, and call recall(id) if I need the full content of an archived block, without redoing work that is already done."
|
||||
|
||||
// compactPending : la demande en cours à réinjecter avant la queue (voir
|
||||
// compactMessagesOpt), quand un résumé a été obtenu ; nil sinon.
|
||||
func compactPending(msgs, torso []Message, tailStart int) []Message {
|
||||
for _, m := range msgs[tailStart:] {
|
||||
if m.Role == "user" && !isLokiInjected(m) {
|
||||
return nil
|
||||
}
|
||||
}
|
||||
for i := len(torso) - 1; i >= 0; i-- {
|
||||
if torso[i].Role != "user" || isLokiInjected(torso[i]) {
|
||||
continue
|
||||
}
|
||||
if strings.HasPrefix(msgText(torso[i]), compactSummaryPrefix) {
|
||||
continue // résumé d'une compaction précédente, pas une demande
|
||||
}
|
||||
return []Message{torso[i]}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// compactAssemble : tête, zone résumée, demande réinjectée, queue.
|
||||
func compactAssemble(msgs []Message, head, tailStart int, mid, pending []Message) []Message {
|
||||
out := make([]Message, 0, head+len(mid)+len(pending)+len(msgs)-tailStart)
|
||||
out = append(out, msgs[:head]...)
|
||||
out = append(out, mid...)
|
||||
out = append(out, pending...)
|
||||
return append(out, msgs[tailStart:]...)
|
||||
}
|
||||
|
||||
// compactHopeless : la compaction serait refusée par la garantie de réduction
|
||||
// quel que soit le résumé. Le plancher est la sortie avec un résumé VIDE —
|
||||
// message d'en-tête court, sans index ni critères, réponse assistant, demande
|
||||
// réinjectée, queue : toute vraie sortie a la même forme avec des messages au
|
||||
// moins aussi longs, donc une estimation au moins aussi grande.
|
||||
func compactHopeless(msgs []Message, head, tailStart int) bool {
|
||||
torso := msgs[head:tailStart]
|
||||
floor := compactAssemble(msgs, head, tailStart, []Message{
|
||||
{Role: "user", Content: compactSummaryUserMsg("", nil)},
|
||||
{Role: "assistant", Content: compactAckMsg},
|
||||
}, compactPending(msgs, torso, tailStart))
|
||||
return estimateTokens(floor) > estimateTokens(msgs)*4/5
|
||||
}
|
||||
|
||||
// summaryLooksEmpty détecte un résumé raté : trop court, ou constitué seulement
|
||||
@@ -666,13 +821,7 @@ func renderTranscript(msgs []Message) string {
|
||||
// summarizeResp modélise le sous-ensemble utile d'une réponse non-streamée de
|
||||
// /v1/chat/completions.
|
||||
type summarizeResp struct {
|
||||
Choices []struct {
|
||||
Message struct {
|
||||
Content string `json:"content"`
|
||||
ReasoningContent string `json:"reasoning_content"`
|
||||
} `json:"message"`
|
||||
FinishReason string `json:"finish_reason"`
|
||||
} `json:"choices"`
|
||||
Choices []summaryChoice `json:"choices"`
|
||||
}
|
||||
|
||||
// summarizeTranscript demande au modèle local un résumé dense et fidèle du torse.
|
||||
@@ -691,13 +840,17 @@ This is a CODING session. Also keep:
|
||||
- Errors hit (build, tests, tools) and how each was resolved — or that it is still open
|
||||
- The commands that build and test the project`
|
||||
|
||||
// summarizeTranscriptFor : code = résumé d'une session du mode Code.
|
||||
func summarizeTranscriptFor(ctx context.Context, transcript string, code bool) (string, error) {
|
||||
sys := `You are a context compactor. You are given the transcript of the older turns of a conversation between a user and an AI assistant (with its tools). The PURPOSE of your summary is to let the conversation continue in a fresh, smaller context WITHOUT losing any information that is useful or important to understand what came before and keep working — preserve everything that matters, drop only what is redundant.
|
||||
// compactTranscriptIntro : le rôle du résumeur sur transcription. Les règles
|
||||
// qui suivent (compactSummaryRules) servent aussi au résumé en continuation
|
||||
// (chat_compact_cont.go) : les deux chemins gardent les mêmes exigences.
|
||||
const compactTranscriptIntro = `You are a context compactor. You are given the transcript of the older turns of a conversation between a user and an AI assistant (with its tools). The PURPOSE of your summary is to let the conversation continue in a fresh, smaller context WITHOUT losing any information that is useful or important to understand what came before and keep working — preserve everything that matters, drop only what is redundant.
|
||||
|
||||
The assistant is MID-TASK: it will read your summary and must resume exactly where it left off, WITHOUT redoing work it has already done. Its own internal reasoning is NOT part of the transcript and is lost — your summary is the only memory it keeps.
|
||||
|
||||
Summarize densely and faithfully, keeping ONLY the essentials:
|
||||
`
|
||||
|
||||
// compactSummaryRules : ce que tout résumé de compaction doit garder.
|
||||
const compactSummaryRules = `Summarize densely and faithfully, keeping ONLY the essentials:
|
||||
- The user's CURRENT request, goal(s) and constraints
|
||||
- FINDINGS: the concrete information already gathered — facts, figures, dates, names, URLs, file paths, values, config. This is the most important part: whatever is not here is lost and will have to be looked up again.
|
||||
- Sources already consulted (URLs opened, files read, commands run) — so they are not consulted a second time
|
||||
@@ -705,6 +858,10 @@ Summarize densely and faithfully, keeping ONLY the essentials:
|
||||
- STATE OF PROGRESS: what is already answered, what is still missing, and the next concrete step
|
||||
Strict rules: no preamble or conclusion, no verbatim or long quotes, no throwaway detail. Use short bullet points. Be as concise as you can WHILE keeping every fact, decision and still-open task: a detail you drop here is lost for good, so when in doubt keep it. This is a dense compression summary, not a report. Always write ACTUAL prose sentences/bullets — never answer with just an id or a reference.
|
||||
Write the summary in the SAME language as the conversation.`
|
||||
|
||||
// summarizeTranscriptFor : code = résumé d'une session du mode Code.
|
||||
func summarizeTranscriptFor(ctx context.Context, transcript string, code bool) (string, error) {
|
||||
sys := compactTranscriptIntro + compactSummaryRules
|
||||
if code {
|
||||
sys += codeSummaryRules
|
||||
}
|
||||
@@ -735,19 +892,59 @@ Write the summary in the SAME language as the conversation.`
|
||||
// argument inconnu par un 400, et la compaction échouait à chaque fois.
|
||||
delete(payload, "chat_template_kwargs")
|
||||
}
|
||||
// SIDE_SLOT : la transcription est un prompt neuf, calculé à froid — sur le
|
||||
// second slot, qui fait CTX jetons comme le premier, elle laisse intact le
|
||||
// slot de la discussion. Sans second slot en service, aucun id_slot.
|
||||
setEngineSlot(payload, engineSlotFor(ep, false))
|
||||
ch, err := postSummary(ctx, ep, payload, perfTag{kind: perfCompact, conv: perfTagOf(ctx).conv})
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
return cleanSummary(ch.Message.Content, ch.Message.ReasoningContent, ch.FinishReason)
|
||||
}
|
||||
|
||||
// summaryChoice : le premier choix d'une réponse de résumé.
|
||||
type summaryChoice struct {
|
||||
Message struct {
|
||||
Content string `json:"content"`
|
||||
ReasoningContent string `json:"reasoning_content"`
|
||||
// Appels d'outil émis malgré tool_choice « none » (résumé en
|
||||
// continuation, qui garde les outils du tour) : jamais un résumé.
|
||||
ToolCalls []json.RawMessage `json:"tool_calls"`
|
||||
} `json:"message"`
|
||||
FinishReason string `json:"finish_reason"`
|
||||
}
|
||||
|
||||
// summaryHTTPError : le moteur a refusé la requête de résumé.
|
||||
type summaryHTTPError struct {
|
||||
who string
|
||||
status int
|
||||
body string
|
||||
}
|
||||
|
||||
func (e *summaryHTTPError) Error() string {
|
||||
return fmt.Sprintf("résumé: %s %d: %s", e.who, e.status, e.body)
|
||||
}
|
||||
|
||||
// postSummary envoie une requête de résumé non streamée et rend son premier
|
||||
// choix. Partagé par le résumé sur transcription et celui en continuation
|
||||
// (chat_compact_cont.go) : même compteur de requêtes en vol, même télémétrie
|
||||
// (kind=compact), mêmes erreurs.
|
||||
func postSummary(ctx context.Context, ep chatEndpoint, payload map[string]any, tag perfTag) (summaryChoice, error) {
|
||||
body, _ := json.Marshal(payload)
|
||||
req, err := http.NewRequestWithContext(ctx, "POST", ep.URL, bytes.NewReader(body))
|
||||
if err != nil {
|
||||
return "", err
|
||||
return summaryChoice{}, err
|
||||
}
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
ep.auth(req.Header.Set)
|
||||
if !ep.External {
|
||||
defer engineRequestStart()() // voir llm_slots.go
|
||||
end, _ := engineRequestBeginSide(nil, payloadOnSideSlot(payload)) // voir llm_slots.go
|
||||
defer end()
|
||||
}
|
||||
resp, err := http.DefaultClient.Do(req)
|
||||
if err != nil {
|
||||
return "", friendlyLLMError(err)
|
||||
return summaryChoice{}, friendlyLLMError(err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
@@ -756,28 +953,27 @@ Write the summary in the SAME language as the conversation.`
|
||||
if ep.External {
|
||||
who = "API externe"
|
||||
}
|
||||
return "", fmt.Errorf("résumé: %s %d: %s", who, resp.StatusCode, strings.TrimSpace(string(b)))
|
||||
return summaryChoice{}, &summaryHTTPError{who: who, status: resp.StatusCode, body: strings.TrimSpace(string(b))}
|
||||
}
|
||||
raw, err := io.ReadAll(resp.Body)
|
||||
if err != nil {
|
||||
return "", err
|
||||
return summaryChoice{}, err
|
||||
}
|
||||
// Decoder et non Unmarshal : comme avant, seule la première valeur JSON
|
||||
// compte — un octet parasite après elle (proxy, passerelle) ne doit pas
|
||||
// faire échouer une compaction qui passait.
|
||||
var out summarizeResp
|
||||
if err := json.NewDecoder(bytes.NewReader(raw)).Decode(&out); err != nil {
|
||||
return "", err
|
||||
return summaryChoice{}, err
|
||||
}
|
||||
// Télémétrie de la compaction : réponse non streamée, timings et usage sont
|
||||
// à la racine. Lue à part et sans échec possible (perfWire) : un compteur
|
||||
// mal typé ne doit jamais faire échouer la compaction.
|
||||
perfRecord(perfRecFromWire(perfTag{kind: perfCompact, conv: perfTagOf(ctx).conv}, decodePerfWire(raw)), nil)
|
||||
perfRecord(perfRecFromWire(tag, decodePerfWire(raw)), nil)
|
||||
if len(out.Choices) == 0 {
|
||||
return "", fmt.Errorf("résumé: réponse vide")
|
||||
return summaryChoice{}, fmt.Errorf("résumé: réponse vide")
|
||||
}
|
||||
ch := out.Choices[0]
|
||||
return cleanSummary(ch.Message.Content, ch.Message.ReasoningContent, ch.FinishReason)
|
||||
return out.Choices[0], nil
|
||||
}
|
||||
|
||||
// summaryRuneCap borne le résumé en caractères, en filet de sécurité
|
||||
@@ -888,6 +1084,10 @@ const shrinkTruncMarker = "\n[…tronqué : contexte plein. Relance l'outil de f
|
||||
// typiquement un tour qui a lu beaucoup de gros fichiers d'un coup, tout est
|
||||
// dans la queue protégée). Avant, l'erreur 400 remontait telle quelle et la
|
||||
// conversation restait bloquée. Ici on réduit pour de bon :
|
||||
// 0. le raisonnement renvoyé (REASONING_ECHO), du plus ancien au plus récent :
|
||||
// c'est ce que Loki n'envoyait pas sans la clé, à retirer avant tout le reste
|
||||
// — y compris dans un seul long tour sans frontière `user`, que l'étape 2
|
||||
// ne sait pas couper ;
|
||||
// 1. les gros résultats d'outils (du plus ancien au plus récent) sont tronqués ;
|
||||
// 2. si ça ne suffit pas, on retire les plus vieux échanges, par tour entier
|
||||
// (frontière `user`), en gardant les messages système de tête.
|
||||
@@ -905,6 +1105,21 @@ func shrinkToFit(msgs []Message, actual int) ([]Message, bool) {
|
||||
}
|
||||
target := int(float64(ctxWindow()) * 0.6 / ratio)
|
||||
out := append([]Message(nil), msgs...)
|
||||
// Avant tout : les images gardées par KEEP_TURN_IMAGES, retirées en bloc
|
||||
// comme à la compaction (sans la clé, aucune).
|
||||
if s, freed := dropKeptImages(out); freed > 0 {
|
||||
logKeptImagesDropped("réduction", freed)
|
||||
out = s
|
||||
}
|
||||
for i := range out {
|
||||
if out[i].ReasoningContent == "" && out[i].ReasoningModel == "" {
|
||||
continue
|
||||
}
|
||||
if estimateTokens(out) <= target {
|
||||
break
|
||||
}
|
||||
out[i].ReasoningContent, out[i].ReasoningModel = "", ""
|
||||
}
|
||||
for i := range out {
|
||||
if estimateTokens(out) <= target {
|
||||
break
|
||||
|
||||
@@ -0,0 +1,476 @@
|
||||
package loki
|
||||
|
||||
// chat_compact_cont.go — COMPACT_CONTINUATION : la compaction proactive comme
|
||||
// suite du prompt que le moteur a déjà en cache.
|
||||
//
|
||||
// Pourquoi. Aujourd'hui, le résumé d'une compaction part dans une requête à
|
||||
// part : son propre prompt système et une transcription du torse. Le moteur
|
||||
// local la calcule à froid (5 à 46k jetons), puis recalcule à froid le prompt
|
||||
// compacté au message suivant. Sur un 27B, des dizaines de secondes ; sur un
|
||||
// MoE aux experts en RAM, des minutes — à chaque compaction.
|
||||
//
|
||||
// Avec la clé (off par défaut), quand le slot porte selon toute vraisemblance
|
||||
// le prompt de CETTE discussion, la requête de résumé est la requête du tour
|
||||
// telle qu'elle est partie — mêmes messages assemblés par les mêmes fonctions
|
||||
// (turnViewDry, wireMessages, buildChatPayload), mêmes outils, mêmes arguments
|
||||
// du gabarit, même intensité de raisonnement — suivie d'un seul message
|
||||
// utilisateur qui demande le résumé. Le moteur ne calcule que ce message.
|
||||
//
|
||||
// Ce qui ne change pas :
|
||||
// - la compaction elle-même : bornes, archives recall, demande réinjectée,
|
||||
// garantie de réduction et historique rangé sont calculés sur la vue
|
||||
// MODÈLE, comme avant. La vue d'envoi ne sert qu'à construire la requête :
|
||||
// ni prompt système, ni skills, ni bloc projet n'entrent dans l'historique ;
|
||||
// - les exigences du résumé : mêmes règles (compactSummaryRules, plus
|
||||
// codeSummaryRules en mode Code), même budget, même température 0.2 sans
|
||||
// l'échantillonnage du preset, réflexion coupée (enable_thinking=false,
|
||||
// ajouté aux arguments du tour) ;
|
||||
// - le modèle principal, ses tours et leur échantillonnage.
|
||||
//
|
||||
// Ce qui change : le résumeur voit le torse entier dans son format natif (plus
|
||||
// que la transcription, qui raccourcit les résultats d'outils), depuis la
|
||||
// place de l'agent. La frontière lui est désignée par le nombre de messages
|
||||
// gardés et le début du premier d'entre eux ; en cas de doute, il doit plutôt
|
||||
// répéter que laisser tomber, et dater l'état d'avancement à la frontière.
|
||||
//
|
||||
// Repli sur la transcription d'avant, sans rien perdre, au moindre écart :
|
||||
// preset externe, autre requête passée par le slot depuis le dernier tour
|
||||
// (vérificateur, sous-agent, tâche, préchauffage, résumé, bench, client /v1,
|
||||
// autre discussion), moteur relancé sur un autre modèle ou une autre fenêtre,
|
||||
// étape de secours (outils coupés), marge insuffisante dans la fenêtre, vue
|
||||
// désalignée, refus du moteur (gabarit qui exige l'alternance, fenêtre
|
||||
// pleine…), erreur réseau, appel d'outil émis, raisonnement sans résumé,
|
||||
// résumé vide. Un refus du gabarit ou un appel d'outil suspend la continuation
|
||||
// pour ce modèle jusqu'au redémarrage ; deux résumés ratés de suite aussi.
|
||||
//
|
||||
// La clé apporte aussi deux économies sans effet sur le résultat :
|
||||
// - un résumé n'est pas demandé quand même un résumé vide ne passerait pas la
|
||||
// garantie de réduction (compactHopeless, borne exacte) ;
|
||||
// - une compaction refusée parce que le résumé obtenu était trop long n'est
|
||||
// retentée, en début ou fin de tour et entre deux étapes, qu'une fois le
|
||||
// contexte grossi de 10 % sur le même fil (un historique réécrit retente)
|
||||
// — jamais pour le filet réactif, le bouton « compacter », une fenêtre
|
||||
// pleine à 90 % ou plus.
|
||||
//
|
||||
// Réactif (prompt refusé par le moteur), fenêtre pleine en pleine génération,
|
||||
// bouton manuel, tâches, sous-agents, terminal : chemin d'avant, toujours.
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"os"
|
||||
"strings"
|
||||
"sync"
|
||||
"unicode/utf8"
|
||||
)
|
||||
|
||||
// compactContinuationOn : clé posée, moteur local.
|
||||
func compactContinuationOn(cfg map[string]string) bool {
|
||||
if isExternalConfig(cfg) {
|
||||
return false
|
||||
}
|
||||
switch strings.ToLower(strings.TrimSpace(cfg["COMPACT_CONTINUATION"])) {
|
||||
case "on", "1", "true", "yes", "oui":
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// compactView : la requête du tour telle qu'elle est partie, pour un résumé
|
||||
// en continuation. wire couvre les n premiers messages de la vue modèle.
|
||||
type compactView struct {
|
||||
wire []Message
|
||||
n int
|
||||
tools []Tool
|
||||
effort string
|
||||
kwargs map[string]any
|
||||
used int // contexte réel (dernier compte du moteur + ce qu'il n'a pas vu)
|
||||
conv string // discussion, pour la télémétrie
|
||||
}
|
||||
|
||||
// compactOpts : options d'une compaction. Nulles : le chemin d'avant, exact.
|
||||
type compactOpts struct {
|
||||
view *compactView // nil : résumé sur transcription
|
||||
precheck bool // pas de résumé quand même un vide serait refusé
|
||||
conv string // discussion, pour la mémoire des refus
|
||||
used int
|
||||
}
|
||||
|
||||
// compactViewLen : la vue s'arrête avant les messages utilisateur de fin (le
|
||||
// nouveau message, ceux de la file, une image montrée à l'étape) : le moteur
|
||||
// ne les a pas en cache, et un second message utilisateur d'affilée ferait
|
||||
// refuser la requête par les gabarits à alternance stricte.
|
||||
func compactViewLen(msgs []Message) int {
|
||||
k := len(msgs)
|
||||
for k > 0 && msgs[k-1].Role == "user" {
|
||||
k--
|
||||
}
|
||||
return k
|
||||
}
|
||||
|
||||
// compactOptsFor : options des compactions de début et de fin de tour.
|
||||
func (c *Conversation) compactOptsFor(caps Caps, msgs []Message, used int, conv string) compactOpts {
|
||||
cfg := ReadConfig()
|
||||
if conv == "" || !compactContinuationOn(cfg) {
|
||||
return compactOpts{}
|
||||
}
|
||||
opt := compactOpts{precheck: true, conv: conv, used: used}
|
||||
if used <= 0 || !engineSlotHolds(conv) || compactContDisabled() {
|
||||
return opt
|
||||
}
|
||||
k := compactViewLen(msgs)
|
||||
if k == 0 {
|
||||
return opt
|
||||
}
|
||||
sent, tools := c.turnViewDry(caps, msgs[:k], caps.Agent)
|
||||
_, effort, _, kwargs := turnReasoning(cfg)
|
||||
wire, _ := wireMessages(sent, false, turnEchoPolicy(false))
|
||||
opt.view = &compactView{wire: wire, n: k, tools: tools, effort: effort, kwargs: kwargs, used: used, conv: conv}
|
||||
return opt
|
||||
}
|
||||
|
||||
// compactOptsMid : options d'une compaction entre deux étapes d'un tour
|
||||
// (runChatTools). messages porte déjà le prompt système et le bloc projet ;
|
||||
// pol est la politique de raisonnement renvoyé de la dernière requête.
|
||||
// rescue : étape de secours (outils coupés ou neutralisés), dont le prompt
|
||||
// n'est pas celui du tour.
|
||||
func compactOptsMid(cfg map[string]string, ep chatEndpoint, conv string, messages []Message, used int,
|
||||
tools []Tool, effort string, kwargs map[string]any, pol echoPolicy, rescue bool) compactOpts {
|
||||
if conv == "" || ep.External || !compactContinuationOn(cfg) {
|
||||
return compactOpts{}
|
||||
}
|
||||
opt := compactOpts{precheck: true, conv: conv, used: used}
|
||||
if rescue || used <= 0 || !engineSlotHolds(conv) || compactContDisabled() {
|
||||
return opt
|
||||
}
|
||||
k := compactViewLen(messages)
|
||||
if k == 0 {
|
||||
return opt
|
||||
}
|
||||
wire, _ := wireMessages(messages[:k], false, pol)
|
||||
opt.view = &compactView{wire: wire, n: k, tools: tools, effort: effort, kwargs: kwargs, used: used, conv: conv}
|
||||
return opt
|
||||
}
|
||||
|
||||
// compactMessagesNoted : compactMessagesOpt, plus la mémoire des refus.
|
||||
func compactMessagesNoted(ctx context.Context, msgs []Message, caps Caps, opt compactOpts) ([]Message, bool) {
|
||||
out, changed, refused := compactMessagesOpt(ctx, msgs, caps, opt)
|
||||
if opt.conv != "" {
|
||||
if changed {
|
||||
compactRefusedClear(opt.conv)
|
||||
} else if refused {
|
||||
compactRefusedNote(opt.conv, opt.used, msgs)
|
||||
}
|
||||
}
|
||||
return out, changed
|
||||
}
|
||||
|
||||
// --- refus mémorisés --------------------------------------------------------
|
||||
|
||||
// compactRefusal : une compaction refusée faute de réduction, à at jetons de
|
||||
// contexte, sur un historique de n messages d'empreinte hash. En mémoire
|
||||
// seulement : un redémarrage retente.
|
||||
type compactRefusal struct {
|
||||
at, window int
|
||||
model string
|
||||
n int
|
||||
hash uint64
|
||||
}
|
||||
|
||||
// historyHash : empreinte d'un historique (celle de perfPrefix).
|
||||
func historyHash(msgs []Message) uint64 {
|
||||
if len(msgs) == 0 {
|
||||
return 0
|
||||
}
|
||||
p := perfPrefix(msgs)
|
||||
return p[len(p)-1]
|
||||
}
|
||||
|
||||
var compactRefused struct {
|
||||
mu sync.Mutex
|
||||
m map[string]compactRefusal
|
||||
}
|
||||
|
||||
const compactRefusedMax = 64
|
||||
|
||||
// compactRefusedSkip : la dernière compaction de conv a été refusée, msgs ne
|
||||
// fait que prolonger l'historique d'alors, et le contexte n'a pas grossi de
|
||||
// 10 % depuis. Jamais à 90 % de la fenêtre ou plus ; oublié dès que
|
||||
// l'historique a été réécrit (compaction, édition, régénération, discussion
|
||||
// vidée), que le contexte baisse, ou que le modèle ou la fenêtre changent.
|
||||
func compactRefusedSkip(conv string, used int, msgs []Message) bool {
|
||||
if conv == "" || used <= 0 || !compactContinuationOn(ReadConfig()) {
|
||||
return false
|
||||
}
|
||||
model, window := engineMainNow()
|
||||
compactRefused.mu.Lock()
|
||||
r, ok := compactRefused.m[conv]
|
||||
compactRefused.mu.Unlock()
|
||||
if !ok {
|
||||
return false
|
||||
}
|
||||
if r.model != model || r.window != window || used < r.at || used >= r.at+r.at/10 ||
|
||||
len(msgs) < r.n || historyHash(msgs[:r.n]) != r.hash {
|
||||
compactRefusedClear(conv)
|
||||
return false
|
||||
}
|
||||
if used*10 >= window*9 {
|
||||
return false
|
||||
}
|
||||
fmt.Fprintf(os.Stderr, "[compact] pas de nouvel essai : refusée à %d jetons, contexte à %d\n", r.at, used)
|
||||
return true
|
||||
}
|
||||
|
||||
func compactRefusedNote(conv string, used int, msgs []Message) {
|
||||
if conv == "" || used <= 0 {
|
||||
return
|
||||
}
|
||||
model, window := engineMainNow()
|
||||
hash := historyHash(msgs)
|
||||
compactRefused.mu.Lock()
|
||||
defer compactRefused.mu.Unlock()
|
||||
if compactRefused.m == nil {
|
||||
compactRefused.m = map[string]compactRefusal{}
|
||||
}
|
||||
if _, ok := compactRefused.m[conv]; !ok && len(compactRefused.m) >= compactRefusedMax {
|
||||
for k := range compactRefused.m {
|
||||
delete(compactRefused.m, k)
|
||||
break
|
||||
}
|
||||
}
|
||||
compactRefused.m[conv] = compactRefusal{at: used, window: window, model: model, n: len(msgs), hash: hash}
|
||||
}
|
||||
|
||||
func compactRefusedClear(conv string) {
|
||||
compactRefused.mu.Lock()
|
||||
delete(compactRefused.m, conv)
|
||||
compactRefused.mu.Unlock()
|
||||
}
|
||||
|
||||
// --- échecs par modèle ------------------------------------------------------
|
||||
|
||||
// compactContFails : échecs de la continuation par modèle, jusqu'au
|
||||
// redémarrage. À compactContMaxFails, elle n'est plus tentée pour ce modèle.
|
||||
var compactContFails struct {
|
||||
mu sync.Mutex
|
||||
m map[string]int
|
||||
}
|
||||
|
||||
const compactContMaxFails = 2
|
||||
|
||||
func compactContDisabled() bool {
|
||||
model, _ := engineMainNow()
|
||||
compactContFails.mu.Lock()
|
||||
defer compactContFails.mu.Unlock()
|
||||
return compactContFails.m[model] >= compactContMaxFails
|
||||
}
|
||||
|
||||
// compactContFail : hard = refus du gabarit ou appel d'outil, qui se
|
||||
// reproduiraient à l'identique : suspendue tout de suite.
|
||||
func compactContFail(hard bool) {
|
||||
model, _ := engineMainNow()
|
||||
compactContFails.mu.Lock()
|
||||
defer compactContFails.mu.Unlock()
|
||||
if compactContFails.m == nil {
|
||||
compactContFails.m = map[string]int{}
|
||||
}
|
||||
n := compactContFails.m[model] + 1
|
||||
if hard {
|
||||
n = compactContMaxFails
|
||||
}
|
||||
if n >= compactContMaxFails && compactContFails.m[model] < compactContMaxFails {
|
||||
fmt.Fprintf(os.Stderr, "[compact] continuation suspendue pour %s jusqu'au redémarrage : résumés sur transcription\n", model)
|
||||
}
|
||||
compactContFails.m[model] = n
|
||||
}
|
||||
|
||||
func compactContOK() {
|
||||
model, _ := engineMainNow()
|
||||
compactContFails.mu.Lock()
|
||||
delete(compactContFails.m, model)
|
||||
compactContFails.mu.Unlock()
|
||||
}
|
||||
|
||||
// --- la requête -------------------------------------------------------------
|
||||
|
||||
// compactWireIndex : position dans view.wire du message msgs[i] de la vue
|
||||
// modèle (len(view.wire) pour i == view.n : toute la vue), -1 si les deux vues
|
||||
// ne se correspondent pas. L'assemblage n'ajoute ou ne déplace que des
|
||||
// messages système (prompt, skills, bloc projet, rappel des pages lues) ; les
|
||||
// autres se suivent un pour un, dans le même ordre — vérifié rôle par rôle,
|
||||
// appel par appel, plutôt que supposé.
|
||||
func compactWireIndex(msgs []Message, view *compactView, i int) int {
|
||||
if view == nil || view.n > len(msgs) || i < 0 || i > view.n {
|
||||
return -1
|
||||
}
|
||||
var a, b []int
|
||||
for j, m := range msgs[:view.n] {
|
||||
if m.Role != "system" {
|
||||
a = append(a, j)
|
||||
}
|
||||
}
|
||||
for j, m := range view.wire {
|
||||
if m.Role != "system" {
|
||||
b = append(b, j)
|
||||
}
|
||||
}
|
||||
if len(a) != len(b) {
|
||||
return -1
|
||||
}
|
||||
at := -1
|
||||
for j := range a {
|
||||
m, w := msgs[a[j]], view.wire[b[j]]
|
||||
if m.Role != w.Role || m.ToolCallID != w.ToolCallID || len(m.ToolCalls) != len(w.ToolCalls) {
|
||||
return -1
|
||||
}
|
||||
if a[j] == i {
|
||||
at = b[j]
|
||||
}
|
||||
}
|
||||
if i == view.n {
|
||||
return len(view.wire)
|
||||
}
|
||||
return at
|
||||
}
|
||||
|
||||
// clipRunes : s sur une ligne, coupé à n caractères.
|
||||
func clipRunes(s string, n int) string {
|
||||
s = strings.Join(strings.Fields(s), " ")
|
||||
if utf8.RuneCountInString(s) <= n {
|
||||
return s
|
||||
}
|
||||
return string([]rune(s)[:n]) + "…"
|
||||
}
|
||||
|
||||
// compactBoundaryDesc désigne le premier message gardé, m (vue modèle), qui
|
||||
// est wire[wi] : son rôle, et l'appel d'outil ou le début du texte. Un début
|
||||
// déjà vu plus haut est allongé, pour rester sans ambiguïté.
|
||||
func compactBoundaryDesc(wire []Message, wi int, m Message) string {
|
||||
if m.Role == "assistant" && strings.TrimSpace(msgText(m)) == "" && len(m.ToolCalls) > 0 {
|
||||
f := m.ToolCalls[0].Function
|
||||
return fmt.Sprintf("your call to the tool %s(%s)", f.Name, clipRunes(f.Arguments, 120))
|
||||
}
|
||||
who := "the user message"
|
||||
switch m.Role {
|
||||
case "assistant":
|
||||
who = "your message"
|
||||
case "tool":
|
||||
who = "the tool result"
|
||||
}
|
||||
m, _ = withoutCtxUpdate(m)
|
||||
text := msgText(m)
|
||||
snip := clipRunes(text, 80)
|
||||
for _, w := range wire[:wi] {
|
||||
if strings.Contains(strings.Join(strings.Fields(msgText(w)), " "), strings.TrimSuffix(snip, "…")) {
|
||||
snip = clipRunes(text, 200)
|
||||
break
|
||||
}
|
||||
}
|
||||
return fmt.Sprintf("%s that starts with «%s»", who, snip)
|
||||
}
|
||||
|
||||
// compactContPrefix ouvre la demande de résumé : jamais rangée, elle ne vit
|
||||
// que dans la requête.
|
||||
const compactContPrefix = "[COMPACTION]"
|
||||
|
||||
// compactContInstruction : la demande ajoutée en fin de requête. kept =
|
||||
// messages gardés tels quels à partir de la frontière (0 : tout est résumé).
|
||||
func compactContInstruction(kept int, desc string, code bool) string {
|
||||
var b strings.Builder
|
||||
b.WriteString(compactContPrefix)
|
||||
b.WriteString(" Pause the task: do not answer and do not call any tool. The context is full: the older part of this conversation will be replaced by the summary you write now, and your past reasoning will be lost — this summary is the only memory kept of that part.\n")
|
||||
if kept > 0 {
|
||||
fmt.Fprintf(&b, "Summarize every message BEFORE %s. That message and the ones after it (the last %d) stay verbatim after your summary: mention them briefly at most, but never leave out anything that comes before. Write the STATE OF PROGRESS as of that message, not later.\n", desc, kept)
|
||||
} else {
|
||||
b.WriteString("Summarize the whole conversation above.\n")
|
||||
}
|
||||
b.WriteString("Leave out the system instructions and any <project_context> or <context_update> block: they are sent again separately.\n\n")
|
||||
b.WriteString(compactSummaryRules)
|
||||
if code {
|
||||
b.WriteString(codeSummaryRules)
|
||||
}
|
||||
return b.String()
|
||||
}
|
||||
|
||||
// summarizeContinuation demande le résumé du torse [.., tailStart) en
|
||||
// prolongeant la requête du tour (view). Toute erreur fait retomber
|
||||
// l'appelant sur la transcription.
|
||||
func summarizeContinuation(ctx context.Context, msgs []Message, tailStart int, view *compactView, code bool) (string, error) {
|
||||
ep := resolveChatEndpoint()
|
||||
if ep.External {
|
||||
return "", errors.New("preset externe")
|
||||
}
|
||||
if tailStart > view.n {
|
||||
return "", errors.New("frontière hors de la vue")
|
||||
}
|
||||
// Relu à l'envoi : une requête partie depuis la construction de la vue
|
||||
// aurait pris le slot.
|
||||
if !engineSlotHolds(view.conv) {
|
||||
return "", errors.New("slot repris entre-temps")
|
||||
}
|
||||
wi := compactWireIndex(msgs, view, tailStart)
|
||||
if wi < 0 {
|
||||
return "", errors.New("vue d'envoi désalignée")
|
||||
}
|
||||
kept, desc := len(view.wire)-wi, ""
|
||||
if kept > 0 {
|
||||
desc = compactBoundaryDesc(view.wire, wi, msgs[tailStart])
|
||||
}
|
||||
instr := Message{Role: "user", Content: compactContInstruction(kept, desc, code)}
|
||||
// Marge : contexte réel, la demande, le résumé, et 5 % de la fenêtre pour
|
||||
// ce que l'estimation ne voit pas.
|
||||
if need := view.used + msgTokens(instr) + compactSummaryBudget() + ctxWindow()*5/100; need > ctxWindow() {
|
||||
return "", fmt.Errorf("marge insuffisante : %d jetons pour une fenêtre de %d", need, ctxWindow())
|
||||
}
|
||||
// Arguments du gabarit du tour, réflexion coupée : sur les gabarits
|
||||
// hybrides (Qwen3…), enable_thinking ne change que l'ouverture de la
|
||||
// réponse ; reasoning_effort, rendu dans le préfixe par gpt-oss, reste
|
||||
// celui du tour.
|
||||
kwargs := map[string]any{}
|
||||
for k, v := range view.kwargs {
|
||||
kwargs[k] = v
|
||||
}
|
||||
kwargs["enable_thinking"] = false
|
||||
sent := append(append([]Message(nil), view.wire...), instr)
|
||||
// tool_choice « none » : les outils restent rendus dans le prompt (le
|
||||
// préfixe en cache tient), aucun appel n'est attendu.
|
||||
payload := buildChatPayload(ep, sent, 0.2, chatPayloadOpts{
|
||||
tools: view.tools, toolChoiceNone: true, effort: view.effort, kwargs: kwargs, noSampling: true,
|
||||
})
|
||||
payload["stream"] = false
|
||||
delete(payload, "stream_options")
|
||||
payload["max_tokens"] = compactSummaryBudget()
|
||||
// SIDE_SLOT : elle prolonge le prompt du slot de la discussion, elle y va.
|
||||
setEngineSlot(payload, engineSlotFor(ep, true))
|
||||
ch, err := postSummary(ctx, ep, payload, perfTag{kind: perfCompact, conv: view.conv})
|
||||
if err != nil {
|
||||
// Refus de la requête elle-même (gabarit à alternance stricte, champ
|
||||
// inconnu…) : il se reproduirait à chaque compaction. Une fenêtre
|
||||
// pleine, un moteur en chargement ou une coupure réseau, non.
|
||||
var he *summaryHTTPError
|
||||
if errors.As(err, &he) && (he.status == 400 || he.status == 422 || he.status == 500) && !contextOverflow(he.body, nil) {
|
||||
compactContFail(true)
|
||||
}
|
||||
return "", err
|
||||
}
|
||||
if len(ch.Message.ToolCalls) > 0 {
|
||||
compactContFail(true)
|
||||
return "", errors.New("appel d'outil émis au lieu d'un résumé")
|
||||
}
|
||||
if textualToolCallSnippet(ch.Message.Content) != "" {
|
||||
compactContFail(false)
|
||||
return "", errors.New("appel d'outil écrit en texte")
|
||||
}
|
||||
s, err := cleanSummary(ch.Message.Content, ch.Message.ReasoningContent, ch.FinishReason)
|
||||
if err != nil {
|
||||
compactContFail(false)
|
||||
return "", err
|
||||
}
|
||||
if summaryLooksEmpty(s) {
|
||||
compactContFail(false)
|
||||
return "", errors.New("résumé vide")
|
||||
}
|
||||
compactContOK()
|
||||
return s, nil
|
||||
}
|
||||
@@ -0,0 +1,680 @@
|
||||
package loki
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"net/url"
|
||||
"reflect"
|
||||
"strings"
|
||||
"sync"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// oldCompactorSys : le prompt du résumeur tel qu'il était avant le partage de
|
||||
// ses règles avec la continuation — copie figée, à l'octet près.
|
||||
const oldCompactorSys = `You are a context compactor. You are given the transcript of the older turns of a conversation between a user and an AI assistant (with its tools). The PURPOSE of your summary is to let the conversation continue in a fresh, smaller context WITHOUT losing any information that is useful or important to understand what came before and keep working — preserve everything that matters, drop only what is redundant.
|
||||
|
||||
The assistant is MID-TASK: it will read your summary and must resume exactly where it left off, WITHOUT redoing work it has already done. Its own internal reasoning is NOT part of the transcript and is lost — your summary is the only memory it keeps.
|
||||
|
||||
Summarize densely and faithfully, keeping ONLY the essentials:
|
||||
- The user's CURRENT request, goal(s) and constraints
|
||||
- FINDINGS: the concrete information already gathered — facts, figures, dates, names, URLs, file paths, values, config. This is the most important part: whatever is not here is lost and will have to be looked up again.
|
||||
- Sources already consulted (URLs opened, files read, commands run) — so they are not consulted a second time
|
||||
- Decisions made and established facts
|
||||
- STATE OF PROGRESS: what is already answered, what is still missing, and the next concrete step
|
||||
Strict rules: no preamble or conclusion, no verbatim or long quotes, no throwaway detail. Use short bullet points. Be as concise as you can WHILE keeping every fact, decision and still-open task: a detail you drop here is lost for good, so when in doubt keep it. This is a dense compression summary, not a report. Always write ACTUAL prose sentences/bullets — never answer with just an id or a reference.
|
||||
Write the summary in the SAME language as the conversation.`
|
||||
|
||||
const contSummary = "- L'utilisateur prépare un voyage à Lyon ; horaires trouvés : départ 8 h 12, arrivée 10 h 05."
|
||||
|
||||
// moteurCont : faux llama-server. Tours streamés : « ok. ». Résumés :
|
||||
// contSummary ; pour la continuation, contStatus (refus) ou contReply s'ils
|
||||
// sont posés.
|
||||
type moteurCont struct {
|
||||
mu sync.Mutex
|
||||
bodies []string
|
||||
contStatus int
|
||||
contReply map[string]any
|
||||
}
|
||||
|
||||
func (m *moteurCont) start(t *testing.T) {
|
||||
t.Helper()
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
var buf bytes.Buffer
|
||||
_, _ = buf.ReadFrom(r.Body)
|
||||
body := buf.String()
|
||||
switch r.URL.Path {
|
||||
case "/health":
|
||||
sendJSON(w, 200, map[string]any{"status": "ok"})
|
||||
return
|
||||
case "/props":
|
||||
sendJSON(w, 200, map[string]any{"total_slots": 1, "build_info": "b9000-x"})
|
||||
return
|
||||
}
|
||||
m.mu.Lock()
|
||||
m.bodies = append(m.bodies, body)
|
||||
status, reply := m.contStatus, m.contReply
|
||||
m.mu.Unlock()
|
||||
if strings.Contains(body, `"stream":true`) {
|
||||
w.Header().Set("Content-Type", "text/event-stream")
|
||||
_, _ = w.Write([]byte(sseChunk("ok.") + sseFinal("stop", 100, 2) + "data: [DONE]\n\n"))
|
||||
return
|
||||
}
|
||||
if isContBody(body) {
|
||||
if status != 0 {
|
||||
sendJSON(w, status, map[string]any{"error": map[string]any{"message": "template error"}})
|
||||
return
|
||||
}
|
||||
if reply != nil {
|
||||
sendJSON(w, 200, reply)
|
||||
return
|
||||
}
|
||||
}
|
||||
sendJSON(w, 200, map[string]any{
|
||||
"choices": []any{map[string]any{"message": map[string]any{"content": contSummary}, "finish_reason": "stop"}},
|
||||
"usage": map[string]any{"prompt_tokens": 900, "completion_tokens": 40},
|
||||
"timings": map[string]any{"prompt_n": 30, "cache_n": 870, "predicted_n": 40},
|
||||
})
|
||||
}))
|
||||
t.Cleanup(srv.Close)
|
||||
u, _ := url.Parse(srv.URL)
|
||||
if err := SetConfigKey("PORT", u.Port()); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
|
||||
func isContBody(body string) bool {
|
||||
var p struct {
|
||||
Messages []Message `json:"messages"`
|
||||
}
|
||||
_ = json.Unmarshal([]byte(body), &p)
|
||||
n := len(p.Messages)
|
||||
return n > 0 && strings.HasPrefix(msgText(p.Messages[n-1]), compactContPrefix)
|
||||
}
|
||||
|
||||
func isTranscriptBody(body string) bool {
|
||||
var p struct {
|
||||
Messages []Message `json:"messages"`
|
||||
}
|
||||
_ = json.Unmarshal([]byte(body), &p)
|
||||
return len(p.Messages) == 2 && p.Messages[0].Role == "system" &&
|
||||
strings.HasPrefix(msgText(p.Messages[0]), "You are a context compactor.")
|
||||
}
|
||||
|
||||
func (m *moteurCont) all() []string {
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
return append([]string(nil), m.bodies...)
|
||||
}
|
||||
|
||||
// contReset : état global de la continuation remis à neuf.
|
||||
func contReset(t *testing.T) {
|
||||
reset := func() {
|
||||
engineGate.mu.Lock()
|
||||
engineGate.main = engineMainStamp{}
|
||||
engineGate.mu.Unlock()
|
||||
compactContFails.mu.Lock()
|
||||
compactContFails.m = nil
|
||||
compactContFails.mu.Unlock()
|
||||
compactRefused.mu.Lock()
|
||||
compactRefused.m = nil
|
||||
compactRefused.mu.Unlock()
|
||||
}
|
||||
reset()
|
||||
t.Cleanup(reset)
|
||||
}
|
||||
|
||||
func contSetup(t *testing.T, keys map[string]string) *moteurCont {
|
||||
t.Helper()
|
||||
withWorkspace(t)
|
||||
activeProjMu.Lock()
|
||||
activeProjCache = ""
|
||||
activeProjMu.Unlock()
|
||||
setProjectOverride("")
|
||||
t.Cleanup(func() {
|
||||
activeProjMu.Lock()
|
||||
activeProjCache = ""
|
||||
activeProjMu.Unlock()
|
||||
setProjectOverride("")
|
||||
})
|
||||
ensureDefaultProject()
|
||||
freshTplProbe(t)
|
||||
convEnsureActive()
|
||||
contReset(t)
|
||||
if err := SetConfigKey("MODEL", "/models/"+echoTestModel); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := SetConfigKey("CTX", "8192"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for k, v := range keys {
|
||||
if err := SetConfigKey(k, v); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
m := &moteurCont{}
|
||||
m.start(t)
|
||||
return m
|
||||
}
|
||||
|
||||
// contHistory : une longue discussion, de quoi compacter.
|
||||
func contHistory() []Message {
|
||||
var h []Message
|
||||
for i := 0; i < 10; i++ {
|
||||
h = append(h, um("question "+string(rune('a'+i))+" "+strings.Repeat("u", 1500)),
|
||||
am("réponse "+string(rune('a'+i))+" "+strings.Repeat("r", 1500)))
|
||||
}
|
||||
return h
|
||||
}
|
||||
|
||||
// contTwoTurns : un premier tour sans compaction (il pose le tampon du slot),
|
||||
// puis un second dont le début compacte. Rend les corps envoyés pendant le
|
||||
// second tour, et celui du premier tour.
|
||||
func contTwoTurns(t *testing.T, m *moteurCont, c *Conversation, caps Caps) (first string, second []string) {
|
||||
t.Helper()
|
||||
c.mu.Lock()
|
||||
c.Messages = contHistory()
|
||||
c.CtxUsed, c.ctxUsedLen = 1000, len(c.Messages)
|
||||
c.mu.Unlock()
|
||||
prewarmTurn(t, c, caps, "première demande")
|
||||
bodies := m.all()
|
||||
if len(bodies) != 1 {
|
||||
t.Fatalf("premier tour : 1 requête attendue, %d", len(bodies))
|
||||
}
|
||||
c.mu.Lock()
|
||||
c.CtxUsed, c.ctxUsedLen = 6200, len(c.Messages)
|
||||
c.mu.Unlock()
|
||||
prewarmTurn(t, c, caps, "deuxième demande")
|
||||
return bodies[0], m.all()[1:]
|
||||
}
|
||||
|
||||
// Sans la clé : la requête de résumé est celle d'avant — prompt du résumeur
|
||||
// identique à l'octet près, transcription, aucun champ de plus — et aucun
|
||||
// tampon de slot n'est posé.
|
||||
func TestCompactContDefautIdentique(t *testing.T) {
|
||||
m := contSetup(t, map[string]string{"TOP_P": "0.9", "REASONING_EFFORT": "high"})
|
||||
c := newTestConv()
|
||||
_, second := contTwoTurns(t, m, c, Caps{Agent: true})
|
||||
if len(second) != 2 || !isTranscriptBody(second[0]) {
|
||||
t.Fatalf("sans la clé : résumé sur transcription puis tour attendus, %d requêtes", len(second))
|
||||
}
|
||||
var p map[string]any
|
||||
if err := json.Unmarshal([]byte(second[0]), &p); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
want := map[string]any{"model": p["model"], "stream": false, "temperature": 0.2, "max_tokens": float64(700),
|
||||
"chat_template_kwargs": map[string]any{"enable_thinking": false}, "messages": p["messages"]}
|
||||
if !reflect.DeepEqual(p, want) {
|
||||
t.Fatalf("champs du résumé changés : %v", p)
|
||||
}
|
||||
if sys := p["messages"].([]any)[0].(map[string]any)["content"]; sys != oldCompactorSys {
|
||||
t.Fatalf("prompt du résumeur changé :\n%v", sys)
|
||||
}
|
||||
engineGate.mu.Lock()
|
||||
stamp := engineGate.main
|
||||
engineGate.mu.Unlock()
|
||||
if stamp.seq != 0 {
|
||||
t.Fatal("tampon de slot posé sans la clé")
|
||||
}
|
||||
if compactContinuationOn(ReadConfig()) || compactRefusedSkip(convActiveID(), 7000, nil) {
|
||||
t.Fatal("continuation ou mémoire des refus active sans la clé")
|
||||
}
|
||||
}
|
||||
|
||||
// Avec la clé : la requête de résumé prolonge EXACTEMENT celle du premier tour
|
||||
// (même préfixe, mêmes outils et réglages du gabarit), plus la réponse rangée
|
||||
// et une seule demande de résumé ; sans l'échantillonnage du preset ; et rien
|
||||
// d'injecté n'entre dans l'historique.
|
||||
func TestCompactContPrefixeEtHistorique(t *testing.T) {
|
||||
m := contSetup(t, map[string]string{"COMPACT_CONTINUATION": "on", "TOP_P": "0.9", "REASONING_EFFORT": "high"})
|
||||
c := newTestConv()
|
||||
first, second := contTwoTurns(t, m, c, Caps{Agent: true})
|
||||
if len(second) != 2 || !isContBody(second[0]) {
|
||||
t.Fatalf("continuation puis tour attendus, %d requêtes (transcription : %v)", len(second), len(second) > 0 && isTranscriptBody(second[0]))
|
||||
}
|
||||
var f, s map[string]any
|
||||
if err := json.Unmarshal([]byte(first), &f); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := json.Unmarshal([]byte(second[0]), &s); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
fm, sm := f["messages"].([]any), s["messages"].([]any)
|
||||
if len(sm) != len(fm)+2 {
|
||||
t.Fatalf("continuation : %d messages, premier tour %d (+ réponse + demande attendus)", len(sm), len(fm))
|
||||
}
|
||||
if !reflect.DeepEqual(sm[:len(fm)], fm) {
|
||||
t.Fatal("la continuation ne prolonge pas la requête du premier tour")
|
||||
}
|
||||
if a := sm[len(fm)].(map[string]any); a["role"] != "assistant" || a["content"] != "ok." {
|
||||
t.Fatalf("réponse rangée attendue après le préfixe : %v", a)
|
||||
}
|
||||
last := sm[len(sm)-1].(map[string]any)
|
||||
if last["role"] != "user" || !strings.Contains(last["content"].(string), "STATE OF PROGRESS") {
|
||||
t.Fatalf("demande de résumé : %v", last)
|
||||
}
|
||||
for _, k := range []string{"tools", "parallel_tool_calls", "reasoning_effort", "model"} {
|
||||
if !reflect.DeepEqual(f[k], s[k]) {
|
||||
t.Fatalf("champ %s : tour %v, continuation %v", k, f[k], s[k])
|
||||
}
|
||||
}
|
||||
if s["tool_choice"] != "none" || s["stream"] != false || s["max_tokens"] != float64(700) ||
|
||||
s["temperature"] != 0.2 || s["top_p"] != nil || s["stream_options"] != nil {
|
||||
t.Fatalf("champs de réponse : %v", s)
|
||||
}
|
||||
if f["top_p"] != 0.9 {
|
||||
t.Fatal("le tour lui-même doit garder l'échantillonnage du preset")
|
||||
}
|
||||
kw, _ := s["chat_template_kwargs"].(map[string]any)
|
||||
if kw["enable_thinking"] != false || kw["reasoning_effort"] != "high" {
|
||||
t.Fatalf("arguments du gabarit : %v", kw)
|
||||
}
|
||||
c.mu.Lock()
|
||||
defer c.mu.Unlock()
|
||||
found := false
|
||||
for _, msg := range c.Messages {
|
||||
txt := msgText(msg)
|
||||
if msg.Role == "system" || strings.HasPrefix(txt, compactContPrefix) || strings.Contains(txt, "<project_context>") {
|
||||
t.Fatalf("message injecté rangé dans l'historique : %s %.80q", msg.Role, txt)
|
||||
}
|
||||
if strings.Contains(txt, contSummary) {
|
||||
found = true
|
||||
}
|
||||
}
|
||||
if !found {
|
||||
t.Fatal("résumé absent de l'historique compacté")
|
||||
}
|
||||
kinds := false
|
||||
for _, r := range perfLog.snapshot() {
|
||||
if r.Kind == perfCompact && r.Conv == convActiveID() {
|
||||
kinds = true
|
||||
}
|
||||
}
|
||||
if !kinds {
|
||||
t.Fatal("aucune complétion kind=compact rattachée à la discussion")
|
||||
}
|
||||
}
|
||||
|
||||
// contViewOpts : options en cours de tour pour msgs, slot tamponné pour conv.
|
||||
func contViewOpts(t *testing.T, msgs []Message, used int) compactOpts {
|
||||
t.Helper()
|
||||
end, seq := engineRequestBegin(nil)
|
||||
end()
|
||||
engineMarkMain(seq, "conv-test")
|
||||
return compactOptsMid(ReadConfig(), resolveChatEndpoint(), "conv-test", msgs, used, nil, "", nil, echoPolicy{}, false)
|
||||
}
|
||||
|
||||
func contMidMsgs() []Message {
|
||||
return append([]Message{{Role: "system", Content: "SYS"}}, contHistory()...)
|
||||
}
|
||||
|
||||
// Les replis : refus du moteur (gabarit), appel d'outil émis, marge
|
||||
// insuffisante, slot pris entre-temps — la compaction passe par la
|
||||
// transcription et réussit quand même. Un refus du gabarit ou un appel
|
||||
// d'outil suspend la continuation pour ce modèle.
|
||||
func TestCompactContReplis(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
setup func(m *moteurCont)
|
||||
used int
|
||||
stale bool
|
||||
wantCont bool
|
||||
disabled bool
|
||||
}{
|
||||
{"gabarit 500", func(m *moteurCont) { m.contStatus = 500 }, 5000, false, true, true},
|
||||
{"appel d'outil", func(m *moteurCont) {
|
||||
m.contReply = map[string]any{"choices": []any{map[string]any{"message": map[string]any{
|
||||
"content": "", "tool_calls": []any{map[string]any{"id": "x", "type": "function"}}}, "finish_reason": "tool_calls"}}}
|
||||
}, 5000, false, true, true},
|
||||
{"raisonnement seul", func(m *moteurCont) {
|
||||
m.contReply = map[string]any{"choices": []any{map[string]any{"message": map[string]any{
|
||||
"content": "", "reasoning_content": "je réfléchis"}, "finish_reason": "length"}}}
|
||||
}, 5000, false, true, false},
|
||||
{"marge", func(*moteurCont) {}, 7500, false, false, false},
|
||||
{"slot pris", func(*moteurCont) {}, 5000, true, false, false},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
m := contSetup(t, map[string]string{"COMPACT_CONTINUATION": "on"})
|
||||
tc.setup(m)
|
||||
msgs := contMidMsgs()
|
||||
opt := contViewOpts(t, msgs, tc.used)
|
||||
if opt.view == nil {
|
||||
t.Fatal("vue attendue")
|
||||
}
|
||||
if tc.stale {
|
||||
engineRequestStart()()
|
||||
}
|
||||
out, changed := compactMessagesNoted(context.Background(), msgs, Caps{}, opt)
|
||||
if !changed {
|
||||
t.Fatal("compaction refusée")
|
||||
}
|
||||
if out[0].Role != "system" || msgText(out[0]) != "SYS" || !strings.Contains(msgText(out[1]), contSummary) {
|
||||
t.Fatalf("sortie inattendue : %v / %.80q", out[0], msgText(out[1]))
|
||||
}
|
||||
bodies := m.all()
|
||||
cont := len(bodies) == 2 && isContBody(bodies[0]) && isTranscriptBody(bodies[1])
|
||||
direct := len(bodies) == 1 && isTranscriptBody(bodies[0])
|
||||
if tc.wantCont && !cont || !tc.wantCont && !direct {
|
||||
t.Fatalf("requêtes inattendues (%d)", len(bodies))
|
||||
}
|
||||
if compactContDisabled() != tc.disabled {
|
||||
t.Fatalf("suspension pour le modèle : %v, attendu %v", compactContDisabled(), tc.disabled)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// Deux résumés ratés de suite suspendent la continuation ; un réussi remet le
|
||||
// compte à zéro.
|
||||
func TestCompactContSuspension(t *testing.T) {
|
||||
contSetup(t, map[string]string{"COMPACT_CONTINUATION": "on"})
|
||||
compactContFail(false)
|
||||
if compactContDisabled() {
|
||||
t.Fatal("suspendue après un seul échec")
|
||||
}
|
||||
compactContOK()
|
||||
compactContFail(false)
|
||||
if compactContDisabled() {
|
||||
t.Fatal("le succès n'a pas remis le compte à zéro")
|
||||
}
|
||||
compactContFail(false)
|
||||
if !compactContDisabled() {
|
||||
t.Fatal("pas suspendue après deux échecs")
|
||||
}
|
||||
if o := contViewOpts(t, contMidMsgs(), 5000); o.view != nil || !o.precheck {
|
||||
t.Fatal("vue construite pour un modèle suspendu")
|
||||
}
|
||||
}
|
||||
|
||||
// Le tampon du slot : posé par une étape d'un tour de discussion, perdu dès
|
||||
// qu'une autre requête part (résumé, vérification, préchauffage…), ou que le
|
||||
// modèle ou la fenêtre changent.
|
||||
func TestEngineSlotHolds(t *testing.T) {
|
||||
testHome(t)
|
||||
contReset(t)
|
||||
if err := SetConfigKey("MODEL", "/models/a.gguf"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
mark := func() {
|
||||
end, seq := engineRequestBegin(nil)
|
||||
end()
|
||||
engineMarkMain(seq, "c1")
|
||||
}
|
||||
mark()
|
||||
if !engineSlotHolds("c1") || engineSlotHolds("c2") || engineSlotHolds("") {
|
||||
t.Fatal("tampon mal lu")
|
||||
}
|
||||
engineRequestStart()()
|
||||
if engineSlotHolds("c1") {
|
||||
t.Fatal("tampon gardé après une autre requête")
|
||||
}
|
||||
// Une requête partie entre le départ et l'acceptation : pas de tampon.
|
||||
end, seq := engineRequestBegin(nil)
|
||||
end()
|
||||
engineRequestStart()()
|
||||
engineMarkMain(seq, "c1")
|
||||
if engineSlotHolds("c1") {
|
||||
t.Fatal("tampon posé pour une requête dépassée")
|
||||
}
|
||||
mark()
|
||||
p := &prewarmRun{cancel: func() {}}
|
||||
if endP, ok := prewarmBegin(p); ok {
|
||||
endP()
|
||||
}
|
||||
if engineSlotHolds("c1") {
|
||||
t.Fatal("tampon gardé après un préchauffage")
|
||||
}
|
||||
mark()
|
||||
if err := SetConfigKey("MODEL", "/models/b.gguf"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if engineSlotHolds("c1") {
|
||||
t.Fatal("tampon gardé après un changement de modèle")
|
||||
}
|
||||
}
|
||||
|
||||
// Une étape d'un sous-agent ou d'une vérification ne pose pas de tampon :
|
||||
// après elle, la compaction de fin de tour passe par la transcription.
|
||||
func TestCompactContPasApresVerification(t *testing.T) {
|
||||
contSetup(t, map[string]string{"COMPACT_CONTINUATION": "on"})
|
||||
end, seq := engineRequestBegin(nil)
|
||||
end()
|
||||
engineMarkMain(seq, convActiveID())
|
||||
ctx := withPerf(context.Background(), perfMain, convActiveID())
|
||||
if _, err := runChat(withPerfKind(ctx, perfVerify), []Message{um("vérifie")}, 0.2, Caps{}, func(StreamEvent) bool { return true }); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if engineSlotHolds(convActiveID()) {
|
||||
t.Fatal("tampon gardé après une vérification")
|
||||
}
|
||||
if _, err := runChat(ctx, []Message{um("suite")}, 0.2, Caps{}, func(StreamEvent) bool { return true }); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !engineSlotHolds(convActiveID()) {
|
||||
t.Fatal("une étape du tour devrait poser le tampon")
|
||||
}
|
||||
}
|
||||
|
||||
// La correspondance des deux vues : messages système ajoutés ou déplacés,
|
||||
// les autres un pour un ; tout écart rend -1.
|
||||
func TestCompactWireIndex(t *testing.T) {
|
||||
msgs := []Message{
|
||||
{Role: "system", Content: memReminderPrefix + " pages"},
|
||||
um("a"), atc("bash"), tm("r"), am("b"), um("c"),
|
||||
}
|
||||
wire := normalizeSystemMessages(append([]Message{{Role: "system", Content: "SYS"}, {Role: "system", Content: projectContextPrefix + " p"}}, msgs[:5]...))
|
||||
v := &compactView{wire: wire, n: 5}
|
||||
if wire[0].Role != "system" || len(wire) != 5 {
|
||||
t.Fatalf("vue d'envoi : %v", wire)
|
||||
}
|
||||
for i, want := range map[int]int{1: 1, 2: 2, 3: 3, 4: 4, 5: 5, 0: -1, 6: -1} {
|
||||
if got := compactWireIndex(msgs, v, i); got != want {
|
||||
t.Errorf("index %d : %d, attendu %d", i, got, want)
|
||||
}
|
||||
}
|
||||
bad := append([]Message(nil), wire...)
|
||||
bad[3] = am("x")
|
||||
if compactWireIndex(msgs, &compactView{wire: bad, n: 5}, 2) != -1 {
|
||||
t.Fatal("vue désalignée acceptée")
|
||||
}
|
||||
if compactWireIndex(msgs, &compactView{wire: wire[:4], n: 5}, 2) != -1 {
|
||||
t.Fatal("vue tronquée acceptée")
|
||||
}
|
||||
}
|
||||
|
||||
// La frontière désignée : appel d'outil par son nom et ses arguments, texte
|
||||
// par son début, allongé s'il est déjà apparu plus haut.
|
||||
func TestCompactBoundaryDesc(t *testing.T) {
|
||||
call := Message{Role: "assistant", ToolCalls: []ToolCall{{ID: "c1", Function: ToolCallFunc{Name: "web_read", Arguments: `{"url":"https://exemple.fr"}`}}}}
|
||||
if d := compactBoundaryDesc(nil, 0, call); !strings.Contains(d, `web_read({"url":"https://exemple.fr"})`) {
|
||||
t.Fatalf("appel d'outil : %s", d)
|
||||
}
|
||||
long := strings.Repeat("même début ", 10) + "fin unique"
|
||||
wire := []Message{um(strings.Repeat("même début ", 10) + "autre fin"), um(long)}
|
||||
d := compactBoundaryDesc(wire, 1, wire[1])
|
||||
if !strings.Contains(d, "fin unique") {
|
||||
t.Fatalf("début ambigu non allongé : %s", d)
|
||||
}
|
||||
if d := compactBoundaryDesc(nil, 0, um("court")); d != "the user message that starts with «court»" {
|
||||
t.Fatalf("texte : %s", d)
|
||||
}
|
||||
}
|
||||
|
||||
// La demande de résumé reprend les règles du résumeur, et celles du mode Code.
|
||||
func TestCompactContInstruction(t *testing.T) {
|
||||
s := compactContInstruction(3, "the user message that starts with «x»", true)
|
||||
for _, want := range []string{compactContPrefix, compactSummaryRules, codeSummaryRules, "the last 3", "BEFORE the user message"} {
|
||||
if !strings.Contains(s, want) {
|
||||
t.Fatalf("demande sans %q", want)
|
||||
}
|
||||
}
|
||||
if s := compactContInstruction(0, "", false); !strings.Contains(s, "whole conversation") || strings.Contains(s, "CODING") {
|
||||
t.Fatal("demande sans frontière")
|
||||
}
|
||||
}
|
||||
|
||||
// Pré-contrôle : quand même un résumé vide ne réduirait pas de 20 %, aucun
|
||||
// résumé n'est demandé ni aucun bloc archivé ; sans lui (sans la clé), la
|
||||
// requête part comme avant, pour être refusée ensuite.
|
||||
func TestCompactHopeless(t *testing.T) {
|
||||
m := contSetup(t, nil)
|
||||
// Torse minuscule, queue énorme : rien à gagner.
|
||||
msgs := []Message{um("a"), am("b"), um("c"), am(strings.Repeat("r", 40000))}
|
||||
head, tailStart := compactBounds(msgs, int(float64(estimateTokens(msgs))*compactTailFrac))
|
||||
if !compactHopeless(msgs, head, tailStart) {
|
||||
t.Fatal("cas sans issue non reconnu")
|
||||
}
|
||||
if _, changed, _ := compactMessagesOpt(context.Background(), msgs, Caps{Agent: true}, compactOpts{precheck: true}); changed {
|
||||
t.Fatal("compaction acceptée")
|
||||
}
|
||||
if n := len(m.all()); n != 0 {
|
||||
t.Fatalf("résumé demandé malgré le pré-contrôle (%d requêtes)", n)
|
||||
}
|
||||
_, changed, refused := compactMessagesOpt(context.Background(), msgs, Caps{Agent: true}, compactOpts{})
|
||||
if changed || !refused || len(m.all()) != 1 {
|
||||
t.Fatalf("sans pré-contrôle : changed=%v refused=%v requêtes=%d", changed, refused, len(m.all()))
|
||||
}
|
||||
// Un refus après résumé est noté ; il n'arrête ni le filet réactif ni le
|
||||
// bouton manuel (compactMessages, sans options), qui redemandent un résumé.
|
||||
if _, changed := compactMessagesNoted(context.Background(), msgs, Caps{}, compactOpts{conv: "c9", used: 7000}); changed {
|
||||
t.Fatal("compaction acceptée")
|
||||
}
|
||||
compactRefused.mu.Lock()
|
||||
_, noted := compactRefused.m["c9"]
|
||||
compactRefused.mu.Unlock()
|
||||
if !noted {
|
||||
t.Fatal("refus non noté")
|
||||
}
|
||||
if err := SetConfigKey("COMPACT_CONTINUATION", "on"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
before := len(m.all())
|
||||
compactMessages(context.Background(), msgs, Caps{})
|
||||
if len(m.all()) != before+1 {
|
||||
t.Fatal("le chemin réactif ne doit pas tenir compte des refus mémorisés")
|
||||
}
|
||||
// Un cas qui réussit n'est jamais déclaré sans issue.
|
||||
h := contHistory()
|
||||
head, tailStart = compactBounds(h, int(float64(estimateTokens(h))*compactTailFrac))
|
||||
if compactHopeless(h, head, tailStart) {
|
||||
t.Fatal("compaction faisable déclarée sans issue")
|
||||
}
|
||||
}
|
||||
|
||||
// Mémoire des refus : pas de nouvel essai avant +10 % de contexte, jamais à
|
||||
// 90 % de la fenêtre, oubliée quand le contexte baisse ou que l'historique est
|
||||
// réécrit ; rien sans la clé.
|
||||
func TestCompactRefusedSkip(t *testing.T) {
|
||||
testHome(t)
|
||||
contReset(t)
|
||||
if err := SetConfigKey("CTX", "65536"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
h := contHistory()
|
||||
grown := append(append([]Message(nil), h...), um("suite"))
|
||||
compactRefusedNote("c1", 50000, h)
|
||||
if compactRefusedSkip("c1", 52000, grown) {
|
||||
t.Fatal("sans la clé, aucun essai ne doit être sauté")
|
||||
}
|
||||
if err := SetConfigKey("COMPACT_CONTINUATION", "on"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !compactRefusedSkip("c1", 52000, grown) || compactRefusedSkip("c2", 52000, grown) {
|
||||
t.Fatal("refus mal appliqué")
|
||||
}
|
||||
if compactRefusedSkip("c1", 59000, grown) {
|
||||
t.Fatal("essai sauté à 90 % de la fenêtre")
|
||||
}
|
||||
if compactRefusedSkip("c1", 55000, grown) {
|
||||
t.Fatal("essai sauté après +10 %")
|
||||
}
|
||||
if compactRefusedSkip("c1", 52000, grown) {
|
||||
t.Fatal("refus non oublié après +10 %")
|
||||
}
|
||||
compactRefusedNote("c1", 50000, h)
|
||||
if compactRefusedSkip("c1", 30000, grown) || compactRefusedSkip("c1", 50000, grown) {
|
||||
t.Fatal("refus non oublié quand le contexte baisse")
|
||||
}
|
||||
// Historique réécrit (édition, régénération, discussion vidée puis
|
||||
// regarnie) : même taille de contexte, autre fil — on retente.
|
||||
compactRefusedNote("c1", 50000, h)
|
||||
edited := append([]Message(nil), grown...)
|
||||
edited[3] = um("message modifié")
|
||||
if compactRefusedSkip("c1", 51000, edited) || compactRefusedSkip("c1", 51000, grown) {
|
||||
t.Fatal("refus gardé pour un historique réécrit")
|
||||
}
|
||||
compactRefusedNote("c1", 50000, h)
|
||||
if compactRefusedSkip("c1", 51000, h[:5]) {
|
||||
t.Fatal("refus gardé pour un historique raccourci")
|
||||
}
|
||||
// Une compaction réussie l'efface.
|
||||
compactRefusedNote("c1", 50000, h)
|
||||
compactRefusedClear("c1")
|
||||
if compactRefusedSkip("c1", 51000, grown) {
|
||||
t.Fatal("refus non effacé")
|
||||
}
|
||||
}
|
||||
|
||||
// buildChatPayload : sans noSampling, l'échantillonnage du preset est posé
|
||||
// comme avant ; avec, il ne l'est pas.
|
||||
func TestBuildChatPayloadNoSampling(t *testing.T) {
|
||||
testHome(t)
|
||||
if err := SetConfigKey("TOP_P", "0.9"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
ep := chatEndpoint{Model: "m"}
|
||||
if p := buildChatPayload(ep, []Message{um("x")}, 0.7, chatPayloadOpts{}); p["top_p"] != 0.9 {
|
||||
t.Fatalf("échantillonnage perdu : %v", p)
|
||||
}
|
||||
if p := buildChatPayload(ep, []Message{um("x")}, 0.2, chatPayloadOpts{noSampling: true}); p["top_p"] != nil || p["temperature"] != 0.2 {
|
||||
t.Fatalf("échantillonnage posé malgré noSampling : %v", p)
|
||||
}
|
||||
}
|
||||
|
||||
// Corrections de relecture du lot 2 : une lecture passée par les proxys de
|
||||
// Loki (GET /v1/models, /health d'un client qui sonde) ne calcule rien dans un
|
||||
// slot — elle ne retire pas le tampon de la discussion et n'annule pas un
|
||||
// préchauffage. Une complétion de client, si.
|
||||
func TestProxyReadKeepsSlotStamp(t *testing.T) {
|
||||
testHome(t)
|
||||
contReset(t)
|
||||
if err := SetConfigKey("MODEL", "/models/a.gguf"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
end, seq := engineRequestBegin(nil)
|
||||
end()
|
||||
engineMarkMain(seq, "c1")
|
||||
cancelled := false
|
||||
p := &prewarmRun{cancel: func() { cancelled = true }}
|
||||
endP, ok := prewarmBegin(p)
|
||||
if !ok {
|
||||
t.Fatal("préchauffage refusé, moteur libre")
|
||||
}
|
||||
defer endP()
|
||||
// Le préchauffage retire lui-même le tampon : reposé à la main, pour voir
|
||||
// ce que fait la lecture seule.
|
||||
engineGate.mu.Lock()
|
||||
engineGate.main.seq = engineGate.seq
|
||||
engineGate.mu.Unlock()
|
||||
for _, m := range []string{http.MethodGet, http.MethodHead} {
|
||||
r := httptest.NewRequest(m, "/v1/models", nil)
|
||||
engineProxyBegin(r, false)()
|
||||
}
|
||||
if cancelled {
|
||||
t.Error("une lecture a annulé le préchauffage")
|
||||
}
|
||||
if !engineSlotHolds("c1") {
|
||||
t.Error("une lecture a retiré le tampon de la discussion")
|
||||
}
|
||||
engineProxyBegin(httptest.NewRequest(http.MethodPost, "/v1/chat/completions", nil), false)()
|
||||
if !cancelled {
|
||||
t.Error("une complétion de client n'a pas annulé le préchauffage")
|
||||
}
|
||||
if engineSlotHolds("c1") {
|
||||
t.Error("tampon gardé après une complétion de client")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
package loki
|
||||
|
||||
import "testing"
|
||||
|
||||
// Après une compaction en cours de tour qui n'a rien réduit, l'étape suivante
|
||||
// ne relance pas un résumé tant que le contexte n'a pas grossi de 5 % de la
|
||||
// fenêtre — sauf à 90 % de la fenêtre ou plus.
|
||||
func TestCompactRetryBackoff(t *testing.T) {
|
||||
testHome(t)
|
||||
if err := putStr(bkConfig, "CTX", "100000"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !compactRetryDue(0, 70000, nil) {
|
||||
t.Fatal("sans échec précédent, l'essai doit être permis")
|
||||
}
|
||||
next := compactNextRetry(false, 70000, nil)
|
||||
if next != 75000 {
|
||||
t.Fatalf("prochain essai à %d, attendu 75000", next)
|
||||
}
|
||||
if compactRetryDue(next, 72000, nil) {
|
||||
t.Fatal("relancé alors que le contexte n'a presque pas grossi")
|
||||
}
|
||||
if !compactRetryDue(next, 75000, nil) {
|
||||
t.Fatal("pas relancé alors que le contexte a grossi de 5 % de la fenêtre")
|
||||
}
|
||||
if !compactRetryDue(200000, 90000, nil) {
|
||||
t.Fatal("à 90 % de la fenêtre, l'essai doit toujours être permis")
|
||||
}
|
||||
if compactNextRetry(true, 70000, nil) != 0 {
|
||||
t.Fatal("une compaction réussie doit lever le garde-fou")
|
||||
}
|
||||
}
|
||||
@@ -67,6 +67,10 @@ type Conversation struct {
|
||||
// persisté : un rechargement repart du seul CtxUsed, comme avant.
|
||||
ctxUsedLen int
|
||||
genPeak int
|
||||
// ProjSnap : bloc projet figé de la discussion et dernier état annoncé au
|
||||
// modèle (PROJ_SNAPSHOT, chat_projsnap.go). nil = pas d'instantané : le
|
||||
// prochain tour en prend un. Toujours nil sans la clé (JSON inchangé).
|
||||
ProjSnap *projSnapshot `json:"proj_snap,omitempty"`
|
||||
|
||||
Generating bool `json:"-"`
|
||||
cancel context.CancelFunc // annule la génération en cours (/stop)
|
||||
@@ -80,6 +84,12 @@ type Conversation struct {
|
||||
// benching : le verrou est tenu par un benchmark (llm_bench_job.go). Même
|
||||
// logique que la tâche : l'interface et les refus disent qui occupe le moteur.
|
||||
benching bool
|
||||
// leaseWhy : le refus à donner tant que benching tient le verrou —
|
||||
// errBenchBusy pour un bench, errTuneBusy pour l'optimiseur, qui prend le
|
||||
// même verrou (backend_tune_job.go).
|
||||
leaseWhy error
|
||||
// extLease : le verrou est tenu par la façade Rust (web_internal_lease.go).
|
||||
extLease *extLeaseState
|
||||
|
||||
// File d'attente des messages envoyés PENDANT une génération (AJEAN
|
||||
// 0.14.0). Ils sont injectés dans le tour en cours à la prochaine frontière
|
||||
@@ -182,6 +192,8 @@ func LoadConversation() {
|
||||
}
|
||||
conv.mu.Lock()
|
||||
defer conv.mu.Unlock()
|
||||
// Unmarshal ne remet pas à zéro un champ absent du JSON.
|
||||
conv.ProjSnap = nil
|
||||
_ = json.Unmarshal(b, conv)
|
||||
// Guérit les conversations d'avant le passage des captures en éphémère : un
|
||||
// base64 d'image persisté était rejoué à chaque tour et dépassait le contexte.
|
||||
@@ -190,6 +202,7 @@ func LoadConversation() {
|
||||
// Une génération n'a pas pu survivre à l'arrêt du process : on repart propre.
|
||||
conv.Generating = false
|
||||
conv.cancel = nil
|
||||
conv.extLeaseClearLocked()
|
||||
}
|
||||
|
||||
// loadFrom remplace l'état en mémoire par une autre discussion (b vide = fil
|
||||
@@ -212,6 +225,10 @@ func (c *Conversation) loadFrom(id string, b []byte) {
|
||||
}
|
||||
c.Messages, c.Log, c.Seq, c.CtxUsed = nil, nil, 0, 0
|
||||
c.ctxUsedLen, c.genPeak = 0, 0
|
||||
// Bloc figé de l'ancienne discussion : json.Unmarshal ne remet pas à zéro un
|
||||
// champ absent, une discussion neuve (ou enregistrée sans lui) l'aurait hérité
|
||||
// — avec la description et l'index d'un autre projet.
|
||||
c.ProjSnap = nil
|
||||
c.queued = nil // file de l'ancienne discussion : elle ne suit pas la bascule
|
||||
if len(b) > 0 {
|
||||
_ = json.Unmarshal(b, c)
|
||||
@@ -221,6 +238,7 @@ func (c *Conversation) loadFrom(id string, b []byte) {
|
||||
if !c.benching {
|
||||
c.Generating = false
|
||||
c.cancel = nil
|
||||
c.extLeaseClearLocked() // le tour de la façade est rompu, comme un tour Go
|
||||
}
|
||||
c.epoch++
|
||||
c.cond.Broadcast()
|
||||
@@ -340,6 +358,17 @@ func compactLog(log []LogEvent) []LogEvent {
|
||||
if tu, ok := ev.Delta["tool_used"].(map[string]any); ok {
|
||||
flush()
|
||||
if done, _ := tu["done"].(bool); done {
|
||||
// L'aperçu du navigateur (shot) n'est que du direct : on ne le
|
||||
// garde pas une fois le tour fini (voir cu_autoshot.go).
|
||||
if _, has := tu["shot"]; has {
|
||||
cp := make(map[string]any, len(tu))
|
||||
for k, v := range tu {
|
||||
if k != "shot" {
|
||||
cp[k] = v
|
||||
}
|
||||
}
|
||||
ev.Delta = map[string]any{"tool_used": cp}
|
||||
}
|
||||
out = append(out, ev)
|
||||
}
|
||||
continue
|
||||
@@ -379,9 +408,12 @@ func compactLog(log []LogEvent) []LogEvent {
|
||||
// messages donne ce que l'estimation ne voit pas (prompt système injecté,
|
||||
// schémas d'outils, gabarit de chat). On le rajoute à l'estimation d'après
|
||||
// compaction, sinon la jauge s'effondre puis resaute au tour suivant.
|
||||
func (c *Conversation) compactAndPublish(ctx context.Context, epoch int, phase string, msgs []Message, ctxUsed int, caps Caps) ([]Message, bool) {
|
||||
//
|
||||
// opt : options de COMPACT_CONTINUATION (chat_compact_cont.go) ; nulles, le
|
||||
// chemin d'avant.
|
||||
func (c *Conversation) compactAndPublish(ctx context.Context, epoch int, phase string, msgs []Message, ctxUsed int, caps Caps, opt compactOpts) ([]Message, bool) {
|
||||
c.appendDelta(epoch, map[string]any{"compacting": true})
|
||||
compacted, changed := compactMessages(ctx, msgs, caps)
|
||||
compacted, changed := compactMessagesNoted(ctx, msgs, caps, opt)
|
||||
c.appendDelta(epoch, map[string]any{"compacting": false})
|
||||
logCompact(phase, ctxUsed, msgs, compacted, changed)
|
||||
if !changed {
|
||||
@@ -397,6 +429,12 @@ func (c *Conversation) compactAndPublish(ctx context.Context, epoch int, phase s
|
||||
if caps.Agent {
|
||||
compacted = remindReadMemPages(compacted, msgs)
|
||||
}
|
||||
// PROJ_SNAPSHOT : le début du prompt est réécrit, le bloc projet sera donc
|
||||
// repris tout neuf au prochain tour (ProjSnap = nil). Les mises à jour déjà
|
||||
// livrées le suivraient dans la queue gardée telle quelle, une vieille valeur
|
||||
// après la fraîche : retirées ici, elles n'apprennent rien que le bloc neuf
|
||||
// ne dise. Sans la clé, rien à retirer.
|
||||
compacted = stripContextUpdates(compacted)
|
||||
overhead := ctxUsed - estimateTokens(msgs)
|
||||
if overhead < 0 {
|
||||
overhead = 0
|
||||
@@ -405,6 +443,7 @@ func (c *Conversation) compactAndPublish(ctx context.Context, epoch int, phase s
|
||||
c.mu.Lock()
|
||||
if c.epoch == epoch {
|
||||
c.Messages = compacted
|
||||
c.ProjSnap = nil
|
||||
c.CtxUsed = est // le vrai compte reviendra avec les stats du prochain tour
|
||||
c.ctxUsedLen = len(compacted)
|
||||
}
|
||||
@@ -414,6 +453,47 @@ func (c *Conversation) compactAndPublish(ctx context.Context, epoch int, phase s
|
||||
return compacted, true
|
||||
}
|
||||
|
||||
// historyFromView rend l'historique à ranger après une compaction en cours de
|
||||
// tour : la vue publiée (NewHistory), sans les messages système que la vue
|
||||
// d'envoi avait posés en tête — préambule et prompt du preset, consignes du
|
||||
// dépôt, contexte du projet ou bloc figé.
|
||||
//
|
||||
// Retirer TOUS les systèmes de tête effaçait aussi ceux de l'historique
|
||||
// lui-même : le rappel des pages mémoire lues ([MEMORY PAGES REMINDER]), posé
|
||||
// par une compaction de début de tour, disparaissait à la première compaction
|
||||
// en cours de tour, et le modèle perdait la liste des pages à relire. Un
|
||||
// système de tête n'est donc gardé que s'il figurait en tête de hist,
|
||||
// l'historique dont la vue est partie : la compaction garde la tête telle
|
||||
// quelle, et la vue d'envoi n'y ajoute que ses propres messages. Seule
|
||||
// exception, le premier : InjectSkills a pu fusionner le préambule devant un
|
||||
// système de l'historique (« préambule\n\nsystème ») — on rend alors celui-ci.
|
||||
func historyFromView(view, hist []Message) []Message {
|
||||
own := map[string]int{}
|
||||
for _, m := range hist {
|
||||
if m.Role != "system" {
|
||||
break
|
||||
}
|
||||
own[msgText(m)]++
|
||||
}
|
||||
var out []Message
|
||||
i := 0
|
||||
for ; i < len(view) && view[i].Role == "system"; i++ {
|
||||
k := msgText(view[i])
|
||||
if own[k] > 0 {
|
||||
own[k]--
|
||||
out = append(out, view[i])
|
||||
continue
|
||||
}
|
||||
if i == 0 && len(hist) > 0 && hist[0].Role == "system" {
|
||||
if h := msgText(hist[0]); h != "" && own[h] > 0 && strings.HasSuffix(k, "\n\n"+h) {
|
||||
own[h]--
|
||||
out = append(out, hist[0])
|
||||
}
|
||||
}
|
||||
}
|
||||
return append(out, view[i:]...)
|
||||
}
|
||||
|
||||
// convState renvoie un instantané léger (pour /api/chat/state).
|
||||
func (c *Conversation) state() map[string]any {
|
||||
c.mu.Lock()
|
||||
@@ -455,9 +535,12 @@ var ErrBusy = fmt.Errorf("génération en cours")
|
||||
// nomme la tâche et on dit comment reprendre la main.
|
||||
func (c *Conversation) busyReason() error {
|
||||
c.mu.Lock()
|
||||
name, bench := c.runningTaskName, c.benching
|
||||
name, bench, why := c.runningTaskName, c.benching, c.leaseWhy
|
||||
c.mu.Unlock()
|
||||
if bench {
|
||||
if why != nil {
|
||||
return why
|
||||
}
|
||||
return errBenchBusy
|
||||
}
|
||||
if name == "" {
|
||||
@@ -477,6 +560,11 @@ var errModelLoading = fmt.Errorf("⏳ Le modèle est encore en train de charger
|
||||
// MODÈLE en tête du message, mais rendues comme pastilles dans la bulle : le fil
|
||||
// doit montrer ce que l'utilisateur a écrit, pas la consigne qu'on ajoute pour lui.
|
||||
func (c *Conversation) StartTurn(text string, files []attachInfo, caps Caps, temperature float64) error {
|
||||
// Optimisation en cours (backend_tune_lock.go) : le vrai moteur est arrêté,
|
||||
// « le modèle charge » serait faux — on dit pourquoi.
|
||||
if err := tuneGuard(); err != nil {
|
||||
return err
|
||||
}
|
||||
if !healthCheck() {
|
||||
return errModelLoading
|
||||
}
|
||||
@@ -495,20 +583,19 @@ func (c *Conversation) StartTurn(text string, files []attachInfo, caps Caps, tem
|
||||
if strings.TrimSpace(prompt) == "" {
|
||||
prompt = "Prends-en connaissance."
|
||||
}
|
||||
askedCaps := caps
|
||||
// Mode code : fige le rôle du tour (plan demandé → planner, sinon builder).
|
||||
// La détection de bascule (puce « passer en mode Code ? ») est émise après
|
||||
// la bulle utilisateur, plus bas.
|
||||
if caps.Code && caps.Role == "" {
|
||||
caps.Role = "builder"
|
||||
if wantsPlan(text) {
|
||||
caps.Role = "planner"
|
||||
}
|
||||
}
|
||||
caps = withTurnRole(caps, text)
|
||||
// Content = simple texte d'ordinaire ; format multimodal (texte + images) quand
|
||||
// la vision est active et qu'une pièce jointe est une image (userMessageContent).
|
||||
c.Messages = append(c.Messages, Message{Role: "user", Content: userMessageContent(files, prompt)})
|
||||
epoch := c.epoch
|
||||
c.mu.Unlock()
|
||||
// Capacités demandées pour ce tour, retenues en mémoire (jamais persistées)
|
||||
// pour préchauffer le suivant (PREWARM, chat_prewarm.go).
|
||||
prewarmNoteCaps(convActiveID(), askedCaps)
|
||||
|
||||
// Borne de tour + bulle utilisateur (rejouables). Persistée tout de suite
|
||||
// (sur disque quelques ms plus tard, voir persistAsync) : si le process
|
||||
@@ -746,9 +833,15 @@ func (c *Conversation) generate(ctx context.Context, caps Caps, temperature floa
|
||||
// (pas d'extrait de réponse) : la notif transite par Apple/Google. Pas
|
||||
// de notification après un « stop » (retour ci-dessus) : l'utilisateur
|
||||
// est là et a coupé volontairement.
|
||||
if !c.startQueuedIfAny() && hasPushSubs() {
|
||||
started := c.startQueuedIfAny()
|
||||
if !started && hasPushSubs() {
|
||||
go sendPushToAll("Loki", "Réponse prête · "+fmtDurFR(time.Since(turnStart)))
|
||||
}
|
||||
// Plus rien ne suit : le prochain tour peut être préparé pendant que
|
||||
// l'utilisateur lit (PREWARM, off par défaut — sans la clé, rien ne part).
|
||||
if !started {
|
||||
c.prewarmKick(prewarmTurnEnd)
|
||||
}
|
||||
}()
|
||||
// Télémétrie : tout ce que ce tour envoie au moteur (étapes, compaction,
|
||||
// sous-agents, vérification) est rattaché à la discussion active.
|
||||
@@ -772,11 +865,20 @@ func (c *Conversation) generate(ctx context.Context, caps Caps, temperature floa
|
||||
// d'affichage garde le fil complet. Le résumé est un appel modèle non streamé :
|
||||
// il bloque plusieurs secondes AVANT que la vraie réponse commence, d'où la
|
||||
// bannière de progression émise par compactAndPublish.
|
||||
// KEEP_TURN_IMAGES (chat_keep_images.go) : images gardées retirées d'abord
|
||||
// si le contexte le demande ou si elles ne doivent plus partir. Sans la
|
||||
// clé, il n'y en a aucune : rien ne change.
|
||||
msgs, ctxUsed = c.keptImagesTurnStart(epoch, msgs, ctxUsed, peak)
|
||||
// Estimation de début de tour, comparée au premier compte réel du moteur
|
||||
// (journal [ctx], seulement au-delà de 2 % d'écart).
|
||||
startEst := ctxUsed
|
||||
if compactNeeded(msgs, ctxUsed, peak) {
|
||||
if out, changed := c.compactAndPublish(ctx, epoch, "début-tour", msgs, ctxUsed, caps); changed {
|
||||
// COMPACT_CONTINUATION (off par défaut) : un refus récent n'est pas retenté
|
||||
// tout de suite, et le résumé peut prolonger le prompt en cache. Sans la
|
||||
// clé, ni l'un ni l'autre.
|
||||
conv := perfTagOf(ctx).conv
|
||||
if compactNeeded(msgs, ctxUsed, peak) && !compactRefusedSkip(conv, ctxUsed, msgs) {
|
||||
opt := c.compactOptsFor(caps, msgs, ctxUsed, conv)
|
||||
if out, changed := c.compactAndPublish(ctx, epoch, "début-tour", msgs, ctxUsed, caps, opt); changed {
|
||||
msgs = out
|
||||
// Le chiffre d'avant compaction ne dit plus rien de la requête qui
|
||||
// part : on compare celui que la compaction vient de poser.
|
||||
@@ -797,21 +899,23 @@ func (c *Conversation) generate(ctx context.Context, caps Caps, temperature floa
|
||||
// agent, on veut parler au modèle nu, comme à un llama-server direct. Le
|
||||
// prompt du preset et l'index mémoire décrivent des outils que ce mode n'a
|
||||
// pas : le modèle se mettait à écrire des <tool_call> en clair.
|
||||
final := msgs
|
||||
if caps.Agent {
|
||||
final = append(projectSystemMessages(), msgs...)
|
||||
if m, ok := codeInstructionsMessage(caps); ok {
|
||||
final = append([]Message{m}, final...)
|
||||
}
|
||||
if sp := readSysPrompt(); sp != "" {
|
||||
final = append([]Message{{Role: "system", Content: sp}}, final...)
|
||||
}
|
||||
//
|
||||
// Assemblage dans turnView (chat_projsnap.go) : avec PROJ_SNAPSHOT, le bloc
|
||||
// projet est celui, figé, de la discussion, et ses changements partent en
|
||||
// tête de ce message — après la compaction de début de tour, sous c.mu.
|
||||
sent, tools, snapTurn := c.turnView(caps, epoch, msgs, caps.Agent)
|
||||
hist := msgs // l'historique d'où part la vue : voir historyFromView
|
||||
runCtx := ctx
|
||||
if snapTurn != nil {
|
||||
runCtx = withProjSnapTurn(ctx, snapTurn)
|
||||
}
|
||||
|
||||
// newBase : vue modèle publiée par une compaction survenue PENDANT le tour.
|
||||
// Non-nil = elle remplace l'historique (elle contient déjà le tour en cours).
|
||||
var newBase []Message
|
||||
var content strings.Builder
|
||||
// echo : raisonnement de la réponse finale (REASONING_ECHO), rangé avec elle.
|
||||
var echo *ReasoningEcho
|
||||
// Le comptage EXACT du contexte vient de `usage.prompt_tokens` (option
|
||||
// include_usage). Tous les moteurs ne le renvoient pas — un llama-server
|
||||
// récent a cessé de le faire, et la jauge est restée bloquée à zéro sur des
|
||||
@@ -823,8 +927,7 @@ func (c *Conversation) generate(ctx context.Context, caps Caps, temperature floa
|
||||
// asked : le tour s'est terminé sur une question à l'utilisateur (outil ask) —
|
||||
// pas de vérification du mode Code avant sa réponse.
|
||||
asked := false
|
||||
sent, tools := prepareTurn(final, caps)
|
||||
extra, _ := runChatTools(ctx, sent, tools, temperature, caps, func(ev StreamEvent) bool {
|
||||
extra, _ := runChatTools(runCtx, sent, tools, temperature, caps, func(ev StreamEvent) bool {
|
||||
switch {
|
||||
case ev.Err != nil:
|
||||
c.appendDelta(epoch, map[string]any{"error": ev.Err.Error()})
|
||||
@@ -857,6 +960,9 @@ func (c *Conversation) generate(ctx context.Context, caps Caps, temperature floa
|
||||
if ev.ToolUsed.Image != "" {
|
||||
tu["image"] = ev.ToolUsed.Image
|
||||
}
|
||||
if ev.ToolUsed.Shot != "" {
|
||||
tu["shot"] = ev.ToolUsed.Shot
|
||||
}
|
||||
// Aperçu seulement dans le flux : taille réelle + id pour « voir plus ».
|
||||
if ev.ToolUsed.ResultChars > 0 {
|
||||
tu["result_chars"] = ev.ToolUsed.ResultChars
|
||||
@@ -867,15 +973,14 @@ func (c *Conversation) generate(ctx context.Context, caps Caps, temperature floa
|
||||
c.appendDelta(epoch, map[string]any{"tool_used": tu})
|
||||
case ev.NewHistory != nil:
|
||||
// Compaction faite en cours de tour : elle remplace la base au lieu de
|
||||
// s'ajouter à l'ancienne (voir StreamEvent.NewHistory). On retire le
|
||||
// préfixe système injecté à la volée (prompt perso + skills, fusionnés en
|
||||
// UN message system en tête) : il n'appartient pas à l'historique persisté
|
||||
// et doit rester modifiable à chaud.
|
||||
base := ev.NewHistory
|
||||
for len(base) > 0 && base[0].Role == "system" {
|
||||
base = base[1:]
|
||||
}
|
||||
newBase = append([]Message(nil), base...)
|
||||
// s'ajouter à l'ancienne (voir StreamEvent.NewHistory). On retire ce
|
||||
// que la vue d'envoi a posé en tête (prompt perso + préambule, contexte
|
||||
// du projet) : il n'appartient pas à l'historique persisté et doit
|
||||
// rester modifiable à chaud. Le rappel des pages lues, lui, en fait
|
||||
// partie (historyFromView).
|
||||
newBase = historyFromView(ev.NewHistory, hist)
|
||||
case ev.Echo != nil:
|
||||
echo = ev.Echo
|
||||
case ev.Compacting != nil:
|
||||
// Compaction déclenchée pendant la boucle d'outils : même bannière que la
|
||||
// compaction de début de tour.
|
||||
@@ -923,9 +1028,14 @@ func (c *Conversation) generate(ctx context.Context, caps Caps, temperature floa
|
||||
if newBase != nil {
|
||||
c.Messages = newBase
|
||||
}
|
||||
// Bloc projet rafraîchi par une compaction en cours de tour
|
||||
// (PROJ_SNAPSHOT) : c'est lui que le tour suivant renvoie.
|
||||
if s := snapTurn.takeRefreshed(); s != nil {
|
||||
c.ProjSnap = s
|
||||
}
|
||||
c.Messages = append(c.Messages, extra...)
|
||||
if s := content.String(); strings.TrimSpace(s) != "" {
|
||||
c.Messages = append(c.Messages, Message{Role: "assistant", Content: s})
|
||||
c.Messages = append(c.Messages, withEcho(Message{Role: "assistant", Content: s}, echo))
|
||||
}
|
||||
// Rappel de loki resté sans réponse (stop, erreur) ou collé à un autre
|
||||
// user : retiré, sinon deux `user` d'affilée au tour suivant.
|
||||
@@ -980,12 +1090,20 @@ func (c *Conversation) generate(ctx context.Context, caps Caps, temperature floa
|
||||
// retombe tout de suite et le tour suivant démarre avec de la marge.
|
||||
// Sauf si un Reset est passé (rien à compacter) ou si le tour a été annulé
|
||||
// (bouton stop) : on n'enchaîne pas plusieurs secondes de résumé sur un stop.
|
||||
if stale || ctx.Err() != nil || !compactNeeded(msgs, ctxUsed, peak) {
|
||||
// KEEP_TURN_IMAGES : comme en début de tour, les images gardées partent
|
||||
// d'abord si le contexte le demande, et le résumé ne suit que s'il reste
|
||||
// nécessaire. Sans la clé, aucune image gardée : rien ne change.
|
||||
if !stale && ctx.Err() == nil {
|
||||
msgs, ctxUsed = c.keptImagesTurnStart(epoch, msgs, ctxUsed, peak)
|
||||
}
|
||||
if stale || ctx.Err() != nil || !compactNeeded(msgs, ctxUsed, peak) || compactRefusedSkip(conv, ctxUsed, msgs) {
|
||||
return
|
||||
}
|
||||
// context.Background() et non ctx : le tour est terminé, son contexte peut
|
||||
// être annulé alors que cette compaction-là doit aller au bout.
|
||||
c.compactAndPublish(context.Background(), epoch, "fin-tour", msgs, ctxUsed, caps)
|
||||
// être annulé alors que cette compaction-là doit aller au bout. Après une
|
||||
// vérification du mode Code, le slot porte son prompt : pas de
|
||||
// continuation (engineSlotHolds), transcription comme avant.
|
||||
c.compactAndPublish(context.Background(), epoch, "fin-tour", msgs, ctxUsed, caps, c.compactOptsFor(caps, msgs, ctxUsed, conv))
|
||||
}
|
||||
|
||||
// ctxNowLocked : contexte à juger pour compacter, c.mu tenu. llama-server
|
||||
@@ -1006,6 +1124,9 @@ func (c *Conversation) ctxNowLocked(msgs []Message, local bool) int {
|
||||
// événements passent par le flux d'abonnement, donc tous les appareils voient la
|
||||
// progression. Renvoie ErrBusy si un tour est déjà en cours.
|
||||
func (c *Conversation) CompactNow() error {
|
||||
if err := tuneGuard(); err != nil {
|
||||
return err
|
||||
}
|
||||
if !healthCheck() {
|
||||
return errModelLoading
|
||||
}
|
||||
@@ -1034,7 +1155,7 @@ func (c *Conversation) CompactNow() error {
|
||||
}
|
||||
c.mu.Unlock()
|
||||
}()
|
||||
if _, changed := c.compactAndPublish(ctx, epoch, "manuel", msgs, lastReal, Caps{}); changed {
|
||||
if _, changed := c.compactAndPublish(ctx, epoch, "manuel", msgs, lastReal, Caps{}, compactOpts{}); changed {
|
||||
c.persist()
|
||||
}
|
||||
}()
|
||||
@@ -1076,11 +1197,14 @@ func (c *Conversation) Reset() {
|
||||
c.Seq = 0
|
||||
c.CtxUsed = 0
|
||||
c.ctxUsedLen, c.genPeak = 0, 0
|
||||
c.ProjSnap = nil
|
||||
c.epoch++
|
||||
c.Generating = false
|
||||
c.benching = false // Stop a annulé le bench : le verrou est rendu ici
|
||||
c.leaseWhy = nil
|
||||
c.cancel = nil
|
||||
c.queued = nil // les messages en attente visaient le fil qu'on vient de vider
|
||||
c.extLeaseClearLocked() // Reset débloque aussi un tour de la façade
|
||||
c.queued = nil // les messages en attente visaient le fil qu'on vient de vider
|
||||
c.cond.Broadcast()
|
||||
c.mu.Unlock()
|
||||
c.persist()
|
||||
|
||||
@@ -0,0 +1,116 @@
|
||||
package loki
|
||||
|
||||
// chat_envctx.go — PROJ_SNAPSHOT : la date et le dossier de travail sortent du
|
||||
// prompt système pour entrer dans le bloc projet figé de la discussion.
|
||||
//
|
||||
// Pourquoi. Le système (préambule + briefing machine) et les outils forment le
|
||||
// début de chaque requête ; tant qu'ils ne bougent pas, le moteur reprend son
|
||||
// cache. Deux valeurs y bougeaient pourtant : la date — à minuit, tout le prompt
|
||||
// d'une discussion en cours est recalculé — et le dossier de travail, propre à
|
||||
// chaque discussion — deux discussions ne partageaient donc rien, pas même le
|
||||
// système. Avec la clé, le système ne garde que des consignes (« ton dossier de
|
||||
// travail est dans ton contexte ») et les valeurs partent dans le bloc projet,
|
||||
// en tête du premier message utilisateur, avec le reste du contexte figé.
|
||||
//
|
||||
// Rien n'est mémorisé au-delà du bloc lui-même : chaque tour relit la date, le
|
||||
// dossier et la cible, et tout écart part en <context_update> dans le message
|
||||
// suivant — une ligne pour un changement de jour, le bloc entier sinon (poste
|
||||
// distant passé hors ligne, autre cible, autre dossier). Les mêmes faits, à une
|
||||
// autre place ; aucun n'est retenu par Loki plus longtemps qu'avant.
|
||||
//
|
||||
// Seul un tour de discussion avec la clé y passe (assembleTurn pose
|
||||
// caps.envInCtx). Une tâche planifiée, un sous-agent, la vérification du mode
|
||||
// Code, le terminal gardent leur système d'avant, date et dossier compris.
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
// envContextPrefix ouvre le message (isProjectSystem le range avec le contexte
|
||||
// du projet). Assez particulier pour qu'aucun prompt de preset ne commence ainsi.
|
||||
const envContextPrefix = "Environment (from Loki)"
|
||||
|
||||
// nodeOfflineLine : l'avertissement d'un poste distant hors ligne — dans le
|
||||
// système sans la clé, dans le bloc projet avec.
|
||||
const nodeOfflineLine = "⚠ It is currently OFFLINE: those tools will fail until it reconnects. Tell the user instead of trying repeatedly."
|
||||
|
||||
// envNow : l'horloge du bloc (remplaçable en test).
|
||||
var envNow = time.Now
|
||||
|
||||
// envDateLine : la date du jour, avec l'année et l'année périmée que la
|
||||
// consigne web du système désignait jusque-là par leur valeur.
|
||||
func envDateLine(day string) string {
|
||||
year := day
|
||||
if len(day) >= 4 {
|
||||
year = day[:4]
|
||||
}
|
||||
return "Date: " + day + " (the current year is " + year + ", not " + prevYear(year) + ")."
|
||||
}
|
||||
|
||||
// envMachineFacts : les valeurs du briefing machine qui varient — dossier de la
|
||||
// discussion, ou poste distant (nom, système, dossier, état). "" sans agent.
|
||||
// Les consignes qui les accompagnent restent dans machineSystemPrompt.
|
||||
func envMachineFacts(caps Caps) string {
|
||||
if !caps.Agent {
|
||||
return ""
|
||||
}
|
||||
if tgt, ok := nodeTargetMetaGet(); ok {
|
||||
s := "Machine: REMOTE node " + tgt.name
|
||||
if tgt.os != "" {
|
||||
s += " (" + tgt.os + ")"
|
||||
}
|
||||
if tgt.root != "" {
|
||||
s += ", working folder " + tgt.root
|
||||
}
|
||||
s += "."
|
||||
if !tgt.connected {
|
||||
s += " " + nodeOfflineLine
|
||||
}
|
||||
return s
|
||||
}
|
||||
if cwd := agentCwd(); cwd != "" {
|
||||
return "Working folder (cwd): " + cwd + "."
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// envCapture : la date (si le préambule en portait une) et les valeurs machine
|
||||
// de ce tour.
|
||||
func envCapture(caps Caps) (day, facts string) {
|
||||
if caps.Agent || caps.Mem != MemOff {
|
||||
day = envNow().Format("2006-01-02")
|
||||
}
|
||||
return day, envMachineFacts(caps)
|
||||
}
|
||||
|
||||
// envContextMessage : le message du bloc, ok=false s'il n'a rien à dire.
|
||||
func envContextMessage(day, facts string) (Message, bool) {
|
||||
var lines []string
|
||||
if day != "" {
|
||||
lines = append(lines, envDateLine(day))
|
||||
}
|
||||
if facts != "" {
|
||||
lines = append(lines, facts)
|
||||
}
|
||||
if len(lines) == 0 {
|
||||
return Message{}, false
|
||||
}
|
||||
return Message{Role: "system", Content: envContextPrefix + ":\n" + strings.Join(lines, "\n")}, true
|
||||
}
|
||||
|
||||
// envDelta : la mise à jour entre l'environnement annoncé et le vivant. Un
|
||||
// changement de jour seul tient en une ligne ; tout autre écart renvoie le
|
||||
// message entier.
|
||||
func envDelta(oldDay, oldFacts, day, facts string) string {
|
||||
switch {
|
||||
case oldFacts == facts && oldDay == day:
|
||||
return ""
|
||||
case oldFacts == facts && day != "" && oldDay != "":
|
||||
return "New day — " + envDateLine(day) + "\n"
|
||||
}
|
||||
if m, ok := envContextMessage(day, facts); ok {
|
||||
return "Current version, replaces any earlier one — " + msgText(m) + "\n"
|
||||
}
|
||||
return "Environment: removed.\n"
|
||||
}
|
||||
@@ -0,0 +1,185 @@
|
||||
package loki
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"os"
|
||||
"os/user"
|
||||
"runtime"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
// sysPlaceholders remplace dans un prompt système les valeurs propres à la
|
||||
// machine et au jour (date, année, hôte, utilisateur, dossiers) par des jetons
|
||||
// fixes : ce qui reste doit être identique, octet pour octet, d'une machine à
|
||||
// l'autre.
|
||||
func sysPlaceholders(s string) string {
|
||||
now := time.Now()
|
||||
year := now.Format("2006")
|
||||
host, _ := os.Hostname()
|
||||
if host == "" {
|
||||
host = "unknown"
|
||||
}
|
||||
who := ""
|
||||
if u, err := user.Current(); err == nil {
|
||||
who = u.Username
|
||||
}
|
||||
pairs := []string{
|
||||
now.Format("2006-01-02"), "<DATE>",
|
||||
scriptsDir(), "<SCRIPTS>",
|
||||
agentCwd(), "<CWD>",
|
||||
"host=" + host, "host=<HOST>",
|
||||
runtime.GOOS + "/" + runtime.GOARCH, "<OS>/<ARCH>",
|
||||
"The shell is " + agentTargetShellName(), "The shell is <SHELL>",
|
||||
}
|
||||
if who != "" {
|
||||
pairs = append(pairs, "user="+who, "user=<USER>")
|
||||
}
|
||||
pairs = append(pairs, year, "<YEAR>", prevYear(year), "<PREVYEAR>")
|
||||
for i := 0; i < len(pairs); i += 2 {
|
||||
s = strings.ReplaceAll(s, pairs[i], pairs[i+1])
|
||||
}
|
||||
return s
|
||||
}
|
||||
|
||||
func sysOf(t *testing.T, msgs []Message) string {
|
||||
t.Helper()
|
||||
if len(msgs) == 0 || msgs[0].Role != "system" {
|
||||
t.Fatalf("pas de système en tête : %+v", msgs)
|
||||
}
|
||||
s, _ := msgs[0].Content.(string)
|
||||
return s
|
||||
}
|
||||
|
||||
// Gabarits du système SANS PROJ_SNAPSHOT, relevés sur le code d'avant le
|
||||
// déplacement de la date et du dossier de travail : ils ne doivent pas bouger
|
||||
// d'un octet (valeurs du jour et de la machine remplacées par des jetons).
|
||||
var sysGolden = map[string]string{
|
||||
"agent": "You are Loki, an expert assistant operating directly on this machine with real tools. You evolve with every conversation: you actively maintain a persistent memory so nothing useful is lost between sessions.\n\nThe shell is <SHELL>: use its syntax.\n\nManaging your memory is part of the job, not optional:\n- Save anything worth keeping (a preference, fact, decision, how-to) with mem_add, or mem_edit to update a page — on your own, without being asked.\n- The memory index (page list) is in your context: when a page looks relevant, mem_read it directly. mem_search is only for finding something by content.\nFor anything about the system or files, use bash instead of guessing. Act immediately — call the right tool, then answer. Never end your turn after only thinking. Be concise.\nTo give the user a file, link it in Markdown with its path relative to your working directory — [le rapport](rapport.pdf) — which downloads it. A raw server path is useless: they read you in a browser.\nYou can schedule work for yourself with task_create — only for what must recur or happen later, never a one-off you can do now. A script from your scripts folder runs as a task with no model at all.\n\nDate: <DATE>\n\nMachine: host=<HOST>, <OS>/<ARCH>, user=<USER>, cwd=<CWD>. This is your working folder: relative paths in write/edit/bash resolve here, and it is the DEFAULT place for scratch work — notes, outputs, a clone, a test. But it is DISPOSABLE: deleting the discussion wipes it. Any script you want to KEEP (or schedule), write it into your scripts folder <SCRIPTS> instead — a separate folder a workspace wipe won't touch; you write and run scripts there normally. Do NOT install or write files into system directories such as /usr/local/bin, /usr, /bin or /etc: those need root and are not yours. Only use an absolute path outside this folder (except your scripts folder) when the user explicitly named that location.",
|
||||
"internet": "You are Loki, an expert assistant operating directly on this machine with real tools. You evolve with every conversation: you actively maintain a persistent memory so nothing useful is lost between sessions.\n\nThe shell is <SHELL>: use its syntax.\n\nManaging your memory is part of the job, not optional:\n- Save anything worth keeping (a preference, fact, decision, how-to) with mem_add, or mem_edit to update a page — on your own, without being asked.\n- The memory index (page list) is in your context: when a page looks relevant, mem_read it directly. mem_search is only for finding something by content.\nFor anything about the system or files, use bash instead of guessing. Act immediately — call the right tool, then answer. Never end your turn after only thinking. Be concise.\nTo give the user a file, link it in Markdown with its path relative to your working directory — [le rapport](rapport.pdf) — which downloads it. A raw server path is useless: they read you in a browser.\nYou can schedule work for yourself with task_create — only for what must recur or happen later, never a one-off you can do now. A script from your scripts folder runs as a task with no model at all.\n\nWeb: web_open first, then web_read/web_grep on it.\nYour training data is stale. For ANY question about recent/latest/current things (releases, versions, news, prices, scores, 'since when') call web_search BEFORE writing any date or version, and match what you actually read.\nToday is in <YEAR>. If a query needs a year use ONLY <YEAR>, never a remembered past year like <PREVYEAR> — it biases results toward stale pages; better still, omit the year. Don't hedge ('probably') about a fact a tool can verify — search instead.\n\nDate: <DATE>\n\nMachine: host=<HOST>, <OS>/<ARCH>, user=<USER>, cwd=<CWD>. This is your working folder: relative paths in write/edit/bash resolve here, and it is the DEFAULT place for scratch work — notes, outputs, a clone, a test. But it is DISPOSABLE: deleting the discussion wipes it. Any script you want to KEEP (or schedule), write it into your scripts folder <SCRIPTS> instead — a separate folder a workspace wipe won't touch; you write and run scripts there normally. Do NOT install or write files into system directories such as /usr/local/bin, /usr, /bin or /etc: those need root and are not yours. Only use an absolute path outside this folder (except your scripts folder) when the user explicitly named that location.",
|
||||
"ondemand": "You are Loki, an expert assistant operating directly on this machine with real tools.\n\nThe shell is <SHELL>: use its syntax.\n\nMemory is ON-DEMAND: you have the mem_* tools but do NOT read or write memory on your own. Call mem_search/mem_read only when the user explicitly asks you to recall or look something up, and mem_add/mem_edit only when the user explicitly asks you to remember something. Otherwise leave memory untouched and answer directly.\nFor anything about the system or files, use bash instead of guessing. Act immediately — call the right tool, then answer. Never end your turn after only thinking. Be concise.\nTo give the user a file, link it in Markdown with its path relative to your working directory — [le rapport](rapport.pdf) — which downloads it. A raw server path is useless: they read you in a browser.\nYou can schedule work for yourself with task_create — only for what must recur or happen later, never a one-off you can do now. A script from your scripts folder runs as a task with no model at all.\n\nDate: <DATE>\n\nMachine: host=<HOST>, <OS>/<ARCH>, user=<USER>, cwd=<CWD>. This is your working folder: relative paths in write/edit/bash resolve here, and it is the DEFAULT place for scratch work — notes, outputs, a clone, a test. But it is DISPOSABLE: deleting the discussion wipes it. Any script you want to KEEP (or schedule), write it into your scripts folder <SCRIPTS> instead — a separate folder a workspace wipe won't touch; you write and run scripts there normally. Do NOT install or write files into system directories such as /usr/local/bin, /usr, /bin or /etc: those need root and are not yours. Only use an absolute path outside this folder (except your scripts folder) when the user explicitly named that location.",
|
||||
"memseule": "You are Loki, an expert assistant operating directly on this machine with real tools. You evolve with every conversation: you actively maintain a persistent memory so nothing useful is lost between sessions.\n\n\nManaging your memory is part of the job, not optional:\n- Save anything worth keeping (a preference, fact, decision, how-to) with mem_add, or mem_edit to update a page — on your own, without being asked.\n- The memory index (page list) is in your context: when a page looks relevant, mem_read it directly. mem_search is only for finding something by content.\n\nDate: <DATE>",
|
||||
}
|
||||
|
||||
func TestSystemeSansCleInchange(t *testing.T) {
|
||||
projSnapSetup(t, false)
|
||||
cases := map[string]Caps{
|
||||
"agent": {Agent: true, Mem: MemAlways},
|
||||
"internet": {Agent: true, Internet: true, Mem: MemAlways},
|
||||
"ondemand": {Agent: true, Mem: MemOnDemand},
|
||||
"memseule": {Mem: MemAlways},
|
||||
}
|
||||
for name, caps := range cases {
|
||||
got, _ := prepareTurn([]Message{um("salut")}, caps)
|
||||
s := sysPlaceholders(sysOf(t, got))
|
||||
if want, ok := sysGolden[name]; !ok || s != want {
|
||||
t.Errorf("%s : système changé sans la clé\nreçu %q\nattendu %q", name, s, want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// toolsOf : le champ tools d'une requête, tel qu'envoyé.
|
||||
func toolsOf(t *testing.T, body string) string {
|
||||
t.Helper()
|
||||
var p struct {
|
||||
Tools json.RawMessage `json:"tools"`
|
||||
}
|
||||
if err := json.Unmarshal([]byte(body), &p); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return string(p.Tools)
|
||||
}
|
||||
|
||||
// Avec la clé : deux discussions dans deux dossiers ont le MÊME système et les
|
||||
// mêmes outils ; la date et le dossier sont dans le bloc projet de chacune.
|
||||
func TestEnvCtxSystemeCommunAuxDiscussions(t *testing.T) {
|
||||
m := projSnapSetup(t, true)
|
||||
t.Cleanup(func() { setTaskWorkspace("") })
|
||||
caps := Caps{Agent: true, Internet: true, Mem: MemAlways}
|
||||
var bodies []string
|
||||
dirs := []string{t.TempDir(), t.TempDir()}
|
||||
for _, d := range dirs {
|
||||
setTaskWorkspace(d)
|
||||
bodies = append(bodies, playTurn(t, m, newTestConv(), caps, "salut"))
|
||||
}
|
||||
s0, s1 := systemContent(t, bodies[0]), systemContent(t, bodies[1])
|
||||
if s0 != s1 || toolsOf(t, bodies[0]) != toolsOf(t, bodies[1]) {
|
||||
t.Fatalf("système ou outils différents d'une discussion à l'autre :\n%q\n%q", s0, s1)
|
||||
}
|
||||
today := time.Now().Format("2006-01-02")
|
||||
year := today[:4]
|
||||
for _, v := range []string{today, year, dirs[0], dirs[1], "cwd="} {
|
||||
if strings.Contains(s0, v) {
|
||||
t.Fatalf("%q encore dans le système : %q", v, s0)
|
||||
}
|
||||
}
|
||||
if !strings.Contains(s0, "Your working folder (cwd) is given in your context") || !strings.Contains(s0, scriptsDir()) {
|
||||
t.Fatalf("consignes du dossier de travail perdues : %q", s0)
|
||||
}
|
||||
for i, b := range bodies {
|
||||
u := userContents(t, b)[0]
|
||||
if !strings.Contains(u, envContextPrefix+":\n"+envDateLine(today)) || !strings.Contains(u, "Working folder (cwd): "+dirs[i]+".") {
|
||||
t.Fatalf("discussion %d : date ou dossier absents du bloc : %q", i, u)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Changement de jour : une ligne de mise à jour, une seule fois ; système et
|
||||
// bloc figé inchangés, donc tout le début du prompt reste en cache.
|
||||
func TestEnvCtxChangementDeJour(t *testing.T) {
|
||||
m := projSnapSetup(t, true)
|
||||
day := time.Date(2026, 12, 31, 23, 50, 0, 0, time.Local)
|
||||
envNow = func() time.Time { return day }
|
||||
t.Cleanup(func() { envNow = time.Now })
|
||||
c := newTestConv()
|
||||
caps := Caps{Agent: true, Mem: MemAlways}
|
||||
b1 := playTurn(t, m, c, caps, "salut")
|
||||
day = day.Add(20 * time.Minute)
|
||||
b2 := playTurn(t, m, c, caps, "et maintenant ?")
|
||||
b3 := playTurn(t, m, c, caps, "merci")
|
||||
if systemContent(t, b1) != systemContent(t, b2) || systemContent(t, b2) != systemContent(t, b3) {
|
||||
t.Fatal("le système a changé avec le jour")
|
||||
}
|
||||
u1, u2, u3 := userContents(t, b1), userContents(t, b2), userContents(t, b3)
|
||||
if u1[0] != u2[0] || u2[0] != u3[0] || !strings.Contains(u1[0], "Date: 2026-12-31 (the current year is 2026, not 2025).") {
|
||||
t.Fatalf("bloc figé modifié ou sans date : %q / %q", u1[0], u2[0])
|
||||
}
|
||||
want := ctxUpdOpen
|
||||
last := u2[len(u2)-1]
|
||||
if !strings.HasPrefix(last, want) || !strings.Contains(last, "New day — Date: 2027-01-01 (the current year is 2027, not 2026).") {
|
||||
t.Fatalf("mise à jour du jour attendue : %q", last)
|
||||
}
|
||||
if u3[1] != last || strings.Contains(u3[len(u3)-1], ctxUpdOpen) {
|
||||
t.Fatalf("la mise à jour doit partir une fois, puis rester telle quelle : %q", u3)
|
||||
}
|
||||
}
|
||||
|
||||
// Tâches, sous-agents, vérification, terminal : la clé ne change rien à leur
|
||||
// système, qui garde la date et le dossier.
|
||||
func TestEnvCtxHorsDiscussionInchange(t *testing.T) {
|
||||
projSnapSetup(t, true)
|
||||
caps := Caps{Agent: true, Mem: MemAlways}
|
||||
got, _ := prepareTurn([]Message{um("tâche")}, caps)
|
||||
s := sysOf(t, got)
|
||||
if !strings.Contains(s, "\nDate: "+time.Now().Format("2006-01-02")) || !strings.Contains(s, ", cwd="+agentCwd()+".") {
|
||||
t.Fatalf("système d'une tâche modifié par la clé : %q", s)
|
||||
}
|
||||
if mp := machineSystemPrompt(caps); !strings.Contains(mp, "cwd="+agentCwd()) {
|
||||
t.Fatalf("briefing d'un sous-agent sans dossier : %q", mp)
|
||||
}
|
||||
}
|
||||
|
||||
// Poste distant passé hors ligne, autre dossier : le bloc entier repart, rien
|
||||
// n'est retenu d'avant.
|
||||
func TestEnvDelta(t *testing.T) {
|
||||
if d := envDelta("2026-10-04", "x", "2026-10-04", "x"); d != "" {
|
||||
t.Fatalf("rien de changé : %q", d)
|
||||
}
|
||||
if d := envDelta("2026-10-04", "Working folder (cwd): /a.", "2026-10-04", "Machine: REMOTE node pc. "+nodeOfflineLine); !strings.Contains(d, "Current version") || !strings.Contains(d, nodeOfflineLine) || !strings.Contains(d, "Date: 2026-10-04") {
|
||||
t.Fatalf("bloc entier attendu : %q", d)
|
||||
}
|
||||
if d := envDelta("2026-10-04", "x", "2026-10-05", "x"); d != "New day — "+envDateLine("2026-10-05")+"\n" {
|
||||
t.Fatalf("une ligne attendue : %q", d)
|
||||
}
|
||||
}
|
||||
Loaded 100 of 564 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user