mirror of
https://github.com/R0m1k3/Loki.git
synced 2026-10-22 06:28:11 +02:00
Compare commits
310
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
0be90a6941 | ||
|
|
f86937a1ae | ||
|
|
e9c0555ab7 | ||
|
|
6a45f19831 | ||
|
|
d22a087d8e | ||
|
|
0ef2ed72f5 | ||
|
|
615e58dd2f | ||
|
|
9792953d62 | ||
|
|
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 | ||
|
|
09ee04037b | ||
|
|
cf83f5f629 | ||
|
|
9f6bd725e6 | ||
|
|
056f307983 | ||
|
|
667e340e57 | ||
|
|
c3005f5b56 | ||
|
|
9b2e974942 | ||
|
|
4f34694400 | ||
|
|
3dff7e0e04 | ||
|
|
f1f6cbe4ea | ||
|
|
eaf5e313d9 | ||
|
|
7dd319a058 | ||
|
|
9263df404d | ||
|
|
38b488515a | ||
|
|
72ba8e0fb6 | ||
|
|
e14ead98c4 | ||
|
|
26fc348506 | ||
|
|
d6023ef8a0 | ||
|
|
18a723a671 | ||
|
|
80b7793773 | ||
|
|
3ff56e01db | ||
|
|
3a6ad6f23b | ||
|
|
7f49310b7f | ||
|
|
dd27b81af0 | ||
|
|
c72467ce4c | ||
|
|
3565b594ea | ||
|
|
d028e81ce2 | ||
|
|
0e841c26bd | ||
|
|
c4f051a0e9 | ||
|
|
2bf388f7b7 | ||
|
|
a42c184ed2 | ||
|
|
9aa426a389 | ||
|
|
2de20e0ab2 | ||
|
|
f7f1a30d08 | ||
|
|
0a02604968 | ||
|
|
c6c5dc2b7e | ||
|
|
03eb56aee3 | ||
|
|
76f5697487 | ||
|
|
aebecd75bd | ||
|
|
cf8d1cfaaa | ||
|
|
b955181bb9 | ||
|
|
15495431fc | ||
|
|
d5632e3595 | ||
|
|
dfafca6fb2 | ||
|
|
2e0356cd6c | ||
|
|
a745d2399e | ||
|
|
ad87f2fc9f | ||
|
|
7b754c0cb6 | ||
|
|
9ccdc021cb | ||
|
|
5e36b1cf82 | ||
|
|
2bbf0ba76d | ||
|
|
8171899ac1 | ||
|
|
199d7f1e99 | ||
|
|
7f8c5d360f | ||
|
|
2db0ecd431 | ||
|
|
48b8193dca | ||
|
|
58c2da1302 | ||
|
|
ae0e181711 | ||
|
|
6aafeeea98 | ||
|
|
f54c304019 | ||
|
|
51c791268d | ||
|
|
04137411fa | ||
|
|
f7a4ee71e7 | ||
|
|
1db987844e | ||
|
|
f75f3cf27d | ||
|
|
38802d9e21 | ||
|
|
2c36fa6d2c | ||
|
|
4a1054c1b7 | ||
|
|
626227f2f2 | ||
|
|
4602614be0 | ||
|
|
f117a466af | ||
|
|
97378da7af | ||
|
|
e12f07426f | ||
|
|
20375d0e4c | ||
|
|
b893d1a81f | ||
|
|
513aca1c7b | ||
|
|
f1075030dd | ||
|
|
c4e399e101 | ||
|
|
c32fd40f1a | ||
|
|
382c5dbdce | ||
|
|
fa69dac77f | ||
|
|
e9ec8ae3a8 | ||
|
|
74fb1fc781 | ||
|
|
91d1796615 | ||
|
|
00b2350fb9 | ||
|
|
b5904907e8 | ||
|
|
d1626781f9 | ||
|
|
9bdaf00ca4 | ||
|
|
54468b3336 | ||
|
|
17f8fa8f34 | ||
|
|
c45ba615fa | ||
|
|
2d4f5378c0 | ||
|
|
9d2ff33943 | ||
|
|
475d523e4a | ||
|
|
3edd356f34 | ||
|
|
dfdee26e4c | ||
|
|
cfe70f2b70 | ||
|
|
b4f4135101 | ||
|
|
6f6a3218ad | ||
|
|
78c258ab45 | ||
|
|
33de4a1144 | ||
|
|
009b585e75 | ||
|
|
895aa67366 | ||
|
|
08eaf10a34 | ||
|
|
e177d54e39 | ||
|
|
66b53c8255 | ||
|
|
3991e68fc9 | ||
|
|
cc218ccfd7 | ||
|
|
d8ed86bbba | ||
|
|
748328db25 | ||
|
|
8e88a73d91 | ||
|
|
f4969de9f7 | ||
|
|
5d391c4f73 | ||
|
|
3cd4facb0e | ||
|
|
3624194b39 | ||
|
|
2fa05fdaf7 | ||
|
|
e0ed0a32af | ||
|
|
a19b490b6a | ||
|
|
393986faa6 | ||
|
|
2585d2c2e1 | ||
|
|
fedbaf32a0 | ||
|
|
cbf8635c61 | ||
|
|
69bf8d985d | ||
|
|
50fccdc9ad | ||
|
|
66072e4047 | ||
|
|
62573ff972 | ||
|
|
481028b023 | ||
|
|
86d711e772 | ||
|
|
ad107ed284 | ||
|
|
43489769f4 | ||
|
|
a333603ca6 | ||
|
|
f4090059f1 |
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:
|
||||
|
||||
@@ -15,6 +15,17 @@ jobs:
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
# L'image runtime CUDA de llama.cpp (~4 Go) et Chromium/Playwright
|
||||
# (~1 Go) tiennent large dans les ~14 Go libres d'un runner neuf, mais
|
||||
# ces trois répertoires ne servent à rien ici et rendent une dizaine de
|
||||
# gigaoctets en moins d'une minute. Gardé depuis l'époque où l'image
|
||||
# compilait whisper.cpp avec CUDA : la marge reste bonne à prendre.
|
||||
|
||||
- name: Libérer de l'espace disque
|
||||
run: |
|
||||
df -h /
|
||||
sudo rm -rf /usr/share/dotnet /usr/local/lib/android /opt/ghc
|
||||
df -h /
|
||||
|
||||
- name: Setup Buildx
|
||||
uses: docker/setup-buildx-action@v3
|
||||
@@ -43,7 +54,64 @@ 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
|
||||
# binaire de dictée) tant que ses lignes du Dockerfile ne bougent
|
||||
# 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/
|
||||
+79
-1
@@ -14,6 +14,17 @@
|
||||
# Déclaré avant le premier FROM : requis pour être utilisable dans un FROM.
|
||||
ARG LLAMACPP_IMAGE=ghcr.io/ggml-org/llama.cpp:server-cuda
|
||||
|
||||
# ⚠️ AVANT le premier FROM, obligatoirement. Un ARG posé entre deux étapes
|
||||
# appartient à l'étape où il apparaît ; les « ARG SHERPA_* » nus de l'étape
|
||||
# asrfetch héritent du scope GLOBAL — c'est-à-dire d'ici. Placés plus bas, ils
|
||||
# hériteraient d'une valeur VIDE, et l'étape téléchargerait une URL tronquée.
|
||||
# Un test (TestDockerfileArgFlagsGlobal) verrouille cette position.
|
||||
#
|
||||
# La version est ÉPINGLÉE, et l'empreinte avec : le binaire de dictée est
|
||||
# exécuté sur la machine de l'utilisateur, il ne se prend pas « au dernier ».
|
||||
ARG SHERPA_ONNX_VERSION=v1.13.7
|
||||
ARG SHERPA_ONNX_SHA256=2aa3965b37aa235e56aa3ebbb94942c20a23960059a006b4442b4bf893967983
|
||||
|
||||
# ── Étape 1 : binaire Go loki ───────────────────────────────────────────
|
||||
FROM golang:1.25 AS gobuild
|
||||
WORKDIR /src
|
||||
@@ -31,6 +42,55 @@ 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
|
||||
# compilation (dont une CUDA de ~200 fichiers nvcc, tuée par l'OOM du runner
|
||||
# plus d'une fois) laissent place à une extraction de 35 Mo.
|
||||
#
|
||||
# Le build « static-no-tts » lie sherpa-onnx ET onnxruntime en statique : rien à
|
||||
# fournir côté image en dehors de la libc et de libstdc++, déjà présentes. Il
|
||||
# n'existe qu'en variante CPU — c'est assumé : un modèle de 0,6 B en int8 sur
|
||||
# des tranches de quelques secondes n'a pas besoin de la carte, qui reste au
|
||||
# moteur de chat. La variante CUDA existe mais tirerait onnxruntime-gpu et
|
||||
# cuDNN dans l'image, pour un gain sans intérêt à cette taille de tranche.
|
||||
#
|
||||
# L'empreinte est vérifiée : le binaire s'exécute sur la machine de
|
||||
# l'utilisateur, une release remplacée en amont ne doit pas passer en silence.
|
||||
FROM debian:12-slim AS asrfetch
|
||||
ARG SHERPA_ONNX_VERSION
|
||||
ARG SHERPA_ONNX_SHA256
|
||||
RUN apt-get update && apt-get install -y --no-install-recommends \
|
||||
curl ca-certificates bzip2 \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
RUN set -eu; \
|
||||
nom="sherpa-onnx-${SHERPA_ONNX_VERSION}-linux-x64-static-no-tts"; \
|
||||
curl -fsSL -o /tmp/sherpa.tar.bz2 \
|
||||
"https://github.com/k2-fsa/sherpa-onnx/releases/download/${SHERPA_ONNX_VERSION}/${nom}.tar.bz2"; \
|
||||
echo "${SHERPA_ONNX_SHA256} /tmp/sherpa.tar.bz2" | sha256sum -c -; \
|
||||
mkdir -p /out; \
|
||||
tar -xjf /tmp/sherpa.tar.bz2 -C /out --strip-components=2 \
|
||||
"${nom}/bin/sherpa-onnx-offline-websocket-server"; \
|
||||
chmod +x /out/sherpa-onnx-offline-websocket-server; \
|
||||
/out/sherpa-onnx-offline-websocket-server --help >/dev/null 2>&1 || true
|
||||
|
||||
# ── Étape 2 : runtime sur l'image serveur CUDA officielle ───────────────
|
||||
FROM ${LLAMACPP_IMAGE} AS runtime
|
||||
|
||||
@@ -44,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/*
|
||||
@@ -84,6 +148,10 @@ 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
|
||||
COPY docker-entrypoint.sh /usr/local/bin/docker-entrypoint.sh
|
||||
RUN chmod +x /usr/local/bin/docker-entrypoint.sh && mkdir -p /data /models
|
||||
|
||||
@@ -92,10 +160,20 @@ ENV LOKI_CONTAINER=1 \
|
||||
LOKI_HOME=/data \
|
||||
LOKI_MODEL_DIRS=/models \
|
||||
LOKI_ENGINE_BIN=/app/llama-server \
|
||||
LOKI_ASR_SERVER=/usr/local/bin/sherpa-onnx-offline-websocket-server \
|
||||
LD_LIBRARY_PATH=/app \
|
||||
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
|
||||
|
||||
@@ -26,7 +26,51 @@ La licence d'origine est conservée à l'identique dans [`LICENSE`](LICENSE).
|
||||
(contrat builder/verifier, tracker « lu avant d'écrire », auto-retry
|
||||
patterns, table languages.json) est reprise
|
||||
d'**[OpenFox](https://github.com/co-l/openfox)** (MIT) et réécrite en Go
|
||||
pour ce fork ; les prompts de rôles sont des réécritures originales.
|
||||
pour ce fork ; les prompts de rôles sont des réécritures originales. S'y
|
||||
ajoutent depuis d'autres idées reprises d'OpenFox et réimplémentées ici :
|
||||
les **sous-agents** (`code_subagent.go` — un rôle délégué qui travaille dans
|
||||
son propre contexte), la **vérification déclenchée quand le builder a
|
||||
fini** (statut `completed`, relance sur critères ouverts), le **pré-vol**
|
||||
qui refuse une écriture dès son chemin (`tool_schema_order.go`), la
|
||||
**réparation des noms d'outils hallucinés** (`code_alias.go`), les
|
||||
**consignes du dépôt** AGENTS.md / CLAUDE.md (`code_instructions.go`),
|
||||
l'**hygiène du terminal** (`chat_shell_hygiene.go`) et un **compactage**
|
||||
qui garde l'état du mode Code.
|
||||
|
||||
## Reprises depuis l'amont après le fork
|
||||
|
||||
Le fork continue de suivre AJEAN et y reprend des fonctionnalités, adaptées au
|
||||
conteneur : le **contrôle du navigateur** (`computer_use.go`, `computer_cdp.go`,
|
||||
`browser_grid.go` — pilotage d'un Chromium en CDP, ici celui de Playwright déjà
|
||||
présent dans l'image) et la **préparation des images** envoyées au modèle
|
||||
(`web_upload_orient.go` — orientation EXIF et redimensionnement), les
|
||||
**notifications Web Push** (`push.go`, `web_push.go`), les **tâches script** et
|
||||
le dossier de scripts (`chat_scripts.go`, `tasks_script.go`, `tasks_tools.go`),
|
||||
et le **chiffrement de la mémoire** (`mem_crypto.go`, `mem_vault.go`,
|
||||
`mem_store.go`, `mem_io.go`, `mem_migrate.go`, `mem_snapshots.go`,
|
||||
`backup_bundle.go`). La sauvegarde vers le relais (`relay_backup.go`) n'est
|
||||
**pas** reprise : elle vise ajean.link, le service de l'auteur amont. Loki
|
||||
expose à la place un export/import de paquet chiffré en fichier local.
|
||||
|
||||
La **recherche plein-texte des discussions** (`chat_search.go`) reprend celle
|
||||
de l'historique d'AJEAN (commit `558d3b4d`, issue amont #98, PR #99 d'Olioli4) :
|
||||
index inversé chiffré comme les discussions, tokens repliés sans accents,
|
||||
correspondance par début de mot, titre compté double. Adaptée au modèle de
|
||||
Loki (toutes les discussions dans `bkChat`, index tenu à chaque écriture, y
|
||||
compris celles de la façade Rust) et à sa barre latérale (« rechercher
|
||||
partout », extrait autour du passage trouvé).
|
||||
|
||||
## Moteur Strata
|
||||
|
||||
Le second moteur de Loki (`internal/loki/backend_strata.go`) est
|
||||
**[Strata](https://github.com/Niko1221/Strata)** (MIT), spécialisé dans
|
||||
Qwen3.8-Flash-Next. Son intégration est reprise d'AJEAN (« AJEAN MoE 1.1 »,
|
||||
`backend_moe.go`) : Loki télécharge le paquet figé publié par AJEAN sur sa
|
||||
release `moe-v1.1` (sources de Strata et binaire Linux CUDA 12 compilé, avec
|
||||
les modifications d'AJEAN), vérifié par SHA-256 avec les empreintes publiées
|
||||
par AJEAN. La reprise automatique d'une installation 1.0 (`strataMigrate`) est
|
||||
reprise d'AJEAN (`moeMigrate`). Ni Strata ni le modèle ne sont inclus dans
|
||||
l'image : ils sont téléchargés à la demande.
|
||||
|
||||
## Services externes
|
||||
|
||||
|
||||
@@ -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,89 @@ la recherche qu'au téléchargement. À défaut, la variable d'environnement
|
||||
priorité. Un jeton en **lecture** suffit (huggingface.co/settings/tokens), et il
|
||||
n'est envoyé qu'aux adresses Hugging Face.
|
||||
|
||||
## Choisir son moteur : llama.cpp ou Strata
|
||||
|
||||
Loki a deux moteurs, et le choix se fait **par preset** (clé `ENGINE`) :
|
||||
|
||||
- **llama.cpp** (par défaut) — n'importe quel GGUF, réglages complets dans
|
||||
l'éditeur de preset.
|
||||
- **[Strata](https://github.com/Niko1221/Strata)** — spécialisé dans
|
||||
**Qwen3.8 Flash Next** (MoE de 125 B) : les experts sont répartis entre
|
||||
VRAM, RAM et disque pour faire tourner ce modèle sur une carte de joueur.
|
||||
Intégration reprise d'AJEAN (paquet figé « AJEAN MoE 1.1 », vérifié par
|
||||
SHA-256).
|
||||
|
||||
**Paramètres → Moteur → Strata** : la fenêtre lit la machine (cartes, RAM,
|
||||
disque), propose la version (Classique ou Swift 1.5) et la qualité qui tient,
|
||||
puis installe et active le modèle en un clic (téléchargement de 66 à 84 Go dans
|
||||
`/data/strata`). Le modèle installé devient un preset comme les autres :
|
||||
basculer dessus lance Strata, basculer sur un autre preset revient à llama.cpp.
|
||||
Son engrenage ouvre les réglages propres à Strata (contexte jusqu'à 262 144,
|
||||
cache KV fp16/8 bits, prédiction MTP, lecture des images, seconde carte).
|
||||
|
||||
La seconde carte a trois réglages : **non utilisée**, **aide aux experts** (le
|
||||
défaut : elle garde et calcule une part des experts) ou **partage des couches**
|
||||
(les deux cartes se répartissent les couches du modèle). Le partage est souvent
|
||||
plus rapide avec deux cartes identiques (+50 % en génération mesuré par un
|
||||
utilisateur d'AJEAN sur deux RTX 3060) ; sur deux cartes très différentes,
|
||||
l'aide aux experts reste le meilleur choix. En partage des couches, le mode
|
||||
« hors RAM » n'est jamais choisi : la seconde carte n'y garde pas d'experts.
|
||||
|
||||
Depuis le paquet 1.1, la carte d'appoint calcule elle-même ses experts à la
|
||||
lecture des prompts (au lieu de les recopier vers la principale) : les ajouts
|
||||
de 100 à 200 jetons se lisent ~30 à 40 % plus vite (mesures d'AJEAN). Un modèle
|
||||
installé avec la 1.0 passe à la 1.1 **à son premier lancement**, sans
|
||||
réinstallation : Loki télécharge le nouveau paquet (~340 Mo), reprend
|
||||
l'environnement Python et la configuration de la 1.0 (le dossier
|
||||
`/data/strata/ajean-moe-1.0` doit donc rester en place) et réécrit le preset.
|
||||
En cas d'échec, la 1.0 continue de tourner et la raison est dans le journal du
|
||||
moteur. En mode « hors RAM », le contexte est plafonné à 131 072 jetons (au-delà,
|
||||
le cache KV verrouillé en RAM ne tient plus).
|
||||
|
||||
Pré-requis : Linux + NVIDIA RTX 20 ou plus récente (8 Go de VRAM minimum, une
|
||||
deuxième carte est utilisée comme carte d'appoint), environ 46 Go de RAM pour
|
||||
la plus petite qualité, pilote compatible CUDA 12.8. Le conteneur doit pouvoir
|
||||
verrouiller la mémoire : `ulimits: memlock: -1` (déjà présent dans les
|
||||
fichiers compose fournis). Le benchmark et l'optimiseur ne s'appliquent qu'à
|
||||
llama.cpp.
|
||||
|
||||
### Strata sur un SSD dédié (Unraid)
|
||||
|
||||
Strata range **tout** dans `/data/strata` : moteur, environnement Python (~10
|
||||
Go), modèle (66 à 84 Go), et, quand la RAM ne suffit pas à garder les experts
|
||||
(mode « lus depuis le disque »), il les relit en continu depuis ce dossier. Un
|
||||
SSD de 250 Go dédié lui suffit largement (quelle que soit la qualité choisie)
|
||||
et c'est lui qui fait la vitesse en mode disque : un NVMe vaut mieux qu'un SATA,
|
||||
et un disque dur de l'array est à proscrire.
|
||||
|
||||
1. **Le SSD en pool Unraid.** Array arrêté : *Main → Add Pool*, nom `strata`,
|
||||
1 emplacement, assigne le SSD. Démarre l'array, clique le pool, *File
|
||||
system type* = `xfs` (ou `btrfs`), puis *Format* en bas de la page Main.
|
||||
Le SSD est alors monté en `/mnt/strata`.
|
||||
(Avec le plugin *Unassigned Devices* à la place : monte-le, il apparaît en
|
||||
`/mnt/disks/<nom>` — même suite en remplaçant le chemin.)
|
||||
2. **Le dossier** (terminal Unraid) : `mkdir -p /mnt/strata/loki`.
|
||||
Vérifie avec `df -h /mnt/strata` que c'est bien le SSD (~230 Go) et pas
|
||||
`rootfs` : un `/mnt/<pool>` qui n'existe pas est dans la RAM d'Unraid.
|
||||
3. **Ce qui existe déjà.** Si une installation de Strata avait commencé dans
|
||||
`/mnt/user/appdata/loki/data/strata`, *Compose Down* puis, au choix :
|
||||
`rsync -a /mnt/user/appdata/loki/data/strata/ /mnt/strata/loki/` (on garde,
|
||||
les presets restent valides : le chemin vu par le conteneur ne change pas),
|
||||
ou supprime ce dossier pour repartir de zéro. Sinon il reste caché sous le
|
||||
montage et occupe de la place pour rien.
|
||||
4. **Le compose** : décommente dans `docker-compose.unraid.yml` la ligne
|
||||
```yaml
|
||||
- /mnt/strata/loki:/data/strata
|
||||
```
|
||||
(sous les deux volumes existants), puis *Compose Up*.
|
||||
5. **Installer** : *Paramètres → Moteur → Strata*. La fenêtre affiche
|
||||
maintenant l'espace libre **du SSD** ; choisis la version et la qualité
|
||||
proposées, puis installe.
|
||||
|
||||
Pas de partage `/mnt/user/…` pour ce montage : la couche FUSE d'Unraid
|
||||
ralentit fortement la lecture des experts en mmap. Et rien à faire côté mover
|
||||
ou cache : le pool `strata` n'est lié à aucun partage.
|
||||
|
||||
## Installation sur Unraid
|
||||
|
||||
L'image est construite et publiée par GitHub Actions sur GHCR
|
||||
@@ -108,6 +200,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
|
||||
@@ -122,9 +240,43 @@ redémarrages (volume `/data`). Variables d'environnement du conteneur :
|
||||
| `LOKI_HOME` | données (volume) | `/data` |
|
||||
| `LOKI_MODEL_DIRS` | dossiers .gguf additionnels | `/models` |
|
||||
| `HF_TOKEN` | jeton Hugging Face, pour les dépôts à accès restreint (repli : le jeton réglé dans l'UI prime) | — |
|
||||
| `LOKI_CHROME` | binaire du navigateur piloté (contrôle du navigateur) ; par défaut le Chromium de Playwright de l'image | — |
|
||||
| `LOKI_CU_HEADFUL` | `1` : ouvre une vraie fenêtre au lieu du mode headless (machine avec écran) | — |
|
||||
| `LOKI_TRUSTED_HOSTS` | noms de domaine autorisés à servir l'UI **sans** clé de pilotage (reverse proxy), séparés par des virgules | — |
|
||||
| `LOKI_AUTO_UPDATE` | `0` : pas de mise à jour des binaires au démarrage | `1` |
|
||||
| `LOKI_FACADE` | `0` : sans façade Rust, le Go sert directement le port public (porte de sortie) | `1` |
|
||||
| `LOKI_GO_PORT` | port local (127.0.0.1) du serveur Go derrière la façade | `8091` |
|
||||
| `LOKI_CHAT` | qui tient la conversation : la façade Rust par défaut, outils fichiers, shell et git compris, avec l'export, le mode Code et les critères de la discussion ; le Go garde les autres outils et le reste du mode Code. La façade sonde aussi le gabarit du moteur après ses complétions (sonde de gabarit, `NUDGE_IN_TOOL`), range la télémétrie de ses complétions et ses requêtes en vol auprès du Go, honore les motifs de relance personnalisés (`retry_patterns`) et les slots du moteur (`SIDE_SLOT`, `SLOT_PERSIST`, isolation après la vérification) les images gardées (`KEEP_TURN_IMAGES`), le préchauffage (`PREWARM`) et le bloc projet figé (`PROJ_SNAPSHOT`). `go` : la rend au Go (porte de sortie) | Rust |
|
||||
| `LOKI_MODELS` | `rust` : les routes des modèles — liste, dossiers, suppression, téléchargements, catalogue Hugging Face — sont servies par la façade Rust (expérimental, migration en cours) | Go |
|
||||
| `LOKI_DICTATE` | `rust` : la dictée vocale (`/api/transcribe`, `/api/dictate/*`) et son serveur de transcription sont tenus par la façade Rust (expérimental, migration en cours) | Go |
|
||||
| `LOKI_SETTINGS` | `rust` : les réglages partagés — préférences d'affichage, prompt système, intensité du raisonnement, adresse publique — sont servis par la façade Rust (expérimental, migration en cours) | Go |
|
||||
| `LOKI_FILES` | `rust` : les fichiers de la discussion — dépôt, panneau Fichiers, archive .zip, téléchargement, images et vignettes — sont servis par la façade Rust (expérimental, migration en cours) | Go |
|
||||
| `LOKI_PUSH` | `rust` : les notifications Web Push (clé VAPID, abonnements, envoi « réponse prête ») sont tenues par la façade Rust (expérimental, migration en cours) | Go |
|
||||
| `LOKI_MEM` | `rust` : la mémoire (éditeur des pages, index, déplacement entre projets, trackers, mode mémoire, interrupteurs du mode agent), et les outils mémoire de l'agent (mem_search, mem_read, mem_add, mem_edit, tracker) dans les tours de la façade, sont tenus par la façade Rust ; le chiffrement reste au Go (expérimental, migration en cours) | Go |
|
||||
| `LOKI_SERVE` | `rust` : la ligne de commande de llama-server est composée par la façade Rust (`loki-rs engine serve`) au lieu de `loki serve` ; Strata et l'optimiseur restent au Go (expérimental, migration en cours) | Go |
|
||||
| `LOKI_PRESETS` | `rust` : les presets (liste, éditeur et aperçus, suppression, placement auto, presets externes, bascule) sont tenus par la façade Rust ; l'application au moteur reste au Go (expérimental, migration en cours) | Go |
|
||||
| `LOKI_UPDATE` | `rust` : la mise à jour de Loki (`/api/update`, `/api/update/apply`) est servie par la façade Rust ; en conteneur, l'image se met à jour par Docker (expérimental, migration en cours) | Go |
|
||||
| `LOKI_MCP` | `rust` : les serveurs MCP — configuration, catalogue, état, pool de sessions stdio et HTTP — sont tenus par la façade Rust ; le Go lui demande les outils MCP de l'agent et leurs appels ; les tours de la façade appellent son pool directement (expérimental, migration en cours) | Go |
|
||||
| `LOKI_BACKENDS` | `rust` : les backends llama.cpp — statut, vérification git, binaires précompilés, bascule fast/opt, backends custom, GPU vus par un moteur — et le job des moteurs sont tenus par la façade Rust, qui compile aussi llama.cpp depuis les sources sous Linux et macOS ; les jobs du Go (compilation sous Windows, moteur de l'image, Strata) lui rapportent leurs étapes (expérimental, migration en cours) | Go |
|
||||
| `LOKI_STRATA` | `rust` (avec `LOKI_BACKENDS=rust`) : l'écran de Strata — machine, ce qui y tient, recommandation, modèles installés —, ses réglages et son installation sont tenus par la façade Rust ; le lancement reste au Go (expérimental, migration en cours) | Go |
|
||||
| `LOKI_TASKS` | `rust` : les tâches planifiées — liste, enregistrement, bascule, interrupteur maître, lancement, arrêt —, le planificateur et les tâches « script seul » sont tenus par la façade Rust ; les outils task_* de l'agent aussi, dans les tours de la façade ; une tâche IA reste exécutée par le Go, à la demande de la façade (expérimental, migration en cours) | Go |
|
||||
| `LOKI_BENCH` | `rust` : le banc d'essai — lancement, progression, annulation, dernière mesure, mode rapide et mode complet en profondeur — est tenu par la façade Rust ; le verrou de génération reste celui du Go, pris par le bail du moteur (expérimental, migration en cours) | Go |
|
||||
| `LOKI_TUNE` | `rust` : l'optimiseur — lancement, essais, progression, annulation, application dans une copie ou dans le preset, dernier résultat — est tenu par la façade Rust, sous Unix ; le verrou reste le fichier `tune.lock` lu par le Go, les essais des « loki serve » du Go (expérimental, migration en cours) | Go |
|
||||
| `LOKI_ENGINE_IMAGE` | `rust` (avec `LOKI_BACKENDS=rust`) : le panneau « moteur de l'image » — version qui tourne, dernier build publié, version recommandée, mise à jour depuis le registre officiel avec essai à blanc, bascules, retour à la version précédente, vérification du rendu du gabarit — est tenu par la façade Rust (expérimental, migration en cours) | Go |
|
||||
| `LOKI_PROJECTS` | `rust` : le hub des projets (liste et comptes, création, renommage, description, suppression, bascule) est tenu par la façade Rust ; les discussions restent à qui tient la conversation (expérimental, migration en cours) | Go |
|
||||
| `LOKI_SYSTEM` | `rust` : les interrupteurs système — accès internet et Crawl4AI, pilotage du navigateur, clé de complétion, écoute réseau du moteur — sont tenus par la façade Rust (expérimental, migration en cours) ; le navigateur piloté, lui, est celui de la façade dès qu'elle tient la conversation | Go |
|
||||
| `LOKI_ENGINE` | `rust` : le pilotage du moteur (démarrage, arrêt, redémarrage, déchargement et rechargement de la VRAM) est tenu par la façade Rust, en conteneur avec son superviseur ; le verrou de l'optimiseur reste au Go (expérimental, migration en cours) | Go |
|
||||
|
||||
**Accès par nom de domaine.** Sans clé de pilotage, l'API n'accepte que les
|
||||
hôtes locaux (IP, `localhost`, nom sans point, `.local`/`.lan`…) et refuse
|
||||
toute requête venue d'un autre site : une page web ouverte dans un navigateur
|
||||
du réseau ne peut plus piloter Loki en douce (ni par DNS rebinding). Derrière
|
||||
un reverse proxy (`loki.mondomaine.fr`), définir une clé
|
||||
(`docker exec -it loki loki set-web-key`) ou lister le nom dans
|
||||
`LOKI_TRUSTED_HOSTS`.
|
||||
|
||||
En CLI dans le conteneur : `docker exec -it loki loki status` (aussi :
|
||||
`logs`, `restart`, `config`, `bench`, `test`…).
|
||||
`logs`, `restart`, `config`, `bench`, `tune`, `test`…).
|
||||
|
||||
### Données et persistance
|
||||
|
||||
@@ -137,7 +289,9 @@ et modèles compris.
|
||||
| `/data/loki.db` | base bbolt : préférences, discussions, mémoire des réglages |
|
||||
| `/data/presets/` | un `.env` par preset (modèle, contexte, NGL, vision…) |
|
||||
| `/data/models/` | modèles téléchargés depuis l'interface |
|
||||
| `/data/memory/` | pages de mémoire persistante (`.md`) |
|
||||
| `/data/memory/` | pages de mémoire persistante (`.md`) — joignables **uniquement** par les outils `mem_*`, pas au shell ; chiffrées si le chiffrement est actif |
|
||||
| `/data/backups/memory/` | snapshots de la mémoire pris avant chaque opération qui touche à tout |
|
||||
| `/data/scripts/` | scripts durables de l'agent, hors du workspace jetable (planifiables sans modèle) |
|
||||
| `/data/workspace/` | racine du dossier de travail de l'agent |
|
||||
| `/data/workspace/discussions/<id>/` | fichiers d'UNE discussion : dépôts, captures, ce que l'agent y écrit |
|
||||
| `/data/loki-engine.log` | journal de `llama-server` (aussi via `loki logs`) |
|
||||
@@ -154,6 +308,27 @@ Sur Unraid, garde le **même** chemin hôte d'un lancement à l'autre : `/mnt/us
|
||||
emplacements différents dès que le partage n'est pas en cache-only ou que le
|
||||
*mover* est passé. Le compose fourni utilise `/mnt/user/appdata/loki/…`.
|
||||
|
||||
**Performance sur Unraid.** `/mnt/user/…` passe par `shfs`, la couche FUSE des
|
||||
partages : chaque écriture de `loki.db` et chaque page d'un gros modèle relue
|
||||
depuis le disque (MoE plus gros que la RAM, mmappé) traverse un démon en espace
|
||||
utilisateur, ce qui ralentit le décodage. Loki le détecte au démarrage
|
||||
(`fuse.shfs` dans `/proc/self/mountinfo`) et l'affiche en encart d'information —
|
||||
rien n'est perdu ni altéré. Pour l'éviter :
|
||||
|
||||
- **recommandé** — partages en *Exclusive access* (Unraid 6.12+). Ce n'est
|
||||
pas une case mais un état : *Global Share Settings* → *Permit exclusive
|
||||
shares* = Yes, puis le partage `appdata` (et celui des modèles) entièrement
|
||||
sur **un** pool — mover d'abord vers le pool, ensuite *Secondary storage* =
|
||||
None — jusqu'à lire *Exclusive access : Yes* sur la page du partage.
|
||||
Redémarre le conteneur : le chemin `/mnt/user/…` ne change pas, FUSE est
|
||||
court-circuité ;
|
||||
- **avancé** — mappe `/mnt/<ton-pool>/appdata/loki/…` (ex. `/mnt/cache` si ton
|
||||
pool s'appelle `cache`), avec une migration manuelle : `Compose Down`,
|
||||
`rsync -a` de l'ancien dossier vers le nouveau, puis modification des deux
|
||||
lignes `volumes`. Un `/mnt/<pool>` inexistant atterrit dans la RAM d'Unraid
|
||||
(données perdues au redémarrage, serveur saturé par un modèle de 80 Go), et
|
||||
changer le chemin sans migrer donne un `/data` vide.
|
||||
|
||||
**Tu perds modèles, discussions et fichiers à chaque redémarrage ?** C'est le
|
||||
signe que `/data` n'est **pas monté** : le conteneur écrit alors dans sa couche
|
||||
éphémère, détruite à chaque recréation (mise à jour d'image, `compose down`,
|
||||
@@ -175,11 +350,29 @@ Héritées d'AJEAN :
|
||||
|
||||
- **Tchat** avec streaming, raisonnement visible, pièces jointes, export de
|
||||
conversations. La **vision** demande un modèle multimodal *et* son projecteur
|
||||
`mmproj` — voir [Installer un modèle](#installer-un-modèle).
|
||||
`mmproj` — voir [Installer un modèle](#installer-un-modèle). Toute image
|
||||
envoyée au modèle (pièce jointe, `see_image`, capture) est d'abord
|
||||
**redressée** — l'orientation EXIF d'une photo de téléphone est cuite dans
|
||||
les pixels, sinon le projecteur, qui ignore l'EXIF, la voyait couchée — et
|
||||
**ramenée sous 1568 px** de grand côté, taille au-delà de laquelle le base64
|
||||
grossit sans rien apprendre de plus au modèle.
|
||||
- **Mémoire persistante** (`memory off|ondemand|always`).
|
||||
- **Accès internet** : recherche + lecture de pages, moteur Go intégré ou
|
||||
[Crawl4AI](https://github.com/unclecode/crawl4ai) pour les pages JS.
|
||||
- **Agent** : shell, fichiers, workspace (`agent on`).
|
||||
- **Contrôle du navigateur** (`computer on`, ou *Réglages → Contrôle du
|
||||
navigateur*) : l'IA **pilote** un Chromium — celui de Playwright, déjà dans
|
||||
l'image — par le protocole DevTools. Elle ouvre une page, en reçoit les
|
||||
éléments interactifs **numérotés** (`[12] bouton « Se connecter »`) et agit
|
||||
par numéro : `browser_open`, `browser_snapshot`, `browser_find`,
|
||||
`browser_click`, `browser_type`, `browser_key`, `browser_scroll`. Aucune
|
||||
vision requise — ça marche avec de petits modèles texte. Avec un projecteur
|
||||
`mmproj` chargé s'ajoutent `browser_screenshot` (image quadrillée tous les
|
||||
100 px) et `browser_click_xy`, pour ce que l'arbre d'accessibilité ne montre
|
||||
pas (canvas, bandeau en iframe). Ce sont des **actions réelles** sur le web :
|
||||
l'interrupteur est distinct de l'accès internet et n'agit qu'en **mode
|
||||
agent**, au même niveau de confiance que `bash`. La session navigateur est
|
||||
unique et réutilisée entre les appels ; couper l'interrupteur la ferme.
|
||||
- **Serveurs MCP** : Node.js (`npx`) et uv (`uvx`) sont inclus dans l'image, pour
|
||||
les serveurs écrits en JavaScript comme en Python. Au **premier** lancement,
|
||||
`npx`/`uvx` téléchargent le paquet du serveur — Loki attend jusqu'à 3 minutes
|
||||
@@ -195,7 +388,48 @@ Héritées d'AJEAN :
|
||||
activer avant l'exécution, accès mémoire et web. Un **interrupteur maître**
|
||||
suspend tout d'un coup. Sans **mode agent**, une tâche s'exécute mais n'a aucun
|
||||
outil pour agir — l'interface le dit. Une seule inférence tourne à la fois : une
|
||||
tâche attend son tour, et le bouton stop du chat l'interrompt.
|
||||
tâche attend son tour, et le bouton stop du chat l'interrompt. Deux types de
|
||||
tâche : une **consigne IA**, ou un **script seul** — un fichier du dossier
|
||||
`/data/scripts` lancé directement, **sans charger le modèle ni consommer un
|
||||
token** (sauvegarde, synchro, nettoyage n'ont rien à demander à un LLM). Ce
|
||||
dossier vit **hors du workspace** : vider une discussion, ou la supprimer,
|
||||
n'y touche pas. L'IA elle-même dispose des outils `task_create`, `task_list`,
|
||||
`task_update` et `task_delete` — elle peut donc se poser ses propres rappels
|
||||
et veilles — cloisonnés par projet : dans un projet, elle ne voit et ne
|
||||
pilote que les tâches de ce projet.
|
||||
- **Notifications** (Web Push) : le serveur prévient le navigateur **à la fin
|
||||
d'une réponse et à la fin d'une tâche planifiée**, même l'app fermée ou le
|
||||
téléphone verrouillé — c'est le serveur qui pousse, pas la page (un onglet
|
||||
caché relâche son flux). L'interrupteur est dans *Réglages → Mode agent*, à
|
||||
armer **sur chaque appareil** (l'abonnement appartient au navigateur). Exige
|
||||
**HTTPS** ou localhost ; sur iPhone, il faut d'abord ajouter Loki à l'écran
|
||||
d'accueil. Les clés VAPID sont générées à la première demande et rangées avec
|
||||
le reste sous `/data` ; le corps de la notification reste générique (aucun
|
||||
extrait de réponse), puisqu'elle transite par Apple ou Google.
|
||||
- **Chiffrement de la mémoire** (*Réglages → Mémoire*) : pages mémoire,
|
||||
discussions, blocs archivés au compactage et trackers chiffrés en
|
||||
**AES-256-GCM** sur le disque. Chiffrement à enveloppe : une clé de données
|
||||
(DEK) tirée une fois, enfermée dans un coffre par une clé dérivée en
|
||||
**Argon2id**. Ce qui ouvre le coffre : la **clé de pilotage de l'appareil**
|
||||
(le serveur n'en garde qu'une empreinte — il ne peut pas ouvrir le coffre
|
||||
seul) ou une **clé de récupération** affichée une seule fois à l'activation.
|
||||
La DEK ne vit qu'en RAM : après un redémarrage à froid, la mémoire est
|
||||
verrouillée jusqu'à ce qu'un navigateur se reconnecte — verrouillée, Loki
|
||||
n'écrit jamais de clair par-dessus du chiffré, il refuse d'écrire. Règle
|
||||
tenue partout : **rien n'est supprimé avant que son remplaçant ait été relu
|
||||
et vérifié**, un **snapshot** est pris avant chaque bascule, et une migration
|
||||
interrompue reprend au démarrage.
|
||||
- **Sauvegarde chiffrée en fichier** : *exporter* télécharge un paquet scellé
|
||||
(mémoire, presets, réglages) que *importer* rejoue sur un autre serveur avec
|
||||
la seule clé — de quoi remonter le conteneur ailleurs. C'est la sauvegarde de
|
||||
l'amont sans son relais : ici rien ne part sur un service tiers, le fichier
|
||||
reste chez toi.
|
||||
- **Interface en anglais** (*Réglages → Apparence → Langue*) : la source reste
|
||||
française et « English » applique un dictionnaire sur la coque — navigation,
|
||||
intitulés, boutons, interrupteurs. Ce qui n'y figure pas **reste en
|
||||
français** plutôt que d'afficher une clé technique, et le fil de discussion
|
||||
n'est jamais touché : c'est ton contenu. Le dictionnaire s'enrichit sans
|
||||
toucher au reste de l'interface (`ui/src/js/28-i18n.js`).
|
||||
- **Presets** de configuration par modèle, bench, auto-détection GPU.
|
||||
- **Échantillonnage réglable par preset** : température, `top_p`, `top_k`,
|
||||
`min_p`, pénalités de présence et de répétition, dans l'éditeur de preset. Ces
|
||||
@@ -228,7 +462,15 @@ Ajoutées par ce fork :
|
||||
de code fait apparaître une puce « passer en mode Code ? » — suggestion,
|
||||
jamais bascule automatique. Conception reprise
|
||||
d'[OpenFox](https://github.com/co-l/openfox) (MIT), réécrite en Go — voir
|
||||
`NOTICE.md`.
|
||||
`NOTICE.md`. **Sous-agents** : l'outil `subagent` délègue une recherche
|
||||
(`explorer`), une relecture (`code-reviewer`) ou un découpage (`planner`) à
|
||||
un rôle qui travaille dans **son propre contexte** et ne rend que sa réponse.
|
||||
Sur un modèle local, c'est ce qui sauve la fenêtre : « trouve où est géré le
|
||||
cache » coûte dix lectures de fichiers, qui resteraient sinon dans
|
||||
l'historique jusqu'à la compaction alors que seule la réponse comptait. Tous
|
||||
les rôles délégués sont en **lecture seule** — ce qui modifie le dépôt reste
|
||||
dans le fil principal, sous tes yeux — et un sous-agent ne peut pas en
|
||||
appeler un autre.
|
||||
- **Catalogue MCP** : le panneau *Serveurs MCP* offre un bouton **catalogue** —
|
||||
une vingtaine de serveurs connus (filesystem, git, fetch, memory, sqlite,
|
||||
playwright, context7, github…) avec leur commande déjà renseignée, classés par
|
||||
@@ -255,6 +497,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
|
||||
@@ -290,6 +535,12 @@ Ajoutées par ce fork :
|
||||
latérale s'escamote pour rendre toute la largeur au fil ; les discussions s'y
|
||||
cherchent au clavier et les jauges **GPU / VRAM / mémoire vive** restent
|
||||
visibles en pied de colonne.
|
||||
- **Libérer la VRAM d'un clic** : sur les jauges du moniteur, un bouton décharge
|
||||
le modèle et arrête le moteur (ainsi que le serveur de dictée, qui occupe la
|
||||
carte lui aussi) pour rendre la mémoire vidéo à une autre application — jeu,
|
||||
encodage, autre serveur d'inférence. Le bilan est annoncé en Gio réellement
|
||||
rendus, et le même bouton devient **Recharger le modèle** pour reprendre la
|
||||
main. Routes : `POST /api/vram/unload` et `POST /api/vram/reload`.
|
||||
- **API OpenAI servie par Loki** : `/v1/*` est exposé **sur le port de
|
||||
l'interface** (8090) et relayé vers llama-server, au lieu d'annoncer l'adresse
|
||||
du moteur. Conséquence directe : l'API est joignable partout où l'interface
|
||||
@@ -320,8 +571,24 @@ Ajoutées par ce fork :
|
||||
`AGENT_BUDGET=off` le désactive complètement.
|
||||
- **Identité** : ton prénom et un avatar emoji pour toi et pour Loki, affichés
|
||||
dans le fil.
|
||||
- **Paramètres** : les réglages d'application (identité, apparence, accès
|
||||
OpenAI, actions) sont regroupés à part des réglages d'IA.
|
||||
- **Réglages en modale** : tous les réglages vivent dans une fenêtre à deux
|
||||
volets — la nav des sections à gauche (IA, moteur, application), le panneau
|
||||
choisi à droite. La barre latérale ne garde que les discussions (les plus
|
||||
récentes en tête) et le moniteur machine.
|
||||
- **Nom du modèle sur chaque réponse** : une pastille à côté de « Loki » dit
|
||||
quel modèle a produit la réponse. Elle est journalisée avec le tour : elle
|
||||
survit au rechargement, et un vieux tour garde le modèle de l'époque.
|
||||
- **Dictée vocale** : un bouton micro dans la carte de saisie enregistre,
|
||||
transcrit **en local** (whisper.cpp, compilé dans l'image ; modèle
|
||||
`small-q5_1` multilingue ~190 Mo téléchargé au premier usage dans
|
||||
`/data/whisper/`) et pose le texte dans le champ. ⚠️ le navigateur n'autorise
|
||||
le micro qu'en **HTTPS** (ou sur `localhost`) — derrière un reverse proxy
|
||||
TLS, rien à faire ; en `http://IP:8090`, le bouton l'explique.
|
||||
- **Cartes raisonnement/outils à hauteur bornée** : un long raisonnement ne
|
||||
fait plus grandir la page de plusieurs écrans — la carte reste à taille fixe
|
||||
et défile toute seule pendant la génération. Sur un raisonnement géant, seul
|
||||
le bas du bloc est re-rendu en direct (le texte complet est posé à la fin) :
|
||||
l'affichage ne se fige plus.
|
||||
|
||||
Retirées par ce fork :
|
||||
|
||||
@@ -338,6 +605,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) |
|
||||
@@ -352,7 +1139,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 :
|
||||
@@ -413,6 +1200,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.
|
||||
|
||||
|
||||
+138
-8
@@ -1,19 +1,149 @@
|
||||
La grande nouveauté de cette version : AJEAN sait enfin voir les images.
|
||||
# Loki 0.15.0
|
||||
|
||||
## La vision, configurable par preset
|
||||
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.
|
||||
|
||||
Jusqu'ici rien dans l'interface ne permettait de donner des yeux à un modèle. Il fallait glisser l'option `--mmproj` à la main dans la configuration brute, et son chemin n'était même pas résolu comme celui du modèle. L'éditeur de preset gagne un champ **Vision** : tu y choisis le projecteur multimodal (fichier `mmproj`) qui accompagne le modèle, et c'est tout. Au démarrage du moteur, AJEAN le charge tout seul.
|
||||
La section **Performance** du README rassemble toutes les clés, leur défaut et
|
||||
leur prix.
|
||||
|
||||
Le champ ne liste que les projecteurs (les fichiers dont le nom contient `mmproj`), pas les modèles de plusieurs Go, pour que le choix reste lisible. Et s'il te manque un projecteur, le champ « télécharger un modèle » accepte aussi un lien vers un `mmproj` : une fois récupéré, il se place directement dans le champ Vision.
|
||||
## Actif d'office, sans perte
|
||||
|
||||
## Les images arrivent vraiment au modèle
|
||||
**La conversation n'est plus recalculée pour rien.**
|
||||
|
||||
Avant, une image collée dans le chat était simplement déposée comme un fichier dans le dossier de travail, à charge pour le modèle de l'ouvrir avec ses outils (ce qui ne donnait qu'un tas d'octets illisibles). Désormais, quand un projecteur est configuré, l'image part au modèle en contenu multimodal : il la voit, et peut en parler.
|
||||
- **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.
|
||||
|
||||
**Le matériel est mieux employé.**
|
||||
|
||||
- **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).
|
||||
|
||||
**Loki lui-même pèse moins.**
|
||||
|
||||
- É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.
|
||||
|
||||
**On peut enfin mesurer.**
|
||||
|
||||
- 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.
|
||||
|
||||
**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.
|
||||
|
||||
## En opt-in : à essayer, à mesurer
|
||||
|
||||
| Clé | Ce qu'elle fait |
|
||||
|---|---|
|
||||
| `PREWARM=on` | prépare le prochain tour pendant que tu lis (`full` : aussi la discussion qu'on ouvre) |
|
||||
| `COMPACT_CONTINUATION=on` | le résumé de compaction prolonge le prompt en cache au lieu d'être calculé à froid |
|
||||
| `PROJ_SNAPSHOT=on` | bloc projet figé par discussion, changements livrés à part ; date et dossier sortent du système |
|
||||
| `REASONING_ECHO=on` | renvoie au moteur local la réflexion du modèle, au format entraîné |
|
||||
| `SPEC=auto` / `mtp` | décodage spéculatif MTP (tête détectée dans le fichier, garde-fous VRAM) |
|
||||
| `SPEC=ngram` / `mtp+ngram` | n-grammes du contexte, utiles en mode Code |
|
||||
| `SIDE_SLOT=on` | second slot pour les travaux annexes (VRAM en double) |
|
||||
| `SLOT_PERSIST=on` | état du slot gardé à la bascule de preset |
|
||||
| `SPLIT_MODE=tensor` | parallélisme de tenseurs entre cartes (expérimental) |
|
||||
| `FIT_TARGET`, `OP_OFFLOAD_MIN_BATCH`, `CUDA_GRAPH_OPT` | réglages d'expert pour cartes inégales et MoE |
|
||||
| `KEEP_TURN_IMAGES=on` | **zone grise** : garde les images des outils dans l'historique |
|
||||
| `NUDGE_IN_TOOL=on` | **zone grise** : rappel de budget dans le résultat d'outil |
|
||||
|
||||
Et deux outils qui ne font rien d'eux-mêmes :
|
||||
|
||||
- **Optimiseur** (`loki tune`, bouton « Optimiser… ») : cherche lots, threads,
|
||||
marges et placement sur un moteur d'essai isolé ; rien n'est écrit sans un
|
||||
clic, et l'application est vérifiée puis défaite si le moteur ne tient pas.
|
||||
- **« Dupliquer en placement auto… »** pour un MoE aux experts placés à la
|
||||
main : une copie du preset où `--fit` les répartit.
|
||||
|
||||
## Ordre de test conseillé
|
||||
|
||||
Une étape à la fois, mesurée avant la suivante :
|
||||
|
||||
1. **Bench complet** du preset tel quel : la référence.
|
||||
2. **`PREWARM` + `COMPACT_CONTINUATION`**, une vraie session de travail, puis
|
||||
`/api/perf/summary` : le cache perdu (`lost`) doit baisser.
|
||||
3. **Optimiseur** sur le preset en service.
|
||||
4. **MoE** : « Dupliquer en placement auto… », bench complet des deux.
|
||||
5. **Mise à jour du moteur** vers la version recommandée (encart du panneau
|
||||
Moteur).
|
||||
6. **`SPEC=auto`** (ou `mtp`) si le modèle a une tête MTP.
|
||||
|
||||
## Moteur : version recommandée et retour arrière
|
||||
|
||||
Quand le moteur officiel est antérieur à `b10864`, le panneau Moteur dit ce
|
||||
qu'une mise à jour apporte (points de reprise des hybrides, MTP rapide) et la
|
||||
propose — sur clic seulement, version vérifiée sur le registre. La version
|
||||
quittée est gardée : **Revenir à la version précédente** y ramène sans réseau.
|
||||
|
||||
Depuis `b10763`, llama-server garde par défaut la réflexion des tours passés :
|
||||
un gabarit comme Qwen3.6 la rendrait vide. Avant de basculer, Loki le détecte et
|
||||
propose `REASONING_PRESERVE=off` ; après, il compare le rendu de l'ancien et du
|
||||
nouveau moteur et signale le premier écart.
|
||||
|
||||
## Ce qui a été écarté
|
||||
|
||||
- **`--cache-reuse`** : réutilise du cache calculé sous un autre contexte, donc
|
||||
change les sorties.
|
||||
- **`--context-shift`** : retire des jetons du contexte, et ne marche pas sur
|
||||
les hybrides.
|
||||
- **Cache KV quantifié par défaut** : `q8_0` n'est pas exact, `q4_0` perd
|
||||
mesurablement, pour ~2 Gio gagnés sur un 27B hybride. Reste un choix
|
||||
explicite (`KV_TYPE`).
|
||||
|
||||
## Autres corrections
|
||||
|
||||
- Une compaction en plein tour ne fait plus disparaître le rappel des pages
|
||||
mémoire lues.
|
||||
- Un `loki tune` tué net ne laisse plus le moteur arrêté ; lancé avec un autre
|
||||
`LOKI_HOME` que l'interface (`sudo`), il refuse au lieu de charger un essai à
|
||||
côté d'un moteur vivant.
|
||||
- L'ancienne option « n-grammes (mod) » de l'éditeur rejoint `SPEC=ngram`, avec
|
||||
une migration proposée (jamais faite d'office).
|
||||
- Accès refusé : l'interface dit pourquoi et comment le lever.
|
||||
|
||||
## Mise à jour
|
||||
|
||||
```
|
||||
ajean update
|
||||
docker compose pull && docker compose up -d
|
||||
```
|
||||
|
||||
Non vérifié sur cette version : le résultat final dépend d'un modèle vision et de son projecteur compatibles (Qwen2.5-VL, Gemma 3, etc.). À noter, l'image reste dans l'historique de la conversation et repart au moteur à chaque tour tant que la conversation dure.
|
||||
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,14 +2,14 @@
|
||||
"FixedFileInfo": {
|
||||
"FileVersion": {
|
||||
"Major": 0,
|
||||
"Minor": 9,
|
||||
"Patch": 4,
|
||||
"Minor": 15,
|
||||
"Patch": 0,
|
||||
"Build": 0
|
||||
},
|
||||
"ProductVersion": {
|
||||
"Major": 0,
|
||||
"Minor": 9,
|
||||
"Patch": 4,
|
||||
"Minor": 15,
|
||||
"Patch": 0,
|
||||
"Build": 0
|
||||
},
|
||||
"FileFlagsMask": "3f",
|
||||
@@ -25,7 +25,7 @@
|
||||
"LegalCopyright": "Copyright (c) 2026 Loki contributors. MIT License.",
|
||||
"OriginalFilename": "loki.exe",
|
||||
"ProductName": "Loki",
|
||||
"ProductVersion": "0.9.4",
|
||||
"ProductVersion": "0.15.0",
|
||||
"Comments": "https://github.com/R0m1k3/Loki — projet open source (MIT)"
|
||||
},
|
||||
"VarFileInfo": {
|
||||
@@ -34,4 +34,4 @@
|
||||
"CharsetID": "04B0"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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:
|
||||
@@ -34,9 +36,50 @@ services:
|
||||
# lancement). Ex. LOKI_MODEL=Qwen3-14B-Q4_K_M.gguf après dépôt du
|
||||
# fichier dans /mnt/user/appdata/loki/models.
|
||||
- LOKI_MODEL=
|
||||
# Accès par NOM DE DOMAINE (reverse proxy, ex. loki.mondomaine.fr) sans
|
||||
# clé de pilotage : liste ici le(s) nom(s), séparés par des virgules —
|
||||
# sinon l'API répond 403 (protection contre les sites tiers / DNS
|
||||
# rebinding). Inutile pour un accès par IP ou nom local.
|
||||
- LOKI_TRUSTED_HOSTS=
|
||||
# Conversation tenue par la façade Rust (défaut). « go » la rend au Go si
|
||||
# quelque chose ne va pas — puis Compose Down / Up.
|
||||
# - LOKI_CHAT=go
|
||||
volumes:
|
||||
# PERFORMANCE — /mnt/user/… passe par la couche FUSE des partages
|
||||
# Unraid (shfs) : chaque écriture de la base et chaque page d'un gros
|
||||
# modèle relue depuis le disque (modèle plus gros que la RAM) traverse un
|
||||
# démon en espace utilisateur. Loki le signale au démarrage (encart
|
||||
# d'information, rien n'est perdu). Deux remèdes, au choix :
|
||||
# - Recommandé (Unraid 6.12+) : partages en « Exclusive access ».
|
||||
# Ce n'est pas une case mais un état : Settings -> Global Share
|
||||
# Settings -> « Permit exclusive shares » = Yes, puis le partage
|
||||
# appdata (et celui des modèles) entièrement sur UN pool — mover
|
||||
# d'abord vers le pool, ensuite Secondary storage = None. La page du
|
||||
# partage affiche alors « Exclusive access : Yes ». Redémarre le
|
||||
# conteneur : le chemin /mnt/user reste le même, FUSE est
|
||||
# court-circuité.
|
||||
# - Avancé : mapper /mnt/<ton-pool>/appdata/loki/… (ex. /mnt/cache si
|
||||
# ton pool s'appelle « cache »). Migration MANUELLE obligatoire :
|
||||
# Compose Down, rsync -a de l'ancien dossier vers le nouveau, puis
|
||||
# changer les deux lignes ci-dessous. Un /mnt/<pool> qui n'existe
|
||||
# pas atterrit dans la RAM d'Unraid : données perdues au redémarrage,
|
||||
# et un modèle de 80 Go peut saturer la mémoire du serveur.
|
||||
# Ne change JAMAIS ce chemin sans migrer : /data apparaîtrait vide.
|
||||
# /data : config, base, mémoire, workspace, modèles téléchargés par l'UI
|
||||
- /mnt/user/appdata/loki/data:/data
|
||||
# /models : dossier de .gguf déposés à la main (gros fichiers)
|
||||
- /mnt/user/appdata/loki/models:/models
|
||||
# Strata sur un SSD dédié (optionnel) : tout ce que Strata installe
|
||||
# (moteur, Python, modèle de 66 à 84 Go, experts lus en mmap) va dans
|
||||
# /data/strata. Monter un SSD à cet endroit l'y envoie entier. Le SSD
|
||||
# doit être un pool Unraid (Main -> Add Pool, ex. « strata ») : chemin
|
||||
# /mnt/<pool>/…, PAS /mnt/user/… (FUSE ralentit le mmap des experts).
|
||||
# Vérifie que /mnt/strata existe avant Compose Up : sinon Docker crée
|
||||
# le dossier dans la RAM d'Unraid. Détails : README, « Strata sur un
|
||||
# SSD dédié ».
|
||||
# - /mnt/strata/loki:/data/strata
|
||||
# Moteur Strata (Qwen3.8 Flash Next) : il verrouille ses experts en RAM
|
||||
# (mlock, 30 à 50 Go). La limite Docker par défaut (64 Ko) l'en empêche.
|
||||
ulimits:
|
||||
memlock: -1
|
||||
restart: unless-stopped
|
||||
@@ -22,6 +22,9 @@ services:
|
||||
- LOKI_MODEL=${LOKI_MODEL:-}
|
||||
- LOKI_CTX=${LOKI_CTX:-}
|
||||
- LOKI_NGL=${LOKI_NGL:-}
|
||||
# Accès par nom de domaine (reverse proxy) sans clé de pilotage : nom(s)
|
||||
# autorisé(s), séparés par des virgules — sinon l'API répond 403.
|
||||
- LOKI_TRUSTED_HOSTS=${LOKI_TRUSTED_HOSTS:-}
|
||||
volumes:
|
||||
# /data = LOKI_HOME : config, base bbolt, mémoire, workspace, modèles téléchargés
|
||||
- ./data:/data
|
||||
@@ -34,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
|
||||
+58
-4
@@ -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,10 +16,55 @@ 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.
|
||||
loki config set "BIN=${LOKI_ENGINE_BIN:-/app/llama-server}"
|
||||
#
|
||||
# SAUF si l'utilisateur a mis le moteur à jour depuis l'interface : celui-là
|
||||
# vit sous $LOKI_HOME/engine/<version>/ (volume de données, il survit au
|
||||
# conteneur) et c'est lui qu'il a choisi. Le reposer sur /app à chaque
|
||||
# redémarrage ramenait silencieusement l'ancien moteur — un modèle qui
|
||||
# chargeait la veille ne chargeait plus le lendemain, « unknown model
|
||||
# architecture », sans que rien n'ait été touché. On ne le garde que s'il
|
||||
# existe encore : un dossier supprimé à la main ne doit pas laisser BIN dans
|
||||
# le vide.
|
||||
cur=$(loki config get BIN)
|
||||
case "$cur" in
|
||||
"$LOKI_HOME"/engine/*/llama-server) [ -x "$cur" ] || cur="" ;;
|
||||
*) cur="" ;;
|
||||
esac
|
||||
if [ -n "$cur" ]; then
|
||||
echo "[entrypoint] moteur mis à jour conservé : $cur"
|
||||
else
|
||||
loki config set "BIN=${LOKI_ENGINE_BIN:-/app/llama-server}"
|
||||
fi
|
||||
|
||||
# Les autres clés ne sont semées QUE si absentes : ce que l'utilisateur règle
|
||||
# ensuite dans l'UI (modèle, contexte…) est conservé d'un redémarrage à l'autre.
|
||||
@@ -41,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
|
||||
File diff suppressed because it is too large.
Load diff
@@ -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,187 @@
|
||||
# Dictée en temps réel, configurable selon le matériel
|
||||
|
||||
*Conception validée le 21 août 2026.*
|
||||
|
||||
## Le problème
|
||||
|
||||
La dictée fonctionne depuis la correction du binaire non portable (PR #18), mais deux
|
||||
défauts la rendent pénible :
|
||||
|
||||
1. **Elle comprend mal.** Le modèle `small-q5_1` est le plus petit modèle multilingue
|
||||
utilisable, et `-l auto` lui fait redeviner la langue à chaque appel.
|
||||
2. **Elle est lente et muette pendant l'enregistrement.** Le texte n'arrive qu'à
|
||||
l'arrêt du micro, après ~3 s de calcul pour 3,4 s d'audio.
|
||||
|
||||
La lenteur a une cause structurelle : chaque `POST /api/transcribe` lance un processus
|
||||
`whisper-cli` qui **recharge le modèle depuis le disque**, transcrit, puis meurt. Le
|
||||
chargement domine le temps de réponse. Aucun réglage ne rattrape ça, et le découpage en
|
||||
tranches — indispensable pour écrire pendant qu'on parle — le rendrait catastrophique :
|
||||
un rechargement complet du modèle toutes les quelques secondes.
|
||||
|
||||
## Ce qu'on construit
|
||||
|
||||
Trois choses, dans cet ordre de dépendance :
|
||||
|
||||
1. `whisper-server` supervisé par Loki, remplaçant les lancements de `whisper-cli`
|
||||
2. Un découpage en tranches piloté par le serveur, alimenté par une WebSocket
|
||||
3. Un panneau **Dictée** dans les réglages : GPU, modèle, langue, réactivité, test du micro
|
||||
|
||||
## 1. Le moteur
|
||||
|
||||
### whisper-server plutôt que whisper-cli
|
||||
|
||||
`whisper.cpp` fournit `whisper-server` : modèle chargé **une fois**, requêtes en HTTP.
|
||||
Loki le supervise comme il supervise déjà `llama-server`.
|
||||
|
||||
**Démarrage paresseux, extinction sur inactivité.** Le serveur démarre au premier clic
|
||||
sur le micro et s'arrête après ~10 min sans dictée. La machine cible a deux GPU dont on
|
||||
veut préserver la VRAM : whisper n'a aucune raison d'occuper plusieurs centaines de Mo
|
||||
pendant qu'on ne dicte pas. Le premier clic après extinction coûte le chargement du
|
||||
modèle ; les suivants sont immédiats.
|
||||
|
||||
### Choix du GPU
|
||||
|
||||
`CUDA_VISIBLE_DEVICES` posé sur le processus `whisper-server`. Pas de drapeau à inventer,
|
||||
pas de code à maintenir dans ggml :
|
||||
|
||||
- `CUDA_VISIBLE_DEVICES=1` → whisper ne voit que le second GPU
|
||||
- valeur vide → CPU seul
|
||||
|
||||
Changer le GPU, le modèle ou la langue redémarre le processus. Rien d'autre ne bouge.
|
||||
|
||||
### Compilation : deux binaires, pas un
|
||||
|
||||
L'étape `whisperbuild` produit **deux** binaires :
|
||||
|
||||
- `whisper-server-cpu`, bâti comme aujourd'hui (`ubuntu:22.04`)
|
||||
- `whisper-server-cuda`, bâti sur une image `nvidia/cuda:*-devel` avec `-DGGML_CUDA=ON`
|
||||
|
||||
La raison est dans le Dockerfile lui-même : `LLAMACPP_IMAGE` accepte la variante CPU
|
||||
(`ghcr.io/ggml-org/llama.cpp:server`, ligne 11). Sur cette base, les bibliothèques CUDA
|
||||
sont absentes et un binaire lié à CUDA **ne démarre pas du tout** — pas de repli
|
||||
possible, l'éditeur de liens échoue avant le premier octet de code. Un unique binaire
|
||||
CUDA condamnerait donc la variante CPU de l'image.
|
||||
|
||||
Loki choisit à l'exécution : binaire CUDA si un GPU est demandé *et* que le binaire
|
||||
démarre, binaire CPU sinon. Un échec de démarrage du binaire CUDA bascule sur le CPU en
|
||||
le disant, plutôt que de laisser la dictée morte.
|
||||
|
||||
**Les drapeaux de portabilité restent sur les deux cibles** : `GGML_NATIVE=OFF`,
|
||||
`GGML_AVX512=OFF`, `GGML_AMX_*=OFF`. Le bug corrigé en PR #18 — binaire compilé pour le
|
||||
processeur du runner, SIGILL ailleurs — ne doit pas revenir par la porte du build CUDA.
|
||||
|
||||
## 2. Le découpage
|
||||
|
||||
### On coupe dans les silences, jamais à heure fixe
|
||||
|
||||
Une tranche toutes les N secondes tombe statistiquement au milieu d'un mot une fois sur
|
||||
deux. Whisper reçoit alors une demi-syllabe sans contexte, l'entrée même qui lui fait
|
||||
produire du charabia. Couper au mauvais endroit dégraderait la reconnaissance au lieu de
|
||||
l'améliorer.
|
||||
|
||||
Le **seuil de bruit est adaptatif**, pas une constante. Un seuil fixe se trompe des deux
|
||||
côtés : trop haut dans une pièce silencieuse avec un micro discret, il ne coupe jamais ;
|
||||
trop bas près d'un ventilateur, il coupe en permanence. Le découpeur mesure donc le
|
||||
niveau des premières centaines de millisecondes comme plancher de bruit ambiant, le
|
||||
réévalue en continu sur les passages calmes, et considère « silence » tout ce qui reste
|
||||
proche de ce plancher. Le cas dégénéré — un plancher qui monte jusqu'à avaler la parole —
|
||||
est borné par la coupure forcée.
|
||||
|
||||
Règle : on ferme la tranche dès qu'on observe **~400 ms de silence** *et*
|
||||
qu'au moins **1,5 s de parole** est en réserve. Filet de sécurité : au-delà de **~8 s**
|
||||
sans silence exploitable, on coupe quand même — quelqu'un qui parle sans respirer ne doit
|
||||
pas voir le texte se figer.
|
||||
|
||||
Le réglage « réactivité » pilote ces bornes **ensemble** plutôt que d'exposer des curseurs
|
||||
dont personne ne connaît l'interaction :
|
||||
|
||||
| Réglage | Réserve minimale | Coupure forcée |
|
||||
|---|---|---|
|
||||
| Court | 1 s | 5 s |
|
||||
| Moyen (défaut) | 1,5 s | 8 s |
|
||||
| Long | 3 s | 15 s |
|
||||
|
||||
### La logique vit en Go
|
||||
|
||||
Le navigateur ouvre une WebSocket et pousse le PCM au fil de l'eau **sans rien décider**.
|
||||
Go accumule, détecte les silences, appelle `whisper-server`, renvoie chaque tranche.
|
||||
|
||||
Deux raisons. Il n'existe aucun harnais de test JavaScript dans le dépôt, alors que la CI
|
||||
fait tourner `go test` — et le découpeur est précisément la pièce qui mérite des tests.
|
||||
Et le navigateur redevient bête : capter, envoyer, afficher.
|
||||
|
||||
### Contrat de la socket
|
||||
|
||||
- **Client → serveur** : trames binaires, PCM 16 kHz mono 16 bits
|
||||
- **Serveur → client** : JSON, `{"texte": "..."}` par tranche définitive, `{"erreur": "..."}` sinon
|
||||
|
||||
Le champ de saisie **ajoute à la suite, sans jamais réécrire**. Aucune révision d'un texte
|
||||
déjà affiché : c'est le comportement retenu, contre l'affichage mot à mot qui se corrige
|
||||
sous les yeux.
|
||||
|
||||
### Langue forcée par défaut
|
||||
|
||||
Le réglage part sur **français** plutôt que `-l auto`. Sur des tranches de quelques
|
||||
secondes, la détection automatique se trompe régulièrement — c'est le mécanisme le plus
|
||||
probable derrière les `ლლლ` géorgiens observés. Le mode auto reste disponible.
|
||||
|
||||
## 3. Le panneau Dictée
|
||||
|
||||
Nouveau `data-pane="dictee"` dans la modale des réglages, entre « Moteur llama.cpp » et
|
||||
« Identité ». Suit le gabarit des panneaux existants.
|
||||
|
||||
| Réglage | Contenu |
|
||||
|---|---|
|
||||
| **Matériel** | GPU détectés avec VRAM libre (`/api/vram`, `/api/backends/devices`), plus « CPU seul » |
|
||||
| **Modèle** | `large-v3-turbo` q5_0 (574 Mo) par défaut, `medium` q5_0 (539 Mo), `small` q5_1 (190 Mo), `large-v3` q5_0 (1,1 Go) |
|
||||
| **Langue** | Français par défaut, auto et autres langues disponibles |
|
||||
| **Réactivité** | Court / Moyen / Long — les bornes ci-dessus |
|
||||
| **État** | whisper-server allumé ou éteint, modèle chargé, dernière erreur |
|
||||
| **Test du micro** | Capte 3 s, affiche le niveau réel et la transcription, sans toucher au champ de saisie |
|
||||
|
||||
Les modèles restent **hors de l'image**, dans `/data/whisper/` : ils survivent aux
|
||||
recréations du conteneur et ne gonflent pas le tirage. Téléchargement à la demande avec
|
||||
progression et suppression, en généralisant le mécanisme mono-modèle existant.
|
||||
|
||||
Persistance des réglages : `putStr(bkState, …)`, le magasin clé/valeur déjà utilisé pour
|
||||
le jeton Hugging Face.
|
||||
|
||||
## Les pannes, nommées
|
||||
|
||||
Chaque échec dit ce qui a échoué et ce qu'on peut y faire :
|
||||
|
||||
- **VRAM insuffisante au démarrage** → on le dit et on propose le repli CPU, plutôt que
|
||||
d'échouer sec
|
||||
- **Modèle absent** → téléchargement avec progression, pas un micro qui ne répond pas
|
||||
- **Socket coupée en pleine dictée** → le texte déjà obtenu reste dans le champ,
|
||||
l'utilisateur est prévenu
|
||||
- **whisper-server qui meurt** → on dit **comment** il est mort (code de sortie ou signal),
|
||||
pas la dernière ligne de son journal. La leçon de la PR #18 : sur une mort par signal,
|
||||
cette dernière ligne ressemble à une explication sans en être une, et envoie chercher
|
||||
dans la mauvaise direction.
|
||||
|
||||
`POST /api/transcribe` **survit** et passe désormais par `whisper-server`. C'est le chemin
|
||||
de repli si la WebSocket ne s'établit pas, et ce qui rend le test du micro trivial.
|
||||
|
||||
## Tests
|
||||
|
||||
En Go, sur ce qui peut réellement casser :
|
||||
|
||||
- **le découpeur** — silence franc, parole ininterrompue jusqu'à la coupure forcée,
|
||||
tranche trop courte rejetée, silence total, bruit de fond continu au-dessus du seuil
|
||||
- **l'environnement de whisper-server** — indice GPU → `CUDA_VISIBLE_DEVICES`, chemin du
|
||||
modèle, langue
|
||||
- **l'aller-retour des réglages** dans `bkState`
|
||||
- **l'extinction sur inactivité**
|
||||
- **le garde-fou du Dockerfile**, étendu : `GGML_NATIVE=OFF` conservé sur les deux cibles,
|
||||
présence des cibles `whisper-server` CPU et CUDA, `GGML_CUDA=ON` sur la seconde
|
||||
|
||||
## Ce que les tests ne prouveront pas
|
||||
|
||||
**La qualité de reconnaissance ne peut pas être vérifiée automatiquement ici** : aucun
|
||||
échantillon de voix n'est disponible côté développement. Les tests établissent que le
|
||||
découpage et la supervision sont corrects ; que `large-v3-turbo` comprenne mieux que
|
||||
`small` reste une constatation d'usage.
|
||||
|
||||
C'est la raison d'être du sélecteur de modèle et du test du micro : rendre le compromis
|
||||
ajustable par celui qui l'entend, au lieu de le figer dans le code sur une intuition.
|
||||
@@ -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.
|
||||
@@ -5,6 +5,7 @@ go 1.25.0
|
||||
require (
|
||||
codeberg.org/readeck/go-readability/v2 v2.1.2
|
||||
github.com/JohannesKaufmann/html-to-markdown/v2 v2.5.2
|
||||
github.com/SherClockHolmes/webpush-go v1.4.0
|
||||
github.com/caddyserver/certmagic v0.25.4
|
||||
github.com/coder/websocket v1.8.15
|
||||
github.com/getlantern/systray v1.2.2
|
||||
@@ -13,6 +14,7 @@ require (
|
||||
go.etcd.io/bbolt v1.5.0
|
||||
golang.org/x/mod v0.35.0
|
||||
golang.org/x/net v0.55.0
|
||||
golang.org/x/sys v0.45.0
|
||||
)
|
||||
|
||||
require (
|
||||
@@ -28,6 +30,7 @@ require (
|
||||
github.com/go-shiori/dom v0.0.0-20230515143342-73569d674e1c // indirect
|
||||
github.com/go-stack/stack v1.8.0 // indirect
|
||||
github.com/gogs/chardet v0.0.0-20211120154057-b7413eaefb8f // indirect
|
||||
github.com/golang-jwt/jwt/v5 v5.3.1 // indirect
|
||||
github.com/google/jsonschema-go v0.4.3 // indirect
|
||||
github.com/itlightning/dateparse v0.2.1 // indirect
|
||||
github.com/klauspost/cpuid/v2 v2.3.0 // indirect
|
||||
@@ -45,7 +48,6 @@ require (
|
||||
golang.org/x/crypto v0.51.0 // indirect
|
||||
golang.org/x/oauth2 v0.35.0 // indirect
|
||||
golang.org/x/sync v0.20.0 // indirect
|
||||
golang.org/x/sys v0.45.0 // indirect
|
||||
golang.org/x/text v0.37.0 // indirect
|
||||
golang.org/x/tools v0.44.0 // indirect
|
||||
)
|
||||
@@ -6,6 +6,8 @@ github.com/JohannesKaufmann/dom v0.3.1 h1:J16l9JAHWgkFPR3VIPbQ1gvS0cWab6laK1q7PF
|
||||
github.com/JohannesKaufmann/dom v0.3.1/go.mod h1:BZPkf8ZeYrBgABjwJn9iiKt8aiCtkxpHkevms+Yp2DE=
|
||||
github.com/JohannesKaufmann/html-to-markdown/v2 v2.5.2 h1:XFJZFWESIWlUEHHjzBuv8RvrtCWnSGlimEX17ysSDb8=
|
||||
github.com/JohannesKaufmann/html-to-markdown/v2 v2.5.2/go.mod h1:BHWO8lJzttJLqwuV8Rb1B3OG2OSzLbssZDI1FRg2eAA=
|
||||
github.com/SherClockHolmes/webpush-go v1.4.0 h1:ocnzNKWN23T9nvHi6IfyrQjkIc0oJWv1B1pULsf9i3s=
|
||||
github.com/SherClockHolmes/webpush-go v1.4.0/go.mod h1:XSq8pKX11vNV8MJEMwjrlTkxhAj1zKfxmyhdV7Pd6UA=
|
||||
github.com/andybalholm/cascadia v1.3.4 h1:vM2lgh0Vru9Vwyfm4cQqWP2HHMW0u0+2PAW7Q38Qufg=
|
||||
github.com/andybalholm/cascadia v1.3.4/go.mod h1:BLRmbRjpEtNKieZOCCvYj4RqN+KRA41GBe/5O+G93kM=
|
||||
github.com/caddyserver/certmagic v0.25.4 h1:8eIXh0HC3MsGnNo8One+BCxMGTbe5zb/oz+2KsxBFQg=
|
||||
@@ -39,8 +41,10 @@ github.com/go-stack/stack v1.8.0 h1:5SgMzNM5HxrEjV0ww2lTmX6E2Izsfxas4+YHWRs3Lsk=
|
||||
github.com/go-stack/stack v1.8.0/go.mod h1:v0f6uXyyMGvRgIKkXu+yp6POWl0qKG85gN/melR3HDY=
|
||||
github.com/gogs/chardet v0.0.0-20211120154057-b7413eaefb8f h1:3BSP1Tbs2djlpprl7wCLuiqMaUh5SJkkzI2gDs+FgLs=
|
||||
github.com/gogs/chardet v0.0.0-20211120154057-b7413eaefb8f/go.mod h1:Pcatq5tYkCW2Q6yrR2VRHlbHpZ/R4/7qyL1TCF7vl14=
|
||||
github.com/golang-jwt/jwt/v5 v5.2.1/go.mod h1:pqrtFR0X4osieyHYxtmOUWsAWrfe1Q5UVIyoH402zdk=
|
||||
github.com/golang-jwt/jwt/v5 v5.3.1 h1:kYf81DTWFe7t+1VvL7eS+jKFVWaUnK9cB1qbwn63YCY=
|
||||
github.com/golang-jwt/jwt/v5 v5.3.1/go.mod h1:fxCRLWMO43lRc8nhHWY6LGqRcf+1gQWArsqaEUEa5bE=
|
||||
github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=
|
||||
github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
|
||||
github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=
|
||||
github.com/google/jsonschema-go v0.4.3 h1:/DBOLZTfDow7pe2GmaJNhltueGTtDKICi8V8p+DQPd0=
|
||||
@@ -84,6 +88,7 @@ github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu
|
||||
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
|
||||
github.com/yosida95/uritemplate/v3 v3.0.2 h1:Ed3Oyj9yrmi9087+NczuL5BwkIc4wvTb5zIM+UJPGz4=
|
||||
github.com/yosida95/uritemplate/v3 v3.0.2/go.mod h1:ILOh0sOhIJR3+L/8afwt/kE++YT040gmv5BQTMR2HP4=
|
||||
github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY=
|
||||
github.com/yuin/goldmark v1.8.2 h1:kEGpgqJXdgbkhcOgBxkC0X0PmoPG1ZyoZ117rDVp4zE=
|
||||
github.com/yuin/goldmark v1.8.2/go.mod h1:ip/1k0VRfGynBgxOz0yCqHrbZXhcjxyuS66Brc7iBKg=
|
||||
github.com/zeebo/assert v1.1.0 h1:hU1L1vLTHsnO8x8c9KAR5GmM5QscxHg5RNU5z5qbUWY=
|
||||
@@ -102,24 +107,86 @@ go.uber.org/zap v1.27.1 h1:08RqriUEv8+ArZRYSTXy1LeBScaMpVSTBhCeaZYfMYc=
|
||||
go.uber.org/zap v1.27.1/go.mod h1:GB2qFLM7cTU87MWRP2mPIjqfIDnGu+VIO4V/SdhGo2E=
|
||||
go.uber.org/zap/exp v0.3.0 h1:6JYzdifzYkGmTdRR59oYH+Ng7k49H9qVpWwNSsGJj3U=
|
||||
go.uber.org/zap/exp v0.3.0/go.mod h1:5I384qq7XGxYyByIhHm6jg5CHkGY0nsTfbDLgDDlgJQ=
|
||||
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
|
||||
golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc=
|
||||
golang.org/x/crypto v0.13.0/go.mod h1:y6Z2r+Rw4iayiXXAIxJIDAJ1zMW4yaTpebo8fPOliYc=
|
||||
golang.org/x/crypto v0.19.0/go.mod h1:Iy9bg/ha4yyC70EfRS8jz+B6ybOBKMaSxLj6P6oBDfU=
|
||||
golang.org/x/crypto v0.23.0/go.mod h1:CKFgDieR+mRhux2Lsu27y0fO304Db0wZe70UKqHu0v8=
|
||||
golang.org/x/crypto v0.31.0/go.mod h1:kDsLvtWBEx7MV9tJOj9bnXsPbxwJQ6csT/x4KIN4Ssk=
|
||||
golang.org/x/crypto v0.51.0 h1:IBPXwPfKxY7cWQZ38ZCIRPI50YLeevDLlLnyC5wRGTI=
|
||||
golang.org/x/crypto v0.51.0/go.mod h1:8AdwkbraGNABw2kOX6YFPs3WM22XqI4EXEd8g+x7Oc8=
|
||||
golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4=
|
||||
golang.org/x/mod v0.8.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs=
|
||||
golang.org/x/mod v0.12.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs=
|
||||
golang.org/x/mod v0.15.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c=
|
||||
golang.org/x/mod v0.17.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c=
|
||||
golang.org/x/mod v0.35.0 h1:Ww1D637e6Pg+Zb2KrWfHQUnH2dQRLBQyAtpr/haaJeM=
|
||||
golang.org/x/mod v0.35.0/go.mod h1:+GwiRhIInF8wPm+4AoT6L0FA1QWAad3OMdTRx4tFYlU=
|
||||
golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
|
||||
golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg=
|
||||
golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c=
|
||||
golang.org/x/net v0.6.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs=
|
||||
golang.org/x/net v0.10.0/go.mod h1:0qNGK6F8kojg2nk9dLZ2mShWaEBan6FAoqfSigmmuDg=
|
||||
golang.org/x/net v0.15.0/go.mod h1:idbUs1IY1+zTqbi8yxTbhexhEEk5ur9LInksu6HrEpk=
|
||||
golang.org/x/net v0.21.0/go.mod h1:bIjVDfnllIU7BJ2DNgfnXvpSvtn8VRwhlsaeUTyUS44=
|
||||
golang.org/x/net v0.25.0/go.mod h1:JkAGAh7GEvH74S6FOH42FLoXpXbE/aqXSrIQjXgsiwM=
|
||||
golang.org/x/net v0.55.0 h1:bcvxaJn3e1U6InsFWt1JUq1aSjnRxLzT2rtD2KfkDF8=
|
||||
golang.org/x/net v0.55.0/go.mod h1:L5U2KuzuOe1lY7Z+aWVIKK6qEeJXnXV9yzGA+WCHJww=
|
||||
golang.org/x/oauth2 v0.35.0 h1:Mv2mzuHuZuY2+bkyWXIHMfhNdJAdwW3FuWeCPYN5GVQ=
|
||||
golang.org/x/oauth2 v0.35.0/go.mod h1:lzm5WQJQwKZ3nwavOZ3IS5Aulzxi68dUSgRHujetwEA=
|
||||
golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||
golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||
golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||
golang.org/x/sync v0.3.0/go.mod h1:FU7BRWz2tNW+3quACPkgCx/L+uEAv1htQ0V83Z9Rj+Y=
|
||||
golang.org/x/sync v0.6.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
|
||||
golang.org/x/sync v0.7.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
|
||||
golang.org/x/sync v0.10.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
|
||||
golang.org/x/sync v0.20.0 h1:e0PTpb7pjO8GAtTs2dQ6jYa5BWYlMuX047Dco/pItO4=
|
||||
golang.org/x/sync v0.20.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0=
|
||||
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
|
||||
golang.org/x/sys v0.0.0-20201018230417-eeed37f84f13/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.1.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.8.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.12.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.17.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
|
||||
golang.org/x/sys v0.20.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
|
||||
golang.org/x/sys v0.28.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
|
||||
golang.org/x/sys v0.45.0 h1:dO4czNzziLiiXplLQgBCEpCvXQ3dnkn0SdaZSYdQ+FY=
|
||||
golang.org/x/sys v0.45.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
|
||||
golang.org/x/telemetry v0.0.0-20240228155512-f48c80bd79b2/go.mod h1:TeRTkGYfJXctD9OcfyVLyj2J3IxLnKwHJR8f4D8a3YE=
|
||||
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
|
||||
golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8=
|
||||
golang.org/x/term v0.5.0/go.mod h1:jMB1sMXY+tzblOD4FWmEbocvup2/aLOaQEp7JmGp78k=
|
||||
golang.org/x/term v0.8.0/go.mod h1:xPskH00ivmX89bAKVGSKKtLOWNx2+17Eiy94tnKShWo=
|
||||
golang.org/x/term v0.12.0/go.mod h1:owVbMEjm3cBLCHdkQu9b1opXd4ETQWc3BhuQGKgXgvU=
|
||||
golang.org/x/term v0.17.0/go.mod h1:lLRBjIVuehSbZlaOtGMbcMncT+aqLLLmKrsjNrUguwk=
|
||||
golang.org/x/term v0.20.0/go.mod h1:8UkIAJTvZgivsXaD6/pH6U9ecQzZ45awqEOzuCvwpFY=
|
||||
golang.org/x/term v0.27.0/go.mod h1:iMsnZpn0cago0GOrHO2+Y7u7JPn5AylBrcoWkElMTSM=
|
||||
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
|
||||
golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
|
||||
golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ=
|
||||
golang.org/x/text v0.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8=
|
||||
golang.org/x/text v0.9.0/go.mod h1:e1OnstbJyHTd6l/uOt8jFFHp6TRDWZR/bV3emEE/zU8=
|
||||
golang.org/x/text v0.13.0/go.mod h1:TvPlkZtksWOMsz7fbANvkp4WM8x/WCo/om8BMLbz+aE=
|
||||
golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU=
|
||||
golang.org/x/text v0.15.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU=
|
||||
golang.org/x/text v0.21.0/go.mod h1:4IBbMaMmOPCJ8SecivzSH54+73PCFmPWxNTLm+vZkEQ=
|
||||
golang.org/x/text v0.37.0 h1:Cqjiwd9eSg8e0QAkyCaQTNHFIIzWtidPahFWR83rTrc=
|
||||
golang.org/x/text v0.37.0/go.mod h1:a5sjxXGs9hsn/AJVwuElvCAo9v8QYLzvavO5z2PiM38=
|
||||
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
|
||||
golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
|
||||
golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc=
|
||||
golang.org/x/tools v0.6.0/go.mod h1:Xwgl3UAJ/d3gWutnCtw505GrjyAbvKui8lOU390QaIU=
|
||||
golang.org/x/tools v0.13.0/go.mod h1:HvlwmtVNQAhOuCjW7xxvovg8wbNq7LwfXh/k7wXUl58=
|
||||
golang.org/x/tools v0.21.1-0.20240508182429-e35e4ccd0d2d/go.mod h1:aiJjzUbINMkxbQROHiO6hDPo2LHcIPhhQsa9DLh0yGk=
|
||||
golang.org/x/tools v0.44.0 h1:UP4ajHPIcuMjT1GqzDWRlalUEoY+uzoZKnhOjbIPD2c=
|
||||
golang.org/x/tools v0.44.0/go.mod h1:KA0AfVErSdxRZIsOVipbv3rQhVXTnlU6UhKxHd1seDI=
|
||||
golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
||||
gopkg.in/Knetic/govaluate.v3 v3.0.0/go.mod h1:csKLBORsPbafmSCGTEh3U7Ozmsuq8ZSIlKk1bcqph0E=
|
||||
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
|
||||
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||
@@ -10,6 +10,7 @@ Method — in this order:
|
||||
Rules:
|
||||
- PLAN ONCE. If the task needs a plan, write it to PLAN.md in ONE short write, then follow it. Between tool calls, think one or two sentences at most — NEVER restate or re-derive the plan: it is in PLAN.md and in your criteria, read them instead.
|
||||
- Write each file COMPLETE in a single write call. Many small writes waste turns.
|
||||
- You may NOT mark a criterion passed — the verification pass does that.
|
||||
- When a criterion is done AND checked, mark it completed (criteria set). Verification starts once none is left pending; only it may mark passed.
|
||||
- If a command fails, read the error and fix the cause; do not retry the same command unchanged.
|
||||
- Ask the user (ask tool) only for decisions that are genuinely theirs; decide the rest yourself.
|
||||
- Making the change is your job; shipping it is not. Without an explicit request, never commit, push, reset or rebase, never deploy, restart a service or replace a running binary. Read-only inspection (git_status, git_diff) is fine and encouraged.
|
||||
@@ -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:
|
||||
|
||||
@@ -26,6 +26,7 @@ package loki
|
||||
import (
|
||||
"archive/tar"
|
||||
"compress/gzip"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
@@ -281,11 +282,19 @@ func parseEngineBuild(out string) (build int, commit string) {
|
||||
// engineBuildOf interroge un llama-server pour son numéro de build. Renvoie
|
||||
// (0, "") si le binaire ne répond pas — un moteur cassé ne doit pas empêcher
|
||||
// d'en installer un autre.
|
||||
//
|
||||
// Borné dans le temps comme binHelp : « loki serve » le lance avant le moteur
|
||||
// (points de reprise d'un hybride, voir ckptArgs), et un binaire qui reste
|
||||
// coincé à l'initialisation de ses backends ne doit pas bloquer le lancement —
|
||||
// passé le délai, build inconnu, donc ni avis ni drapeau.
|
||||
func engineBuildOf(bin string) (int, string) {
|
||||
if bin == "" || !isFile(bin) {
|
||||
return 0, ""
|
||||
}
|
||||
cmd := hideCmd(exec.Command(bin, "--version"))
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second)
|
||||
defer cancel()
|
||||
cmd := hideCmd(exec.CommandContext(ctx, bin, "--version"))
|
||||
cmd.WaitDelay = 2 * time.Second // un descendant qui garde la sortie ouverte ne retient pas Wait
|
||||
cmd.Env = libraryPathEnv(filepath.Dir(bin))
|
||||
out, _ := cmd.CombinedOutput() // --version sort parfois en code non nul
|
||||
return parseEngineBuild(string(out))
|
||||
|
||||
@@ -0,0 +1,132 @@
|
||||
package loki
|
||||
|
||||
// backend_external.go — presets « externes » : au lieu de lancer un llama-server
|
||||
// local, un tel preset route le chat vers une API OpenAI-compatible distante
|
||||
// (OpenAI, Groq, OpenRouter, un autre Loki, un vLLM sur une autre machine…).
|
||||
// Un preset externe se reconnaît à sa clé EXTERNAL=1 ; il porte l'URL, le nom du
|
||||
// modèle et la clé d'accès.
|
||||
//
|
||||
// Choix d'archi, repris d'AJEAN : l'externe est un PRESET COMME UN AUTRE, un
|
||||
// fichier .env dans presetsDir. Toute la mécanique existante s'applique sans
|
||||
// rien changer — liste, nom d'affichage, empreinte d'activité, bascule, prompt
|
||||
// système par preset, réglages d'échantillonnage. La différence ne vit qu'à
|
||||
// DEUX endroits : au moment de l'inférence (resolveChatEndpoint) et au moment
|
||||
// de la bascule (aucun moteur à redémarrer).
|
||||
//
|
||||
// Ce qui ne s'applique pas, et c'est voulu : un preset externe n'a ni MODEL, ni
|
||||
// NGL, ni moteur. La machine distante décide de tout ça. CTX, en revanche,
|
||||
// reste utile — c'est lui qui pilote la jauge de contexte et le seuil de
|
||||
// compaction côté Loki.
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
)
|
||||
|
||||
const (
|
||||
extKeyFlag = "EXTERNAL" // marqueur : "1" = preset externe
|
||||
extKeyURL = "EXTERNAL_URL" // base ou URL complète des complétions
|
||||
extKeyModel = "EXTERNAL_MODEL" // nom du modèle envoyé dans le payload
|
||||
extKeyToken = "EXTERNAL_KEY" // clé API (Bearer), peut être vide
|
||||
extKeyVision = "EXTERNAL_VISION" // "1" = le modèle distant accepte les images
|
||||
)
|
||||
|
||||
// chatEndpoint : où partent les appels /v1/chat/completions de ce tour.
|
||||
type chatEndpoint struct {
|
||||
URL string // URL complète des complétions
|
||||
Model string // nom de modèle envoyé dans le payload
|
||||
Key string // Bearer ("" = aucun)
|
||||
External bool // true = API distante (pas le llama-server local)
|
||||
}
|
||||
|
||||
// isExternalConfig indique si une configuration décrit un endpoint externe.
|
||||
func isExternalConfig(cfg map[string]string) bool {
|
||||
return strings.TrimSpace(cfg[extKeyFlag]) == "1"
|
||||
}
|
||||
|
||||
// externalActive : le preset actif est-il un endpoint externe ?
|
||||
func externalActive() bool { return isExternalConfig(ReadConfig()) }
|
||||
|
||||
// externalVisionActive : le preset actif est externe ET déclaré multimodal. Pas
|
||||
// de MMPROJ ni de sonde /props à consulter côté distant : seule la case du
|
||||
// preset dit si le modèle accepte les images.
|
||||
func externalVisionActive() bool {
|
||||
cfg := ReadConfig()
|
||||
return isExternalConfig(cfg) && strings.TrimSpace(cfg[extKeyVision]) == "1"
|
||||
}
|
||||
|
||||
// completionsURL normalise l'URL saisie en une URL de complétions complète.
|
||||
// Accepte les trois formes qu'on colle en pratique :
|
||||
//
|
||||
// - une URL déjà complète (…/chat/completions) — laissée telle quelle ;
|
||||
// - une base OpenAI (…/v1, …/openai/v1, …/api/v1) — on ajoute /chat/completions ;
|
||||
// - autre chose — on suppose une racine et on ajoute /v1/chat/completions.
|
||||
//
|
||||
// Exiger la forme exacte serait une source d'échecs muets : la page d'un
|
||||
// fournisseur donne tantôt « https://api.groq.com/openai/v1 », tantôt l'URL
|
||||
// complète, et se tromper ne produit qu'un 404 illisible.
|
||||
func completionsURL(raw string) string {
|
||||
u := strings.TrimRight(strings.TrimSpace(raw), "/")
|
||||
if u == "" {
|
||||
return ""
|
||||
}
|
||||
if strings.HasSuffix(u, "/chat/completions") {
|
||||
return u
|
||||
}
|
||||
if strings.HasSuffix(u, "/v1") {
|
||||
return u + "/chat/completions"
|
||||
}
|
||||
return u + "/v1/chat/completions"
|
||||
}
|
||||
|
||||
// resolveChatEndpoint décide où envoyer les complétions : l'API externe si le
|
||||
// preset actif en est un, sinon le llama-server local.
|
||||
func resolveChatEndpoint() chatEndpoint {
|
||||
cfg := ReadConfig()
|
||||
if isExternalConfig(cfg) {
|
||||
return chatEndpoint{
|
||||
URL: completionsURL(cfg[extKeyURL]),
|
||||
Model: strings.TrimSpace(cfg[extKeyModel]),
|
||||
Key: strings.TrimSpace(cfg[extKeyToken]),
|
||||
External: true,
|
||||
}
|
||||
}
|
||||
return chatEndpoint{
|
||||
URL: fmt.Sprintf("http://localhost:%d/v1/chat/completions", LLMPort()),
|
||||
Model: "loki",
|
||||
Key: readAPIKey(),
|
||||
}
|
||||
}
|
||||
|
||||
// auth pose l'en-tête d'autorisation de CET endpoint. Indispensable de le tenir
|
||||
// ici plutôt que d'appeler authHeader : celui-ci envoie la clé du serveur LOCAL,
|
||||
// et l'expédier à api.openai.com serait fuiter un secret chez un tiers en même
|
||||
// temps qu'un 401 garanti.
|
||||
func (e chatEndpoint) auth(set func(k, v string)) {
|
||||
if e.Key != "" {
|
||||
set("Authorization", "Bearer "+e.Key)
|
||||
}
|
||||
}
|
||||
|
||||
// externalPresetContent construit le corps .env d'un preset externe (hors ligne
|
||||
// « # NAME= », ajoutée par SavePreset). Une clé vide n'est pas écrite. `ctx` va
|
||||
// dans la clé CTX standard — elle pilote la jauge de contexte et le seuil de
|
||||
// compaction, exactement comme pour un preset local. vision = le modèle distant
|
||||
// accepte les images (EXTERNAL_VISION=1).
|
||||
func externalPresetContent(url, model, key, ctx string, vision bool) string {
|
||||
m := map[string]string{
|
||||
extKeyFlag: "1",
|
||||
extKeyURL: strings.TrimSpace(url),
|
||||
extKeyModel: strings.TrimSpace(model),
|
||||
}
|
||||
if k := strings.TrimSpace(key); k != "" {
|
||||
m[extKeyToken] = k
|
||||
}
|
||||
if vision {
|
||||
m[extKeyVision] = "1"
|
||||
}
|
||||
if c := strings.TrimSpace(ctx); c != "" {
|
||||
m["CTX"] = c
|
||||
}
|
||||
return formatEnv(m)
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,245 @@
|
||||
package loki
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// Les trois formes d'URL qu'on colle en pratique. Exiger la forme exacte serait
|
||||
// une source d'échecs muets : la page d'un fournisseur donne tantôt la base,
|
||||
// tantôt l'URL complète, et se tromper ne produit qu'un 404 illisible.
|
||||
func TestNormalisationDeLURLDeComplétions(t *testing.T) {
|
||||
cas := map[string]string{
|
||||
"https://api.openai.com/v1": "https://api.openai.com/v1/chat/completions",
|
||||
"https://api.groq.com/openai/v1": "https://api.groq.com/openai/v1/chat/completions",
|
||||
"https://api.openai.com/v1/chat/completions": "https://api.openai.com/v1/chat/completions",
|
||||
"https://api.openai.com/v1/": "https://api.openai.com/v1/chat/completions",
|
||||
"http://192.168.1.20:8080": "http://192.168.1.20:8080/v1/chat/completions",
|
||||
"": "",
|
||||
}
|
||||
for in, want := range cas {
|
||||
if got := completionsURL(in); got != want {
|
||||
t.Errorf("completionsURL(%q) = %q, attendu %q", in, got, want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Un preset externe actif détourne les complétions ; sans lui, on reste sur le
|
||||
// llama-server local avec la clé LOCALE.
|
||||
func TestEndpointSuitLePresetActif(t *testing.T) {
|
||||
testHome(t)
|
||||
if err := SetConfigKey("PORT", "9999"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
ep := resolveChatEndpoint()
|
||||
if ep.External {
|
||||
t.Fatal("aucun preset externe : l'endpoint doit rester local")
|
||||
}
|
||||
if ep.URL != "http://localhost:9999/v1/chat/completions" {
|
||||
t.Fatalf("URL locale = %q", ep.URL)
|
||||
}
|
||||
if ep.Model != "loki" {
|
||||
t.Fatalf("modèle local = %q, attendu loki", ep.Model)
|
||||
}
|
||||
|
||||
if err := WriteConfig(parseEnv(externalPresetContent(
|
||||
"https://api.groq.com/openai/v1", "llama-3.3-70b", "sk-secret", "65536", false))); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
ep = resolveChatEndpoint()
|
||||
if !ep.External {
|
||||
t.Fatal("EXTERNAL=1 non reconnu")
|
||||
}
|
||||
if ep.URL != "https://api.groq.com/openai/v1/chat/completions" {
|
||||
t.Fatalf("URL externe = %q", ep.URL)
|
||||
}
|
||||
if ep.Model != "llama-3.3-70b" || ep.Key != "sk-secret" {
|
||||
t.Fatalf("modèle/clé externes = %q / %q", ep.Model, ep.Key)
|
||||
}
|
||||
// CTX passe par la clé standard : c'est elle qui pilote la jauge de contexte
|
||||
// et le seuil de compaction, exactement comme pour un preset local.
|
||||
if got := ReadConfig()["CTX"]; got != "65536" {
|
||||
t.Fatalf("CTX = %q, attendu 65536", got)
|
||||
}
|
||||
}
|
||||
|
||||
// La clé du serveur LOCAL ne doit jamais partir chez un tiers : ce serait fuiter
|
||||
// un secret en même temps qu'un 401 garanti.
|
||||
func TestLaCleLocaleNePartJamaisChezUnTiers(t *testing.T) {
|
||||
testHome(t)
|
||||
if err := WriteConfig(parseEnv(externalPresetContent(
|
||||
"https://api.openai.com/v1", "gpt-4o-mini", "", "", false))); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
// Une clé d'API locale existe et n'a rien à faire dans la requête sortante.
|
||||
if err := SetConfigKey("API_KEY", "sk-loki-locale"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
ep := resolveChatEndpoint()
|
||||
if ep.Key != "" {
|
||||
t.Fatalf("clé envoyée à l'API externe = %q — le preset n'en déclare aucune", ep.Key)
|
||||
}
|
||||
got := map[string]string{}
|
||||
ep.auth(func(k, v string) { got[k] = v })
|
||||
if len(got) != 0 {
|
||||
t.Fatalf("en-tête posé sans clé de preset : %v", got)
|
||||
}
|
||||
}
|
||||
|
||||
// Un preset externe se reconnaît, un preset local ne se prend pas pour un externe.
|
||||
func TestReconnaissanceDunPresetExterne(t *testing.T) {
|
||||
if !isExternalConfig(map[string]string{extKeyFlag: "1"}) {
|
||||
t.Error("EXTERNAL=1 non reconnu")
|
||||
}
|
||||
for _, v := range []string{"", "0", "off", "false"} {
|
||||
if isExternalConfig(map[string]string{extKeyFlag: v}) {
|
||||
t.Errorf("EXTERNAL=%q pris pour un preset externe", v)
|
||||
}
|
||||
}
|
||||
if isExternalConfig(parseEnv("MODEL=\"m.gguf\"\nCTX=4096\n")) {
|
||||
t.Error("un preset local est pris pour un externe")
|
||||
}
|
||||
}
|
||||
|
||||
// La route de test rend un message LISIBLE plutôt que le JSON brut : c'est tout
|
||||
// l'intérêt de tester avant d'enregistrer.
|
||||
func TestRouteDeTestRemonteLErreurDeLAPI(t *testing.T) {
|
||||
testHome(t)
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Header.Get("Authorization") != "Bearer sk-bonne" {
|
||||
w.WriteHeader(401)
|
||||
_, _ = w.Write([]byte(`{"error":{"message":"Incorrect API key provided"}}`))
|
||||
return
|
||||
}
|
||||
sendJSON(w, 200, map[string]any{"choices": []any{}})
|
||||
}))
|
||||
t.Cleanup(srv.Close)
|
||||
|
||||
post := func(body string) map[string]any {
|
||||
w := httptest.NewRecorder()
|
||||
handlePresetExternalTest(w, httptest.NewRequest("POST", "/api/preset/external/test", strings.NewReader(body)))
|
||||
var out map[string]any
|
||||
_ = json.Unmarshal(w.Body.Bytes(), &out)
|
||||
return out
|
||||
}
|
||||
out := post(`{"url":"` + srv.URL + `","model":"m","key":"sk-mauvaise","keyTouched":true}`)
|
||||
if out["ok"] == true {
|
||||
t.Fatal("clé refusée annoncée comme valide")
|
||||
}
|
||||
if msg, _ := out["error"].(string); msg != "Incorrect API key provided" {
|
||||
t.Fatalf("message d'erreur = %q, attendu celui de l'API et non le JSON brut", msg)
|
||||
}
|
||||
if out = post(`{"url":"` + srv.URL + `","model":"m","key":"sk-bonne","keyTouched":true}`); out["ok"] != true {
|
||||
t.Fatalf("clé valide refusée : %v", out)
|
||||
}
|
||||
}
|
||||
|
||||
// Rééditer un preset sans toucher au champ clé ne doit PAS l'effacer : la modale
|
||||
// ne la reçoit jamais en clair, donc un champ vide n'est pas une demande
|
||||
// d'effacement.
|
||||
func TestEditionSansToucherALaCleLaConserve(t *testing.T) {
|
||||
testHome(t)
|
||||
id, err := SavePreset("", "Groq", externalPresetContent("https://api.groq.com/openai/v1", "llama", "sk-gardee", "", false))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
w := httptest.NewRecorder()
|
||||
handlePresetExternalSave(w, httptest.NewRequest("POST", "/api/preset/external/save",
|
||||
strings.NewReader(`{"id":"`+id+`","name":"Groq","url":"https://api.groq.com/openai/v1","model":"llama-3.3","key":""}`)))
|
||||
if w.Code != 200 {
|
||||
t.Fatalf("code %d : %s", w.Code, w.Body.String())
|
||||
}
|
||||
content, err := ReadPreset(id)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
cfg := parseEnv(content)
|
||||
if cfg[extKeyToken] != "sk-gardee" {
|
||||
t.Fatalf("clé = %q — effacée par une édition qui n'y touchait pas", cfg[extKeyToken])
|
||||
}
|
||||
if cfg[extKeyModel] != "llama-3.3" {
|
||||
t.Fatalf("modèle non mis à jour : %q", cfg[extKeyModel])
|
||||
}
|
||||
// Et la clé ne ressort jamais en clair par la route de lecture.
|
||||
w = httptest.NewRecorder()
|
||||
handlePresetExternal(w, httptest.NewRequest("GET", "/api/preset/external?id="+id, nil))
|
||||
if strings.Contains(w.Body.String(), "sk-gardee") {
|
||||
t.Fatalf("la clé est renvoyée en clair : %s", w.Body.String())
|
||||
}
|
||||
var out map[string]any
|
||||
_ = json.Unmarshal(w.Body.Bytes(), &out)
|
||||
if out["hasKey"] != true {
|
||||
t.Fatal("hasKey doit signaler la présence d'une clé")
|
||||
}
|
||||
}
|
||||
|
||||
// Un preset externe demande explicitement d'écraser la clé par une vide.
|
||||
func TestEffacementExpliciteDeLaCle(t *testing.T) {
|
||||
testHome(t)
|
||||
id, err := SavePreset("", "Local distant", externalPresetContent("http://192.168.1.20:8080", "qwen", "sk-a-jeter", "", false))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
w := httptest.NewRecorder()
|
||||
handlePresetExternalSave(w, httptest.NewRequest("POST", "/api/preset/external/save",
|
||||
strings.NewReader(`{"id":"`+id+`","name":"Local distant","url":"http://192.168.1.20:8080","model":"qwen","key":"","keyTouched":true}`)))
|
||||
if w.Code != 200 {
|
||||
t.Fatalf("code %d : %s", w.Code, w.Body.String())
|
||||
}
|
||||
content, _ := ReadPreset(id)
|
||||
if k := parseEnv(content)[extKeyToken]; k != "" {
|
||||
t.Fatalf("clé = %q, attendu effacée (keyTouched avec champ vide)", k)
|
||||
}
|
||||
}
|
||||
|
||||
// Le résumé de compactage n'envoie pas chat_template_kwargs (propre à
|
||||
// llama.cpp) à une API distante : une API stricte répond 400 et la compaction
|
||||
// échouait à chaque fois.
|
||||
func TestCompactageExterneSansKwargsLlamaCpp(t *testing.T) {
|
||||
testHome(t)
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
var body map[string]any
|
||||
_ = json.NewDecoder(r.Body).Decode(&body)
|
||||
if _, ok := body["chat_template_kwargs"]; ok {
|
||||
w.WriteHeader(400)
|
||||
_, _ = w.Write([]byte(`{"error":{"message":"Unrecognized request argument supplied: chat_template_kwargs"}}`))
|
||||
return
|
||||
}
|
||||
sendJSON(w, 200, map[string]any{"choices": []any{map[string]any{"message": map[string]any{"content": "- résumé"}}}})
|
||||
}))
|
||||
t.Cleanup(srv.Close)
|
||||
if err := WriteConfig(parseEnv(externalPresetContent(srv.URL, "gpt-4o-mini", "", "", false))); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := summarizeTranscript(context.Background(), "user: bonjour\nassistant: salut"); err != nil {
|
||||
t.Fatalf("compactage refusé par l'API distante : %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// Vision et benchmark d'un preset externe : la case du preset décide, et le
|
||||
// bench (qui mesure le moteur local) refuse de tourner.
|
||||
func TestPresetExterneVisionEtBench(t *testing.T) {
|
||||
testHome(t)
|
||||
if err := WriteConfig(parseEnv(externalPresetContent("https://api.openai.com/v1", "gpt-4o", "", "", true))); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !visionEnabled() || !engineSeesImages() {
|
||||
t.Error("preset externe déclaré multimodal : la vision devrait être active")
|
||||
}
|
||||
if _, err := runBench(context.Background(), benchOpts{Prompt: 10, Predict: 10}, nil); err == nil {
|
||||
t.Error("benchmark lancé sur un preset externe")
|
||||
}
|
||||
if got := chatModelName(); got != "gpt-4o" {
|
||||
t.Errorf("badge de modèle = %q, attendu le modèle distant", got)
|
||||
}
|
||||
if err := WriteConfig(parseEnv(externalPresetContent("https://api.openai.com/v1", "gpt-4o", "", "", false))); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if visionEnabled() {
|
||||
t.Error("preset externe texte seul : la vision devrait être coupée")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,624 @@
|
||||
package loki
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"encoding/binary"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"math"
|
||||
"math/bits"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync"
|
||||
)
|
||||
|
||||
// Lecteur des métadonnées GGUF. Plusieurs réglages du moteur dépendent de ce que
|
||||
// le fichier contient VRAIMENT : un modèle qui embarque une tête MTP (prédiction
|
||||
// de plusieurs jetons, vérifiée exactement par le modèle cible), le nombre de
|
||||
// têtes KV par couche pour estimer le cache, une architecture hybride à état
|
||||
// récurrent. Jusqu'ici Loki ne le savait qu'à travers le nom du fichier, ce qui
|
||||
// ne prouve rien.
|
||||
//
|
||||
// On ne lit que l'en-tête, la section clé/valeur et la table des tenseurs : les
|
||||
// poids eux-mêmes ne sont jamais touchés, quelques Mo au plus pour un modèle de
|
||||
// 30 Go. Le fichier peut être en cours de téléchargement, tronqué, ou tout
|
||||
// simplement pas un GGUF : chaque longueur lue est bornée par ce qui reste du
|
||||
// fichier, et la moindre incohérence donne une erreur, jamais un plantage ni une
|
||||
// allocation géante. Une erreur veut dire « on ne sait pas » : l'appelant reste
|
||||
// sur le comportement prudent.
|
||||
|
||||
// GGUFInfo résume ce que Loki retient d'un modèle GGUF. Les champs absents du
|
||||
// fichier restent à zéro.
|
||||
type GGUFInfo struct {
|
||||
Version int // version du format (1 à 3)
|
||||
Arch string // general.architecture
|
||||
BlockCount int // <arch>.block_count
|
||||
NextN int // <arch>.nextn_predict_layers (couches MTP annoncées)
|
||||
ContextLength int // <arch>.context_length (contexte d'entraînement)
|
||||
ExpertCount int // <arch>.expert_count (0 = modèle dense)
|
||||
KeyLen int // <arch>.attention.key_length
|
||||
ValLen int // <arch>.attention.value_length
|
||||
// HeadCountKV : valeur unique, ou maximum sur les couches quand le fichier
|
||||
// donne un tableau par couche (HeadCountKVLayers, nil sinon). Sur un hybride,
|
||||
// les couches récurrentes y valent 0.
|
||||
HeadCountKV int
|
||||
HeadCountKVLayers []int
|
||||
FullAttnInterval int // <arch>.full_attention_interval, si présent
|
||||
Hybrid bool // au moins une clé <arch>.ssm.* : couches à état récurrent
|
||||
// Repli de llama.cpp quand key_length / value_length manquent : n_embd / n_head.
|
||||
EmbeddingLength int // <arch>.embedding_length
|
||||
HeadCount int // <arch>.attention.head_count (valeur unique seulement)
|
||||
// Taille de l'état récurrent par couche (llama_hparams::n_embd_r / n_embd_s),
|
||||
// pour estimer ce que pèse un état sauvegardé d'un modèle hybride.
|
||||
SSMConv int // <arch>.ssm.conv_kernel
|
||||
SSMInner int // <arch>.ssm.inner_size
|
||||
SSMState int // <arch>.ssm.state_size
|
||||
SSMGroups int // <arch>.ssm.group_count
|
||||
// HasNextNTensor : le tenseur blk.{BlockCount-1}.nextn.eh_proj.weight existe
|
||||
// dans l'une des tranches. C'est le test de llama.cpp lui-même pour reconnaître
|
||||
// une tête MTP : la clé nextn_predict_layers seule ne suffit pas, certains GGUF
|
||||
// la gardent alors que les tenseurs ont été retirés (tête publiée à part).
|
||||
HasNextNTensor bool
|
||||
TensorCount int // total sur toutes les tranches
|
||||
}
|
||||
|
||||
// Bornes de lecture. Elles sont larges devant les vrais modèles (une poignée de
|
||||
// milliers de tenseurs, une cinquantaine de clés hors vocabulaire) et servent
|
||||
// seulement à refuser un fichier aberrant avant d'allouer quoi que ce soit.
|
||||
const (
|
||||
ggufMaxString = 64 << 20 // modèle de chat le plus long connu : quelques dizaines de Ko
|
||||
ggufMaxKeyLen = 64 << 10
|
||||
ggufMaxTensorName = 4096 // GGML_MAX_NAME vaut 64
|
||||
ggufMaxKV = 1 << 20
|
||||
ggufMaxTensors = 1 << 22
|
||||
ggufMaxDims = 8 // GGML_MAX_DIMS vaut 4
|
||||
ggufMaxArrayDepth = 4
|
||||
ggufMaxLayerArray = 1 << 16
|
||||
)
|
||||
|
||||
// Types de valeur GGUF (gguf.h).
|
||||
const (
|
||||
ggufUint8 uint32 = iota
|
||||
ggufInt8
|
||||
ggufUint16
|
||||
ggufInt16
|
||||
ggufUint32
|
||||
ggufInt32
|
||||
ggufFloat32
|
||||
ggufBool
|
||||
ggufString
|
||||
ggufArray
|
||||
ggufUint64
|
||||
ggufInt64
|
||||
ggufFloat64
|
||||
)
|
||||
|
||||
var errGGUFTruncated = errors.New("GGUF tronqué")
|
||||
|
||||
// ggufScalarSize : taille en octets d'un type de taille fixe, 0 sinon.
|
||||
func ggufScalarSize(t uint32) int64 {
|
||||
switch t {
|
||||
case ggufUint8, ggufInt8, ggufBool:
|
||||
return 1
|
||||
case ggufUint16, ggufInt16:
|
||||
return 2
|
||||
case ggufUint32, ggufInt32, ggufFloat32:
|
||||
return 4
|
||||
case ggufUint64, ggufInt64, ggufFloat64:
|
||||
return 8
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
// ggufReader lit séquentiellement un GGUF en comptant ce qui reste du fichier :
|
||||
// toute longueur annoncée au-delà est une incohérence, détectée AVANT de lire.
|
||||
type ggufReader struct {
|
||||
r *bufio.Reader
|
||||
bo binary.ByteOrder
|
||||
v1 bool // GGUF v1 : compteurs et longueurs sur 32 bits
|
||||
size int64 // taille totale du fichier
|
||||
left int64 // octets restants dans le fichier
|
||||
buf [8]byte
|
||||
}
|
||||
|
||||
func (g *ggufReader) fixed(n int) ([]byte, error) {
|
||||
if int64(n) > g.left {
|
||||
return nil, errGGUFTruncated
|
||||
}
|
||||
b := g.buf[:n]
|
||||
if _, err := io.ReadFull(g.r, b); err != nil {
|
||||
return nil, errGGUFTruncated
|
||||
}
|
||||
g.left -= int64(n)
|
||||
return b, nil
|
||||
}
|
||||
|
||||
func (g *ggufReader) u32() (uint32, error) {
|
||||
b, err := g.fixed(4)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
return g.bo.Uint32(b), nil
|
||||
}
|
||||
|
||||
func (g *ggufReader) u64() (uint64, error) {
|
||||
b, err := g.fixed(8)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
return g.bo.Uint64(b), nil
|
||||
}
|
||||
|
||||
// count lit un compteur ou une longueur : 32 bits en v1, 64 bits ensuite.
|
||||
func (g *ggufReader) count() (uint64, error) {
|
||||
if g.v1 {
|
||||
v, err := g.u32()
|
||||
return uint64(v), err
|
||||
}
|
||||
return g.u64()
|
||||
}
|
||||
|
||||
// countSize : taille sur disque d'un compteur, pour borner les tableaux de chaînes.
|
||||
func (g *ggufReader) countSize() int64 {
|
||||
if g.v1 {
|
||||
return 4
|
||||
}
|
||||
return 8
|
||||
}
|
||||
|
||||
func (g *ggufReader) skip(n int64) error {
|
||||
if n < 0 || n > g.left {
|
||||
return errGGUFTruncated
|
||||
}
|
||||
for rest := n; rest > 0; {
|
||||
step := min(rest, 1<<30)
|
||||
if _, err := g.r.Discard(int(step)); err != nil {
|
||||
return errGGUFTruncated
|
||||
}
|
||||
rest -= step
|
||||
}
|
||||
g.left -= n
|
||||
return nil
|
||||
}
|
||||
|
||||
// str lit une chaîne d'au plus max octets.
|
||||
func (g *ggufReader) str(max uint64) (string, error) {
|
||||
n, err := g.count()
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
if n > max {
|
||||
return "", fmt.Errorf("chaîne GGUF de %d octets", n)
|
||||
}
|
||||
if int64(n) > g.left {
|
||||
return "", errGGUFTruncated
|
||||
}
|
||||
b := make([]byte, n)
|
||||
if _, err := io.ReadFull(g.r, b); err != nil {
|
||||
return "", errGGUFTruncated
|
||||
}
|
||||
g.left -= int64(n)
|
||||
return string(b), nil
|
||||
}
|
||||
|
||||
func (g *ggufReader) skipStr() error {
|
||||
n, err := g.count()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if n > ggufMaxString {
|
||||
return fmt.Errorf("chaîne GGUF de %d octets", n)
|
||||
}
|
||||
return g.skip(int64(n))
|
||||
}
|
||||
|
||||
// intVal lit une valeur entière (ou booléenne) de type t. ok vaut false pour les
|
||||
// flottants, lus et ignorés : aucune des clés retenues n'en est un.
|
||||
func (g *ggufReader) intVal(t uint32) (v int64, ok bool, err error) {
|
||||
size := ggufScalarSize(t)
|
||||
if size == 0 {
|
||||
return 0, false, fmt.Errorf("type GGUF %d inattendu", t)
|
||||
}
|
||||
b, err := g.fixed(int(size))
|
||||
if err != nil {
|
||||
return 0, false, err
|
||||
}
|
||||
switch t {
|
||||
case ggufUint8, ggufBool:
|
||||
return int64(b[0]), true, nil
|
||||
case ggufInt8:
|
||||
return int64(int8(b[0])), true, nil
|
||||
case ggufUint16:
|
||||
return int64(g.bo.Uint16(b)), true, nil
|
||||
case ggufInt16:
|
||||
return int64(int16(g.bo.Uint16(b))), true, nil
|
||||
case ggufUint32:
|
||||
return int64(g.bo.Uint32(b)), true, nil
|
||||
case ggufInt32:
|
||||
return int64(int32(g.bo.Uint32(b))), true, nil
|
||||
case ggufUint64:
|
||||
u := g.bo.Uint64(b)
|
||||
if u > math.MaxInt64 {
|
||||
u = math.MaxInt64
|
||||
}
|
||||
return int64(u), true, nil
|
||||
case ggufInt64:
|
||||
return int64(g.bo.Uint64(b)), true, nil
|
||||
}
|
||||
return 0, false, nil // flottant
|
||||
}
|
||||
|
||||
// skipArray saute n éléments de type et, tableaux imbriqués compris (profondeur
|
||||
// bornée). Le vocabulaire (150 000 chaînes) passe par ici sans rien allouer.
|
||||
func (g *ggufReader) skipArray(et uint32, n uint64, depth int) error {
|
||||
if depth > ggufMaxArrayDepth {
|
||||
return errors.New("tableaux GGUF trop imbriqués")
|
||||
}
|
||||
if size := ggufScalarSize(et); size > 0 {
|
||||
if n > uint64(g.left/size) {
|
||||
return errGGUFTruncated
|
||||
}
|
||||
return g.skip(int64(n) * size)
|
||||
}
|
||||
switch et {
|
||||
case ggufString:
|
||||
if n > uint64(g.left/g.countSize()) {
|
||||
return errGGUFTruncated
|
||||
}
|
||||
for i := uint64(0); i < n; i++ {
|
||||
if err := g.skipStr(); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
case ggufArray:
|
||||
// Chaque sous-tableau coûte au moins son type et son compteur.
|
||||
if n > uint64(g.left/(4+g.countSize())) {
|
||||
return errGGUFTruncated
|
||||
}
|
||||
for i := uint64(0); i < n; i++ {
|
||||
sub, err := g.u32()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
sn, err := g.count()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if err := g.skipArray(sub, sn, depth+1); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
return fmt.Errorf("type GGUF %d inconnu", et)
|
||||
}
|
||||
|
||||
// ggufFile : ce qu'on retient d'UNE tranche avant de résoudre les clés selon
|
||||
// l'architecture (general.architecture n'est pas forcément la première clé).
|
||||
type ggufFile struct {
|
||||
version int
|
||||
arch string
|
||||
ints map[string]int64
|
||||
arrays map[string][]int64 // tableaux d'entiers retenus (têtes KV par couche)
|
||||
keys []string // noms de toutes les clés, pour repérer <arch>.ssm.*
|
||||
tensors int
|
||||
nextn []string // noms des tenseurs *.nextn.eh_proj.weight
|
||||
}
|
||||
|
||||
// keepIntArray : les seuls tableaux qu'on lit au lieu de les sauter.
|
||||
func keepIntArray(key string) bool {
|
||||
return strings.HasSuffix(key, ".attention.head_count_kv")
|
||||
}
|
||||
|
||||
// readGGUFHeader lit l'en-tête, les clés et la table des tenseurs d'un fichier.
|
||||
func readGGUFHeader(path string) (*ggufFile, error) {
|
||||
fh, err := os.Open(path)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer fh.Close()
|
||||
st, err := fh.Stat()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if st.IsDir() {
|
||||
return nil, fmt.Errorf("%s est un dossier", baseName(path))
|
||||
}
|
||||
f, err := parseGGUF(fh, st.Size())
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("%s : %w", baseName(path), err)
|
||||
}
|
||||
return f, nil
|
||||
}
|
||||
|
||||
// parseGGUF lit size octets au plus de r. Un panic éventuel (fichier
|
||||
// pathologique non prévu par les bornes) est rattrapé en erreur : lire des
|
||||
// métadonnées ne doit jamais faire tomber Loki.
|
||||
func parseGGUF(r io.Reader, size int64) (f *ggufFile, err error) {
|
||||
defer func() {
|
||||
if p := recover(); p != nil {
|
||||
f, err = nil, fmt.Errorf("GGUF illisible : %v", p)
|
||||
}
|
||||
}()
|
||||
g := &ggufReader{r: bufio.NewReaderSize(r, 1<<20), bo: binary.LittleEndian, size: size, left: size}
|
||||
magic, err := g.fixed(4)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if string(magic) != "GGUF" {
|
||||
return nil, errors.New("pas un fichier GGUF")
|
||||
}
|
||||
ver, err := g.u32()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
// Les GGUF gros-boutistes (s390x) gardent la même signature : seule la
|
||||
// version lue à l'envers trahit l'ordre des octets.
|
||||
if ver&0xFFFF == 0 {
|
||||
if sw := bits.ReverseBytes32(ver); sw >= 1 && sw <= 3 {
|
||||
g.bo, ver = binary.BigEndian, sw
|
||||
}
|
||||
}
|
||||
if ver < 1 || ver > 3 {
|
||||
return nil, fmt.Errorf("version GGUF %d non prise en charge", ver)
|
||||
}
|
||||
g.v1 = ver == 1
|
||||
nTensors, err := g.count()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
nKV, err := g.count()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if nKV > ggufMaxKV || nTensors > ggufMaxTensors {
|
||||
return nil, fmt.Errorf("compteurs GGUF aberrants (%d clés, %d tenseurs)", nKV, nTensors)
|
||||
}
|
||||
f = &ggufFile{version: int(ver), ints: map[string]int64{}, arrays: map[string][]int64{}, tensors: int(nTensors)}
|
||||
|
||||
for i := uint64(0); i < nKV; i++ {
|
||||
key, err := g.str(ggufMaxKeyLen)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
t, err := g.u32()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
f.keys = append(f.keys, key)
|
||||
switch t {
|
||||
case ggufString:
|
||||
if key == "general.architecture" {
|
||||
if f.arch, err = g.str(ggufMaxKeyLen); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
} else if err := g.skipStr(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
case ggufArray:
|
||||
et, err := g.u32()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
n, err := g.count()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if keepIntArray(key) && et != ggufFloat32 && et != ggufFloat64 && ggufScalarSize(et) > 0 && n <= ggufMaxLayerArray {
|
||||
vals := make([]int64, 0, min(n, uint64(g.left)))
|
||||
for j := uint64(0); j < n; j++ {
|
||||
v, _, err := g.intVal(et)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
vals = append(vals, v)
|
||||
}
|
||||
f.arrays[key] = vals
|
||||
} else if err := g.skipArray(et, n, 1); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
default:
|
||||
v, ok, err := g.intVal(t)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if ok {
|
||||
f.ints[key] = v
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
dimSize := int64(8)
|
||||
if g.v1 {
|
||||
dimSize = 4
|
||||
}
|
||||
var maxOff uint64
|
||||
for i := uint64(0); i < nTensors; i++ {
|
||||
name, err := g.str(ggufMaxTensorName)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
nd, err := g.u32()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if nd > ggufMaxDims {
|
||||
return nil, fmt.Errorf("tenseur %q à %d dimensions", name, nd)
|
||||
}
|
||||
// dimensions et type (u32), puis position des données (u64)
|
||||
if err := g.skip(int64(nd)*dimSize + 4); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
off, err := g.u64()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
maxOff = max(maxOff, off)
|
||||
if strings.HasSuffix(name, ".nextn.eh_proj.weight") {
|
||||
f.nextn = append(f.nextn, name)
|
||||
}
|
||||
}
|
||||
|
||||
// Les données commencent au prochain multiple de general.alignment. Le début
|
||||
// du dernier tenseur doit tenir dans le fichier : un GGUF coupé dans ses poids
|
||||
// (copie interrompue, disque plein) a un en-tête intact, il faut le voir
|
||||
// quand même. On ne connaît pas la taille de chaque tenseur sans la table des
|
||||
// types ggml : vérifier son premier octet suffit à attraper l'essentiel.
|
||||
align := int64(32)
|
||||
if a, ok := f.ints["general.alignment"]; ok {
|
||||
if a <= 0 || a > 1<<20 || a&(a-1) != 0 {
|
||||
return nil, fmt.Errorf("alignement GGUF %d invalide", a)
|
||||
}
|
||||
align = a
|
||||
}
|
||||
if nTensors > 0 {
|
||||
pos := g.size - g.left
|
||||
dataStart := (pos + align - 1) / align * align
|
||||
if maxOff >= uint64(g.size) || dataStart+int64(maxOff) >= g.size {
|
||||
return nil, errGGUFTruncated
|
||||
}
|
||||
}
|
||||
return f, nil
|
||||
}
|
||||
|
||||
// clampInt ramène une valeur de métadonnée dans [0, MaxInt32] : un champ négatif
|
||||
// ou démesuré n'a pas de sens pour un compte de couches ou de têtes.
|
||||
func clampInt(v int64) int {
|
||||
if v < 0 {
|
||||
return 0
|
||||
}
|
||||
if v > math.MaxInt32 {
|
||||
return math.MaxInt32
|
||||
}
|
||||
return int(v)
|
||||
}
|
||||
|
||||
// ggufInfoFrom résout les clés de la première tranche selon l'architecture et
|
||||
// cherche la tête MTP dans les tenseurs de toutes les tranches.
|
||||
func ggufInfoFrom(first *ggufFile, all []*ggufFile) GGUFInfo {
|
||||
in := GGUFInfo{Version: first.version, Arch: first.arch}
|
||||
p := first.arch + "."
|
||||
get := func(k string) int { return clampInt(first.ints[p+k]) }
|
||||
in.BlockCount = get("block_count")
|
||||
in.NextN = get("nextn_predict_layers")
|
||||
in.ContextLength = get("context_length")
|
||||
in.ExpertCount = get("expert_count")
|
||||
in.KeyLen = get("attention.key_length")
|
||||
in.ValLen = get("attention.value_length")
|
||||
in.FullAttnInterval = get("full_attention_interval")
|
||||
in.EmbeddingLength = get("embedding_length")
|
||||
in.HeadCount = get("attention.head_count")
|
||||
in.SSMConv = get("ssm.conv_kernel")
|
||||
in.SSMInner = get("ssm.inner_size")
|
||||
in.SSMState = get("ssm.state_size")
|
||||
in.SSMGroups = get("ssm.group_count")
|
||||
in.HeadCountKV = get("attention.head_count_kv")
|
||||
if arr, ok := first.arrays[p+"attention.head_count_kv"]; ok {
|
||||
in.HeadCountKVLayers = make([]int, len(arr))
|
||||
in.HeadCountKV = 0
|
||||
for i, v := range arr {
|
||||
in.HeadCountKVLayers[i] = clampInt(v)
|
||||
in.HeadCountKV = max(in.HeadCountKV, in.HeadCountKVLayers[i])
|
||||
}
|
||||
}
|
||||
if first.arch != "" {
|
||||
for _, k := range first.keys {
|
||||
if strings.HasPrefix(k, p+"ssm.") {
|
||||
in.Hybrid = true
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
want := ""
|
||||
if in.BlockCount > 0 {
|
||||
want = "blk." + strconv.Itoa(in.BlockCount-1) + ".nextn.eh_proj.weight"
|
||||
}
|
||||
for _, f := range all {
|
||||
in.TensorCount += f.tensors
|
||||
for _, n := range f.nextn {
|
||||
if n == want {
|
||||
in.HasNextNTensor = true
|
||||
}
|
||||
}
|
||||
}
|
||||
return in
|
||||
}
|
||||
|
||||
// Cache par chemin : la même liste de modèles est consultée à chaque démarrage
|
||||
// et à chaque affichage. L'empreinte (taille et date de chaque tranche) suffit à
|
||||
// voir un fichier remplacé ou un téléchargement terminé ; les erreurs ne sont
|
||||
// pas gardées, un fichier en cours de téléchargement sera relu une fois complet.
|
||||
type ggufCacheEntry struct {
|
||||
stamp string
|
||||
info GGUFInfo
|
||||
}
|
||||
|
||||
var (
|
||||
ggufCacheMu sync.Mutex
|
||||
ggufCache = map[string]ggufCacheEntry{}
|
||||
)
|
||||
|
||||
const ggufCacheMax = 256
|
||||
|
||||
// ggufMeta lit les métadonnées du modèle désigné par path. Pour un modèle en
|
||||
// tranches, path peut désigner n'importe laquelle : les clés viennent de la
|
||||
// première (les suivantes ne portent que split.*), les tenseurs de toutes. Une
|
||||
// tranche manquante est une erreur : le modèle ne démarrerait pas, et on ne
|
||||
// saurait pas dire si la tête MTP s'y trouvait.
|
||||
func ggufMeta(path string) (GGUFInfo, error) {
|
||||
path = filepath.Clean(path)
|
||||
dir := filepath.Dir(path)
|
||||
fam := shardFamily(path)
|
||||
paths := make([]string, len(fam))
|
||||
var stamp strings.Builder
|
||||
for i, n := range fam {
|
||||
paths[i] = filepath.Join(dir, n)
|
||||
st, err := os.Stat(paths[i])
|
||||
if err != nil {
|
||||
return GGUFInfo{}, err
|
||||
}
|
||||
if st.IsDir() {
|
||||
return GGUFInfo{}, fmt.Errorf("%s est un dossier", n)
|
||||
}
|
||||
fmt.Fprintf(&stamp, "%d:%d;", st.Size(), st.ModTime().UnixNano())
|
||||
}
|
||||
key := paths[0]
|
||||
|
||||
ggufCacheMu.Lock()
|
||||
e, ok := ggufCache[key]
|
||||
ggufCacheMu.Unlock()
|
||||
if ok && e.stamp == stamp.String() {
|
||||
return cloneGGUFInfo(e.info), nil
|
||||
}
|
||||
|
||||
files := make([]*ggufFile, 0, len(paths))
|
||||
for _, p := range paths {
|
||||
f, err := readGGUFHeader(p)
|
||||
if err != nil {
|
||||
return GGUFInfo{}, err
|
||||
}
|
||||
files = append(files, f)
|
||||
}
|
||||
info := ggufInfoFrom(files[0], files)
|
||||
|
||||
ggufCacheMu.Lock()
|
||||
if len(ggufCache) >= ggufCacheMax {
|
||||
ggufCache = map[string]ggufCacheEntry{}
|
||||
}
|
||||
ggufCache[key] = ggufCacheEntry{stamp: stamp.String(), info: info}
|
||||
ggufCacheMu.Unlock()
|
||||
return cloneGGUFInfo(info), nil
|
||||
}
|
||||
|
||||
// cloneGGUFInfo : l'appelant peut modifier le tableau par couche sans toucher au
|
||||
// cache.
|
||||
func cloneGGUFInfo(in GGUFInfo) GGUFInfo {
|
||||
if in.HeadCountKVLayers != nil {
|
||||
in.HeadCountKVLayers = append([]int(nil), in.HeadCountKVLayers...)
|
||||
}
|
||||
return in
|
||||
}
|
||||
@@ -0,0 +1,417 @@
|
||||
package loki
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/binary"
|
||||
"math"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"reflect"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
// ggufBuilder écrit un GGUF synthétique : en-tête, clés typées, table des
|
||||
// tenseurs, sans aucune donnée de poids (le lecteur n'en lit jamais).
|
||||
type ggufBuilder struct {
|
||||
bo binary.ByteOrder
|
||||
version uint32
|
||||
nKV int
|
||||
nT int
|
||||
kv bytes.Buffer
|
||||
tensors bytes.Buffer
|
||||
}
|
||||
|
||||
func newGGUF() *ggufBuilder { return &ggufBuilder{bo: binary.LittleEndian, version: 3} }
|
||||
|
||||
func (b *ggufBuilder) cnt(w *bytes.Buffer, n uint64) {
|
||||
if b.version == 1 {
|
||||
binary.Write(w, b.bo, uint32(n))
|
||||
} else {
|
||||
binary.Write(w, b.bo, n)
|
||||
}
|
||||
}
|
||||
|
||||
func (b *ggufBuilder) str(w *bytes.Buffer, s string) {
|
||||
b.cnt(w, uint64(len(s)))
|
||||
w.WriteString(s)
|
||||
}
|
||||
|
||||
func (b *ggufBuilder) key(k string, t uint32) {
|
||||
b.nKV++
|
||||
b.str(&b.kv, k)
|
||||
binary.Write(&b.kv, b.bo, t)
|
||||
}
|
||||
|
||||
func (b *ggufBuilder) u32(k string, v uint32) *ggufBuilder {
|
||||
b.key(k, ggufUint32)
|
||||
binary.Write(&b.kv, b.bo, v)
|
||||
return b
|
||||
}
|
||||
|
||||
func (b *ggufBuilder) i64(k string, v int64) *ggufBuilder {
|
||||
b.key(k, ggufInt64)
|
||||
binary.Write(&b.kv, b.bo, v)
|
||||
return b
|
||||
}
|
||||
|
||||
func (b *ggufBuilder) f32(k string, v float32) *ggufBuilder {
|
||||
b.key(k, ggufFloat32)
|
||||
binary.Write(&b.kv, b.bo, math.Float32bits(v))
|
||||
return b
|
||||
}
|
||||
|
||||
func (b *ggufBuilder) boolean(k string, v bool) *ggufBuilder {
|
||||
b.key(k, ggufBool)
|
||||
if v {
|
||||
b.kv.WriteByte(1)
|
||||
} else {
|
||||
b.kv.WriteByte(0)
|
||||
}
|
||||
return b
|
||||
}
|
||||
|
||||
func (b *ggufBuilder) s(k, v string) *ggufBuilder {
|
||||
b.key(k, ggufString)
|
||||
b.str(&b.kv, v)
|
||||
return b
|
||||
}
|
||||
|
||||
func (b *ggufBuilder) strArr(k string, vs []string) *ggufBuilder {
|
||||
b.key(k, ggufArray)
|
||||
binary.Write(&b.kv, b.bo, ggufString)
|
||||
b.cnt(&b.kv, uint64(len(vs)))
|
||||
for _, v := range vs {
|
||||
b.str(&b.kv, v)
|
||||
}
|
||||
return b
|
||||
}
|
||||
|
||||
func (b *ggufBuilder) i32Arr(k string, vs []int32) *ggufBuilder {
|
||||
b.key(k, ggufArray)
|
||||
binary.Write(&b.kv, b.bo, ggufInt32)
|
||||
b.cnt(&b.kv, uint64(len(vs)))
|
||||
for _, v := range vs {
|
||||
binary.Write(&b.kv, b.bo, v)
|
||||
}
|
||||
return b
|
||||
}
|
||||
|
||||
// nested : tableau de deux tableaux de u8, pour vérifier le saut récursif.
|
||||
func (b *ggufBuilder) nested(k string) *ggufBuilder {
|
||||
b.key(k, ggufArray)
|
||||
binary.Write(&b.kv, b.bo, ggufArray)
|
||||
b.cnt(&b.kv, 2)
|
||||
for i := 0; i < 2; i++ {
|
||||
binary.Write(&b.kv, b.bo, ggufUint8)
|
||||
b.cnt(&b.kv, 3)
|
||||
b.kv.Write([]byte{1, 2, 3})
|
||||
}
|
||||
return b
|
||||
}
|
||||
|
||||
func (b *ggufBuilder) tensor(name string, dims ...uint64) *ggufBuilder {
|
||||
b.str(&b.tensors, name)
|
||||
binary.Write(&b.tensors, b.bo, uint32(len(dims)))
|
||||
for _, d := range dims {
|
||||
if b.version == 1 {
|
||||
binary.Write(&b.tensors, b.bo, uint32(d))
|
||||
} else {
|
||||
binary.Write(&b.tensors, b.bo, d)
|
||||
}
|
||||
}
|
||||
binary.Write(&b.tensors, b.bo, uint32(0)) // type F32
|
||||
binary.Write(&b.tensors, b.bo, uint64(b.nT*32)) // position des données
|
||||
b.nT++
|
||||
return b
|
||||
}
|
||||
|
||||
func (b *ggufBuilder) bytes() []byte {
|
||||
var out bytes.Buffer
|
||||
out.WriteString("GGUF")
|
||||
binary.Write(&out, b.bo, b.version)
|
||||
b.cnt(&out, uint64(b.nT))
|
||||
b.cnt(&out, uint64(b.nKV))
|
||||
out.Write(b.kv.Bytes())
|
||||
out.Write(b.tensors.Bytes())
|
||||
// bourrage jusqu'à l'alignement par défaut, puis 32 octets de « poids » par
|
||||
// tenseur
|
||||
if b.nT > 0 {
|
||||
out.Write(make([]byte, (32-out.Len()%32)%32+32*b.nT))
|
||||
}
|
||||
return out.Bytes()
|
||||
}
|
||||
|
||||
func writeGGUF(t *testing.T, dir, name string, data []byte) string {
|
||||
t.Helper()
|
||||
p := filepath.Join(dir, name)
|
||||
if err := os.WriteFile(p, data, 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return p
|
||||
}
|
||||
|
||||
// qwenMTP : modèle hybride à 4 couches, têtes KV par couche (0 sur les couches
|
||||
// récurrentes), tête MTP sur la dernière couche, vocabulaire à sauter.
|
||||
func qwenMTP(b *ggufBuilder) *ggufBuilder {
|
||||
return b.u32("general.alignment", 32).
|
||||
strArr("tokenizer.ggml.tokens", []string{"<s>", "a", "bb", strings.Repeat("c", 300)}).
|
||||
s("general.architecture", "qwen35").
|
||||
s("general.name", "Qwen synthétique").
|
||||
u32("qwen35.block_count", 4).
|
||||
u32("qwen35.nextn_predict_layers", 1).
|
||||
i64("qwen35.context_length", 262144).
|
||||
u32("qwen35.attention.key_length", 256).
|
||||
u32("qwen35.attention.value_length", 256).
|
||||
i32Arr("qwen35.attention.head_count_kv", []int32{0, 0, 0, 4}).
|
||||
u32("qwen35.full_attention_interval", 4).
|
||||
u32("qwen35.ssm.conv_kernel", 4).
|
||||
f32("qwen35.rope.freq_base", 1e7).
|
||||
boolean("tokenizer.ggml.add_bos_token", false).
|
||||
nested("bidon.imbrique").
|
||||
tensor("token_embd.weight", 1024, 32000).
|
||||
tensor("blk.0.attn_norm.weight", 1024).
|
||||
tensor("blk.3.nextn.eh_proj.weight", 2048, 1024)
|
||||
}
|
||||
|
||||
func TestGGUFMetaQwenHybrideMTP(t *testing.T) {
|
||||
p := writeGGUF(t, t.TempDir(), "qwen.gguf", qwenMTP(newGGUF()).bytes())
|
||||
got, err := ggufMeta(p)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
want := GGUFInfo{
|
||||
Version: 3, Arch: "qwen35", BlockCount: 4, NextN: 1, ContextLength: 262144,
|
||||
KeyLen: 256, ValLen: 256, HeadCountKV: 4, HeadCountKVLayers: []int{0, 0, 0, 4},
|
||||
FullAttnInterval: 4, Hybrid: true, SSMConv: 4, HasNextNTensor: true, TensorCount: 3,
|
||||
}
|
||||
if !reflect.DeepEqual(got, want) {
|
||||
t.Fatalf("ggufMeta =\n%+v\nattendu\n%+v", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
// La clé nextn_predict_layers ne prouve rien : seul le tenseur de la DERNIÈRE
|
||||
// couche compte, comme dans llama.cpp.
|
||||
func TestGGUFMetaTeteMTPAbsenteOuMalPlacee(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
base := func() *ggufBuilder {
|
||||
return newGGUF().s("general.architecture", "glm4moe").
|
||||
u32("glm4moe.block_count", 47).
|
||||
u32("glm4moe.nextn_predict_layers", 1).
|
||||
u32("glm4moe.expert_count", 128).
|
||||
u32("glm4moe.attention.head_count_kv", 8)
|
||||
}
|
||||
cases := []struct {
|
||||
name string
|
||||
b *ggufBuilder
|
||||
}{
|
||||
{"sans-tenseur.gguf", base().tensor("blk.45.ffn_up.weight", 8, 8)},
|
||||
{"mauvaise-couche.gguf", base().tensor("blk.45.nextn.eh_proj.weight", 8, 8)},
|
||||
}
|
||||
for _, c := range cases {
|
||||
got, err := ggufMeta(writeGGUF(t, dir, c.name, c.b.bytes()))
|
||||
if err != nil {
|
||||
t.Fatalf("%s : %v", c.name, err)
|
||||
}
|
||||
if got.HasNextNTensor || got.NextN != 1 || got.ExpertCount != 128 || got.HeadCountKV != 8 ||
|
||||
got.HeadCountKVLayers != nil || got.Hybrid {
|
||||
t.Fatalf("%s : %+v", c.name, got)
|
||||
}
|
||||
}
|
||||
got, err := ggufMeta(writeGGUF(t, dir, "ok.gguf", base().tensor("blk.46.nextn.eh_proj.weight", 8, 8).bytes()))
|
||||
if err != nil || !got.HasNextNTensor {
|
||||
t.Fatalf("tête MTP en dernière couche non vue : %+v, %v", got, err)
|
||||
}
|
||||
}
|
||||
|
||||
// Modèle en tranches : les clés sont dans la première, la tête MTP dans la
|
||||
// dernière. N'importe quelle tranche peut être désignée.
|
||||
func TestGGUFMetaTranches(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
first := newGGUF().s("general.architecture", "qwen35").u32("qwen35.block_count", 4).
|
||||
u32("split.count", 2).tensor("token_embd.weight", 8, 8)
|
||||
second := newGGUF().u32("split.no", 1).u32("split.count", 2).
|
||||
tensor("blk.3.nextn.eh_proj.weight", 8, 8).tensor("output.weight", 8, 8)
|
||||
p1 := writeGGUF(t, dir, "m-00001-of-00002.gguf", first.bytes())
|
||||
writeGGUF(t, dir, "m-00002-of-00002.gguf", second.bytes())
|
||||
for _, p := range []string{p1, filepath.Join(dir, "m-00002-of-00002.gguf")} {
|
||||
got, err := ggufMeta(p)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if got.Arch != "qwen35" || !got.HasNextNTensor || got.TensorCount != 3 {
|
||||
t.Fatalf("%s : %+v", baseName(p), got)
|
||||
}
|
||||
}
|
||||
// tranche manquante : on ne sait pas, donc erreur
|
||||
writeGGUF(t, dir, "n-00001-of-00002.gguf", first.bytes())
|
||||
if _, err := ggufMeta(filepath.Join(dir, "n-00001-of-00002.gguf")); err == nil {
|
||||
t.Fatal("tranche manquante acceptée")
|
||||
}
|
||||
// gguf-split --no-tensor-first-split : la première tranche ne porte que les
|
||||
// clés, aucun tenseur ni aucune donnée. Elle doit passer telle quelle.
|
||||
meta := newGGUF().s("general.architecture", "glm4moe").u32("glm4moe.block_count", 47).
|
||||
u32("split.count", 2)
|
||||
rest := newGGUF().u32("split.no", 1).tensor("blk.46.nextn.eh_proj.weight", 8, 8)
|
||||
writeGGUF(t, dir, "s-00001-of-00002.gguf", meta.bytes())
|
||||
writeGGUF(t, dir, "s-00002-of-00002.gguf", rest.bytes())
|
||||
got, err := ggufMeta(filepath.Join(dir, "s-00001-of-00002.gguf"))
|
||||
if err != nil || got.Arch != "glm4moe" || !got.HasNextNTensor || got.TensorCount != 1 {
|
||||
t.Fatalf("première tranche sans tenseur : %+v, %v", got, err)
|
||||
}
|
||||
}
|
||||
|
||||
// v1 (compteurs 32 bits) et gros-boutiste : même résultat que le v3 ordinaire.
|
||||
func TestGGUFMetaVersionsEtOrdre(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
v1 := newGGUF()
|
||||
v1.version = 1
|
||||
v2 := newGGUF()
|
||||
v2.version = 2
|
||||
be := newGGUF()
|
||||
be.bo = binary.BigEndian
|
||||
for name, b := range map[string]*ggufBuilder{"v1.gguf": v1, "v2.gguf": v2, "be.gguf": be} {
|
||||
got, err := ggufMeta(writeGGUF(t, dir, name, qwenMTP(b).bytes()))
|
||||
if err != nil {
|
||||
t.Fatalf("%s : %v", name, err)
|
||||
}
|
||||
if got.Arch != "qwen35" || got.BlockCount != 4 || !got.HasNextNTensor ||
|
||||
!reflect.DeepEqual(got.HeadCountKVLayers, []int{0, 0, 0, 4}) || got.Version != int(b.version) {
|
||||
t.Fatalf("%s : %+v", name, got)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Fichier en cours de téléchargement : chaque coupure donne une erreur, jamais
|
||||
// un panic ni un faux résultat.
|
||||
func TestGGUFMetaTronque(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
full := qwenMTP(newGGUF()).bytes()
|
||||
// Toutes les coupures jusqu'au premier octet du dernier tenseur (le seul que
|
||||
// le lecteur sait situer sans la table des types ggml).
|
||||
for cut := 0; cut <= len(full)-32; cut++ {
|
||||
if _, err := parseGGUF(bytes.NewReader(full[:cut]), int64(cut)); err == nil {
|
||||
t.Fatalf("coupure à %d/%d acceptée", cut, len(full))
|
||||
}
|
||||
}
|
||||
if _, err := parseGGUF(bytes.NewReader(full), int64(len(full))); err != nil {
|
||||
t.Fatalf("fichier complet refusé : %v", err)
|
||||
}
|
||||
// et par le chemin fichier, pour une coupure dans l'en-tête
|
||||
if _, err := ggufMeta(writeGGUF(t, dir, "cut.gguf", full[:len(full)/3])); err == nil {
|
||||
t.Fatal("fichier coupé accepté")
|
||||
}
|
||||
}
|
||||
|
||||
func TestGGUFMetaFichiersAberrants(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
hdr := func(ver uint32, nT, nKV uint64) *bytes.Buffer {
|
||||
var w bytes.Buffer
|
||||
w.WriteString("GGUF")
|
||||
binary.Write(&w, binary.LittleEndian, ver)
|
||||
binary.Write(&w, binary.LittleEndian, nT)
|
||||
binary.Write(&w, binary.LittleEndian, nKV)
|
||||
return &w
|
||||
}
|
||||
// clé annoncée à 1 Eo
|
||||
hugeKey := hdr(3, 0, 1)
|
||||
binary.Write(hugeKey, binary.LittleEndian, uint64(1)<<60)
|
||||
// tableau de chaînes annoncé à 2^62 éléments
|
||||
hugeArr := hdr(3, 0, 1)
|
||||
binary.Write(hugeArr, binary.LittleEndian, uint64(1))
|
||||
hugeArr.WriteString("k")
|
||||
binary.Write(hugeArr, binary.LittleEndian, ggufArray)
|
||||
binary.Write(hugeArr, binary.LittleEndian, ggufString)
|
||||
binary.Write(hugeArr, binary.LittleEndian, uint64(1)<<62)
|
||||
// type de valeur inconnu
|
||||
badType := hdr(3, 0, 1)
|
||||
binary.Write(badType, binary.LittleEndian, uint64(1))
|
||||
badType.WriteString("k")
|
||||
binary.Write(badType, binary.LittleEndian, uint32(99))
|
||||
binary.Write(badType, binary.LittleEndian, uint64(0))
|
||||
// tenseur à 1000 dimensions
|
||||
badDims := hdr(3, 1, 0)
|
||||
binary.Write(badDims, binary.LittleEndian, uint64(1))
|
||||
badDims.WriteString("t")
|
||||
binary.Write(badDims, binary.LittleEndian, uint32(1000))
|
||||
badDims.Write(make([]byte, 64))
|
||||
|
||||
cases := map[string][]byte{
|
||||
"vide": nil,
|
||||
"alignement": newGGUF().u32("general.alignment", 24).tensor("t", 1).bytes(),
|
||||
"signature": []byte("GGML\x03\x00\x00\x00" + strings.Repeat("\x00", 16)),
|
||||
"version": hdr(4, 0, 0).Bytes(),
|
||||
"compteurs": hdr(3, 1<<40, 1<<40).Bytes(),
|
||||
"cle-geante": hugeKey.Bytes(),
|
||||
"tableau": hugeArr.Bytes(),
|
||||
"type-inconnu": badType.Bytes(),
|
||||
"dimensions": badDims.Bytes(),
|
||||
}
|
||||
for name, data := range cases {
|
||||
if _, err := ggufMeta(writeGGUF(t, dir, name+".gguf", data)); err == nil {
|
||||
t.Errorf("%s : accepté", name)
|
||||
}
|
||||
}
|
||||
if _, err := ggufMeta(filepath.Join(dir, "absent.gguf")); err == nil {
|
||||
t.Error("fichier absent accepté")
|
||||
}
|
||||
}
|
||||
|
||||
// Octet corrompu n'importe où dans l'en-tête (v1 et v3) : le lecteur doit rendre
|
||||
// la main vite, sans panic remonté ni allocation démesurée. Les valeurs 0xFF et
|
||||
// 0x7F transforment un compteur en nombre géant, le cas qui ferait mal.
|
||||
func TestGGUFMetaOctetCorrompu(t *testing.T) {
|
||||
v1 := newGGUF()
|
||||
v1.version = 1
|
||||
for _, b := range []*ggufBuilder{newGGUF(), v1} {
|
||||
full := qwenMTP(b).bytes()
|
||||
hdr := len(full) - 3*32 // le bourrage et les « poids » ne sont jamais lus
|
||||
for i := 0; i < hdr; i++ {
|
||||
for _, v := range []byte{0xFF, 0x7F, 0x00} {
|
||||
mut := append([]byte(nil), full...)
|
||||
mut[i] = v
|
||||
// le filet de sécurité existe, mais aucune entrée ne doit y tomber
|
||||
if _, err := parseGGUF(bytes.NewReader(mut), int64(len(mut))); err != nil &&
|
||||
strings.Contains(err.Error(), "illisible") {
|
||||
t.Fatalf("v%d, octet %d = %#x : %v", b.version, i, v, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Le cache tient tant que taille et date sont les mêmes, et se renouvelle dès
|
||||
// qu'elles changent (téléchargement terminé, fichier remplacé).
|
||||
func TestGGUFMetaCache(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
data := qwenMTP(newGGUF()).bytes()
|
||||
p := writeGGUF(t, dir, "c.gguf", data)
|
||||
stamp := time.Date(2026, 1, 2, 3, 4, 5, 0, time.UTC)
|
||||
if err := os.Chtimes(p, stamp, stamp); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
a, err := ggufMeta(p)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
a.HeadCountKVLayers[0] = 99 // ne doit pas atteindre le cache
|
||||
|
||||
// même taille, même date, contenu illisible : le cache répond
|
||||
writeGGUF(t, dir, "c.gguf", make([]byte, len(data)))
|
||||
if err := os.Chtimes(p, stamp, stamp); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
b, err := ggufMeta(p)
|
||||
if err != nil || b.HeadCountKVLayers[0] != 0 || !b.HasNextNTensor {
|
||||
t.Fatalf("cache non utilisé ou altéré : %+v, %v", b, err)
|
||||
}
|
||||
|
||||
// date différente : relecture, donc erreur sur le contenu illisible
|
||||
if err := os.Chtimes(p, stamp.Add(time.Second), stamp.Add(time.Second)); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := ggufMeta(p); err == nil {
|
||||
t.Fatal("fichier modifié servi depuis le cache")
|
||||
}
|
||||
}
|
||||
+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
|
||||
|
||||
+227
-19
@@ -201,38 +201,70 @@ func modelFromPresetContent(content string) string {
|
||||
// n'effacer que la première laissait des dizaines de Go de fichiers que plus
|
||||
// rien ne référence, et que rien ne sait plus supprimer depuis l'interface (les
|
||||
// tranches suivantes n'y apparaissent pas).
|
||||
func deleteModelFile(name string) error {
|
||||
//
|
||||
// Renvoie le nombre d'octets réellement libérés — l'interface le dit, et
|
||||
// « 41,2 Go libérés » est la seule confirmation qui vaille quelque chose après
|
||||
// un ménage de disque.
|
||||
//
|
||||
// Un modèle EN SERVICE n'est jamais effacé : le fichier reste mappé par
|
||||
// llama-server (la place n'est donc même pas rendue) et le prochain démarrage
|
||||
// meurt sur un .gguf absent, sans que rien n'ait prévenu.
|
||||
func deleteModelFile(name string) (int64, error) {
|
||||
p, err := resolveModelPath(name)
|
||||
if err != nil {
|
||||
return err
|
||||
return 0, err
|
||||
}
|
||||
if _, active := modelUsers(name); active {
|
||||
return 0, fmt.Errorf("%s", modelInServiceMsg)
|
||||
}
|
||||
freed := modelFamilySize(p)
|
||||
if err := os.Remove(p); err != nil {
|
||||
if os.IsNotExist(err) {
|
||||
return fmt.Errorf("modèle introuvable: %s", filepath.Base(p))
|
||||
return 0, fmt.Errorf("modèle introuvable: %s", filepath.Base(p))
|
||||
}
|
||||
return err
|
||||
return 0, err
|
||||
}
|
||||
dir := filepath.Dir(p)
|
||||
for _, n := range shardFamily(filepath.Base(p)) {
|
||||
_ = os.Remove(filepath.Join(dir, n)) // déjà supprimée ou absente = rien à faire
|
||||
}
|
||||
return nil
|
||||
return freed, nil
|
||||
}
|
||||
|
||||
// handleModelDelete deletes a single .gguf from LOKI_HOME.
|
||||
// handleModelDelete efface un .gguf d'un des dossiers de modèles déclarés.
|
||||
//
|
||||
// Deux garde-fous, et un seul est un refus définitif :
|
||||
//
|
||||
// - modèle en service → refus net (deleteModelFile), il faut basculer d'abord ;
|
||||
// - modèle nommé par un ou plusieurs presets → 409 avec la LISTE des presets
|
||||
// concernés. L'interface la montre, l'utilisateur tranche, et renvoie la
|
||||
// même requête avec force:true. Refuser tout court transformerait le
|
||||
// nettoyage en impasse — c'est exactement le travers qu'on corrige ici.
|
||||
func handleModelDelete(w http.ResponseWriter, r *http.Request) {
|
||||
var req struct {
|
||||
Name string `json:"name"`
|
||||
Name string `json:"name"`
|
||||
Force bool `json:"force"`
|
||||
}
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||
sendJSON(w, 400, map[string]any{"ok": false, "error": err.Error()})
|
||||
return
|
||||
}
|
||||
if err := deleteModelFile(req.Name); err != nil {
|
||||
users, active := modelUsers(req.Name)
|
||||
if active {
|
||||
sendJSON(w, 409, map[string]any{"ok": false, "error": modelInServiceMsg, "active": true})
|
||||
return
|
||||
}
|
||||
if len(users) > 0 && !req.Force {
|
||||
sendJSON(w, 409, map[string]any{"ok": false, "used": users,
|
||||
"error": "modèle utilisé par : " + strings.Join(users, ", ")})
|
||||
return
|
||||
}
|
||||
freed, err := deleteModelFile(req.Name)
|
||||
if err != nil {
|
||||
sendJSON(w, 400, map[string]any{"ok": false, "error": err.Error()})
|
||||
return
|
||||
}
|
||||
sendJSON(w, 200, map[string]any{"ok": true})
|
||||
sendJSON(w, 200, map[string]any{"ok": true, "freed": freed, "used": users})
|
||||
}
|
||||
|
||||
// ---- Hugging Face downloads -------------------------------------------------
|
||||
@@ -250,10 +282,96 @@ type dlState struct {
|
||||
Part int `json:"part"` // tranche en cours (1-based)
|
||||
Finished bool `json:"finished"`
|
||||
Canceled bool `json:"canceled"`
|
||||
Paused bool `json:"paused"`
|
||||
Err string `json:"error"`
|
||||
StartedAt int64 `json:"started_at"`
|
||||
|
||||
cancel context.CancelFunc `json:"-"` // set while in flight, cleared on finish
|
||||
gate *dlGate `json:"-"`
|
||||
}
|
||||
|
||||
// dlGate met un téléchargement en pause (repris d'AJEAN v0.18.1) : les
|
||||
// connexions en cours sont coupées (rien ne reste ouvert vers le CDN pendant la
|
||||
// pause), et chaque morceau attend la reprise puis repart de l'octet où il
|
||||
// s'était arrêté, par une requête de plage.
|
||||
type dlGate struct {
|
||||
mu sync.Mutex
|
||||
paused bool
|
||||
wake chan struct{} // fermé à la reprise
|
||||
attempt map[int]context.CancelFunc // requêtes en cours, coupées par la pause
|
||||
next int
|
||||
}
|
||||
|
||||
func newDlGate() *dlGate { return &dlGate{attempt: map[int]context.CancelFunc{}} }
|
||||
|
||||
func (g *dlGate) setPaused(p bool) {
|
||||
if g == nil {
|
||||
return
|
||||
}
|
||||
g.mu.Lock()
|
||||
defer g.mu.Unlock()
|
||||
if p == g.paused {
|
||||
return
|
||||
}
|
||||
g.paused = p
|
||||
if p {
|
||||
g.wake = make(chan struct{})
|
||||
for _, c := range g.attempt {
|
||||
c()
|
||||
}
|
||||
return
|
||||
}
|
||||
close(g.wake)
|
||||
}
|
||||
|
||||
// wait bloque tant que le téléchargement est en pause. false = annulé entre-temps.
|
||||
func (g *dlGate) wait(ctx context.Context) bool {
|
||||
if g == nil {
|
||||
return ctx.Err() == nil
|
||||
}
|
||||
g.mu.Lock()
|
||||
paused, wake := g.paused, g.wake
|
||||
g.mu.Unlock()
|
||||
if !paused {
|
||||
return ctx.Err() == nil
|
||||
}
|
||||
select {
|
||||
case <-wake:
|
||||
return ctx.Err() == nil
|
||||
case <-ctx.Done():
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
func (g *dlGate) isPaused() bool {
|
||||
if g == nil {
|
||||
return false
|
||||
}
|
||||
g.mu.Lock()
|
||||
defer g.mu.Unlock()
|
||||
return g.paused
|
||||
}
|
||||
|
||||
// begin ouvre une requête que la pause pourra couper ; la fonction rendue la referme.
|
||||
func (g *dlGate) begin(ctx context.Context) (context.Context, func()) {
|
||||
if g == nil {
|
||||
return ctx, func() {}
|
||||
}
|
||||
actx, cancel := context.WithCancel(ctx)
|
||||
g.mu.Lock()
|
||||
id := g.next
|
||||
g.next++
|
||||
g.attempt[id] = cancel
|
||||
if g.paused {
|
||||
cancel() // pause tombée entre wait et begin
|
||||
}
|
||||
g.mu.Unlock()
|
||||
return actx, func() {
|
||||
g.mu.Lock()
|
||||
delete(g.attempt, id)
|
||||
g.mu.Unlock()
|
||||
cancel()
|
||||
}
|
||||
}
|
||||
|
||||
var (
|
||||
@@ -382,13 +500,22 @@ func handleModelDownload(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
if len(todoURLs) == 0 {
|
||||
dlMu.Unlock()
|
||||
sendJSON(w, 409, map[string]any{"ok": false, "error": "le modèle existe déjà: " + name})
|
||||
// Rien à faire : toutes les tranches sont là. Ce n'était pas une erreur,
|
||||
// c'était le résultat voulu — le modèle est installé. On répond ok avec la
|
||||
// valeur à écrire dans MODEL=, l'interface le sélectionne et enchaîne la
|
||||
// file (le projecteur vision, par exemple) au lieu de s'arrêter en rouge.
|
||||
value, _, _ := modelPresence(name, req.Dir)
|
||||
if value == "" {
|
||||
value = modelPickerValue(filepath.Dir(dests[0]), name)
|
||||
}
|
||||
sendJSON(w, 200, map[string]any{"ok": true, "filename": name, "exists": true,
|
||||
"value": value, "parts": 0})
|
||||
return
|
||||
}
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
st := &dlState{
|
||||
Filename: name, URL: urls[0], Dir: filepath.Dir(dests[0]),
|
||||
Parts: len(todoURLs), StartedAt: time.Now().Unix(), cancel: cancel,
|
||||
Parts: len(todoURLs), StartedAt: time.Now().Unix(), cancel: cancel, gate: newDlGate(),
|
||||
}
|
||||
dlDownloads[name] = st
|
||||
dlMu.Unlock()
|
||||
@@ -462,6 +589,21 @@ func handleModelDownloadProbe(w http.ResponseWriter, r *http.Request) {
|
||||
// puis échouer au deux tiers du transfert.
|
||||
urls, names := shardURLSet(dlURL, name)
|
||||
name = names[0]
|
||||
// Déjà sur le disque : on ne sonde même pas Hugging Face. C'est le cas qui
|
||||
// mettait l'interface dans une impasse — un modèle gardé après la suppression
|
||||
// de son preset ne pouvait plus être re-choisi qu'en le retéléchargeant, et
|
||||
// le téléchargement refusait d'écraser. Il n'y a rien à télécharger : il y a
|
||||
// un modèle à SÉLECTIONNER, et l'UI a besoin de sa valeur MODEL= pour ça.
|
||||
// Cherché dans TOUS les dossiers déclarés, pas seulement la destination
|
||||
// choisie : re-télécharger 40 Go sur le SSD parce que le fichier est sur le
|
||||
// disque externe déjà déclaré n'aide personne — le sélecteur, lui, le trouve
|
||||
// où qu'il soit.
|
||||
if value, path, ok := modelPresence(name, ""); ok {
|
||||
sendJSON(w, 200, map[string]any{"ok": true, "filename": name, "dir": filepath.Dir(path),
|
||||
"exists": true, "value": value, "size": modelFamilySize(path),
|
||||
"free": diskFree(dir), "free_exact": diskFreeReliable(dir), "enough": true, "parts": len(urls)})
|
||||
return
|
||||
}
|
||||
ctx, cancel := context.WithTimeout(r.Context(), 30*time.Second)
|
||||
defer cancel()
|
||||
var total int64
|
||||
@@ -539,7 +681,11 @@ func dlSourceError(resp *http.Response, dlURL string) error {
|
||||
case 404:
|
||||
switch code {
|
||||
case "EntryNotFound":
|
||||
return fmt.Errorf("fichier absent du dépôt (HTTP 404) — la révision a pu être réécrite depuis que le lien a été copié")
|
||||
// Nommer le fichier : sur un modèle en fragments, c'est souvent UN
|
||||
// fragment qui manque — certains dépôts n'en publient que six sur
|
||||
// sept et livrent la table PLE à part, à lier soi-même — et sans
|
||||
// le nom, on cherche une révision réécrite qui n'existe pas.
|
||||
return fmt.Errorf("fichier %s absent du dépôt (HTTP 404) — dépôt réorganisé depuis la copie du lien, ou fragment jamais publié (table PLE livrée à part ?)", dlFileLabel(dlURL))
|
||||
case "RevisionNotFound":
|
||||
return fmt.Errorf("révision introuvable dans le dépôt (HTTP 404)")
|
||||
}
|
||||
@@ -555,6 +701,20 @@ func dlSourceError(resp *http.Response, dlURL string) error {
|
||||
return fmt.Errorf("HTTP %d depuis la source", resp.StatusCode)
|
||||
}
|
||||
|
||||
// dlFileLabel : « nom.gguf » entre guillemets pour un message, ou vide si le
|
||||
// lien n'a pas de nom de fichier lisible.
|
||||
func dlFileLabel(dlURL string) string {
|
||||
u, err := url.Parse(dlURL)
|
||||
if err != nil {
|
||||
return ""
|
||||
}
|
||||
name := path.Base(u.Path)
|
||||
if name == "" || name == "/" || name == "." {
|
||||
return ""
|
||||
}
|
||||
return "« " + name + " »"
|
||||
}
|
||||
|
||||
// dlAccessReason nomme la raison du refus. repo vide = source hors Hugging Face
|
||||
// (lien direct vers un autre hébergeur) : on ne parle alors pas de conditions à
|
||||
// accepter, qui n'existent que là-bas.
|
||||
@@ -685,7 +845,9 @@ func runDownloadSet(ctx context.Context, st *dlState, urls, dests []string) {
|
||||
dt := now.Sub(lastAt).Seconds()
|
||||
dlMu.Lock()
|
||||
st.Done = cur
|
||||
if dt > 0 {
|
||||
if st.Paused {
|
||||
st.Speed = 0
|
||||
} else if dt > 0 {
|
||||
inst := int64(float64(cur-last) / dt)
|
||||
if st.Speed == 0 {
|
||||
st.Speed = inst
|
||||
@@ -753,7 +915,7 @@ func dlOnePart(ctx context.Context, st *dlState, dlURL, dest string, total int64
|
||||
return err
|
||||
}
|
||||
}
|
||||
if err := dlFetch(ctx, f, dlURL, total, conns, done); err != nil {
|
||||
if err := dlFetch(ctx, st.gate, f, dlURL, total, conns, done); err != nil {
|
||||
f.Close()
|
||||
_ = os.Remove(tmp)
|
||||
return err
|
||||
@@ -771,9 +933,9 @@ func dlOnePart(ctx context.Context, st *dlState, dlURL, dest string, total int64
|
||||
|
||||
// dlFetch writes the whole body into f, either as one stream or as `conns`
|
||||
// parallel byte ranges. done is incremented atomically as bytes land on disk.
|
||||
func dlFetch(ctx context.Context, f *os.File, dlURL string, total int64, conns int, done *int64) error {
|
||||
func dlFetch(ctx context.Context, g *dlGate, f *os.File, dlURL string, total int64, conns int, done *int64) error {
|
||||
if conns <= 1 {
|
||||
return dlChunk(ctx, f, dlURL, 0, total-1, total <= 0, done)
|
||||
return dlChunk(ctx, g, f, dlURL, 0, total-1, total <= 0, done)
|
||||
}
|
||||
size := total / int64(conns)
|
||||
var wg sync.WaitGroup
|
||||
@@ -787,7 +949,7 @@ func dlFetch(ctx context.Context, f *os.File, dlURL string, total int64, conns i
|
||||
wg.Add(1)
|
||||
go func(i int, start, end int64) {
|
||||
defer wg.Done()
|
||||
errs[i] = dlChunk(ctx, f, dlURL, start, end, false, done)
|
||||
errs[i] = dlChunk(ctx, g, f, dlURL, start, end, false, done)
|
||||
}(i, start, end)
|
||||
}
|
||||
wg.Wait()
|
||||
@@ -802,11 +964,15 @@ func dlFetch(ctx context.Context, f *os.File, dlURL string, total int64, conns i
|
||||
// dlChunk downloads [start,end] into f at the right offset, retrying from where
|
||||
// it stopped if the connection drops mid-chunk. With whole=true it streams the
|
||||
// entire body sequentially (server without range support, unknown size).
|
||||
func dlChunk(ctx context.Context, f *os.File, dlURL string, start, end int64, whole bool, done *int64) error {
|
||||
func dlChunk(ctx context.Context, g *dlGate, f *os.File, dlURL string, start, end int64, whole bool, done *int64) error {
|
||||
const attempts = 4
|
||||
pos := start
|
||||
var lastErr error
|
||||
for try := 0; try < attempts; try++ {
|
||||
// Une pause n'est pas un échec : on attend la reprise sans consommer d'essai.
|
||||
if !g.wait(ctx) {
|
||||
return ctx.Err()
|
||||
}
|
||||
if err := ctx.Err(); err != nil {
|
||||
return err // cancelled: never retry
|
||||
}
|
||||
@@ -826,18 +992,26 @@ func dlChunk(ctx context.Context, f *os.File, dlURL string, start, end int64, wh
|
||||
} else if pos > start {
|
||||
rng = fmt.Sprintf("bytes=%d-", pos) // best-effort resume
|
||||
}
|
||||
req, err := dlRequest(ctx, dlURL, rng)
|
||||
actx, release := g.begin(ctx)
|
||||
req, err := dlRequest(actx, dlURL, rng)
|
||||
if err != nil {
|
||||
release()
|
||||
return err
|
||||
}
|
||||
resp, err := dlClient.Do(req)
|
||||
if err != nil {
|
||||
release()
|
||||
if g.isPaused() && ctx.Err() == nil {
|
||||
try--
|
||||
continue
|
||||
}
|
||||
lastErr = err
|
||||
continue
|
||||
}
|
||||
if resp.StatusCode != 200 && resp.StatusCode != 206 {
|
||||
err := dlSourceError(resp, dlURL)
|
||||
resp.Body.Close()
|
||||
release()
|
||||
return err
|
||||
}
|
||||
if resp.StatusCode == 200 && pos > start {
|
||||
@@ -847,10 +1021,15 @@ func dlChunk(ctx context.Context, f *os.File, dlURL string, start, end int64, wh
|
||||
}
|
||||
n, cerr := dlCopy(f, resp.Body, pos, done)
|
||||
resp.Body.Close()
|
||||
release()
|
||||
pos += n
|
||||
if cerr == nil {
|
||||
return nil
|
||||
}
|
||||
if g.isPaused() && ctx.Err() == nil {
|
||||
try-- // coupé par la pause : la reprise repart de pos
|
||||
continue
|
||||
}
|
||||
lastErr = cerr
|
||||
}
|
||||
return lastErr
|
||||
@@ -907,6 +1086,35 @@ func handleModelDownloadCancel(w http.ResponseWriter, r *http.Request) {
|
||||
sendJSON(w, 200, map[string]any{"ok": true})
|
||||
}
|
||||
|
||||
// handleModelDownloadPause met en pause ({"pause":true}) ou reprend
|
||||
// ({"pause":false}) un téléchargement en cours. Le fichier partiel est gardé.
|
||||
func handleModelDownloadPause(w http.ResponseWriter, r *http.Request) {
|
||||
var req struct {
|
||||
Filename string `json:"filename"`
|
||||
Pause bool `json:"pause"`
|
||||
}
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||
sendJSON(w, 400, map[string]any{"ok": false, "error": err.Error()})
|
||||
return
|
||||
}
|
||||
name := filepath.Base(strings.TrimSpace(req.Filename))
|
||||
dlMu.Lock()
|
||||
st, ok := dlDownloads[name]
|
||||
if !ok || st.Finished {
|
||||
dlMu.Unlock()
|
||||
sendJSON(w, 404, map[string]any{"ok": false, "error": "aucun téléchargement en cours pour " + name})
|
||||
return
|
||||
}
|
||||
st.Paused = req.Pause
|
||||
if req.Pause {
|
||||
st.Speed = 0
|
||||
}
|
||||
g := st.gate
|
||||
dlMu.Unlock()
|
||||
g.setPaused(req.Pause)
|
||||
sendJSON(w, 200, map[string]any{"ok": true, "paused": req.Pause})
|
||||
}
|
||||
|
||||
// cleanStalePartFiles removes leftover *.gguf.part files in every model dir at
|
||||
// startup. A download killed by a crash or a service restart can't be resumed
|
||||
// (its state lived in memory), so the partial file would otherwise sit there
|
||||
|
||||
@@ -172,7 +172,15 @@ func TestDLSourceErrorExplainsGatedRepo(t *testing.T) {
|
||||
},
|
||||
{
|
||||
name: "fichier absent", resp: resp(404, "EntryNotFound"), url: gated,
|
||||
want: []string{"absent du dépôt", "404"},
|
||||
want: []string{"« m.gguf » absent du dépôt", "404"},
|
||||
},
|
||||
{
|
||||
// Dépôt qui publie six fragments sur sept (table PLE livrée à part) :
|
||||
// le message doit NOMMER le fragment manquant, sinon on cherche une
|
||||
// révision réécrite qui n'existe pas.
|
||||
name: "fragment absent", resp: resp(404, "EntryNotFound"),
|
||||
url: "https://huggingface.co/x/y-GGUF/resolve/main/Q2_K/y-Q2_K-00007-of-00007.gguf",
|
||||
want: []string{"« y-Q2_K-00007-of-00007.gguf » absent du dépôt", "fragment jamais publié"},
|
||||
},
|
||||
{
|
||||
name: "source hors Hugging Face", resp: resp(403, ""), url: "https://example.com/m.gguf",
|
||||
@@ -223,3 +231,96 @@ func TestDLProbeSurfacesExplainedError(t *testing.T) {
|
||||
t.Errorf("message %q sans indication sur le jeton", err)
|
||||
}
|
||||
}
|
||||
|
||||
type slowWriter struct{ http.ResponseWriter }
|
||||
|
||||
func (s slowWriter) Write(b []byte) (int, error) {
|
||||
time.Sleep(2 * time.Millisecond)
|
||||
return s.ResponseWriter.Write(b)
|
||||
}
|
||||
|
||||
// Une pause coupe le flux, garde ce qui est écrit, et la reprise termine un
|
||||
// fichier identique à la source (repris d'AJEAN v0.18.1).
|
||||
func TestRunDownloadPauseResume(t *testing.T) {
|
||||
data := make([]byte, 48<<20)
|
||||
rand.New(rand.NewSource(2)).Read(data)
|
||||
t.Setenv("LOKI_DL_CONNS", "4")
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
http.ServeContent(slowWriter{w}, r, "m.gguf", time.Time{}, bytes.NewReader(data))
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
dest := filepath.Join(t.TempDir(), "m.gguf")
|
||||
st := &dlState{Filename: "m.gguf", gate: newDlGate()}
|
||||
done := make(chan struct{})
|
||||
go func() {
|
||||
runDownloadSet(context.Background(), st, []string{srv.URL + "/m.gguf"}, []string{dest})
|
||||
close(done)
|
||||
}()
|
||||
|
||||
time.Sleep(300 * time.Millisecond)
|
||||
st.gate.setPaused(true)
|
||||
time.Sleep(1500 * time.Millisecond) // le compteur publié se fige
|
||||
dlMu.Lock()
|
||||
a := st.Done
|
||||
dlMu.Unlock()
|
||||
time.Sleep(1500 * time.Millisecond)
|
||||
dlMu.Lock()
|
||||
b, fin := st.Done, st.Finished
|
||||
dlMu.Unlock()
|
||||
if fin || a != b || a == 0 {
|
||||
t.Fatalf("pause inopérante : %d puis %d octets, terminé=%v", a, b, fin)
|
||||
}
|
||||
st.gate.setPaused(false)
|
||||
select {
|
||||
case <-done:
|
||||
case <-time.After(30 * time.Second):
|
||||
t.Fatal("pas terminé après la reprise")
|
||||
}
|
||||
if st.Err != "" {
|
||||
t.Fatalf("erreur après reprise : %s", st.Err)
|
||||
}
|
||||
got, err := os.ReadFile(dest)
|
||||
if err != nil || !bytes.Equal(got, data) {
|
||||
t.Fatalf("fichier différent de la source après reprise (err=%v)", err)
|
||||
}
|
||||
}
|
||||
|
||||
// La route : pause puis reprise d'un téléchargement connu, refus pour
|
||||
// l'inconnu et pour un téléchargement terminé.
|
||||
func TestHandleModelDownloadPause(t *testing.T) {
|
||||
dlMu.Lock()
|
||||
saved := dlDownloads
|
||||
dlDownloads = map[string]*dlState{
|
||||
"a.gguf": {Filename: "a.gguf", Speed: 9, gate: newDlGate()},
|
||||
"b.gguf": {Filename: "b.gguf", Finished: true},
|
||||
}
|
||||
dlMu.Unlock()
|
||||
t.Cleanup(func() { dlMu.Lock(); dlDownloads = saved; dlMu.Unlock() })
|
||||
post := func(body string) (int, string) {
|
||||
w := httptest.NewRecorder()
|
||||
handleModelDownloadPause(w, httptest.NewRequest("POST", "/api/models/download/pause", strings.NewReader(body)))
|
||||
return w.Code, strings.TrimSpace(w.Body.String())
|
||||
}
|
||||
if code, out := post(`{"filename":"../a.gguf","pause":true}`); code != 200 || out != `{"ok":true,"paused":true}` {
|
||||
t.Fatalf("pause : %d %s", code, out)
|
||||
}
|
||||
dlMu.Lock()
|
||||
st := dlDownloads["a.gguf"]
|
||||
paused, speed := st.Paused, st.Speed
|
||||
dlMu.Unlock()
|
||||
if !paused || speed != 0 || !st.gate.isPaused() {
|
||||
t.Errorf("état après pause : paused=%v speed=%d porte=%v", paused, speed, st.gate.isPaused())
|
||||
}
|
||||
if code, out := post(`{"filename":"a.gguf","pause":false}`); code != 200 || out != `{"ok":true,"paused":false}` || st.gate.isPaused() {
|
||||
t.Fatalf("reprise : %d %s", code, out)
|
||||
}
|
||||
for _, body := range []string{`{"filename":"b.gguf","pause":true}`, `{"filename":"x.gguf","pause":true}`} {
|
||||
if code, _ := post(body); code != 404 {
|
||||
t.Errorf("%s : %d, attendu 404", body, code)
|
||||
}
|
||||
}
|
||||
if code, _ := post(`[`); code != 400 {
|
||||
t.Errorf("corps illisible : %d", code)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,165 @@
|
||||
package loki
|
||||
|
||||
// backend_models_refs.go — QUI référence un .gguf, et peut-on l'effacer ?
|
||||
//
|
||||
// Vécu qui a produit ce fichier : un modèle téléchargé depuis l'éditeur d'un
|
||||
// preset, le preset supprimé (sans cocher « supprimer aussi le .gguf »), et
|
||||
// 40 Go de fichier plus rien pour les reprendre — la route /api/models/delete
|
||||
// existait mais AUCUN bouton ne l'appelait. Supprimer un modèle depuis
|
||||
// l'interface demande deux choses : savoir qui s'en sert (le moteur en service,
|
||||
// d'autres presets), et le dire AVANT d'effacer.
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// modelRefIndex — l'index « ce fichier est référencé par… », construit en une
|
||||
// seule passe. Demander « qui utilise ce modèle ? » fichier par fichier
|
||||
// relisait tous les presets à chaque ligne de la liste des modèles.
|
||||
//
|
||||
// Les clés sont des chemins normalisés (normDir) : un preset peut nommer son
|
||||
// modèle en chemin absolu et un autre en simple nom de fichier, c'est le même
|
||||
// fichier sur le disque.
|
||||
type modelRefIndex struct {
|
||||
active map[string]bool // référencé par config.env — donc par le moteur qui tourne
|
||||
presets map[string][]string // chemin -> noms d'affichage des presets
|
||||
}
|
||||
|
||||
// modelRefKey normalise une valeur MODEL=/MMPROJ= en clé d'index, ou "" si elle
|
||||
// ne désigne pas un .gguf. resolveModelPath accepte le nom simple comme le
|
||||
// chemin absolu et les résout dans les dossiers déclarés — c'est exactement la
|
||||
// règle qu'appliquera le moteur.
|
||||
func modelRefKey(v string) string {
|
||||
v = strings.TrimSpace(v)
|
||||
if v == "" {
|
||||
return ""
|
||||
}
|
||||
p, err := resolveServeModelPath(v)
|
||||
if err != nil {
|
||||
// Introuvable : on garde quand même une clé comparable, sinon deux
|
||||
// presets qui nomment le même fichier absent paraîtraient distincts.
|
||||
p = filepath.Join(modelsDir(), baseName(v))
|
||||
}
|
||||
return normDir(p)
|
||||
}
|
||||
|
||||
// modelKeysOf liste les .gguf référencés par une configuration : le modèle, le
|
||||
// projecteur vision, le brouillon (MODEL_DRAFT : une tête MTP effacée sous le
|
||||
// moteur coupe la spéculation sans rien dire), et un --mmproj posé à la main
|
||||
// dans EXTRA_ARGS (les presets d'avant la clé MMPROJ l'écrivaient là).
|
||||
func modelKeysOf(env map[string]string) []string {
|
||||
vals := []string{env["MODEL"], env["MMPROJ"], env["MODEL_DRAFT"]}
|
||||
args := splitArgs(env["EXTRA_ARGS"])
|
||||
for i, a := range args {
|
||||
if a == "--mmproj" && i+1 < len(args) {
|
||||
vals = append(vals, args[i+1])
|
||||
}
|
||||
}
|
||||
out := []string{}
|
||||
for _, v := range vals {
|
||||
if k := modelRefKey(v); k != "" {
|
||||
out = append(out, k)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// buildModelRefIndex lit la configuration active et tous les presets.
|
||||
func buildModelRefIndex() modelRefIndex {
|
||||
ix := modelRefIndex{active: map[string]bool{}, presets: map[string][]string{}}
|
||||
for _, k := range modelKeysOf(ReadConfig()) {
|
||||
ix.active[k] = true
|
||||
}
|
||||
list, err := ListPresets()
|
||||
if err != nil {
|
||||
return ix
|
||||
}
|
||||
for _, p := range list {
|
||||
content, err := ReadPreset(p.ID)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
seen := map[string]bool{}
|
||||
for _, k := range modelKeysOf(parseEnv(content)) {
|
||||
if seen[k] {
|
||||
continue // MODEL et MMPROJ identiques : un seul « utilisé par »
|
||||
}
|
||||
seen[k] = true
|
||||
ix.presets[k] = append(ix.presets[k], p.Name)
|
||||
}
|
||||
}
|
||||
return ix
|
||||
}
|
||||
|
||||
// lookup renvoie les presets qui référencent path et s'il est en service.
|
||||
func (ix modelRefIndex) lookup(path string) (presets []string, active bool) {
|
||||
k := normDir(path)
|
||||
return ix.presets[k], ix.active[k]
|
||||
}
|
||||
|
||||
// modelUsers répond pour UN modèle (nom de fichier ou chemin).
|
||||
func modelUsers(name string) (presets []string, active bool) {
|
||||
k := modelRefKey(name)
|
||||
if k == "" {
|
||||
return nil, false
|
||||
}
|
||||
return buildModelRefIndex().lookup(k)
|
||||
}
|
||||
|
||||
// modelInServiceErr est le refus opposé à la suppression d'un modèle que le
|
||||
// moteur a ouvert. Effacer le .gguf sous un llama-server qui tourne ne libère
|
||||
// même pas la place (le fichier reste mappé) et rend le prochain démarrage
|
||||
// impossible sans rien dire.
|
||||
const modelInServiceMsg = "modèle en service — bascule sur un autre preset avant de le supprimer"
|
||||
|
||||
// modelFamilySize additionne ce que la suppression libérera réellement
|
||||
// (toutes les tranches présentes).
|
||||
func modelFamilySize(p string) int64 {
|
||||
return shardFamilySize(filepath.Dir(p), filepath.Base(p))
|
||||
}
|
||||
|
||||
// modelPresence dit si un modèle est DÉJÀ sur le disque, et sous quelle valeur
|
||||
// l'écrire dans MODEL=. dir restreint la recherche à ce dossier (celui visé par
|
||||
// un téléchargement) ; vide = tous les dossiers déclarés.
|
||||
//
|
||||
// C'est ce qui transforme « le modèle existe déjà » — un cul-de-sac rouge au
|
||||
// bout d'un clic sur un quant — en « déjà là, je le sélectionne ».
|
||||
func modelPresence(name, dir string) (value, path string, ok bool) {
|
||||
base := baseName(strings.TrimSpace(name))
|
||||
if base == "" || !strings.HasSuffix(strings.ToLower(base), ".gguf") {
|
||||
return "", "", false
|
||||
}
|
||||
dirs := modelDirs()
|
||||
if strings.TrimSpace(dir) != "" {
|
||||
if d, err := resolveDownloadDir(dir); err == nil {
|
||||
dirs = []string{d}
|
||||
}
|
||||
}
|
||||
for _, d := range dirs {
|
||||
p := filepath.Join(d, base)
|
||||
if st, err := os.Stat(p); err != nil || st.IsDir() {
|
||||
continue
|
||||
}
|
||||
// Famille incomplète = modèle inutilisable : on ne prétend pas qu'il est
|
||||
// là, l'appelant doit pouvoir relancer le téléchargement des tranches
|
||||
// manquantes (handleModelDownload les reprend une par une).
|
||||
if len(shardFamilyMissing(d, base)) > 0 {
|
||||
continue
|
||||
}
|
||||
return modelPickerValue(d, base), p, true
|
||||
}
|
||||
return "", "", false
|
||||
}
|
||||
|
||||
// modelPickerValue est la valeur à écrire dans MODEL= pour un fichier donné :
|
||||
// le simple nom quand il vit dans le dossier de téléchargement de Loki (le
|
||||
// preset reste alors portable d'une machine à l'autre), le chemin complet
|
||||
// ailleurs. Même règle que la liste /api/models.
|
||||
func modelPickerValue(dir, base string) string {
|
||||
if normDir(dir) == normDir(modelsDir()) {
|
||||
return base
|
||||
}
|
||||
return filepath.Join(dir, base)
|
||||
}
|
||||
@@ -0,0 +1,254 @@
|
||||
package loki
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// writeModel pose un .gguf factice dans dir et renvoie son chemin.
|
||||
func writeModel(t *testing.T, dir, name string, size int) string {
|
||||
t.Helper()
|
||||
if err := os.MkdirAll(dir, 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
p := filepath.Join(dir, name)
|
||||
if err := os.WriteFile(p, make([]byte, size), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return p
|
||||
}
|
||||
|
||||
// Le scénario complet du rapport : un modèle téléchargé depuis un preset, le
|
||||
// preset supprimé sans son .gguf, et plus rien pour reprendre le fichier.
|
||||
// Après ce correctif il doit être A) retrouvé par le sélecteur, B) effaçable.
|
||||
func TestModeleOrphelinResteUtilisableEtEffaçable(t *testing.T) {
|
||||
testHome(t)
|
||||
writeModel(t, modelsDir(), "modele.gguf", 1024)
|
||||
|
||||
// A) Présent sur le disque, sous la valeur qu'on écrira dans MODEL=.
|
||||
value, path, ok := modelPresence("modele.gguf", "")
|
||||
if !ok {
|
||||
t.Fatal("modèle présent non détecté — le téléchargement resterait un cul-de-sac")
|
||||
}
|
||||
if value != "modele.gguf" {
|
||||
t.Fatalf("valeur MODEL= = %q, attendu le simple nom de fichier", value)
|
||||
}
|
||||
if path != filepath.Join(modelsDir(), "modele.gguf") {
|
||||
t.Fatalf("chemin = %q", path)
|
||||
}
|
||||
|
||||
// B) Plus personne ne le référence : il s'efface sans force.
|
||||
users, active := modelUsers("modele.gguf")
|
||||
if active || len(users) != 0 {
|
||||
t.Fatalf("modèle orphelin annoncé utilisé : actif=%v presets=%v", active, users)
|
||||
}
|
||||
freed, err := deleteModelFile("modele.gguf")
|
||||
if err != nil {
|
||||
t.Fatalf("suppression refusée : %v", err)
|
||||
}
|
||||
if freed != 1024 {
|
||||
t.Fatalf("octets libérés = %d, attendu 1024", freed)
|
||||
}
|
||||
if _, err := os.Stat(path); !os.IsNotExist(err) {
|
||||
t.Fatal("le fichier est toujours là")
|
||||
}
|
||||
}
|
||||
|
||||
// Le modèle que le moteur a ouvert ne s'efface pas : la place ne serait même pas
|
||||
// rendue, et le prochain démarrage mourrait sur un .gguf absent.
|
||||
func TestSuppressionRefuseeSurLeModeleEnService(t *testing.T) {
|
||||
testHome(t)
|
||||
writeModel(t, modelsDir(), "servi.gguf", 8)
|
||||
if err := WriteConfig(map[string]string{"MODEL": "servi.gguf"}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, active := modelUsers("servi.gguf"); !active {
|
||||
t.Fatal("modèle de la configuration active non reconnu")
|
||||
}
|
||||
if _, err := deleteModelFile("servi.gguf"); err == nil {
|
||||
t.Fatal("le modèle en service a été supprimé")
|
||||
}
|
||||
if _, err := os.Stat(filepath.Join(modelsDir(), "servi.gguf")); err != nil {
|
||||
t.Fatal("fichier supprimé malgré le refus")
|
||||
}
|
||||
}
|
||||
|
||||
// Un modèle nommé par des presets n'est pas bloqué : il est SIGNALÉ, et
|
||||
// force:true tranche. Refuser tout court recréerait l'impasse qu'on corrige.
|
||||
func TestSuppressionSignaleLesPresetsPuisObeitAForce(t *testing.T) {
|
||||
testHome(t)
|
||||
p := writeModel(t, modelsDir(), "partage.gguf", 16)
|
||||
if _, err := SavePreset("", "Rapide", "MODEL=\"partage.gguf\"\nCTX=4096\n"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := SavePreset("", "Long", "MODEL=\"partage.gguf\"\nCTX=131072\n"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
users, active := modelUsers("partage.gguf")
|
||||
if active {
|
||||
t.Fatal("aucune configuration active ne devrait référencer ce modèle")
|
||||
}
|
||||
if len(users) != 2 {
|
||||
t.Fatalf("presets référents = %v, attendu les deux", users)
|
||||
}
|
||||
|
||||
post := func(body string) (int, map[string]any) {
|
||||
r := httptest.NewRequest("POST", "/api/models/delete", strings.NewReader(body))
|
||||
w := httptest.NewRecorder()
|
||||
handleModelDelete(w, r)
|
||||
var out map[string]any
|
||||
_ = json.Unmarshal(w.Body.Bytes(), &out)
|
||||
return w.Code, out
|
||||
}
|
||||
code, out := post(`{"name":"partage.gguf"}`)
|
||||
if code != 409 {
|
||||
t.Fatalf("code = %d, attendu 409 (presets référents)", code)
|
||||
}
|
||||
if len(out["used"].([]any)) != 2 {
|
||||
t.Fatalf("la réponse doit NOMMER les presets concernés : %v", out)
|
||||
}
|
||||
if _, err := os.Stat(p); err != nil {
|
||||
t.Fatal("fichier supprimé alors que la confirmation n'a pas eu lieu")
|
||||
}
|
||||
if code, out = post(`{"name":"partage.gguf","force":true}`); code != 200 {
|
||||
t.Fatalf("code = %d avec force:true — %v", code, out)
|
||||
}
|
||||
if _, err := os.Stat(p); !os.IsNotExist(err) {
|
||||
t.Fatal("force:true n'a pas supprimé le fichier")
|
||||
}
|
||||
}
|
||||
|
||||
// MMPROJ et un --mmproj resté dans EXTRA_ARGS comptent aussi : effacer le
|
||||
// projecteur du modèle en service casse la vision sans rien dire.
|
||||
func TestProjecteurCompteCommeReference(t *testing.T) {
|
||||
testHome(t)
|
||||
writeModel(t, modelsDir(), "mmproj-F16.gguf", 4)
|
||||
writeModel(t, modelsDir(), "vieux-mmproj.gguf", 4)
|
||||
if err := WriteConfig(map[string]string{"MODEL": "m.gguf", "MMPROJ": "mmproj-F16.gguf"}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, active := modelUsers("mmproj-F16.gguf"); !active {
|
||||
t.Fatal("MMPROJ= non compté comme référence")
|
||||
}
|
||||
if _, err := SavePreset("", "Vision", `MODEL="m.gguf"`+"\n"+`EXTRA_ARGS="--mmproj vieux-mmproj.gguf --flash-attn"`+"\n"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
users, _ := modelUsers("vieux-mmproj.gguf")
|
||||
if len(users) != 1 || users[0] != "Vision" {
|
||||
t.Fatalf("--mmproj d'EXTRA_ARGS non compté : %v", users)
|
||||
}
|
||||
// La tête MTP publiée à part aussi (MODEL_DRAFT).
|
||||
writeModel(t, modelsDir(), "mtp-Qwen3.6-27B.gguf", 4)
|
||||
if _, err := SavePreset("", "MTP", `MODEL="m.gguf"`+"\n"+`SPEC=auto`+"\n"+`MODEL_DRAFT="mtp-Qwen3.6-27B.gguf"`+"\n"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if users, _ := modelUsers("mtp-Qwen3.6-27B.gguf"); len(users) != 1 || users[0] != "MTP" {
|
||||
t.Fatalf("MODEL_DRAFT= non compté : %v", users)
|
||||
}
|
||||
}
|
||||
|
||||
// Une famille de tranches incomplète n'est pas « déjà là » : il reste à
|
||||
// télécharger, et prétendre le contraire donnerait un moteur qui meurt sur un
|
||||
// tenseur introuvable.
|
||||
func TestFamilleIncompleteNestPasPresente(t *testing.T) {
|
||||
testHome(t)
|
||||
writeModel(t, modelsDir(), "gros-00001-of-00002.gguf", 4)
|
||||
if _, _, ok := modelPresence("gros-00001-of-00002.gguf", ""); ok {
|
||||
t.Fatal("famille incomplète annoncée présente")
|
||||
}
|
||||
writeModel(t, modelsDir(), "gros-00002-of-00002.gguf", 4)
|
||||
if _, _, ok := modelPresence("gros-00001-of-00002.gguf", ""); !ok {
|
||||
t.Fatal("famille complète non détectée")
|
||||
}
|
||||
}
|
||||
|
||||
// Un modèle rangé dans un dossier déclaré ailleurs (disque externe) est trouvé
|
||||
// lui aussi, et sous son chemin complet — un simple nom ne le désignerait pas.
|
||||
func TestPresenceDansUnDossierDeclare(t *testing.T) {
|
||||
home := testHome(t)
|
||||
ext := filepath.Join(home, "externe")
|
||||
writeModel(t, ext, "ailleurs.gguf", 32)
|
||||
if _, _, ok := modelPresence("ailleurs.gguf", ""); ok {
|
||||
t.Fatal("dossier non déclaré : le modèle ne doit pas être visible")
|
||||
}
|
||||
if err := saveExtraModelDirs([]string{ext}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
value, _, ok := modelPresence("ailleurs.gguf", "")
|
||||
if !ok {
|
||||
t.Fatal("modèle d'un dossier déclaré non détecté")
|
||||
}
|
||||
if value != filepath.Join(ext, "ailleurs.gguf") {
|
||||
t.Fatalf("valeur MODEL= = %q, attendu le chemin complet", value)
|
||||
}
|
||||
}
|
||||
|
||||
// /api/models doit marquer « dossier loki » le dossier de téléchargement et en
|
||||
// donner le SIMPLE NOM : c'est ce que resolveModelPath retrouve, et c'est ce qui
|
||||
// permet au sélecteur de reconnaître un MODEL=modele.gguf écrit à la main.
|
||||
func TestListeModelesNommeLeDossierDeLoki(t *testing.T) {
|
||||
testHome(t)
|
||||
writeModel(t, modelsDir(), "local.gguf", 64)
|
||||
if err := WriteConfig(map[string]string{"MODEL": "local.gguf"}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
w := httptest.NewRecorder()
|
||||
handleModels(w, httptest.NewRequest("GET", "/api/models", nil))
|
||||
var out []map[string]any
|
||||
if err := json.Unmarshal(w.Body.Bytes(), &out); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(out) != 1 {
|
||||
t.Fatalf("liste = %v", out)
|
||||
}
|
||||
if out[0]["home"] != true {
|
||||
t.Fatal("le dossier de téléchargement n'est pas marqué « dossier loki »")
|
||||
}
|
||||
if out[0]["value"] != "local.gguf" {
|
||||
t.Fatalf("value = %v, attendu le simple nom de fichier", out[0]["value"])
|
||||
}
|
||||
if out[0]["active"] != true {
|
||||
t.Fatal("le modèle en service doit être signalé comme tel")
|
||||
}
|
||||
}
|
||||
|
||||
// Télécharger un modèle DÉJÀ sur le disque n'est pas une erreur : c'est le
|
||||
// résultat voulu. Les deux routes doivent répondre « ok, exists » avec la valeur
|
||||
// à écrire dans MODEL= — sinon l'interface s'arrête en rouge sur « le modèle
|
||||
// existe déjà » et il devient impossible de refaire un preset dessus.
|
||||
func TestTelechargerUnModelePresentLeSelectionne(t *testing.T) {
|
||||
testHome(t)
|
||||
writeModel(t, modelsDir(), "deja.gguf", 2048)
|
||||
const url = "https://huggingface.co/auteur/depot/resolve/main/deja.gguf"
|
||||
|
||||
call := func(h func(w http.ResponseWriter, r *http.Request), path string) (int, map[string]any) {
|
||||
body := `{"url":"` + url + `","dir":""}`
|
||||
w := httptest.NewRecorder()
|
||||
h(w, httptest.NewRequest("POST", path, strings.NewReader(body)))
|
||||
var out map[string]any
|
||||
_ = json.Unmarshal(w.Body.Bytes(), &out)
|
||||
return w.Code, out
|
||||
}
|
||||
|
||||
// La sonde ne doit même pas sortir sur le réseau : elle répond depuis le disque.
|
||||
code, out := call(handleModelDownloadProbe, "/api/models/download/probe")
|
||||
if code != 200 || out["ok"] != true || out["exists"] != true {
|
||||
t.Fatalf("sonde : code=%d out=%v", code, out)
|
||||
}
|
||||
if out["value"] != "deja.gguf" {
|
||||
t.Fatalf("sonde : value = %v, attendu le nom à mettre dans MODEL=", out["value"])
|
||||
}
|
||||
|
||||
code, out = call(handleModelDownload, "/api/models/download")
|
||||
if code != 200 || out["ok"] != true || out["exists"] != true {
|
||||
t.Fatalf("téléchargement : code=%d out=%v", code, out)
|
||||
}
|
||||
if out["value"] != "deja.gguf" {
|
||||
t.Fatalf("téléchargement : value = %v", out["value"])
|
||||
}
|
||||
}
|
||||
@@ -106,9 +106,13 @@ func TestDeleteModelFileEmporteLesTranches(t *testing.T) {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
if err := deleteModelFile("m-00001-of-00002.gguf"); err != nil {
|
||||
freed, err := deleteModelFile("m-00001-of-00002.gguf")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if freed != 2 {
|
||||
t.Fatalf("octets libérés = %d, attendu 2 (les deux tranches d'un octet)", freed)
|
||||
}
|
||||
for _, n := range names[:2] {
|
||||
if _, err := os.Stat(filepath.Join(dir, n)); !os.IsNotExist(err) {
|
||||
t.Fatalf("tranche %s non supprimée", n)
|
||||
|
||||
@@ -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
|
||||
@@ -189,7 +251,13 @@ func safePresetPath(name string) (string, error) {
|
||||
// version effaçait la clé, et le moteur repartait sur le défaut « toutes les
|
||||
// interfaces » : une machine volontairement fermée se rouvrait toute seule au
|
||||
// premier changement de preset.
|
||||
var preservedKeys = []string{"MEM_MODE", "CRAWL4AI_URL", "WEB_ENGINE", "CUDA_VISIBLE_DEVICES", "HOST"}
|
||||
// CUDA_LAUNCH_QUEUES (files de lancement CUDA, voir launchQueuesEnv) suit la
|
||||
// même logique : un « off » posé parce que CETTE machine bloque ne doit pas
|
||||
// disparaître à la bascule suivante.
|
||||
// SLOT_PERSIST (état du slot gardé à la bascule, backend_serve_persist.go) est
|
||||
// par nature un réglage de bascule : posé sur A seulement, il disparaîtrait en
|
||||
// passant sur B, dont le lancement effacerait l'état gardé de A.
|
||||
var preservedKeys = []string{"MEM_MODE", "CRAWL4AI_URL", "WEB_ENGINE", "CUDA_VISIBLE_DEVICES", "HOST", "CUDA_LAUNCH_QUEUES", "SLOT_PERSIST"}
|
||||
|
||||
// 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
|
||||
@@ -199,7 +267,8 @@ var preservedKeys = []string{"MEM_MODE", "CRAWL4AI_URL", "WEB_ENGINE", "CUDA_VIS
|
||||
// inconditionnellement écrasait cette valeur par l'ancienne (mono-GPU) : le
|
||||
// modèle se retrouvait entièrement sur une seule carte et mourait sur
|
||||
// « cudaMalloc failed: out of memory ». Le preset explicite gagne.
|
||||
var softPreservedKeys = map[string]bool{"CUDA_VISIBLE_DEVICES": true, "HOST": true}
|
||||
// CUDA_LAUNCH_QUEUES aussi : un preset peut l'imposer pour son modèle.
|
||||
var softPreservedKeys = map[string]bool{"CUDA_VISIBLE_DEVICES": true, "HOST": true, "CUDA_LAUNCH_QUEUES": true, "SLOT_PERSIST": true}
|
||||
|
||||
// applyPresetFile installe le preset comme configuration active, en réinjectant
|
||||
// les réglages « appareil » par-dessus. Séparé de SwitchToPreset pour être
|
||||
@@ -225,17 +294,66 @@ func applyPresetFile(target string) error {
|
||||
}
|
||||
next[k] = v
|
||||
}
|
||||
// Le moteur suit la machine, pas le preset (voir presetImposesEngine).
|
||||
if cur["BIN"] != "" && !presetImposesEngine(next["BIN"]) {
|
||||
next["BIN"] = cur["BIN"]
|
||||
}
|
||||
return WriteConfig(next)
|
||||
}
|
||||
|
||||
// presetImposesEngine : le BIN d'un preset a-t-il son mot à dire ?
|
||||
//
|
||||
// Le moteur est un réglage de MACHINE. Un preset créé avant une mise à jour du
|
||||
// moteur (Réglages → Moteur, qui installe sous $LOKI_HOME/engine/) embarque
|
||||
// encore le chemin de l'image, /app/llama-server ; l'appliquer — changer de
|
||||
// modèle, une tâche planifiée — ramenait silencieusement ce vieux moteur, et
|
||||
// un modèle qui chargeait la veille mourait sur « unknown model architecture »
|
||||
// sans qu'aucun réglage visible ait bougé. Le moteur de l'image, un moteur
|
||||
// téléchargé par Loki ou un précompilé ne sont donc jamais imposés par un
|
||||
// preset : le courant reste.
|
||||
//
|
||||
// Un backend PERSONNALISÉ (compilé pour un modèle précis, backend_llamacpp.go :
|
||||
// « un backend custom se choisit par modèle »), lui, est un vrai choix par
|
||||
// modèle : le preset gagne.
|
||||
func presetImposesEngine(p string) bool {
|
||||
p = strings.TrimSpace(p)
|
||||
if p == "" || engineOwns(p) || prebuiltOwns(p) {
|
||||
return false
|
||||
}
|
||||
if img := strings.TrimSpace(os.Getenv("LOKI_ENGINE_BIN")); img != "" && samePath(p, img) {
|
||||
return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
// 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
|
||||
}
|
||||
fmt.Printf("%s configuration <- %s\n", green("[ok]"), filepath.Base(target))
|
||||
// Preset externe (backend_external.go) : rien à redémarrer, il n'a pas de
|
||||
// MODEL et llama-server partirait en boucle de crash. On arrête au contraire
|
||||
// le moteur encore en vie — le chat part vers l'API distante, garder le
|
||||
// modèle en VRAM ne sert plus à rien.
|
||||
if isExternalConfig(ReadConfig()) {
|
||||
fmt.Println(dim("[info] preset externe — arrêt du moteur local"))
|
||||
if !serviceIsActive() {
|
||||
return nil
|
||||
}
|
||||
persist.save()
|
||||
return serviceAction("stop")
|
||||
}
|
||||
fmt.Println(dim("[info] redémarrage du service..."))
|
||||
persist.save()
|
||||
return serviceAction("restart")
|
||||
}
|
||||
|
||||
@@ -287,6 +405,10 @@ func cmdSwitch(args []string) error {
|
||||
// mélangeaient donc aux options cochées pour le nouveau, et il fallait penser à
|
||||
// tout nettoyer à la main. On repart d'une base vide : les valeurs non
|
||||
// renseignées sont les défauts documentés (CTX 32768, NGL 999, BATCH 2048…).
|
||||
//
|
||||
// BIN n'est repris que s'il désigne un backend personnalisé (presetImposesEngine) :
|
||||
// figer /app/llama-server dans chaque preset, c'est ce qui faisait perdre le
|
||||
// moteur mis à jour à la première bascule.
|
||||
var newPresetSeedKeys = []string{"BIN", "HOST", "PORT"}
|
||||
|
||||
// newPresetSeed renvoie la configuration de départ d'un preset créé depuis l'UI.
|
||||
@@ -294,9 +416,11 @@ func newPresetSeed() map[string]string {
|
||||
cur := ReadConfig()
|
||||
seed := map[string]string{}
|
||||
for _, k := range newPresetSeedKeys {
|
||||
if v := cur[k]; v != "" {
|
||||
seed[k] = v
|
||||
v := cur[k]
|
||||
if v == "" || (k == "BIN" && !presetImposesEngine(v)) {
|
||||
continue
|
||||
}
|
||||
seed[k] = v
|
||||
}
|
||||
return seed
|
||||
}
|
||||
@@ -331,6 +455,41 @@ func SavePreset(id, name, content string) (string, error) {
|
||||
return id, os.WriteFile(p, []byte(content), 0o644)
|
||||
}
|
||||
|
||||
// SavePresetApplying enregistre un preset et, si c'était celui EN SERVICE,
|
||||
// installe aussitôt la nouvelle version comme configuration active (mêmes
|
||||
// règles qu'une bascule : applyPresetFile). Renvoie applied=true dans ce cas —
|
||||
// l'appelant redémarre alors le moteur.
|
||||
//
|
||||
// Vécu : CTX passé à 100000 dans l'éditeur, « enregistré »… et la carte du
|
||||
// chat qui affiche toujours 32768. Seul le fichier du preset avait changé ;
|
||||
// config.env gardait l'ancienne valeur, le moteur tournait avec, et plus aucun
|
||||
// preset n'était détecté actif (empreinte différente). Éditer le preset en
|
||||
// service, c'est vouloir ces réglages-là tout de suite. Un preset inactif ou
|
||||
// nouveau, lui, n'est qu'un fichier : rien ne bouge.
|
||||
//
|
||||
// « Actif » se décide AVANT l'écriture : c'est l'ancienne version du fichier qui
|
||||
// porte l'empreinte de la configuration courante.
|
||||
func SavePresetApplying(id, name, content string) (newID string, applied bool, err error) {
|
||||
wasActive := false
|
||||
if id != "" {
|
||||
if old, rerr := ReadPreset(id); rerr == nil {
|
||||
wasActive = presetFingerprint([]byte(old)) == configFingerprint(ReadConfig())
|
||||
}
|
||||
}
|
||||
newID, err = SavePreset(id, name, content)
|
||||
if err != nil || !wasActive {
|
||||
return newID, false, err
|
||||
}
|
||||
p, err := safePresetPath(newID)
|
||||
if err != nil {
|
||||
return newID, false, err
|
||||
}
|
||||
if err := applyPresetFile(p); err != nil {
|
||||
return newID, false, err
|
||||
}
|
||||
return newID, true, nil
|
||||
}
|
||||
|
||||
// DeletePreset removes a preset by id; refuses if it is the active config.
|
||||
func DeletePreset(id string) error {
|
||||
p, err := safePresetPath(id)
|
||||
@@ -344,7 +503,17 @@ func DeletePreset(id string) error {
|
||||
if presetFingerprint(target) == configFingerprint(ReadConfig()) {
|
||||
return fmt.Errorf("preset actif, switche d'abord")
|
||||
}
|
||||
return os.Remove(p)
|
||||
if err := os.Remove(p); err != nil {
|
||||
return err
|
||||
}
|
||||
// Le prompt système du preset vit en base, pas dans son .env (web_sysprompt.go) :
|
||||
// sans ce ménage, il resterait orphelin et ressusciterait sur un preset recréé
|
||||
// sous le même id.
|
||||
_ = putStr(bkState, sysPromptKeyPrefix+id, "")
|
||||
// Même ménage pour son benchmark : un preset recréé sous cet id en
|
||||
// hériterait, alors qu'il a été mesuré sur un autre modèle.
|
||||
deletePresetBench(id)
|
||||
return nil
|
||||
}
|
||||
|
||||
// ReadPreset returns the contents of a preset by id (filename).
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -47,7 +47,7 @@ func TestSwitchToPresetGardeLesReglagesMachine(t *testing.T) {
|
||||
}
|
||||
}
|
||||
// Configuration courante : un preset + les réglages machine.
|
||||
setConfig(t, "MODEL=ancien.gguf\nCTX=4096\nCUDA_VISIBLE_DEVICES=1\nWEB_ENGINE=go\nMEM_MODE=always\n")
|
||||
setConfig(t, "MODEL=ancien.gguf\nCTX=4096\nCUDA_VISIBLE_DEVICES=1\nWEB_ENGINE=go\nMEM_MODE=always\nCUDA_LAUNCH_QUEUES=off\n")
|
||||
target := filepath.Join(home, "cible.env")
|
||||
write(target, "MODEL=nouveau.gguf\nCTX=8192\n")
|
||||
|
||||
@@ -60,7 +60,7 @@ func TestSwitchToPresetGardeLesReglagesMachine(t *testing.T) {
|
||||
t.Fatalf("le preset n'a pas été appliqué : %v", cfg)
|
||||
}
|
||||
for k, want := range map[string]string{
|
||||
"CUDA_VISIBLE_DEVICES": "1", "WEB_ENGINE": "go", "MEM_MODE": "always",
|
||||
"CUDA_VISIBLE_DEVICES": "1", "WEB_ENGINE": "go", "MEM_MODE": "always", "CUDA_LAUNCH_QUEUES": "off",
|
||||
} {
|
||||
if cfg[k] != want {
|
||||
t.Errorf("%s = %q après bascule, attendu %q (réglage machine effacé)", k, cfg[k], want)
|
||||
@@ -68,6 +68,64 @@ func TestSwitchToPresetGardeLesReglagesMachine(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// Le moteur suit la machine. Vécu : moteur mis à jour depuis l'interface
|
||||
// (BIN → /data/engine/server-cuda-b10680), Qwen3.8-Flash-Next charge ; on
|
||||
// change de modèle — le preset, créé avant, porte BIN=/app/llama-server — et
|
||||
// le vieux moteur revient : « unknown model architecture: 'qwen4exp' ». Seul
|
||||
// un backend personnalisé, choix par modèle, a le droit d'imposer son BIN.
|
||||
func TestSwitchToPresetGardeLeMoteur(t *testing.T) {
|
||||
home := testHome(t)
|
||||
t.Setenv("LOKI_ENGINE_BIN", "/app/llama-server")
|
||||
write := func(p, body string) {
|
||||
if err := os.WriteFile(p, []byte(body), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
engine := filepath.Join(home, "engine", "server-cuda-b10680", "llama-server")
|
||||
setConfig(t, "MODEL=ancien.gguf\nBIN="+engine+"\n")
|
||||
|
||||
// Preset d'avant la mise à jour : il porte le moteur de l'image.
|
||||
image := filepath.Join(home, "image.env")
|
||||
write(image, "MODEL=nouveau.gguf\nBIN=/app/llama-server\n")
|
||||
if err := applyPresetFile(image); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if got := ReadConfig()["BIN"]; got != engine {
|
||||
t.Errorf("BIN = %q après bascule, attendu le moteur mis à jour %q", got, engine)
|
||||
}
|
||||
// Preset muet sur le moteur : idem.
|
||||
muet := filepath.Join(home, "muet.env")
|
||||
write(muet, "MODEL=autre.gguf\n")
|
||||
if err := applyPresetFile(muet); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if got := ReadConfig()["BIN"]; got != engine {
|
||||
t.Errorf("BIN = %q après preset muet, attendu %q conservé", got, engine)
|
||||
}
|
||||
// Backend personnalisé : le preset gagne.
|
||||
custom := filepath.Join(home, "custom.env")
|
||||
write(custom, "MODEL=exotique.gguf\nBIN=/opt/mon-fork/llama-server\n")
|
||||
if err := applyPresetFile(custom); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if got := ReadConfig()["BIN"]; got != "/opt/mon-fork/llama-server" {
|
||||
t.Errorf("BIN = %q, attendu le backend personnalisé du preset", got)
|
||||
}
|
||||
// Un nouveau preset ne fige pas ce backend-là… si, justement : c'est un
|
||||
// choix par modèle. Mais il ne fige ni l'image ni un moteur téléchargé.
|
||||
if seed := newPresetSeed(); seed["BIN"] != "/opt/mon-fork/llama-server" {
|
||||
t.Errorf("seed BIN = %q, attendu le backend personnalisé", seed["BIN"])
|
||||
}
|
||||
setConfig(t, "MODEL=ancien.gguf\nBIN="+engine+"\n")
|
||||
if seed := newPresetSeed(); seed["BIN"] != "" {
|
||||
t.Errorf("seed BIN = %q, un moteur téléchargé ne doit pas être figé dans un preset", seed["BIN"])
|
||||
}
|
||||
setConfig(t, "MODEL=ancien.gguf\nBIN=/app/llama-server\n")
|
||||
if seed := newPresetSeed(); seed["BIN"] != "" {
|
||||
t.Errorf("seed BIN = %q, le moteur de l'image ne doit pas être figé dans un preset", seed["BIN"])
|
||||
}
|
||||
}
|
||||
|
||||
// Un preset qui définit LUI-MÊME la sélection de cartes doit gagner sur celle
|
||||
// de la machine : « FABLE 2 GPU » impose CUDA_VISIBLE_DEVICES=1,0 pour que son
|
||||
// --tensor-split ait deux cartes. Écraser ça par une sélection mono-GPU faisait
|
||||
@@ -102,3 +160,54 @@ func TestSwitchToPresetLaisseLePresetImposerSesGPU(t *testing.T) {
|
||||
t.Errorf("WEB_ENGINE = %q, attendu go (réglage machine, jamais dicté par un preset)", cfg["WEB_ENGINE"])
|
||||
}
|
||||
}
|
||||
|
||||
// Modifier le preset ACTIF doit changer la configuration courante. Vécu : CTX
|
||||
// passé à 100000 dans l'éditeur, « enregistré » — et la carte du chat qui
|
||||
// affiche toujours 32768, parce que seul le fichier du preset avait bougé ;
|
||||
// config.env, lui, gardait l'ancienne valeur et plus aucun preset n'était
|
||||
// détecté actif. Un preset qui n'est pas celui en service, ou un preset
|
||||
// nouveau, ne touche pas à la configuration.
|
||||
func TestModifierLePresetActifLApplique(t *testing.T) {
|
||||
testHome(t)
|
||||
dir := presetsDir()
|
||||
if err := os.MkdirAll(dir, 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
write := func(name, body string) {
|
||||
if err := os.WriteFile(filepath.Join(dir, name+".env"), []byte(body), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
write("actif", "# NAME=Actif\nMODEL=foo.gguf\nCTX=32768\n")
|
||||
write("autre", "# NAME=Autre\nMODEL=bar.gguf\nCTX=4096\n")
|
||||
setConfig(t, "MODEL=foo.gguf\nCTX=32768\nMEM_MODE=always\n")
|
||||
|
||||
// Le preset en service : la config suit, les réglages machine restent.
|
||||
id, applied, err := SavePresetApplying("actif", "Actif", "# NAME=Actif\nMODEL=foo.gguf\nCTX=100000\n")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if id != "actif" || !applied {
|
||||
t.Fatalf("id=%q applied=%v, attendu actif/true", id, applied)
|
||||
}
|
||||
cfg := ReadConfig()
|
||||
if cfg["CTX"] != "100000" || cfg["MEM_MODE"] != "always" {
|
||||
t.Fatalf("configuration non suivie : %v", cfg)
|
||||
}
|
||||
|
||||
// Un autre preset : le fichier change, la config non.
|
||||
if _, applied, err = SavePresetApplying("autre", "Autre", "# NAME=Autre\nMODEL=bar.gguf\nCTX=8192\n"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if applied || ReadConfig()["CTX"] != "100000" {
|
||||
t.Fatalf("un preset inactif a touché la configuration (applied=%v, CTX=%q)", applied, ReadConfig()["CTX"])
|
||||
}
|
||||
|
||||
// Un preset nouveau : jamais appliqué à la volée.
|
||||
if _, applied, err = SavePresetApplying("", "Neuf", "MODEL=baz.gguf\n"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if applied || ReadConfig()["MODEL"] != "foo.gguf" {
|
||||
t.Fatalf("un preset nouveau a touché la configuration (applied=%v)", applied)
|
||||
}
|
||||
}
|
||||
+730
-82
@@ -4,14 +4,18 @@ import (
|
||||
"bytes"
|
||||
"context"
|
||||
"fmt"
|
||||
"net"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"runtime"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
)
|
||||
|
||||
// binSupportsReasoningFlag dit si ce llama-server accepte « --reasoning ».
|
||||
// helpSupportsReasoningFlag dit si ce llama-server accepte « --reasoning ».
|
||||
//
|
||||
// Le drapeau est récent : les moteurs plus anciens, et certains forks, ne le
|
||||
// connaissent pas et REFUSENT de démarrer sur un argument inconnu. Comme on ne
|
||||
@@ -21,24 +25,267 @@ import (
|
||||
// L'aide se lit avec le même chemin de bibliothèques que le vrai lancement
|
||||
// (setLibraryPath a déjà été appelé) : sans ça un moteur parfaitement valide
|
||||
// échoue à s'exécuter (« libllama-common.so introuvable ») et on conclurait à
|
||||
// tort qu'il ne gère pas le drapeau.
|
||||
func binSupportsReasoningFlag(bin string) bool {
|
||||
// tort qu'il ne gère pas le drapeau. Les tests de capacité prennent le TEXTE de
|
||||
// l'aide, pas le binaire : buildServeArgs reste ainsi une fonction pure,
|
||||
// testable avec une aide fabriquée.
|
||||
func helpSupportsReasoningFlag(help string) bool {
|
||||
return strings.Contains(help, "--reasoning ")
|
||||
}
|
||||
|
||||
// binHelp lit « <bin> --help » UNE fois par binaire et garde le texte. Plusieurs
|
||||
// capacités se déduisent de cette aide (--reasoning, --load-mode, « -ngl auto »)
|
||||
// et relancer le moteur à chaque question coûterait quelques secondes de plus au
|
||||
// démarrage. Aide illisible = chaîne vide : tous les tests de capacité répondent
|
||||
// « non », c'est-à-dire « ne prends aucun risque, garde la forme historique ».
|
||||
var (
|
||||
helpMu sync.Mutex
|
||||
helpCache = map[string]string{}
|
||||
)
|
||||
|
||||
func binHelp(bin string) string {
|
||||
helpMu.Lock()
|
||||
defer helpMu.Unlock()
|
||||
if h, ok := helpCache[bin]; ok {
|
||||
return h
|
||||
}
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
||||
defer cancel()
|
||||
cmd := exec.CommandContext(ctx, bin, "--help")
|
||||
var out bytes.Buffer
|
||||
cmd.Stdout, cmd.Stderr = &out, &out
|
||||
if err := cmd.Run(); err != nil && out.Len() == 0 {
|
||||
return false // aide illisible : on ne prend pas le risque
|
||||
h := ""
|
||||
if err := cmd.Run(); err == nil || out.Len() > 0 {
|
||||
h = out.String()
|
||||
}
|
||||
return strings.Contains(out.String(), "--reasoning ")
|
||||
helpCache[bin] = h
|
||||
return h
|
||||
}
|
||||
|
||||
// helpSupportsLoadMode : les moteurs récents ont fusionné --mlock, --mmap et
|
||||
// --no-mmap dans un seul --load-mode ; les anciens ne connaissent que les trois
|
||||
// drapeaux d'origine. On traduit donc à l'exécution, sans réécrire le preset :
|
||||
// le même EXTRA_ARGS reste lançable sur les deux générations de moteur.
|
||||
func helpSupportsLoadMode(help string) bool {
|
||||
return strings.Contains(help, "--load-mode")
|
||||
}
|
||||
|
||||
// helpSupportsNGLAuto : « -ngl auto » laisse llama.cpp mesurer la VRAM libre et
|
||||
// choisir le nombre de couches. Imposer un nombre (999 compris) désarme ce
|
||||
// calcul — « n_gpu_layers already set by user to 999, abort » — et le moteur
|
||||
// tente alors de tout mettre sur le GPU, quitte à échouer en cudaMalloc.
|
||||
func helpSupportsNGLAuto(h string) bool {
|
||||
i := strings.Index(h, "--n-gpu-layers")
|
||||
if i < 0 {
|
||||
i = strings.Index(h, "-ngl")
|
||||
}
|
||||
if i < 0 {
|
||||
return false
|
||||
}
|
||||
end := i + 400
|
||||
if end > len(h) {
|
||||
end = len(h)
|
||||
}
|
||||
return strings.Contains(h[i:end], "'auto'")
|
||||
}
|
||||
|
||||
// helpFitsLayersItself : ce moteur sait-il répartir les couches tout seul ?
|
||||
//
|
||||
// La lecture fine de l'aide (helpSupportsNGLAuto) reste la source de vérité, mais
|
||||
// elle dépend de la mise en page d'un texte d'aide — une description reformulée
|
||||
// ou une colonne plus large, et on conclut « non » sur un moteur parfaitement
|
||||
// capable, donc on lui réimpose 999 et l'abandon revient. --load-mode est arrivé
|
||||
// dans la même vague que « -ngl auto » : sa présence sert de second témoin, plus
|
||||
// grossier mais insensible à la mise en page.
|
||||
func helpFitsLayersItself(help string) bool {
|
||||
return helpSupportsNGLAuto(help) || helpSupportsLoadMode(help)
|
||||
}
|
||||
|
||||
// nglArgs traduit la valeur NGL du preset en arguments, et renvoie au passage la
|
||||
// note à afficher quand Loki a substitué quelque chose. Fonction pure : la seule
|
||||
// question qui demande le moteur (« sait-il répartir les couches tout seul ? »)
|
||||
// arrive déjà tranchée, ce qui la rend testable sans lancer llama-server.
|
||||
func nglArgs(ngl string, fitsItself bool) ([]string, string) {
|
||||
ngl = strings.TrimSpace(ngl)
|
||||
switch {
|
||||
case strings.EqualFold(ngl, "auto"):
|
||||
return nil, "" // rien du tout : le moteur décide seul
|
||||
case ngl == "999" && fitsItself:
|
||||
return []string{"-ngl", "auto"},
|
||||
"NGL=999 (sentinelle « toutes les couches ») → -ngl auto : ce moteur mesure lui-même la " +
|
||||
"VRAM libre. NGL=all pour forcer l'ancien comportement."
|
||||
case ngl != "":
|
||||
return []string{"-ngl", ngl}, ""
|
||||
case fitsItself:
|
||||
return []string{"-ngl", "auto"}, ""
|
||||
default:
|
||||
// Moteur ancien : omettre -ngl le ferait tourner 100 % CPU.
|
||||
return []string{"-ngl", "999"}, ""
|
||||
}
|
||||
}
|
||||
|
||||
// hasAnyFlag dit si l'utilisateur a déjà posé l'un de ces drapeaux dans
|
||||
// EXTRA_ARGS. Loki ajoute plusieurs valeurs par défaut (--parallel, -ngl) : les
|
||||
// ajouter EN PLUS de celles de l'utilisateur fait râler le moteur (« argument
|
||||
// specified multiple times ») et, pire, c'est la DERNIÈRE occurrence qui gagne
|
||||
// — le réglage explicite du preset serait donc silencieusement écrasé par le
|
||||
// défaut de Loki. Quand l'utilisateur a tranché, on se tait.
|
||||
func hasAnyFlag(args []string, flags ...string) bool {
|
||||
for _, a := range args {
|
||||
name := a
|
||||
if i := strings.IndexByte(name, '='); i > 0 {
|
||||
name = name[:i]
|
||||
}
|
||||
for _, f := range flags {
|
||||
if name == f {
|
||||
return true
|
||||
}
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// normalizeLoadFlags choisit, au lancement, la forme des drapeaux de chargement
|
||||
// que CE binaire comprend. llama.cpp récent a remplacé --mlock / --no-mmap par
|
||||
// --load-mode (et refuse les anciens) ; un moteur ancien ou un fork ne connaît
|
||||
// que les anciens. L'éditeur écrit --load-mode, un preset d'avant peut porter
|
||||
// les anciens : on traduit dans le sens qu'il faut (repris d'AJEAN 0.16.0).
|
||||
//
|
||||
// Vers --load-mode (moteur récent). Deux interrupteurs → une seule valeur, au
|
||||
// sens où llama.cpp l'entend (« mlock » = PAS de mmap + résident) :
|
||||
//
|
||||
// --mlock seul → mmap+mlock (l'ancien sens de --mlock : mmap conservé)
|
||||
// --no-mmap seul → none
|
||||
// --mlock + --no-mmap → mlock
|
||||
// --mmap → mmap
|
||||
//
|
||||
// ⚠️ Jusqu'ici --mlock seul devenait « mlock », qui COUPE le mmap : le modèle
|
||||
// entier montait en RAM au lieu d'être mappé — un OOM assuré sur un modèle plus
|
||||
// gros que la mémoire. Un --load-mode déjà écrit gagne sur tout : on retire
|
||||
// alors simplement les vieux drapeaux.
|
||||
//
|
||||
// Vers les anciens drapeaux (moteur qui ne connaît pas --load-mode) :
|
||||
//
|
||||
// none → --no-mmap · mlock → --mlock --no-mmap · mmap+mlock → --mlock
|
||||
// auto, mmap, dio → rien (défaut de l'ancien moteur)
|
||||
//
|
||||
// La note éventuelle (dio abandonné) est RENDUE, pas affichée : buildServeArgs
|
||||
// reste pure et c'est cmdServe qui l'écrit, à sa place parmi les autres notes.
|
||||
func normalizeLoadFlags(args []string, supportsLoadMode bool) ([]string, string) {
|
||||
if !supportsLoadMode {
|
||||
return downgradeLoadMode(args)
|
||||
}
|
||||
explicit := hasAnyFlag(args, "--load-mode", "-lm")
|
||||
mlock, nommap, mmap := false, false, false
|
||||
out := make([]string, 0, len(args)+2)
|
||||
for _, a := range args {
|
||||
switch a {
|
||||
case "--mlock":
|
||||
mlock = true
|
||||
case "--no-mmap":
|
||||
nommap = true
|
||||
case "--mmap":
|
||||
mmap = true
|
||||
default:
|
||||
out = append(out, a)
|
||||
}
|
||||
}
|
||||
if explicit || !(mlock || nommap || mmap) {
|
||||
return out, ""
|
||||
}
|
||||
mode := "mmap"
|
||||
switch {
|
||||
case mlock && nommap:
|
||||
mode = "mlock"
|
||||
case mlock:
|
||||
mode = "mmap+mlock"
|
||||
case nommap:
|
||||
mode = "none"
|
||||
}
|
||||
return append(out, "--load-mode", mode), ""
|
||||
}
|
||||
|
||||
// downgradeLoadMode retraduit un --load-mode pour un moteur qui ne le connaît
|
||||
// pas : le lui passer tel quel le ferait sortir en erreur au démarrage.
|
||||
func downgradeLoadMode(args []string) ([]string, string) {
|
||||
mode, found := "", false
|
||||
kept := make([]string, 0, len(args))
|
||||
for i := 0; i < len(args); i++ {
|
||||
a := args[i]
|
||||
if a == "--load-mode" || a == "-lm" {
|
||||
found = true
|
||||
if i+1 < len(args) {
|
||||
mode = strings.ToLower(args[i+1])
|
||||
i++
|
||||
}
|
||||
continue
|
||||
}
|
||||
if v, ok := strings.CutPrefix(a, "--load-mode="); ok {
|
||||
found, mode = true, strings.ToLower(v)
|
||||
continue
|
||||
}
|
||||
kept = append(kept, a)
|
||||
}
|
||||
if !found {
|
||||
return args, ""
|
||||
}
|
||||
add := func(flag string) {
|
||||
for _, a := range kept {
|
||||
if a == flag {
|
||||
return
|
||||
}
|
||||
}
|
||||
kept = append(kept, flag)
|
||||
}
|
||||
switch mode {
|
||||
case "none":
|
||||
add("--no-mmap")
|
||||
case "mlock":
|
||||
add("--mlock")
|
||||
add("--no-mmap")
|
||||
case "mmap+mlock":
|
||||
add("--mlock")
|
||||
case "dio":
|
||||
return kept, "ce moteur ne connaît pas --load-mode dio (DirectIO) : chargement par défaut"
|
||||
}
|
||||
return kept, ""
|
||||
}
|
||||
|
||||
// cmdServe replaces the historic start.sh: read config.env, build the
|
||||
// llama-server invocation, and exec it (replacing this process so systemd
|
||||
// supervises llama-server directly).
|
||||
//
|
||||
// Tout ce qui touche au monde (fichiers, aide du moteur, variables
|
||||
// d'environnement, port, exec) reste ICI ; la composition de la ligne de
|
||||
// commande vit dans buildServeArgs, pure, pour qu'un changement de drapeau se
|
||||
// vérifie en table de tests plutôt qu'en relançant un moteur.
|
||||
func cmdServe(args []string) error {
|
||||
cfg := ReadConfig()
|
||||
// Moteur d'ESSAI de l'optimiseur (backend_tune_run.go) : sa configuration
|
||||
// est une copie posée dans le dossier de l'essai, jamais config.env, et rien
|
||||
// de ce que le vrai moteur laisse derrière lui (état des slots, jeton de
|
||||
// SPEC=auto) n'est touché. Sans la variable, rien ne change.
|
||||
trialDir := os.Getenv(tuneTrialEnv)
|
||||
if trialDir != "" {
|
||||
c, err := readTuneTrialConfig(trialDir)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
cfg = c
|
||||
}
|
||||
// API externe : aucun moteur local. On sort SANS erreur, sinon systemd
|
||||
// (Restart=on-failure) relance en boucle « MODEL non défini » (amont #95).
|
||||
if isExternalConfig(cfg) {
|
||||
fmt.Println("[info] preset externe actif : pas de moteur local à lancer")
|
||||
return nil
|
||||
}
|
||||
// Preset ENGINE=strata (backend_strata.go) : un autre moteur, lancé par son
|
||||
// propre serveur. L'optimiseur ne règle que llama-server.
|
||||
if isStrataConfig(cfg) {
|
||||
if trialDir != "" {
|
||||
return fmt.Errorf("l'optimiseur ne s'applique pas à un preset Strata")
|
||||
}
|
||||
return serveStrata(cfg)
|
||||
}
|
||||
bin := cfg["BIN"]
|
||||
if bin == "" {
|
||||
return fmt.Errorf("BIN non défini — lance « loki edit »")
|
||||
@@ -81,58 +328,12 @@ func cmdServe(args []string) error {
|
||||
// Linux, PATH on Windows — handled inside execServer.
|
||||
setLibraryPath(filepath.Dir(bin))
|
||||
|
||||
// Sélection GPU (loki gpu) : on filtre les devices visibles par llama-server.
|
||||
// CUDA_DEVICE_ORDER=PCI_BUS_ID garantit que les index correspondent à ceux
|
||||
// affichés par nvidia-smi (sinon CUDA réordonne par "device le plus rapide").
|
||||
if v := cfg["CUDA_VISIBLE_DEVICES"]; v != "" {
|
||||
_ = os.Setenv("CUDA_VISIBLE_DEVICES", v)
|
||||
_ = os.Setenv("CUDA_DEVICE_ORDER", "PCI_BUS_ID")
|
||||
}
|
||||
// La sélection GPU est posée AVANT de lire l'aide du moteur, comme elle l'a
|
||||
// toujours été : le moteur interrogé voit déjà les mêmes devices que celui
|
||||
// qu'on lancera. buildServeArgs la renvoie aussi dans env (même valeur).
|
||||
applyServeEnv(cudaDeviceEnv(cfg))
|
||||
|
||||
get := func(key, fallback string) string {
|
||||
if v, ok := cfg[key]; ok && v != "" {
|
||||
return v
|
||||
}
|
||||
return fallback
|
||||
}
|
||||
kv := get("KV_TYPE", "")
|
||||
ktv := get("KV_TYPE_K", kv)
|
||||
vtv := get("KV_TYPE_V", kv)
|
||||
|
||||
llmArgs := []string{bin,
|
||||
"-m", model,
|
||||
"-c", get("CTX", "32768"),
|
||||
"-t", get("THREADS", "0"),
|
||||
"-tb", get("THREADS_BATCH", "0"),
|
||||
"-b", get("BATCH", "2048"),
|
||||
"-ub", get("UBATCH", "512"),
|
||||
"--host", get("HOST", "0.0.0.0"),
|
||||
"--port", get("PORT", "8080"),
|
||||
// --parallel 1 EXPLICITE : les llama-server récents ouvrent 4 slots par
|
||||
// défaut, soit des tampons de calcul GPU ×4 — pour un serveur mono-
|
||||
// utilisateur comme Loki, c'est de la VRAM brûlée pour rien. Vécu : une
|
||||
// config 27B/60k ctx qui tournait très bien est morte en « cudaMalloc
|
||||
// failed: out of memory » après une mise à jour du moteur, uniquement à
|
||||
// cause de ce nouveau défaut. PARALLEL=n dans le preset pour qui veut
|
||||
// vraiment servir plusieurs requêtes à la fois.
|
||||
"--parallel", get("PARALLEL", "1"),
|
||||
}
|
||||
// NGL=auto : on n'envoie PAS -ngl, et llama.cpp choisit lui-même combien de
|
||||
// couches tiennent dans la VRAM libre (common_fit_params). Il s'en abstient
|
||||
// dès que la valeur est imposée — « n_gpu_layers already set by user to 999,
|
||||
// abort » — d'où des cudaMalloc en échec, un repli partiel sur le CPU et un
|
||||
// débit effondré alors que le GPU tourne à 100 %.
|
||||
// Le défaut reste 999 (tout sur le GPU) : omettre -ngl sur un llama.cpp
|
||||
// antérieur à cet ajustement automatique le ferait tourner 100 % CPU.
|
||||
if ngl := get("NGL", "999"); !strings.EqualFold(ngl, "auto") {
|
||||
llmArgs = append(llmArgs, "-ngl", ngl)
|
||||
}
|
||||
if ktv != "" {
|
||||
llmArgs = append(llmArgs, "-ctk", ktv)
|
||||
}
|
||||
if vtv != "" {
|
||||
llmArgs = append(llmArgs, "-ctv", vtv)
|
||||
}
|
||||
si := serveSysInfo{Help: binHelp(bin), Model: model}
|
||||
// Vision : le projecteur multimodal (mmproj-*.gguf) donne des yeux au modèle.
|
||||
// C'est un fichier .gguf À PART du modèle, chargé via --mmproj. On le résout
|
||||
// comme le modèle (nom simple cherché dans les dossiers déclarés, ou chemin
|
||||
@@ -148,7 +349,367 @@ func cmdServe(args []string) error {
|
||||
if _, err := os.Stat(mmPath); err != nil {
|
||||
return fmt.Errorf("projecteur vision introuvable : %s", mmPath)
|
||||
}
|
||||
llmArgs = append(llmArgs, "--mmproj", mmPath)
|
||||
si.MMProj = mmPath
|
||||
}
|
||||
// API_KEY protège le serveur quand il est exposé sur internet : llama-server
|
||||
// exige alors l'en-tête "Authorization: Bearer <clé>". La clé est lue depuis
|
||||
// $LOKI_HOME/.api_key en priorité (elle survit ainsi aux changements de preset
|
||||
// qui réécrivent config.env), avec config.env comme repli rétro-compatible.
|
||||
si.APIKey, _ = effectiveAPIKeyErr()
|
||||
// Conteneur à l'étroit (cpuset, quota) : seul Linux l'expose, dans /proc et
|
||||
// /sys. Un LLAMA_ARG_THREADS déjà posé est un choix de l'utilisateur — un -t
|
||||
// de Loki l'écraserait, donc on ne sonde même pas.
|
||||
if runtime.GOOS == "linux" && os.Getenv("LLAMA_ARG_THREADS") == "" {
|
||||
si.CPU = cpusetThreads(os.DirFS("/"))
|
||||
}
|
||||
|
||||
extra := splitArgs(cfg["EXTRA_ARGS"])
|
||||
// Deux GPU ou plus : de quoi décider des files de lancement CUDA (voir
|
||||
// launchQueuesEnv). Après la sélection GPU, qui fait partie de la réponse.
|
||||
probeServeGPUs(cfg, extra, &si)
|
||||
probeExpertEnv(&si)
|
||||
probeLoadEnv(&si)
|
||||
probeFidelityEnv(&si)
|
||||
probeCacheRAM(cfg, extra, &si)
|
||||
probeCkptEnv(&si)
|
||||
probeSideSlotEnv(cfg, &si)
|
||||
// SPLIT_MODE=tensor seulement : cartes listées par CE moteur, dans son ordre.
|
||||
probeSplit(cfg, bin, &si)
|
||||
// LOAD_GUARD : un refus envisagé sur la VRAM NVIDIA se décide sur les
|
||||
// cartes du moteur (Vulkan sur cartes mixtes, ROCm…). Sinon rien n'est lu.
|
||||
normExtra, _ := normalizeLoadFlags(extra, helpSupportsLoadMode(si.Help))
|
||||
probeLoadDevices(cfg, normExtra, bin, &si)
|
||||
spec := specMode(cfg)
|
||||
if spec != "off" && spec != "" {
|
||||
probeSpec(cfg, &si)
|
||||
}
|
||||
// Build du moteur : seulement pour un hybride ou SPEC=auto, les seuls cas où
|
||||
// il décide de quelque chose (voir ckptArgs, specAutoBlocker) — inutile de
|
||||
// lancer « --version » sinon.
|
||||
if ggufHybrid(si.GGUF) || spec == "auto" {
|
||||
si.EngineBuild = engineBuildTrusted(bin)
|
||||
}
|
||||
// Lu seulement ici ; rien n'est consommé avant que le port soit libre.
|
||||
specMark := specAutoMark{FP: configFingerprint(cfg), Bin: bin, Build: si.EngineBuild}
|
||||
specRecord := false
|
||||
if spec == "auto" {
|
||||
si.SpecAutoBlocked, specRecord = specAutoPeek(specMark)
|
||||
}
|
||||
probeSlotPersistEnv(cfg, &si)
|
||||
if strings.Contains(si.Help, "--slot-save-path") && !hasAnyFlag(extra, "--slot-save-path") {
|
||||
if trialDir != "" {
|
||||
// L'essai a son propre dossier : celui du vrai moteur garde ses états.
|
||||
si.SlotDir = prepareSlotDir(trialDir, false)
|
||||
} else {
|
||||
si.SlotDir = prepareSlotDir(LokiHome(), slotPersistOn(cfg))
|
||||
}
|
||||
}
|
||||
|
||||
llmArgs, env, notes := buildServeArgs(cfg, extra, bin, si)
|
||||
applyServeEnv(env)
|
||||
// SLOT_PERSIST (backend_serve_persist.go) : la clé de CE lancement, et les
|
||||
// états gardés au-delà de deux retirés. Refusé pour ce preset (brouillon,
|
||||
// poids sur CPU…) : ceux des autres presets restent, pour leur retour.
|
||||
// Clé retirée : plus aucun état gardé sur le disque.
|
||||
persistKey := ""
|
||||
persistOK, _ := slotPersistPlan(cfg, extra, si)
|
||||
switch {
|
||||
case trialDir != "":
|
||||
// Un essai ne garde rien et n'efface rien.
|
||||
case persistOK:
|
||||
persistKey = slotPersistKey(llmArgs, env, si, bin)
|
||||
slotPersistPrune(si.SlotDir)
|
||||
case !slotPersistOn(cfg):
|
||||
slotPersistPurge(filepath.Join(LokiHome(), "slots"))
|
||||
}
|
||||
for _, n := range notes {
|
||||
fmt.Fprintln(os.Stderr, "[loki serve] "+n)
|
||||
}
|
||||
// Chargement résident avec plus de poids en RAM que la machine n'en a :
|
||||
// échec certain (voir loadModeRisk), LOAD_GUARD=off pour passer outre.
|
||||
if _, refuse := loadModeRisk(cfg, llmArgs, si); refuse != "" {
|
||||
return loadGuardRefusal(refuse, trialDir != "")
|
||||
}
|
||||
|
||||
// Essai : écoute sur 127.0.0.1 et le port libre choisi par l'optimiseur,
|
||||
// même si EXTRA_ARGS en nomme d'autres (le dernier --port gagne). La ligne
|
||||
// composée est rendue à l'optimiseur, qui la compare à celle de référence
|
||||
// AVANT de charger quoi que ce soit ; à blanc, on s'arrête là.
|
||||
if trialDir != "" {
|
||||
llmArgs = tuneForceAddr(llmArgs, cfg["HOST"], cfg["PORT"])
|
||||
l := tuneLaunch{Bin: bin, Args: llmArgs, Env: env, Notes: notes, Probe: tuneProbeFrom(si)}
|
||||
if err := writeTuneLaunch(trialDir, l); err != nil {
|
||||
return err
|
||||
}
|
||||
if os.Getenv(tuneDryRunEnv) != "" {
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
// Working dir = LOKI_HOME so relative paths in EXTRA_ARGS (e.g. --mmproj
|
||||
// mmproj-F16.gguf) still resolve.
|
||||
_ = os.Chdir(LokiHome())
|
||||
|
||||
// Port déjà pris (souvent un llama-server orphelin qu'un stop n'a pas pu
|
||||
// tuer) : deux moteurs sur un même port se partagent les requêtes au hasard,
|
||||
// VRAM saturée et réponses du mauvais modèle. On refuse en clair.
|
||||
host, port := argValue(llmArgs, "--host"), argValue(llmArgs, "--port")
|
||||
if err := waitPortFree(host, port, 5*time.Second); err != nil {
|
||||
return err
|
||||
}
|
||||
// Le type EFFECTIF : un -ctk/-ctv d'EXTRA_ARGS l'emporte sur KV_TYPE, et c'est
|
||||
// lui que le moteur accélère ou non.
|
||||
kt, vt, _ := effectiveKVTypes(cfg, extra, si.ArgEnv)
|
||||
warnSlowKV(kt, vt)
|
||||
// Jeton de tentative : l'ancien consommé (et l'échec inscrit), le nouveau
|
||||
// posé, au dernier moment — port libre, l'ancien moteur est donc bien parti
|
||||
// et le process web ne peut pas le confondre avec lui.
|
||||
if spec == "auto" && trialDir == "" {
|
||||
_, _, auto := specArgs(cfg, extra, si)
|
||||
specAutoSettle(specMark, si.SpecAutoBlocked, specRecord, auto)
|
||||
}
|
||||
|
||||
// Posée au dernier moment, port libre : l'ancien moteur est parti, la clé
|
||||
// lue par le process web est celle du moteur qui répondra.
|
||||
if trialDir == "" {
|
||||
writeSlotPersistMarker(LokiHome(), persistKey)
|
||||
}
|
||||
|
||||
fmt.Fprintf(os.Stderr, "[loki serve] %s model=%s port=%s\n",
|
||||
bin, filepath.Base(model), port)
|
||||
// Ligne de commande réellement lancée, gardée pour l'interface (amont #108).
|
||||
if trialDir == "" {
|
||||
recordEngineCmdline(env, llmArgs)
|
||||
}
|
||||
|
||||
// Hand off to the llama-server process. On Unix this replaces the current
|
||||
// process (exec); on Windows it runs as a child and waits. See sys_platform_*.go.
|
||||
return execServer(bin, llmArgs)
|
||||
}
|
||||
|
||||
// serveSysInfo rassemble ce que cmdServe a dû demander au monde avant de
|
||||
// composer la ligne de commande : l'aide du moteur (d'où se déduisent ses
|
||||
// capacités), les chemins déjà résolus et vérifiés, la clé d'API. Avec elle,
|
||||
// buildServeArgs n'a plus besoin de toucher ni au disque ni au binaire.
|
||||
type serveSysInfo struct {
|
||||
Help string // sortie de « <bin> --help » ; vide = moteur inconnu, aucun risque pris
|
||||
Model string // chemin du .gguf principal, résolu et vérifié
|
||||
MMProj string // chemin du projecteur vision, résolu et vérifié ; vide = pas de vision
|
||||
APIKey string // clé effective (.api_key, sinon config.env) ; vide = serveur ouvert
|
||||
CPU cpuBudget // sonde de conteneur (Linux) ; zéro = llama.cpp choisit ses threads seul
|
||||
|
||||
LaunchQueues string // CUDA_SCALE_LAUNCH_QUEUES déjà dans l'environnement : choix de l'utilisateur, intouché
|
||||
GPUs int // GPU visibles (CUDA_VISIBLE_DEVICES, sinon nvidia-smi) ; 0 = inconnu ou non sondé
|
||||
ArgEnv map[string]string // LLAMA_ARG_* de l'environnement qui décident du pipeline, de --fit et du cache (serveArgEnv, fitArgEnv, fidelityArgEnv)
|
||||
UserEnv map[string]string // GGML_* des réglages d'expert déjà posés (voir expertEnvKeys) : intouchés
|
||||
|
||||
// Cache de prompts (voir backend_serve_cache.go). Zéro = inconnu : le moteur
|
||||
// garde son défaut.
|
||||
GGUF *GGUFInfo // métadonnées du modèle ; nil = illisibles
|
||||
ModelBytes int64 // taille du modèle, toutes tranches
|
||||
RAMMiB int64 // RAM de la machine, limite du conteneur comprise
|
||||
RAMAvailMiB int64 // RAM libre au lancement
|
||||
VRAMMiB int64 // VRAM NVIDIA des cartes visibles
|
||||
UnifiedMem bool // macOS : RAM et VRAM ne font qu'une (voir loadModeRisk)
|
||||
// VRAM des cartes listées par le moteur lui-même (--list-devices), lue
|
||||
// seulement quand LOAD_GUARD envisage un refus (probeLoadDevices) ; 0 =
|
||||
// non lue : un avis au plus, jamais de refus sur la seule VRAM NVIDIA.
|
||||
EngineVRAMMiB int64
|
||||
SlotDir string // dossier de --slot-save-path, créé et vérifié ; vide = pas de drapeau
|
||||
|
||||
// Build d'un moteur officiel (engineBuildTrusted), lu seulement pour un
|
||||
// modèle hybride ou SPEC=auto ; 0 = inconnu ou non lu : ni avis ni drapeau
|
||||
// automatique.
|
||||
EngineBuild int
|
||||
|
||||
// Décodage spéculatif (voir backend_serve_spec.go), lu seulement si SPEC le
|
||||
// demande.
|
||||
Draft string // MODEL_DRAFT résolu et vérifié ; vide = absent ou introuvable
|
||||
DraftErr string // pourquoi MODEL_DRAFT n'a pas été trouvé
|
||||
DraftGGUF *GGUFInfo // métadonnées du brouillon ; nil = illisibles
|
||||
DraftIsModel bool // MODEL_DRAFT désigne le modèle principal lui-même (voir specArgs)
|
||||
SpecAutoBlocked string // un essai automatique précédent a échoué : la raison
|
||||
|
||||
// Cartes listées par le moteur (--list-devices), lues seulement pour
|
||||
// SPLIT_MODE=tensor (voir backend_serve_split.go) ; vide = inconnues.
|
||||
SplitDevs []splitDev
|
||||
}
|
||||
|
||||
// cudaDeviceEnv : sélection GPU (loki gpu), on filtre les devices visibles par
|
||||
// llama-server. CUDA_DEVICE_ORDER=PCI_BUS_ID garantit que les index
|
||||
// correspondent à ceux affichés par nvidia-smi (sinon CUDA réordonne par
|
||||
// « device le plus rapide »).
|
||||
func cudaDeviceEnv(cfg map[string]string) map[string]string {
|
||||
env := map[string]string{}
|
||||
if v := cfg["CUDA_VISIBLE_DEVICES"]; v != "" {
|
||||
env["CUDA_VISIBLE_DEVICES"] = v
|
||||
env["CUDA_DEVICE_ORDER"] = "PCI_BUS_ID"
|
||||
}
|
||||
return env
|
||||
}
|
||||
|
||||
func applyServeEnv(env map[string]string) {
|
||||
for k, v := range env {
|
||||
_ = os.Setenv(k, v)
|
||||
}
|
||||
}
|
||||
|
||||
// serveKVTypes donne les types de cache K et V du preset : KV_TYPE pour les
|
||||
// deux, KV_TYPE_K / KV_TYPE_V pour les régler séparément. Vide = défaut moteur.
|
||||
func serveKVTypes(cfg map[string]string) (k, v string) {
|
||||
kv := cfg["KV_TYPE"]
|
||||
k, v = kv, kv
|
||||
if s := cfg["KV_TYPE_K"]; s != "" {
|
||||
k = s
|
||||
}
|
||||
if s := cfg["KV_TYPE_V"]; s != "" {
|
||||
v = s
|
||||
}
|
||||
return k, v
|
||||
}
|
||||
|
||||
// buildServeArgs compose la ligne de commande de llama-server (bin en tête) à
|
||||
// partir du preset, d'EXTRA_ARGS tel que découpé par splitArgs et de ce que
|
||||
// cmdServe a sondé. Fonction pure : elle renvoie aussi les variables
|
||||
// d'environnement à poser et les notes à afficher, sans rien faire elle-même.
|
||||
// L'ordre des arguments est celui qu'a toujours produit cmdServe — le moteur
|
||||
// retient la DERNIÈRE occurrence d'un drapeau, donc l'ordre fait partie du
|
||||
// comportement.
|
||||
func buildServeArgs(cfg map[string]string, extra []string, bin string, si serveSysInfo) (args []string, env map[string]string, notes []string) {
|
||||
get := func(key, fallback string) string {
|
||||
if v, ok := cfg[key]; ok && v != "" {
|
||||
return v
|
||||
}
|
||||
return fallback
|
||||
}
|
||||
ktv, vtv := serveKVTypes(cfg)
|
||||
env = cudaDeviceEnv(cfg)
|
||||
|
||||
// EXTRA_ARGS est lu ICI, avant de composer la ligne de commande : ce que
|
||||
// l'utilisateur a écrit à la main décide des défauts que Loki a le droit
|
||||
// d'ajouter (voir hasAnyFlag). Il est aussi traduit vers les drapeaux de
|
||||
// chargement actuels quand le moteur les attend (voir normalizeLoadFlags).
|
||||
extra, loadNote := normalizeLoadFlags(extra, helpSupportsLoadMode(si.Help))
|
||||
if loadNote != "" {
|
||||
notes = append(notes, loadNote)
|
||||
}
|
||||
|
||||
// Parallélisme de tenseurs (SPLIT_MODE=tensor, voir backend_serve_split.go) :
|
||||
// sans la clé, splitOn est faux et rien de ce qui suit ne change. Accepté, il
|
||||
// compte pour les réglages qui lisent le placement dans EXTRA_ARGS (files de
|
||||
// lancement, graphes CUDA, --fit) comme un -sm tensor écrit à la main.
|
||||
splitOn, splitA, splitNGLArgs, splitEnv, splitNotes := splitTensorArgs(cfg, extra, si)
|
||||
place := extra
|
||||
if splitOn {
|
||||
place = append(append([]string{}, extra...), "-sm", "tensor")
|
||||
}
|
||||
for k, v := range splitEnv {
|
||||
env[k] = v
|
||||
}
|
||||
notes = append(notes, splitNotes...)
|
||||
|
||||
// Files de lancement CUDA : une variable d'environnement, pas un drapeau —
|
||||
// la ligne de commande n'en dépend pas (voir launchQueuesEnv).
|
||||
q, qNotes := launchQueuesEnv(cfg, place, si)
|
||||
if q != "" {
|
||||
env["CUDA_SCALE_LAUNCH_QUEUES"] = q
|
||||
}
|
||||
notes = append(notes, qNotes...)
|
||||
// Réglages d'expert passés par variable (voir backend_serve_expert.go) :
|
||||
// absents du preset, ils ne posent rien.
|
||||
g, gNotes := graphOptEnv(cfg, place, si)
|
||||
if g != "" {
|
||||
env["GGML_CUDA_GRAPH_OPT"] = g
|
||||
}
|
||||
o, oNotes := opOffloadMinBatchEnv(cfg, si)
|
||||
if o != "" {
|
||||
env["GGML_OP_OFFLOAD_MIN_BATCH"] = o
|
||||
}
|
||||
notes = append(append(notes, gNotes...), oNotes...)
|
||||
|
||||
// Threads : vide ou 0 = AUCUN drapeau, pour que llama.cpp prenne ses cœurs
|
||||
// physiques au lieu de tous les threads logiques (voir threadArgs).
|
||||
threads, threadNotes := threadArgs(cfg["THREADS"], cfg["THREADS_BATCH"], extra, si.CPU)
|
||||
notes = append(notes, threadNotes...)
|
||||
// Second slot (SIDE_SLOT, voir backend_serve_side.go) : sans la clé, sideCtx
|
||||
// vaut 0 et rien de ce qui suit ne change.
|
||||
sideCtx, sideMiB, sideWhy := sideSlotPlan(cfg, extra, si)
|
||||
ctxArg := get("CTX", "32768")
|
||||
if sideCtx > 0 {
|
||||
ctxArg = strconv.Itoa(2 * sideCtx)
|
||||
}
|
||||
args = []string{bin,
|
||||
"-m", si.Model,
|
||||
"-c", ctxArg,
|
||||
}
|
||||
args = append(args, threads...)
|
||||
args = append(args,
|
||||
"-b", get("BATCH", "2048"),
|
||||
"-ub", get("UBATCH", "512"),
|
||||
"--host", get("HOST", "0.0.0.0"),
|
||||
"--port", get("PORT", "8080"),
|
||||
)
|
||||
// --parallel 1 EXPLICITE : les llama-server récents ouvrent plusieurs slots
|
||||
// par défaut, soit des tampons de calcul GPU multipliés d'autant — pour un
|
||||
// serveur mono-utilisateur comme Loki, c'est de la VRAM brûlée pour rien.
|
||||
// Vécu : une config 27B/60k ctx qui tournait très bien est morte en
|
||||
// « cudaMalloc failed: out of memory » après une mise à jour du moteur,
|
||||
// uniquement à cause de ce nouveau défaut. PARALLEL=n dans le preset pour qui
|
||||
// veut vraiment servir plusieurs requêtes à la fois — ou --parallel dans
|
||||
// EXTRA_ARGS, auquel cas on ne redouble pas le drapeau.
|
||||
if sideCtx > 0 {
|
||||
// Deux flux KV séparés de sideCtx jetons : aucun slot ne peut manger la
|
||||
// place de l'autre (voir backend_serve_side.go).
|
||||
args = append(args, "--parallel", "2", "--no-kv-unified")
|
||||
} else if !hasAnyFlag(extra, "--parallel", "-np") {
|
||||
args = append(args, "--parallel", get("PARALLEL", "1"))
|
||||
}
|
||||
if n := sideSlotNote(sideCtx, sideMiB, sideWhy, nglForced(cfg, extra)); n != "" {
|
||||
notes = append(notes, n)
|
||||
}
|
||||
// Couches GPU. Quatre cas, dans cet ordre :
|
||||
//
|
||||
// NGL=auto → rien du tout (le moteur décide seul)
|
||||
// NGL=999 → -ngl auto sur un moteur récent (voir plus bas)
|
||||
// NGL=<nombre> → -ngl <nombre> (l'utilisateur tranche pour de vrai)
|
||||
// clé absente → -ngl auto si le moteur le propose, sinon -ngl 999
|
||||
//
|
||||
// Imposer un NOMBRE désarme common_fit_params, qui mesure la VRAM libre et
|
||||
// choisit combien de couches y tiennent : « n_gpu_layers already set by user
|
||||
// to 999, abort ». Le moteur pousse alors tout sur le GPU, échoue en
|
||||
// cudaMalloc ou se replie à moitié sur le CPU — débit effondré, GPU à 100 %.
|
||||
//
|
||||
// 999 est traité à part parce que ce n'a JAMAIS été un nombre de couches :
|
||||
// c'est la sentinelle historique « toutes », semée dans config.env par
|
||||
// defaultConfig() sur chaque installation neuve. La quasi-totalité des
|
||||
// configurations la portent donc sans que personne ne l'ait choisie — la
|
||||
// traiter comme un choix délibéré, c'est condamner tout le monde à l'abandon
|
||||
// ci-dessus. Sur un moteur qui connaît « auto », 999 devient donc auto, et on
|
||||
// le DIT sur stderr plutôt que de le faire en douce. Qui veut réellement
|
||||
// forcer tout sur le GPU écrit NGL=all (ou un nombre qui n'est pas 999).
|
||||
if splitOn {
|
||||
args = append(args, splitNGLArgs...) // « -ngl all », ou rien si EXTRA_ARGS l'a déjà
|
||||
} else if !hasAnyFlag(extra, "-ngl", "--n-gpu-layers", "--gpu-layers") {
|
||||
ngl, note := nglArgs(get("NGL", ""), helpFitsLayersItself(si.Help))
|
||||
if note != "" {
|
||||
notes = append(notes, note)
|
||||
}
|
||||
args = append(args, ngl...)
|
||||
}
|
||||
// Marge VRAM par carte du placement automatique (FIT_TARGET, voir
|
||||
// fitTargetArgs) : seulement si --fit tournera vraiment.
|
||||
fitt, fitNotes := fitTargetArgs(cfg, place, si)
|
||||
args = append(append(args, fitt...), splitA...)
|
||||
notes = append(notes, fitNotes...)
|
||||
if ktv != "" {
|
||||
args = append(args, "-ctk", ktv)
|
||||
}
|
||||
if vtv != "" {
|
||||
args = append(args, "-ctv", vtv)
|
||||
}
|
||||
// Vision : chemin déjà résolu et vérifié par cmdServe (voir là-bas).
|
||||
if si.MMProj != "" {
|
||||
args = append(args, "--mmproj", si.MMProj)
|
||||
}
|
||||
// Raisonnement. Trois cas, et la nuance compte :
|
||||
//
|
||||
@@ -168,37 +729,124 @@ func cmdServe(args []string) error {
|
||||
// contenu). L'anti-boucle côté llm_client.go reste le garde-fou. NE PAS forcer 0 :
|
||||
// sur llama.cpp vanilla, 0 = "immediate end" → coupe tout le raisonnement
|
||||
// (le fork ik_llama.cpp l'ignore). Configurable via REASONING_BUDGET.
|
||||
llmArgs = append(llmArgs, "--reasoning", r, "--reasoning-budget", get("REASONING_BUDGET", "-1"))
|
||||
} else if binSupportsReasoningFlag(bin) {
|
||||
args = append(args, "--reasoning", r, "--reasoning-budget", get("REASONING_BUDGET", "-1"))
|
||||
} else if helpSupportsReasoningFlag(si.Help) {
|
||||
// Pas de budget ici : « off » suffit, et un budget sur un moteur qui
|
||||
// n'attend rien d'autre ne ferait qu'ajouter une occasion d'échouer.
|
||||
llmArgs = append(llmArgs, "--reasoning", "off")
|
||||
args = append(args, "--reasoning", "off")
|
||||
} else {
|
||||
// Vieux moteur (ou fork) qui ne connaît pas le drapeau : le lui passer
|
||||
// le ferait sortir en erreur au démarrage, donc boucler. On le dit et on
|
||||
// continue sans — mieux vaut un modèle qui réfléchit qu'un moteur mort.
|
||||
fmt.Fprintf(os.Stderr, "[loki serve] ce moteur ne connaît pas --reasoning : impossible de désactiver le raisonnement\n")
|
||||
notes = append(notes, "ce moteur ne connaît pas --reasoning : impossible de désactiver le raisonnement")
|
||||
}
|
||||
}
|
||||
// API_KEY protège le serveur quand il est exposé sur internet : llama-server
|
||||
// exige alors l'en-tête "Authorization: Bearer <clé>". La clé est lue depuis
|
||||
// $LOKI_HOME/.api_key en priorité (elle survit ainsi aux changements de preset
|
||||
// qui réécrivent config.env), avec config.env comme repli rétro-compatible.
|
||||
if k, _ := effectiveAPIKeyErr(); k != "" {
|
||||
llmArgs = append(llmArgs, "--api-key", k)
|
||||
// Conservation du raisonnement des tours passés : seulement si
|
||||
// REASONING_PRESERVE est posée (voir reasoningPreserveArgs pour le compromis).
|
||||
rp, rpNotes := reasoningPreserveArgs(cfg, extra, si)
|
||||
args = append(args, rp...)
|
||||
notes = append(notes, rpNotes...)
|
||||
if si.APIKey != "" {
|
||||
args = append(args, "--api-key", si.APIKey)
|
||||
}
|
||||
// Cache de prompts en RAM et dossier des slots (voir backend_serve_cache.go) :
|
||||
// avant EXTRA_ARGS, qui garde le dernier mot.
|
||||
cram, cramNotes := cacheRAMArgs(cfg, extra, si)
|
||||
args = append(append(args, cram...), slotSaveArgs(cfg, extra, si)...)
|
||||
notes = append(notes, cramNotes...)
|
||||
if n := slotPersistNote(slotPersistPlan(cfg, extra, si)); n != "" {
|
||||
notes = append(notes, n)
|
||||
}
|
||||
// Points de reprise des hybrides (voir backend_serve_ckpt.go).
|
||||
ck, ckNotes := ckptArgs(cfg, extra, si)
|
||||
args = append(args, ck...)
|
||||
notes = append(notes, ckNotes...)
|
||||
// Décodage spéculatif (voir backend_serve_spec.go) : rien tant que SPEC
|
||||
// n'est pas posé.
|
||||
sp, spNotes, _ := specArgs(cfg, extra, si)
|
||||
args = append(args, sp...)
|
||||
notes = append(notes, spNotes...)
|
||||
// EXTRA_ARGS (déjà découpé comme le ferait le shell — les guillemets gardent
|
||||
// ensemble un chemin qui contient des espaces) ferme la marche.
|
||||
args = append(args, extra...)
|
||||
// Garde-fous de fidélité (voir backend_serve_fidelity.go) : ils ne touchent à
|
||||
// aucun drapeau, ils DISENT ce que la ligne finale change aux calculs.
|
||||
ek, ev, src := effectiveKVTypes(cfg, extra, si.ArgEnv)
|
||||
if n := kvFidelityNote(ek, ev, src, extra, si.ArgEnv); n != "" {
|
||||
notes = append(notes, n)
|
||||
}
|
||||
notes = append(notes, lossyCacheNotes(extra, si)...)
|
||||
// Experts MoE et mode de chargement (voir backend_serve_moe.go) : des notes
|
||||
// seulement, la ligne ne bouge pas. Le refus d'un chargement voué à l'échec
|
||||
// se décide dans cmdServe, sur cette même ligne.
|
||||
notes = append(notes, moeNotes(cfg, extra, si)...)
|
||||
if w, _ := loadModeRisk(cfg, args, si); w != "" {
|
||||
notes = append(notes, w)
|
||||
}
|
||||
return args, env, notes
|
||||
}
|
||||
|
||||
// argValue renvoie la valeur de la DERNIÈRE occurrence de flag (llama-server
|
||||
// retient la dernière : EXTRA_ARGS peut surcharger --port).
|
||||
func argValue(args []string, flag string) string {
|
||||
v := ""
|
||||
for i := 0; i+1 < len(args); i++ {
|
||||
if args[i] == flag {
|
||||
v = args[i+1]
|
||||
}
|
||||
}
|
||||
return v
|
||||
}
|
||||
|
||||
// waitPortFree vérifie que personne n'écoute déjà sur host:port. On tente une
|
||||
// connexion TCP (fiable même si l'occupant a bindé en SO_REUSEADDR, où un
|
||||
// Listen de test pourrait réussir). Un moteur qu'on vient d'arrêter peut tenir
|
||||
// le port quelques instants : on réessaie jusqu'à wait avant de conclure.
|
||||
// Repris d'AJEAN 0.15.9.
|
||||
func waitPortFree(host, port string, wait time.Duration) error {
|
||||
if port == "" {
|
||||
return nil
|
||||
}
|
||||
h := strings.Trim(host, "[]")
|
||||
if h == "" || h == "0.0.0.0" || h == "::" {
|
||||
h = "127.0.0.1"
|
||||
}
|
||||
addr := net.JoinHostPort(h, port)
|
||||
deadline := time.Now().Add(wait)
|
||||
for {
|
||||
c, err := net.DialTimeout("tcp", addr, 500*time.Millisecond)
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
c.Close()
|
||||
if time.Now().After(deadline) {
|
||||
return fmt.Errorf("le port %s est déjà utilisé (sans doute un ancien llama-server encore actif) : "+
|
||||
"« loki stop », docker restart loki, ou change PORT", port)
|
||||
}
|
||||
time.Sleep(500 * time.Millisecond)
|
||||
}
|
||||
}
|
||||
|
||||
// fastKV : combinaisons K/V que llama.cpp CUDA accélère en Flash-Attention
|
||||
// avec ses options de compilation par défaut (GGML_CUDA_FA_ALL_QUANTS=OFF) —
|
||||
// c'est le cas du moteur de l'image server-cuda comme de ceux tirés par OCI.
|
||||
// Les autres (q5_*, q4_1, combinaisons mixtes) marchent mais sont reconverties
|
||||
// en f16 à chaque pas : la génération ralentit nettement (AJEAN 0.15.9).
|
||||
var fastKV = map[string]bool{"f16|f16": true, "q8_0|q8_0": true, "q4_0|q4_0": true, "bf16|bf16": true}
|
||||
|
||||
func slowKV(k, v string) bool {
|
||||
if k == "" {
|
||||
k = "f16"
|
||||
}
|
||||
if v == "" {
|
||||
v = "f16"
|
||||
}
|
||||
return !fastKV[k+"|"+v]
|
||||
}
|
||||
|
||||
func warnSlowKV(k, v string) {
|
||||
if slowKV(k, v) {
|
||||
fmt.Fprintf(os.Stderr, "[loki serve] avertissement : cache KV %s/%s non accéléré par llama.cpp CUDA "+
|
||||
"(converti en f16 à chaque pas, lent). Préfère q8_0/q8_0, q4_0/q4_0 ou f16.\n", k, v)
|
||||
}
|
||||
// EXTRA_ARGS is appended verbatim, split like the shell would — quotes kept
|
||||
// together so a path with spaces stays one argument.
|
||||
llmArgs = append(llmArgs, splitArgs(cfg["EXTRA_ARGS"])...)
|
||||
|
||||
// Working dir = LOKI_HOME so relative paths in EXTRA_ARGS (e.g. --mmproj
|
||||
// mmproj-F16.gguf) still resolve.
|
||||
_ = os.Chdir(LokiHome())
|
||||
|
||||
fmt.Fprintf(os.Stderr, "[loki serve] %s model=%s port=%s\n",
|
||||
bin, filepath.Base(model), get("PORT", "8080"))
|
||||
|
||||
// Hand off to the llama-server process. On Unix this replaces the current
|
||||
// process (exec); on Windows it runs as a child and waits. See sys_platform_*.go.
|
||||
return execServer(bin, llmArgs)
|
||||
}
|
||||
@@ -0,0 +1,299 @@
|
||||
package loki
|
||||
|
||||
import (
|
||||
"reflect"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// Deux aides de moteur fabriquées, réduites aux lignes que lisent les tests de
|
||||
// capacité : un llama.cpp récent (--load-mode, « -ngl auto », --reasoning) et
|
||||
// un ancien qui ne connaît que les drapeaux d'origine.
|
||||
const (
|
||||
helpRecent = `-ngl, --gpu-layers, --n-gpu-layers N max. number of layers to store in VRAM, either an exact number,
|
||||
'auto', or 'all' (default: auto)
|
||||
-lm, --load-mode MODE how to load the model: auto, mmap, mmap+mlock, mlock, none, dio
|
||||
-rea, --reasoning [on|off|auto] use reasoning/thinking in the chat (default: auto)
|
||||
`
|
||||
helpOld = `-ngl, --gpu-layers, --n-gpu-layers N number of layers to store in VRAM
|
||||
--mlock force system to keep model in RAM
|
||||
--no-mmap do not memory-map model
|
||||
`
|
||||
)
|
||||
|
||||
// TestBuildServeArgs fige la ligne de commande produite pour les presets
|
||||
// courants : la moindre différence d'ordre ou de valeur casse le test. Le
|
||||
// moteur retient la DERNIÈRE occurrence d'un drapeau, donc l'ordre compte
|
||||
// autant que les valeurs — c'est la base sur laquelle les réglages suivants
|
||||
// viendront s'ajouter, chacun avec sa ligne ici.
|
||||
func TestBuildServeArgs(t *testing.T) {
|
||||
const bin, model = "/opt/llama/llama-server", "/models/Qwen3.6-27B-Q4_K_M.gguf"
|
||||
base := []string{bin, "-m", model,
|
||||
"-c", "32768", "-b", "2048", "-ub", "512",
|
||||
"--host", "0.0.0.0", "--port", "8080"}
|
||||
with := func(tail ...string) []string {
|
||||
return append(append([]string{}, base...), tail...)
|
||||
}
|
||||
// withThreads insère -t / -tb à leur place, juste après -c.
|
||||
withThreads := func(threads []string, tail ...string) []string {
|
||||
out := append(append([]string{}, base[:5]...), threads...)
|
||||
return append(append(out, base[5:]...), tail...)
|
||||
}
|
||||
cases := []struct {
|
||||
name string
|
||||
cfg map[string]string
|
||||
si serveSysInfo
|
||||
want []string
|
||||
wantEnv map[string]string
|
||||
wantNotes int
|
||||
}{
|
||||
{
|
||||
name: "dense par défaut, moteur récent : 999 devient auto, et on le dit",
|
||||
cfg: map[string]string{"NGL": "999"},
|
||||
si: serveSysInfo{Help: helpRecent},
|
||||
want: with("--parallel", "1", "-ngl", "auto"), wantNotes: 1,
|
||||
},
|
||||
{
|
||||
name: "dense par défaut, moteur ancien : 999 reste 999",
|
||||
cfg: map[string]string{"NGL": "999"},
|
||||
si: serveSysInfo{Help: helpOld},
|
||||
want: with("--parallel", "1", "-ngl", "999"),
|
||||
},
|
||||
{
|
||||
name: "clé NGL absente, aide illisible : forme historique",
|
||||
cfg: map[string]string{},
|
||||
si: serveSysInfo{},
|
||||
want: with("--parallel", "1", "-ngl", "999"),
|
||||
},
|
||||
{
|
||||
name: "NGL forcé à un nombre : jamais touché",
|
||||
cfg: map[string]string{"NGL": "28"},
|
||||
si: serveSysInfo{Help: helpRecent},
|
||||
want: with("--parallel", "1", "-ngl", "28"),
|
||||
},
|
||||
{
|
||||
name: "NGL=auto : aucun drapeau",
|
||||
cfg: map[string]string{"NGL": "auto"},
|
||||
si: serveSysInfo{Help: helpRecent},
|
||||
want: with("--parallel", "1"),
|
||||
},
|
||||
{
|
||||
name: "réglages du preset et cache KV commun",
|
||||
cfg: map[string]string{"CTX": "65536", "THREADS": "8", "THREADS_BATCH": "16", "BATCH": "4096",
|
||||
"UBATCH": "1024", "HOST": "127.0.0.1", "PORT": "9090", "PARALLEL": "2", "NGL": "all",
|
||||
"KV_TYPE": "q8_0"},
|
||||
si: serveSysInfo{Help: helpRecent},
|
||||
want: []string{bin, "-m", model,
|
||||
"-c", "65536", "-t", "8", "-tb", "16", "-b", "4096", "-ub", "1024",
|
||||
"--host", "127.0.0.1", "--port", "9090",
|
||||
"--parallel", "2", "-ngl", "all", "-ctk", "q8_0", "-ctv", "q8_0"},
|
||||
wantNotes: 1, // cache quantifié : dit, jamais retiré
|
||||
},
|
||||
{
|
||||
name: "THREADS=0 et THREADS_BATCH=0 : aucun drapeau, llama.cpp prend ses cœurs physiques",
|
||||
cfg: map[string]string{"NGL": "28", "THREADS": "0", "THREADS_BATCH": "0"},
|
||||
si: serveSysInfo{Help: helpRecent},
|
||||
want: with("--parallel", "1", "-ngl", "28"),
|
||||
},
|
||||
{
|
||||
name: "THREADS=6 seul : -t 6, -tb laissé au moteur (il recopie -t)",
|
||||
cfg: map[string]string{"NGL": "28", "THREADS": "6"},
|
||||
si: serveSysInfo{Help: helpRecent},
|
||||
want: withThreads([]string{"-t", "6"}, "--parallel", "1", "-ngl", "28"),
|
||||
},
|
||||
{
|
||||
name: "THREADS_BATCH seul : -tb sans -t",
|
||||
cfg: map[string]string{"NGL": "28", "THREADS_BATCH": "12"},
|
||||
si: serveSysInfo{Help: helpRecent},
|
||||
want: withThreads([]string{"-tb", "12"}, "--parallel", "1", "-ngl", "28"),
|
||||
},
|
||||
{
|
||||
name: "THREADS illisible ou négatif : ignoré, et dit",
|
||||
cfg: map[string]string{"NGL": "28", "THREADS": "auto", "THREADS_BATCH": "-1"},
|
||||
si: serveSysInfo{Help: helpRecent},
|
||||
want: with("--parallel", "1", "-ngl", "28"), wantNotes: 2,
|
||||
},
|
||||
{
|
||||
name: "-t=6 dans EXTRA_ARGS : pas de -t de Loki",
|
||||
cfg: map[string]string{"NGL": "28", "THREADS": "8", "THREADS_BATCH": "16", "EXTRA_ARGS": "-t=6 --threads-batch 10"},
|
||||
si: serveSysInfo{Help: helpRecent},
|
||||
want: with("--parallel", "1", "-ngl", "28", "-t=6", "--threads-batch", "10"),
|
||||
},
|
||||
{
|
||||
// Sans -tb, llama.cpp remplacerait tout le réglage CPU du batch par
|
||||
// celui de -t : le --poll-batch de l'utilisateur serait effacé.
|
||||
name: "--poll-batch dans EXTRA_ARGS, THREADS vide : -tb 0 le protège, et on le dit",
|
||||
cfg: map[string]string{"NGL": "28", "EXTRA_ARGS": "--poll-batch 0"},
|
||||
si: serveSysInfo{Help: helpRecent},
|
||||
want: withThreads([]string{"-tb", "0"}, "--parallel", "1", "-ngl", "28", "--poll-batch", "0"), wantNotes: 1,
|
||||
},
|
||||
{
|
||||
name: "-Cb dans EXTRA_ARGS, THREADS=6 : -tb recopie 6",
|
||||
cfg: map[string]string{"NGL": "28", "THREADS": "6", "EXTRA_ARGS": "-Cb 0xff"},
|
||||
si: serveSysInfo{Help: helpRecent},
|
||||
want: withThreads([]string{"-t", "6", "-tb", "6"}, "--parallel", "1", "-ngl", "28", "-Cb", "0xff"),
|
||||
},
|
||||
{
|
||||
name: "-t=5 et --prio-batch dans EXTRA_ARGS : -tb recopie 5",
|
||||
cfg: map[string]string{"NGL": "28", "EXTRA_ARGS": "-t=5 --prio-batch 2"},
|
||||
si: serveSysInfo{Help: helpRecent},
|
||||
want: withThreads([]string{"-tb", "5"}, "--parallel", "1", "-ngl", "28", "-t=5", "--prio-batch", "2"),
|
||||
},
|
||||
{
|
||||
name: "-Crb et -tb dans EXTRA_ARGS : Loki se tait",
|
||||
cfg: map[string]string{"NGL": "28", "EXTRA_ARGS": "-Crb 0-7 -tb 8"},
|
||||
si: serveSysInfo{Help: helpRecent},
|
||||
want: with("--parallel", "1", "-ngl", "28", "-Crb", "0-7", "-tb", "8"),
|
||||
},
|
||||
{
|
||||
name: "conteneur à l'étroit : -t de la sonde, et une note",
|
||||
cfg: map[string]string{"NGL": "28"},
|
||||
si: serveSysInfo{Help: helpRecent, CPU: cpuBudget{N: 4, Engine: 8, Why: "quota cgroup de 4 CPU"}},
|
||||
want: withThreads([]string{"-t", "4"}, "--parallel", "1", "-ngl", "28"), wantNotes: 1,
|
||||
},
|
||||
{
|
||||
name: "THREADS explicite : la sonde se tait",
|
||||
cfg: map[string]string{"NGL": "28", "THREADS": "6"},
|
||||
si: serveSysInfo{Help: helpRecent, CPU: cpuBudget{N: 4, Engine: 8, Why: "quota"}},
|
||||
want: withThreads([]string{"-t", "6"}, "--parallel", "1", "-ngl", "28"),
|
||||
},
|
||||
{
|
||||
name: "masque d'affinité dans EXTRA_ARGS : la sonde se tait",
|
||||
cfg: map[string]string{"NGL": "28", "EXTRA_ARGS": "-Cr 0-3"},
|
||||
si: serveSysInfo{Help: helpRecent, CPU: cpuBudget{N: 4, Engine: 8, Why: "quota"}},
|
||||
want: with("--parallel", "1", "-ngl", "28", "-Cr", "0-3"),
|
||||
},
|
||||
{
|
||||
name: "cache KV séparé K/V",
|
||||
cfg: map[string]string{"NGL": "28", "KV_TYPE": "q8_0", "KV_TYPE_V": "q4_0"},
|
||||
si: serveSysInfo{Help: helpRecent},
|
||||
want: with("--parallel", "1", "-ngl", "28", "-ctk", "q8_0", "-ctv", "q4_0"), wantNotes: 1,
|
||||
},
|
||||
{
|
||||
// Preset MoE typique : experts sur CPU via EXTRA_ARGS, qui pose aussi
|
||||
// --parallel, -ngl et le cache KV. Loki n'ajoute alors ni --parallel ni
|
||||
// -ngl, et EXTRA_ARGS ferme la marche (la dernière occurrence gagne).
|
||||
name: "MoE : EXTRA_ARGS avec -ot, --n-cpu-moe, -ctk q8_0",
|
||||
cfg: map[string]string{"NGL": "999", "PARALLEL": "1",
|
||||
"EXTRA_ARGS": `-ngl 99 --parallel 1 -ot "blk\.(\d+)\.ffn_.*_exps=CPU" --n-cpu-moe 30 -ctk q8_0 -ctv q8_0 -fa on`},
|
||||
si: serveSysInfo{Help: helpRecent},
|
||||
want: with("-ngl", "99", "--parallel", "1", "-ot", `blk\.(\d+)\.ffn_.*_exps=CPU`,
|
||||
"--n-cpu-moe", "30", "-ctk", "q8_0", "-ctv", "q8_0", "-fa", "on"),
|
||||
wantNotes: 1, // q8_0 venu d'EXTRA_ARGS : vu comme celui de KV_TYPE
|
||||
},
|
||||
{
|
||||
name: "--n-gpu-layers=N dans EXTRA_ARGS : pas de -ngl en double",
|
||||
cfg: map[string]string{"NGL": "999", "EXTRA_ARGS": "--n-gpu-layers=40 -np 2"},
|
||||
si: serveSysInfo{Help: helpRecent},
|
||||
want: with("--n-gpu-layers=40", "-np", "2"),
|
||||
},
|
||||
{
|
||||
name: "raisonnement actif : budget illimité par défaut",
|
||||
cfg: map[string]string{"NGL": "28", "REASONING": "on"},
|
||||
si: serveSysInfo{Help: helpRecent},
|
||||
want: with("--parallel", "1", "-ngl", "28", "--reasoning", "on", "--reasoning-budget", "-1"),
|
||||
},
|
||||
{
|
||||
name: "raisonnement auto avec budget choisi",
|
||||
cfg: map[string]string{"NGL": "28", "REASONING": " auto ", "REASONING_BUDGET": "4096"},
|
||||
si: serveSysInfo{Help: helpRecent},
|
||||
want: with("--parallel", "1", "-ngl", "28", "--reasoning", "auto", "--reasoning-budget", "4096"),
|
||||
},
|
||||
{
|
||||
name: "raisonnement interdit, moteur qui connaît le drapeau",
|
||||
cfg: map[string]string{"NGL": "28", "REASONING": "off"},
|
||||
si: serveSysInfo{Help: helpRecent},
|
||||
want: with("--parallel", "1", "-ngl", "28", "--reasoning", "off"),
|
||||
},
|
||||
{
|
||||
name: "raisonnement interdit, vieux moteur : aucun drapeau, une note",
|
||||
cfg: map[string]string{"NGL": "28", "REASONING": "off"},
|
||||
si: serveSysInfo{Help: helpOld},
|
||||
want: with("--parallel", "1", "-ngl", "28"), wantNotes: 1,
|
||||
},
|
||||
{
|
||||
name: "vision et clé d'API, avant EXTRA_ARGS",
|
||||
cfg: map[string]string{"NGL": "28", "EXTRA_ARGS": "-fa on"},
|
||||
si: serveSysInfo{Help: helpRecent, MMProj: "/models/mmproj-F16.gguf", APIKey: "secret"},
|
||||
want: with("--parallel", "1", "-ngl", "28", "--mmproj", "/models/mmproj-F16.gguf",
|
||||
"--api-key", "secret", "-fa", "on"),
|
||||
},
|
||||
{
|
||||
name: "drapeaux de chargement anciens → --load-mode sur moteur récent",
|
||||
cfg: map[string]string{"NGL": "28", "EXTRA_ARGS": "--mlock --no-mmap -fa on"},
|
||||
si: serveSysInfo{Help: helpRecent},
|
||||
want: with("--parallel", "1", "-ngl", "28", "-fa", "on", "--load-mode", "mlock"),
|
||||
},
|
||||
{
|
||||
name: "--load-mode retraduit pour un moteur ancien",
|
||||
cfg: map[string]string{"NGL": "28", "EXTRA_ARGS": "--load-mode none"},
|
||||
si: serveSysInfo{Help: helpOld},
|
||||
want: with("--parallel", "1", "-ngl", "28", "--no-mmap"),
|
||||
},
|
||||
{
|
||||
// dio n'existe pas sur l'ancien moteur : retiré, et dit par une note
|
||||
// RENDUE (buildServeArgs n'écrit rien elle-même).
|
||||
name: "--load-mode dio sur moteur ancien : retiré, une note",
|
||||
cfg: map[string]string{"NGL": "28", "EXTRA_ARGS": "--load-mode dio -fa on"},
|
||||
si: serveSysInfo{Help: helpOld},
|
||||
want: with("--parallel", "1", "-ngl", "28", "-fa", "on"), wantNotes: 1,
|
||||
},
|
||||
{
|
||||
name: "sélection GPU : variables d'environnement, ligne inchangée",
|
||||
cfg: map[string]string{"NGL": "28", "CUDA_VISIBLE_DEVICES": "1,0"},
|
||||
si: serveSysInfo{Help: helpRecent},
|
||||
want: with("--parallel", "1", "-ngl", "28"),
|
||||
wantEnv: map[string]string{"CUDA_VISIBLE_DEVICES": "1,0", "CUDA_DEVICE_ORDER": "PCI_BUS_ID"},
|
||||
},
|
||||
}
|
||||
for _, c := range cases {
|
||||
t.Run(c.name, func(t *testing.T) {
|
||||
c.si.Model = model
|
||||
args, env, notes := buildServeArgs(c.cfg, splitArgs(c.cfg["EXTRA_ARGS"]), bin, c.si)
|
||||
if !reflect.DeepEqual(args, c.want) {
|
||||
t.Fatalf("args\n got %q\nwant %q", args, c.want)
|
||||
}
|
||||
wantEnv := c.wantEnv
|
||||
if wantEnv == nil {
|
||||
wantEnv = map[string]string{}
|
||||
}
|
||||
if !reflect.DeepEqual(env, wantEnv) {
|
||||
t.Fatalf("env = %v, want %v", env, wantEnv)
|
||||
}
|
||||
if len(notes) != c.wantNotes {
|
||||
t.Fatalf("notes = %q, en attendait %d", notes, c.wantNotes)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestHelpCapabilities(t *testing.T) {
|
||||
if !helpSupportsReasoningFlag(helpRecent) || helpSupportsReasoningFlag(helpOld) {
|
||||
t.Fatal("--reasoning mal détecté")
|
||||
}
|
||||
if !helpSupportsLoadMode(helpRecent) || helpSupportsLoadMode(helpOld) {
|
||||
t.Fatal("--load-mode mal détecté")
|
||||
}
|
||||
if !helpSupportsNGLAuto(helpRecent) || helpSupportsNGLAuto(helpOld) {
|
||||
t.Fatal("« -ngl auto » mal détecté")
|
||||
}
|
||||
// Aide illisible = aucune capacité : on garde la forme historique.
|
||||
if helpFitsLayersItself("") || helpSupportsReasoningFlag("") {
|
||||
t.Fatal("une aide vide ne doit promettre aucune capacité")
|
||||
}
|
||||
}
|
||||
|
||||
func TestServeKVTypes(t *testing.T) {
|
||||
for _, c := range []struct {
|
||||
cfg map[string]string
|
||||
k, v string
|
||||
}{
|
||||
{map[string]string{}, "", ""},
|
||||
{map[string]string{"KV_TYPE": "q8_0"}, "q8_0", "q8_0"},
|
||||
{map[string]string{"KV_TYPE": "q8_0", "KV_TYPE_K": "f16"}, "f16", "q8_0"},
|
||||
{map[string]string{"KV_TYPE_V": "q4_0"}, "", "q4_0"},
|
||||
} {
|
||||
if k, v := serveKVTypes(c.cfg); k != c.k || v != c.v {
|
||||
t.Errorf("%v : got %s/%s, want %s/%s", c.cfg, k, v, c.k, c.v)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,576 @@
|
||||
package loki
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"io/fs"
|
||||
"math"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"runtime"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
)
|
||||
|
||||
// Cache de prompts en RAM (--cache-ram) et isolation des travaux annexes.
|
||||
//
|
||||
// llama-server garde en RAM hôte une copie EXACTE des états de slot qu'il quitte
|
||||
// (cache KV, état récurrent, points de reprise, contexte du brouillon MTP) et la
|
||||
// recharge octet pour octet quand la conversation revient. Ce n'est pas une
|
||||
// approximation : la seule différence avec un prefill complet est le bruit de
|
||||
// virgule flottante dû à un découpage des lots différent, exactement comme le
|
||||
// cache_prompt par défaut. Le type du cache KV n'est pas touché, il est copié
|
||||
// tel quel.
|
||||
//
|
||||
// Le défaut du moteur (8 Gio) est trop petit pour une conversation longue d'un
|
||||
// modèle dense : l'état principal (30 à 65 k jetons) ne tient plus à côté de
|
||||
// celui d'un vérificateur ou d'un sous-agent, et la conversation est alors
|
||||
// recalculée en entier — 30 à 80 s sur un 27B. Loki agrandit donc ce cache,
|
||||
// mais seulement quand il sait mesurer le besoin et que la RAM est libre : il ne
|
||||
// descend JAMAIS sous le défaut du moteur, et se tait dès que l'utilisateur a
|
||||
// tranché (CACHE_RAM, -cram dans EXTRA_ARGS, LLAMA_ARG_CACHE_RAM).
|
||||
//
|
||||
// Tout ce qui décide est pur ; probeCacheRAM fait les lectures (GGUF, RAM,
|
||||
// nvidia-smi) une fois, avant buildServeArgs.
|
||||
|
||||
// engineCacheRAMDefault : cache_ram_mib par défaut de llama.cpp (common.h).
|
||||
const engineCacheRAMDefault = 8192
|
||||
|
||||
// cacheArgEnv : la variable qui règle le cache sans drapeau. llama.cpp donne la
|
||||
// priorité à la ligne de commande : un --cache-ram de Loki l'écraserait.
|
||||
var cacheArgEnv = []string{"LLAMA_ARG_CACHE_RAM"}
|
||||
|
||||
// probeCacheRAM complète si avec ce dont cacheRAMAuto a besoin. Chaque lecture
|
||||
// ratée laisse un zéro, et un zéro veut dire « on ne sait pas » : le moteur
|
||||
// garde alors son défaut.
|
||||
func probeCacheRAM(cfg map[string]string, extra []string, si *serveSysInfo) {
|
||||
if si.ArgEnv == nil {
|
||||
si.ArgEnv = map[string]string{}
|
||||
}
|
||||
for _, k := range cacheArgEnv {
|
||||
if v, ok := os.LookupEnv(k); ok && strings.TrimSpace(v) != "" {
|
||||
si.ArgEnv[k] = v
|
||||
}
|
||||
}
|
||||
if si.Model != "" {
|
||||
si.ModelBytes = shardFamilySize(filepath.Dir(si.Model), filepath.Base(si.Model))
|
||||
if g, err := ggufMeta(si.Model); err == nil {
|
||||
si.GGUF = &g
|
||||
}
|
||||
}
|
||||
used, total := ramUsageMB()
|
||||
if total > 0 {
|
||||
si.RAMMiB, si.RAMAvailMiB = int64(total), int64(total-used)
|
||||
}
|
||||
if runtime.GOOS == "linux" {
|
||||
if limit, cur := cgroupMemMiB(os.DirFS("/")); limit > 0 {
|
||||
if si.RAMMiB == 0 || limit < si.RAMMiB {
|
||||
si.RAMMiB = limit
|
||||
}
|
||||
if free := limit - cur; free >= 0 && (si.RAMAvailMiB == 0 || free < si.RAMAvailMiB) {
|
||||
si.RAMAvailMiB = free
|
||||
}
|
||||
}
|
||||
}
|
||||
// Mémoire unifiée : Apple Silicon seulement. Un Mac Intel à carte AMD a une
|
||||
// VRAM à lui, que Loki ne sait pas mesurer (loadModeRisk la tient pour
|
||||
// inconnue, jamais pour nulle).
|
||||
si.UnifiedMem = runtime.GOOS == "darwin" && runtime.GOARCH == "arm64"
|
||||
// macOS, AMD, Vulkan, --device choisi à la main : pas de total VRAM fiable,
|
||||
// donc pas d'agrandissement.
|
||||
if runtime.GOOS == "darwin" || hasAnyFlag(extra, "-dev", "--device") || si.ArgEnv["LLAMA_ARG_DEVICE"] != "" {
|
||||
return
|
||||
}
|
||||
// La sélection du preset d'abord (cudaDeviceEnv l'accompagne toujours de
|
||||
// PCI_BUS_ID), celle de l'environnement ensuite : l'aperçu de l'interface
|
||||
// tourne dans un process où le preset n'a rien posé.
|
||||
cvd, pci := cfg["CUDA_VISIBLE_DEVICES"], true
|
||||
if cvd == "" {
|
||||
cvd, pci = os.Getenv("CUDA_VISIBLE_DEVICES"), os.Getenv("CUDA_DEVICE_ORDER") == "PCI_BUS_ID"
|
||||
}
|
||||
si.VRAMMiB = nvidiaVRAMMiB(cvd, pci, 3*time.Second)
|
||||
}
|
||||
|
||||
// cgroupMemMiB : limite mémoire du conteneur et sa consommation, en Mio (v2,
|
||||
// sinon v1). 0 = pas de limite. Le MemAvailable de /proc/meminfo voit l'hôte
|
||||
// entier : sans ça, un conteneur limité à 16 Go sur une machine de 64 Go se
|
||||
// croirait riche.
|
||||
func cgroupMemMiB(fsys fs.FS) (limit, current int64) {
|
||||
read := func(p string) int64 {
|
||||
b, err := fs.ReadFile(fsys, p)
|
||||
if err != nil {
|
||||
return 0
|
||||
}
|
||||
n, err := strconv.ParseInt(strings.TrimSpace(string(b)), 10, 64)
|
||||
if err != nil || n <= 0 || n >= 1<<60 { // « max » ou la valeur géante du v1 sans limite
|
||||
return 0
|
||||
}
|
||||
return n >> 20
|
||||
}
|
||||
if limit = read("sys/fs/cgroup/memory.max"); limit > 0 {
|
||||
return limit, read("sys/fs/cgroup/memory.current")
|
||||
}
|
||||
if limit = read("sys/fs/cgroup/memory/memory.limit_in_bytes"); limit > 0 {
|
||||
return limit, read("sys/fs/cgroup/memory/memory.usage_in_bytes")
|
||||
}
|
||||
return 0, 0
|
||||
}
|
||||
|
||||
// nvidiaVRAMMiB : VRAM totale des cartes que llama-server verra. Borné dans le
|
||||
// temps comme nvidiaGPUCount ; au moindre doute, 0.
|
||||
func nvidiaVRAMMiB(cvd string, pciOrder bool, timeout time.Duration) int64 {
|
||||
out, ok := smiTotals.get(time.Now, timeout)
|
||||
if !ok {
|
||||
return 0
|
||||
}
|
||||
return vramFromSMI(out, cvd, pciOrder)
|
||||
}
|
||||
|
||||
// nvidiaTotalsQuery : la lecture brute des mémoires totales. Variable pour
|
||||
// les tests.
|
||||
var nvidiaTotalsQuery = func(timeout time.Duration) (string, error) {
|
||||
if !hasTool("nvidia-smi") {
|
||||
return "", exec.ErrNotFound
|
||||
}
|
||||
ctx, cancel := context.WithTimeout(context.Background(), timeout)
|
||||
defer cancel()
|
||||
out, err := hideCmd(exec.CommandContext(ctx, "nvidia-smi", "--query-gpu=index,memory.total",
|
||||
"--format=csv,noheader,nounits")).Output()
|
||||
return string(out), err
|
||||
}
|
||||
|
||||
// smiTotalsCache : la lecture des mémoires totales, partagée quelques
|
||||
// secondes. L'éditeur de preset demande à l'ouverture la taille du cache de
|
||||
// prompts (et celle du second slot) et les avis MoE, en parallèle : chacun
|
||||
// lançait SON nvidia-smi pour une réponse identique — la mémoire totale d'une
|
||||
// carte ne bouge pas. Le verrou tenu pendant la lecture fait attendre les
|
||||
// appels simultanés sur la même ; la sélection des cartes (CUDA_VISIBLE_DEVICES)
|
||||
// s'applique après, sur la sortie brute. Un échec est gardé aussi, le temps du
|
||||
// délai : la même salve ne relance pas trois lectures qui échoueront.
|
||||
type smiTotalsCache struct {
|
||||
mu sync.Mutex
|
||||
exp time.Time
|
||||
out string
|
||||
ok bool
|
||||
}
|
||||
|
||||
const smiTotalsTTL = 10 * time.Second
|
||||
|
||||
var smiTotals smiTotalsCache
|
||||
|
||||
func (c *smiTotalsCache) get(now func() time.Time, timeout time.Duration) (string, bool) {
|
||||
c.mu.Lock()
|
||||
defer c.mu.Unlock()
|
||||
if now().Before(c.exp) {
|
||||
return c.out, c.ok
|
||||
}
|
||||
out, err := nvidiaTotalsQuery(timeout)
|
||||
c.out, c.ok, c.exp = out, err == nil, now().Add(smiTotalsTTL)
|
||||
return c.out, c.ok
|
||||
}
|
||||
|
||||
// vramFromSMI additionne la VRAM des cartes sélectionnées par
|
||||
// CUDA_VISIBLE_DEVICES. Les index de nvidia-smi sont dans l'ordre du bus PCI ;
|
||||
// ceux de CUDA aussi, mais seulement avec CUDA_DEVICE_ORDER=PCI_BUS_ID. Sans
|
||||
// lui, on ne sait pas quelle carte est laquelle : on prend la plus petite autant
|
||||
// de fois qu'il y a de cartes — un minorant, qui ne fait que rendre Loki plus
|
||||
// prudent. Un UUID ou une tranche MIG : on ne sait pas, 0.
|
||||
func vramFromSMI(out, cvd string, pciOrder bool) int64 {
|
||||
byIdx := map[int]int64{}
|
||||
var minMiB int64
|
||||
for _, line := range strings.Split(strings.TrimSpace(out), "\n") {
|
||||
f := strings.Split(line, ",")
|
||||
if len(f) < 2 {
|
||||
continue
|
||||
}
|
||||
i, err1 := strconv.Atoi(strings.TrimSpace(f[0]))
|
||||
m, err2 := strconv.ParseInt(strings.TrimSpace(f[1]), 10, 64)
|
||||
if err1 != nil || err2 != nil || m <= 0 {
|
||||
return 0
|
||||
}
|
||||
byIdx[i] = m
|
||||
if minMiB == 0 || m < minMiB {
|
||||
minMiB = m
|
||||
}
|
||||
}
|
||||
if len(byIdx) == 0 {
|
||||
return 0
|
||||
}
|
||||
cvd = strings.TrimSpace(cvd)
|
||||
if cvd == "" {
|
||||
var sum int64
|
||||
for _, m := range byIdx {
|
||||
sum += m
|
||||
}
|
||||
return sum
|
||||
}
|
||||
var sum int64
|
||||
n := 0
|
||||
for _, d := range strings.Split(cvd, ",") {
|
||||
if d = strings.TrimSpace(d); d == "" {
|
||||
continue
|
||||
}
|
||||
i, err := strconv.Atoi(d)
|
||||
if err != nil {
|
||||
return 0
|
||||
}
|
||||
m, ok := byIdx[i]
|
||||
if !ok {
|
||||
return 0
|
||||
}
|
||||
sum += m
|
||||
n++
|
||||
}
|
||||
if !pciOrder {
|
||||
return minMiB * int64(n)
|
||||
}
|
||||
return sum
|
||||
}
|
||||
|
||||
// kvTypeBytes : octets par élément d'un type de cache (blocs de 32 pour les
|
||||
// types quantifiés de ggml). Inconnu : compté comme f16, l'estimation reste
|
||||
// bornée par le plafond de RAM.
|
||||
func kvTypeBytes(t string) float64 {
|
||||
switch strings.ToLower(strings.TrimSpace(t)) {
|
||||
case "f32":
|
||||
return 4
|
||||
case "q8_0":
|
||||
return 34.0 / 32
|
||||
case "q5_1":
|
||||
return 24.0 / 32
|
||||
case "q5_0":
|
||||
return 22.0 / 32
|
||||
case "q4_1":
|
||||
return 20.0 / 32
|
||||
case "q4_0", "iq4_nl":
|
||||
return 18.0 / 32
|
||||
}
|
||||
return 2
|
||||
}
|
||||
|
||||
// ggufStateBytes estime ce que pèse l'état d'une séquence : octets de cache KV
|
||||
// par jeton (couches d'attention), et octets d'état récurrent (couches SSM d'un
|
||||
// hybride, en f32 comme dans llama.cpp). 0 par jeton = on ne sait pas.
|
||||
func ggufStateBytes(g GGUFInfo, kt, vt string) (perToken float64, recurrent int64) {
|
||||
kl, vl := g.KeyLen, g.ValLen
|
||||
if g.EmbeddingLength > 0 && g.HeadCount > 0 {
|
||||
if kl == 0 {
|
||||
kl = g.EmbeddingLength / g.HeadCount
|
||||
}
|
||||
if vl == 0 {
|
||||
vl = g.EmbeddingLength / g.HeadCount
|
||||
}
|
||||
}
|
||||
if kl == 0 || vl == 0 || g.BlockCount == 0 {
|
||||
return 0, 0
|
||||
}
|
||||
heads, recLayers := 0, 0
|
||||
switch {
|
||||
case len(g.HeadCountKVLayers) > 0:
|
||||
for _, h := range g.HeadCountKVLayers {
|
||||
heads += h
|
||||
if h == 0 && g.Hybrid {
|
||||
recLayers++
|
||||
}
|
||||
}
|
||||
case g.Hybrid && g.FullAttnInterval > 0:
|
||||
attn := g.BlockCount / g.FullAttnInterval
|
||||
heads, recLayers = attn*g.HeadCountKV, g.BlockCount-attn
|
||||
default:
|
||||
heads = g.BlockCount * g.HeadCountKV
|
||||
if heads == 0 && g.Hybrid {
|
||||
recLayers = g.BlockCount // récurrent pur
|
||||
}
|
||||
}
|
||||
perToken = float64(heads) * (float64(kl)*kvTypeBytes(kt) + float64(vl)*kvTypeBytes(vt))
|
||||
if recLayers > 0 {
|
||||
conv := int64(0)
|
||||
if g.SSMConv > 1 {
|
||||
conv = int64(g.SSMConv-1) * int64(g.SSMInner+2*g.SSMGroups*g.SSMState)
|
||||
}
|
||||
recurrent = int64(recLayers) * (conv + int64(g.SSMState)*int64(g.SSMInner)) * 4
|
||||
}
|
||||
return perToken, recurrent
|
||||
}
|
||||
|
||||
// serveCtx : la taille de contexte que le moteur ouvrira (-c d'EXTRA_ARGS, sinon
|
||||
// CTX, sinon le défaut de Loki ; 0 = celle d'entraînement du modèle).
|
||||
func serveCtx(cfg map[string]string, extra []string, g *GGUFInfo) int {
|
||||
s := flagValue(extra, "-c", "--ctx-size")
|
||||
if s == "" {
|
||||
s = strings.TrimSpace(cfg["CTX"])
|
||||
}
|
||||
if s == "" {
|
||||
s = "32768"
|
||||
}
|
||||
n, err := strconv.Atoi(s)
|
||||
if err != nil || n < 0 {
|
||||
return 0
|
||||
}
|
||||
if n == 0 && g != nil {
|
||||
n = g.ContextLength
|
||||
}
|
||||
return n
|
||||
}
|
||||
|
||||
// cpuWeights dit pourquoi des poids (ou le cache KV) vivent en RAM hôte. Le
|
||||
// cache de prompts y ferait alors concurrence au cache de pages des experts
|
||||
// mappés : on n'y touche pas. Vide = tout est sur GPU.
|
||||
func cpuWeights(cfg map[string]string, extra []string, argEnv map[string]string, blocks int) string {
|
||||
ot := flagValues(extra, "-ot", "--override-tensor")
|
||||
if v := argEnv["LLAMA_ARG_OVERRIDE_TENSOR"]; v != "" {
|
||||
ot = append(ot, v)
|
||||
}
|
||||
for _, v := range ot {
|
||||
for _, el := range strings.Split(v, ",") {
|
||||
if _, buft, ok := strings.Cut(el, "="); ok && strings.HasPrefix(strings.ToUpper(strings.TrimSpace(buft)), "CPU") {
|
||||
return "tenseurs sur CPU (-ot)"
|
||||
}
|
||||
}
|
||||
}
|
||||
if hasAnyFlag(extra, "--cpu-moe", "-cmoe") || envTruthy(argEnv["LLAMA_ARG_CPU_MOE"]) {
|
||||
return "experts sur CPU"
|
||||
}
|
||||
for _, n := range []struct{ env, short, long string }{
|
||||
{"LLAMA_ARG_N_CPU_MOE", "-ncmoe", "--n-cpu-moe"},
|
||||
{"LLAMA_ARG_N_CPU_FFN", "-ncffn", "--n-cpu-ffn"},
|
||||
} {
|
||||
for _, v := range append(flagValues(extra, n.short, n.long), argEnv[n.env]) {
|
||||
if v = strings.TrimSpace(v); v != "" && v != "0" {
|
||||
return "couches " + n.long + " sur CPU"
|
||||
}
|
||||
}
|
||||
}
|
||||
if !kvOffloaded(extra, argEnv) {
|
||||
return "cache KV sur CPU"
|
||||
}
|
||||
ngl := flagValue(extra, "-ngl", "--n-gpu-layers", "--gpu-layers")
|
||||
if ngl == "" {
|
||||
ngl = strings.TrimSpace(cfg["NGL"])
|
||||
if strings.EqualFold(ngl, "auto") {
|
||||
ngl = argEnv["LLAMA_ARG_N_GPU_LAYERS"]
|
||||
}
|
||||
}
|
||||
// 999 est la sentinelle « toutes » ; un nombre plus petit que le modèle (sa
|
||||
// couche de sortie comprise) laisse des couches au CPU.
|
||||
if n, err := strconv.Atoi(strings.TrimSpace(ngl)); err == nil && n != 999 && (blocks == 0 || n < blocks+1) {
|
||||
return "couches GPU limitées à " + strconv.Itoa(n)
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// cacheRAMAuto calcule la taille du cache de prompts quand CACHE_RAM est vide
|
||||
// ou « auto ». 0 = garder le défaut du moteur, why dit pourquoi.
|
||||
//
|
||||
// Besoin : 2,5 fois l'état d'une conversation pleine (cache KV à CTX jetons,
|
||||
// plus l'état récurrent et ses points de reprise sur un hybride) — de quoi
|
||||
// garder la conversation principale ET l'état d'un travail annexe. Plafond : 30
|
||||
// % de la RAM (limite du conteneur comprise) et la moitié de ce qui est libre au
|
||||
// lancement. Ce plafond est un réglage, pas un filet : sous Linux, un
|
||||
// dépassement finit en général par l'OOM killer, pas par le repli du moteur.
|
||||
func cacheRAMAuto(cfg map[string]string, extra []string, si serveSysInfo) (mib int64, why string) {
|
||||
switch {
|
||||
case si.VRAMMiB <= 0:
|
||||
return 0, "VRAM NVIDIA inconnue"
|
||||
case si.GGUF == nil:
|
||||
return 0, "métadonnées GGUF illisibles"
|
||||
case si.RAMMiB <= 0 || si.RAMAvailMiB <= 0:
|
||||
return 0, "RAM inconnue"
|
||||
case si.ModelBytes <= 0:
|
||||
return 0, "taille du modèle inconnue"
|
||||
}
|
||||
if r := cpuWeights(cfg, extra, si.ArgEnv, si.GGUF.BlockCount); r != "" {
|
||||
return 0, r
|
||||
}
|
||||
kt, vt, _ := effectiveKVTypes(cfg, extra, si.ArgEnv)
|
||||
perTok, rec := ggufStateBytes(*si.GGUF, kt, vt)
|
||||
ctx := serveCtx(cfg, extra, si.GGUF)
|
||||
if perTok <= 0 || ctx <= 0 {
|
||||
return 0, "taille d'état inconnue"
|
||||
}
|
||||
kvBytes := perTok * float64(ctx)
|
||||
if float64(si.ModelBytes)+kvBytes > 0.9*float64(si.VRAMMiB)*(1<<20) {
|
||||
return 0, "modèle trop gros pour la VRAM : des poids restent en RAM"
|
||||
}
|
||||
state := kvBytes
|
||||
if rec > 0 {
|
||||
// Points de reprise : llama.cpp en garde jusqu'à 32 par séquence ; on en
|
||||
// compte un par tranche de 8 k jetons, estimation grossière mais bornée.
|
||||
state += float64(rec) * float64(min(32, ctx/8192+1)+1)
|
||||
}
|
||||
need := int64(math.Ceil(2.5 * state / (1 << 20)))
|
||||
ceiling := min(si.RAMMiB*30/100, si.RAMAvailMiB/2)
|
||||
if ceiling <= engineCacheRAMDefault {
|
||||
return 0, fmt.Sprintf("RAM libre trop juste (plafond %d Mio)", ceiling)
|
||||
}
|
||||
if need <= engineCacheRAMDefault {
|
||||
return 0, fmt.Sprintf("le défaut suffit (besoin estimé %d Mio)", need)
|
||||
}
|
||||
return min(need, ceiling), fmt.Sprintf("besoin estimé %d Mio, plafond %d Mio", need, ceiling)
|
||||
}
|
||||
|
||||
// cacheRAMSetting lit CACHE_RAM : auto (vide ou « auto »), ou un nombre de Mio
|
||||
// passé tel quel (-1 = sans limite, 0 = cache coupé). ok=false : valeur
|
||||
// illisible.
|
||||
func cacheRAMSetting(cfg map[string]string) (manual string, auto, ok bool) {
|
||||
v := strings.TrimSpace(cfg["CACHE_RAM"])
|
||||
if v == "" || strings.EqualFold(v, "auto") {
|
||||
return "", true, true
|
||||
}
|
||||
if n, err := strconv.Atoi(v); err == nil && n >= -1 {
|
||||
return strconv.Itoa(n), false, true
|
||||
}
|
||||
return "", false, false
|
||||
}
|
||||
|
||||
// cacheRAMArgs : le --cache-ram que Loki ajoute, et ce qu'il en dit.
|
||||
func cacheRAMArgs(cfg map[string]string, extra []string, si serveSysInfo) (args, notes []string) {
|
||||
manual, auto, ok := cacheRAMSetting(cfg)
|
||||
if !ok {
|
||||
return nil, []string{"CACHE_RAM=" + cfg["CACHE_RAM"] + " illisible (un nombre de Mio, -1, 0 ou auto) : ignoré"}
|
||||
}
|
||||
if !strings.Contains(si.Help, "--cache-ram") {
|
||||
if !auto {
|
||||
notes = append(notes, "ce moteur ne connaît pas --cache-ram : CACHE_RAM ignoré")
|
||||
}
|
||||
return nil, notes
|
||||
}
|
||||
if hasAnyFlag(extra, "-cram", "--cache-ram") {
|
||||
return nil, nil
|
||||
}
|
||||
if si.ArgEnv["LLAMA_ARG_CACHE_RAM"] != "" {
|
||||
if !auto {
|
||||
notes = append(notes, "LLAMA_ARG_CACHE_RAM est posée : CACHE_RAM ignoré")
|
||||
}
|
||||
return nil, notes
|
||||
}
|
||||
if !auto {
|
||||
return []string{"--cache-ram", manual}, nil
|
||||
}
|
||||
n, why := cacheRAMAuto(cfg, extra, si)
|
||||
if n <= 0 {
|
||||
return nil, nil
|
||||
}
|
||||
return []string{"--cache-ram", strconv.FormatInt(n, 10)},
|
||||
[]string{fmt.Sprintf("cache de prompts : --cache-ram %d (RAM hôte, copie exacte de l'état, jamais la VRAM ; %s). "+
|
||||
"CACHE_RAM=<Mio> pour fixer, 0 pour couper.", n, why)}
|
||||
}
|
||||
|
||||
// cacheRAMOff : le cache de prompts est-il coupé (0) par l'une des trois
|
||||
// sources ? Sans lui, effacer un slot ne protège rien.
|
||||
func cacheRAMOff(cfg map[string]string, extra []string, argEnv map[string]string) bool {
|
||||
v := flagValue(extra, "-cram", "--cache-ram")
|
||||
if v == "" {
|
||||
v = argEnv["LLAMA_ARG_CACHE_RAM"]
|
||||
}
|
||||
if v == "" {
|
||||
v, _, _ = cacheRAMSetting(cfg)
|
||||
}
|
||||
return strings.TrimSpace(v) == "0"
|
||||
}
|
||||
|
||||
// cacheIsolationOn : CACHE_ISOLATE=off coupe l'effacement du slot après les
|
||||
// travaux annexes (et donc --slot-save-path), sans toucher au code.
|
||||
func cacheIsolationOn(cfg map[string]string) bool {
|
||||
return !strings.EqualFold(strings.TrimSpace(cfg["CACHE_ISOLATE"]), "off")
|
||||
}
|
||||
|
||||
// loopbackHost : le moteur n'écoute que sur la machine.
|
||||
func loopbackHost(h string) bool {
|
||||
switch strings.Trim(strings.TrimSpace(h), "[]") {
|
||||
case "127.0.0.1", "::1", "localhost":
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// slotSaveArgs : --slot-save-path, sans lequel llama-server refuse toute action
|
||||
// sur /slots — l'effacement compris. Mais le drapeau ouvre aussi save et
|
||||
// restore, qui écrivent des Gio sur le disque, à quiconque joint le moteur. On
|
||||
// ne le pose donc que si le moteur est fermé aux autres (boucle locale, ou clé
|
||||
// d'API exigée), que le dossier existe (le moteur refuse de démarrer sinon), et
|
||||
// qu'il servira : à l'isolation (cache actif, CACHE_ISOLATE pas à off), ou à
|
||||
// SLOT_PERSIST accepté (backend_serve_persist.go). Les proxys de Loki refusent
|
||||
// de leur côté tout /slots qui n'est pas une lecture.
|
||||
func slotSaveArgs(cfg map[string]string, extra []string, si serveSysInfo) []string {
|
||||
if si.SlotDir == "" || !strings.Contains(si.Help, "--slot-save-path") || hasAnyFlag(extra, "--slot-save-path") {
|
||||
return nil
|
||||
}
|
||||
persist, _ := slotPersistPlan(cfg, extra, si)
|
||||
if !persist && (!cacheIsolationOn(cfg) || cacheRAMOff(cfg, extra, si.ArgEnv)) {
|
||||
return nil
|
||||
}
|
||||
if !slotSaveSafe(cfg, extra, si) {
|
||||
return nil
|
||||
}
|
||||
return []string{"--slot-save-path", si.SlotDir}
|
||||
}
|
||||
|
||||
// prepareSlotDir crée LOKI_HOME/slots (0700) et le vide : un fichier présent ne
|
||||
// peut venir que d'un appel qui n'aurait pas dû passer. Seule exception, avec
|
||||
// keepPersist (SLOT_PERSIST demandé) : les états que Loki y a lui-même gardés
|
||||
// à la bascule de preset, au nom reconnaissable. Vide = dossier indisponible,
|
||||
// le drapeau ne sera pas posé.
|
||||
func prepareSlotDir(home string, keepPersist bool) string {
|
||||
dir := filepath.Join(home, "slots")
|
||||
if err := os.MkdirAll(dir, 0o700); err != nil {
|
||||
return ""
|
||||
}
|
||||
if st, err := os.Stat(dir); err != nil || !st.IsDir() {
|
||||
return ""
|
||||
}
|
||||
if entries, err := os.ReadDir(dir); err == nil {
|
||||
for _, e := range entries {
|
||||
if e.Type().IsRegular() && !(keepPersist && slotPersistFileRe.MatchString(e.Name())) {
|
||||
_ = os.Remove(filepath.Join(dir, e.Name()))
|
||||
}
|
||||
}
|
||||
}
|
||||
return dir
|
||||
}
|
||||
|
||||
// cacheRAMPreview : la taille que le moteur prendra pour ce preset, pour
|
||||
// l'éditeur. 0 = défaut du moteur (8192 Mio). Même calcul qu'au lancement, mais
|
||||
// sans l'aide du moteur : l'aperçu dit ce que Loki demanderait.
|
||||
func cacheRAMPreview(content string) (mib int64, why string) {
|
||||
cfg := parseEnv(content)
|
||||
if isExternalConfig(cfg) {
|
||||
return 0, "preset externe"
|
||||
}
|
||||
extra := splitArgs(cfg["EXTRA_ARGS"])
|
||||
if v := flagValue(extra, "-cram", "--cache-ram"); v != "" {
|
||||
n, _ := strconv.ParseInt(v, 10, 64)
|
||||
return n, "fixé par EXTRA_ARGS"
|
||||
}
|
||||
if v := strings.TrimSpace(os.Getenv("LLAMA_ARG_CACHE_RAM")); v != "" {
|
||||
n, _ := strconv.ParseInt(v, 10, 64)
|
||||
return n, "fixé par LLAMA_ARG_CACHE_RAM"
|
||||
}
|
||||
manual, auto, ok := cacheRAMSetting(cfg)
|
||||
if !ok {
|
||||
return 0, "CACHE_RAM illisible"
|
||||
}
|
||||
if !auto {
|
||||
n, _ := strconv.ParseInt(manual, 10, 64)
|
||||
return n, "fixé par CACHE_RAM"
|
||||
}
|
||||
si := serveSysInfo{ArgEnv: map[string]string{}}
|
||||
for _, k := range append(append(append([]string{}, serveArgEnv...), fidelityArgEnv...), cacheArgEnv...) {
|
||||
if v, ok := os.LookupEnv(k); ok && v != "" {
|
||||
si.ArgEnv[k] = v
|
||||
}
|
||||
}
|
||||
if m := strings.TrimSpace(cfg["MODEL"]); m != "" {
|
||||
if p, err := resolveServeModelPath(m); err == nil {
|
||||
si.Model = p
|
||||
}
|
||||
}
|
||||
if si.Model == "" {
|
||||
return 0, "modèle introuvable"
|
||||
}
|
||||
probeCacheRAM(cfg, extra, &si)
|
||||
return cacheRAMAuto(cfg, extra, si)
|
||||
}
|
||||
@@ -0,0 +1,319 @@
|
||||
package loki
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"reflect"
|
||||
"strings"
|
||||
"sync"
|
||||
"sync/atomic"
|
||||
"testing"
|
||||
"testing/fstest"
|
||||
"time"
|
||||
)
|
||||
|
||||
const helpCacheRAM = helpRecent + `-cram, --cache-ram N set the maximum cache size in MiB (default: 8192, -1 - no limit, 0 - disable)
|
||||
--slot-save-path PATH path to save slot kv cache (default: disabled)
|
||||
`
|
||||
|
||||
// denseGPU : un 27B dense de 17 Go sur deux cartes de 24 Go, machine de 64 Go.
|
||||
// État : 64 couches × 4 têtes KV × (256+256) × 2 octets = 0,25 Mio par jeton.
|
||||
func denseGPU() serveSysInfo {
|
||||
return serveSysInfo{
|
||||
Help: helpCacheRAM,
|
||||
Model: "/models/dense.gguf",
|
||||
GGUF: &GGUFInfo{Arch: "qwen3", BlockCount: 64, HeadCountKV: 4, KeyLen: 256, ValLen: 256,
|
||||
ContextLength: 262144},
|
||||
ModelBytes: 17 << 30,
|
||||
RAMMiB: 65536, RAMAvailMiB: 60000,
|
||||
VRAMMiB: 2 * 24576,
|
||||
ArgEnv: map[string]string{},
|
||||
}
|
||||
}
|
||||
|
||||
func TestCacheRAMArgs(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
cfg map[string]string
|
||||
extra []string
|
||||
si func(*serveSysInfo)
|
||||
want []string
|
||||
notes int
|
||||
}{
|
||||
{name: "dense sur GPU, 64 Go, 65k : besoin 40 Gio, plafonné à 30 % de la RAM",
|
||||
cfg: map[string]string{"CTX": "65536"}, want: []string{"--cache-ram", "19660"}, notes: 1},
|
||||
{name: "contexte court : le défaut du moteur suffit",
|
||||
cfg: map[string]string{"CTX": "8192"}},
|
||||
{name: "cache q8_0 : l'état pèse deux fois moins, besoin sous le plafond",
|
||||
cfg: map[string]string{"CTX": "32768", "KV_TYPE": "q8_0"}, want: []string{"--cache-ram", "10880"}, notes: 1},
|
||||
{name: "même contexte en f16 : plafonné",
|
||||
cfg: map[string]string{"CTX": "32768"}, want: []string{"--cache-ram", "19660"}, notes: 1},
|
||||
{name: "-c d'EXTRA_ARGS l'emporte sur CTX",
|
||||
cfg: map[string]string{"CTX": "65536"}, extra: []string{"-c", "8192"}},
|
||||
{name: "MoE, experts sur CPU par -ot : jamais touché (pas de 4096)",
|
||||
cfg: map[string]string{"CTX": "65536"}, extra: []string{"-ot", "exps=CPU"}},
|
||||
{name: "-ot mixte avec un élément sur CPU",
|
||||
cfg: map[string]string{"CTX": "65536"}, extra: []string{"-ot", "a=CUDA0,b=CPU"}},
|
||||
{name: "-ot vers une autre carte seulement : tout reste en VRAM",
|
||||
cfg: map[string]string{"CTX": "65536"}, extra: []string{"-ot", "blk\\.6.*=CUDA1"},
|
||||
want: []string{"--cache-ram", "19660"}, notes: 1},
|
||||
{name: "--n-cpu-moe",
|
||||
cfg: map[string]string{"CTX": "65536"}, extra: []string{"--n-cpu-moe", "30"}},
|
||||
{name: "LLAMA_ARG_N_CPU_MOE",
|
||||
cfg: map[string]string{"CTX": "65536"}, si: func(s *serveSysInfo) { s.ArgEnv["LLAMA_ARG_N_CPU_MOE"] = "20" }},
|
||||
{name: "cache KV sur CPU",
|
||||
cfg: map[string]string{"CTX": "65536"}, extra: []string{"-nkvo"}},
|
||||
{name: "NGL plus petit que le modèle",
|
||||
cfg: map[string]string{"CTX": "65536", "NGL": "40"}},
|
||||
{name: "NGL=999 : toutes les couches",
|
||||
cfg: map[string]string{"CTX": "65536", "NGL": "999"}, want: []string{"--cache-ram", "19660"}, notes: 1},
|
||||
{name: "modèle trop gros pour la VRAM",
|
||||
cfg: map[string]string{"CTX": "65536"}, si: func(s *serveSysInfo) { s.ModelBytes = 40 << 30 }},
|
||||
{name: "pas de VRAM connue (macOS, AMD, sans nvidia-smi)",
|
||||
cfg: map[string]string{"CTX": "65536"}, si: func(s *serveSysInfo) { s.VRAMMiB = 0 }},
|
||||
{name: "GGUF illisible",
|
||||
cfg: map[string]string{"CTX": "65536"}, si: func(s *serveSysInfo) { s.GGUF = nil }},
|
||||
{name: "RAM libre trop juste",
|
||||
cfg: map[string]string{"CTX": "65536"}, si: func(s *serveSysInfo) { s.RAMAvailMiB = 12000 }},
|
||||
{name: "CACHE_RAM fixé : passé tel quel",
|
||||
cfg: map[string]string{"CTX": "65536", "CACHE_RAM": "12000"}, want: []string{"--cache-ram", "12000"}},
|
||||
{name: "CACHE_RAM=-1 : sans limite, passé tel quel",
|
||||
cfg: map[string]string{"CACHE_RAM": "-1"}, want: []string{"--cache-ram", "-1"}},
|
||||
{name: "CACHE_RAM=0 : cache coupé",
|
||||
cfg: map[string]string{"CACHE_RAM": "0"}, want: []string{"--cache-ram", "0"}},
|
||||
{name: "CACHE_RAM=auto",
|
||||
cfg: map[string]string{"CTX": "65536", "CACHE_RAM": "auto"}, want: []string{"--cache-ram", "19660"}, notes: 1},
|
||||
{name: "CACHE_RAM illisible : ignoré, et dit",
|
||||
cfg: map[string]string{"CACHE_RAM": "beaucoup"}, notes: 1},
|
||||
{name: "-cram d'EXTRA_ARGS : Loki se tait",
|
||||
cfg: map[string]string{"CTX": "65536", "CACHE_RAM": "12000"}, extra: []string{"-cram", "2048"}},
|
||||
{name: "LLAMA_ARG_CACHE_RAM : la ligne de commande l'écraserait, Loki se tait",
|
||||
cfg: map[string]string{"CTX": "65536"}, si: func(s *serveSysInfo) { s.ArgEnv["LLAMA_ARG_CACHE_RAM"] = "4096" }},
|
||||
{name: "LLAMA_ARG_CACHE_RAM et CACHE_RAM : la variable gagne, et on le dit",
|
||||
cfg: map[string]string{"CACHE_RAM": "12000"}, si: func(s *serveSysInfo) { s.ArgEnv["LLAMA_ARG_CACHE_RAM"] = "4096" }, notes: 1},
|
||||
{name: "moteur sans --cache-ram : rien, même fixé",
|
||||
cfg: map[string]string{"CACHE_RAM": "12000"}, si: func(s *serveSysInfo) { s.Help = helpRecent }, notes: 1},
|
||||
}
|
||||
for _, c := range cases {
|
||||
t.Run(c.name, func(t *testing.T) {
|
||||
si := denseGPU()
|
||||
if c.si != nil {
|
||||
c.si(&si)
|
||||
}
|
||||
got, notes := cacheRAMArgs(c.cfg, c.extra, si)
|
||||
if !reflect.DeepEqual(got, c.want) {
|
||||
t.Fatalf("args = %v, attendu %v (notes %v)", got, c.want, notes)
|
||||
}
|
||||
if len(notes) != c.notes {
|
||||
t.Fatalf("notes = %v, attendu %d", notes, c.notes)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// Hybride (Qwen3.5) : 16 couches d'attention sur 64, état récurrent des 48
|
||||
// autres, compté avec ses points de reprise.
|
||||
func TestCacheRAMAutoHybride(t *testing.T) {
|
||||
si := denseGPU()
|
||||
layers := make([]int, 64)
|
||||
for i := range layers {
|
||||
if (i+1)%4 == 0 {
|
||||
layers[i] = 4
|
||||
}
|
||||
}
|
||||
si.GGUF = &GGUFInfo{Arch: "qwen35", BlockCount: 64, HeadCountKV: 4, HeadCountKVLayers: layers,
|
||||
KeyLen: 256, ValLen: 256, FullAttnInterval: 4, Hybrid: true,
|
||||
SSMConv: 4, SSMInner: 6144, SSMState: 128, SSMGroups: 16}
|
||||
perTok, rec := ggufStateBytes(*si.GGUF, "", "")
|
||||
if perTok != 65536 || rec != 156893184 {
|
||||
t.Fatalf("état = %v o/jeton, %d o récurrents", perTok, rec)
|
||||
}
|
||||
// 4 Gio de KV + 10 états récurrents (9 points de reprise + l'état courant),
|
||||
// × 2,5.
|
||||
n, why := cacheRAMAuto(map[string]string{"CTX": "65536"}, nil, si)
|
||||
if n != 13981 {
|
||||
t.Fatalf("cacheRAMAuto = %d (%s), attendu 13981", n, why)
|
||||
}
|
||||
}
|
||||
|
||||
// Sans key_length, llama.cpp prend n_embd / n_head : nous aussi.
|
||||
func TestGGUFStateBytesReplis(t *testing.T) {
|
||||
g := GGUFInfo{BlockCount: 2, HeadCountKV: 2, EmbeddingLength: 1024, HeadCount: 8}
|
||||
if p, _ := ggufStateBytes(g, "", ""); p != 2*2*(128+128)*2 {
|
||||
t.Fatalf("repli n_embd/n_head : %v", p)
|
||||
}
|
||||
if p, _ := ggufStateBytes(GGUFInfo{BlockCount: 2, HeadCountKV: 2}, "", ""); p != 0 {
|
||||
t.Fatalf("dimensions inconnues : %v, attendu 0", p)
|
||||
}
|
||||
}
|
||||
|
||||
func TestVRAMFromSMI(t *testing.T) {
|
||||
const out = "0, 24576\n1, 16384\n"
|
||||
cases := []struct {
|
||||
cvd string
|
||||
pci bool
|
||||
want int64
|
||||
}{
|
||||
{"", false, 40960},
|
||||
{"1", true, 16384},
|
||||
{"0,1", true, 40960},
|
||||
{"1", false, 16384}, // ordre inconnu : la plus petite carte
|
||||
{"0", false, 16384},
|
||||
{"GPU-1234", true, 0},
|
||||
{"2", true, 0},
|
||||
}
|
||||
for _, c := range cases {
|
||||
if got := vramFromSMI(out, c.cvd, c.pci); got != c.want {
|
||||
t.Errorf("vramFromSMI(%q, %v) = %d, attendu %d", c.cvd, c.pci, got, c.want)
|
||||
}
|
||||
}
|
||||
if got := vramFromSMI("[N/A]\n", "", false); got != 0 {
|
||||
t.Errorf("sortie illisible : %d", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCgroupMemMiB(t *testing.T) {
|
||||
v2 := fstest.MapFS{
|
||||
"sys/fs/cgroup/memory.max": {Data: []byte("17179869184\n")},
|
||||
"sys/fs/cgroup/memory.current": {Data: []byte("4294967296\n")},
|
||||
}
|
||||
if l, c := cgroupMemMiB(v2); l != 16384 || c != 4096 {
|
||||
t.Fatalf("v2 = %d/%d", l, c)
|
||||
}
|
||||
if l, _ := cgroupMemMiB(fstest.MapFS{"sys/fs/cgroup/memory.max": {Data: []byte("max\n")}}); l != 0 {
|
||||
t.Fatalf("v2 sans limite = %d", l)
|
||||
}
|
||||
v1 := fstest.MapFS{"sys/fs/cgroup/memory/memory.limit_in_bytes": {Data: []byte("9223372036854771712\n")}}
|
||||
if l, _ := cgroupMemMiB(v1); l != 0 {
|
||||
t.Fatalf("v1 sans limite = %d", l)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSlotSaveArgs(t *testing.T) {
|
||||
want := []string{"--slot-save-path", "/data/slots"}
|
||||
cases := []struct {
|
||||
name string
|
||||
cfg map[string]string
|
||||
extra []string
|
||||
si func(*serveSysInfo)
|
||||
want []string
|
||||
}{
|
||||
{name: "boucle locale", cfg: map[string]string{"HOST": "127.0.0.1"}, want: want},
|
||||
{name: "--host ::1 dans EXTRA_ARGS", extra: []string{"--host", "::1"}, want: want},
|
||||
{name: "0.0.0.0 sans clé : ouvert à tous, pas de drapeau", cfg: map[string]string{}},
|
||||
{name: "0.0.0.0 avec clé d'API", cfg: map[string]string{"HOST": "0.0.0.0"},
|
||||
si: func(s *serveSysInfo) { s.APIKey = "k" }, want: want},
|
||||
{name: "EXTRA_ARGS le pose déjà", cfg: map[string]string{"HOST": "127.0.0.1"},
|
||||
extra: []string{"--slot-save-path", "/ailleurs"}},
|
||||
{name: "CACHE_ISOLATE=off", cfg: map[string]string{"HOST": "127.0.0.1", "CACHE_ISOLATE": "off"}},
|
||||
{name: "cache coupé : rien à isoler", cfg: map[string]string{"HOST": "127.0.0.1", "CACHE_RAM": "0"}},
|
||||
{name: "cache coupé par EXTRA_ARGS", cfg: map[string]string{"HOST": "127.0.0.1"}, extra: []string{"-cram", "0"}},
|
||||
{name: "moteur sans le drapeau", cfg: map[string]string{"HOST": "127.0.0.1"},
|
||||
si: func(s *serveSysInfo) { s.Help = helpRecent }},
|
||||
{name: "dossier indisponible", cfg: map[string]string{"HOST": "127.0.0.1"},
|
||||
si: func(s *serveSysInfo) { s.SlotDir = "" }},
|
||||
}
|
||||
for _, c := range cases {
|
||||
t.Run(c.name, func(t *testing.T) {
|
||||
si := serveSysInfo{Help: helpCacheRAM, SlotDir: "/data/slots", ArgEnv: map[string]string{}}
|
||||
if c.si != nil {
|
||||
c.si(&si)
|
||||
}
|
||||
if got := slotSaveArgs(c.cfg, c.extra, si); !reflect.DeepEqual(got, c.want) {
|
||||
t.Fatalf("slotSaveArgs = %v, attendu %v", got, c.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// Place dans la ligne complète : après --api-key, avant EXTRA_ARGS qui garde le
|
||||
// dernier mot. Sans aide qui les connaisse, la ligne historique est intacte
|
||||
// (TestBuildServeArgs).
|
||||
func TestBuildServeArgsCacheRAM(t *testing.T) {
|
||||
si := denseGPU()
|
||||
si.APIKey, si.SlotDir = "k", "/data/slots"
|
||||
args, _, _ := buildServeArgs(map[string]string{"CTX": "65536", "NGL": "999"}, []string{"--jinja"}, "/bin/llama-server", si)
|
||||
got := strings.Join(args, " ")
|
||||
if !strings.HasSuffix(got, "--api-key k --cache-ram 19660 --slot-save-path /data/slots --jinja") {
|
||||
t.Fatalf("ligne = %s", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPrepareSlotDir(t *testing.T) {
|
||||
home := t.TempDir()
|
||||
dir := prepareSlotDir(home, false)
|
||||
if dir != filepath.Join(home, "slots") {
|
||||
t.Fatalf("dossier = %q", dir)
|
||||
}
|
||||
stray := filepath.Join(dir, "slot0.bin")
|
||||
if err := os.WriteFile(stray, []byte("x"), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if prepareSlotDir(home, false) == "" {
|
||||
t.Fatal("second appel")
|
||||
}
|
||||
if _, err := os.Stat(stray); !os.IsNotExist(err) {
|
||||
t.Fatal("un fichier laissé dans slots/ doit être purgé au lancement")
|
||||
}
|
||||
// Un fichier à la place du dossier : pas de drapeau, plutôt qu'un moteur
|
||||
// qui refuse de démarrer.
|
||||
bad := t.TempDir()
|
||||
if err := os.WriteFile(filepath.Join(bad, "slots"), nil, 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if got := prepareSlotDir(bad, false); got != "" {
|
||||
t.Fatalf("slots est un fichier : %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
// L'éditeur demande en parallèle le cache de prompts, le second slot et les
|
||||
// avis MoE : une seule lecture nvidia-smi pour tous, la sélection des cartes
|
||||
// appliquée ensuite. Après le délai, une lecture neuve ; un échec n'est pas
|
||||
// relancé dans la même salve.
|
||||
func TestNvidiaVRAMShared(t *testing.T) {
|
||||
var calls atomic.Int32
|
||||
fail := false
|
||||
prevQ := nvidiaTotalsQuery
|
||||
nvidiaTotalsQuery = func(time.Duration) (string, error) {
|
||||
calls.Add(1)
|
||||
time.Sleep(20 * time.Millisecond) // les appels simultanés attendent la même lecture
|
||||
if fail {
|
||||
return "", errors.New("exit status 9")
|
||||
}
|
||||
return "0, 16311\n1, 12288\n", nil
|
||||
}
|
||||
t.Cleanup(func() {
|
||||
nvidiaTotalsQuery = prevQ
|
||||
smiTotals.mu.Lock()
|
||||
smiTotals.exp = time.Time{} // les tests suivants relisent nvidia-smi
|
||||
smiTotals.mu.Unlock()
|
||||
})
|
||||
expire := func() {
|
||||
smiTotals.mu.Lock()
|
||||
smiTotals.exp = time.Time{}
|
||||
smiTotals.mu.Unlock()
|
||||
}
|
||||
expire()
|
||||
var wg sync.WaitGroup
|
||||
got := make([]int64, 3)
|
||||
for i, cvd := range []string{"", "0", "1"} {
|
||||
wg.Add(1)
|
||||
go func() { defer wg.Done(); got[i] = nvidiaVRAMMiB(cvd, true, time.Second) }()
|
||||
}
|
||||
wg.Wait()
|
||||
if n := calls.Load(); n != 1 {
|
||||
t.Errorf("%d lectures nvidia-smi pour trois demandes simultanées", n)
|
||||
}
|
||||
if !reflect.DeepEqual(got, []int64{16311 + 12288, 16311, 12288}) {
|
||||
t.Errorf("sélection des cartes : %v", got)
|
||||
}
|
||||
expire()
|
||||
fail = true
|
||||
if v := nvidiaVRAMMiB("", true, time.Second); v != 0 {
|
||||
t.Errorf("échec : %d, attendu 0", v)
|
||||
}
|
||||
if v := nvidiaVRAMMiB("", true, time.Second); v != 0 || calls.Load() != 2 {
|
||||
t.Errorf("échec relancé dans la même salve : %d lectures", calls.Load())
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,332 @@
|
||||
package loki
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync"
|
||||
)
|
||||
|
||||
// Points de reprise des modèles hybrides (--ctx-checkpoints,
|
||||
// --checkpoint-min-step) et conservation du raisonnement (--reasoning-preserve).
|
||||
//
|
||||
// Un modèle hybride (couches récurrentes : Qwen3.5/3.6, Qwen3-Next…) ne sait pas
|
||||
// rembobiner son état récurrent comme un cache KV : llama-server en garde des
|
||||
// copies exactes, les points de reprise, et repart du plus proche quand la
|
||||
// conversation diverge ou revient du cache de prompts. Avant b10864 (PR #28302),
|
||||
// le moteur effaçait un point trop proche du précédent (moins de 8192 jetons)
|
||||
// MÊME quand la liste avait de la place : la reprise retombait jusqu'à ~8 k
|
||||
// jetons en arrière, recalculés à chaque tour d'outil. Rien de ce que voit le
|
||||
// modèle ne change ici — un point de reprise est une copie octet pour octet de
|
||||
// l'état — seul le temps de prefill, et la RAM hôte que ces copies occupent.
|
||||
//
|
||||
// Loki ne touche à rien sans savoir : le build n'est cru que pour un moteur
|
||||
// officiel (voir engineBuildTrusted), le drapeau n'est posé que si l'aide le
|
||||
// connaît, et l'utilisateur garde le dernier mot (clés du preset, EXTRA_ARGS,
|
||||
// LLAMA_ARG_*). Tout ce qui décide est pur ; cmdServe fait les lectures.
|
||||
|
||||
const (
|
||||
// engineMinRecommended : premier build où l'espacement minimal n'évince un
|
||||
// point de reprise que lorsque la liste est pleine (PR #28302, 5d806aa).
|
||||
engineMinRecommended = 10864
|
||||
// engineBuildFloor : en dessous, le numéro ne prouve rien. Un llama.cpp
|
||||
// compilé depuis un clone superficiel (--depth=1) annonce « build 1 », un
|
||||
// fork numérote à sa façon : traiter ça comme un moteur ancien poserait un
|
||||
// avis faux et un drapeau inutile sur un moteur récent.
|
||||
engineBuildFloor = 5000
|
||||
// engineCkptDefault : n_ctx_checkpoints par défaut de llama.cpp (common.h).
|
||||
engineCkptDefault = 32
|
||||
// ckptAutoMinStep : espacement posé d'office sur un moteur ancien. Plus serré
|
||||
// que les 8192 du moteur, il garde des points qu'il aurait jetés ; jamais 0,
|
||||
// qui laisserait l'éviction FIFO jeter le point du début de la conversation
|
||||
// dans une longue boucle d'outils.
|
||||
ckptAutoMinStep = 2048
|
||||
// ckptFallbackMiB : poids d'un point de reprise quand le GGUF ne permet pas de
|
||||
// le calculer (70 à 200 Mio mesurés sur les hybrides Qwen) — on prend le haut.
|
||||
ckptFallbackMiB = 200
|
||||
)
|
||||
|
||||
// ckptArgEnv : les variables qui règlent la même chose sans drapeau. llama.cpp
|
||||
// les lit avant la ligne de commande : un drapeau de Loki les écraserait.
|
||||
var ckptArgEnv = []string{"LLAMA_ARG_CTX_CHECKPOINTS", "LLAMA_ARG_CHECKPOINT_MIN_SPACING_NT",
|
||||
"LLAMA_ARG_REASONING_PRESERVE", "LLAMA_ARG_CHAT_TEMPLATE_KWARGS"}
|
||||
|
||||
// probeCkptEnv complète ArgEnv avec ckptArgEnv.
|
||||
func probeCkptEnv(si *serveSysInfo) {
|
||||
if si.ArgEnv == nil {
|
||||
si.ArgEnv = map[string]string{}
|
||||
}
|
||||
for _, k := range ckptArgEnv {
|
||||
if v, ok := os.LookupEnv(k); ok && strings.TrimSpace(v) != "" {
|
||||
si.ArgEnv[k] = v
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// hybridArchs : architectures hybrides connues, au cas où le GGUF ne porterait
|
||||
// pas de clé ssm.* lisible.
|
||||
var hybridArchs = map[string]bool{"qwen35": true, "qwen35moe": true, "qwen3next": true, "qwen4exp": true}
|
||||
|
||||
// ggufHybrid : le modèle a-t-il des couches récurrentes ? Jamais deviné d'après
|
||||
// le nom du fichier : sans métadonnées, on répond non.
|
||||
func ggufHybrid(g *GGUFInfo) bool {
|
||||
return g != nil && (g.Hybrid || hybridArchs[strings.ToLower(g.Arch)])
|
||||
}
|
||||
|
||||
// engineSource classe un moteur comme le panneau Moteur : celui de l'image,
|
||||
// un moteur téléchargé par Loki depuis l'image officielle, le précompilé des
|
||||
// releases officielles, ou tout le reste (« custom » : compilé, fork, chemin
|
||||
// à la main).
|
||||
func engineSource(bin string) string {
|
||||
imageBin := providedEngineBin()
|
||||
switch {
|
||||
case imageBin != "" && samePath(bin, imageBin):
|
||||
return "image"
|
||||
case engineOwns(bin):
|
||||
return "downloaded"
|
||||
case prebuiltOwns(bin):
|
||||
return "prebuilt"
|
||||
}
|
||||
return "custom"
|
||||
}
|
||||
|
||||
// engineBuildTrust : le build n'est cru que pour un moteur publié par ggml-org,
|
||||
// dont la CI numérote sur l'historique complet, et s'il est vraisemblable. 0 =
|
||||
// inconnu : ni avis, ni drapeau automatique.
|
||||
func engineBuildTrust(source string, build int) int {
|
||||
if source == "custom" || build < engineBuildFloor {
|
||||
return 0
|
||||
}
|
||||
return build
|
||||
}
|
||||
|
||||
// engineBuildTrusted : build du moteur, ou 0 s'il n'est pas digne de confiance.
|
||||
// Le « --version » est gardé par binaire, comme l'aide (binHelp), et la clé
|
||||
// porte la taille et la date du fichier : un moteur recompilé au même chemin
|
||||
// est relu.
|
||||
func engineBuildTrusted(bin string) int {
|
||||
src := engineSource(bin)
|
||||
if src == "custom" {
|
||||
return 0 // pas la peine de lancer le binaire
|
||||
}
|
||||
return engineBuildTrust(src, engineBuildCached(bin))
|
||||
}
|
||||
|
||||
var (
|
||||
buildMu sync.Mutex
|
||||
buildCache = map[string]int{}
|
||||
)
|
||||
|
||||
func engineBuildCached(bin string) int {
|
||||
st, err := os.Stat(bin)
|
||||
if err != nil {
|
||||
return 0
|
||||
}
|
||||
key := fmt.Sprintf("%s|%d|%d", bin, st.Size(), st.ModTime().UnixNano())
|
||||
buildMu.Lock()
|
||||
defer buildMu.Unlock()
|
||||
if b, ok := buildCache[key]; ok {
|
||||
return b
|
||||
}
|
||||
b, _ := engineBuildOf(bin)
|
||||
buildCache[key] = b
|
||||
return b
|
||||
}
|
||||
|
||||
// engineBuildNotice : l'avis du panneau Moteur et du journal de lancement pour
|
||||
// un build trop ancien. Vide = rien à dire (à jour, ou build inconnu).
|
||||
func engineBuildNotice(build int) string {
|
||||
if build <= 0 || build >= engineMinRecommended {
|
||||
return ""
|
||||
}
|
||||
return fmt.Sprintf("moteur b%d : les points de reprise des modèles hybrides (Qwen3.5/3.6, Qwen3-Next…) y sont "+
|
||||
"évincés trop tôt — jusqu'à ~8 k jetons recalculés à chaque reprise. Corrigé à partir de b%d.",
|
||||
build, engineMinRecommended)
|
||||
}
|
||||
|
||||
// ckptCount : nombre de points de reprise par slot que le moteur gardera
|
||||
// (CTX_CHECKPOINTS valide, sinon EXTRA_ARGS, sinon la variable, sinon le
|
||||
// défaut). -1 = illisible ; un CTX_CHECKPOINTS illisible est ignoré (voir
|
||||
// ckptArgs), le moteur garde alors son défaut.
|
||||
func ckptCount(cfg map[string]string, extra []string, argEnv map[string]string) int {
|
||||
v := flagValue(extra, "-ctxcp", "--ctx-checkpoints", "--swa-checkpoints")
|
||||
if v == "" {
|
||||
v = argEnv["LLAMA_ARG_CTX_CHECKPOINTS"]
|
||||
}
|
||||
if v == "" {
|
||||
if n, err := strconv.Atoi(strings.TrimSpace(cfg["CTX_CHECKPOINTS"])); err == nil && n >= 0 {
|
||||
return n
|
||||
}
|
||||
return engineCkptDefault
|
||||
}
|
||||
n, err := strconv.Atoi(strings.TrimSpace(v))
|
||||
if err != nil || n < 0 {
|
||||
return -1
|
||||
}
|
||||
return n
|
||||
}
|
||||
|
||||
// ckptSlots : nombre de slots que le moteur ouvrira, chacun avec sa propre liste
|
||||
// de points de reprise. --parallel d'EXTRA_ARGS, sinon PARALLEL, sinon 1 (Loki
|
||||
// pose toujours --parallel, voir buildServeArgs). Illisible ou « auto » (-1) :
|
||||
// 4, ce que choisissent les moteurs récents — on compte large.
|
||||
func ckptSlots(cfg map[string]string, extra []string) int {
|
||||
v := flagValue(extra, "-np", "--parallel")
|
||||
if v == "" {
|
||||
v = strings.TrimSpace(cfg["PARALLEL"])
|
||||
}
|
||||
if v == "" {
|
||||
return 1
|
||||
}
|
||||
if n, err := strconv.Atoi(strings.TrimSpace(v)); err == nil && n > 0 {
|
||||
return n
|
||||
}
|
||||
return 4
|
||||
}
|
||||
|
||||
// ckptMiB : poids d'un point de reprise d'un hybride (état récurrent en f32).
|
||||
func ckptMiB(g *GGUFInfo) int64 {
|
||||
if g != nil {
|
||||
if _, rec := ggufStateBytes(*g, "", ""); rec > 0 {
|
||||
return (rec + (1 << 20) - 1) >> 20
|
||||
}
|
||||
}
|
||||
return ckptFallbackMiB
|
||||
}
|
||||
|
||||
// ramTight : le modèle occupe plus de 70 % de la RAM (mmap d'un gros MoE dont
|
||||
// les experts vivent dans le cache de pages). Chaque Mio de points de reprise
|
||||
// y est pris aux poids : plus de lectures disque, décodage plus lent.
|
||||
func ramTight(si serveSysInfo) bool {
|
||||
return si.RAMMiB > 0 && si.ModelBytes > 0 && float64(si.ModelBytes) > 0.7*float64(si.RAMMiB)*(1<<20)
|
||||
}
|
||||
|
||||
// ckptArgs : --ctx-checkpoints et --checkpoint-min-step que Loki ajoute, et ce
|
||||
// qu'il en dit.
|
||||
//
|
||||
// CTX_CHECKPOINTS=<n> → --ctx-checkpoints n (opt-in ; jamais de défaut relevé)
|
||||
// CKPT_MIN_STEP=<n> → --checkpoint-min-step n (n > 0)
|
||||
// clé absente → --checkpoint-min-step 2048 seulement sur un hybride,
|
||||
// moteur officiel < b10864, RAM non serrée
|
||||
//
|
||||
// Chaque drapeau a sa propre garde : un --ctx-checkpoints écrit à la main ne
|
||||
// coupe pas l'atténuation de l'espacement, qui est une autre question.
|
||||
func ckptArgs(cfg map[string]string, extra []string, si serveSysInfo) (args, notes []string) {
|
||||
hybrid := ggufHybrid(si.GGUF)
|
||||
|
||||
if v := strings.TrimSpace(cfg["CTX_CHECKPOINTS"]); v != "" {
|
||||
n, err := strconv.Atoi(v)
|
||||
switch {
|
||||
case err != nil || n < 0:
|
||||
notes = append(notes, "CTX_CHECKPOINTS="+v+" illisible (un nombre de points de reprise par slot, ≥ 0) : ignoré")
|
||||
case !strings.Contains(si.Help, "--ctx-checkpoints"):
|
||||
notes = append(notes, "ce moteur ne connaît pas --ctx-checkpoints : CTX_CHECKPOINTS ignoré")
|
||||
case hasAnyFlag(extra, "-ctxcp", "--ctx-checkpoints", "--swa-checkpoints"):
|
||||
case si.ArgEnv["LLAMA_ARG_CTX_CHECKPOINTS"] != "":
|
||||
notes = append(notes, "LLAMA_ARG_CTX_CHECKPOINTS est posée : CTX_CHECKPOINTS ignoré")
|
||||
default:
|
||||
args = append(args, "--ctx-checkpoints", strconv.Itoa(n))
|
||||
}
|
||||
} else if hybrid && ramTight(si) && strings.Contains(si.Help, "--ctx-checkpoints") &&
|
||||
!hasAnyFlag(extra, "-ctxcp", "--ctx-checkpoints", "--swa-checkpoints") && si.ArgEnv["LLAMA_ARG_CTX_CHECKPOINTS"] == "" {
|
||||
// Un conseil, pas un réglage : moins de points de reprise, c'est plus de
|
||||
// prefill, et seul l'utilisateur peut mesurer lequel des deux coûte le plus.
|
||||
notes = append(notes, fmt.Sprintf("modèle hybride de %d Gio pour %d Gio de RAM : jusqu'à %d points de reprise "+
|
||||
"(~%d Mio chacun) prennent sur le cache de pages des poids. CTX_CHECKPOINTS=8 à 16 si le débit baisse.",
|
||||
si.ModelBytes>>30, si.RAMMiB>>10, engineCkptDefault, ckptMiB(si.GGUF)))
|
||||
}
|
||||
|
||||
stepUser := hasAnyFlag(extra, "-cms", "--checkpoint-min-step") || si.ArgEnv["LLAMA_ARG_CHECKPOINT_MIN_SPACING_NT"] != ""
|
||||
if v := strings.TrimSpace(cfg["CKPT_MIN_STEP"]); v != "" {
|
||||
n, err := strconv.Atoi(v)
|
||||
switch {
|
||||
case err != nil || n <= 0:
|
||||
// 0 = « aucun minimum » pour le moteur : la liste se remplit de points
|
||||
// serrés et l'éviction FIFO finit par jeter celui du début de la
|
||||
// conversation. On ne le pose pas, sans se taire pour autant.
|
||||
notes = append(notes, "CKPT_MIN_STEP="+v+" refusé (un nombre de jetons > 0 ; 0 laisserait le moteur jeter "+
|
||||
"le point de reprise du début de la conversation) : défaut du moteur")
|
||||
case !strings.Contains(si.Help, "--checkpoint-min-step"):
|
||||
notes = append(notes, "ce moteur ne connaît pas --checkpoint-min-step : CKPT_MIN_STEP ignoré")
|
||||
case hasAnyFlag(extra, "-cms", "--checkpoint-min-step"):
|
||||
case si.ArgEnv["LLAMA_ARG_CHECKPOINT_MIN_SPACING_NT"] != "":
|
||||
notes = append(notes, "LLAMA_ARG_CHECKPOINT_MIN_SPACING_NT est posée : CKPT_MIN_STEP ignoré")
|
||||
default:
|
||||
args = append(args, "--checkpoint-min-step", strconv.Itoa(n))
|
||||
}
|
||||
return args, notes
|
||||
}
|
||||
|
||||
notice := engineBuildNotice(si.EngineBuild)
|
||||
if !hybrid || notice == "" {
|
||||
return args, notes
|
||||
}
|
||||
n := ckptCount(cfg, extra, si.ArgEnv)
|
||||
switch {
|
||||
case stepUser || !strings.Contains(si.Help, "--checkpoint-min-step"):
|
||||
return args, append(notes, notice)
|
||||
case n <= 0:
|
||||
return args, append(notes, notice) // points de reprise coupés : l'espacement ne sert à rien
|
||||
case si.RAMMiB <= 0 || si.ModelBytes <= 0 || ramTight(si):
|
||||
return args, append(notes, notice+" Pas d'espacement resserré d'office : RAM inconnue ou déjà prise par le modèle "+
|
||||
"(CKPT_MIN_STEP=2048 pour l'imposer).")
|
||||
}
|
||||
// La liste est PAR SLOT : avec PARALLEL=4, quatre listes se remplissent.
|
||||
total := n * ckptSlots(cfg, extra)
|
||||
worst := int64(total) * ckptMiB(si.GGUF)
|
||||
if si.RAMAvailMiB > 0 && worst > si.RAMAvailMiB/4 {
|
||||
return args, append(notes, fmt.Sprintf("%s Pas d'espacement resserré d'office : %d points de reprise (~%d Mio) "+
|
||||
"dépasseraient le quart de la RAM libre (CKPT_MIN_STEP=2048 pour l'imposer).", notice, total, worst))
|
||||
}
|
||||
args = append(args, "--checkpoint-min-step", strconv.Itoa(ckptAutoMinStep))
|
||||
return args, append(notes, fmt.Sprintf("%s → --checkpoint-min-step %d : points de reprise exacts plus rapprochés, "+
|
||||
"~%d Mio de RAM hôte au plus. CKPT_MIN_STEP=<jetons> pour fixer.", notice, ckptAutoMinStep, worst))
|
||||
}
|
||||
|
||||
// reasoningPreserveArgs : REASONING_PRESERVE=on|off → --reasoning-preserve /
|
||||
// --no-reasoning-preserve, seulement si la clé est posée.
|
||||
//
|
||||
// Le compromis, qui explique pourquoi Loki ne choisit pas : depuis b10763 le
|
||||
// moteur active preserve_reasoning par défaut, et le gabarit garde alors la
|
||||
// réflexion de TOUS les tours passés. Mais sans REASONING_ECHO, Loki ne renvoie
|
||||
// pas le raisonnement des tours passés : un gabarit qui ne conserve rien de
|
||||
// lui-même (Qwen3.6) rend alors des blocs de réflexion vides, là où « off » rend
|
||||
// l'historique des anciens moteurs. À l'inverse, un gabarit qui conserve déjà de
|
||||
// lui-même (Qwen3.8) changerait de rendu avec « off ». Aucun choix global n'est
|
||||
// neutre pour tous les modèles : vide = défaut du moteur, inchangé.
|
||||
//
|
||||
// Avec REASONING_ECHO=on (llm_reasoning_echo.go), les deux clés se complètent
|
||||
// sans se recouvrir : l'écho fournit le raisonnement, cette clé décide si le
|
||||
// gabarit le garde au-delà du tour en cours. « on » donne alors un préfixe
|
||||
// stable d'un message utilisateur à l'autre, au prix de plus de contexte ; le
|
||||
// comptage de Loki suit le verdict de la sonde de gabarit (preservesHistory),
|
||||
// pas cette clé, car un gabarit sans le réglage (Qwen3.5) l'ignore.
|
||||
func reasoningPreserveArgs(cfg map[string]string, extra []string, si serveSysInfo) (args, notes []string) {
|
||||
v := strings.ToLower(strings.TrimSpace(cfg["REASONING_PRESERVE"]))
|
||||
if v == "" {
|
||||
return nil, nil
|
||||
}
|
||||
flag := ""
|
||||
switch v {
|
||||
case "on":
|
||||
flag = "--reasoning-preserve"
|
||||
case "off":
|
||||
flag = "--no-reasoning-preserve"
|
||||
default:
|
||||
return nil, []string{"REASONING_PRESERVE=" + cfg["REASONING_PRESERVE"] + " illisible (on ou off) : ignoré"}
|
||||
}
|
||||
switch {
|
||||
case !strings.Contains(si.Help, "--no-reasoning-preserve"):
|
||||
return nil, []string{"ce moteur ne connaît pas --reasoning-preserve : REASONING_PRESERVE ignoré"}
|
||||
case hasAnyFlag(extra, "--reasoning-preserve", "--no-reasoning-preserve"):
|
||||
return nil, nil
|
||||
case strings.Contains(strings.Join(flagValues(extra, "--chat-template-kwargs"), " "), "preserve_reasoning"):
|
||||
return nil, []string{"--chat-template-kwargs fixe déjà preserve_reasoning : REASONING_PRESERVE ignoré"}
|
||||
case si.ArgEnv["LLAMA_ARG_REASONING_PRESERVE"] != "":
|
||||
return nil, []string{"LLAMA_ARG_REASONING_PRESERVE est posée : REASONING_PRESERVE ignoré"}
|
||||
case strings.Contains(si.ArgEnv["LLAMA_ARG_CHAT_TEMPLATE_KWARGS"], "preserve_reasoning"):
|
||||
return nil, []string{"LLAMA_ARG_CHAT_TEMPLATE_KWARGS fixe déjà preserve_reasoning : REASONING_PRESERVE ignoré"}
|
||||
}
|
||||
return []string{flag}, nil
|
||||
}
|
||||
@@ -0,0 +1,247 @@
|
||||
package loki
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"reflect"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
const helpCkpt = helpRecent + `-ctxcp, --ctx-checkpoints, --swa-checkpoints N
|
||||
max number of context checkpoints to create per slot (default: 32)
|
||||
-cms, --checkpoint-min-step N minimum spacing between context checkpoints in tokens (default: 8192, 0 = no minimum)
|
||||
--reasoning-preserve, --no-reasoning-preserve
|
||||
preserve reasoning trace in the full history, not just the last assistant message (default: enabled)
|
||||
`
|
||||
|
||||
// hybridOld : un Qwen3.6 27B hybride de 17 Go sur un moteur officiel b10678,
|
||||
// machine de 64 Go. État récurrent : 48 couches × (3 × 10240 + 128 × 6144) × 4
|
||||
// octets ≈ 150 Mio par point de reprise.
|
||||
func hybridOld() serveSysInfo {
|
||||
return serveSysInfo{
|
||||
Help: helpCkpt,
|
||||
Model: "/models/Qwen3.6-27B.gguf",
|
||||
GGUF: &GGUFInfo{Arch: "qwen35", Hybrid: true, BlockCount: 64, FullAttnInterval: 4, HeadCountKV: 4,
|
||||
KeyLen: 256, ValLen: 256, SSMConv: 4, SSMInner: 6144, SSMState: 128, SSMGroups: 16},
|
||||
ModelBytes: 17 << 30,
|
||||
RAMMiB: 65536,
|
||||
RAMAvailMiB: 50000,
|
||||
ArgEnv: map[string]string{},
|
||||
EngineBuild: 10678,
|
||||
}
|
||||
}
|
||||
|
||||
func TestCkptArgs(t *testing.T) {
|
||||
cms := []string{"--checkpoint-min-step", "2048"}
|
||||
cases := []struct {
|
||||
name string
|
||||
cfg map[string]string
|
||||
extra []string
|
||||
si func(*serveSysInfo)
|
||||
want []string
|
||||
notes int
|
||||
}{
|
||||
{name: "hybride, moteur b10678 : espacement resserré d'office", want: cms, notes: 1},
|
||||
{name: "hybride, moteur b11351 : rien", si: func(s *serveSysInfo) { s.EngineBuild = 11351 }},
|
||||
{name: "hybride, build pile au correctif : rien", si: func(s *serveSysInfo) { s.EngineBuild = engineMinRecommended }},
|
||||
{name: "build inconnu (compilé, clone superficiel, fork) : ni avis ni drapeau",
|
||||
si: func(s *serveSysInfo) { s.EngineBuild = 0 }},
|
||||
{name: "modèle dense sur moteur ancien : rien",
|
||||
si: func(s *serveSysInfo) { s.GGUF = &GGUFInfo{Arch: "qwen3", BlockCount: 64} }},
|
||||
{name: "métadonnées illisibles : jamais deviné au nom du fichier",
|
||||
si: func(s *serveSysInfo) { s.GGUF = nil }},
|
||||
{name: "arch hybride connue sans clé ssm.*", want: cms, notes: 1,
|
||||
si: func(s *serveSysInfo) { s.GGUF = &GGUFInfo{Arch: "qwen3next"} }},
|
||||
{name: "CKPT_MIN_STEP=4096 passé tel quel, quel que soit le build",
|
||||
cfg: map[string]string{"CKPT_MIN_STEP": "4096"}, want: []string{"--checkpoint-min-step", "4096"},
|
||||
si: func(s *serveSysInfo) { s.EngineBuild = 0 }},
|
||||
{name: "CKPT_MIN_STEP=0 refusé, défaut du moteur, et on le dit",
|
||||
cfg: map[string]string{"CKPT_MIN_STEP": "0"}, notes: 1},
|
||||
{name: "-cms d'EXTRA_ARGS : Loki se tait sur l'espacement",
|
||||
extra: []string{"-cms", "1024"}, notes: 1},
|
||||
{name: "--checkpoint-min-step=1024 d'EXTRA_ARGS", cfg: map[string]string{"CKPT_MIN_STEP": "4096"},
|
||||
extra: []string{"--checkpoint-min-step=1024"}},
|
||||
{name: "LLAMA_ARG_CHECKPOINT_MIN_SPACING_NT posée : pas d'espacement d'office", notes: 1,
|
||||
si: func(s *serveSysInfo) { s.ArgEnv["LLAMA_ARG_CHECKPOINT_MIN_SPACING_NT"] = "4096" }},
|
||||
{name: "--ctx-checkpoints d'EXTRA_ARGS ne coupe pas l'espacement", extra: []string{"--ctx-checkpoints", "8"},
|
||||
want: cms, notes: 1},
|
||||
{name: "aide sans -cms : pas de drapeau, l'avis reste", notes: 1,
|
||||
si: func(s *serveSysInfo) { s.Help = helpRecent }},
|
||||
{name: "points de reprise coupés : l'espacement ne sert à rien", cfg: map[string]string{"CTX_CHECKPOINTS": "0"},
|
||||
want: []string{"--ctx-checkpoints", "0"}, notes: 1},
|
||||
{name: "MoE mmappé de 82 Go sur 64 Go : conseil, pas d'espacement d'office", notes: 2,
|
||||
si: func(s *serveSysInfo) { s.ModelBytes = 82 << 30 }},
|
||||
{name: "RAM inconnue : pas d'espacement d'office", notes: 1,
|
||||
si: func(s *serveSysInfo) { s.RAMMiB = 0 }},
|
||||
{name: "RAM libre trop juste pour 32 points", notes: 1,
|
||||
si: func(s *serveSysInfo) { s.RAMAvailMiB = 8000 }},
|
||||
// Une liste par slot : 2 × 32 × ~150 Mio tient dans le quart de 50 Go, 4 × non.
|
||||
{name: "PARALLEL=2 : deux listes, encore dans le quart de la RAM libre", cfg: map[string]string{"PARALLEL": "2"},
|
||||
want: cms, notes: 1},
|
||||
{name: "PARALLEL=4 : quatre listes, trop pour la RAM libre", cfg: map[string]string{"PARALLEL": "4"}, notes: 1},
|
||||
{name: "--parallel 4 d'EXTRA_ARGS l'emporte sur PARALLEL", cfg: map[string]string{"PARALLEL": "1"},
|
||||
extra: []string{"--parallel", "4"}, notes: 1},
|
||||
{name: "CTX_CHECKPOINTS=16 passé", cfg: map[string]string{"CTX_CHECKPOINTS": "16"},
|
||||
want: []string{"--ctx-checkpoints", "16", "--checkpoint-min-step", "2048"}, notes: 1},
|
||||
{name: "CTX_CHECKPOINTS illisible", cfg: map[string]string{"CTX_CHECKPOINTS": "beaucoup"}, want: cms, notes: 2},
|
||||
{name: "CTX_CHECKPOINTS et LLAMA_ARG_CTX_CHECKPOINTS", cfg: map[string]string{"CTX_CHECKPOINTS": "16"},
|
||||
want: cms, notes: 2,
|
||||
si: func(s *serveSysInfo) { s.ArgEnv["LLAMA_ARG_CTX_CHECKPOINTS"] = "8" }},
|
||||
{name: "CTX_CHECKPOINTS et -ctxcp d'EXTRA_ARGS", cfg: map[string]string{"CTX_CHECKPOINTS": "16"},
|
||||
extra: []string{"-ctxcp", "8"}, want: cms, notes: 1},
|
||||
{name: "moteur sans --ctx-checkpoints : CTX_CHECKPOINTS ignoré, dit", cfg: map[string]string{"CTX_CHECKPOINTS": "16"},
|
||||
notes: 2, si: func(s *serveSysInfo) { s.Help = helpRecent }},
|
||||
}
|
||||
for _, c := range cases {
|
||||
t.Run(c.name, func(t *testing.T) {
|
||||
si := hybridOld()
|
||||
if c.si != nil {
|
||||
c.si(&si)
|
||||
}
|
||||
got, notes := ckptArgs(c.cfg, c.extra, si)
|
||||
if !reflect.DeepEqual(got, c.want) {
|
||||
t.Fatalf("ckptArgs = %v, attendu %v", got, c.want)
|
||||
}
|
||||
if len(notes) != c.notes {
|
||||
t.Fatalf("notes = %q, attendu %d", notes, c.notes)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// Place dans la ligne complète : avant EXTRA_ARGS, qui garde le dernier mot.
|
||||
func TestBuildServeArgsCkpt(t *testing.T) {
|
||||
args, _, notes := buildServeArgs(map[string]string{"NGL": "999"}, []string{"--jinja"}, "/bin/llama-server", hybridOld())
|
||||
if got := strings.Join(args, " "); !strings.HasSuffix(got, "--checkpoint-min-step 2048 --jinja") {
|
||||
t.Fatalf("ligne = %s", got)
|
||||
}
|
||||
found := false
|
||||
for _, n := range notes {
|
||||
found = found || strings.Contains(n, "b10678")
|
||||
}
|
||||
if !found {
|
||||
t.Fatalf("l'avis du moteur ancien manque : %q", notes)
|
||||
}
|
||||
}
|
||||
|
||||
func TestEngineBuildTrust(t *testing.T) {
|
||||
cases := []struct {
|
||||
src string
|
||||
build int
|
||||
want int
|
||||
}{
|
||||
{"downloaded", 10678, 10678},
|
||||
{"image", 11351, 11351},
|
||||
{"prebuilt", 10864, 10864},
|
||||
{"custom", 10678, 0}, // compilé ou fork : numérotation inconnue
|
||||
{"downloaded", 0, 0}, // --version illisible
|
||||
{"image", 1, 0}, // clone superficiel
|
||||
{"prebuilt", 4999, 0}, // sous le plancher
|
||||
{"downloaded", 5000, 5000},
|
||||
}
|
||||
for _, c := range cases {
|
||||
if got := engineBuildTrust(c.src, c.build); got != c.want {
|
||||
t.Errorf("engineBuildTrust(%s, %d) = %d, attendu %d", c.src, c.build, got, c.want)
|
||||
}
|
||||
}
|
||||
if engineBuildNotice(10678) == "" || engineBuildNotice(engineMinRecommended) != "" || engineBuildNotice(0) != "" {
|
||||
t.Fatal("avis : seulement pour un build connu et antérieur au correctif")
|
||||
}
|
||||
}
|
||||
|
||||
func TestReasoningPreserveArgs(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
cfg map[string]string
|
||||
extra []string
|
||||
si func(*serveSysInfo)
|
||||
want []string
|
||||
notes int
|
||||
}{
|
||||
{name: "clé absente : rien, même sur un moteur qui connaît le drapeau"},
|
||||
{name: "on", cfg: map[string]string{"REASONING_PRESERVE": "on"}, want: []string{"--reasoning-preserve"}},
|
||||
{name: "OFF", cfg: map[string]string{"REASONING_PRESERVE": "OFF"}, want: []string{"--no-reasoning-preserve"}},
|
||||
{name: "illisible", cfg: map[string]string{"REASONING_PRESERVE": "peut-être"}, notes: 1},
|
||||
{name: "moteur sans le drapeau", cfg: map[string]string{"REASONING_PRESERVE": "off"}, notes: 1,
|
||||
si: func(s *serveSysInfo) { s.Help = helpRecent }},
|
||||
{name: "EXTRA_ARGS tranche déjà", cfg: map[string]string{"REASONING_PRESERVE": "off"},
|
||||
extra: []string{"--reasoning-preserve"}},
|
||||
{name: "--chat-template-kwargs le fixe", cfg: map[string]string{"REASONING_PRESERVE": "off"},
|
||||
extra: []string{"--chat-template-kwargs", `{"preserve_reasoning":true}`}, notes: 1},
|
||||
{name: "LLAMA_ARG_REASONING_PRESERVE posée", cfg: map[string]string{"REASONING_PRESERVE": "off"}, notes: 1,
|
||||
si: func(s *serveSysInfo) { s.ArgEnv["LLAMA_ARG_REASONING_PRESERVE"] = "1" }},
|
||||
{name: "LLAMA_ARG_CHAT_TEMPLATE_KWARGS le fixe", cfg: map[string]string{"REASONING_PRESERVE": "on"}, notes: 1,
|
||||
si: func(s *serveSysInfo) {
|
||||
s.ArgEnv["LLAMA_ARG_CHAT_TEMPLATE_KWARGS"] = `{"preserve_reasoning":false}`
|
||||
}},
|
||||
}
|
||||
for _, c := range cases {
|
||||
t.Run(c.name, func(t *testing.T) {
|
||||
si := serveSysInfo{Help: helpCkpt, ArgEnv: map[string]string{}}
|
||||
if c.si != nil {
|
||||
c.si(&si)
|
||||
}
|
||||
got, notes := reasoningPreserveArgs(c.cfg, c.extra, si)
|
||||
if !reflect.DeepEqual(got, c.want) {
|
||||
t.Fatalf("reasoningPreserveArgs = %v, attendu %v", got, c.want)
|
||||
}
|
||||
if len(notes) != c.notes {
|
||||
t.Fatalf("notes = %q, attendu %d", notes, c.notes)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// Après une mise à jour, la version téléchargée qui tournait reste installée :
|
||||
// c'est le retour arrière sans réseau. Le moteur de l'image, lui, n'a rien à
|
||||
// garder sous engine/.
|
||||
func TestEngineKeepAfterUpdate(t *testing.T) {
|
||||
testHome(t)
|
||||
for _, tag := range []string{"server-cuda-b10500", "server-cuda-b10678", "server-cuda-b10900"} {
|
||||
d := filepath.Join(engineDir(), tag)
|
||||
if err := os.MkdirAll(d, 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.WriteFile(filepath.Join(d, "llama-server"), []byte("x"), 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
nouveau := filepath.Join(engineDir(), "server-cuda-b10900", "llama-server")
|
||||
ancien := filepath.Join(engineDir(), "server-cuda-b10678", "llama-server")
|
||||
keep := engineKeepAfterUpdate(nouveau, ancien, nil)
|
||||
if !reflect.DeepEqual(keep, map[string]bool{"server-cuda-b10900": true, "server-cuda-b10678": true}) {
|
||||
t.Fatalf("gardés = %v", keep)
|
||||
}
|
||||
enginePrune(keep, nil)
|
||||
if got := engineInstalled(); !reflect.DeepEqual(got, []string{"server-cuda-b10678", "server-cuda-b10900"}) {
|
||||
t.Fatalf("après ménage : %v", got)
|
||||
}
|
||||
if keep := engineKeepAfterUpdate(nouveau, "/app/llama-server", nil); len(keep) != 1 {
|
||||
t.Fatalf("moteur de l'image : %v", keep)
|
||||
}
|
||||
if keep := engineKeepAfterUpdate(nouveau, nouveau, nil); len(keep) != 1 {
|
||||
t.Fatalf("même version : %v", keep)
|
||||
}
|
||||
}
|
||||
|
||||
// Nombre de slots : chacun a sa liste de points de reprise.
|
||||
func TestCkptSlots(t *testing.T) {
|
||||
cases := []struct {
|
||||
cfg map[string]string
|
||||
extra []string
|
||||
want int
|
||||
}{
|
||||
{want: 1},
|
||||
{cfg: map[string]string{"PARALLEL": "3"}, want: 3},
|
||||
{cfg: map[string]string{"PARALLEL": "3"}, extra: []string{"-np", "2"}, want: 2},
|
||||
{extra: []string{"--parallel=6"}, want: 6},
|
||||
{extra: []string{"--parallel", "-1"}, want: 4},
|
||||
{cfg: map[string]string{"PARALLEL": "auto"}, want: 4},
|
||||
}
|
||||
for _, c := range cases {
|
||||
if got := ckptSlots(c.cfg, c.extra); got != c.want {
|
||||
t.Errorf("ckptSlots(%v, %v) = %d, attendu %d", c.cfg, c.extra, got, c.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,260 @@
|
||||
package loki
|
||||
|
||||
import (
|
||||
"os"
|
||||
"strconv"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// Réglages d'expert du moteur local, tous éteints tant que le preset ne les
|
||||
// demande pas. Aucun ne touche au modèle : ni poids, ni contexte, ni
|
||||
// échantillonnage — seulement OÙ et COMMENT le même calcul s'exécute.
|
||||
//
|
||||
// FIT_TARGET=1024,3072 → --fit-target : marge VRAM laissée libre par carte
|
||||
// OP_OFFLOAD_MIN_BATCH=N → GGML_OP_OFFLOAD_MIN_BATCH : seuil d'envoi au GPU
|
||||
// des poids restés en RAM
|
||||
// CUDA_GRAPH_OPT=on → GGML_CUDA_GRAPH_OPT=1 : branches Q/K/V en parallèle
|
||||
//
|
||||
// Une variable déjà présente dans l'environnement (gabarit Docker/Unraid) est
|
||||
// un choix de l'utilisateur : jamais touchée. Les variables passent par
|
||||
// l'environnement du seul « loki serve », qui devient llama-server : le reste
|
||||
// du conteneur ne les voit pas.
|
||||
|
||||
// expertEnvKeys : les variables du moteur que ces réglages posent.
|
||||
var expertEnvKeys = []string{"GGML_CUDA_GRAPH_OPT", "GGML_OP_OFFLOAD_MIN_BATCH"}
|
||||
|
||||
// fitArgEnv : les LLAMA_ARG_* qui, sans drapeau, décident de --fit et de sa
|
||||
// marge. llama.cpp les applique quand la ligne de commande se tait.
|
||||
var fitArgEnv = []string{"LLAMA_ARG_FIT", "LLAMA_ARG_FIT_TARGET", "LLAMA_ARG_TENSOR_SPLIT"}
|
||||
|
||||
// probeExpertEnv relève ce que l'environnement a déjà tranché. Appelée après
|
||||
// probeServeGPUs, dont elle complète ArgEnv.
|
||||
func probeExpertEnv(si *serveSysInfo) {
|
||||
si.UserEnv = map[string]string{}
|
||||
for _, k := range expertEnvKeys {
|
||||
if v := os.Getenv(k); v != "" {
|
||||
si.UserEnv[k] = v
|
||||
}
|
||||
}
|
||||
if si.ArgEnv == nil {
|
||||
si.ArgEnv = map[string]string{}
|
||||
}
|
||||
for _, k := range fitArgEnv {
|
||||
if v := os.Getenv(k); v != "" {
|
||||
si.ArgEnv[k] = v
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// fitTargetMin : la marge par défaut de llama.cpp (common.h), en Mio. En
|
||||
// dessous, fit range plus serré que ce qu'il sait estimer — le tampon du modèle
|
||||
// MTP, qu'il ne mesure pas toujours, suffit alors à ramener le « cudaMalloc
|
||||
// failed: out of memory » au premier long prompt. On remonte, en le disant.
|
||||
const fitTargetMin = 1024
|
||||
|
||||
// fitTargetMaxValues : llama.cpp refuse (et le serveur meurt en boucle) une
|
||||
// liste aussi longue que son nombre maximal de cartes ; 8 reste loin du bord.
|
||||
const fitTargetMaxValues = 8
|
||||
|
||||
// parseFitTarget valide FIT_TARGET : des entiers en Mio séparés par « , » ou
|
||||
// « / », une valeur par carte dans l'ordre que voit le moteur (une seule vaut
|
||||
// pour toutes). Jamais transmis brut : llama.cpp lit chaque valeur avec
|
||||
// std::stoull, qui lève sur le moindre caractère parasite — moteur mort au
|
||||
// démarrage, relancé en boucle par systemd ou Docker.
|
||||
func parseFitTarget(v string) (vals []int, raised bool, ok bool) {
|
||||
parts := strings.FieldsFunc(v, func(r rune) bool { return r == ',' || r == '/' })
|
||||
if len(parts) == 0 || len(parts) > fitTargetMaxValues ||
|
||||
strings.Count(v, ",")+strings.Count(v, "/") != len(parts)-1 {
|
||||
return nil, false, false
|
||||
}
|
||||
for _, p := range parts {
|
||||
p = strings.TrimSpace(p)
|
||||
if p == "" || strings.Trim(p, "0123456789") != "" || len(p) > 7 {
|
||||
return nil, false, false
|
||||
}
|
||||
n, _ := strconv.Atoi(p)
|
||||
if n < fitTargetMin {
|
||||
n, raised = fitTargetMin, true
|
||||
}
|
||||
vals = append(vals, n)
|
||||
}
|
||||
return vals, raised, true
|
||||
}
|
||||
|
||||
// fitTargetArgs traduit FIT_TARGET en --fit-target. Fonction pure.
|
||||
//
|
||||
// La marge règle combien de VRAM --fit laisse libre sur chaque carte ; sur deux
|
||||
// cartes inégales (5060 Ti + 3060), une marge plus large sur la lente pousse
|
||||
// plus de couches vers la rapide. Placement seulement : mêmes poids, mêmes
|
||||
// types de cache, même contexte. Rien n'est posé quand :
|
||||
//
|
||||
// - le moteur ne connaît pas --fit-target (un drapeau inconnu le tue) ;
|
||||
// - EXTRA_ARGS ou LLAMA_ARG_FIT_TARGET l'ont déjà fixée ;
|
||||
// - le contexte n'est pas un nombre positif : avec -c 0, fit a le droit de
|
||||
// RÉDUIRE le contexte pour tenir, et une marge plus large l'y pousserait
|
||||
// sans un mot — de l'information perdue pour le modèle ;
|
||||
// - --fit ne tournera pas (voir fitBlocker) : la clé ne ferait rien, et une
|
||||
// comparaison A/B mesurerait deux fois le même placement.
|
||||
func fitTargetArgs(cfg map[string]string, extra []string, si serveSysInfo) (args, notes []string) {
|
||||
raw := strings.TrimSpace(cfg["FIT_TARGET"])
|
||||
if raw == "" {
|
||||
return nil, nil
|
||||
}
|
||||
ignore := func(why string) (a, n []string) {
|
||||
return nil, []string{"FIT_TARGET=" + raw + " ignoré : " + why}
|
||||
}
|
||||
vals, raised, ok := parseFitTarget(raw)
|
||||
switch {
|
||||
case !ok:
|
||||
return ignore("des Mio par carte, ex. 1024,3072 (8 valeurs au plus)")
|
||||
case !strings.Contains(si.Help, "--fit-target"):
|
||||
return ignore("ce moteur ne connaît pas --fit-target")
|
||||
case hasAnyFlag(extra, "-fitt", "--fit-target"):
|
||||
return ignore("--fit-target déjà dans EXTRA_ARGS")
|
||||
case si.ArgEnv["LLAMA_ARG_FIT_TARGET"] != "":
|
||||
return ignore("LLAMA_ARG_FIT_TARGET déjà posé dans l'environnement")
|
||||
}
|
||||
if ctx, ok := ctxFixed(cfg, extra); !ok {
|
||||
return ignore("contexte « " + ctx + " » non fixé — --fit pourrait le réduire pour tenir ; donne un CTX chiffré")
|
||||
}
|
||||
if why := fitBlocker(cfg, extra, si); why != "" {
|
||||
return ignore("--fit inactif (" + why + "), la marge ne servirait à rien")
|
||||
}
|
||||
s := make([]string, len(vals))
|
||||
for i, n := range vals {
|
||||
s[i] = strconv.Itoa(n)
|
||||
}
|
||||
v := strings.Join(s, ",")
|
||||
note := "--fit-target " + v + " (FIT_TARGET, Mio libres par carte, dans l'ordre vu par le moteur) — " +
|
||||
"vérifie « offloaded N/N layers » dans le journal"
|
||||
if raised {
|
||||
note += " ; marges sous 1024 Mio remontées à 1024"
|
||||
}
|
||||
return []string{"--fit-target", v}, []string{note}
|
||||
}
|
||||
|
||||
// fitBlocker dit pourquoi llama.cpp ne lancera PAS --fit (common/fit.cpp :
|
||||
// « already set by user, abort »), ou vide s'il le lancera. Les couches GPU
|
||||
// sont lues comme buildServeArgs les écrira : EXTRA_ARGS d'abord, sinon la
|
||||
// traduction de NGL par nglArgs (999 y devient auto), sinon — NGL=auto, aucun
|
||||
// drapeau — la variable du moteur.
|
||||
func fitBlocker(cfg map[string]string, extra []string, si serveSysInfo) string {
|
||||
fit := flagValue(extra, "-fit", "--fit")
|
||||
if fit == "" {
|
||||
fit = si.ArgEnv["LLAMA_ARG_FIT"]
|
||||
}
|
||||
switch strings.ToLower(strings.TrimSpace(fit)) {
|
||||
case "off", "disabled", "false", "0":
|
||||
return "--fit off"
|
||||
}
|
||||
ngl := flagValue(extra, "-ngl", "--n-gpu-layers", "--gpu-layers")
|
||||
if ngl == "" {
|
||||
if a, _ := nglArgs(cfg["NGL"], helpFitsLayersItself(si.Help)); len(a) == 2 {
|
||||
ngl = a[1]
|
||||
} else {
|
||||
ngl = si.ArgEnv["LLAMA_ARG_N_GPU_LAYERS"]
|
||||
}
|
||||
}
|
||||
// -1 est la valeur par défaut du moteur, celle qu'« auto » écrit : fit ne
|
||||
// la prend pas pour un choix de l'utilisateur.
|
||||
if ngl = strings.TrimSpace(ngl); ngl != "" && ngl != "-1" && !strings.EqualFold(ngl, "auto") {
|
||||
return "couches GPU fixées à " + ngl
|
||||
}
|
||||
if hasAnyFlag(extra, "-ts", "--tensor-split") || si.ArgEnv["LLAMA_ARG_TENSOR_SPLIT"] != "" {
|
||||
return "--tensor-split"
|
||||
}
|
||||
if r := tensorOverride(extra, si.ArgEnv); r != "" {
|
||||
return r
|
||||
}
|
||||
sm := flagValue(extra, "-sm", "--split-mode")
|
||||
if sm == "" {
|
||||
sm = si.ArgEnv["LLAMA_ARG_SPLIT_MODE"]
|
||||
}
|
||||
// row ET tensor : fit abandonne sur l'une comme sur l'autre (« not
|
||||
// implemented », common/fit.cpp).
|
||||
if sm = strings.ToLower(strings.TrimSpace(sm)); sm == "row" || sm == "tensor" {
|
||||
return "-sm " + sm
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// opOffloadMinBatchEnv traduit OP_OFFLOAD_MIN_BATCH en GGML_OP_OFFLOAD_MIN_BATCH.
|
||||
// Fonction pure.
|
||||
//
|
||||
// Quand des poids restent en RAM (experts MoE sur CPU), llama.cpp recopie vers
|
||||
// le GPU ceux dont un lot a besoin dès qu'il compte au moins 32 jetons ; en
|
||||
// dessous, il calcule sur CPU. Monter le seuil garde sur CPU les lots plus
|
||||
// courts : utile seulement si une spéculation ngram propose des brouillons de
|
||||
// 32 jetons ou plus (les lots de vérification MTP, quelques jetons, restent
|
||||
// déjà sous 32). Le prix : un tour d'outil de 32 à N-1 jetons se calcule lui
|
||||
// aussi sur CPU. N se trouve à la mesure, pas d'office. Même distribution ;
|
||||
// les logits ne sont pas identiques au bit près, comme déjà entre décodage CPU
|
||||
// et prefill GPU.
|
||||
func opOffloadMinBatchEnv(cfg map[string]string, si serveSysInfo) (val string, notes []string) {
|
||||
raw := strings.TrimSpace(cfg["OP_OFFLOAD_MIN_BATCH"])
|
||||
if raw == "" || si.UserEnv["GGML_OP_OFFLOAD_MIN_BATCH"] != "" {
|
||||
return "", nil
|
||||
}
|
||||
n, err := strconv.Atoi(raw)
|
||||
if err != nil || n < 1 || n > 1<<20 {
|
||||
return "", []string{"OP_OFFLOAD_MIN_BATCH=" + raw + " ignoré : un nombre de jetons, ex. 128"}
|
||||
}
|
||||
v := strconv.Itoa(n)
|
||||
return v, []string{"GGML_OP_OFFLOAD_MIN_BATCH=" + v + " (OP_OFFLOAD_MIN_BATCH) : lots de moins de " + v +
|
||||
" jetons calculés sur CPU pour les poids restés en RAM"}
|
||||
}
|
||||
|
||||
// graphOptEnv traduit CUDA_GRAPH_OPT=on en GGML_CUDA_GRAPH_OPT=1. Fonction pure.
|
||||
//
|
||||
// Au décodage, llama.cpp lance alors les trois branches Q, K et V de chaque
|
||||
// couche sur des flux CUDA concurrents. Chaque opération garde son noyau et ses
|
||||
// entrées : sortie identique au jeton près en amont (PR #28198, perplexité
|
||||
// inchangée). Gain modeste — quelques % sur un MoE tout GPU, rien sur un dense,
|
||||
// dilué quand les experts tournent sur CPU ou que MTP vérifie par lots. Sans
|
||||
// effet avant #28198 en multi-GPU ou si les graphes CUDA sont coupés.
|
||||
//
|
||||
// Expérimental : sur une architecture qu'il n'a pas vue, le repérage des
|
||||
// branches peut s'arrêter sur un GGML_ASSERT, et le moteur redémarre en boucle.
|
||||
// Une variable ne se détecte pas dans l'aide du moteur : on le dit au
|
||||
// lancement, pour que « CUDA_GRAPH_OPT=off puis redémarrer » vienne à l'esprit.
|
||||
//
|
||||
// Refusé quand Q, K et V d'une même couche pourraient finir sur deux cartes
|
||||
// (-sm row/tensor, -ot visant l'attention) : #28198 en fait une condition
|
||||
// d'exactitude.
|
||||
func graphOptEnv(cfg map[string]string, extra []string, si serveSysInfo) (val string, notes []string) {
|
||||
raw := strings.TrimSpace(cfg["CUDA_GRAPH_OPT"])
|
||||
switch strings.ToLower(raw) {
|
||||
case "", "off":
|
||||
return "", nil
|
||||
case "on":
|
||||
default:
|
||||
return "", []string{"CUDA_GRAPH_OPT=" + raw + " ignoré (on ou off)"}
|
||||
}
|
||||
if si.UserEnv["GGML_CUDA_GRAPH_OPT"] != "" {
|
||||
return "", nil
|
||||
}
|
||||
if why := qkvSplitRisk(extra, si.ArgEnv); why != "" {
|
||||
return "", []string{"CUDA_GRAPH_OPT ignoré : " + why + " peut séparer Q, K et V d'une couche entre cartes"}
|
||||
}
|
||||
return "1", []string{"GGML_CUDA_GRAPH_OPT=1 (CUDA_GRAPH_OPT, expérimental) — si le moteur s'arrête sur un " +
|
||||
"GGML_ASSERT, CUDA_GRAPH_OPT=off puis redémarre"}
|
||||
}
|
||||
|
||||
// qkvSplitRisk : un placement qui pourrait répartir les tenseurs d'attention
|
||||
// d'une même couche sur plusieurs appareils. La découpe par couches (défaut,
|
||||
// --tensor-split compris) garde chaque couche entière : rien à craindre.
|
||||
func qkvSplitRisk(extra []string, argEnv map[string]string) string {
|
||||
sm := flagValue(extra, "-sm", "--split-mode")
|
||||
if sm == "" {
|
||||
sm = argEnv["LLAMA_ARG_SPLIT_MODE"]
|
||||
}
|
||||
if sm = strings.ToLower(strings.TrimSpace(sm)); sm == "row" || sm == "tensor" {
|
||||
return "-sm " + sm
|
||||
}
|
||||
for _, v := range append(flagValues(extra, "-ot", "--override-tensor"), argEnv["LLAMA_ARG_OVERRIDE_TENSOR"]) {
|
||||
if strings.Contains(strings.ToLower(v), "attn") {
|
||||
return "-ot sur l'attention"
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
@@ -0,0 +1,218 @@
|
||||
package loki
|
||||
|
||||
import (
|
||||
"reflect"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// helpFit : un moteur récent qui connaît --fit et sa marge.
|
||||
const helpFit = helpRecent + `-fit, --fit [on|off] whether to adjust unset arguments to fit in device memory
|
||||
-fitt, --fit-target MiB0,MiB1,... target margin per device for --fit (default: 1024)
|
||||
`
|
||||
|
||||
// TestFitTargetArgs : --fit-target n'est passé que validé, sur un moteur qui le
|
||||
// connaît, avec un contexte fixé, et quand --fit tournera vraiment.
|
||||
func TestFitTargetArgs(t *testing.T) {
|
||||
fit := serveSysInfo{Help: helpFit}
|
||||
cases := []struct {
|
||||
name string
|
||||
cfg map[string]string
|
||||
si serveSysInfo
|
||||
want []string
|
||||
wantNotes int
|
||||
}{
|
||||
{name: "clé absente : rien, sans un mot",
|
||||
cfg: map[string]string{}, si: fit},
|
||||
{name: "deux marges : passées, et on le dit",
|
||||
cfg: map[string]string{"FIT_TARGET": "1024,3072"}, si: fit,
|
||||
want: []string{"--fit-target", "1024,3072"}, wantNotes: 1},
|
||||
{name: "séparateur / : normalisé en virgules",
|
||||
cfg: map[string]string{"FIT_TARGET": "2048/1536"}, si: fit,
|
||||
want: []string{"--fit-target", "2048,1536"}, wantNotes: 1},
|
||||
{name: "une seule valeur : vaut pour toutes les cartes",
|
||||
cfg: map[string]string{"FIT_TARGET": " 2048 "}, si: fit,
|
||||
want: []string{"--fit-target", "2048"}, wantNotes: 1},
|
||||
{name: "marge sous le défaut : remontée à 1024",
|
||||
cfg: map[string]string{"FIT_TARGET": "256,3072"}, si: fit,
|
||||
want: []string{"--fit-target", "1024,3072"}, wantNotes: 1},
|
||||
{name: "valeur non numérique : jamais transmise (stoull ferait mourir le moteur)",
|
||||
cfg: map[string]string{"FIT_TARGET": "1G,2G"}, si: fit, wantNotes: 1},
|
||||
{name: "valeur négative : refusée",
|
||||
cfg: map[string]string{"FIT_TARGET": "-1024"}, si: fit, wantNotes: 1},
|
||||
{name: "séparateur doublé : refusé",
|
||||
cfg: map[string]string{"FIT_TARGET": "1024,,2048"}, si: fit, wantNotes: 1},
|
||||
{name: "trop de valeurs : refusé",
|
||||
cfg: map[string]string{"FIT_TARGET": "1,2,3,4,5,6,7,8,9"}, si: fit, wantNotes: 1},
|
||||
{name: "moteur sans --fit-target : rien, le drapeau le tuerait",
|
||||
cfg: map[string]string{"FIT_TARGET": "1024,3072"}, si: serveSysInfo{Help: helpRecent}, wantNotes: 1},
|
||||
{name: "-fitt déjà dans EXTRA_ARGS : l'utilisateur a tranché",
|
||||
cfg: map[string]string{"FIT_TARGET": "1024", "EXTRA_ARGS": "-fitt 2048"}, si: fit, wantNotes: 1},
|
||||
{name: "LLAMA_ARG_FIT_TARGET dans l'environnement : intouché",
|
||||
cfg: map[string]string{"FIT_TARGET": "1024"},
|
||||
si: serveSysInfo{Help: helpFit, ArgEnv: map[string]string{"LLAMA_ARG_FIT_TARGET": "4096"}}, wantNotes: 1},
|
||||
{name: "CTX=0 : fit pourrait réduire le contexte, refusé",
|
||||
cfg: map[string]string{"FIT_TARGET": "1024,3072", "CTX": "0"}, si: fit, wantNotes: 1},
|
||||
{name: "-c 0 dans EXTRA_ARGS l'emporte sur CTX : refusé",
|
||||
cfg: map[string]string{"FIT_TARGET": "1024", "CTX": "65536", "EXTRA_ARGS": "-c 0"}, si: fit, wantNotes: 1},
|
||||
{name: "CTX absent : Loki passe -c 32768, accepté",
|
||||
cfg: map[string]string{"FIT_TARGET": "1024"}, si: fit,
|
||||
want: []string{"--fit-target", "1024"}, wantNotes: 1},
|
||||
{name: "NGL=999 : devient -ngl auto, fit tourne",
|
||||
cfg: map[string]string{"FIT_TARGET": "1024", "NGL": "999"}, si: fit,
|
||||
want: []string{"--fit-target", "1024"}, wantNotes: 1},
|
||||
{name: "NGL chiffré : fit abandonne, rien",
|
||||
cfg: map[string]string{"FIT_TARGET": "1024", "NGL": "40"}, si: fit, wantNotes: 1},
|
||||
{name: "NGL=all : fit abandonne, rien",
|
||||
cfg: map[string]string{"FIT_TARGET": "1024", "NGL": "all"}, si: fit, wantNotes: 1},
|
||||
{name: "-ngl auto dans EXTRA_ARGS : fit tourne",
|
||||
cfg: map[string]string{"FIT_TARGET": "1024", "NGL": "40", "EXTRA_ARGS": "-ngl auto"}, si: fit,
|
||||
want: []string{"--fit-target", "1024"}, wantNotes: 1},
|
||||
{name: "NGL=auto et LLAMA_ARG_N_GPU_LAYERS=30 : rien",
|
||||
cfg: map[string]string{"FIT_TARGET": "1024", "NGL": "auto"},
|
||||
si: serveSysInfo{Help: helpFit, ArgEnv: map[string]string{"LLAMA_ARG_N_GPU_LAYERS": "30"}}, wantNotes: 1},
|
||||
{name: "--tensor-split : rien",
|
||||
cfg: map[string]string{"FIT_TARGET": "1024", "EXTRA_ARGS": "--tensor-split 0.965,0.035"}, si: fit, wantNotes: 1},
|
||||
{name: "preset MoE (-ot, --n-cpu-moe) : rien",
|
||||
cfg: map[string]string{"FIT_TARGET": "1024", "EXTRA_ARGS": `-ot "per_layer_token_embd=CPU" --n-cpu-moe 40`},
|
||||
si: fit, wantNotes: 1},
|
||||
{name: "--cpu-moe : rien",
|
||||
cfg: map[string]string{"FIT_TARGET": "1024", "EXTRA_ARGS": "-cmoe"}, si: fit, wantNotes: 1},
|
||||
{name: "--fit off : rien",
|
||||
cfg: map[string]string{"FIT_TARGET": "1024", "EXTRA_ARGS": "--fit off"}, si: fit, wantNotes: 1},
|
||||
{name: "LLAMA_ARG_FIT=off : rien",
|
||||
cfg: map[string]string{"FIT_TARGET": "1024"},
|
||||
si: serveSysInfo{Help: helpFit, ArgEnv: map[string]string{"LLAMA_ARG_FIT": "off"}}, wantNotes: 1},
|
||||
{name: "-sm row : rien",
|
||||
cfg: map[string]string{"FIT_TARGET": "1024", "EXTRA_ARGS": "-sm row"}, si: fit, wantNotes: 1},
|
||||
{name: "-sm tensor : fit abandonne aussi, rien",
|
||||
cfg: map[string]string{"FIT_TARGET": "1024", "EXTRA_ARGS": "--split-mode tensor"}, si: fit, wantNotes: 1},
|
||||
{name: "LLAMA_ARG_SPLIT_MODE=tensor : rien",
|
||||
cfg: map[string]string{"FIT_TARGET": "1024"},
|
||||
si: serveSysInfo{Help: helpFit, ArgEnv: map[string]string{"LLAMA_ARG_SPLIT_MODE": "tensor"}}, wantNotes: 1},
|
||||
{name: "-sm layer : fit tourne",
|
||||
cfg: map[string]string{"FIT_TARGET": "1024", "EXTRA_ARGS": "-sm layer"}, si: fit,
|
||||
want: []string{"--fit-target", "1024"}, wantNotes: 1},
|
||||
{name: "-ngl -1 (le défaut du moteur) : fit tourne",
|
||||
cfg: map[string]string{"FIT_TARGET": "1024", "EXTRA_ARGS": "-ngl -1"}, si: fit,
|
||||
want: []string{"--fit-target", "1024"}, wantNotes: 1},
|
||||
{name: "--device choisi : le placement reste à fit, accepté",
|
||||
cfg: map[string]string{"FIT_TARGET": "1024,3072", "EXTRA_ARGS": "--device CUDA1,CUDA0"}, si: fit,
|
||||
want: []string{"--fit-target", "1024,3072"}, wantNotes: 1},
|
||||
}
|
||||
for _, c := range cases {
|
||||
t.Run(c.name, func(t *testing.T) {
|
||||
got, notes := fitTargetArgs(c.cfg, splitArgs(c.cfg["EXTRA_ARGS"]), c.si)
|
||||
if !reflect.DeepEqual(got, c.want) {
|
||||
t.Fatalf("args = %q, attendu %q", got, c.want)
|
||||
}
|
||||
if len(notes) != c.wantNotes {
|
||||
t.Fatalf("notes = %q, en attendait %d", notes, c.wantNotes)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestGraphOptEnv : GGML_CUDA_GRAPH_OPT=1 seulement sur demande, jamais
|
||||
// par-dessus l'environnement, jamais quand Q/K/V pourraient changer de carte.
|
||||
func TestGraphOptEnv(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
cfg map[string]string
|
||||
si serveSysInfo
|
||||
want string
|
||||
wantNotes int
|
||||
}{
|
||||
{name: "clé absente : rien", cfg: map[string]string{}},
|
||||
{name: "off : rien", cfg: map[string]string{"CUDA_GRAPH_OPT": "off"}},
|
||||
{name: "on : posé, et on le dit", cfg: map[string]string{"CUDA_GRAPH_OPT": "on"}, want: "1", wantNotes: 1},
|
||||
{name: "ON (casse libre) : posé", cfg: map[string]string{"CUDA_GRAPH_OPT": "ON"}, want: "1", wantNotes: 1},
|
||||
{name: "valeur inconnue : ignorée, dite", cfg: map[string]string{"CUDA_GRAPH_OPT": "2"}, wantNotes: 1},
|
||||
{name: "variable déjà posée (même à 0) : intouchée",
|
||||
cfg: map[string]string{"CUDA_GRAPH_OPT": "on"},
|
||||
si: serveSysInfo{UserEnv: map[string]string{"GGML_CUDA_GRAPH_OPT": "0"}}},
|
||||
{name: "découpe par couches + --tensor-split : couches entières, posé",
|
||||
cfg: map[string]string{"CUDA_GRAPH_OPT": "on", "EXTRA_ARGS": "--device CUDA1,CUDA0 -ts 0.6,0.4"},
|
||||
want: "1", wantNotes: 1},
|
||||
{name: "-sm row : refusé",
|
||||
cfg: map[string]string{"CUDA_GRAPH_OPT": "on", "EXTRA_ARGS": "-sm row"}, wantNotes: 1},
|
||||
{name: "LLAMA_ARG_SPLIT_MODE=tensor : refusé",
|
||||
cfg: map[string]string{"CUDA_GRAPH_OPT": "on"},
|
||||
si: serveSysInfo{ArgEnv: map[string]string{"LLAMA_ARG_SPLIT_MODE": "tensor"}}, wantNotes: 1},
|
||||
{name: "-ot sur l'attention : refusé",
|
||||
cfg: map[string]string{"CUDA_GRAPH_OPT": "on", "EXTRA_ARGS": `-ot "blk\.1\.attn_k.*=CUDA1"`}, wantNotes: 1},
|
||||
{name: "-ot des experts seulement : posé",
|
||||
cfg: map[string]string{"CUDA_GRAPH_OPT": "on", "EXTRA_ARGS": `-ot "ffn_.*_exps=CPU" --n-cpu-moe 40`},
|
||||
want: "1", wantNotes: 1},
|
||||
}
|
||||
for _, c := range cases {
|
||||
t.Run(c.name, func(t *testing.T) {
|
||||
got, notes := graphOptEnv(c.cfg, splitArgs(c.cfg["EXTRA_ARGS"]), c.si)
|
||||
if got != c.want {
|
||||
t.Fatalf("valeur = %q, attendu %q", got, c.want)
|
||||
}
|
||||
if len(notes) != c.wantNotes {
|
||||
t.Fatalf("notes = %q, en attendait %d", notes, c.wantNotes)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestOpOffloadMinBatchEnv : la variable n'existe que si la clé est posée et
|
||||
// valide, et ne remplace jamais celle de l'environnement.
|
||||
func TestOpOffloadMinBatchEnv(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
cfg map[string]string
|
||||
si serveSysInfo
|
||||
want string
|
||||
wantNotes int
|
||||
}{
|
||||
{name: "clé absente : rien", cfg: map[string]string{}},
|
||||
{name: "128 : posé", cfg: map[string]string{"OP_OFFLOAD_MIN_BATCH": "128"}, want: "128", wantNotes: 1},
|
||||
{name: "espaces autour : posé", cfg: map[string]string{"OP_OFFLOAD_MIN_BATCH": " 64 "}, want: "64", wantNotes: 1},
|
||||
{name: "0 : ignoré, dit", cfg: map[string]string{"OP_OFFLOAD_MIN_BATCH": "0"}, wantNotes: 1},
|
||||
{name: "texte : ignoré, dit", cfg: map[string]string{"OP_OFFLOAD_MIN_BATCH": "beaucoup"}, wantNotes: 1},
|
||||
{name: "variable déjà posée : intouchée",
|
||||
cfg: map[string]string{"OP_OFFLOAD_MIN_BATCH": "128"},
|
||||
si: serveSysInfo{UserEnv: map[string]string{"GGML_OP_OFFLOAD_MIN_BATCH": "32"}}},
|
||||
}
|
||||
for _, c := range cases {
|
||||
t.Run(c.name, func(t *testing.T) {
|
||||
got, notes := opOffloadMinBatchEnv(c.cfg, c.si)
|
||||
if got != c.want {
|
||||
t.Fatalf("valeur = %q, attendu %q", got, c.want)
|
||||
}
|
||||
if len(notes) != c.wantNotes {
|
||||
t.Fatalf("notes = %q, en attendait %d", notes, c.wantNotes)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// Les trois clés absentes, la ligne de commande et l'environnement ne bougent
|
||||
// pas ; posées, --fit-target arrive avant EXTRA_ARGS et les variables vont
|
||||
// dans env, nulle part ailleurs.
|
||||
func TestBuildServeArgsExpertToggles(t *testing.T) {
|
||||
const bin, model = "/opt/llama/llama-server", "/models/Qwen3.6-27B-Q4_K_M.gguf"
|
||||
si := serveSysInfo{Help: helpFit, Model: model}
|
||||
cfg := map[string]string{"CTX": "65536", "EXTRA_ARGS": "--device CUDA0"}
|
||||
base, baseEnv, baseNotes := buildServeArgs(cfg, splitArgs(cfg["EXTRA_ARGS"]), bin, si)
|
||||
if len(baseEnv) != 0 || len(baseNotes) != 0 {
|
||||
t.Fatalf("sans les clés : env = %v, notes = %q", baseEnv, baseNotes)
|
||||
}
|
||||
on := map[string]string{"CTX": "65536", "EXTRA_ARGS": "--device CUDA0",
|
||||
"FIT_TARGET": "1024,3072", "OP_OFFLOAD_MIN_BATCH": "128", "CUDA_GRAPH_OPT": "on"}
|
||||
args, env, notes := buildServeArgs(on, splitArgs(on["EXTRA_ARGS"]), bin, si)
|
||||
n := len(base)
|
||||
want := append(append(append([]string{}, base[:n-2]...), "--fit-target", "1024,3072"), base[n-2:]...)
|
||||
if !reflect.DeepEqual(args, want) {
|
||||
t.Fatalf("args\n got %q\nwant %q", args, want)
|
||||
}
|
||||
wantEnv := map[string]string{"GGML_CUDA_GRAPH_OPT": "1", "GGML_OP_OFFLOAD_MIN_BATCH": "128"}
|
||||
if !reflect.DeepEqual(env, wantEnv) {
|
||||
t.Fatalf("env = %v, want %v", env, wantEnv)
|
||||
}
|
||||
if len(notes) != 3 {
|
||||
t.Fatalf("notes = %q", notes)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,217 @@
|
||||
package loki
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"strconv"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// Garde-fous de fidélité. Loki ne choisit JAMAIS à la place de l'utilisateur un
|
||||
// réglage qui change ce que le modèle calcule : pas de cache KV quantifié posé
|
||||
// d'office, pas de réutilisation approximative du cache, pas de contexte qui
|
||||
// glisse en jetant des jetons. Ces réglages restent possibles — ce sont des
|
||||
// compromis légitimes quand la VRAM manque — mais ils doivent se VOIR : un
|
||||
// preset qui tourne en q8_0 depuis EXTRA_ARGS pendant que l'interface affiche
|
||||
// « f16 » est exactement l'altération silencieuse qu'on refuse.
|
||||
//
|
||||
// Tout ici est pur : buildServeArgs en tire des notes, cmdServe les écrit.
|
||||
|
||||
// fidelityArgEnv : les LLAMA_ARG_* qui, sans drapeau, posent un cache
|
||||
// quantifié, un cache approché ou la vision. llama.cpp les applique AVANT la
|
||||
// ligne de commande : un -ctk de KV_TYPE ou d'EXTRA_ARGS les écrase, mais seuls
|
||||
// ils décident. LLAMA_ARG_NO_CONTEXT_SHIFT est celle des moteurs anciens.
|
||||
var fidelityArgEnv = []string{"LLAMA_ARG_CACHE_TYPE_K", "LLAMA_ARG_CACHE_TYPE_V",
|
||||
"LLAMA_ARG_CONTEXT_SHIFT", "LLAMA_ARG_NO_CONTEXT_SHIFT", "LLAMA_ARG_CACHE_REUSE",
|
||||
"LLAMA_ARG_MMPROJ", "LLAMA_ARG_MMPROJ_URL", "LLAMA_ARG_SPEC_SYNTH_LEN"}
|
||||
|
||||
// probeFidelityEnv complète ArgEnv avec fidelityArgEnv. Appelée après
|
||||
// probeServeGPUs, qui crée ArgEnv.
|
||||
func probeFidelityEnv(si *serveSysInfo) {
|
||||
if si.ArgEnv == nil {
|
||||
si.ArgEnv = map[string]string{}
|
||||
}
|
||||
for _, k := range fidelityArgEnv {
|
||||
if v, ok := os.LookupEnv(k); ok && v != "" {
|
||||
si.ArgEnv[k] = v
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// effectiveKVTypes donne les types de cache K et V que le moteur utilisera
|
||||
// VRAIMENT. Trois sources, de la plus faible à la plus forte : les variables
|
||||
// LLAMA_ARG_CACHE_TYPE_K/V (lues avant la ligne de commande), le preset
|
||||
// (serveKVTypes, traduit en -ctk/-ctv), puis EXTRA_ARGS, qui ferme la ligne :
|
||||
// llama-server retient la dernière occurrence d'un drapeau, un -ctk q8_0 écrit
|
||||
// à la main l'emporte donc sur KV_TYPE. src nomme la source la plus forte qui a
|
||||
// tranché l'un des deux (vide : aucune). Vide = défaut du moteur (f16).
|
||||
func effectiveKVTypes(cfg map[string]string, extra []string, argEnv map[string]string) (k, v, src string) {
|
||||
k, v = argEnv["LLAMA_ARG_CACHE_TYPE_K"], argEnv["LLAMA_ARG_CACHE_TYPE_V"]
|
||||
if k != "" || v != "" {
|
||||
src = "défini par LLAMA_ARG_CACHE_TYPE_K/V"
|
||||
}
|
||||
if ck, cv := serveKVTypes(cfg); ck != "" || cv != "" {
|
||||
src = "KV_TYPE"
|
||||
if ck != "" {
|
||||
k = ck
|
||||
}
|
||||
if cv != "" {
|
||||
v = cv
|
||||
}
|
||||
}
|
||||
if s := flagValue(extra, "-ctk", "--cache-type-k"); s != "" {
|
||||
k, src = s, "défini par EXTRA_ARGS"
|
||||
}
|
||||
if s := flagValue(extra, "-ctv", "--cache-type-v"); s != "" {
|
||||
v, src = s, "défini par EXTRA_ARGS"
|
||||
}
|
||||
return k, v, src
|
||||
}
|
||||
|
||||
// kvFidelity classe un type de cache par rapport au f16 de référence. Rang 0 =
|
||||
// mêmes calculs (f16, ou f32 qui ne perd rien) ; plus le rang monte, plus les
|
||||
// sorties s'en écartent. Un type inconnu (sorti après cette version) est
|
||||
// supposé altérer : on préfère un avertissement de trop à un silence.
|
||||
func kvFidelity(t string) (rank int, label string) {
|
||||
switch strings.ToLower(strings.TrimSpace(t)) {
|
||||
case "", "f16", "f32":
|
||||
return 0, ""
|
||||
case "bf16":
|
||||
return 1, "numérique différente de f16 (mantisse plus courte)"
|
||||
case "q8_0", "q5_0", "q5_1":
|
||||
return 2, "modifie légèrement les sorties par rapport à f16"
|
||||
case "q4_0", "q4_1", "iq4_nl":
|
||||
return 3, "perte mesurable par rapport à f16"
|
||||
default:
|
||||
return 2, "modifie les sorties par rapport à f16"
|
||||
}
|
||||
}
|
||||
|
||||
// kvFidelityNote dit, quand le cache n'est pas en f16, ce que ce choix coûte en
|
||||
// fidélité et d'où il vient. Le choix reste celui du preset : Loki ne le change
|
||||
// jamais, ni dans un sens ni dans l'autre. Pas d'estimation de la VRAM qu'un
|
||||
// retour en f16 demanderait : sans les métadonnées du GGUF, ce serait un chiffre
|
||||
// inventé. En revanche, si le placement est figé à la main (-ot, --n-cpu-moe N>0,
|
||||
// en drapeau ou en variable : tensorOverride), --fit ne tourne pas et ne
|
||||
// rattrapera pas ce surplus — mieux vaut le savoir avant de basculer et de
|
||||
// finir en « out of memory ».
|
||||
func kvFidelityNote(k, v, src string, extra []string, argEnv map[string]string) string {
|
||||
rk, lk := kvFidelity(k)
|
||||
rv, lv := kvFidelity(v)
|
||||
if rk == 0 && rv == 0 {
|
||||
return ""
|
||||
}
|
||||
label := lk
|
||||
if rv > rk {
|
||||
label = lv
|
||||
}
|
||||
note := fmt.Sprintf("cache KV %s/%s (%s) : %s — choix de l'utilisateur, laissé tel quel.",
|
||||
orF16(k), orF16(v), src, label)
|
||||
if tensorOverride(extra, argEnv) != "" {
|
||||
note += " Repasser en f16 demande plus de VRAM, et le placement fixé par -ot/--n-cpu-moe " +
|
||||
"empêche --fit de compenser : il faudrait sans doute relever --n-cpu-moe."
|
||||
}
|
||||
return note
|
||||
}
|
||||
|
||||
func orF16(t string) string {
|
||||
if t == "" {
|
||||
return "f16"
|
||||
}
|
||||
return t
|
||||
}
|
||||
|
||||
// lossyCacheNotes avertit des réglages de cache qui changent ce que voit le
|
||||
// modèle. Seuls deux le font :
|
||||
//
|
||||
// - le glissement de contexte : contexte plein → le moteur JETTE des jetons
|
||||
// anciens et continue, le modèle perd une partie de la conversation sans le
|
||||
// savoir ;
|
||||
// - --cache-reuse N (N > 0) : recolle des morceaux de cache calculés sous un
|
||||
// AUTRE préfixe, simplement décalés — le résultat n'est plus celui d'un
|
||||
// calcul complet. --cache-reuse 0 le désactive : rien à dire.
|
||||
//
|
||||
// --swa-full n'en fait PAS partie : il garde le cache complet des couches à
|
||||
// fenêtre glissante, ce qui rend la réutilisation du préfixe exacte (au prix de
|
||||
// VRAM), et ne fait rien sur un modèle sans SWA. Correspondance exacte des noms
|
||||
// (flagValue) : --no-context-shift ne déclenche rien, et la dernière occurrence
|
||||
// gagne comme dans llama-server. Les variables LLAMA_ARG_* comptent quand la
|
||||
// ligne de commande se tait.
|
||||
//
|
||||
// Avec la vision chargée (MMPROJ, --mmproj d'EXTRA_ARGS ou LLAMA_ARG_MMPROJ),
|
||||
// llama.cpp désactive lui-même les deux (non pris en charge en multimodal) : on
|
||||
// le dit plutôt que de crier au loup. Les modèles hybrides ou récurrents les
|
||||
// ignorent aussi, mais sans métadonnées du GGUF on ne sait pas les reconnaître
|
||||
// ici : on se tait sur ce point plutôt que deviner.
|
||||
func lossyCacheNotes(extra []string, si serveSysInfo) []string {
|
||||
var notes []string
|
||||
ignored := ""
|
||||
if si.MMProj != "" || hasAnyFlag(extra, "-mm", "--mmproj", "-mmu", "--mmproj-url") ||
|
||||
si.ArgEnv["LLAMA_ARG_MMPROJ"] != "" || si.ArgEnv["LLAMA_ARG_MMPROJ_URL"] != "" {
|
||||
ignored = " (vision chargée : llama.cpp l'ignore de toute façon)"
|
||||
}
|
||||
switch on, src := contextShift(extra, si.ArgEnv, si.Help); {
|
||||
case on && src == "":
|
||||
notes = append(notes, "avertissement : ce llama-server ancien glisse le contexte par défaut — contexte "+
|
||||
"plein, il jette des jetons anciens et le modèle perd une partie de la conversation sans le savoir"+
|
||||
ignored+". --no-context-shift dans EXTRA_ARGS l'en empêche.")
|
||||
case on:
|
||||
notes = append(notes, "avertissement : --context-shift ("+src+") — contexte plein, le moteur jette "+
|
||||
"des jetons anciens et le modèle perd une partie de la conversation sans le savoir"+ignored+".")
|
||||
}
|
||||
s, src := flagValue(extra, "--cache-reuse"), "EXTRA_ARGS"
|
||||
if s == "" {
|
||||
s, src = si.ArgEnv["LLAMA_ARG_CACHE_REUSE"], "LLAMA_ARG_CACHE_REUSE"
|
||||
}
|
||||
if n, err := strconv.Atoi(strings.TrimSpace(s)); err == nil && n > 0 {
|
||||
notes = append(notes, fmt.Sprintf("avertissement : --cache-reuse %d (%s) — réutilise des morceaux "+
|
||||
"de cache calculés sous un autre préfixe : les sorties ne sont plus exactement celles d'un calcul "+
|
||||
"complet%s.", n, src, ignored))
|
||||
}
|
||||
// Acceptation SYNTHÉTIQUE (« benchmarking only ») : le moteur garde des
|
||||
// jetons du brouillon au hasard, sans les comparer à ce qu'aurait tiré le
|
||||
// modèle. Loki ne les pose jamais ; écrits à la main, on le crie.
|
||||
if f := hasFlagPrefix(extra, "--spec-synth-"); f != "" || si.ArgEnv["LLAMA_ARG_SPEC_SYNTH_LEN"] != "" {
|
||||
if f == "" {
|
||||
f = "LLAMA_ARG_SPEC_SYNTH_LEN"
|
||||
}
|
||||
notes = append(notes, "avertissement : "+f+" — le moteur accepte des jetons du brouillon AU HASARD, sans "+
|
||||
"vérification : les réponses ne sont plus celles du modèle. Réservé aux mesures, retire-le.")
|
||||
}
|
||||
return notes
|
||||
}
|
||||
|
||||
// contextShift dit si le moteur glissera le contexte, et qui l'a décidé (vide :
|
||||
// son propre défaut). --context-shift et --no-context-shift peuvent cohabiter
|
||||
// dans EXTRA_ARGS (preset copié, puis corrigé) ; comme llama-server, la
|
||||
// dernière occurrence décide. Sans drapeau, la variable du moteur, puis son
|
||||
// défaut — qui a changé : les llama-server d'avant --context-shift (mi-2025) ne
|
||||
// connaissent que --no-context-shift et GLISSENT par défaut. Aide vide (moteur
|
||||
// inconnu) : on ne suppose rien.
|
||||
func contextShift(extra []string, argEnv map[string]string, help string) (on bool, src string) {
|
||||
set := false
|
||||
for _, a := range extra {
|
||||
name, _, _ := strings.Cut(a, "=")
|
||||
switch name {
|
||||
case "--context-shift":
|
||||
on, set = true, true
|
||||
case "--no-context-shift":
|
||||
on, set = false, true
|
||||
}
|
||||
}
|
||||
if set {
|
||||
return on, "EXTRA_ARGS"
|
||||
}
|
||||
if strings.Contains(help, "--no-context-shift") && !strings.Contains(help, "--context-shift") {
|
||||
// Moteur ancien : seul LLAMA_ARG_NO_CONTEXT_SHIFT (lu comme un booléen
|
||||
// vrai) l'arrête ; LLAMA_ARG_CONTEXT_SHIFT lui est inconnue.
|
||||
if envTruthy(argEnv["LLAMA_ARG_NO_CONTEXT_SHIFT"]) {
|
||||
return false, "LLAMA_ARG_NO_CONTEXT_SHIFT"
|
||||
}
|
||||
return true, ""
|
||||
}
|
||||
if envTruthy(argEnv["LLAMA_ARG_CONTEXT_SHIFT"]) {
|
||||
return true, "LLAMA_ARG_CONTEXT_SHIFT"
|
||||
}
|
||||
return false, ""
|
||||
}
|
||||
@@ -0,0 +1,307 @@
|
||||
package loki
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"go/ast"
|
||||
"go/parser"
|
||||
"go/token"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"net/url"
|
||||
"path/filepath"
|
||||
"reflect"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestEffectiveKVTypes(t *testing.T) {
|
||||
const ea, kv = "défini par EXTRA_ARGS", "KV_TYPE"
|
||||
const env = "défini par LLAMA_ARG_CACHE_TYPE_K/V"
|
||||
for _, c := range []struct {
|
||||
name string
|
||||
cfg map[string]string
|
||||
extra string
|
||||
argEnv map[string]string
|
||||
k, v string
|
||||
src string
|
||||
}{
|
||||
{"rien : défaut moteur", map[string]string{}, "", nil, "", "", ""},
|
||||
{"KV_TYPE seul", map[string]string{"KV_TYPE": "q8_0"}, "", nil, "q8_0", "q8_0", kv},
|
||||
{"EXTRA_ARGS l'emporte sur KV_TYPE", map[string]string{"KV_TYPE": "f16"},
|
||||
"--cache-type-k q8_0 --cache-type-v q8_0", nil, "q8_0", "q8_0", ea},
|
||||
{"formes courtes, la dernière gagne", map[string]string{},
|
||||
"-ctk q4_0 -ctv q4_0 -ctk q8_0", nil, "q8_0", "q4_0", ea},
|
||||
{"forme --flag=valeur", map[string]string{}, "--cache-type-v=q8_0", nil, "", "q8_0", ea},
|
||||
{"K seul dans EXTRA_ARGS, V du preset", map[string]string{"KV_TYPE_V": "q4_0"},
|
||||
"-ctk q8_0", nil, "q8_0", "q4_0", ea},
|
||||
// Les variables passent AVANT la ligne de commande : seules, elles
|
||||
// décident ; un -ctk venu de KV_TYPE les écrase.
|
||||
{"variables seules", map[string]string{}, "",
|
||||
map[string]string{"LLAMA_ARG_CACHE_TYPE_K": "q8_0", "LLAMA_ARG_CACHE_TYPE_V": "q8_0"}, "q8_0", "q8_0", env},
|
||||
{"KV_TYPE_K écrase la variable K, la variable V reste", map[string]string{"KV_TYPE_K": "f16"}, "",
|
||||
map[string]string{"LLAMA_ARG_CACHE_TYPE_K": "q4_0", "LLAMA_ARG_CACHE_TYPE_V": "q8_0"}, "f16", "q8_0", kv},
|
||||
} {
|
||||
t.Run(c.name, func(t *testing.T) {
|
||||
k, v, src := effectiveKVTypes(c.cfg, splitArgs(c.extra), c.argEnv)
|
||||
if k != c.k || v != c.v || src != c.src {
|
||||
t.Fatalf("got %q/%q src=%q, want %q/%q src=%q", k, v, src, c.k, c.v, c.src)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestKVFidelityNote(t *testing.T) {
|
||||
for _, c := range []struct {
|
||||
name string
|
||||
k, v string
|
||||
src string
|
||||
extra string
|
||||
argEnv map[string]string
|
||||
want []string // fragments attendus ; nil = aucune note
|
||||
fit bool // la note doit parler de --fit (placement figé)
|
||||
}{
|
||||
{"défaut moteur : rien à dire", "", "", "", "", nil, nil, false},
|
||||
{"f16 explicite : rien à dire", "f16", "f16", "KV_TYPE", "", nil, nil, false},
|
||||
{"f32 ne perd rien", "f32", "f32", "KV_TYPE", "", nil, nil, false},
|
||||
{"q8_0 : léger écart", "q8_0", "q8_0", "KV_TYPE", "", nil,
|
||||
[]string{"q8_0/q8_0", "KV_TYPE", "modifie légèrement"}, false},
|
||||
{"q4_0 : perte mesurable", "q4_0", "q4_0", "KV_TYPE", "", nil, []string{"perte mesurable"}, false},
|
||||
{"mixte : le pire des deux", "q8_0", "q4_0", "KV_TYPE", "", nil, []string{"perte mesurable"}, false},
|
||||
{"bf16 : numérique différente", "bf16", "bf16", "KV_TYPE", "", nil, []string{"mantisse"}, false},
|
||||
{"V seul quantifié", "", "q8_0", "KV_TYPE", "", nil, []string{"f16/q8_0"}, false},
|
||||
{"type inconnu : supposé altérer", "q3_k", "q3_k", "KV_TYPE", "", nil, []string{"modifie les sorties"}, false},
|
||||
{"venu d'EXTRA_ARGS, placement figé : --fit ne compense pas", "q8_0", "q8_0", "défini par EXTRA_ARGS",
|
||||
"-ot per_layer_token_embd.weight=CPU --n-cpu-moe 40 --cache-type-k q8_0", nil,
|
||||
[]string{"défini par EXTRA_ARGS", "relever --n-cpu-moe"}, true},
|
||||
// --n-cpu-moe 0 ne place rien : --fit tourne, rien à en dire.
|
||||
{"--n-cpu-moe 0 : placement libre", "q8_0", "q8_0", "défini par EXTRA_ARGS",
|
||||
"--n-cpu-moe 0 -ctk q8_0 -ctv q8_0", nil, []string{"q8_0/q8_0"}, false},
|
||||
{"experts sur CPU par variable : placement figé aussi", "q8_0", "q8_0", "KV_TYPE", "",
|
||||
map[string]string{"LLAMA_ARG_N_CPU_MOE": "30"}, []string{"relever --n-cpu-moe"}, true},
|
||||
} {
|
||||
t.Run(c.name, func(t *testing.T) {
|
||||
got := kvFidelityNote(c.k, c.v, c.src, splitArgs(c.extra), c.argEnv)
|
||||
if c.want == nil {
|
||||
if got != "" {
|
||||
t.Fatalf("note inattendue : %q", got)
|
||||
}
|
||||
return
|
||||
}
|
||||
for _, w := range c.want {
|
||||
if !strings.Contains(got, w) {
|
||||
t.Fatalf("note %q sans %q", got, w)
|
||||
}
|
||||
}
|
||||
if strings.Contains(got, "--fit") != c.fit {
|
||||
t.Fatalf("note %q : mention de --fit attendue = %v", got, c.fit)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestLossyCacheNotes(t *testing.T) {
|
||||
// Aides des deux générations de llama-server : l'ancienne ne connaît que
|
||||
// --no-context-shift et glisse par défaut, la récente ne glisse pas.
|
||||
const helpShiftOld = "--no-context-shift disables context shift on infinite text generation (default: disabled)"
|
||||
const helpShiftNew = "--context-shift, --no-context-shift whether to use context shift on infinite text generation (default: disabled)"
|
||||
envOf := func(kv ...string) map[string]string {
|
||||
m := map[string]string{}
|
||||
for i := 0; i+1 < len(kv); i += 2 {
|
||||
m[kv[i]] = kv[i+1]
|
||||
}
|
||||
return m
|
||||
}
|
||||
for _, c := range []struct {
|
||||
name string
|
||||
extra string
|
||||
si serveSysInfo
|
||||
want []string // un fragment par note attendue, dans l'ordre
|
||||
}{
|
||||
{"rien", "", serveSysInfo{}, nil},
|
||||
{"--context-shift", "--context-shift", serveSysInfo{}, []string{"--context-shift (EXTRA_ARGS)"}},
|
||||
{"--no-context-shift ne déclenche rien", "--no-context-shift", serveSysInfo{}, nil},
|
||||
{"la dernière occurrence décide", "--context-shift --no-context-shift", serveSysInfo{}, nil},
|
||||
{"--cache-reuse 256", "--cache-reuse 256", serveSysInfo{}, []string{"--cache-reuse 256 (EXTRA_ARGS)"}},
|
||||
{"--cache-reuse=256", "--cache-reuse=256", serveSysInfo{}, []string{"--cache-reuse 256"}},
|
||||
{"--cache-reuse 0 : désactivé", "--cache-reuse 0", serveSysInfo{}, nil},
|
||||
{"--swa-full est sans perte", "--swa-full", serveSysInfo{}, nil},
|
||||
{"les deux", "--context-shift --cache-reuse 64", serveSysInfo{}, []string{"jette", "--cache-reuse 64"}},
|
||||
{"vision (MMPROJ) : llama.cpp les ignore, on le dit", "--context-shift --cache-reuse 64",
|
||||
serveSysInfo{MMProj: "/m/mmproj.gguf"}, []string{"l'ignore", "l'ignore"}},
|
||||
{"vision par --mmproj d'EXTRA_ARGS : pareil", "--mmproj mmproj-F16.gguf --cache-reuse 64",
|
||||
serveSysInfo{}, []string{"l'ignore"}},
|
||||
// Les variables du moteur comptent quand la ligne de commande se tait.
|
||||
{"LLAMA_ARG_CACHE_REUSE", "", serveSysInfo{ArgEnv: envOf("LLAMA_ARG_CACHE_REUSE", "128")},
|
||||
[]string{"--cache-reuse 128 (LLAMA_ARG_CACHE_REUSE)"}},
|
||||
{"--cache-reuse 0 d'EXTRA_ARGS écrase la variable", "--cache-reuse 0",
|
||||
serveSysInfo{ArgEnv: envOf("LLAMA_ARG_CACHE_REUSE", "128")}, nil},
|
||||
{"LLAMA_ARG_CONTEXT_SHIFT=1", "", serveSysInfo{Help: helpShiftNew, ArgEnv: envOf("LLAMA_ARG_CONTEXT_SHIFT", "1")},
|
||||
[]string{"(LLAMA_ARG_CONTEXT_SHIFT)"}},
|
||||
{"moteur récent sans drapeau : rien", "", serveSysInfo{Help: helpShiftNew}, nil},
|
||||
// Défaut d'un moteur ancien : il jette des jetons sans qu'on ait rien écrit.
|
||||
{"moteur ancien : glisse par défaut, on le dit", "", serveSysInfo{Help: helpShiftOld},
|
||||
[]string{"glisse le contexte par défaut"}},
|
||||
{"moteur ancien, --no-context-shift : rien", "--no-context-shift", serveSysInfo{Help: helpShiftOld}, nil},
|
||||
{"moteur ancien, LLAMA_ARG_NO_CONTEXT_SHIFT=1 : rien", "",
|
||||
serveSysInfo{Help: helpShiftOld, ArgEnv: envOf("LLAMA_ARG_NO_CONTEXT_SHIFT", "1")}, nil},
|
||||
{"moteur inconnu (aide vide) : on ne suppose rien", "", serveSysInfo{}, nil},
|
||||
} {
|
||||
t.Run(c.name, func(t *testing.T) {
|
||||
got := lossyCacheNotes(splitArgs(c.extra), c.si)
|
||||
if len(got) != len(c.want) {
|
||||
t.Fatalf("notes = %q, en attendait %d", got, len(c.want))
|
||||
}
|
||||
for i, w := range c.want {
|
||||
if !strings.Contains(got[i], w) {
|
||||
t.Fatalf("note %q sans %q", got[i], w)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// Ce que buildServeArgs fait des garde-fous : JAMAIS de drapeau ajouté ou
|
||||
// retiré, seulement des notes. Sans KV_TYPE, aucun -ctk/-ctv : le moteur garde
|
||||
// son f16.
|
||||
func TestBuildServeArgsFidelite(t *testing.T) {
|
||||
const bin = "/opt/llama/llama-server"
|
||||
si := serveSysInfo{Help: helpRecent, Model: "/models/m.gguf"}
|
||||
for _, c := range []struct {
|
||||
name string
|
||||
cfg map[string]string
|
||||
notes []string // fragments attendus parmi les notes ; nil = aucune note de fidélité
|
||||
}{
|
||||
{"KV_TYPE absent : ni -ctk ni -ctv, rien à dire", map[string]string{"NGL": "28"}, nil},
|
||||
{"--swa-full : sans perte, silence", map[string]string{"NGL": "28", "EXTRA_ARGS": "--swa-full"}, nil},
|
||||
{"cache quantifié par EXTRA_ARGS : vu", map[string]string{"NGL": "28",
|
||||
"EXTRA_ARGS": "--cache-type-k q8_0 --cache-type-v q8_0"}, []string{"défini par EXTRA_ARGS"}},
|
||||
{"--cache-reuse 256 : averti", map[string]string{"NGL": "28", "EXTRA_ARGS": "--cache-reuse 256"},
|
||||
[]string{"--cache-reuse 256"}},
|
||||
{"--context-shift : averti", map[string]string{"NGL": "28", "EXTRA_ARGS": "--context-shift"},
|
||||
[]string{"--context-shift"}},
|
||||
} {
|
||||
t.Run(c.name, func(t *testing.T) {
|
||||
extra := splitArgs(c.cfg["EXTRA_ARGS"])
|
||||
args, _, notes := buildServeArgs(c.cfg, extra, bin, si)
|
||||
if c.cfg["KV_TYPE"] == "" && !hasAnyFlag(extra, "-ctk", "-ctv", "--cache-type-k", "--cache-type-v") &&
|
||||
hasAnyFlag(args, "-ctk", "-ctv", "--cache-type-k", "--cache-type-v") {
|
||||
t.Fatalf("Loki a posé un type de cache de lui-même : %q", args)
|
||||
}
|
||||
// La ligne de commande se termine exactement par EXTRA_ARGS, intouché.
|
||||
if tail := args[len(args)-len(extra):]; strings.Join(tail, " ") != strings.Join(extra, " ") {
|
||||
t.Fatalf("EXTRA_ARGS modifié : %q", tail)
|
||||
}
|
||||
all := strings.Join(notes, "\n")
|
||||
if c.notes == nil {
|
||||
if strings.Contains(all, "cache KV") || strings.Contains(all, "avertissement") {
|
||||
t.Fatalf("note de fidélité inattendue : %q", notes)
|
||||
}
|
||||
return
|
||||
}
|
||||
for _, w := range c.notes {
|
||||
if !strings.Contains(all, w) {
|
||||
t.Fatalf("notes %q sans %q", notes, w)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// Aucune requête de Loki vers le moteur ne coupe le cache de préfixe
|
||||
// (cache_prompt:false) ni n'active la réutilisation approximative
|
||||
// (n_cache_reuse). On vérifie les corps RÉELLEMENT envoyés par le chat et par
|
||||
// la compaction, contre un faux llama-server local.
|
||||
func TestRequetesSansCacheApproximatif(t *testing.T) {
|
||||
testHome(t)
|
||||
var mu sync.Mutex
|
||||
var bodies []map[string]any
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
var body map[string]any
|
||||
_ = json.NewDecoder(r.Body).Decode(&body)
|
||||
mu.Lock()
|
||||
bodies = append(bodies, body)
|
||||
mu.Unlock()
|
||||
if body["stream"] == true {
|
||||
w.Header().Set("Content-Type", "text/event-stream")
|
||||
w.WriteHeader(200)
|
||||
_, _ = w.Write([]byte(sseChunk("bonjour")))
|
||||
_, _ = w.Write([]byte(`data: {"choices":[{"delta":{},"finish_reason":"stop"}]}` + "\n\ndata: [DONE]\n\n"))
|
||||
return
|
||||
}
|
||||
sendJSON(w, 200, map[string]any{"choices": []any{map[string]any{"message": map[string]any{"content": "- résumé"}}}})
|
||||
}))
|
||||
t.Cleanup(srv.Close)
|
||||
u, err := url.Parse(srv.URL)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := SetConfigKey("PORT", u.Port()); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := runChat(context.Background(), []Message{{Role: "user", Content: "salut"}}, 0.7, Caps{},
|
||||
func(StreamEvent) bool { return true }); err != nil {
|
||||
t.Fatalf("runChat : %v", err)
|
||||
}
|
||||
if _, err := summarizeTranscript(context.Background(), "user: bonjour\nassistant: salut"); err != nil {
|
||||
t.Fatalf("compaction : %v", err)
|
||||
}
|
||||
mu.Lock()
|
||||
defer mu.Unlock()
|
||||
if len(bodies) < 2 {
|
||||
t.Fatalf("%d requête(s) reçue(s), attendu chat + compaction", len(bodies))
|
||||
}
|
||||
for _, b := range bodies {
|
||||
if v, ok := b["cache_prompt"]; ok && v != true {
|
||||
t.Errorf("cache_prompt=%v envoyé au moteur : le préfixe serait recalculé ou faussé", v)
|
||||
}
|
||||
if _, ok := b["n_cache_reuse"]; ok {
|
||||
t.Errorf("n_cache_reuse envoyé au moteur : réutilisation approximative du cache")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Filet sur tout le paquet : les clés cache_prompt et n_cache_reuse n'existent
|
||||
// dans le code QUE pour le benchmark, qui mesure le prefill et doit donc couper
|
||||
// le cache. Lecture de l'arbre syntaxique (pas du texte) : un commentaire qui
|
||||
// les mentionne ne compte pas, un nouveau fichier est couvert d'office.
|
||||
func TestCleCacheReserveeAuBench(t *testing.T) {
|
||||
files, err := filepath.Glob("*.go")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
allowed := map[string]bool{"llm_bench.go": true}
|
||||
fset := token.NewFileSet()
|
||||
for _, f := range files {
|
||||
if strings.HasSuffix(f, "_test.go") || allowed[f] {
|
||||
continue
|
||||
}
|
||||
file, err := parser.ParseFile(fset, f, nil, parser.SkipObjectResolution)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
banned := func(s string) bool { return s == "cache_prompt" || s == "n_cache_reuse" }
|
||||
ast.Inspect(file, func(n ast.Node) bool {
|
||||
// Un champ de structure sérialisé (étiquette json:"cache_prompt")
|
||||
// enverrait la clé aussi sûrement qu'une map : on lit les étiquettes.
|
||||
if f, ok := n.(*ast.Field); ok && f.Tag != nil {
|
||||
if tag, err := strconv.Unquote(f.Tag.Value); err == nil {
|
||||
if name, _, _ := strings.Cut(reflect.StructTag(tag).Get("json"), ","); banned(name) {
|
||||
t.Errorf("%s : champ %q hors du benchmark", fset.Position(f.Pos()), name)
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
lit, ok := n.(*ast.BasicLit)
|
||||
if !ok || lit.Kind != token.STRING {
|
||||
return true
|
||||
}
|
||||
if s, err := strconv.Unquote(lit.Value); err == nil && banned(s) {
|
||||
t.Errorf("%s : clé %q hors du benchmark", fset.Position(lit.Pos()), s)
|
||||
}
|
||||
return true
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,138 @@
|
||||
package loki
|
||||
|
||||
import (
|
||||
"net"
|
||||
"reflect"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func TestHasAnyFlag(t *testing.T) {
|
||||
args := []string{"--parallel", "4", "--load-mode=mlock", "-fa"}
|
||||
if !hasAnyFlag(args, "--parallel", "-np") {
|
||||
t.Fatal("--parallel devrait être détecté")
|
||||
}
|
||||
// Forme --clé=valeur : le nom seul compte.
|
||||
if !hasAnyFlag(args, "--load-mode") {
|
||||
t.Fatal("--load-mode=… devrait être détecté")
|
||||
}
|
||||
if hasAnyFlag(args, "-ngl", "--n-gpu-layers") {
|
||||
t.Fatal("-ngl absent, ne devrait pas être détecté")
|
||||
}
|
||||
// Une VALEUR qui ressemble à un drapeau ne doit pas compter comme tel :
|
||||
// ici "-ngl" n'est pas posé, seul --parallel l'est.
|
||||
if hasAnyFlag(nil, "--parallel") {
|
||||
t.Fatal("liste vide : rien à détecter")
|
||||
}
|
||||
}
|
||||
|
||||
func TestNormalizeLoadFlags(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
in []string
|
||||
supports bool
|
||||
want []string
|
||||
}{
|
||||
{"moteur ancien : anciens drapeaux intacts", []string{"--mlock", "--no-mmap"}, false,
|
||||
[]string{"--mlock", "--no-mmap"}},
|
||||
// L'ancien --mlock gardait le mmap : « mlock » de llama.cpp le coupe.
|
||||
{"mlock seul → mmap+mlock", []string{"--mlock", "-fa"}, true,
|
||||
[]string{"-fa", "--load-mode", "mmap+mlock"}},
|
||||
{"no-mmap traduit", []string{"--no-mmap"}, true,
|
||||
[]string{"--load-mode", "none"}},
|
||||
{"mmap traduit", []string{"--mmap"}, true,
|
||||
[]string{"--load-mode", "mmap"}},
|
||||
{"mlock + no-mmap → mlock", []string{"--no-mmap", "--mlock"}, true,
|
||||
[]string{"--load-mode", "mlock"}},
|
||||
{"moteur ancien : none retraduit", []string{"-fa", "--load-mode", "none"}, false,
|
||||
[]string{"-fa", "--no-mmap"}},
|
||||
{"moteur ancien : mlock retraduit", []string{"--load-mode", "mlock"}, false,
|
||||
[]string{"--mlock", "--no-mmap"}},
|
||||
{"moteur ancien : mmap+mlock retraduit", []string{"-lm", "mmap+mlock"}, false,
|
||||
[]string{"--mlock"}},
|
||||
{"moteur ancien : dio abandonné", []string{"--load-mode=dio", "-fa"}, false,
|
||||
[]string{"-fa"}},
|
||||
{"--load-mode explicite gagne, vieux drapeaux retirés",
|
||||
[]string{"--mlock", "--load-mode", "dio"}, true,
|
||||
[]string{"--load-mode", "dio"}},
|
||||
{"rien à traduire", []string{"-fa", "--n-cpu-moe", "8"}, true,
|
||||
[]string{"-fa", "--n-cpu-moe", "8"}},
|
||||
}
|
||||
for _, c := range cases {
|
||||
t.Run(c.name, func(t *testing.T) {
|
||||
got, note := normalizeLoadFlags(c.in, c.supports)
|
||||
if !reflect.DeepEqual(got, c.want) {
|
||||
t.Fatalf("got %q, want %q", got, c.want)
|
||||
}
|
||||
// Seul « dio » abandonné sur un moteur ancien se signale : les autres
|
||||
// traductions gardent le sens demandé.
|
||||
if wantNote := c.name == "moteur ancien : dio abandonné"; (note != "") != wantNote {
|
||||
t.Fatalf("note = %q, attendue : %v", note, wantNote)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestNGLArgs(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
ngl string
|
||||
fitsItself bool
|
||||
want []string
|
||||
wantNote bool
|
||||
}{
|
||||
// Le cas qui motive tout : defaultConfig() sème NGL=999 sur chaque
|
||||
// installation neuve. Personne ne l'a choisi, et le moteur récent
|
||||
// abandonne son calcul de VRAM dès qu'on lui impose un nombre.
|
||||
{"sentinelle 999, moteur récent", "999", true, []string{"-ngl", "auto"}, true},
|
||||
{"sentinelle 999, moteur ancien", "999", false, []string{"-ngl", "999"}, false},
|
||||
{"nombre choisi, jamais touché", "28", true, []string{"-ngl", "28"}, false},
|
||||
{"nombre choisi, moteur ancien", "28", false, []string{"-ngl", "28"}, false},
|
||||
{"all reste all", "all", true, []string{"-ngl", "all"}, false},
|
||||
{"auto : aucun drapeau", "auto", true, nil, false},
|
||||
{"auto insensible à la casse", "AUTO", true, nil, false},
|
||||
{"espaces parasites autour de la sentinelle", " 999 ", true, []string{"-ngl", "auto"}, true},
|
||||
{"clé absente, moteur récent", "", true, []string{"-ngl", "auto"}, false},
|
||||
{"clé absente, moteur ancien", "", false, []string{"-ngl", "999"}, false},
|
||||
}
|
||||
for _, c := range cases {
|
||||
t.Run(c.name, func(t *testing.T) {
|
||||
got, note := nglArgs(c.ngl, c.fitsItself)
|
||||
if !reflect.DeepEqual(got, c.want) {
|
||||
t.Fatalf("args = %q, want %q", got, c.want)
|
||||
}
|
||||
if (note != "") != c.wantNote {
|
||||
t.Fatalf("note = %q, en voulait-on une ? %v", note, c.wantNote)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestArgValueEtPortLibre(t *testing.T) {
|
||||
if got := argValue([]string{"--port", "8080", "-c", "1", "--port", "9000"}, "--port"); got != "9000" {
|
||||
t.Fatalf("la dernière occurrence doit gagner, got %q", got)
|
||||
}
|
||||
ln, err := net.Listen("tcp", "127.0.0.1:0")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
_, port, _ := net.SplitHostPort(ln.Addr().String())
|
||||
if waitPortFree("0.0.0.0", port, 600*time.Millisecond) == nil {
|
||||
t.Fatal("un port occupé doit être refusé")
|
||||
}
|
||||
ln.Close()
|
||||
if err := waitPortFree("0.0.0.0", port, time.Second); err != nil {
|
||||
t.Fatalf("un port libre doit passer : %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSlowKV(t *testing.T) {
|
||||
for _, c := range []struct {
|
||||
k, v string
|
||||
lent bool
|
||||
}{{"", "", false}, {"q8_0", "q8_0", false}, {"q4_0", "q4_0", false}, {"q8_0", "q4_0", true}, {"q5_1", "q5_1", true}} {
|
||||
if slowKV(c.k, c.v) != c.lent {
|
||||
t.Errorf("%s/%s : lent attendu %v", c.k, c.v, c.lent)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
Loaded 100 of 705 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user